diff --git a/.claude/skills/gitnexus-impact-analysis/SKILL.md b/.claude/skills/gitnexus-impact-analysis/SKILL.md index 0b81795de..2e34f86f6 100644 --- a/.claude/skills/gitnexus-impact-analysis/SKILL.md +++ b/.claude/skills/gitnexus-impact-analysis/SKILL.md @@ -53,6 +53,14 @@ description: "Use when the user wants to know what will break if they change som | 5-15 symbols, 2-5 processes | MEDIUM | | >15 symbols or many processes | HIGH | | Critical path (auth, payments) | CRITICAL | +| **Zero callers found** | **UNKNOWN** | + +`UNKNOWN` is not a low rung on this scale — it means the walk could not answer. +An empty caller set is equally consistent with "genuinely unused" and "the +callers are not resolvable by the index" (plain-object property access, dynamic +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. ## Tools diff --git a/AGENTS.md b/AGENTS.md index 032a3a6b6..f4fcef0af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,6 +120,7 @@ This project is indexed by GitNexus as **GitNexus** (248612 symbols, 565510 rela - **MUST run impact analysis before editing.** Use `impact({target: "symbolName", direction: "upstream"})` (MCP) or `node .gitnexus/run.cjs impact "symbolName" --direction upstream --repo .` (CLI fallback); report callers, processes, and risk. Never substitute grep for graph analysis. For unified PDG impact, add `mode: "pdg"` with optional `line: ` — 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 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"})`. - For security review, `explain({target: "fileOrSymbol"})` lists taint findings (source→sink flows; needs `analyze --pdg`). @@ -128,7 +129,7 @@ This project is indexed by GitNexus as **GitNexus** (248612 symbols, 565510 rela ## 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. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 42160627e..03e3730b3 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) | +| `routes` | `routes.ts` | `parse` | Route nodes + HANDLES_ROUTE edges (Next.js, Expo, PHP, decorators, and JS/TS dispatch guards — 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 | @@ -164,6 +164,48 @@ export const myPhase: PipelinePhase = { }; ``` +### Where routes come from + +`route-extractors/` holds four independent ways a route can be discovered, all +converging on the routes phase's `(method, url)` registry: + +| Source | Shape | Examples | +| --- | --- | --- | +| 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** | + +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 +dispatch — `if (req.method === 'GET' && pathname === '/api/x')` is a route with +a path, a verb and a handler, and nothing else in the pipeline could see it. +`route-extractors/dispatch-guard.ts` reads that shape; the transport, dedup and +handler resolution are shared with decorator routes, and +`ExtractedDecoratorRoute.source` carries the provenance difference through to +the `HANDLES_ROUTE` edge. + +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 +than guessed at. A missing route is a coverage limit; an invented one is a lie. + +Two rules there need more than one comparison to decide, and are worth knowing +about before changing either: + +- **Same-file constant folding.** `` pathname === `${basePath}/rules` `` is + common enough that refusing it loses whole route modules — and loses them + invisibly, since a module with unfoldable paths and a module with no routes + produce the same empty answer. Folding is same-file, string literals only, one + alias hop, and refuses on ambiguity (a name declared twice with different + values is dropped, never guessed). +- **Whole-repo reconciliation** (`reconcileDispatchGuardRoutes`, applied in the + routes phase). A split route table — one module listing every path it + recognises so the dispatcher can 404 early, handlers in others — otherwise + lists every route twice, once verb-less with the table as its "handler". It + applies to dispatch-guard routes only: a framework route with no verb is + method-agnostic *by declaration*, which is a fact, not a weaker observation. + --- ## Semantic model @@ -214,6 +256,9 @@ Language-agnostic scope-resolution resolver. This is the resolution path for eve │ emitReferencesViaLookup ── uses handledSites + deferred-site skip set │ emitPropertyDispatchCalls ── registration USES + conservative CALLS │ emitCallableValueFlow ── assigned/passed callable invocation CALLS + │ emitImportedValueReferences ── cross-file value reads via finalized imports + │ emitUniqueNamePropertyAccesses ── LAST-RESORT property reads by name, + │ narrowed same-file → direct-import, refusing to choose otherwise │ emitImportEdges ▼ KnowledgeGraph (IMPORTS / CALLS / ACCESSES / INHERITS / USES) diff --git a/CLAUDE.md b/CLAUDE.md index af4473f38..8382c69ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,6 +71,7 @@ This project is indexed by GitNexus as **GitNexus** (248612 symbols, 565510 rela - **MUST run impact analysis before editing.** Use `impact({target: "symbolName", direction: "upstream"})` (MCP) or `node .gitnexus/run.cjs impact "symbolName" --direction upstream --repo .` (CLI fallback); report callers, processes, and risk. Never substitute grep for graph analysis. For unified PDG impact, add `mode: "pdg"` with optional `line: ` — 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 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"})`. - For security review, `explain({target: "fileOrSymbol"})` lists taint findings (source→sink flows; needs `analyze --pdg`). @@ -79,7 +80,7 @@ This project is indexed by GitNexus as **GitNexus** (248612 symbols, 565510 rela ## 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. diff --git a/GUARDRAILS.md b/GUARDRAILS.md index 3e9bdff83..e157ade1e 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. +### 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. +- **Do:** Re-run `npx gitnexus analyze --force`. If it recurs, check free disk space on the volume holding `.gitnexus/`, confirm no second `analyze` is running against the same repo (both stage through `.gitnexus/csv`), then run `npx gitnexus doctor`. +- **Why:** The run finished and wrote metadata, but far fewer relationships are readable back than the pipeline produced. Nothing throws: the DB holds rows and the metadata is valid, so every query answers with missing edges rather than an error — a confident empty answer, which is worse than a failure because it looks like a result. Unlike `incremental-in-progress` and `embedding-checkpoint-pending`, which describe a run that did what it said and left work for next time, this one means most of your edges are gone, so it is the one incomplete reason that also fails the exit code. The check compares in-memory totals (including rows streamed out of the heap) against the post-write count, refuses to answer when the count cannot be read, and is skipped on incremental runs where whole-scope counts are not comparable. + ### MCP lists no repos - **Trigger:** MCP stderr says no indexed repos. diff --git a/MIGRATION.md b/MIGRATION.md index 9c6c1de2d..f63d18c87 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -106,6 +106,19 @@ Running `npx gitnexus analyze` writes both `gitnexus.json` and `meta.json` with identical content. A pre-existing repo that only has `meta.json` gets `gitnexus.json` bootstrapped from it on the first run. +### Process ids are not stable across this release + +`Process` ids are positional (`proc__`), and this release changes +both which execution flows are detected and the order they are selected in: +tracing is depth-first, sibling branches follow source order, and selection +round-robins across terminals so one flow cannot take every slot. A given +`proc_7_handle` before the upgrade is not the same flow afterwards. + +Nothing in GitNexus persists or joins on a raw process id across a re-index — +the MCP resource keys by label — so this is one-time index churn rather than a +broken consumer. If you have external tooling that stored a process id, re- +resolve it by label after the next analyze. + ### What about rollback? Downgrading to an older GitNexus version is safe: `meta.json` is always diff --git a/RUNBOOK.md b/RUNBOOK.md index e34d20c2b..0f5c8b7bb 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -66,6 +66,16 @@ npx gitnexus analyze No `--embeddings` flag needed — a retained checkpoint forces embedding generation for 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. +**Collapsed graph write (analyze exits NON-ZERO and says INCOMPLETE):** A run can finish writing metadata while only a fraction of the relationships it produced are readable back from the index — edges collapsing to a small share of what was built, or a `CodeRelation` table that never materialized (which reads as a persisted count of zero). Because the metadata IS written and the DB does hold rows, nothing looks broken: queries answer with missing edges rather than an error, which is a confident empty answer rather than a failure. `npx gitnexus status` reports `incompleteReasons: ["graph-write-collapsed"]`, the analyze summary prints `Repository indexed INCOMPLETELY` with the expected and persisted counts, and the CLI exits non-zero so automation is not told an unusable index is fine. + +Recovery is a full rebuild: + +```bash +npx gitnexus analyze --force +``` + +If it recurs, the cause is almost always environmental rather than a code defect: check free disk space on the volume holding `.gitnexus/`, make sure no second `analyze` is running against the same repo (both use `.gitnexus/csv` for staging), then run `npx gitnexus doctor`. The check compares in-memory relationship totals (including streamed rows) against what the DB hands back, and is deliberately skipped on incremental runs, where the two counts are not comparable. + **Large repos:** Analyze may skip or limit embedding work when node counts are very high; watch CLI output. --- diff --git a/gitnexus-claude-plugin/skills/gitnexus-impact-analysis/SKILL.md b/gitnexus-claude-plugin/skills/gitnexus-impact-analysis/SKILL.md index 0b81795de..2e34f86f6 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-impact-analysis/SKILL.md +++ b/gitnexus-claude-plugin/skills/gitnexus-impact-analysis/SKILL.md @@ -53,6 +53,14 @@ description: "Use when the user wants to know what will break if they change som | 5-15 symbols, 2-5 processes | MEDIUM | | >15 symbols or many processes | HIGH | | Critical path (auth, payments) | CRITICAL | +| **Zero callers found** | **UNKNOWN** | + +`UNKNOWN` is not a low rung on this scale — it means the walk could not answer. +An empty caller set is equally consistent with "genuinely unused" and "the +callers are not resolvable by the index" (plain-object property access, dynamic +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. ## Tools diff --git a/gitnexus-cursor-integration/skills/gitnexus-impact-analysis/SKILL.md b/gitnexus-cursor-integration/skills/gitnexus-impact-analysis/SKILL.md index 8f9f1d1e7..7a3586b29 100644 --- a/gitnexus-cursor-integration/skills/gitnexus-impact-analysis/SKILL.md +++ b/gitnexus-cursor-integration/skills/gitnexus-impact-analysis/SKILL.md @@ -52,6 +52,14 @@ description: Analyze blast radius before making code changes | 5-15 symbols, 2-5 processes | MEDIUM | | >15 symbols or many processes | HIGH | | Critical path (auth, payments) | CRITICAL | +| **Zero callers found** | **UNKNOWN** | + +`UNKNOWN` is not a low rung on this scale — it means the walk could not answer. +An empty caller set is equally consistent with "genuinely unused" and "the +callers are not resolvable by the index" (plain-object property access, dynamic +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. ## Tools diff --git a/gitnexus-web/src/core/llm/tools.ts b/gitnexus-web/src/core/llm/tools.ts index 9f6f34291..a702e7db8 100644 --- a/gitnexus-web/src/core/llm/tools.ts +++ b/gitnexus-web/src/core/llm/tools.ts @@ -1233,7 +1233,20 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`, } } - return `No ${direction} dependencies found for "${target}" (types: ${activeRelTypes.join(', ')}). This code appears to be ${direction === 'upstream' ? 'unused (not called by anything)' : 'self-contained (no outgoing dependencies)'}.${multipleMatchWarning}`; + // An empty UPSTREAM walk is not evidence of disuse — it is the absence + // of evidence. The symbol may be reached only through a reference class + // the index does not record (a property access on a plain object, a + // dynamic dispatch, a call from a language whose resolver is weaker + // here). The Node/MCP path reports `risk: UNKNOWN` with a `riskNote` + // for exactly this case; this surface answers in prose rather than an + // enum, so it carries the same MEANING rather than the same field — + // saying "appears to be unused" here is the identical false certainty. + // + // Downstream keeps its wording: no outgoing dependencies really does + // describe the symbol itself, not a claim about the rest of the repo. + return direction === 'upstream' + ? `No ${direction} dependencies found for "${target}" (types: ${activeRelTypes.join(', ')}). This does NOT establish the symbol is unused — an empty caller set can also mean the callers are not resolvable by the index (plain-object property access, dynamic dispatch, cross-language calls). Confirm with a text search before treating it as dead code.${multipleMatchWarning}` + : `No ${direction} dependencies found for "${target}" (types: ${activeRelTypes.join(', ')}). This code appears to be self-contained (no outgoing dependencies).${multipleMatchWarning}`; } const depth1 = byDepth.get(1) || []; diff --git a/gitnexus/bench/emit-persistence/baselines.json b/gitnexus/bench/emit-persistence/baselines.json index 402c9b06c..1d295bd19 100644 --- a/gitnexus/bench/emit-persistence/baselines.json +++ b/gitnexus/bench/emit-persistence/baselines.json @@ -1,6 +1,7 @@ { - "fingerprint": "69e9182ae205183ade24c3d8ad5d7292aea677144b1cbe443dd631bc25b0cafe", + "fingerprint": "4ee15e742a9839671a900df4f57c1c91196c64256c8cab2ac445bec605a092d5", "scaling_budget": 1.8, "max_ms_large": 1000, - "_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. 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`." + "_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.", + "_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/receiver-resolution/baseline.json b/gitnexus/bench/receiver-resolution/baseline.json index 540277391..7a427598f 100644 --- a/gitnexus/bench/receiver-resolution/baseline.json +++ b/gitnexus/bench/receiver-resolution/baseline.json @@ -200,10 +200,11 @@ }, "countArm": { "callDrops": 102, - "totalDropsAllKinds": 129, + "totalDropsAllKinds": 140, "bySiteKind": { "call": 102, - "read": 27 + "read": 27, + "write": 11 }, "callDropsByExtension": { ".java": 49, diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 78b6f2818..5b8de09a9 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -6,22 +6,22 @@ "_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 \u2014 the tag is added to existing call matches, never a new match \u2014 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 — the tag is added to existing call matches, never a new match — 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|\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_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_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." }, "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 \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.", + "_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.", "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\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." + "_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." }, "c": { "fingerprint": "3418cded9f7072152f68992f0a426f43ae7d9d553579a47075fc0cab185848a5", @@ -29,15 +29,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 \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.", + "_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.", "_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 \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_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_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 +45,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 \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).", + "_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).", "_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) \u2014 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) — 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|\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.", + "_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.", "capture_groups_small": 5021, "capture_groups_large": 16021, "capture_groups_fp": 4605, @@ -63,8 +63,8 @@ "_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 \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.", + "_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.", "capture_groups_small": 4259, "capture_groups_large": 13609, "capture_groups_fp": 2657, @@ -73,17 +73,17 @@ "rust": { "fingerprint": "116a971fee0004f340477aff69fa110a1d92bd8ba882d7c926483c6b1e8ca2b9", "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 \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_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_dyn_trait_object_2604": "#2604: RUST_SCOPE_QUERY now captures function_signature_item (abstract trait methods, no body) as a scope + declaration, so a &dyn Trait receiver can dispatch a CALLS edge to the trait's own method. Additive capture shift across every bench fixture with a required trait method. Prior df369c5a5f8de7753fc8bab8b4108ef5081750974ea5085ba9a867675ac9eb29 -> f7742f65f14d7d6590df7f16303fc3cc9dc0c233cd80bf90c98b084933cd3846; scaling 1.033 < 1.5.", "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 65e5bca66bb1ca117949409e8fb5c80ee69d6f1b5318908eaaecf08da0482e5c -> df369c5a5f8de7753fc8bab8b4108ef5081750974ea5085ba9a867675ac9eb29; scaling 1.065 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Rust fn-value callable flow facts with invocation/constructor-result suppression. Prior ac610bbe97666bf285923479dd7b43a2fe4c5354aae8df1bcbafdc04fb220f82 -> 65e5bca66bb1ca117949409e8fb5c80ee69d6f1b5318908eaaecf08da0482e5c; scaling 1.024 < 1.5.", - "_rebaselined": "#1956 tri-review U1: rust-qualified-trait fixture (scoped + generic-of-scoped impl trait paths); bareTypeIdentifier now resolves scoped_type_identifier bases by their name: tail (additive, no existing-fixture drift); linear (~1.04). #1975: + rust-scoped-impl fixture (impl a::Inner / b::Inner inherent scoped impls) \u2014 legacy @definition.impl scoped arm + findEnclosingClassInfo inherent-impl scoped target; rust scope-extractor captures byte-identical. | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.", - "_note": "PR #1934: F66/F68 let-binding pattern narrowing; F71 union (Struct-labeled, now materialized via legacy @definition.struct + resolvable); F72 macro FULLY WIRED \u2014 @declaration.macro/@reference.macro + MacroRegistry \u2192 USES edges to Macro nodes (never a same-named fn). + rust-macro / rust-union fixtures and merged with origin/main #1975 rust-scoped-impl; fingerprint re-baselined (scaling ~0.99, fixture_count 126). #1992: + rust-nested-tail-collision-generic and rust-generic-impl-same-method-name (F3) fixtures \u2014 pure fixture-corpus drift, no scope-extractor change; fixture_count 127->129, fingerprint 56ffc1c0->b00aea0f.", + "_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_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 \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_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_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 \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.", + "_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.", "capture_groups_small": 5507, "capture_groups_large": 17607, "capture_groups_fp": 3556, @@ -95,9 +95,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 \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." + "_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." }, "ruby": { "fingerprint": "1c8c9c4b54036fa24c2a81e39ea530e938645c856d369075e5f437da78218c57", @@ -105,10 +105,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 \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.", + "_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.", "_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 \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." + "_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." }, "swift": { "fingerprint": "adef9284feaecd39cb490aebce83876e15b9150c7a04b00a396feb78b7e1e0a9", @@ -117,8 +117,8 @@ "_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 \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_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_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": { @@ -136,7 +136,7 @@ "scaling_budget": 1.5, "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata; same-name lexical regions use an O(ancestor-depth) ID-set lookup. Prior d5c59d7dc9e206637515d5aea1163f7c1cdd76410c38c5fe6143d13d19677d6a -> 004a3592998dca1193bd1429a8284513725de7764f2a3eceedaaa984cfd763b4; scaling 0.992 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Java method-reference/SAM callable flow facts with invocation-result suppression. Prior 062d754764aaa8a6772fb90875c710502a63e3e7a300e633942381ed914faada -> d5c59d7dc9e206637515d5aea1163f7c1cdd76410c38c5fe6143d13d19677d6a; scaling 1.074 < 1.5.", - "_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.", + "_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.", "_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,8 +144,8 @@ "_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 \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.", + "_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, @@ -155,31 +155,32 @@ "fingerprint": "8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633", "scaling_budget": 1.5, "_added": "#2562 performance follow-up: co-scales same-host, same-name local classes and anonymous classes to gate JLS binary-name ordinal allocation. Precomputed per-sequence ordinals reduce the focused 100->800 workload from 176->6655ms to 141->752ms; normalized 250->800 scaling is 1.054.", - "_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." + "_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." }, "typescript": { - "fingerprint": "ed92588e0fc7b28b3a0174339ac378b4dd85965fe007db1208dea97a65ce0571", + "fingerprint": "f66a3e6f1e096431e7046505129a627deaa00ca0de5bc846b080591b397248f7", "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 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 \u2014 fingerprint drift expected.", - "_note": "#1968: F44, F85, F87 \u2014 fingerprint drift expected.", + "_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_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) \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.", + "_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.", "capture_groups_small": 4503, "capture_groups_large": 14403, - "capture_groups_fp": 2097, - "fixture_count": 146 + "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." }, "javascript": { - "fingerprint": "806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594", + "fingerprint": "2026993b81b873839dd2ef8797d9c14d9c48516b2b57b05ac17d8d43f2f4eba3", "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 b59fe8135b6a31a12bc3f872b224054b16592588153ae3661d03958d787c76f3 -> 479927409bbdd9852a36172c8260aa56df260e99129a7a9c20a0d1903dd5538b; scaling 1.050 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: lexical callable bindings, direct-callee argument metadata, and invocation-result suppression. Prior 917a9cd975ba035bdad71fdb70cd72eeddec58c25797e5a1addfa6172808a55c -> b59fe8135b6a31a12bc3f872b224054b16592588153ae3661d03958d787c76f3; scaling 1.093 < 1.5.", @@ -188,9 +189,10 @@ "_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) \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_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." }, "kotlin": { "fingerprint": "a184f8ff0ae40d246db855b63f7ff26bda3afac03e5f4c76e4593c7e2cefce54", @@ -199,13 +201,13 @@ "_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 \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_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_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 \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_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, diff --git a/gitnexus/skills/gitnexus-impact-analysis.md b/gitnexus/skills/gitnexus-impact-analysis.md index 0b81795de..2e34f86f6 100644 --- a/gitnexus/skills/gitnexus-impact-analysis.md +++ b/gitnexus/skills/gitnexus-impact-analysis.md @@ -53,6 +53,14 @@ description: "Use when the user wants to know what will break if they change som | 5-15 symbols, 2-5 processes | MEDIUM | | >15 symbols or many processes | HIGH | | Critical path (auth, payments) | CRITICAL | +| **Zero callers found** | **UNKNOWN** | + +`UNKNOWN` is not a low rung on this scale — it means the walk could not answer. +An empty caller set is equally consistent with "genuinely unused" and "the +callers are not resolvable by the index" (plain-object property access, dynamic +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. ## Tools diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index 84708d1aa..8267082c9 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -1624,6 +1624,27 @@ const analyzeCommandImpl = async ( // ── Summary ──────────────────────────────────────────────────── const s = result.stats; + // A collapsed graph write is NOT a successful index. The other incomplete + // reasons (`incremental-in-progress`, `embedding-checkpoint-pending`) + // describe a run that did what it said and left work for next time; this + // one means most of your edges are gone, so every query answers a confident + // empty and the exit code is the only thing automation reads. Printing + // "indexed successfully" and exiting 0 here would be the same class of + // false certainty the check itself was written to remove. + if (result.graphWriteCollapsed) { + const { expected, persisted } = result.graphWriteCollapsed; + console.log(`\n Repository indexed INCOMPLETELY (${totalTime}s)\n`); + console.log( + ` Graph write collapsed: the pipeline produced ${expected.toLocaleString()} relationships\n` + + ` but only ${persisted.toLocaleString()} are readable from the index. Queries will answer\n` + + ` with missing edges rather than an error.\n\n` + + ` The index is recorded INCOMPLETE (graph-write-collapsed). Re-run\n` + + ` \`gitnexus analyze --force\`; if it recurs, check disk space and run \`gitnexus doctor\`.`, + ); + console.log(` ${repoPath}`); + process.exitCode = 1; + return; + } console.log(`\n Repository indexed successfully (${totalTime}s)\n`); console.log( ` ${(s.nodes ?? 0).toLocaleString()} nodes | ${(s.edges ?? 0).toLocaleString()} edges | ${s.communities ?? 0} clusters | ${s.processes ?? 0} flows`, diff --git a/gitnexus/src/core/index-freshness.ts b/gitnexus/src/core/index-freshness.ts index 26c107f38..e34d371c2 100644 --- a/gitnexus/src/core/index-freshness.ts +++ b/gitnexus/src/core/index-freshness.ts @@ -5,16 +5,94 @@ export const INDEX_INCOMPLETE_REASONS = [ 'incremental-in-progress', 'embedding-checkpoint-pending', 'embedding-count-unverified', + 'graph-write-collapsed', ] as const; export type IndexIncompleteReason = (typeof INDEX_INCOMPLETE_REASONS)[number]; +/** + * Fraction of the pipeline's relationship count that must survive into the DB + * before the write counts as collapsed. Deliberately generous: this detects + * "most of the graph did not persist" (the reported case lost ~91%), not a + * per-edge reconciliation. + */ +export const GRAPH_WRITE_COLLAPSE_RATIO = 0.5; + +/** + * Below this many relationships the ratio is meaningless — a handful of edges + * lost to legitimate filtering would trip it — so small repos are exempt. + */ +export const GRAPH_WRITE_COLLAPSE_MIN_EDGES = 100; + +/** + * Decide whether a finished write collapsed, comparing what the pipeline + * produced against what the DB hands back. + * + * A RATIO, not equality: some relationship types do not round-trip one-for-one + * and `--pdg` writes MORE rows into the same table, so demanding equality would + * fire on healthy runs. Only a collapse is a defect. + * + * 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. + */ +export function detectGraphWriteCollapse( + expected: number, + /** + * Relationships readable from the DB, or `undefined` when the count could + * not be READ at all (no connection, a query that threw). + * + * The distinction is load-bearing and was got wrong once: `getLbugStats` + * reports `edges: 0` for "no connection", "query threw" AND "empty table" + * alike, so passing it straight in made every run without a readable DB look + * like a total collapse. An unmeasurable count is not a measured zero — + * accepting `undefined` here is what keeps this check from committing the + * same confident-zero error it exists to catch. + */ + persisted: number | undefined, +): { expected: number; persisted: number } | undefined { + // 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: + // `undefined < 100` is false, so the min-edges exemption never fires, and + // `0 >= undefined * 0.5` is `0 >= NaN`, also false, so the ratio check + // "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; + } + const expectedCount = expected; + const persistedCount = persisted; + // 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. + if (expectedCount > 0 && persistedCount === 0) { + return { 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 }; +} + /** Stable machine-readable reasons an index cannot be certified complete. */ export function getIndexIncompleteReasons( - meta: Pick | null | undefined, + meta: + | Pick + | null + | undefined, ): IndexIncompleteReason[] { const reasons: IndexIncompleteReason[] = []; if (meta?.incrementalInProgress) reasons.push('incremental-in-progress'); + // The run finished and wrote metadata, but far fewer edges reached the DB + // than the pipeline produced — the "refresh reported success, the index is + // unusable" failure. Without this the index reads as fresh and every tool + // 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?.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/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index 329faf42f..903027f70 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -282,14 +282,22 @@ interface LanguageProviderConfig { ) => ExtractedRoute[]; /** - * Extract decorator-style route annotations from a parsed file. + * Extract routes that a parsed file declares in its own AST. * * When defined, the parse worker calls this after per-file capture processing - * to extract framework route definitions that require AST-level analysis beyond + * to extract route definitions that require AST-level analysis beyond * generic `@decorator` captures (e.g., Java Spring class-level prefix joining, * multi-class handling). The returned routes are appended to `decoratorRoutes`. * - * Default: undefined (no language-specific decorator route extraction). + * Decorators are the common case and the reason for the name, but not the only + * shape: JS/TS uses this hook for hand-rolled dispatch guards + * (`route-extractors/dispatch-guard.ts`), where a raw `node:http` server + * declares a route by comparing the request path to a literal. Anything that + * yields a `(path, verb, handler)` triple from one file's AST belongs here — + * set `ExtractedDecoratorRoute.source` when the provenance is not a decorator, + * so the `HANDLES_ROUTE` edge does not claim one. + * + * Default: undefined (no language-specific route extraction). */ readonly extractDecoratorRoutes?: ( tree: Parser.Tree, diff --git a/gitnexus/src/core/ingestion/languages/javascript/query.ts b/gitnexus/src/core/ingestion/languages/javascript/query.ts index ec2d6a3f2..168f866d7 100644 --- a/gitnexus/src/core/ingestion/languages/javascript/query.ts +++ b/gitnexus/src/core/ingestion/languages/javascript/query.ts @@ -107,6 +107,15 @@ export const JAVASCRIPT_SCOPE_QUERY = ` (field_definition property: (property_identifier) @declaration.name) @declaration.property +;; Object-literal keys of a NAMED object (A1/A5) — the scope-resolution half of +;; the same rule in TYPESCRIPT/JAVASCRIPT_QUERIES. The parse query mints the +;; Property NODE; this mints the DEF the resolver can point a read/write at. +(variable_declarator + name: (identifier) + value: (object + (pair + key: (property_identifier) @declaration.name) @declaration.property)) + ;; Declarations — free functions (function_declaration name: (identifier) @declaration.name) @declaration.function @@ -589,6 +598,99 @@ export const JAVASCRIPT_SCOPE_QUERY = ` (object (shorthand_property_identifier) @reference.name @reference.property-key @reference.value-ref) + +;; Bare-identifier reads (A2). VALUE POSITIONS ONLY — a blanket +;; \`(identifier)\` rule would mint a site for every token in the file. +(arguments + (identifier) @reference.name @reference.read.identifier) + +(assignment_pattern + right: (identifier) @reference.name @reference.read.identifier) + +(return_statement + (identifier) @reference.name @reference.read.identifier) + +;; \`const next = LIMIT\` and \`n > LIMIT\` — both plainly value reads, and both +;; named in review as gaps between what A2 claimed and what it matched. +(variable_declarator + value: (identifier) @reference.name @reference.read.identifier) + +(binary_expression + left: (identifier) @reference.name @reference.read.identifier) + +(binary_expression + right: (identifier) @reference.name @reference.read.identifier) + +;; Destructured PARAMETER keys (R2-1c). \`function exit({ exitMinAtrMult = 0 })\` +;; reads that property off whatever the caller passes, exactly as +;; \`cfg.exitMinAtrMult\` would — the field just never appears in a +;; member_expression, so the read had no site at all and the function that +;; implements the behaviour was missing from "who reads this setting?". +;; +;; A distinct anchor rather than @reference.read.member: that tag is filtered +;; emit-side to matches with a member_expression ancestor (calls and writes +;; share its shape), and a destructuring pattern has none, so it would be +;; dropped. The \`read.\` head is what maps this to a read kind, so the new tag +;; needs no mapping change. +;; +;; The object_pattern is the receiver. It is anonymous — there is no name to +;; type — which is precisely the untyped-receiver case the name-narrowing pass +;; exists to serve. +;; +;; Scoped to formal_parameters deliberately. A destructuring binding elsewhere +;; (\`const { x } = require('m')\`) is often an import rather than a field read, +;; and minting a property read for it would attribute module bindings to +;; unrelated same-named keys. +(formal_parameters + (object_pattern + (shorthand_property_identifier_pattern) @reference.name + @reference.read.destructured) @reference.receiver) + +(formal_parameters + (object_pattern + (object_assignment_pattern + left: (shorthand_property_identifier_pattern) @reference.name + @reference.read.destructured)) @reference.receiver) + +(formal_parameters + (object_pattern + (pair_pattern + key: (property_identifier) @reference.name + @reference.read.destructured)) @reference.receiver) + +;; Object-literal keys in RECORD CONSTRUCTION position (R2-1b). Building +;; \`{ exitContract: { exitMinAtrMult: settings.x } }\` SETS that field, so this +;; is the write counterpart to the destructured read above — without it +;; "who reads this setting?" answers well and "who SETS it?" misses the code +;; that stamps the value. +;; +;; A WRITE REFERENCE, deliberately not a definition. The round-1 rule already +;; mints Property nodes for literals bound to a variable; minting more for +;; anonymous records would add same-named competitors to the very name-narrowing +;; that makes these reads resolvable — measured at 26 competing definitions for +;; one field on the reporting repo. A construction site is a USE of a field, not +;; another declaration of it. +;; +;; Two positions only: nested under a key, and returned. Both are records with a +;; name attached (the key, or the function). An inline call argument +;; (\`doThing({ id: 1 })\`) stays excluded for the same reason round 1 excluded +;; it from definitions — it is call-site data, not a named surface. +;; +;; The enclosing literal is the receiver, and it is anonymous, which routes +;; these through the same narrowing and the same refusal-to-guess as every other +;; untyped receiver. +(pair + value: (object + (pair + key: (property_identifier) @reference.name + @reference.write.property-key) @_r2b.nested) @reference.receiver) + +(return_statement + (object + (pair + key: (property_identifier) @reference.name + @reference.write.property-key) @_r2b.returned) @reference.receiver) + `; /** JSX-only suffix — appended when compiling against the JSX grammar for .jsx files. */ diff --git a/gitnexus/src/core/ingestion/languages/typescript.ts b/gitnexus/src/core/ingestion/languages/typescript.ts index 2ccfbb577..c24ab6ae8 100644 --- a/gitnexus/src/core/ingestion/languages/typescript.ts +++ b/gitnexus/src/core/ingestion/languages/typescript.ts @@ -124,6 +124,7 @@ import { jsMergeBindings, jsArityCompatibility, } from './javascript/index.js'; +import { extractDispatchGuardRoutes } from '../route-extractors/dispatch-guard.js'; /** * TypeScript/JavaScript: arrow_function and function_expression are @@ -454,6 +455,10 @@ export const typescriptProvider = defineLanguage({ receiverBinding: tsReceiverBinding, arityCompatibility: typescriptArityCompatibility, resolveImportTarget: resolveTsImportTarget, + // 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, }); export const javascriptProvider = defineLanguage({ @@ -526,4 +531,6 @@ export const javascriptProvider = defineLanguage({ mergeBindings: (_scope, bindings) => jsMergeBindings(bindings), receiverBinding: jsReceiverBinding, arityCompatibility: jsArityCompatibility, + // See the TypeScript provider above. + extractDecoratorRoutes: extractDispatchGuardRoutes, }); diff --git a/gitnexus/src/core/ingestion/languages/typescript/query.ts b/gitnexus/src/core/ingestion/languages/typescript/query.ts index 04286ff66..f2d2f22cf 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/query.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/query.ts @@ -170,8 +170,12 @@ export const TYPESCRIPT_SCOPE_QUERY = ` (enum_declaration name: (identifier) @declaration.name) @declaration.enum +;; Tagged @declaration.type_alias, NOT @declaration.type: normalizeNodeLabel +;; accepts typealias / type_alias and has no "type" case, so the old tag mapped +;; 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 + name: (type_identifier) @declaration.name) @declaration.type_alias (internal_module name: (identifier) @declaration.name) @declaration.namespace @@ -506,6 +510,48 @@ export const TYPESCRIPT_SCOPE_QUERY = ` (method_signature name: (property_identifier) @declaration.name) @declaration.method +;; Members of a declared SHAPE — interface bodies and object-type aliases both +;; spell them as property_signature (A4). The sibling method_signature rule +;; above declared interface METHODS, so only properties were missing: a typed +;; receiver resolved to the shape's scope and then found no member there, and +;; the field's consumers were unreachable. TypeScript sets +;; fieldFallbackOnMethodLookup:false, so there is no name-based safety net +;; here — the precise path is the only one, and it needs the declaration. +;; ANCHORED to declared shapes — see the matching rule in TYPESCRIPT_QUERIES +;; for why. Unanchored this matched inline parameter and return types and +;; nested object types, whose members then collided onto the enclosing +;; class/interface/alias. +(interface_body + (property_signature + name: (property_identifier) @declaration.name) @declaration.property) + +;; Object-literal keys of a NAMED object — the scope-resolution half of the +;; matching rule in TYPESCRIPT_QUERIES. The parse query mints the Property NODE; +;; this mints the DEF a precise read can resolve to. +(variable_declarator + name: (identifier) + value: (object + (pair + key: (property_identifier) @declaration.name) @declaration.property)) + +(variable_declarator + name: (identifier) + value: (call_expression + function: (member_expression + object: (identifier) @_ts.identity.obj + property: (property_identifier) @_ts.identity.fn) + arguments: (arguments + (object + (pair + key: (property_identifier) @declaration.name) @declaration.property))) + (#eq? @_ts.identity.obj "Object") + (#match? @_ts.identity.fn "^(freeze|seal|preventExtensions)$")) + +(type_alias_declaration + value: (object_type + (property_signature + name: (property_identifier) @declaration.name) @declaration.property)) + ;; Declarations — class fields (public_field_definition name: (property_identifier) @declaration.name) @declaration.property @@ -1212,6 +1258,65 @@ export const TYPESCRIPT_SCOPE_QUERY = ` (object (shorthand_property_identifier) @reference.name @reference.property-key @reference.value-ref) + +;; Bare-identifier reads (A2), VALUE POSITIONS ONLY — a blanket \`(identifier)\` +;; rule would mint a site for every token in the file. +;; +;; These existed only in the JavaScript query, so A2 did not work for +;; TypeScript AT ALL: a \`.ts\` module reading its own \`const\` by bare name +;; produced no reference site, and "who uses this constant?" answered a +;; confident zero for an entire language. Found by writing the namespace +;; fixture below and watching it fail for the wrong reason. +(arguments + (identifier) @reference.name @reference.read.identifier) + +(assignment_pattern + right: (identifier) @reference.name @reference.read.identifier) + +(return_statement + (identifier) @reference.name @reference.read.identifier) + +;; \`const next = LIMIT\` and \`n > LIMIT\` — both plainly value reads, and both +;; named in review as gaps between what A2 claimed and what it matched. +(variable_declarator + value: (identifier) @reference.name @reference.read.identifier) + +(binary_expression + left: (identifier) @reference.name @reference.read.identifier) + +(binary_expression + right: (identifier) @reference.name @reference.read.identifier) + +;; References — TYPE POSITION (R2-2). An annotation naming a declared type is +;; the only thing that makes that type's declaration reachable from the code +;; that depends on it, and TypeScript captured none: only cpp and csharp emitted +;; type references at all. So an exported API-contract type owned its members +;; (round 1) but had \`incoming: {}\`, and "what breaks if I remove this field?" +;; — the question a contract type exists to answer — had no edge to walk. +;; +;; The resolution path was already complete on the other side: +;; \`type-reference\` routes to the ClassRegistry, whose CLASS_KINDS already +;; lists TypeAlias, Interface and Enum, and \`edges.ts\` already maps the kind to +;; USES. Only the capture was missing. +;; +;; Anchored to the CONTEXTS a type is used in — annotations, type arguments, +;; and heritage \`implements\` — never a bare \`(type_identifier)\`. A blanket rule +;; would also match the identifier in \`type X = …\` and \`interface X\`, making +;; every declaration a consumer of itself. +(type_annotation + (type_identifier) @reference.name @reference.type_reference) + +(type_annotation + (generic_type + name: (type_identifier) @reference.name @reference.type_reference)) + +(type_arguments + (type_identifier) @reference.name @reference.type_reference) + +;; \`x as SomeType\` / \`satisfies SomeType\` — an assertion is a claim ABOUT a +;; declared type, so the code making it depends on that declaration. +(as_expression + (type_identifier) @reference.name @reference.type_reference) `; /** diff --git a/gitnexus/src/core/ingestion/pipeline-phases/processes.ts b/gitnexus/src/core/ingestion/pipeline-phases/processes.ts index 462d1523f..eefce05ea 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/processes.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/processes.ts @@ -4,8 +4,9 @@ * Detects execution flows (processes) and creates Process nodes + * STEP_IN_PROCESS edges. Also links Route/Tool nodes to processes. * - * @deps communities, routes, tools, pruneLocalSymbols - * @reads graph (all nodes and relationships), communityResult, routeRegistry, toolDefs + * @deps communities, routes, tools, pruneLocalSymbols, structure, parse + * @reads graph (all nodes and relationships), communityResult, routeRegistry, + * toolDefs, parse's allFetchCalls + allORMQueries (R3-6 sink sites) * @writes graph (Process nodes, STEP_IN_PROCESS edges, ENTRY_POINT_OF edges) */ @@ -15,6 +16,7 @@ import type { CommunitiesOutput } from './communities.js'; import type { RoutesOutput } from './routes.js'; import type { ToolsOutput } from './tools.js'; import type { StructureOutput } from './structure.js'; +import type { ParseOutput } from './parse.js'; import { processProcesses, type ProcessDetectionResult } from '../process-processor.js'; import { generateId } from '../../../lib/utils.js'; import { routeNodeKey } from '../route-extractors/route-path.js'; @@ -38,11 +40,19 @@ export function computeDynamicMaxProcesses(symbolCount: number): number { export const processesPhase: PipelinePhase = { name: 'processes', - // `structure` supplies `totalFiles` (progress counter) without the spurious - // structural data dependency on `parse`. `pruneLocalSymbols` is declared + // `structure` supplies `totalFiles` (progress counter), which is why this + // phase historically avoided depending on `parse` at all — that dependency + // was spurious for a progress number. + // + // It is no longer spurious. R3-6 reads `allFetchCalls` / `allORMQueries` from + // the parse output to learn WHERE the program reaches outward, which is what + // lets a trace end at a sink instead of only at a leaf. That is a real data + // dependency, so it is declared rather than reached for implicitly — and the + // read below still fails open, so a pipeline without that output detects no + // sinks rather than failing the phase. `pruneLocalSymbols` is declared // explicitly so process extraction always reads the trimmed graph even if a // future option drops the intervening `mro`/`communities` phases. - deps: ['communities', 'routes', 'tools', 'pruneLocalSymbols', 'structure'], + deps: ['communities', 'routes', 'tools', 'pruneLocalSymbols', 'structure', 'parse'], async execute( ctx: PipelineContext, @@ -66,6 +76,32 @@ export const processesPhase: PipelinePhase = { }); const dynamicMaxProcesses = computeDynamicMaxProcesses(symbolCount); + // R3-6: where the program reaches outward. Already collected by the parse + // phase for FILE-level FETCHES/QUERIES edges; reused here at function + // granularity so a trace can end somewhere meaningful instead of only at a + // leaf. Absent (or an older parse output) simply yields no sinks and the + // previous behaviour. + // + // Typed as `ParseOutput` rather than a locally re-declared structural shape, + // the way every other parse consumer does it (cross-file.ts, orm.ts, + // routes.ts, tools.ts). `getPhaseOutput` is a raw `as T` cast, so a local + // shape does not check anything — it only severs the compile-time link, and + // renaming `allFetchCalls` on `ParseOutput` would then still compile here and + // silently detect zero sinks. The runtime `.filter` below is the actual + // guard, and it stays. + let parseOutput: ParseOutput | undefined; + try { + parseOutput = getPhaseOutput(deps, 'parse'); + } catch { + // Fail open: no sinks, previous behaviour. A missing parse output is a + // pipeline-composition question, not a reason to lose every process. + parseOutput = undefined; + } + const outwardActionSites = [ + ...(parseOutput?.allFetchCalls ?? []), + ...(parseOutput?.allORMQueries ?? []), + ].filter((s) => typeof s?.filePath === 'string' && typeof s?.lineNumber === 'number'); + const processResult = await processProcesses( ctx.graph, communityResult.memberships, @@ -79,6 +115,7 @@ export const processesPhase: PipelinePhase = { }); }, { maxProcesses: dynamicMaxProcesses, minSteps: 3 }, + outwardActionSites, ); if (isDev) { diff --git a/gitnexus/src/core/ingestion/pipeline-phases/routes.ts b/gitnexus/src/core/ingestion/pipeline-phases/routes.ts index 041fbebdf..eca248fad 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/routes.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/routes.ts @@ -29,6 +29,7 @@ import { compiledMatcherMatchesRoute, } from '../route-extractors/middleware.js'; import { processNextjsFetchRoutes } from '../call-processor.js'; +import { reconcileDispatchGuardRoutes } from '../route-extractors/dispatch-guard.js'; import { normalizeExtractedRoutePath, normalizeRouteMethod, @@ -248,11 +249,18 @@ export const routesPhase: PipelinePhase = { namedRouteRegistry.set(route.routeName, routeUrl); } } - for (const dr of allDecoratorRoutes) { + // A dispatch-guard route observed WITHOUT a verb is dropped when the same + // URL is claimed WITH one anywhere in the repo — the split route-table + // idiom, which no single file can reconcile. Framework routes are untouched; + // their verb-less form is a declaration, not a weaker observation. + for (const dr of reconcileDispatchGuardRoutes(allDecoratorRoutes)) { const url = normalizeExtractedRoutePath(dr.routePath, dr.prefix ?? null); addRoute(url, { filePath: dr.filePath, - source: `decorator-${dr.decoratorName}`, + // A route extracted from a file's own AST is usually a decorator; a + // dispatch guard is the same transport with different provenance, and + // says so (`ExtractedDecoratorRoute.source`). + source: dr.source ?? `decorator-${dr.decoratorName}`, method: normalizeRouteMethod(dr.httpMethod), }); } diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index 4e858bd3f..70f14120b 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -381,6 +381,7 @@ export const runPipelineFromRepo = async ( const resolutionOutcomes = scopeResolutionOutput.resolutionOutcomes; // Streamed PDG-emit manifest (#2202): present only when streaming was on. const pdgEmitManifest = scopeResolutionOutput.pdgEmitManifest; + const propertyInference = scopeResolutionOutput.propertyInference; // Presence check, not `!skipGraphPhases`: phases can now be filtered out by // any `enabledWhen` predicate (streamGraphEmit disables communities/processes @@ -422,5 +423,6 @@ export const runPipelineFromRepo = async ( resolutionOutcomes, usedWorkerPool, pdgEmitManifest, + propertyInference, }; }; diff --git a/gitnexus/src/core/ingestion/process-processor.ts b/gitnexus/src/core/ingestion/process-processor.ts index dbec25474..845232b5e 100644 --- a/gitnexus/src/core/ingestion/process-processor.ts +++ b/gitnexus/src/core/ingestion/process-processor.ts @@ -3,7 +3,7 @@ * * Detects execution flows (Processes) in the code graph by: * 1. Finding entry points (functions with no internal callers) - * 2. Tracing forward via CALLS edges (BFS) + * 2. Tracing forward via CALLS edges (DFS) * 3. Grouping and deduplicating similar paths * 4. Labeling with heuristic names * @@ -83,8 +83,16 @@ export const processProcesses = async ( memberships: CommunityMembership[], onProgress?: (message: string, progress: number) => void, config: Partial = {}, + /** + * Places the program reaches outward — fetch calls and ORM queries, each with + * a file and a line (R3-6). Attributed to their enclosing function to form the + * sink set; omitted, behaviour is exactly as before. + */ + outwardActionSites: readonly OutwardActionSite[] = [], ): Promise => { const cfg = { ...DEFAULT_CONFIG, ...config }; + const sinkFunctions = buildSinkFunctionSet(knowledgeGraph, outwardActionSites); + const isSink = (nodeId: string): boolean => sinkFunctions.has(nodeId); onProgress?.('Finding entry points...', 0); @@ -109,7 +117,7 @@ export const processProcesses = async ( for (let i = 0; i < entryPoints.length && allTraces.length < cfg.maxProcesses * 2; i++) { const entryId = entryPoints[i]; - const traces = traceFromEntryPoint(entryId, callsEdges, cfg); + const traces = traceFromEntryPoint(entryId, callsEdges, cfg, isSink); // Filter out traces that are too short traces.filter((t) => t.length >= cfg.minSteps).forEach((t) => allTraces.push(t)); @@ -125,7 +133,7 @@ export const processProcesses = async ( onProgress?.(`Found ${allTraces.length} traces, deduplicating...`, 60); // Step 3: Deduplicate similar traces (subset removal) - const uniqueTraces = deduplicateTraces(allTraces); + const uniqueTraces = deduplicateTraces(allTraces, isSink); // Step 3b: Deduplicate by entry+terminal pair (keep longest path per pair) const endpointDeduped = deduplicateByEndpoints(uniqueTraces); @@ -135,10 +143,78 @@ export const processProcesses = async ( 70, ); - // Step 4: Limit to max processes (prioritize longer traces) - const limitedTraces = endpointDeduped - .sort((a, b) => b.length - a.length) - .slice(0, cfg.maxProcesses); + // Step 4: Limit to max processes — deepest first, but ROUND-ROBIN across + // TERMINALS (R2-3). + // + // Ranking was `sort by length` alone, and the top of that list was one + // behaviour described many ways: measured on the reporting repo, eleven of + // the top fourteen processes were four entry points crossed with three + // terminals of the same date-window utility cluster — `Handle -> + // AlignWindowEnd`, `Main -> AlignWindowStart`, `ProcessSymbol -> + // ResolveGridIntervalMs`. Genuine call chains, but a reader learns one thing + // from fourteen entries. + // + // Keyed on the TERMINAL, not the entry point. Keying on the entry was tried + // first and made it worse — many files declare a `main`, so each was a + // distinct entry that round-robin then awarded its own slot, and + // `Main -> AlignWindowEnd` went from one row to eight. The repetition was + // never in where a flow starts; it is in where flows pile up. + // + // Depth still orders within a terminal and still leads the list, since + // insertion order here is deepest-first. What changes is that no terminal + // takes a second slot until every other has had a first. Measured: distinct + // terminals in the top 20 went 3 -> 20, and the repo's own domain flows + // (`ReconcilePositions -> ...`) moved into the top 4%. + // + // What this does NOT do, recorded so it does not read as settled: the + // reported cause — ranking rewarding fan-in, promoting chains ending in + // widely-called helpers — measured FALSE. Those terminals have one caller + // each (`alignWindowStart` 1, `validateSymbol` 1). A fan-in discount was + // implemented against that hypothesis and moved nothing. + // + // R3-6 adds one rule ahead of depth: a SINK-terminated trace outranks a + // leaf-terminated one. A flow that ends where the program does something — + // places an order, writes a row — is what a reader came for; a chain that + // ends in a date helper is where control happened to stop. Depth still orders + // within each group. + // + // This is what closed the gap that used to be described here as out of reach: + // a business flow could not be a process in its own right, because the walk + // emitted only at a leaf, at max depth, or on a cycle, so a flow whose + // meaningful endpoint calls onward survived only as whatever leaf it bottomed + // out in. Ranking could never fix it — the flow was not a candidate to rank. + // It is bounded honestly rather than fully closed: sinks are exactly where + // fetch/ORM extraction fires (see `buildSinkFunctionSet`), so a codebase whose + // outward calls are not detected as such still sees leaf-terminated traces + // only. + const tracesByTerminal = new Map(); + const rankedByInterest = [...endpointDeduped].sort((a, b) => { + const aSink = Number(isSink(a[a.length - 1] ?? '')); + const bSink = Number(isSink(b[b.length - 1] ?? '')); + return bSink - aSink || b.length - a.length; + }); + for (const trace of rankedByInterest) { + const terminalId = trace[trace.length - 1]; + if (terminalId === undefined) continue; + const existing = tracesByTerminal.get(terminalId); + if (existing === undefined) tracesByTerminal.set(terminalId, [trace]); + else existing.push(trace); + } + + // Insertion order is deepest-trace-first, so the round-robin visits terminals + // in that order too and depth still leads the list. + const limitedTraces: string[][] = []; + for (let round = 0; limitedTraces.length < cfg.maxProcesses; round++) { + let addedAny = false; + for (const traces of tracesByTerminal.values()) { + const trace = traces[round]; + if (trace === undefined) continue; + limitedTraces.push(trace); + addedAny = true; + if (limitedTraces.length >= cfg.maxProcesses) break; + } + if (!addedAny) break; + } onProgress?.(`Creating ${limitedTraces.length} process nodes...`, 80); @@ -333,26 +409,55 @@ const findEntryPoints = ( }; // ============================================================================ -// HELPER: Trace from entry point (BFS) +// HELPER: Trace from entry point (DFS) // ============================================================================ /** - * Trace forward from an entry point using BFS. + * Trace forward from an entry point using DEPTH-first search. * Returns all distinct paths up to maxDepth. */ -const traceFromEntryPoint = ( +// Exported for tests ONLY: traversal order is the whole behaviour here, and it +// is unobservable through `processProcesses` because `findEntryPoints` supplies +// several starting points — a deep chain gets traced from inside it regardless +// of order, so a test at that level passes under either traversal and pins +// nothing. +export const traceFromEntryPoint = ( entryId: string, callsEdges: AdjacencyList, config: ProcessDetectionConfig, + /** + * Functions that reach outward (R3-6). A trace also ENDS here, even though + * the walk continues past it: a flow whose meaningful endpoint calls onward + * was otherwise never a candidate, only ever surviving as whatever leaf it + * bottomed out in. + */ + isSink: (nodeId: string) => boolean = () => false, ): string[][] => { const traces: string[][] = []; - // BFS with path tracking - // Each queue item: [currentNodeId, pathSoFar] - const queue: [string, string[]][] = [[entryId, [entryId]]]; + // DEPTH-first, not breadth-first. Each stack item: [currentNodeId, pathSoFar]. + // + // The walk stops after a fixed NUMBER of traces, so the traversal order + // decides which traces those are. Breadth-first reaches every shallow + // terminal before any deep one, so the quota filled with the shortest paths + // in the graph and the walk stopped — `maxTraceDepth` was never approached. + // Measured on a 75k-node repo: of 300 processes, none exceeded 7 steps and + // 90% were 3-4, so a multi-hop business flow (signal → order → exit) had no + // process that could represent it, and `query` could only rank the mechanical + // pairs that did exist. + // + // Depth-first descends to a terminal first, so the same quota is spent on + // paths worth keeping. Cost is unchanged — same budget, same cycle guard, + // same `maxTraceDepth` ceiling — only the ORDER of exploration differs, and + // the caller already sorts by length and dedupes by endpoint, so it was + // always asking for the deepest traces this walk could give it. + // A LIFO stack, not a queue — the name followed the traversal when this was + // breadth-first and was left behind by the change to depth-first. + const stack: [string, string[]][] = [[entryId, [entryId]]]; - while (queue.length > 0 && traces.length < config.maxBranching * 3) { - const [currentId, path] = queue.shift()!; + const traceBudget = config.maxBranching * 3; + while (stack.length > 0 && traces.length < traceBudget) { + const [currentId, path] = stack.pop()!; // Get outgoing calls const callees = callsEdges.get(currentId) || []; @@ -368,14 +473,29 @@ const traceFromEntryPoint = ( traces.push([...path]); } } else { + // A SINK ends a trace without ending the walk (R3-6). Emitting here is + // what lets `placeOrder` be an endpoint while `placeOrder -> formatDate` + // still exists as its own longer trace; the two answer different + // questions and neither should suppress the other. + if (isSink(currentId) && path.length >= config.minSteps) { + traces.push([...path]); + } // Continue tracing - limit branching const limitedCallees = callees.slice(0, config.maxBranching); let addedBranch = false; - for (const calleeId of limitedCallees) { + // PUSHED IN REVERSE so the stack POPS them in source order. `slice` + // selects the first N callees while `pop()` takes the last pushed, so + // without this the walk spends its trace budget on the LAST-declared + // branch first: for `main() { init(); loadConfig(); run(); shutdown(); }` + // it explores `shutdown` first and can exhaust the quota before reaching + // `init` — dropping the earliest steps of a flow, which is the opposite + // of what a process is meant to describe. Selecting the first N and then + // exploring them last-first was simply inconsistent. + for (const calleeId of [...limitedCallees].reverse()) { // Avoid cycles if (!path.includes(calleeId)) { - queue.push([calleeId, [...path, calleeId]]); + stack.push([calleeId, [...path, calleeId]]); addedBranch = true; } } @@ -387,9 +507,89 @@ const traceFromEntryPoint = ( } } + // A silently truncating cap reads as "this is everything", which is the same + // class of confident-empty answer this work is about. The repo already sets + // this precedent for `dispatchFanoutSkipped` and + // `propertyDispatch.skippedKeys`. + if (stack.length > 0) { + logger.debug( + { entryId, traceBudget, unexploredBranches: stack.length }, + 'process-processor: trace budget exhausted; unexplored branches remain for this entry point', + ); + } + return traces; }; +// ============================================================================ +// HELPER: Function-level sink set +// ============================================================================ + +/** A place in the source where the program reaches outward. */ +export interface OutwardActionSite { + readonly filePath: string; + readonly lineNumber: number; +} + +const CALLABLE_SINK_LABELS: ReadonlySet = new Set([ + 'Function', + 'Method', + 'Constructor', +]); + +/** + * Functions that DO something outward — issue a request, run a query (R3-6). + * + * The missing layer behind "business flows are never processes". A trace is + * only emitted at a node with NO outgoing calls, so a real flow — scan, score, + * arm, place the order — is always a PREFIX of some longer chain that runs on + * into date helpers and formatters, and can never be a process in its own + * right. Ending a trace somewhere meaningful needs a notion of an endpoint that + * is not a leaf, and the walk had none. + * + * GitNexus already knew where the program reaches outward: the parse phase + * collects fetch calls and ORM queries carrying `filePath` + `lineNumber`. Those + * facts only ever produced FILE-level edges (`File -[FETCHES]-> Route`), which + * is too coarse to end a trace on — every function in a file containing one + * would qualify. Attributing each site to the function whose range CONTAINS it + * turns the same facts into the function-level signal the walk needs, with no + * new extraction, no new relation pair, and no schema change. + * + * Innermost wins: a nested closure that performs the call is the sink, not the + * outer function that merely spans it. + */ +export function buildSinkFunctionSet( + graph: KnowledgeGraph, + sites: readonly OutwardActionSite[], +): ReadonlySet { + const sinks = new Set(); + if (sites.length === 0) return sinks; + + const byFile = new Map(); + for (const node of graph.iterNodes()) { + if (!CALLABLE_SINK_LABELS.has(node.label)) continue; + const props = node.properties as { filePath?: string; startLine?: number; endLine?: number }; + if (typeof props.filePath !== 'string') continue; + if (typeof props.startLine !== 'number' || typeof props.endLine !== 'number') continue; + const entry = { id: node.id, start: props.startLine, end: props.endLine }; + const list = byFile.get(props.filePath); + if (list === undefined) byFile.set(props.filePath, [entry]); + else list.push(entry); + } + + for (const site of sites) { + const candidates = byFile.get(site.filePath); + if (candidates === undefined) continue; + let best: { id: string; start: number; end: number } | undefined; + for (const c of candidates) { + if (site.lineNumber < c.start || site.lineNumber > c.end) continue; + if (best === undefined || c.end - c.start < best.end - best.start) best = c; + } + if (best !== undefined) sinks.add(best.id); + } + return sinks; +} + // ============================================================================ // HELPER: Deduplicate traces // ============================================================================ @@ -398,23 +598,50 @@ const traceFromEntryPoint = ( * Merge traces that are subsets of other traces. * Keep longer traces, remove redundant shorter ones. */ -const deduplicateTraces = (traces: string[][]): string[][] => { +const deduplicateTraces = ( + traces: string[][], + /** See `buildSinkFunctionSet` — a sink-terminated trace survives subsumption. */ + isSink: (nodeId: string) => boolean = () => false, +): string[][] => { if (traces.length === 0) return []; // Sort by length descending const sorted = [...traces].sort((a, b) => b.length - a.length); const unique: string[][] = []; + // Keys for `unique`, built ONCE per surviving trace rather than once per + // COMPARISON. The join used to sit inside the `some()` callback below, so + // every already-kept trace had its key rebuilt from scratch against every + // candidate — O(T*U) joins of O(depth * id-length) characters, and it is that + // allocation, not the substring scan, that dominates the pass. + // + // Nothing about breadth-first search made that safe; it only kept the cost + // small by keeping traces short. Measured on this repo, the walk went from an + // average of 4.3 steps to 9.4 when D1 made it depth-first, which roughly + // doubles both the number of surviving traces and the length of every key — + // so the same quadratic that was affordable under BFS is ~6x the work under + // DFS. Hoisting the join removes the multiplication entirely. + const uniqueKeys: string[] = []; for (const trace of sorted) { - // Check if this trace is a subset of any already-added trace + // A SINK-TERMINATED trace is never redundant (R3-6), even though it is by + // definition a prefix of the longer chain that runs on past the sink into + // helpers. That is the whole shape of a business flow — scan, score, arm, + // PLACE THE ORDER — so subsuming it here is exactly what kept such flows + // from ever being processes. Emitting one at the walk and deleting it one + // step later would have been a no-op fix. + const terminal = trace[trace.length - 1]; const traceKey = trace.join('->'); - const isSubset = unique.some((existing) => { - const existingKey = existing.join('->'); - return existingKey.includes(traceKey); - }); + if (terminal !== undefined && isSink(terminal)) { + unique.push(trace); + uniqueKeys.push(traceKey); + continue; + } + // Check if this trace is a subset of any already-added trace + const isSubset = uniqueKeys.some((existingKey) => existingKey.includes(traceKey)); if (!isSubset) { unique.push(trace); + uniqueKeys.push(traceKey); } } diff --git a/gitnexus/src/core/ingestion/resolve-references.ts b/gitnexus/src/core/ingestion/resolve-references.ts index e95659d8e..b34bff49d 100644 --- a/gitnexus/src/core/ingestion/resolve-references.ts +++ b/gitnexus/src/core/ingestion/resolve-references.ts @@ -220,7 +220,25 @@ function lookupForSite( ...(site.explicitReceiver !== undefined ? { explicitReceiver: site.explicitReceiver } : {}), }; const fieldHits = fieldRegistry.lookup(site.name, site.inScope, fieldOpts); - if (fieldHits.length > 0) return fieldHits; + // A BARE IDENTIFIER is not a member access. With no receiver there is no + // object whose `Property` this could be, so a hit on one is a false edge: + // in JS/TS/Python/Ruby a field read needs `this.` / `self.` / `@`, and the + // bare name means the nearest lexical binding instead. + // + // Observed: `class Box { baseUrl = '...'; pick() { const baseUrl = ...; + // return baseUrl; } }` linked the block-local read to `Box.baseUrl`, + // duplicating the legitimate `this.baseUrl` edge. That predates the + // TypeScript captures added here — JavaScript has emitted bare-identifier + // reads since A2 and no class fixture exercised the shadow. + // + // Callables are deliberately still reachable: `cb = save` naming a + // top-level function is a real bare-name reference, which is why the + // method/class fallbacks below are untouched. + const receiverlessFieldHits = + site.explicitReceiver === undefined + ? fieldHits.filter((hit) => hit.def?.type !== 'Property') + : fieldHits; + if (receiverlessFieldHits.length > 0) return receiverlessFieldHits; const methodHits = methodRegistry.lookup(site.name, site.inScope); if (methodHits.length > 0) return methodHits; return classRegistry.lookup(site.name, site.inScope); diff --git a/gitnexus/src/core/ingestion/route-extractors/dispatch-guard.ts b/gitnexus/src/core/ingestion/route-extractors/dispatch-guard.ts new file mode 100644 index 000000000..a322a608a --- /dev/null +++ b/gitnexus/src/core/ingestion/route-extractors/dispatch-guard.ts @@ -0,0 +1,692 @@ +/** + * Hand-rolled dispatch-guard route extractor (JavaScript / TypeScript). + * + * Every route extractor before this one recognises a route because a FRAMEWORK + * declares it: a decorator, a `Route::get()` call, a filesystem convention. A + * server written against raw `node:http` declares its routes the only way the + * language offers — by COMPARING the request path to a literal: + * + * if (req.method === 'GET' && pathname === '/api/live/portfolio') { … } + * + * That is a route definition in every sense that matters to this graph: it has a + * path, a verb, and a handler. GitNexus simply had no rule that could see it, so + * `route_map` answered "No routes found in this project" for a repo with + * seventeen route modules and 113 such comparisons — the same confident-empty + * failure this whole change set is about, one tool wide. + * + * PRECISION OVER RECALL, deliberately. A missed route is a coverage limit; an + * invented route is a false fact, and `route_map` presents its output as fact. + * So every rule here requires the comparison to be against something that is + * demonstrably a request path, and anything that cannot be converted cleanly is + * dropped rather than guessed at. Specifically NOT extracted: + * + * - `pathname.startsWith('/api/')` — a namespace test ("do I own this?"), + * not a route. Minting `/api` would claim a route nobody serves. + * - a bare `pathname === '/'` with no verb — far more often a normalisation + * branch (`pathname === '/' ? '/index.html' : pathname`) than a route. With + * a verb alongside it the intent is unambiguous, so that form IS extracted. + * - any regex whose body is not a literal path plus single-segment wildcards. + * + * One consequence worth stating rather than discovering: a single-page app that + * branches on `location.pathname === '/settings'` mints a Route too. That is + * intentional — it is the same claim a Next.js filesystem route makes, that this + * file serves this path — and it keeps the rule from needing to guess whether a + * comparison is "backend enough". It does mean `route_map` on a SPA reports + * client routes alongside API ones, distinguishable by their `source`. + * + * @module route-extractors/dispatch-guard + */ + +import type Parser from 'tree-sitter'; +import type { SyntaxNode } from 'tree-sitter'; +import type { ExtractedDecoratorRoute } from '../workers/parse-worker.js'; + +/** Provenance stamped on the Route node, in place of `decorator-`. */ +export const DISPATCH_GUARD_SOURCE = 'dispatch-guard-route'; + +const HTTP_VERBS: ReadonlySet = new Set([ + 'GET', + 'POST', + 'PUT', + 'PATCH', + 'DELETE', + 'HEAD', + 'OPTIONS', +]); + +const EQUALITY_OPERATORS: ReadonlySet = new Set(['===', '==']); + +/** + * Expressions that denote the request path. Kept deliberately narrow: this is + * the predicate standing between "a string comparison" and "a route", so a loose + * match here is how invented routes would get in. `path` alone is excluded — in + * Node it is overwhelmingly the `node:path` module or a filesystem path. + */ +const PATH_IDENTIFIERS: ReadonlySet = new Set([ + 'pathname', + 'pathName', + 'urlPath', + 'routePath', + 'reqPath', + 'requestPath', +]); + +/** `req.url` / `request.url` — the raw form, before a URL parse. */ +const RAW_URL_RECEIVERS: ReadonlySet = new Set(['req', 'request']); + +/** + * Cheap pre-filter, so this costs nothing on the overwhelming majority of files. + * + * Sound by construction rather than by luck: every rule below reaches a route + * only through {@link isPathExpression}, which returns true only for one of the + * {@link PATH_IDENTIFIERS} or for a member access whose property is `pathname` / + * `url`. A file whose source contains none of those substrings cannot produce a + * route, so skipping the walk cannot change the output. Keep this alternation in + * step with those two predicates — widening one without the other would silently + * re-introduce the empty answer this module exists to remove. + */ +const PATH_TOKEN_HINT = /pathname|pathName|urlPath|routePath|reqPath|requestPath|\.\s*url\b/; + +const FUNCTION_NODE_TYPES: ReadonlySet = new Set([ + 'function_declaration', + 'generator_function_declaration', + 'function_expression', + 'generator_function', + 'arrow_function', + 'method_definition', +]); + +/** + * A string literal that could be a URL path: leading slash, no whitespace, and + * no scheme. The character class is permissive about what a path may CONTAIN + * (`{id}`, `:id`, `%20`, `.json` are all legitimate) because the leading slash + * plus a path-denoting operand already carries the discrimination. + */ +function isPathLiteral(value: string): boolean { + if (!value.startsWith('/')) return false; + if (value.includes('://')) return false; + return /^\/[\w\-./{}:$*%~@]*$/.test(value); +} + +/** + * Same-file string constants, for folding a composed path. + * + * Built once per file and passed down, because the idiom it exists for is + * common enough that refusing it loses whole route modules: the reporting repo + * writes `pathname === \`${autoTradeBasePath}/rules\`` throughout one of its + * seventeen route files, so without folding that file contributes NOTHING while + * looking exactly like a file with no routes. + * + * Deliberately flat — no scope tracking. The cost of that shortcut is bounded by + * refusing ambiguity: a name declared twice with DIFFERENT literal values is + * removed from the map entirely, so a shadowed constant produces no route rather + * than the wrong one. + */ +type ConstantMap = ReadonlyMap; + +/** Follow `a = b = 'literal'` chains, with a cap so a cycle cannot hang. */ +const MAX_CONSTANT_HOPS = 4; + +function buildConstantMap(root: SyntaxNode): ConstantMap { + const direct = new Map(); // name -> literal + const alias = new Map(); // name -> other name + const ambiguous = new Set(); + + const record = (map: Map, name: string, value: string): void => { + const existing = map.get(name); + if (existing !== undefined && existing !== value) ambiguous.add(name); + else map.set(name, value); + }; + + const visit = (node: SyntaxNode): void => { + if (node.type === 'variable_declarator') { + const name = node.childForFieldName('name'); + const value = unparenthesize(node.childForFieldName('value')); + if (name !== null && name.type === 'identifier' && value !== null) { + if (value.type === 'string' || value.type === 'template_string') { + const raw = plainLiteralValue(value); + if (raw !== null) record(direct, name.text, raw); + } else if (value.type === 'identifier') { + record(alias, name.text, value.text); + } + } + } + for (const child of node.namedChildren) visit(child); + }; + visit(root); + + const resolved = new Map(); + for (const name of [...direct.keys(), ...alias.keys()]) { + if (ambiguous.has(name)) continue; + let current = name; + for (let hop = 0; hop < MAX_CONSTANT_HOPS; hop++) { + if (ambiguous.has(current)) break; + const literal = direct.get(current); + if (literal !== undefined) { + resolved.set(name, literal); + break; + } + const next = alias.get(current); + if (next === undefined) break; + current = next; + } + } + return resolved; +} + +/** Unquote a plain string / substitution-free template literal. */ +function plainLiteralValue(node: SyntaxNode): string | null { + if (node.type !== 'string' && node.type !== 'template_string') return null; + if ( + node.type === 'template_string' && + node.namedChildren.some((c) => c.type !== 'string_fragment') + ) { + return null; + } + const text = node.text; + if (text.length < 2) return null; + return text.slice(1, -1); +} + +/** + * The string this expression denotes, folding same-file constants where it can. + * + * Handles a plain literal, a template string whose substitutions all resolve to + * known constants, and `+` concatenation of those. Returns `null` the moment any + * part is unknown — a partially-folded path would be a wrong route, and a route + * that is missing is the cheaper of the two failures. + */ +function literalValue(node: SyntaxNode, constants: ConstantMap = new Map()): string | null { + const plain = plainLiteralValue(node); + if (plain !== null) return plain; + + if (node.type === 'identifier') return constants.get(node.text) ?? null; + + if (node.type === 'template_string') { + let out = ''; + for (const child of node.namedChildren) { + if (child.type === 'string_fragment') { + out += child.text; + continue; + } + if (child.type !== 'template_substitution') return null; + const inner = unparenthesize(child.namedChildren[0] ?? null); + if (inner === null) return null; + const value = literalValue(inner, constants); + if (value === null) return null; + out += value; + } + return out; + } + + if (node.type === 'binary_expression' && node.childForFieldName('operator')?.text === '+') { + const left = unparenthesize(node.childForFieldName('left')); + const right = unparenthesize(node.childForFieldName('right')); + if (left === null || right === null) return null; + const leftValue = literalValue(left, constants); + const rightValue = literalValue(right, constants); + if (leftValue === null || rightValue === null) return null; + return leftValue + rightValue; + } + + return null; +} + +/** + * Does this expression denote the request path? Accepts a bare identifier from + * {@link PATH_IDENTIFIERS}, any member access ending in `.pathname`, and the raw + * `req.url` / `request.url` forms. + */ +function isPathExpression(node: SyntaxNode): boolean { + if (node.type === 'identifier') return PATH_IDENTIFIERS.has(node.text); + if (node.type === 'member_expression') { + const property = node.childForFieldName('property'); + if (property === null) return false; + if (PATH_IDENTIFIERS.has(property.text)) return true; + if (property.text === 'url') { + const object = node.childForFieldName('object'); + return object !== null && RAW_URL_RECEIVERS.has(object.text); + } + return false; + } + return false; +} + +/** A logical `!`. `-` and `~` are unary too and are not negation. */ +function isNegation(node: SyntaxNode): boolean { + return node.type === 'unary_expression' && node.childForFieldName('operator')?.text === '!'; +} + +/** + * Is this comparison reached only when it is FALSE? + * + * The module already refuses to inherit a verb from an `if` whose `else` branch + * holds the comparison, for the reason stated in `governingVerb`: the branch runs + * precisely when the condition did NOT hold, so attributing it is backwards. + * `!` is the same fact written as an operator, and it was not handled — a stated + * invariant with half an implementation, which is worse than an absent one + * because the doc comment reads as though it were covered. + * + * Measured before fixing. `if (!(pathname === '/api/admin'))` INVENTED + * `/api/admin`; `if (!(req.method === 'GET') && pathname === '/api/x')` emitted + * `GET /api/x`, the one verb the branch guarantees the request does not have. + * + * PARITY, not presence: `!!x` is `x`, and a rule keyed on "is there a `!` above + * me" would refuse a positive condition. + * + * The walk stops at the FUNCTION boundary and nowhere else. An earlier draft + * also broke at `statement_block`, reasoning that `if (!cond) { … }` must not + * negate a comparison written in its body — true, but already guaranteed by the + * tree shape: the `!` lives in the if's CONDITION, which is a sibling of the + * block, never an ancestor of anything inside it. So that break could only ever + * fire where a `!` genuinely IS an ancestor across a block, i.e. an IIFE — which + * the function-boundary stop catches first. Unreachable, and unreachable in the + * UNSAFE direction: stopping early under-counts negations, and an under-count + * reads a negated guard as positive and invents the route. Removed rather than + * kept for symmetry. + */ +function isNegatedContext(node: SyntaxNode): boolean { + let negations = 0; + let current: SyntaxNode = node; + let parent = current.parent; + while (parent !== null && !FUNCTION_NODE_TYPES.has(parent.type)) { + if (isNegation(parent)) negations += 1; + current = parent; + parent = current.parent; + } + return negations % 2 === 1; +} + +/** Strip redundant parentheses, which the grammar keeps as real nodes. */ +function unparenthesize(node: SyntaxNode | null): SyntaxNode | null { + let current = node; + while (current !== null && current.type === 'parenthesized_expression') { + current = current.namedChildren[0] ?? null; + } + return current; +} + +/** Does this expression denote the request METHOD (`req.method`, `method`)? */ +function isMethodExpression(node: SyntaxNode): boolean { + if (node.type === 'identifier') return node.text === 'method' || node.text === 'httpMethod'; + if (node.type === 'member_expression') { + const property = node.childForFieldName('property'); + return property !== null && (property.text === 'method' || property.text === 'httpMethod'); + } + return false; +} + +/** + * The HTTP verb an equality comparison asserts, if it is one — `req.method === + * 'GET'` → `GET`. Case-normalised, so `'get'` works too. + */ +function verbFromComparison(node: SyntaxNode): string | null { + if (node.type !== 'binary_expression') return null; + const operator = node.childForFieldName('operator')?.text ?? ''; + if (!EQUALITY_OPERATORS.has(operator)) return null; + const left = node.childForFieldName('left'); + const right = node.childForFieldName('right'); + if (left === null || right === null) return null; + + for (const [expr, literal] of [ + [left, right], + [right, left], + ] as const) { + if (!isMethodExpression(expr)) continue; + const value = literalValue(literal); + if (value === null) continue; + const verb = value.toUpperCase(); + if (HTTP_VERBS.has(verb)) return verb; + } + return null; +} + +/** + * Find the verb that governs a path comparison, by walking outward. + * + * Two idioms, both common and both handled: + * `if (req.method === 'GET' && pathname === '/x')` — a sibling in the same + * condition; and + * `if (req.method === 'GET') { if (pathname === '/x') … }` — an enclosing + * guard. + * + * The walk stops at the function boundary, and REFUSES to inherit a verb from an + * `if` whose `else` branch we are standing in: in + * `if (req.method === 'POST') {…} else if (pathname === '/x')` the path + * comparison is reached precisely when the method is NOT POST, so attributing + * POST to it would be exactly backwards. + */ +function governingVerb(comparison: SyntaxNode): string | null { + let current: SyntaxNode = comparison; + let parent = current.parent; + + while (parent !== null && !FUNCTION_NODE_TYPES.has(parent.type)) { + if ( + parent.type === 'binary_expression' && + parent.childForFieldName('operator')?.text === '&&' + ) { + const sibling = + parent.childForFieldName('left')?.id === current.id + ? parent.childForFieldName('right') + : parent.childForFieldName('left'); + const verb = sibling === null ? null : findVerbInSubtree(sibling); + if (verb !== null) return verb; + } + if (parent.type === 'if_statement') { + const alternative = parent.childForFieldName('alternative'); + const inElseBranch = alternative !== null && alternative.id === current.id; + const condition = parent.childForFieldName('condition'); + // A comparison inside the condition itself is handled by the `&&` rule + // above; here we only inherit from an ENCLOSING if we are governed by. + if (!inElseBranch && condition !== null && condition.id !== current.id) { + const verb = findVerbInSubtree(condition); + if (verb !== null) return verb; + } + } + current = parent; + parent = current.parent; + } + return null; +} + +/** First verb comparison anywhere in this subtree. */ +function findVerbInSubtree(node: SyntaxNode): string | null { + // A verb under a `!` is the verb the branch EXCLUDES. Returning null keeps the + // route (the path evidence is unaffected) and leaves it verb-less, which is + // the honest answer: this branch does not tell us which method it serves. + if (isNegation(node)) return null; + const direct = verbFromComparison(node); + if (direct !== null) return direct; + for (const child of node.namedChildren) { + const found = findVerbInSubtree(child); + if (found !== null) return found; + } + return null; +} + +/** + * The name of the function containing this comparison — the route's handler. + * + * Covers the declared forms and the two anonymous ones that carry a name from + * their binding site: `const handle = (req) => …` and the object-literal method + * shorthand (`{ async handle(req, res) {…} }`), which is how the reporting + * repo's route modules are written. + */ +function enclosingHandlerName(node: SyntaxNode): string | undefined { + let current: SyntaxNode | null = node.parent; + while (current !== null) { + if (FUNCTION_NODE_TYPES.has(current.type)) { + const own = current.childForFieldName('name'); + if (own !== null) return own.text; + const parent = current.parent; + if (parent === null) return undefined; + if (parent.type === 'variable_declarator' || parent.type === 'pair') { + const bound = parent.childForFieldName('name') ?? parent.childForFieldName('key'); + return bound?.text; + } + if (parent.type === 'assignment_expression') { + const left = parent.childForFieldName('left'); + if (left === null) return undefined; + return left.type === 'member_expression' + ? (left.childForFieldName('property')?.text ?? undefined) + : left.text; + } + return undefined; + } + current = current.parent; + } + return undefined; +} + +/** + * Convert an anchored regex used as a path test into a route path, or `null` if + * any part of it is not cleanly representable. + * + * `^\/api\/research-runs\/[^/]+$` → `/api/research-runs/{param}` + * + * Only two wildcard atoms are recognised, both single-segment (`[^/]+` and + * `[^/]*`, with or without the slash escaped). Anything else — an optional + * group, an alternation, a bare `.*` — bails, because a route path is a claim + * about what the server serves and a mistranslated pattern is a wrong one. + */ +export function regexToRoutePath(source: string): string | null { + if (!source.startsWith('^') || !source.endsWith('$')) return null; + const body = source.slice(1, -1); + if (body.length === 0) return null; + + let out = ''; + let i = 0; + let paramIndex = 0; + while (i < body.length) { + const rest = body.slice(i); + const wildcard = /^\[\^\\?\/\][+*]/.exec(rest); + if (wildcard !== null) { + paramIndex += 1; + out += `{param${paramIndex}}`; + i += wildcard[0].length; + continue; + } + const char = body[i] ?? ''; + if (char === '\\') { + const escaped = body[i + 1]; + if (escaped === undefined) return null; + // Only escapes of literal path punctuation are meaningful here; an escape + // class (`\d`, `\w`, `\s`) is a pattern, not a literal. + if (/[A-Za-z0-9]/.test(escaped)) return null; + out += escaped; + i += 2; + continue; + } + if ('[](){}|+*?^$.'.includes(char)) return null; + out += char; + i += 1; + } + return out.startsWith('/') ? out : null; +} + +/** A route the walk found, before per-file reconciliation. */ +interface GuardRoute { + readonly url: string; + readonly verb: string | null; + readonly handlerName: string | undefined; + readonly line: number; +} + +/** + * Extract routes declared by path-comparison dispatch from one JS/TS file. + * + * Returns the same {@link ExtractedDecoratorRoute} transport every AST-level + * route extractor returns — a route is a route once it has a path, a verb and a + * handler, and reusing the transport means the routes phase, the `(method, url)` + * dedup and the handler-symbol resolution all apply unchanged. `source` + * distinguishes the provenance, which is the part that actually differs: a + * decorator route is DECLARED, a dispatch-guard route is INFERRED from a + * comparison. + */ +export function extractDispatchGuardRoutes( + tree: Parser.Tree, + filePath: string, + lineOffset = 0, +): ExtractedDecoratorRoute[] { + // Every JS/TS file in every repo reaches this hook, so the walk is gated on a + // substring test first — see PATH_TOKEN_HINT for why skipping is sound. + if (!PATH_TOKEN_HINT.test(tree.rootNode.text)) return []; + + const found: GuardRoute[] = []; + + const constants = buildConstantMap(tree.rootNode); + + const visit = (node: SyntaxNode): void => { + if (node.type === 'binary_expression') collectFromComparison(node, found, constants); + else if (node.type === 'call_expression') collectFromRegexTest(node, found); + else if (node.type === 'switch_statement') collectFromSwitch(node, found, constants); + for (const child of node.namedChildren) visit(child); + }; + visit(tree.rootNode); + + return dedupeWithinFile(found).map((route) => ({ + filePath, + routePath: route.url, + httpMethod: route.verb ?? '', + decoratorName: DISPATCH_GUARD_SOURCE, + source: DISPATCH_GUARD_SOURCE, + lineNumber: route.line + lineOffset, + ...(route.handlerName ? { handlerName: route.handlerName } : {}), + })); +} + +function collectFromComparison(node: SyntaxNode, out: GuardRoute[], constants: ConstantMap): void { + // Reached only when the comparison is FALSE — claiming the path would be + // exactly backwards. See `isNegatedContext`. + if (isNegatedContext(node)) return; + const operator = node.childForFieldName('operator')?.text ?? ''; + if (!EQUALITY_OPERATORS.has(operator)) return; + const left = node.childForFieldName('left'); + const right = node.childForFieldName('right'); + if (left === null || right === null) return; + + for (const [expr, literal] of [ + [left, right], + [right, left], + ] as const) { + if (!isPathExpression(expr)) continue; + const value = literalValue(literal, constants); + if (value === null || !isPathLiteral(value)) continue; + const verb = governingVerb(node); + // A bare `/` is only a route when a verb says so — see the module header. + if (value === '/' && verb === null) continue; + out.push({ + url: value, + verb, + handlerName: enclosingHandlerName(node), + line: node.startPosition.row + 1, + }); + return; + } +} + +/** + * `switch (pathname) { case '/api/health': … }` — the other way to write the + * same dispatch, and the reason this module is not a rule about `if`. The + * discriminant carries the path signal for every arm at once, so each + * string-literal case is a route with no further evidence needed. + * + * Not reported by anyone; included because it is the same shape wearing + * different syntax, and waiting for a bug report per shape is how a graph stays + * permanently one idiom behind the code it indexes. + */ +function collectFromSwitch(node: SyntaxNode, out: GuardRoute[], constants: ConstantMap): void { + // The grammar wraps a switch discriminant in `parenthesized_expression`, + // unlike a comparison operand. + const discriminant = unparenthesize(node.childForFieldName('value')); + if (discriminant === null || !isPathExpression(discriminant)) return; + + const body = node.childForFieldName('body'); + if (body === null) return; + + // The verb governing the whole switch, if any (`if (req.method === 'GET') + // switch (pathname) { … }`). Read once — every arm shares it. + const verb = governingVerb(node); + + for (const arm of body.namedChildren) { + if (arm.type !== 'switch_case') continue; + const caseValue = arm.childForFieldName('value'); + if (caseValue === null) continue; + const value = literalValue(caseValue, constants); + if (value === null || !isPathLiteral(value)) continue; + if (value === '/' && verb === null) continue; + out.push({ + url: value, + verb, + handlerName: enclosingHandlerName(arm), + line: arm.startPosition.row + 1, + }); + } +} + +function collectFromRegexTest(node: SyntaxNode, out: GuardRoute[]): void { + if (isNegatedContext(node)) return; + const callee = node.childForFieldName('function'); + if (callee === null || callee.type !== 'member_expression') return; + if (callee.childForFieldName('property')?.text !== 'test') return; + const receiver = callee.childForFieldName('object'); + if (receiver === null || receiver.type !== 'regex') return; + + const argument = node.childForFieldName('arguments')?.namedChildren[0]; + if (argument === undefined || !isPathExpression(argument)) return; + + const pattern = receiver.childForFieldName('pattern'); + if (pattern === null) return; + const url = regexToRoutePath(pattern.text); + if (url === null) return; + + out.push({ + url, + verb: governingVerb(node), + handlerName: enclosingHandlerName(node), + line: node.startPosition.row + 1, + }); +} + +/** + * Collapse duplicate `(url, verb)` findings within one file, keeping the first — + * matching the routes phase's own first-writer-wins. The same comparison can + * legitimately appear more than once (an early-return guard and the branch that + * serves it), and each occurrence is the same route. + * + * The verb-less/verb-qualified reconciliation is deliberately NOT here — see + * {@link reconcileDispatchGuardRoutes}, which needs the whole repo to do it. + */ +function dedupeWithinFile(routes: readonly GuardRoute[]): GuardRoute[] { + const seen = new Set(); + const out: GuardRoute[] = []; + for (const route of routes) { + const key = `${route.verb ?? ''} ${route.url}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(route); + } + return out; +} + +/** The minimum a route needs for reconciliation — structural, not nominal. */ +interface ReconcilableRoute { + readonly routePath: string; + readonly httpMethod: string; + readonly source?: string; +} + +/** + * Drop a dispatch-guard route whose URL is claimed WITH a verb somewhere in the + * repository. + * + * The idiom that makes this necessary is the split route table: one module lists + * every path it recognises (`isKnownApiPath`, or a `match(method, pathname)` + * that ORs them all) so the dispatcher can 404 early, and separate modules + * handle each path by verb. Both are path comparisons and both are real, but + * only the second is a route in the sense `route_map` reports — the first is a + * membership test. + * + * Left alone this doubles the map: measured on the reporting repo, 94 routes of + * which 34 were the table's verb-less shadow of a route already listed with its + * verb and its true handler. Reconciling per-FILE cannot see it, because the + * table and the handlers are different files; only the whole registry can. + * + * Applies to dispatch-guard routes only. A framework route with no verb is + * method-agnostic BY DECLARATION (a Django function view, a Laravel resource), + * which is a fact rather than a weaker observation, and must not be dropped. + */ +export function reconcileDispatchGuardRoutes( + routes: readonly T[], +): T[] { + const verbedUrls = new Set( + routes + .filter((r) => r.source === DISPATCH_GUARD_SOURCE && r.httpMethod !== '') + .map((r) => r.routePath), + ); + if (verbedUrls.size === 0) return [...routes]; + return routes.filter( + (r) => + !(r.source === DISPATCH_GUARD_SOURCE && r.httpMethod === '' && verbedUrls.has(r.routePath)), + ); +} diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts index a155bfe71..5099508e3 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts @@ -278,6 +278,21 @@ export const LINKABLE_LABELS: ReadonlySet = new Set([ // IMPLEMENTS edges from classes to traits are otherwise invisible to // the scope-resolution MRO pass. 'Trait', + // TypeAlias is linkable for the same reason Trait is (R2-2). The alias + // resolves fine — `CLASS_KINDS` has always listed it, and the ClassRegistry + // returns the def — but without an entry here `resolveDefGraphId` cannot + // bridge that def to its graph node, so the edge is dropped after a + // SUCCESSFUL lookup. That is why an exported contract type owned its members + // and still reported `incoming: {}`: the failure was one table away from + // everything that appeared to be responsible. + // + // Covers every language that spells an alias this way — TypeScript, Kotlin, + // Dart and Rust all emit `@declaration.type_alias`. The remaining + // `CLASS_KINDS` entries (Typedef, Record, Union, Delegate, Annotation, + // Template) plausibly have the same gap, but nothing exercises them today + // and adding labels no test covers is how this list drifts out of sync with + // what it claims. + 'TypeAlias', // Variable / Property are linkable too — receiver-bound write/read // ACCESSES edges target field nodes (e.g. `user.name = "x"` → // ACCESSES edge to User's `name` Variable/Property node). diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/references-to-edges.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/references-to-edges.ts index e20f65515..7b0560b22 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/references-to-edges.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/references-to-edges.ts @@ -25,6 +25,7 @@ import { resolveCallerGraphId, resolveDefGraphId } from '../graph-bridge/ids.js' import { mapReferenceKindToEdgeType } from '../graph-bridge/edges.js'; import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js'; import type { CalleeIdSink } from '../graph-bridge/callee-id-sink.js'; +import { isValueDefinitionLabel } from '../../utils/ast-helpers.js'; /** * Optional opaque skip key — providers may pre-emit edges (e.g. via @@ -35,6 +36,13 @@ import type { CalleeIdSink } from '../graph-bridge/callee-id-sink.js'; */ type ReferenceSiteSkipSet = ReadonlySet; +/** + * Value labels whose defs MAY be function-local. A reference to one of these is + * dropped only when the def is positively identified as living inside a function + * body — see `functionLocalValueDefIds`. Everything else, including a class + * member in a language that keeps no values at module scope, is emitted. + */ + export function emitReferencesViaLookup( graph: KnowledgeGraph, scopes: ScopeResolutionIndexes, @@ -45,6 +53,29 @@ export function emitReferencesViaLookup( * `--pdg`; `undefined` ⇒ zero overhead, byte-identity (R4). Captured at the * CALLS emit below BEFORE this loop's `seen` dedup (KTD6/R8). */ calleeIdSink?: CalleeIdSink, + /** + * Def ids of value symbols bound inside a FUNCTION body. When supplied, a + * read/write whose target is a `Const`/`Variable`/`Static` in this set emits + * no edge. + * + * Bare-identifier reads (A2) made module-scope constants answerable, but the + * same capture also matches a read of a BLOCK-LOCAL `const`. An edge to one + * of those keeps alive precisely the inert local symbols `pruneLocalSymbols` + * exists to drop — turning a pruned node into a retained node plus an edge, + * in every function of every indexed repo. "Who uses this constant?" is a + * question about a module's surface; a local's uses are the three lines + * around it. + * + * A BLOCKLIST, not an allowlist, and the direction is the point. Asking + * "is this def module-level?" silently excludes class members — Java/C# + * fields, Python class attributes — which are neither module-level nor local. + * Asking "is this def function-local?" excludes only what it can positively + * identify, so an unrecognised or uninspected def is emitted. A stray inert + * local is recoverable; a deleted edge class reads as "nothing uses this". + * + * Optional so callers that never capture bare identifiers are unchanged. + */ + functionLocalValueDefIds?: ReadonlySet, ): { emitted: number; skipped: number } { let emitted = 0; let skipped = 0; @@ -85,6 +116,17 @@ export function emitReferencesViaLookup( continue; } + // Function-local value reference — see `functionLocalValueDefIds`. + if ( + functionLocalValueDefIds !== undefined && + edgeType === 'ACCESSES' && + isValueDefinitionLabel(targetDef.type) && + functionLocalValueDefIds.has(targetDef.nodeId) + ) { + skipped++; + continue; + } + // Resolved-callee-id capture (#2227 U2/KTD6/R8): record this CALLS site's // resolved target BEFORE the `seen` dedup, keyed on `ref.atRange` // (byte-equal to U1's SiteRecord.at: 1-based line / 0-based col). Only diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/imported-value-refs.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/imported-value-refs.ts new file mode 100644 index 000000000..6e47b8b88 --- /dev/null +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/imported-value-refs.ts @@ -0,0 +1,89 @@ +/** + * Cross-file value references, resolved post-finalize (A2). + * + * `resolveReferenceSites` runs against the registries, and — as its own + * comment says — "imports live in finalized bindings the registries can't + * see". That is why free CALLS need `emitFreeCallFallback`. Reads had no + * equivalent, so a module-scope `const` read from another file resolved to + * nothing: `import { DEFAULT_FETCH_LIMIT } from './config.js'` followed by a + * bare use produced no edge, while a CALL through the very same import + * statement resolved fine. "Who imports this constant?" — the question behind + * every constants refactor and dead-code trim — was unanswerable across files. + * + * This is the read/write counterpart to that call fallback, and it reuses the + * walker built for finalized bindings rather than inventing a lookup. + * + * DELIBERATELY CROSS-FILE ONLY. Same-file reads already resolve through the + * registries, so re-resolving them here would add nothing — and would add + * something unwanted: `findValueBindingInScope` accepts `Const`/`Variable`, + * which includes BLOCK-LOCAL values. Emitting an edge to one of those keeps + * alive exactly the inert local symbols `pruneLocalSymbols` exists to drop, + * inflating every indexed repo. A def in another file cannot be a block-local + * of this one, so the file guard is what keeps this pass proportional. + */ + +import type { ParsedFile } from 'gitnexus-shared'; +import type { KnowledgeGraph } from '../../../graph/types.js'; +import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js'; +import { tryEmitEdge } from '../graph-bridge/edges.js'; +import { findValueBindingInScope } from '../scope/walkers.js'; +import { callableFlowSiteKey } from './callable-value-flow.js'; + +/** + * Confidence for a reference resolved through a finalized import binding. + * This is a PRECISE resolution — the import names the def — so it carries the + * ordinary emission confidence, not the reduced tier used for name inference. + */ +const IMPORTED_VALUE_CONFIDENCE = 0.9; + +export interface ImportedValueRefStats { + /** Cross-file value references resolved through finalized bindings. */ + readonly emitted: number; +} + +export function emitImportedValueReferences( + graph: KnowledgeGraph, + indexes: ScopeResolutionIndexes, + parsedFiles: readonly ParsedFile[], + nodeLookup: GraphNodeLookup, + /** Sites an earlier pass already owns — never re-resolved here. */ + skipSites: ReadonlySet, +): ImportedValueRefStats { + let emitted = 0; + const seen = new Set(); + + for (const parsed of parsedFiles) { + for (const site of parsed.referenceSites) { + if (site.kind !== 'read' && site.kind !== 'write') continue; + // A member read (`obj.field`) is the receiver-bound passes' business; + // this pass exists for the BARE identifier an import binds. + if (site.explicitReceiver !== undefined) continue; + const siteKey = callableFlowSiteKey(parsed.filePath, site.atRange); + if (skipSites.has(siteKey)) continue; + + const def = findValueBindingInScope(site.inScope, site.name, indexes); + if (def === undefined) continue; + // See the header: same-file hits are already resolved, and accepting + // them here would start emitting edges to block-local values. + if (def.filePath === parsed.filePath) continue; + + if ( + tryEmitEdge( + graph, + indexes, + nodeLookup, + site, + def, + 'import-resolved', + seen, + IMPORTED_VALUE_CONFIDENCE, + ) + ) { + emitted++; + } + } + } + + return { emitted }; +} diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/return-shape-members.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/return-shape-members.ts new file mode 100644 index 000000000..1ffb2da65 --- /dev/null +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/return-shape-members.ts @@ -0,0 +1,233 @@ +/** + * PRECISE member resolution through a call result's RETURN SHAPE (R3-5). + * + * The last unanswered question from three rounds of blind-spot reports was + * "who reads `wickRatio`?", where the field is produced by several functions + * that each return an anonymous object containing it. Name inference must + * refuse that — a read of `spike.wickRatio` could mean any producer, and a + * wrong edge in the pre-edit safety gate is worse than a missing one — so no + * amount of narrowing gets there. It needs EVIDENCE instead of inference. + * + * The evidence already exists in two halves that had never been joined: + * + * 1. The call-result type binding. `const alert = formatSpikeAlert(row)` + * binds `alert` to a `TypeRef` whose `rawName` is the callee. That + * machinery predates this work; it simply had nothing to resolve to when + * the callee returned an anonymous literal, because an anonymous literal + * named nothing. + * 2. R3-4 gave it a name. A returned literal's keys are now owned by the + * producing function, so `formatSpikeAlert.wickRatio` is a real symbol. + * + * Joining them turns a refusal into a precise answer: + * + * const alert = formatSpikeAlert(row); + * alert.wickRatio → Property:…:formatSpikeAlert.wickRatio + * + * and it works for exactly the case narrowing cannot: several producers sharing + * a field name are no longer competitors, because the receiver says WHICH one. + * That is why this runs before the unique-name fallback and registers its sites + * as handled — a precise answer must never be second-guessed by a name match. + * + * BOUND, deliberately. This only fires where the value is BOUND to a name the + * type binding could attach to. A field read off a bare parameter + * (`function f(spike) { return spike.wickRatio }`) still has no receiver type + * here, because typing it requires the CALLER's type to flow in — that is + * inter-procedural and genuinely larger. Those reads keep falling through to + * name inference, and keep being reported when it declines. + */ + +import type { ParsedFile } from 'gitnexus-shared'; +import type { KnowledgeGraph } from '../../../graph/types.js'; +import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js'; +import { resolveCallerGraphId } from '../graph-bridge/ids.js'; +import { findCallableBindingInScope, findReceiverTypeBinding } from '../scope/walkers.js'; +import { callableFlowSiteKey } from './callable-value-flow.js'; +import type { PropertyNameIndex } from './unique-name-properties.js'; + +/** + * Confidence for a return-shape member. This is a PRECISE resolution — the + * receiver's binding names the producing function and the member is owned by + * it — so it carries the ordinary emission confidence, not the reduced tier + * name inference uses. Nothing here is guessed. + */ +const RETURN_SHAPE_CONFIDENCE = 0.9; + +const EDGE_REASON = 'scope-resolution: return-shape member'; + +export interface ReturnShapeMemberStats { + /** ACCESSES edges resolved through a call result's return shape. */ + readonly emitted: number; + /** + * Sites where the receiver WAS typed to a producer but that producer owns no + * member of this name. Reported rather than dropped: it means the read and + * the shape disagree, which is either a stale field name or a producer this + * pass mis-attributed, and both are worth seeing. + */ + readonly memberNotOnShape: number; +} + +/** + * Does this Property node id name `.`? + * + * Ids carry an optional position suffix for function-local symbols + * (`…:buildFlat.field@33:4`), so the owner segment is matched up to a `@` or + * the end rather than by equality. + */ +function idNamesMember(id: string, owner: string, member: string): boolean { + const needle = `:${owner}.${member}`; + const at = id.indexOf(needle); + if (at === -1) return false; + const after = id.slice(at + needle.length); + return after.length === 0 || after.startsWith('@'); +} + +export function emitReturnShapeMemberAccesses( + graph: KnowledgeGraph, + indexes: ScopeResolutionIndexes, + parsedFiles: readonly ParsedFile[], + nodeLookup: GraphNodeLookup, + /** Sites a precise pass already owns — never re-resolved here. */ + skipSites: ReadonlySet, + propertyNameIndex: PropertyNameIndex, + /** Sites this pass resolves, so the name fallback leaves them alone. */ + handledSink: Set, +): ReturnShapeMemberStats { + let emitted = 0; + let memberNotOnShape = 0; + const seen = new Set(); + + // The files of the language being resolved. `parsedFiles` is already scoped to + // it, so this needs no new plumbing — it is the same restriction the sibling + // unique-name pass gets from `candidatesForLanguage`. + // + // The file guard below is not sufficient on its own, and the reason is worth + // keeping: a receiver typed by CONSTRUCTION (`const cfg = new Loyalty()`) + // resolves `Loyalty` through the shared class registry, which is polyglot. The + // producer then legitimately resolves to `Loyalty.java`, its members + // legitimately live in that same file, and a file-equality check waves the + // cross-language edge straight through. Restricting to the current language's + // own files is what actually closes it. + const ownFilePaths = new Set(parsedFiles.map((p) => p.filePath)); + + for (const parsed of parsedFiles) { + for (const site of parsed.referenceSites) { + if (site.kind !== 'read' && site.kind !== 'write') continue; + const receiver = site.explicitReceiver?.name; + if (receiver === undefined || receiver.length === 0) continue; + const siteKey = callableFlowSiteKey(parsed.filePath, site.atRange); + if (skipSites.has(siteKey)) continue; + + // The receiver's binding names the PRODUCER, not a class. That is the + // whole point: `formatSpikeAlert` is a function, and before R3-4 there + // was nothing named after it to look a member up on. + const typeRef = findReceiverTypeBinding(site.inScope, receiver, indexes); + const producerRef = typeRef?.rawName; + if (producerRef === undefined || producerRef.length === 0) continue; + + // R3-4 qualifies a returned key by the producing function's own name, so + // the owner segment to match is the LAST one. For a plain producer this is + // a no-op. + // + // A MEMBER-CALL producer (`const r = svc.make()`) binds `svc.make`, and + // that spelling resolves to no value binding below, so this pass DECLINES + // rather than resolving it. That is a known coverage limit, not a fix: + // answering it means typing `svc` first and then finding `make` on that + // type, which is a different (and larger) piece of work. Declining is the + // correct behaviour in the meantime — the alternative, matching + // `make.` by name across the graph, is precisely the fabrication + // the file guard below exists to stop. + const producer = producerRef.slice(producerRef.lastIndexOf('.') + 1); + if (producer.length === 0) continue; + + // Resolve the producer to a real definition and keep only members that + // live in ITS file. + // + // Without this the join is textual over a whole-graph index: any node + // whose id happens to read `.` matches, in any file and + // any LANGUAGE. Measured, that fabricated a 0.9-confidence edge from a JS + // component to a Java field — and 0.9 is the precise tier, so a + // `minConfidence` floor cannot filter it out. The sibling unique-name pass + // was given a per-language restriction for exactly this; this pass + // consumes the same shared index and had none. + // + // The file identity is the evidence, not a heuristic: R3-4 anchors a + // returned literal's keys to the function that returns them, so the + // member's node necessarily sits in the same file as that function. A + // candidate elsewhere is a different symbol wearing the same name. + // A CALLABLE lookup, not a value one: the producer is the function whose + // return shape owns the member. It also resolves through finalized import + // bindings, so a producer imported from another file still yields its own + // file — the guard restricts to the RIGHT file, it does not force same-file. + // Three guards, and they catch different shapes — none is redundant: + // + // producerDef — the producer must RESOLVE. This is the one that stops + // the measured cross-language leak: `new Loyalty()` in JS + // yields `producerRef = 'Loyalty'`, and a Java class does + // not resolve as a callable from a JS scope, so the pass + // declines instead of name-matching into `Loyalty.java`. + // Mutation-verified by `polyglot-property-isolation`. + // filePath — among same-named producers, keep the members of the one + // actually resolved. Defence in depth for the case where + // the producer DOES resolve and a same-named function + // exists in another file. + // ownFilePaths — a receiver typed by construction resolves through the + // shared, POLYGLOT class registry, so a producer can + // resolve into another language with its members + // legitimately in that same file. File equality passes + // there; only the language restriction closes it. + const producerDef = findCallableBindingInScope(site.inScope, producerRef, indexes); + const producerFile = producerDef?.filePath; + if (producerFile === undefined) continue; + if (!ownFilePaths.has(producerFile)) continue; + + const candidates = propertyNameIndex.get(site.name); + if (candidates === undefined) continue; + const owned = candidates.filter( + (c) => c.filePath === producerFile && idNamesMember(c.id, producer, site.name), + ); + // Exactly one, or nothing. Two nodes claiming `.` would + // mean the id qualifier failed to separate them, and picking between them + // would be the guess this pass exists to avoid. + if (owned.length !== 1) { + if (owned.length === 0) { + memberNotOnShape++; + // CLAIM THE SITE ANYWAY. This branch is the strongest NEGATIVE + // evidence the pipeline can produce: the receiver is typed to a + // producer, that producer's shape is known, and it owns no member of + // this name. Falling through let the 0.5 name fallback answer a + // question the precise pass had just DISPROVED — measured, it linked + // a read to an unrelated same-named key in another file. Disproving a + // member and then inventing it one pass later is worse than either + // answer alone. + handledSink.add(siteKey); + } + continue; + } + const target = owned[0]!; + + const callerGraphId = resolveCallerGraphId(site.inScope, indexes, nodeLookup, site.atRange); + if (callerGraphId === undefined) continue; + if (callerGraphId === target.id) continue; + + const dedupKey = `ACCESSES:${callerGraphId}->${target.id}:${site.atRange.startLine}:${site.atRange.startCol}`; + if (seen.has(dedupKey)) continue; + seen.add(dedupKey); + + graph.addRelationship({ + id: `rel:${dedupKey}`, + sourceId: callerGraphId, + targetId: target.id, + type: 'ACCESSES', + confidence: RETURN_SHAPE_CONFIDENCE, + reason: `${EDGE_REASON}: ${site.kind}`, + evidence: [], + }); + // Claim the site so the name fallback cannot re-answer it differently. + handledSink.add(siteKey); + emitted++; + } + } + + return { emitted, memberNotOnShape }; +} diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/unique-name-properties.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/unique-name-properties.ts new file mode 100644 index 000000000..cce98f126 --- /dev/null +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/unique-name-properties.ts @@ -0,0 +1,545 @@ +/** + * Last-resort property resolution by UNIQUE NAME (A1/A5). + * + * Idiomatic JS reads configuration off a plain object whose receiver cannot be + * typed — an options bag passed as a parameter, a destructured handle, an + * imported literal. The precise passes resolve none of those, so a field read + * and written across a live code path produced no `ACCESSES` edge at all and + * "who reads this setting?" answered a confident zero. + * + * This pass runs AFTER every precise pass and only sees what they left behind. + * For each still-unresolved read/write site it asks one question: does exactly + * ONE `Property` node in the graph carry this name? If so the read almost + * certainly means it, and an edge is emitted at REDUCED CONFIDENCE with a + * reason that names the inference. If two or more carry the name, nothing is + * emitted — a guess between them would be a coin flip, and a wrong edge in the + * pre-edit safety gate is worse than a missing one. + * + * Why uniqueness is the right gate: the names this recovers are the ones worth + * recovering. Distinctive domain fields (`exitMinAtrMult`, `bookNotionalUsdt`) + * are unique in a repo and resolve; generic keys (`id`, `name`, `data`) are not + * and are skipped, which is exactly where name matching would over-connect. + * That is the `fieldFallbackOnMethodLookup` trade this codebase already accepts + * for dynamic languages, bounded so it cannot fire on the ambiguous majority. + * + * Confidence is 0.5 — the same tier the 3-tier import resolver assigns its + * global fallback, because this is the same kind of claim: a name matched + * workspace-wide with no scope evidence behind it. + * + * ── R2: workspace uniqueness is too blunt on its own ──────────────────────── + * + * Measured on the repo this pass was written for: `exitMinAtrMult` has 26 + * `Property` definitions — 16 of them in one-off `scripts/`, 7 in the frontend, + * one in a test, and exactly ONE in the backend that actually reads it. Strict + * uniqueness declined every backend read because research scripts the backend + * has no relationship with each carry a same-named key. The gate was not + * wrong, it was scope-blind: it compared against the whole workspace when the + * reader can only plausibly mean something it can SEE. + * + * So a name with several definitions is now narrowed before being abandoned: + * Tier 1 — a definition in the READING FILE itself. + * Tier 2 — a definition in a file the reading file DIRECTLY IMPORTS. + * Exactly one survivor at the first non-empty tier resolves; anything else is + * still refused. Narrowing uses the finalized import graph, so it is real + * evidence rather than a path-shape heuristic, and it is language-neutral. + * + * A tier that finds SEVERAL candidates stops the walk instead of falling + * through to the next one. Two same-named keys in the reading file mean the + * read is genuinely ambiguous where the reader is standing; reaching past them + * to an imported file would answer a question the local evidence already + * contradicts. + * + * Confidence stays 0.5 for every tier. Narrowing improves which candidate is + * chosen, not the kind of claim being made — it is still a name match, and the + * round-1 contract is that a consumer filtering on confidence can drop all + * name inference without dropping scope-resolved edges. The reason string + * records which tier fired. + * + * WHY GRAPH NODES, NOT SCOPE DEFS: an object-literal key mints a `Property` + * NODE (parse query) but no scope-resolution DEF, so `scope.bindings` and + * `localDefs` are both empty for exactly the population this pass exists to + * serve. Indexing the graph is therefore not a shortcut — it is the only place + * these symbols exist. It also means the pass emits straight to the graph + * rather than through `tryEmitEdge`, whose target side takes a def. + */ + +import type { ImportEdge, ParsedFile, ScopeId } from 'gitnexus-shared'; +import type { KnowledgeGraph } from '../../../graph/types.js'; +import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js'; +import { resolveCallerGraphId } from '../graph-bridge/ids.js'; +import { callableFlowSiteKey } from './callable-value-flow.js'; +import { isTestFile } from '../../entry-point-scoring.js'; +import { getLanguageFromFilename } from 'gitnexus-shared'; + +/** Language a definition lives in, for reporting which anchor a reader cannot reach. */ +function languageOf(filePath: string): string { + return getLanguageFromFilename(filePath) ?? 'unknown'; +} + +/** + * Confidence for a workspace-unique name match. Deliberately the global tier's + * 0.5 and not the 0.85 default: a consumer filtering on confidence must be able + * to drop these without dropping scope-resolved edges. + */ +const UNIQUE_NAME_CONFIDENCE = 0.5; + +const EDGE_REASON = 'scope-resolution: unique-name property'; + +/** + * Most candidates any one name will keep for narrowing. A name carried by more + * definitions than this is a generic key (`id`, `type`, `value`) that no tier + * is going to disambiguate, so the candidate list is dropped and the name is + * treated as ambiguous outright. This is what keeps the index from + * materializing a long array per generic key in a large repo — the concern + * that made the original implementation store a sentinel instead of a list. + */ +const MAX_TRACKED_CANDIDATES = 32; + +/** Sentinel for "too many nodes carry this name to narrow" — never resolved. */ +const OVERSATURATED = null; + +interface PropertyCandidate { + readonly id: string; + readonly filePath: string; + /** + * True when this definition is the RETURN SHAPE of a function (R3-4) rather + * than a declared surface — a named object literal, a class field, an + * interface or alias member. + * + * Return shapes are the weaker anchor and are ranked below declared ones, so + * adding them cannot change an answer that already resolved. That is what + * reconciles this with R2-1b, which deliberately modelled returned keys as + * WRITES to avoid adding same-named competitors to narrowing: they are + * definitions now, but they never outrank a real declaration, so the + * competitor problem it was avoiding does not come back. + */ + readonly fromReturnShape: boolean; +} + +/** + * The only part of the finalized scope model this pass reads. Narrowed to a + * structural type so the pass does not depend on the full finalize result. + */ +interface FinalizedImportView { + readonly imports: ReadonlyMap; +} + +/** Most distinct names reported back; enough to act on, bounded for logs. */ +const MAX_REPORTED_AMBIGUOUS_NAMES = 25; + +export interface UniqueNamePropertyStats { + /** Edges emitted from a name match at any tier. */ + readonly emitted: number; + /** + * Sites skipped because the name could not be narrowed to one definition, + * so a match would have been a coin flip. Reported rather than silently + * dropped: this is the population a receiver-typing improvement would + * convert into precise edges. + */ + readonly ambiguous: number; + /** + * Of {@link emitted}, how many needed scope narrowing — the name carried + * several definitions and same-file or direct-import evidence picked one. + * Strict workspace uniqueness would have refused every one of these. + */ + readonly narrowed: number; + /** + * The distinct names behind {@link ambiguous}, capped. A bare count says a + * gap exists; the names say WHICH fields are unanswerable, which is the + * difference between a metric and something a reader can act on. + */ + readonly ambiguousNames: readonly string[]; + /** + * Read/write sites whose name IS defined in the workspace, but only in + * ANOTHER language — so per-language inference correctly declined, and the + * caller got an empty result byte-identical to "this field is unused". + * + * Keeping this separate from {@link ambiguous} matters: ambiguity means the + * analyzer saw several candidates and refused to choose, while this means it + * saw candidates it was not allowed to consider. The remedies differ — one + * wants better receiver typing, the other wants an anchor in this language + * (or a text search) — so collapsing them would tell a reader the wrong thing + * to do. + */ + readonly crossLanguageOnly: number; + /** + * The distinct names behind {@link crossLanguageOnly}, capped, each with the + * languages its definitions actually live in. That is the actionable half: + * "wickRatio is defined only in TypeScript" tells a reader why their + * JavaScript query came back empty and what to do about it. + */ + readonly crossLanguageOnlyNames: readonly { + readonly name: string; + readonly languages: string[]; + }[]; +} + +/** + * Every `Property` node in the graph, grouped by name. + * + * WHOLE-GRAPH AND LANGUAGE-AGNOSTIC, so it is built ONCE by the caller and + * shared across every language pass — the same treatment `sharedNodeLookup` and + * `sharedFnNodeIndex` already get in `phase.ts`, and for the same reason: a + * per-language rebuild scans every node in the repo N times, and on a large + * repo a small language's full copy overlaps the next language's. + * + * Deliberately NOT capped here. The cap belongs after the language filter (see + * {@link candidatesForLanguage}) — a name carried by forty properties across a + * polyglot monorepo but only two in the language being resolved is answerable, + * and capping globally would refuse it. One entry per Property node is the same + * order as the node-lookup map built beside it. + */ +export type PropertyNameIndex = ReadonlyMap; + +export function buildPropertyNameIndex(graph: KnowledgeGraph): PropertyNameIndex { + const byName = new Map(); + for (const node of graph.iterNodes()) { + if (node.label !== 'Property') continue; + const name = node.properties.name; + if (typeof name !== 'string' || name.length === 0) continue; + const filePath = node.properties.filePath; + if (typeof filePath !== 'string') continue; + const candidate: PropertyCandidate = { + id: node.id, + filePath, + fromReturnShape: node.properties.fromReturnShape === true, + }; + const existing = byName.get(name); + if (existing === undefined) { + byName.set(name, [candidate]); + continue; + } + if (existing.some((c) => c.id === node.id)) continue; + existing.push(candidate); + } + return byName; +} + +/** + * The candidates a read in THIS language may consider, or {@link OVERSATURATED} + * when there are too many to disambiguate. + * + * SAME LANGUAGE ONLY. The graph is shared across every language in the repo, and + * `fieldFallbackOnMethodLookup` only decides whether this pass RUNS for a + * language — it never restricted which nodes could be TARGETS. So a Java backend + * declaring `private int loyaltyPoints` was the unique carrier of that name, and + * a JS frontend writing `cfg.loyaltyPoints` on an untyped parameter got an edge + * to it: no owner, file, or language evidence, and inference across a language + * boundary that has no call path at all. The confidence tier does not save it, + * since `minConfidence` defaults to 0. + * + * `parsedFiles` is exactly this language's file set, so matching on it is a + * precise restriction rather than a heuristic — no node property needed. + * + * The cap is applied HERE, to the filtered set, so it means what it says: this + * many same-named keys IN THE LANGUAGE BEING RESOLVED is a generic name no tier + * can disambiguate. + */ +function candidatesForLanguage( + all: readonly PropertyCandidate[], + ownFilePaths: ReadonlySet, +): readonly PropertyCandidate[] | null | undefined { + const mine: PropertyCandidate[] = []; + for (const candidate of all) { + if (!ownFilePaths.has(candidate.filePath)) continue; + if (mine.length >= MAX_TRACKED_CANDIDATES) return OVERSATURATED; + mine.push(candidate); + } + // Three outcomes, and they are not interchangeable: `undefined` means no + // property of this name exists in this language (nothing to say HERE, though + // see `crossLanguageAnchors` for why the CALLER still needs to know), `null` + // means too many to choose between (reportable), and a list means proceed to + // narrowing. + return mine.length === 0 ? undefined : mine; +} + +/** + * Files each file directly imports, from the FINALIZED import graph. + * + * Built from `finalized.imports` rather than the raw per-scope edges because + * only the finalized form has `targetFile` linked — pre-finalize the field is + * still null for anything the resolver had to look up, which would silently + * narrow every read to nothing. + */ +function buildDirectImportMap( + indexes: ScopeResolutionIndexes, + finalized: FinalizedImportView, +): ReadonlyMap> { + // Resolve each importing scope to its file by POINT LOOKUP on the scope tree, + // not by walking `parsed.scopes`. + // + // The out-of-core seal replaces `emitParsedFiles` with a scope-STRIPPED copy — + // `scopes: []` for every file — and that is the documented contract: after the + // seal, scopes are reachable only via `scopeTree.getScope`. Building the map + // from `parsed.scopes` therefore produced an EMPTY map under + // `GITNEXUS_DISK_SCOPE_INDEX=1`, every `directImports` lookup returned + // undefined, tier-2 narrowing died, and — worst of it — the loss was + // misreported as `ambiguous`: "several candidates, refused to choose" when the + // truth was "the evidence was discarded one function earlier". Same commit, + // same repo, two different graphs depending on an env var. + // + // This is the SECOND consumer of `parsed.scopes` on this branch to hit the + // seal. The first was hoisted above it; this one is converted to the point + // lookup instead, which is stronger — there is no ordering left to get wrong. + const byFile = new Map>(); + for (const [scopeId, edges] of finalized.imports) { + const fromFile = indexes.scopeTree.getScope(scopeId)?.filePath; + if (fromFile === undefined) continue; + for (const edge of edges) { + if (edge.targetFile === null || edge.targetFile === fromFile) continue; + let set = byFile.get(fromFile); + if (set === undefined) { + set = new Set(); + byFile.set(fromFile, set); + } + set.add(edge.targetFile); + } + } + return byFile; +} + +/** Is this Property id qualified by `.`, allowing a position suffix? */ +function idOwnedBy(id: string, owner: string): boolean { + const at = id.indexOf(`:${owner}.`); + if (at === -1) return false; + const rest = id.slice(at + owner.length + 2); + return !rest.includes('.') || rest.indexOf('@') < rest.indexOf('.'); +} + +/** Trailing symbol name of a graph id (`Function:a/b.js:buildB` -> `buildB`). */ +function simpleNameOfGraphId(graphId: string): string { + const tail = graphId.slice(graphId.lastIndexOf(':') + 1); + const at = tail.indexOf('@'); + return at === -1 ? tail : tail.slice(0, at); +} + +/** + * Pick the single candidate a read in `readingFile` can plausibly mean. + * + * Tiers are tried in order and the FIRST non-empty one decides — including + * deciding to refuse. A tier holding several candidates returns null rather + * than falling through, because local evidence that is itself ambiguous is + * still evidence: reaching past two same-named keys in the reading file to an + * imported third would answer a question the reader's own file contradicts. + */ +function narrowToSingleCandidate( + candidatesIn: readonly PropertyCandidate[], + readingFile: string, + importedFiles: ReadonlySet | undefined, + /** Simple name of the callable the site sits in, when it could be resolved. */ + ownerName?: string, +): { readonly id: string; readonly tier: string } | null { + // DECLARED ANCHORS FIRST. A return shape is a real definition but a weaker + // one: it says "some function builds an object with this key", where a named + // literal or a class/interface member says "this IS the field". Whenever both + // exist, the declared one is what a reader means — and ranking it first is + // what guarantees R3-4 cannot change an answer that already resolved before + // return shapes were indexed at all. + let candidates = candidatesIn; + + // A SITE INSIDE ITS OWN RETURN SHAPE BINDS TO ITS OWN KEY. + // + // `export function buildB(row) { return { tickIntervalMs: row.b } }` writes + // the key that IS `buildB.tickIntervalMs`. Ranking declared anchors above + // return shapes is right for a READ through a receiver, but applied here it + // handed that write to a same-named module const (`fnSettings.tickIntervalMs`) + // that `buildB` never touches — a wrong edge, reported at the confident tier, + // and the node the key actually defines was left with no writer at all. + // + // Checked before every other rule because it is evidence rather than ranking: + // the owner qualifier on the candidate id and the enclosing callable are the + // same symbol. Nothing else can outrank that. + if (ownerName !== undefined && ownerName.length > 0) { + const own = candidates.filter((c) => c.fromReturnShape && idOwnedBy(c.id, ownerName)); + if (own.length === 1) return { id: own[0]!.id, tier: 'own-return-shape' }; + } + + // PRODUCTION CODE FIRST. A test constructs throwaway shapes with the same + // field names as the thing it exercises — measured on the reporting repo, + // four of the seven JavaScript anchors for `wickRatio` are in `tests/` — and + // a read in production code cannot mean any of them. Applied before the + // declared/return-shape split because "is this the shipped program" is the + // stronger signal: a declaration in a test fixture is still a test fixture. + // + // Only when the READER is production. A read inside a test legitimately means + // the test's own shape, so this must not fire there. + if (!isTestFile(readingFile)) { + const production = candidates.filter((c) => !isTestFile(c.filePath)); + if (production.length > 0) candidates = production; + } + + const declared = candidates.filter((c) => !c.fromReturnShape); + const ranked = declared.length > 0 ? declared : candidates; + + if (ranked.length === 1) { + // TIER HONESTY. `workspace-unique` is a claim that exactly one node in the + // workspace carries this name — a fact about the graph. Reaching one + // survivor by FILTERING (tests out, return shapes down-ranked) is a + // different and weaker claim, and reporting it under the same label told a + // reader "unambiguous workspace-wide match" for an answer that was + // narrowed. The edge is the same; what it is allowed to say about itself is + // not. `narrowed` counts it correctly now too, since that keys off the tier. + const tier = candidatesIn.length === 1 ? 'workspace-unique' : 'ranked'; + return { id: ranked[0]!.id, tier }; + } + candidates = ranked; + + const sameFile = candidates.filter((c) => c.filePath === readingFile); + if (sameFile.length > 0) { + return sameFile.length === 1 ? { id: sameFile[0]!.id, tier: 'same-file' } : null; + } + + if (importedFiles === undefined) return null; + const imported = candidates.filter((c) => importedFiles.has(c.filePath)); + if (imported.length > 0) { + return imported.length === 1 ? { id: imported[0]!.id, tier: 'imported-file' } : null; + } + + return null; +} + +export function emitUniqueNamePropertyAccesses( + graph: KnowledgeGraph, + indexes: ScopeResolutionIndexes, + parsedFiles: readonly ParsedFile[], + nodeLookup: GraphNodeLookup, + /** Sites a precise pass already owns — never second-guessed here. */ + skipSites: ReadonlySet, + /** Finalized import graph; narrows a name carried by several definitions. */ + finalized?: FinalizedImportView, + /** + * Whole-graph `Property`-by-name index built ONCE by the caller and shared + * across every language pass. It is a full node scan and language-agnostic, + * so rebuilding it per language repeats that scan N times — the pattern + * `phase.ts` already hoisted out for `sharedNodeLookup`. Built locally when + * omitted (tests / isolated calls). + */ + prebuiltPropertyNameIndex?: PropertyNameIndex, + /** + * DETECT WITHOUT EMITTING. A language that sets + * `fieldFallbackOnMethodLookup: false` (TypeScript) opts out of name + * inference because a real type system should answer precisely — that opt-out + * is right and stays. But skipping the pass wholesale also skipped its + * REPORTING, so a TypeScript read whose only anchor is JavaScript got the + * same silent empty answer R3-1 exists to remove, just in the other + * direction. Detection is not inference: counting what could not be linked + * asserts nothing about what it means. + */ + reportOnly = false, +): UniqueNamePropertyStats { + const byName = prebuiltPropertyNameIndex ?? buildPropertyNameIndex(graph); + const ownFilePaths = new Set(parsedFiles.map((p) => p.filePath)); + if (byName.size === 0) { + return { + emitted: 0, + ambiguous: 0, + narrowed: 0, + ambiguousNames: [], + crossLanguageOnly: 0, + crossLanguageOnlyNames: [], + }; + } + const directImports = + finalized === undefined + ? new Map>() + : buildDirectImportMap(indexes, finalized); + + let emitted = 0; + let ambiguous = 0; + let narrowed = 0; + let crossLanguageOnly = 0; + const ambiguousNames = new Set(); + /** name -> the languages its definitions actually live in. */ + const crossLanguageAnchors = new Map>(); + const seen = new Set(); + + for (const parsed of parsedFiles) { + for (const site of parsed.referenceSites) { + if (site.kind !== 'read' && site.kind !== 'write') continue; + // A bare identifier is not a property access — without a receiver there + // is no object whose member this could be, and matching one by name + // would link a local variable to an unrelated object's key. + if (site.explicitReceiver === undefined) continue; + const siteKey = callableFlowSiteKey(parsed.filePath, site.atRange); + if (skipSites.has(siteKey)) continue; + + const allWithName = byName.get(site.name); + if (allWithName === undefined) continue; + const candidates = candidatesForLanguage(allWithName, ownFilePaths); + if (candidates === undefined) { + // The name IS defined in this workspace, just not in a language this + // pass may infer across — so declining is correct, and staying silent + // about it is not. An empty answer here is byte-identical to "this + // field is unused", which is the confident-empty failure this whole + // series exists to remove; the only difference is that the missing + // fact is now about the ANALYZER's reach rather than the code's. + crossLanguageOnly++; + if (!crossLanguageAnchors.has(site.name)) { + crossLanguageAnchors.set( + site.name, + new Set(allWithName.map((c) => languageOf(c.filePath))), + ); + } + continue; + } + if (candidates === OVERSATURATED) { + ambiguous++; + ambiguousNames.add(site.name); + continue; + } + + // Resolved BEFORE narrowing: the enclosing callable is evidence the + // ranking needs, not just the edge's source. + const callerGraphId = resolveCallerGraphId(site.inScope, indexes, nodeLookup, site.atRange); + if (callerGraphId === undefined) continue; + + const choice = narrowToSingleCandidate( + candidates, + parsed.filePath, + directImports.get(parsed.filePath), + simpleNameOfGraphId(callerGraphId), + ); + if (choice === null) { + ambiguous++; + ambiguousNames.add(site.name); + continue; + } + const targetId = choice.id; + if (choice.tier !== 'workspace-unique') narrowed++; + + // A property reading itself is not a fact about anything. + if (callerGraphId === targetId) continue; + + const dedupKey = `ACCESSES:${callerGraphId}->${targetId}:${site.atRange.startLine}:${site.atRange.startCol}`; + if (seen.has(dedupKey)) continue; + seen.add(dedupKey); + + if (reportOnly) continue; + // `addRelationship` is first-write-wins, so a precise edge already + // emitted for this exact id keeps ownership over the inference. + graph.addRelationship({ + id: `rel:${dedupKey}`, + sourceId: callerGraphId, + targetId, + type: 'ACCESSES', + confidence: UNIQUE_NAME_CONFIDENCE, + reason: `${EDGE_REASON} (${choice.tier}): ${site.kind}`, + evidence: [], + }); + emitted++; + } + } + + return { + emitted, + ambiguous, + narrowed, + ambiguousNames: Array.from(ambiguousNames).sort().slice(0, MAX_REPORTED_AMBIGUOUS_NAMES), + crossLanguageOnly, + crossLanguageOnlyNames: Array.from(crossLanguageAnchors) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .slice(0, MAX_REPORTED_AMBIGUOUS_NAMES) + .map(([name, languages]) => ({ name, languages: Array.from(languages).sort() })), + }; +} diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts index a1d1dbd69..d629a47bc 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts @@ -45,6 +45,7 @@ import type { ResolutionOutcome } from '../resolution-outcome.js'; import type { FunctionSummary } from '../../taint/summary-model.js'; import type { CallSummary } from '../../taint/call-summary-model.js'; import { buildFunctionNodeIndex } from '../../taint/summary-harvest-driver.js'; +import { buildPropertyNameIndex } from '../passes/unique-name-properties.js'; import { PdgEmitSink, type PdgEmitManifest } from '../../../lbug/pdg-emit-sink.js'; import { resolveNativeSafeStorageDir } from '../../../lbug/lbug-config.js'; import type { ScopeResolver } from '../contract/scope-resolver.js'; @@ -61,6 +62,23 @@ export interface ScopeResolutionOutput { readonly referenceEdgesEmitted: number; /** Additive stream of resolver diagnostics; does not affect graph edges. */ readonly resolutionOutcomes: readonly ResolutionOutcome[]; + /** + * Property inference facts a CALLER needs in order to read an empty result + * correctly (R3-1). Without these, "no ACCESSES for this field" is + * byte-identical whether the field is unused, ambiguous, or anchored in a + * language this pass may not infer across — three different situations with + * three different remedies. + */ + readonly propertyInference: { + /** Sites declined because the name could not be narrowed to one definition. */ + readonly ambiguous: number; + /** Those field names, capped. */ + readonly ambiguousNames: readonly string[]; + /** Sites declined because every definition of the name is another language. */ + readonly crossLanguage: number; + /** Those field names with the languages their definitions live in, capped. */ + readonly crossLanguageNames: readonly { readonly name: string; readonly languages: string[] }[]; + }; /** Per-language breakdown for telemetry. */ readonly perLanguage: ReadonlyMap< SupportedLanguages, @@ -102,6 +120,7 @@ const NOOP_OUTPUT: ScopeResolutionOutput = Object.freeze({ perLanguage: new Map(), functionSummaries: [], callSummaries: [], + propertyInference: { ambiguous: 0, ambiguousNames: [], crossLanguage: 0, crossLanguageNames: [] }, }); /** Select source files that must be materialized for one resolver pass. */ @@ -282,6 +301,14 @@ export const scopeResolutionPhase: PipelinePhase = { ctx.options?.pdg === true && totalScopeFiles > 0 ? buildFunctionNodeIndex(ctx.graph) : undefined; + // Same treatment for the `Property`-by-name index the unique-name pass + // consults: a whole-graph node scan, language-agnostic, and previously + // rebuilt inside every qualifying language pass. Language filtering happens + // at LOOKUP time against that language's own file set, so one shared index + // serves all of them without widening what any single language can resolve + // to. + const sharedPropertyNameIndex = + totalScopeFiles > 0 ? buildPropertyNameIndex(ctx.graph) : undefined; // Streaming/chunked PDG emit (#2202): when enabled (the caller has already // gated this to full-rebuild + `--pdg`), route the BasicBlock + intra-file @@ -319,6 +346,11 @@ export const scopeResolutionPhase: PipelinePhase = { // finalize/propagate/a provider hook) must still release the sink's file // descriptors. finalize() runs on the success path; the finally closes the // sink only when finalize did not (idempotent via the sink's `finalized`). + // R3-1 accumulators — see the warning after the language loop. + let crossLanguagePropertyReads = 0; + const crossLanguageAnchorsByName = new Map>(); + let ambiguousPropertyReads = 0; + const ambiguousPropertyNames = new Set(); let pdgEmitManifest: PdgEmitManifest | undefined; let pdgSinkSettled = false; try { @@ -430,6 +462,7 @@ export const scopeResolutionPhase: PipelinePhase = { files, resolutionConfig, prebuiltNodeLookup: sharedNodeLookup, + prebuiltPropertyNameIndex: sharedPropertyNameIndex, prebuiltFunctionNodeIndex: sharedFnNodeIndex, preExtractedParsedFiles: preExtractedByPath, scopeIndexStorePath: parsedFileStorePath, @@ -538,6 +571,36 @@ export const scopeResolutionPhase: PipelinePhase = { `[scope-resolution:${lang}] ${stats.filesProcessed} files → ${stats.importsEmitted} IMPORTS + ${stats.referenceEdgesEmitted} reference edges (${stats.resolve.unresolved} unresolved sites, ${stats.referenceSkipped} skipped)`, ); } + + // R3-1. Accumulated across languages and reported once below, NOT gated + // on isDev: a field whose only definition lives in another language + // answers an empty ACCESSES query that is byte-identical to "unused", + // and that is the confident-empty failure this whole series removes. + ambiguousPropertyReads += stats.uniqueNamePropertyAmbiguous; + for (const n of stats.uniqueNamePropertyAmbiguousNames) ambiguousPropertyNames.add(n); + crossLanguagePropertyReads += stats.uniqueNamePropertyCrossLanguage; + for (const entry of stats.uniqueNamePropertyCrossLanguageNames) { + const existing = crossLanguageAnchorsByName.get(entry.name); + if (existing === undefined) { + crossLanguageAnchorsByName.set(entry.name, new Set(entry.languages)); + } else { + for (const l of entry.languages) existing.add(l); + } + } + } + + if (crossLanguagePropertyReads > 0) { + const sample = Array.from(crossLanguageAnchorsByName) + .slice(0, 10) + .map(([name, langs]) => `${name} (${Array.from(langs).sort().join('/')})`) + .join(', '); + logger.warn( + `[scope-resolution] ${crossLanguagePropertyReads} property read/write site(s) name a field ` + + `that IS defined in this workspace, but only in another language, so per-language inference ` + + `declined to link them. Queries for these fields return an empty result that does NOT mean ` + + `"unused" — it means the definition anchor is in a different language. ` + + `Affected: ${sample}${crossLanguageAnchorsByName.size > 10 ? ', …' : ''}`, + ); } // Finalize the streaming PDG sink (#2202) once after the last language: @@ -588,6 +651,15 @@ export const scopeResolutionPhase: PipelinePhase = { functionSummaries, callSummaries, pdgEmitManifest, + propertyInference: { + ambiguous: ambiguousPropertyReads, + ambiguousNames: Array.from(ambiguousPropertyNames).sort().slice(0, 25), + crossLanguage: crossLanguagePropertyReads, + crossLanguageNames: Array.from(crossLanguageAnchorsByName) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .slice(0, 25) + .map(([name, languages]) => ({ name, languages: Array.from(languages).sort() })), + }, }; }, }; diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 72f1b1168..652a6523f 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -76,6 +76,13 @@ import { MAX_PROPERTY_DISPATCH_FANOUT, } from '../passes/property-dispatch.js'; import { emitReferencesViaLookup } from '../graph-bridge/references-to-edges.js'; +import { + buildPropertyNameIndex, + emitUniqueNamePropertyAccesses, + type PropertyNameIndex, +} from '../passes/unique-name-properties.js'; +import { emitReturnShapeMemberAccesses } from '../passes/return-shape-members.js'; +import { emitImportedValueReferences } from '../passes/imported-value-refs.js'; import { createCalleeIdAccumulator, type CalleeIdAccumulator, @@ -92,6 +99,7 @@ import { buildWorkspaceResolutionIndex } from '../workspace-index.js'; import type { ResolutionOutcome, ResolutionOutcomeRecorder } from '../resolution-outcome.js'; import { logHeapProbe } from '../../utils/heap-probe.js'; import { parseTruthyEnv } from '../../utils/env.js'; +import { isValueDefinitionLabel } from '../../utils/ast-helpers.js'; import { TransitionalScopeTree } from '../../../../storage/scope-index-store.js'; import { forceGc } from '../../../../storage/parsedfile-store.js'; @@ -160,9 +168,7 @@ function preEmitInheritanceEdges( if (site.kind !== 'inherits') continue; const scope = scopes.scopeTree.getScope(site.inScope); const siteKey = - scope?.filePath !== undefined - ? `${scope.filePath}:${site.atRange.startLine}:${site.atRange.startCol}` - : undefined; + scope?.filePath !== undefined ? callableFlowSiteKey(scope.filePath, site.atRange) : undefined; if (siteKey !== undefined) { // Intentionally suppress every `inherits` site from the generic // reference bridge, even when this pre-pass can't emit an EXTENDS @@ -375,6 +381,14 @@ interface RunScopeResolutionInput { * (tests / isolated calls) it is built locally for the pdg-enabled language. */ readonly prebuiltFunctionNodeIndex?: FunctionNodeIndex; + /** + * `Property`-by-name index built ONCE by the caller and shared across every + * language pass. Like the two above it is a whole-graph scan and is + * language-agnostic; the per-language restriction is applied at lookup time + * against that language's own file set, so sharing the index does not widen + * what any single language can resolve to. Built locally when omitted. + */ + readonly prebuiltPropertyNameIndex?: PropertyNameIndex; /** * Opaque per-language import-resolution config (e.g. tsconfig path * aliases for TypeScript). Loaded once by the caller via @@ -438,6 +452,43 @@ interface RunScopeResolutionStats { * #2437 false-safe gap for exactly those keys (names are in the warn log). */ readonly propertyDispatchSkippedKeys: number; + /** Cross-file value references resolved through finalized import bindings. */ + readonly importedValueRefEdges: number; + /** + * ACCESSES edges recovered by property NAME (A1/A5) — the last-resort pass + * for receivers no precise pass could type. + */ + readonly uniqueNamePropertyEdges: number; + /** + * Read/write sites left unresolved because two or more `Property` defs share + * the name, so a unique-name match would have been a coin flip. This is the + * population a receiver-typing improvement would convert into precise edges. + */ + readonly uniqueNamePropertyAmbiguous: number; + /** + * Of `uniqueNamePropertyEdges`, how many the name carried several definitions + * for and same-file or direct-import evidence narrowed to one. Strict + * workspace uniqueness refused every one of these (R2). + */ + readonly uniqueNamePropertyNarrowed: number; + /** + * The distinct field names behind `uniqueNamePropertyAmbiguous`, capped. A + * count says a coverage gap exists; the names say WHICH fields are + * unanswerable, so the gap is actionable rather than merely measured. + */ + readonly uniqueNamePropertyAmbiguousNames: readonly string[]; + /** + * Read/write sites whose name IS defined in the workspace but only in another + * LANGUAGE, so per-language inference declined. Separate from + * `uniqueNamePropertyAmbiguous` because the remedy differs: ambiguity wants + * better receiver typing, this wants an anchor in the reading language (R3-1). + */ + readonly uniqueNamePropertyCrossLanguage: number; + /** Those names with the languages their definitions actually live in. */ + readonly uniqueNamePropertyCrossLanguageNames: readonly { + readonly name: string; + readonly languages: string[]; + }[]; readonly resolutionOutcomes: readonly ResolutionOutcome[]; /** * Per-function taint summaries harvested in the pdg window (#2084 M4 U1). @@ -566,6 +617,13 @@ export function runScopeResolution( referenceEdgesEmitted: 0, referenceSkipped: 0, propertyDispatchSkippedKeys: 0, + importedValueRefEdges: 0, + uniqueNamePropertyEdges: 0, + uniqueNamePropertyAmbiguous: 0, + uniqueNamePropertyNarrowed: 0, + uniqueNamePropertyAmbiguousNames: [], + uniqueNamePropertyCrossLanguage: 0, + uniqueNamePropertyCrossLanguageNames: [], resolutionOutcomes, functionSummaries: [], callSummaries: [], @@ -595,6 +653,13 @@ export function runScopeResolution( referenceEdgesEmitted: 0, referenceSkipped: 0, propertyDispatchSkippedKeys: 0, + importedValueRefEdges: 0, + uniqueNamePropertyEdges: 0, + uniqueNamePropertyAmbiguous: 0, + uniqueNamePropertyNarrowed: 0, + uniqueNamePropertyAmbiguousNames: [], + uniqueNamePropertyCrossLanguage: 0, + uniqueNamePropertyCrossLanguageNames: [], resolutionOutcomes, functionSummaries: [], callSummaries: [], @@ -757,6 +822,76 @@ export function runScopeResolution( const tResolve = PROF ? process.hrtime.bigint() : 0n; logHeapProbe('sr-post-resolve', `lang=${provider.language}`); + // Value defs bound at MODULE LEVEL. A read of a block-local `const` must not + // mint an edge — that would retain the inert locals `pruneLocalSymbols` drops. + // + // Built HERE, above the out-of-core seal, and deliberately from `parsedFiles` + // rather than `emitParsedFiles`. The seal below replaces the latter with a + // scope-STRIPPED copy, so building this after it walked `scopes: []` for every + // file and produced an empty set — which the filter then reads as "no def is + // module-level" and drops EVERY `Const`/`Variable`/`Static` ACCESSES edge in + // the repo, in all languages, on the one path (`GITNEXUS_DISK_SCOPE_INDEX=1`) + // taken by the largest repos. Nothing failed and nothing logged; the edges + // were simply absent, which is the confident-empty answer this PR exists to + // remove. + // + // ── The question is "is this def FUNCTION-LOCAL?", so ask exactly that ── + // + // This was first written as an ALLOWLIST of module-scope value defs, and that + // shape carried a defect that only shows outside JavaScript. A value def is + // not partitioned into {module-level, block-local}; there is a third home — + // a CLASS body. Java/C# fields and Python class attributes live there, and an + // allowlist keyed on "module level" excludes all of them by construction. + // Worse, the guard written to make that safe could not fire: the set is armed + // whenever a Module scope is FOUND, and Java has module scopes while having no + // module-level values at all, so for Java it armed permanently empty. + // + // Inverting it removes the whole class. A BLOCKLIST of defs positively + // identified as function-local fails safe: anything the walk does not + // recognise — a Java field, a Python class attribute, a language whose scopes + // could not be inspected at all — is emitted rather than dropped. That also + // retires `moduleScopesInspected`; there is nothing left to arm, because an + // empty blocklist and an uninspected one mean the same thing and both mean + // "emit". The failure mode moves from "silently deletes a whole edge class" + // to "retains an inert local", which is the direction this work wants. + // + // Built HERE, above the out-of-core seal, and deliberately from `parsedFiles` + // rather than `emitParsedFiles`. The seal below replaces the latter with a + // scope-STRIPPED copy, so building this after it walked `scopes: []` for every + // file and produced an empty set. Under the old allowlist that read as "no def + // is module-level" and dropped EVERY `Const`/`Variable`/`Static` ACCESSES edge + // in the repo on the one path (`GITNEXUS_DISK_SCOPE_INDEX=1`) taken by the + // largest repos. Under the blocklist the same mistake would merely stop + // filtering — still wrong, still worth the ordering, no longer catastrophic. + // + // A scope is function-local when its chain to the root passes through a + // `Function`. `Block`/`Expression`/`Object` alone are not enough: a bare block + // at module level still holds module-level values, and a `Namespace` nested in + // a function IS local, which the chain walk gets right for free. + const functionLocalValueDefIds = new Set(); + for (const parsed of parsedFiles) { + const scopeById = new Map(parsed.scopes.map((sc) => [sc.id, sc])); + for (const scope of parsed.scopes) { + let cursor: typeof scope | undefined = scope; + let insideFunction = false; + while (cursor !== undefined) { + if (cursor.kind === 'Function') { + insideFunction = true; + break; + } + cursor = cursor.parent === null ? undefined : scopeById.get(cursor.parent); + } + if (!insideFunction) continue; + for (const [, refs] of scope.bindings) { + for (const ref of refs) { + if (isValueDefinitionLabel(ref.def.type)) { + functionLocalValueDefIds.add(ref.def.nodeId); + } + } + } + } + } + // ── Out-of-core scope seal boundary ───────────────────────────────────── // Pass-A (finalize + propagate + resolve) is done; all whole-language reads // of `Scope.bindings` are behind us. Emit reaches scopes ONLY via @@ -899,7 +1034,89 @@ export function runScopeResolution( postHeritageNodeLookup, referenceSkipSites, calleeIdAccumulator, + // A blocklist, so it needs no arming: empty means "nothing identified as + // function-local", which is also what an uninspected repo means, and + // both correctly emit. See the build site above for why the earlier + // allowlist could not be made safe this way. + functionLocalValueDefIds, ); + // Last-resort property resolution by workspace-unique name (A1/A5). Runs + // after every precise pass and only sees what they left behind, so a + // scope-resolved target always wins. Sites the generic bridge already + // resolved are excluded explicitly: `graph.addRelationship` is + // first-write-wins per edge id, which stops a DUPLICATE but not a second + // edge to a DIFFERENT target, and second-guessing a resolved receiver is + // exactly the wrong-edge-in-the-safety-gate case this must not create. + const uniqueNameSkipSites = new Set(referenceSkipSites); + for (const [fromScope, refs] of referenceIndex.bySourceScope) { + const fromFilePath = indexes.scopeTree.getScope(fromScope)?.filePath; + if (fromFilePath === undefined) continue; + for (const ref of refs) { + uniqueNameSkipSites.add(callableFlowSiteKey(fromFilePath, ref.atRange)); + } + } + // Gated on the language's own field-name-fallback policy. A statically-typed + // language sets `fieldFallbackOnMethodLookup: false` precisely because + // matching a member by name over-connects when a real type system could have + // answered exactly; inferring an ACCESSES edge by name is the same claim, so + // it must obey the same opt-out rather than route around it. + // Cross-file value references (A2): the read/write counterpart to + // `emitFreeCallFallback`. Runs BEFORE unique-name inference so a precise + // import-resolved target always wins over a name guess. + const importedValueRefs = callableFlowOnly + ? { emitted: 0 } + : emitImportedValueReferences( + graph, + indexes, + emitParsedFiles, + postHeritageNodeLookup, + uniqueNameSkipSites, + ); + + // A language that opts out of name fallback still gets DETECTION. Skipping + // the pass outright also skipped its reporting, so a TypeScript read whose + // only anchor is JavaScript answered the same silent empty as the JS-read / + // TS-anchor case R3-1 was filed about — the identical defect, mirrored. + // `reportOnly` counts without emitting: no edge, no inference, no change to + // what the opt-out protects. + // PRECISE first (R3-5). A call result's return shape names WHICH producer a + // receiver holds, so it answers exactly the case name inference must refuse: + // several functions returning the same field name. Sites it resolves are + // added to the skip set, so the fallback below never second-guesses them. + const sharedPropertyIndex = input.prebuiltPropertyNameIndex ?? buildPropertyNameIndex(graph); + const returnShapeMembers = callableFlowOnly + ? { emitted: 0, memberNotOnShape: 0 } + : emitReturnShapeMemberAccesses( + graph, + indexes, + emitParsedFiles, + postHeritageNodeLookup, + uniqueNameSkipSites, + sharedPropertyIndex, + uniqueNameSkipSites, + ); + + const nameFallbackDisabled = provider.fieldFallbackOnMethodLookup === false; + const uniqueNameProperties = callableFlowOnly + ? { + emitted: 0, + ambiguous: 0, + narrowed: 0, + ambiguousNames: [], + crossLanguageOnly: 0, + crossLanguageOnlyNames: [], + } + : emitUniqueNamePropertyAccesses( + graph, + indexes, + emitParsedFiles, + postHeritageNodeLookup, + uniqueNameSkipSites, + finalized, + sharedPropertyIndex, + nameFallbackDisabled, + ); + // value-ref registrations (#2437): USES edges at the registration sites // plus field-based dispatch — synthesized CALLS from member-call sites to // functions registered under the same property key. This runs after the @@ -1378,6 +1595,13 @@ export function runScopeResolution( propertyDispatch.callsEmitted, referenceSkipped: skipped, propertyDispatchSkippedKeys: propertyDispatch.skippedKeys, + importedValueRefEdges: importedValueRefs.emitted, + uniqueNamePropertyEdges: uniqueNameProperties.emitted, + uniqueNamePropertyAmbiguous: uniqueNameProperties.ambiguous, + uniqueNamePropertyNarrowed: uniqueNameProperties.narrowed, + uniqueNamePropertyAmbiguousNames: uniqueNameProperties.ambiguousNames, + uniqueNamePropertyCrossLanguage: uniqueNameProperties.crossLanguageOnly, + uniqueNamePropertyCrossLanguageNames: uniqueNameProperties.crossLanguageOnlyNames, resolutionOutcomes, functionSummaries: harvestedSummaries, callSummaries: harvestedCallSummaries, diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts index 69a9ec999..b973d5357 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts @@ -186,6 +186,37 @@ export function isClassLike(t: string): boolean { ); } +/** + * Does this label declare MEMBERS addressable by name? + * + * `isClassLike` answers two questions that only coincide for classes: + * 1. does this declare members I can look up? — a SHAPE (structural) + * 2. does this participate in inheritance / MRO? — a NOMINAL TYPE + * + * A TypeScript object-type alias answers YES to (1) and emphatically NO to + * (2): it declares the same `property_signature` members as the interface + * beside it, but has no supertypes and no place in a linearization. Answering + * (2) "yes" merely to buy (1) is what widening `isClassLike` would do, and it + * would enrol every language's aliases (Rust `type_item`, Kotlin/Swift/Dart + * typealias, C `typedef`) into MRO and heritage. + * + * So the two questions get two predicates. Use THIS one where the question is + * "find the shape so I can look up a member"; keep `isClassLike` where the + * question is inheritance. The call sites announce which they are: + * `resolveInheritanceBaseInScope` and `resolveQualifiedInheritanceBase` are + * (2); receiver typing is (1). + * + * NOT YET INCLUDED, deliberately: `Typedef` and `Union`. They belong here + * conceptually — the `union_item` note on `MEMBER_OWNER_NODE_TYPES` records + * the same gap, that a union owns fields captured as `Property` yet is not a + * recognized owner — but neither is wired as a member container today, so + * adding them would widen a predicate nothing exercises. They join when their + * containers do, with fixtures. + */ +export function isShapeLike(t: string): boolean { + return isClassLike(t) || t === 'TypeAlias'; +} + /** * Walk the scope chain from `startScope` looking for a typeBinding * named `receiverName`. Returns the TypeRef or undefined if no binding @@ -968,6 +999,19 @@ export function resolveClassBindingForName( const direct = findClassBindingInScope(scopeId, rawClassName, scopes, stripDecoration); if (direct !== undefined) return direct; + // NO object-type-ALIAS fallback here, and that is a decision rather than an + // omission. This function carried one before #2833 moved it out of + // `passes/receiver-bound-calls.ts`; the move dropped it, and re-applying it at + // merge time turned out to be wrong twice over. It is unexercised — deleting + // it fails no test, because alias MEMBERS resolve through the precise path + // instead (`type_alias_declaration value: (object_type)` emits `@scope.class`, + // so a typed receiver reaches the shape's own scope). And re-adding it + // unconditionally walked straight past the type-parameter refusal #2833 had + // just introduced, re-opening through an alias the exact false edge that + // change closed — their `neg-type-parameter` fixture caught it. + // + // If a future case genuinely needs it, it must be gated on + // `bindsTypeParameter` and land with a test that fails without it. if (!rawClassName.includes('<')) return undefined; const baseName = stripTemplateArguments(rawClassName).replace(/\s+/g, ''); if (baseName.length === 0) return undefined; @@ -1388,6 +1432,24 @@ export function findValueBindingInScope( return walkScopeChain(startScope, receiverName, scopes, (def) => isOwnableValueLabel(def.type)); } +/** + * Look up a SHAPE binding (class-like, or an object-type alias) by name. + * + * Mirrors `findClassBindingInScope` exactly; only the accepted def-type + * predicate differs — the same relationship `findValueBindingInScope` has to + * it. Exists so a receiver typed as an object-type alias can reach that + * alias's members WITHOUT the alias becoming eligible as an inheritance base: + * `findClassBindingInScope` is what `resolveInheritanceBaseInScope` calls, so + * widening that one would answer a question about hierarchies with a shape. + */ +export function findShapeBindingInScope( + startScope: ScopeId, + receiverName: string, + scopes: ScopeResolutionIndexes, +): SymbolDefinition | undefined { + return walkScopeChain(startScope, receiverName, scopes, (def) => isShapeLike(def.type)); +} + /** * Generic scope-chain walker. Walks from `startScope` toward the root, * consulting both the local `scope.bindings` channel and the dual-source @@ -1733,13 +1795,19 @@ export function populateClassOwnedMembers(parsed: ParsedFile): void { // on `class U: def save(self): def helper(): ...` — helper.ownerId will // remain undefined. The theoretical concern is real only if the // extractor ever stops creating scopes for inner defs. + // `isShapeLike`, not `isClassLike`: OWNERSHIP is question (1) — "which + // declaration do these members belong to?" — and an object-type alias owns + // members exactly as the interface beside it does. Without this its members + // get no `ownerId`, so nothing is registered under the alias and a receiver + // typed as one finds the owner but never its members. Inheritance/MRO keep + // `isClassLike`; see the predicate's docstring. for (const scope of parsed.scopes) { // Methods: function scope whose parent is a Class scope. Owner is - // the parent's class-like def. + // the parent's shape def. if (scope.parent !== null) { const parentScope = scopesById.get(scope.parent); if (parentScope !== undefined && parentScope.kind === 'Class') { - const classDef = parentScope.ownedDefs.find((d) => isClassLike(d.type)); + const classDef = parentScope.ownedDefs.find((d) => isShapeLike(d.type)); if (classDef !== undefined) { for (const def of scope.ownedDefs) { (def as { ownerId?: string }).ownerId = classDef.nodeId; @@ -1751,7 +1819,7 @@ export function populateClassOwnedMembers(parsed: ParsedFile): void { // Class-body fields: defs directly owned by a Class scope (the // class-like def itself excluded). if (scope.kind === 'Class') { - const classDef = scope.ownedDefs.find((d) => isClassLike(d.type)); + const classDef = scope.ownedDefs.find((d) => isShapeLike(d.type)); if (classDef !== undefined) { for (const def of scope.ownedDefs) { if (def === classDef) continue; diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index 765e8492b..7a6dee3df 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -24,6 +24,43 @@ export const TYPESCRIPT_QUERIES = ` (interface_declaration name: (type_identifier) @name) @definition.interface +; Type aliases (A4). TypeScript was the only language whose aliases minted no +; node: Rust (type_item), Kotlin (type_alias), Swift (typealias_declaration) +; and Dart all emit @definition.type. The alias was declared for scope +; resolution but never became a graph symbol, so a context() lookup on an +; exported API-contract type answered "Symbol not found". +(type_alias_declaration + name: (type_identifier) @name) @definition.type + +; Members of a declared SHAPE — interface bodies and object-type aliases both +; spell them as property_signature, so one pattern covers both. A TS frontend +; models its API contracts this way, and without these there is no graph path +; from a contract field to the code that reads it. +; ANCHORED to declared shapes. Unanchored, property_signature matches every +; object_type in the grammar — an inline parameter type, an inline return +; type, a nested object type — and the enclosing-container walk then hangs the +; node off the nearest class/interface/alias. class Svc { retries = 1; +; run(opts: { retries: number }) {} } minted Property:a.ts:Svc.retries twice, +; and graph.addNode is first-write-wins, so two distinct symbols merged into +; one and every context()/impact()/rename() answer about that field described +; the merge. It also emitted the outright false Svc HAS_PROPERTY retries for a +; field belonging to an anonymous parameter type. +; +; The sibling JS object-literal rule in this same PR is anchored for exactly +; this reason; this is the TypeScript half of the same fix. +; +; (A (B)) matches DIRECT children, so a nested object type +; (type Config = { host: string; db: { host: string } }) is excluded here as +; well — its members are not direct children of the alias's own object_type. +(interface_body + (property_signature + name: (property_identifier) @name) @definition.property) + +(type_alias_declaration + value: (object_type + (property_signature + name: (property_identifier) @name) @definition.property)) + (function_declaration name: (identifier) @name) @definition.function @@ -367,6 +404,76 @@ export const TYPESCRIPT_QUERIES = ` (public_field_definition name: (property_identifier) @name) @definition.property +; Object-literal keys of a NAMED object, and the same shape behind an +; identity-preserving wrapper. Both rules existed only in JAVASCRIPT_QUERIES, so +; a .ts file writing the single most common config idiom in the language — +; const CONFIG = { retries: 3 } — minted no node for any key: context() answered +; "Symbol not found" and a precise read through the holding variable had nothing +; to resolve to. +; +; TypeScript sets fieldFallbackOnMethodLookup:false, so these do NOT gain +; name-based inference; they gain the PRECISE path, which is the one TypeScript +; is supposed to use. A read through an untyped receiver stays unresolved, and +; is now reported as such rather than answering an empty set. +; +; Scoped exactly as the JavaScript rules are: bound to a variable, and for the +; wrapper only the three functions that return the argument they were given. +(variable_declarator + name: (identifier) + value: (object + (pair + key: (property_identifier) @name) @definition.property)) + +; Keys of an ANONYMOUS object literal in RETURN position (R3-4). The dominant +; shape in idiomatic JS: 437 sites in one backend directory of the reporting +; repo, including the ~25-field payload of its whole signal pipeline, none of +; which could be named because the literal binds to nothing. +; +; The enclosing function is the owner -- the literal is that function's return +; shape, a contract its callers consume -- so the key qualifies as +; . and two functions returning the same key stay distinct. +; +; DEFINITIONS, unlike the record-construction writes of R2-1b, and the +; difference is deliberate: there a definition already existed elsewhere and a +; construction site was a USE of it, while here nothing else names the field at +; all. To keep that from regressing R2-1b's case, narrowing ranks declared +; anchors ABOVE return shapes, so a name that already resolves keeps resolving +; to what it resolved to before. +(return_statement + (object + (pair + key: (property_identifier) @name) @definition.property)) + +; SHORTHAND keys of the same literal. "return { symbol, interval, score }" is +; the commonest spelling of all -- the reporting repo's own alert payload is +; mostly shorthand -- and (pair) does not match it: tree-sitter models it as +; shorthand_property_identifier, where the key IS the value. Found by dumping +; the golden fixture and noticing that a literal returning +; { level, message, timestamp: Date.now() } had indexed only timestamp. +(return_statement + (object + (shorthand_property_identifier) @name @definition.property)) + +; Shorthand keys of a named object literal -- same gap, same reason as the +; return-position rule above. +(variable_declarator + name: (identifier) + value: (object + (shorthand_property_identifier) @name @definition.property)) + +(variable_declarator + name: (identifier) + value: (call_expression + function: (member_expression + object: (identifier) @_ts.identity.obj + property: (property_identifier) @_ts.identity.fn) + arguments: (arguments + (object + (pair + key: (property_identifier) @name) @definition.property))) + (#eq? @_ts.identity.obj "Object") + (#match? @_ts.identity.fn "^(freeze|seal|preventExtensions)$")) + ; Private class fields: #address: Address (public_field_definition name: (private_property_identifier) @name) @definition.property @@ -848,6 +955,84 @@ export const JAVASCRIPT_QUERIES = ` (field_definition property: (property_identifier) @name) @definition.property +; Object-literal keys of a NAMED object (A1/A5). Idiomatic JS models config as +; an object literal, not a class, so without these the fields of an options bag +; have no node and "who reads/writes this setting?" answers a confident zero. +; +; Deliberately scoped to a literal BOUND TO A VARIABLE. An unbound literal is +; usually an inline call argument or a JSX prop bag, whose keys are call-site +; data rather than a named surface other code references — minting a node per +; key there would add volume without adding an answerable question. +(variable_declarator + name: (identifier) + value: (object + (pair + key: (property_identifier) @name) @definition.property)) + +; Keys of an ANONYMOUS object literal in RETURN position (R3-4). The dominant +; shape in idiomatic JS: 437 sites in one backend directory of the reporting +; repo, including the ~25-field payload of its whole signal pipeline, none of +; which could be named because the literal binds to nothing. +; +; The enclosing function is the owner -- the literal is that function's return +; shape, a contract its callers consume -- so the key qualifies as +; . and two functions returning the same key stay distinct. +; +; DEFINITIONS, unlike the record-construction writes of R2-1b, and the +; difference is deliberate: there a definition already existed elsewhere and a +; construction site was a USE of it, while here nothing else names the field at +; all. To keep that from regressing R2-1b's case, narrowing ranks declared +; anchors ABOVE return shapes, so a name that already resolves keeps resolving +; to what it resolved to before. +(return_statement + (object + (pair + key: (property_identifier) @name) @definition.property)) + +; SHORTHAND keys of the same literal. "return { symbol, interval, score }" is +; the commonest spelling of all -- the reporting repo's own alert payload is +; mostly shorthand -- and (pair) does not match it: tree-sitter models it as +; shorthand_property_identifier, where the key IS the value. Found by dumping +; the golden fixture and noticing that a literal returning +; { level, message, timestamp: Date.now() } had indexed only timestamp. +(return_statement + (object + (shorthand_property_identifier) @name @definition.property)) + +; Shorthand keys of a named object literal -- same gap, same reason as the +; return-position rule above. +(variable_declarator + name: (identifier) + value: (object + (shorthand_property_identifier) @name @definition.property)) + +; Same named shape, behind an IDENTITY-PRESERVING wrapper (R2-1a): +; +; export const INERT_EXIT_CONTRACT = Object.freeze({ exitModel: 'bracket', ... }); +; +; Freezing a config object is the idiomatic way to publish an immutable +; contract, so the fields most worth querying are exactly the ones a bare +; "value: (object)" pattern cannot see — one call expression sits between the +; declarator and the literal. +; +; The allowlist is deliberately three functions rather than "any call". Only +; these RETURN THE ARGUMENT THEY WERE GIVEN, which is what makes the literal's +; keys members of the bound name. For an arbitrary "const x = compute({a: 1})" +; the literal is an argument and x is compute's return value, so attributing +; "a" to x would be a fabrication. +(variable_declarator + name: (identifier) + value: (call_expression + function: (member_expression + object: (identifier) @_identity.obj + property: (property_identifier) @_identity.fn) + arguments: (arguments + (object + (pair + key: (property_identifier) @name) @definition.property))) + (#eq? @_identity.obj "Object") + (#match? @_identity.fn "^(freeze|seal|preventExtensions)$")) + ; Closure-valued class fields (#2693) — see the TypeScript block for why these ; are Method rather than Property. (field_definition diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index 64eab074f..65f06bd31 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -335,6 +335,15 @@ export const CLASS_CONTAINER_TYPES = new Set([ 'class_declaration', 'abstract_class_declaration', 'interface_declaration', + // A TypeScript object-type alias owns its members exactly as the interface + // beside it does — same `property_signature` members, same "who reads this + // contract field?" question. Without it an alias member is minted with a + // bare id and no owner, so two aliases in one file sharing a field name + // collapse onto one node and nothing links the field to its consumers, + // while the identical interface resolves. Aliases with no object type + // (`type Id = string`) declare no members, so they own nothing and are + // unaffected. + 'type_alias_declaration', 'struct_declaration', 'record_declaration', 'class_specifier', @@ -398,6 +407,9 @@ export const CONTAINER_TYPE_TO_LABEL: Record = { class_declaration: 'Class', abstract_class_declaration: 'Class', interface_declaration: 'Interface', + // Required by the CLASS_CONTAINER_TYPES invariant above: a container missing + // here gets orphaned member edges or a wrong owner label. + type_alias_declaration: 'TypeAlias', struct_declaration: 'Struct', struct_specifier: 'Struct', class_specifier: 'Class', @@ -1103,8 +1115,15 @@ export interface ObjectLiteralBindingInfo { * * Set by {@link findMemberAssignmentOwnerInfo} so a prototype method keys as * `Foo.bar` — without it two constructors in one file that each define - * `bar` collapse onto a single `Method::bar` id. Left undefined by - * {@link findObjectLiteralBindingInfo}, whose ids stay exactly as they were. + * `bar` collapse onto a single `Method::bar` id. + * + * {@link findObjectLiteralBindingInfo} sets it ONLY when the caller opts in + * via `includeOwnerName`. Its `Method` ids must stay exactly as they were — + * qualifying them would rewrite every object-literal method id in every + * indexed repo — but object-literal KEYS (indexed since A1/A5) genuinely + * need it: two config objects in one file sharing a key name otherwise + * collapse onto a single `Property::` id, merging two distinct + * settings into one symbol. */ ownerName?: string; } @@ -1158,9 +1177,101 @@ const BLOCK_SCOPE_BOUNDARY_TYPES = new Set([ * ancestor also returns null (catches block-scoped declarations inside * top-level `if`/`for`/`try`/etc., which cannot be imported). */ +/** + * Owner for the keys of an ANONYMOUS object literal in return position (R3-4). + * + * `return { symbol, score, wickRatio, … }` binds to nothing, so its keys had no + * anchor and could not be qualified — which on the reporting repo left the + * central payload of the signal pipeline, ~25 fields, entirely unqueryable. + * There are 437 such sites in one backend directory, so this is the dominant + * shape, not an edge case. + * + * The enclosing FUNCTION is the honest owner: the literal is that function's + * return shape, which is a contract its callers consume. Qualifying by it keeps + * two functions returning the same key name as two distinct nodes, exactly as + * `ownerName` does for variable-bound literals. + * + * Returns null when the literal is not DIRECTLY returned (a nested literal, or + * one inside a callback several frames down), because then the enclosing + * function is not what the object describes. + */ +/** + * True when this definition node is a key of a literal in RETURN position. + * + * Deliberately independent of whether an OWNER NAME could be derived. The two + * are different questions, and conflating them mislabels the anonymous case: + * `[function (row) { return { k: row.x }; }]` yields no name to qualify by, so + * the owner lookup returns null — but the key is still a return shape, and + * flagging it by owner-presence would leave it looking like a DECLARED anchor + * and let it outrank a real declaration during narrowing. + */ +export const isReturnShapeProperty = (node: SyntaxNode): boolean => { + let current: SyntaxNode | null = node; + let objectDepth = 0; + while (current && objectDepth === 0) { + if (current.type === 'object') objectDepth = 1; + else if (FUNCTION_NODE_TYPES.has(current.type)) return false; + else current = current.parent; + } + return current?.parent?.type === 'return_statement'; +}; + +export const findReturnShapeOwnerInfo = ( + node: SyntaxNode, + filePath: string, + // NO `ownerId`, deliberately, and the union's optional field is what says so. + // An owner id would emit `HAS_PROPERTY` from the FUNCTION, a `Function|Property` + // relation pair that the schema does not declare — and an undeclared pair does + // not degrade, it throws `UndeclaredRelationPairError` and kills the entire + // analyze. That already shipped once in this PR. The qualifier alone is what + // this needs: it makes the key nameable and keeps two functions' same-named + // keys distinct, without asserting a containment edge nothing consumes. +): { readonly ownerId?: string; readonly ownerName: string } | null => { + // Walk to the literal this key belongs to; bail if it is nested inside + // another object, whose shape it describes instead. + let current: SyntaxNode | null = node; + let objectDepth = 0; + while (current && objectDepth === 0) { + if (current.type === 'object') objectDepth = 1; + else if (FUNCTION_NODE_TYPES.has(current.type)) return null; + else current = current.parent; + } + if (!current) return null; + const literal = current; + if (literal.parent?.type !== 'return_statement') return null; + + // The nearest enclosing function-like, and its name. An anonymous function + // (a callback, an IIFE) gives nothing to qualify by, so those stay + // unanchored rather than colliding on a shared empty owner. + let fn: SyntaxNode | null = literal.parent.parent; + while (fn && !FUNCTION_NODE_TYPES.has(fn.type)) fn = fn.parent; + if (!fn) return null; + + const nameNode = fn.childForFieldName?.('name'); + if (nameNode?.type === 'identifier' || nameNode?.type === 'property_identifier') { + return { ownerName: nameNode.text }; + } + // `const formatAlert = (…) => ({ … })` and `const f = function () {}`: the + // name is on the declarator, not the function. + const declarator = fn.parent; + if (declarator?.type === 'variable_declarator') { + const declName = declarator.childForFieldName?.('name'); + if (declName?.type === 'identifier') return { ownerName: declName.text }; + } + void filePath; + return null; +}; + export const findObjectLiteralBindingInfo = ( node: SyntaxNode, filePath: string, + options?: { + /** + * Also return `ownerName` so the member qualifies as `.`. + * Opt-in because turning it on for `Method` would rewrite existing ids. + */ + readonly includeOwnerName?: boolean; + }, ): ObjectLiteralBindingInfo | null => { // ── Phase A: walk up from node, count `object` ancestors, find declarator let current: SyntaxNode | null = node; @@ -1218,6 +1329,7 @@ export const findObjectLiteralBindingInfo = ( const ownerLabel = declaration?.type === 'variable_declaration' ? 'Variable' : 'Const'; return { ownerId: generateId(ownerLabel, `${filePath}:${nameNode.text}`), + ...(options?.includeOwnerName === true ? { ownerName: nameNode.text } : {}), }; }; diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 3a80eaa71..95b7d37b1 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -91,6 +91,8 @@ import { getDefinitionNodeFromCaptures, findEnclosingClassInfo, findObjectLiteralBindingInfo, + findReturnShapeOwnerInfo, + isReturnShapeProperty, findMemberAssignmentOwnerInfo, isCjsDefaultExportAssignment, type EnclosingClassInfo, @@ -361,6 +363,16 @@ export interface ExtractedDecoratorRoute { * resolution then falls back (the Route node simply carries no handlerSymbolId). */ handlerName?: string; + /** + * Provenance for the `HANDLES_ROUTE` edge, overriding the default + * `decorator-`. Present when the route was extracted from a + * shape that is not a decorator at all — today, JS/TS dispatch guards + * (`route-extractors/dispatch-guard.ts`), where the route is INFERRED from a + * path comparison rather than DECLARED by an annotation. That distinction is + * the only thing that differs downstream, so it travels as a field instead of + * as a parallel extraction channel. + */ + source?: string; } /** @@ -2345,11 +2357,35 @@ const processFileGroup = ( // syntax rather than from an ancestor walk, and both are language-shaped // helpers behind the provider's own label decision — shared code here // only asks "does this Method name an owner". + // `Property` joins `Method` here because object-literal KEYS are now + // indexed (A1/A5), and a key is owned by the object that holds it exactly + // as a literal's function-valued member is. Without it, two config + // objects in one file sharing a key name (`httpConfig.timeoutMs` and + // `dbConfig.timeoutMs`) generate the same `Property::timeoutMs` id + // and COLLAPSE INTO ONE node — two distinct settings become one symbol, + // and the merged name then looks workspace-unique to name inference, + // which resolves reads of it to a node representing both. const objectLiteralOwnerInfo = - !enclosingClassId && nodeLabel === 'Method' && definitionNode + !enclosingClassId && (nodeLabel === 'Method' || nodeLabel === 'Property') && definitionNode ? (findMemberAssignmentOwnerInfo(definitionNode, file.path) ?? - findObjectLiteralBindingInfo(definitionNode, file.path)) + findObjectLiteralBindingInfo(definitionNode, file.path, { + // Only `Property` opts into the qualifier; `Method` ids must stay + // byte-identical or every object-literal method in every indexed + // repo changes id. + includeOwnerName: nodeLabel === 'Property', + }) ?? + // R3-4: an anonymous literal in return position is owned by the + // function whose shape it is. Last in the chain so a variable-bound + // literal keeps its existing owner and its existing id. + (nodeLabel === 'Property' ? findReturnShapeOwnerInfo(definitionNode, file.path) : null)) : null; + // Provenance for narrowing (R3-4). A return shape is a real definition but + // the weaker one, and the unique-name pass ranks declared anchors above it + // so indexing these cannot change an answer that already resolved. + const returnShapeProperty = + nodeLabel === 'Property' && definitionNode !== undefined && definitionNode !== null + ? isReturnShapeProperty(definitionNode) + : false; // #1978: hoisted ABOVE qualifiedName/node-id (load-bearing order) so a // class-like node can key its id by its fully-qualified path. Derived from @@ -2796,6 +2832,7 @@ const processFileGroup = ( ...(description !== undefined ? { description } : {}), ...methodProps, ...(declaredType !== undefined ? { declaredType } : {}), + ...(returnShapeProperty ? { fromReturnShape: true, isDetail: true } : {}), }, }); diff --git a/gitnexus/src/core/lbug/csv-generator.ts b/gitnexus/src/core/lbug/csv-generator.ts index 4444a9687..ec2fe616a 100644 --- a/gitnexus/src/core/lbug/csv-generator.ts +++ b/gitnexus/src/core/lbug/csv-generator.ts @@ -516,7 +516,8 @@ export const streamAllCSVsToDisk = async ( 'Template', 'Module', ] as const; - const propertyHeader = 'id,name,filePath,startLine,endLine,content,description,declaredType'; + const propertyHeader = + 'id,name,filePath,startLine,endLine,content,description,declaredType,isDetail'; const multiLangWriters = new Map(); for (const t of MULTI_LANG_TYPES) { multiLangWriters.set( @@ -709,7 +710,13 @@ export const streamAllCSVsToDisk = async ( escapeCSVField(content), escapeCSVField(formatFtsDescription(node.properties.description || '')), ...(node.label === 'Property' - ? [escapeCSVField(node.properties.declaredType || '')] + ? [ + escapeCSVField(node.properties.declaredType || ''), + // R3-4 detail symbols — see PROPERTY_SCHEMA. Written as + // an explicit boolean so the column is never empty; an + // empty BOOLEAN cell fails the COPY. + node.properties.isDetail === true ? 'true' : 'false', + ] : []), ].join(','), ); diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index ec7ffcb7b..3c85eaf39 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -1,5 +1,5 @@ import fs from 'fs/promises'; -import { createReadStream, createWriteStream, constants as fsConstants } from 'fs'; +import { createReadStream, createWriteStream, existsSync, constants as fsConstants } from 'fs'; import { createInterface } from 'readline'; import { once } from 'events'; import { finished } from 'stream/promises'; @@ -982,6 +982,25 @@ const copyCsvWithRetry = async ( } }; +/** + * A staging CSV named in the COPY manifest is gone by the time COPY runs. + * + * Only tables with `rows > 0` enter the manifest (see `csv-generator.ts`), so + * the file WAS written during this run and something removed it since. Raw, + * that surfaces as a LadybugDB "Binder exception: No file found that matches + * the pattern …" and then an ENOENT on the next file — two engine-level + * messages that name neither the cause nor a remedy, and which the field + * reports show operators hitting on a forced rebuild with nothing to act on. + */ +export const missingStagingCsvError = (table: string, csvPath: string, rows: number): Error => + new Error( + `Staging CSV for ${table} is missing: ${csvPath}. It was written with ` + + `${rows.toLocaleString()} rows during this run, so it was removed mid-run — most often a ` + + `second \`gitnexus analyze\` on the same repo (both use .gitnexus/csv), or an external ` + + `cleanup of .gitnexus/. Ensure no other analyze is running, then re-run ` + + `\`gitnexus analyze --force\`.`, + ); + /** * Bulk-COPY every node CSV sequentially on the single writable connection * (LadybugDB allows one write txn at a time). Extracted from loadGraphToLbug so @@ -990,6 +1009,30 @@ const copyCsvWithRetry = async ( * keeps the IGNORE_ERRORS=true retry; a hard failure throws (no node rows ⇒ the * relationship COPY would dangle on missing endpoints). */ +/** + * Re-check a staging CSV a few times before declaring it gone. + * + * Review asked whether turning a silent degrade into a hard abort was + * deliberate. It is — a fallback that recovers zero rows is exactly the + * confident-empty failure this work is about, so failing loud is right. But the + * transient the error message itself names, a second concurrent `analyze` + * sharing `.gitnexus/csv`, is a RACE, and aborting a multi-minute rebuild on + * one stat() is a harsh answer to a file that may reappear microseconds later. + * + * Bounded and short: three extra looks over ~150ms total. Long enough to ride + * out a rename or a slow network filesystem, far too short to mask a file that + * is genuinely gone. + */ +const stagingCsvExists = async (csvPath: string): Promise => { + const RETRY_DELAYS_MS = [25, 50, 75]; + if (existsSync(csvPath)) return true; + for (const delay of RETRY_DELAYS_MS) { + await new Promise((resolve) => setTimeout(resolve, delay)); + if (existsSync(csvPath)) return true; + } + return false; +}; + const copyNodeCSVs = async ( targetConn: lbug.Connection, nodeFileEntries: [NodeTableName, { csvPath: string; rows: number }][], @@ -1001,6 +1044,8 @@ const copyNodeCSVs = async ( stepsDone++; log(`Loading nodes ${stepsDone}/${totalSteps}: ${table} (${rows.toLocaleString()} rows)`); + if (!(await stagingCsvExists(csvPath))) throw missingStagingCsvError(table, csvPath, rows); + const copyQuery = getCopyQuery(table, normalizeCopyPath(csvPath)); await copyCsvWithRetry(targetConn, copyQuery, (retryErr) => { const retryMsg = retryErr instanceof Error ? retryErr.message : String(retryErr); @@ -1218,6 +1263,12 @@ export const loadGraphToLbug = async ( for (const { pairKey, csvPath: pairCsvPath, rows } of copyJobs) { pairIdx++; const [fromLabel, toLabel] = pairKey.split('|'); + // Same guarantee as the node COPY: a pair file only reaches this loop + // with rows on it, so an absent file means it vanished mid-run. This is + // the `rel_Folder_File.csv` ENOENT the field reports end on. + if (!(await stagingCsvExists(pairCsvPath))) { + throw missingStagingCsvError(`${fromLabel} -> ${toLabel}`, pairCsvPath, rows); + } const normalizedPath = normalizeCopyPath(pairCsvPath); // PARALLEL=false is load-bearing here too — see COPY_CSV_OPTS (#2203 / kuzudb/kuzu#5778). const copyQuery = `COPY ${REL_TABLE_NAME} FROM "${normalizedPath}" (from="${fromLabel}", to="${toLabel}", HEADER=true, ESCAPE='"', DELIM=',', QUOTE='"', PARALLEL=false, auto_detect=false)`; @@ -1488,7 +1539,7 @@ export const getCopyQuery = (table: NodeTableName, filePath: string): string => return `COPY ${t}(id, name, filePath, startLine, endLine, isExported, content, description, parameterCount, returnType) FROM "${filePath}" ${COPY_CSV_OPTS}`; } if (table === 'Property') { - return `COPY ${t}(id, name, filePath, startLine, endLine, content, description, declaredType) FROM "${filePath}" ${COPY_CSV_OPTS}`; + return `COPY ${t}(id, name, filePath, startLine, endLine, content, description, declaredType, isDetail) FROM "${filePath}" ${COPY_CSV_OPTS}`; } // TypeScript/JS code element tables have isExported; multi-language tables do not if (TABLES_WITH_EXPORTED.has(table)) { @@ -1551,7 +1602,7 @@ export const insertNodeToLbug = async ( const descPart = properties.description ? `, description: ${formatCypherValue(properties.description)}` : ''; - query = `CREATE (n:${t} {id: ${formatCypherValue(properties.id)}, name: ${formatCypherValue(properties.name)}, filePath: ${formatCypherValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, content: ${formatCypherValue(properties.content || '')}${descPart}, declaredType: ${formatCypherValue(properties.declaredType || '')}})`; + query = `CREATE (n:${t} {id: ${formatCypherValue(properties.id)}, name: ${formatCypherValue(properties.name)}, filePath: ${formatCypherValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, content: ${formatCypherValue(properties.content || '')}${descPart}, declaredType: ${formatCypherValue(properties.declaredType || '')}, isDetail: ${properties.isDetail === true}})`; } else { // Multi-language tables (Struct, Impl, Trait, Macro, etc.) — no isExported const descPart = properties.description @@ -1641,7 +1692,7 @@ export const batchInsertNodesToLbug = async ( const descPart = properties.description ? `, n.description = ${formatCypherValue(properties.description)}` : ''; - query = `MERGE (n:${t} {id: ${formatCypherValue(properties.id)}}) SET n.name = ${formatCypherValue(properties.name)}, n.filePath = ${formatCypherValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.content = ${formatCypherValue(properties.content || '')}${descPart}, n.declaredType = ${formatCypherValue(properties.declaredType || '')}`; + query = `MERGE (n:${t} {id: ${formatCypherValue(properties.id)}}) SET n.name = ${formatCypherValue(properties.name)}, n.filePath = ${formatCypherValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.content = ${formatCypherValue(properties.content || '')}${descPart}, n.declaredType = ${formatCypherValue(properties.declaredType || '')}, n.isDetail = ${properties.isDetail === true}`; } else { const descPart = properties.description ? `, n.description = ${formatCypherValue(properties.description)}` @@ -1774,9 +1825,23 @@ export const executeWithReusedStatement = async ( } }; -export const getLbugStats = async (): Promise<{ nodes: number; edges: number }> => { +/** + * Node and edge totals for the open index. + * + * `edges` is `undefined` when the count could NOT BE TAKEN, and that is a + * different fact from zero. It used to be initialised to 0 with the query in a + * swallowing `catch`, so a WAL/lock contention throw during finalize — a + * documented hazard on this exact call — returned a measured-looking 0. The + * collapse check downstream then read a perfectly healthy index as a total + * write collapse, which is precisely the confident-zero failure that check + * exists to prevent. + */ +export const getLbugStats = async (): Promise<{ + nodes: number; + edges: number | undefined; +}> => { const c = conn; - if (!c) return { nodes: 0, edges: 0 }; + if (!c) return { nodes: 0, edges: undefined }; // Called during analyze finalize while the WAL-checkpoint driver is still // running; each count read takes the connection lock so it cannot execute @@ -1797,7 +1862,7 @@ export const getLbugStats = async (): Promise<{ nodes: number; edges: number }> } } - let totalEdges = 0; + let totalEdges: number | undefined; try { totalEdges = await withConnLock(async () => { const queryResult = await c.query( @@ -1807,7 +1872,8 @@ export const getLbugStats = async (): Promise<{ nodes: number; edges: number }> return edgeRows.length > 0 ? Number(edgeRows[0]?.cnt ?? edgeRows[0]?.[0] ?? 0) : 0; }); } catch { - // ignore + // Leave `totalEdges` undefined: the count was not obtained. Reporting 0 + // here is what made a throwing query indistinguishable from an empty table. } return { nodes: totalNodes, edges: totalEdges }; @@ -3393,13 +3459,37 @@ export const classifyFtsQueryError = (message: string): FtsQueryFailureClass => * ORDER BY tiebreak for #2787 being the latest. Lives beside * {@link classifyFtsQueryError}, which was already shared for exactly this call. */ +/** + * DETAIL SYMBOLS DO NOT COMPETE IN TEXT SEARCH. + * + * `Property.isDetail` marks the keys of an anonymous literal returned from a + * function (R3-4): real symbols, worth walking and worth an impact analysis, + * but not concepts a text search should surface on their own. Their names are + * ordinary words (`message`, `value`, `timestamp`) and there are many of them, + * so without this they consume the FTS call's own LIMIT and push out the + * CALLABLES named after the same concept — measured, `query('message')` went + * from two processes to none on the mini-repo fixture. + * + * Filtered HERE rather than after the call, because that is the only place it + * works: rows crowded out by the LIMIT never reach the caller, so no amount of + * re-ranking downstream can recover them. (Tried, and it recovered nothing.) + * + * Property-only, since no other table has the column, and `IS NULL`-tolerant so + * an index written before this column existed still answers. + */ +const FTS_DETAIL_FILTER = ` + WITH node, score + WHERE node.isDetail IS NULL OR node.isDetail = false`; + export const buildFtsQueryCypher = ( tableName: string, indexName: string, limit: number, conjunctive: boolean = false, ): string => ` - CALL QUERY_FTS_INDEX('${tableName}', '${indexName}', $query, conjunctive := ${conjunctive}) + CALL QUERY_FTS_INDEX('${tableName}', '${indexName}', $query, conjunctive := ${conjunctive})${ + tableName === 'Property' ? FTS_DETAIL_FILTER : '' + } RETURN node, score ORDER BY score DESC, node.id LIMIT ${limit} diff --git a/gitnexus/src/core/lbug/schema.ts b/gitnexus/src/core/lbug/schema.ts index c1168feb7..11471f2e6 100644 --- a/gitnexus/src/core/lbug/schema.ts +++ b/gitnexus/src/core/lbug/schema.ts @@ -183,6 +183,25 @@ CREATE NODE TABLE \`Property\` ( content STRING, description STRING, declaredType STRING, + /* + * DETAIL SYMBOL — true when this property is a member of a shape that has no + * independent identity: the keys of an anonymous literal returned from a + * function (R3-4). + * + * It exists because indexing those keys is right for the GRAPH and wrong for + * TEXT SEARCH. They are ordinary words (message, value, timestamp) and + * there are many of them, so letting them into the FTS result set pushes the + * CALLABLES named after the same concept past the row cap the search applies + * — measured: query('message') went from two processes to none on the + * mini-repo fixture. A ranking tweak cannot fix that, because the rows never + * come back from the FTS call in the first place. + * + * So the search layer gained a notion it did not have — a symbol that is + * queryable, walkable and impact-analysable, but not a concept a text search + * should surface on its own. buildFtsQueryCypher excludes these for the + * Property table only; every other consumer sees them normally. + */ + isDetail BOOLEAN, PRIMARY KEY (id) )`; export const RECORD_SCHEMA = CODE_ELEMENT_BASE('Record'); @@ -408,7 +427,7 @@ const ATTACHMENT_TARGET_LABELS: readonly NodeTableName[] = [ * * What survives here is characteristic, not arbitrary. Almost all of it is a * TARGET no rule reaches — `CodeElement`, `Impl`, `Namespace`, `Template`, - * `TypeAlias`, `Typedef`, `Union`, `Static`, `Section`, `Folder` are in neither + * `Typedef`, `Union`, `Static`, `Section`, `Folder` are in neither * `SCOPE_BRIDGE_TARGET_LABELS` nor {@link ATTACHMENT_TARGET_LABELS} — plus the * `Impl|*` and `Template|*` member rows (Rust `impl`/`trait` bodies, C++ * templates), the two `Route|Process` / `Tool|Process` entry points whose @@ -418,10 +437,23 @@ const ATTACHMENT_TARGET_LABELS: readonly NodeTableName[] = [ * NOTHING A RULE ALREADY COVERS BELONGS HERE. `generatedRelationPairs` skips * any pair present in this block, so a redundant line does not merely duplicate * — it SUPPRESSES generation, and later narrowing a rule would silently keep - * that pair alive with no test failing. 164 such lines now live in the generated - * half (161 from #2793 plus the three `Record` member pairs moved by #2801); + * that pair alive with no test failing. 166 such lines now live in the generated + * half (161 from #2793, the three `Record` member pairs moved by #2801/#2871, + * and the two `TypeAlias` pairs moved by R2-2 here); * `test/unit/schema-pair-coverage.test.ts` now fails if one comes back. * + * Those last two arrived by MERGE, and the shape is worth recording because it + * is the one this block's warning cannot catch by itself. #2871 and this branch + * each deleted their OWN label's hand-written pairs — `Record` there, + * `TypeAlias` here — for the identical reason, in the identical region. Git + * presents that as one conflict in which each side appears to be deleting the + * other's lines, and "keep ours" or "keep theirs" both resolve cleanly, compile, + * and silently re-suppress the other label's generation. The correct resolution + * is neither: take the UNION of the deletions. Verified by asserting all five + * pairs are still present in the emitted DDL — a check that does not read + * `LINKABLE_LABELS`, unlike the pair-coverage test, which derives both sides + * from it and so moves with any change to it. + * * Folding this remainder into a third cross product * (`DEFINITION_ANCHOR_LABELS × {CodeElement, Section, Typedef, Union, * Namespace, Impl, TypeAlias, Static, Template}`) would take the table to 641 @@ -443,7 +475,6 @@ export const STRUCTURAL_PAIR_DDL = ` FROM File TO Folder, FROM File TO \`Union\`, FROM File TO \`Namespace\`, FROM File TO \`Impl\`, - FROM File TO \`TypeAlias\`, FROM File TO \`Static\`, FROM File TO \`Template\`, FROM File TO Section, @@ -451,20 +482,17 @@ export const STRUCTURAL_PAIR_DDL = ` FROM File TO Folder, FROM Folder TO File, FROM Function TO \`Template\`, FROM Function TO \`Namespace\`, - FROM Function TO \`TypeAlias\`, FROM Function TO \`Impl\`, FROM Function TO \`Typedef\`, FROM Function TO \`Union\`, FROM Function TO CodeElement, FROM Class TO \`Template\`, - FROM Class TO \`TypeAlias\`, FROM Class TO \`Impl\`, FROM Class TO \`Union\`, FROM Class TO \`Namespace\`, FROM Class TO \`Typedef\`, FROM Class TO CodeElement, FROM Method TO \`Template\`, - FROM Method TO \`TypeAlias\`, FROM Method TO \`Namespace\`, FROM Method TO \`Impl\`, FROM Method TO CodeElement, @@ -486,8 +514,6 @@ export const STRUCTURAL_PAIR_DDL = ` FROM File TO Folder, FROM CodeElement TO \`Property\`, FROM Section TO Section, FROM Interface TO CodeElement, - FROM Interface TO \`TypeAlias\`, - FROM \`Enum\` TO \`TypeAlias\`, FROM \`Namespace\` TO \`Struct\`, FROM \`Impl\` TO Method, FROM \`Impl\` TO Function, @@ -496,10 +522,7 @@ export const STRUCTURAL_PAIR_DDL = ` FROM File TO Folder, FROM \`Impl\` TO \`Trait\`, FROM \`Impl\` TO \`Struct\`, FROM \`Impl\` TO \`Impl\`, - FROM \`TypeAlias\` TO \`Trait\`, - FROM \`TypeAlias\` TO Class, FROM \`Constructor\` TO \`Template\`, - FROM \`Constructor\` TO \`TypeAlias\`, FROM \`Constructor\` TO \`Impl\`, FROM \`Constructor\` TO \`Namespace\`, FROM \`Constructor\` TO \`Typedef\`, diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 8e8c4ac7d..70e4a6fe5 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -9,6 +9,7 @@ * wrapper or server worker) is responsible for process lifecycle. */ +import { detectGraphWriteCollapse } from './index-freshness.js'; import path from 'path'; import fs from 'fs/promises'; import { randomUUID } from 'node:crypto'; @@ -481,6 +482,14 @@ export interface AnalyzeResult { * full-text/BM25 search is disabled. Lets callers (CLI summary, server) and * the persisted meta surface the degraded state instead of reporting healthy. */ + /** + * Set when the post-write integrity check found far fewer relationships in + * the DB than the pipeline produced. Surfaced on the RESULT, not only in + * metadata, because the CLI and the analyze worker both report completion + * from this object — and a run whose edges are mostly gone must not be able + * to print "indexed successfully" and exit 0. + */ + graphWriteCollapsed?: { expected: number; persisted: number }; ftsSkipped?: boolean; /** * Why FTS was skipped, when `ftsSkipped` is true (#2658 review L2): @@ -1935,6 +1944,10 @@ async function runFullAnalysisInner( // process already holds and — worse — ran a read against the DB between // writeback and finalize for no recovery benefit. let deletedFilePathsForRestore: Set | null = null; + // True once this run has persisted only a CHANGED SUBGRAPH. The post-write + // collapse check compares the whole in-memory graph against the whole DB, + // which is only a like-for-like comparison on a full rebuild. + let wroteChangedSubgraphOnly = false; if (isIncremental && hashDiff) { // ── Incremental DB writeback ─────────────────────────────────── // 0. Expand the writable set with transitive importers of @@ -2505,6 +2518,7 @@ async function runFullAnalysisInner( // the SAME effectiveWriteSet so the subgraph and the deletes // cover identical files (asymmetry would silently corrupt). const subgraph = extractChangedSubgraph(pipelineResult.graph, effectiveWriteSet); + wroteChangedSubgraphOnly = true; await saveIncrementalDirtyState('load-graph', { importerExpansion, shadowSeedCount: shadowSeed.length, @@ -2745,6 +2759,61 @@ async function runFullAnalysisInner( // ── Phase 4: Embeddings (90–98%) ────────────────────────────────── const stats = await getLbugStats(); + + // Post-write integrity: the pipeline knows exactly how many relationships + // it produced, and `stats` is what the DB hands back after the write, so a + // large shortfall is provable rather than inferred — no comparison against + // the previous index needed. This is the guard for a refresh that reports + // SUCCESS while leaving the index unusable: edges collapsing to a fraction + // of what was built, or a `CodeRelation` table that never materialized + // (which surfaces here as a persisted count of zero). + // + // A RATIO, not equality: some relationship types legitimately do not round + // -trip one-for-one, and `--pdg` writes MORE rows into the same table than + // the call-graph produced, so demanding equality would fire on healthy + // runs. Only a collapse is a defect. + // + // Fail-safe when `expected` reads 0: an implementation that offloads + // relationships out of memory may no longer be able to report a total, and + // a false "your index is broken" is worse than a missed one. + // + // STREAMED EDGES COUNT. When `GraphEmitSink` streaming is active the bulk + // types (CALLS/IMPORTS/REFERENCES/ACCESSES) leave the heap at parse time and + // never enter `relationshipCount`, so a bare count understates `expected` by + // most of the edge volume and the ratio passes trivially. Streaming is on for + // any `force === true` run — which includes the crash/schema-mismatch + // recovery paths AND the `analyze --force` retry this check's own warning + // tells the operator to run. Same correction, and for the same reason, as + // the buffer-pool hint earlier in this file. + const expectedRelationships = + pipelineResult.graph.relationshipCount + (pipelineResult.graphEmitManifest?.totalRows ?? 0); + // `getLbugStats` returns `edges: undefined` when the count could not be + // taken, which is a different fact from zero — an edge query that throws + // must not read as a measured collapse. `nodes > 0` is independent evidence + // the DB was readable at all, but it says nothing about whether the EDGE + // query threw, so both conditions are required. + const persistedRelationships = + stats.nodes > 0 && stats.edges !== undefined ? stats.edges : undefined; + // NOT COMPARABLE ON AN INCREMENTAL WRITE. That path persists only + // `extractChangedSubgraph(...)` while both counts here are whole-scope: the + // full in-memory graph against the entire DB. A 10,000-edge index whose + // incremental rewrite lost 200 replacements reads 9,800 against 10,000 — + // comfortably above the ratio — so a corrupt index would be certified + // complete, and the reverse (a small change to a large index) would report + // a collapse that did not happen. Producing no verdict is the honest answer + // until the check is given the write-set delta to compare against; that is + // the same fail-safe the `expected === 0` case already takes. + const graphWriteCollapsed = wroteChangedSubgraphOnly + ? undefined + : detectGraphWriteCollapse(expectedRelationships, persistedRelationships); + if (graphWriteCollapsed) { + log( + `Warning: graph write incomplete — the pipeline produced ${expectedRelationships} ` + + `relationships but only ${persistedRelationships} are readable from the index. Recording the ` + + `index as INCOMPLETE (graph-write-collapsed) rather than fresh; re-run ` + + `\`gitnexus analyze --force\`.`, + ); + } let embeddingSkipped = true; let semanticMode: 'vector-index' | 'exact-scan' | undefined; // Hoisted out of the Phase 4 block so the Phase 5 gate can tell "the @@ -3154,6 +3223,20 @@ async function runFullAnalysisInner( // origin remote, which is fine: paths-only repos behave as // before. remoteUrl: hasGitDir(repoPath) ? getRemoteUrl(repoPath) : undefined, + // Absent on a healthy run; present it and the index reports as + // incomplete rather than fresh (`graph-write-collapsed`). + ...(graphWriteCollapsed ? { graphWriteCollapsed } : {}), + // R3-1. Not a health signal — the index is complete and correct. This + // records which fields the per-language inference declined to link so a + // later query can say WHY it is returning nothing, instead of leaving an + // empty result that reads as "unused". + ...(pipelineResult.propertyInference?.crossLanguageNames?.length + ? { + crossLanguageProperties: pipelineResult.propertyInference.crossLanguageNames.map( + (e) => ({ name: e.name, languages: [...e.languages] }), + ), + } + : {}), stats: { files: pipelineResult.totalFileCount, nodes: stats.nodes, @@ -3448,6 +3531,7 @@ async function runFullAnalysisInner( repoPath, stats: meta.stats, pipelineResult, + ...(graphWriteCollapsed ? { graphWriteCollapsed } : {}), ftsSkipped: !ftsReady, ftsSkipReason: ftsReady ? undefined : ftsSkipReason, isPrimaryBranch: !placement.branch, diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index d4b3ea063..80058c4cf 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -4115,6 +4115,46 @@ export class LocalBackend { const beanMetadataPromise = queryClassBeanMetadata(repo.lbugPath, symId, epistemicSymType); const aopMetadataPromise = querySpringAopMetadata(repo.lbugPath, symId, epistemicSymType); + // R3-1. A `Property` whose name the analyzer declined to link — because + // every definition of it lives in another language — otherwise returns an + // incoming list byte-identical to a genuinely unread field. Those demand + // opposite actions ("look in the other language / grep" vs "delete it"), so + // the difference has to travel with the answer. + // + // The graph cannot answer this: the unlinked reads mint no edge and no + // node, so the only record is the analyze pass that declined them. Hence the + // meta read — bounded to Property lookups, since `ensureInitialized` + // deliberately avoids a per-call `loadMeta` on the hot path. + let crossLanguageAnchor: { + unresolved?: string; + anchorLanguages?: readonly string[]; + } = {}; + { + try { + // Keyed on the NAME, not on the resolved label. Gating on + // `=== 'Property'` was tried and is wrong: the label is not always + // populated on this path (it reads `''` for a plain Property node), so + // the gate silently suppressed the whole feature. The meta list only + // ever contains property names, so matching the name IS the type check. + const hit = (await this.crossLanguagePropertiesFor(repo)).get( + (sym.name || sym[1]) as string, + ); + if (hit) { + crossLanguageAnchor = { + unresolved: + `property reads of this name were NOT linked: every definition of it is ` + + `${hit.join('/')}, and name inference does not cross languages. ` + + `An empty or short incoming list here is not evidence the field is unused — ` + + `confirm with a text search, or give it an anchor in the reading language.`, + anchorLanguages: hit, + }; + } + } catch { + // A missing or unreadable meta is not worth failing a context lookup + // over; the answer is merely less explained, which is the status quo. + } + } + let methodMetadata: Record | undefined; if (isMethodLike) { try { @@ -4175,6 +4215,7 @@ export class LocalBackend { ...(aopMetadata ? { aop: aopMetadata } : {}), }, ...epistemic, + ...crossLanguageAnchor, incoming: categorize(incomingRows), outgoing: categorize(outgoingRows), ...(typedPropertyRows.length > 0 @@ -5915,8 +5956,14 @@ export class LocalBackend { let summary: { impactedCount: number; risk: string; + riskNote?: string; summary?: { direct: number }; } | null = null; + // Tracks THIS candidate's probe. The outer `probeFailed` is a + // fan-out-wide flag, and `UNKNOWN` now has two causes — a probe that + // threw, and a walk that resolved and found no callers — so the two + // must not be told apart by the enum alone. + let candidateProbeFailed = false; try { summary = await this._runImpactBFS( repo, @@ -5935,6 +5982,7 @@ export class LocalBackend { ); } catch (e) { probeFailed = true; + candidateProbeFailed = true; logQueryError('impact:ambiguous-candidate', e); } return { @@ -5947,6 +5995,18 @@ export class LocalBackend { impactedCount: summary?.impactedCount ?? 0, risk: summary?.risk ?? 'UNKNOWN', direct: summary?.summary?.direct ?? 0, + ...(summary?.riskNote !== undefined ? { riskNote: summary.riskNote } : {}), + // Carry the explanation with the verdict. The single-symbol path + // pairs a zero-caller `UNKNOWN` with a `riskNote` telling the reader + // to confirm with a text search; this shape dropped it, so the same + // enum arrived here bare — losing the entire point of the change on + // the path where a name is ambiguous. + + // `UNKNOWN` used to mean exactly one thing on this path: the probe + // threw. The zero-caller branch gives it a second meaning, so an + // all-UNKNOWN fan-out is no longer distinguishable from a broken one + // without this flag. + ...(candidateProbeFailed ? { probeFailed: true } : {}), }; }), ); @@ -5957,10 +6017,17 @@ export class LocalBackend { candidateSummaries.sort((a, b) => b.impactedCount - a.impactedCount); const maxImpactedCount = candidateSummaries.reduce((m, c) => Math.max(m, c.impactedCount), 0); const RISK_ORDER = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL']; - // If EVERY candidate probe failed (all 'UNKNOWN' — e.g. pool exhaustion - // under the fan-out), the worst real risk is genuinely unknown, not LOW. - // Reporting LOW here would re-introduce the false-safe signal. Only fall to - // the LOW seed when at least one candidate produced a real risk. + // If NO candidate produced a real risk, the worst risk is genuinely + // unknown, not LOW. Reporting LOW here would re-introduce the false-safe + // signal. Only fall to the LOW seed when at least one candidate produced + // a real risk. + // + // Note the two ways this set can be all-UNKNOWN, which is why candidates + // now carry `probeFailed`: every probe THREW (pool exhaustion under the + // fan-out — nothing was measured), or every walk RESOLVED and found no + // callers (measured, and the honest answer). Both are correctly UNKNOWN + // here; the flag is what lets a reader tell a broken fan-out from a + // genuinely caller-less one. const anyKnownRisk = candidateSummaries.some((c) => RISK_ORDER.includes(c.risk)); const maxRisk = anyKnownRisk ? candidateSummaries.reduce( @@ -6262,6 +6329,39 @@ export class LocalBackend { * Never throws: on query error it returns 'exact', so it can only add signal, * never suppress a result. */ + /** + * Fields the analyzer declined to link because every definition of the name + * lives in another language (R3-1), keyed by name. + * + * Cached per index version. `ensureInitialized` deliberately avoids a + * per-call `loadMeta` because every tool call routes through it; this is one + * small read per (index, indexedAt), which re-reads exactly when a re-analyze + * could have changed the answer and never otherwise. + */ + private readonly crossLanguagePropertyCache = new Map< + string, + { indexedAt: string | undefined; byName: ReadonlyMap } + >(); + + private async crossLanguagePropertiesFor( + repo: RepoHandle, + ): Promise> { + const cached = this.crossLanguagePropertyCache.get(repo.lbugPath); + if (cached !== undefined && cached.indexedAt === repo.indexedAt) return cached.byName; + const byName = new Map(); + try { + const meta = await loadMeta(path.dirname(repo.lbugPath)); + for (const entry of meta?.crossLanguageProperties ?? []) { + byName.set(entry.name, entry.languages); + } + } catch { + // A missing or unreadable meta is not worth failing a lookup over; the + // answer is merely less explained, which is the status quo. + } + this.crossLanguagePropertyCache.set(repo.lbugPath, { indexedAt: repo.indexedAt, byName }); + return byName; + } + private async computeEpistemicBoundary( repo: RepoHandle, symId: string, @@ -7050,8 +7150,27 @@ export class LocalBackend { // Risk scoring const processCount = affectedProcesses.length; const moduleCount = affectedModules.length; - let risk = 'LOW'; - if (directCount >= 30 || processCount >= 5 || moduleCount >= 5 || impacted.length >= 200) { + let risk: string; + if (direction === 'upstream' && impacted.length === 0) { + // An upstream walk that resolved NO callers cannot support `LOW`. "Safe + // to change" is a claim ABOUT callers, and this walk found none to reason + // about: the symbol may be genuinely unused, or reached only through a + // reference class this index does not record — a property access on a + // plain object, or a bare-identifier read of a module-scope `Const`, + // neither of which mints a reference site today. Seeding `LOW` from an + // empty result is the same false-safe signal `anyKnownRisk` refuses to + // emit on the ambiguous-candidate path, and that #2687 removed by making + // an undetermined `impactedCount` `null` instead of `0`. + // + // Downstream is deliberately untouched: an empty downstream walk reports + // that this symbol resolved no callees, which is not a safety verdict. + risk = 'UNKNOWN'; + } else if ( + directCount >= 30 || + processCount >= 5 || + moduleCount >= 5 || + impacted.length >= 200 + ) { risk = 'CRITICAL'; } else if ( directCount >= 15 || @@ -7062,6 +7181,8 @@ export class LocalBackend { risk = 'HIGH'; } else if (directCount >= 5 || impacted.length >= 30) { risk = 'MEDIUM'; + } else { + risk = 'LOW'; } // Build per-depth counts (always included, even in summaryOnly mode) @@ -7090,6 +7211,16 @@ export class LocalBackend { direction, impactedCount: impacted.length, risk, + ...(risk === 'UNKNOWN' + ? { + riskNote: + 'No callers resolved. Absence of edges is not evidence the symbol is unused: ' + + 'a caller reaching it through a reference class this index does not record — ' + + 'plain-object property access, a bare-identifier read of a module-scope const — ' + + 'produces no edge to find. Confirm with a text search before treating the ' + + 'change as safe.', + } + : {}), ...epistemic, ...(!traversalComplete && { partial: true }), summary: { diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index ee7d7fbee..f33b3e864 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -455,7 +455,8 @@ WHEN TO USE: Before making code changes — especially refactoring, renaming, or AFTER THIS: Review d=1 items (WILL BREAK). Use context() on high-risk symbols. Output includes: -- risk: LOW / MEDIUM / HIGH / CRITICAL / UNKNOWN +- risk: LOW / MEDIUM / HIGH / CRITICAL / UNKNOWN. An upstream walk that resolved ZERO callers reports UNKNOWN, never LOW, and carries riskNote: "safe to change" is a claim about callers and there were none to reason about, so the symbol is either genuinely unused OR reached only through a reference class the index does not record (plain-object property access, a bare-identifier read of a module-scope const). Confirm with a text search before acting on it. Downstream walks are unaffected — an empty downstream result reports resolved callees, not safety. +- riskNote: string — present only when risk is UNKNOWN; states why the verdict is withheld. - summary: direct callers, processes affected, modules affected - affected_processes: which execution flows break and at which step - affected_modules: which functional areas are hit (direct vs indirect) diff --git a/gitnexus/src/server/analyze-worker-ipc.ts b/gitnexus/src/server/analyze-worker-ipc.ts index 335e8cb83..3bb0a4edd 100644 --- a/gitnexus/src/server/analyze-worker-ipc.ts +++ b/gitnexus/src/server/analyze-worker-ipc.ts @@ -51,7 +51,13 @@ import type { AnalyzeResult } from '../core/run-analyze.js'; */ export type AnalyzeResultIpc = Pick< AnalyzeResult, - 'repoName' | 'repoPath' | 'stats' | 'alreadyUpToDate' | 'ftsRepairedOnly' | 'ftsSkipped' + | 'repoName' + | 'repoPath' + | 'stats' + | 'alreadyUpToDate' + | 'ftsRepairedOnly' + | 'ftsSkipped' + | 'graphWriteCollapsed' >; /** @@ -68,5 +74,9 @@ export function projectAnalyzeResultForIpc(result: AnalyzeResult): AnalyzeResult alreadyUpToDate: result.alreadyUpToDate, ftsRepairedOnly: result.ftsRepairedOnly, ftsSkipped: result.ftsSkipped, + // Carried across IPC so a server-side caller sees the same degraded + // outcome the CLI does; without it the worker reports a clean `complete` + // for a run whose edges are mostly missing. + graphWriteCollapsed: result.graphWriteCollapsed, }; } diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index c9a39303a..fca174dac 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -248,6 +248,24 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j // them. Only comparing against origin/main at MERGE time surfaces it. // PR #2840 (Objective-C, draft) still claims 44 as well — it must move too. // RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING. +// 45 -> 46 for the JavaScript bare-identifier read captures (A2), which emit +// `@reference.read.identifier` in value positions (call arguments, +// default-parameter values, return statements) so a module-scope `const` read +// only by bare name finally mints a reference site, plus the object-literal +// `@definition.property` rule and the TypeScript shape-member captures. All +// PARSE-TIME emission, so a warm cache serves entries carrying none of those +// matches and the new nodes and edges never appear — observed directly while +// developing: a full `analyze --force` produced a byte-identical graph and read +// as a failed hypothesis until the cache was cleared by hand. +// +// This branch originally took 45 and it COLLIDED: #2837 above merged first and +// claimed it. The TENTH entry in this ledger and the FOURTH exact clash, caught +// exactly as the note above says it must be — by comparing against origin/main +// at merge time, not at review time. The pin test cannot catch it: both sides +// asserted `toBe(45)`, which passes while main is already 45, so two capture +// schemas would have shared one PARSE_CACHE_VERSION and the durable ParsedFile +// store would have replayed pre-fix ParsedFiles verbatim for one of them. +// RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING. // // 46 -> 47: method-level Spring `@RequestMapping` now emits wildcard routes // and one route per static `RequestMethod.X` value. These decorator routes live @@ -255,6 +273,76 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j // the previous version would make the fix a no-op for every unchanged Java file. // PR #2856 claims 46, so this branch owns 47. Verified against upstream/main at // 021ac3037 (still 45). RE-CHECK BEFORE MERGE. +// +// ── The FIFTH clash, and the first one the ledger's own convention prevented ── +// #2857 above merged while this branch sat waiting, and it did the right thing: +// it read this PR's claim on 46 and took 47 instead of colliding. That left the +// clash one step further up — 46 was safe, but THIS branch's own 47 (below) was +// not, and neither was anything after it. Every entry from here down has been +// renumbered +1 at merge time. Nothing about the capture sets changed; only the +// numbers did, which is the whole point of re-checking at merge rather than at +// review. Ledger entries 11 through 15. +// +// SIXTH clash, same shape, one merge later: #2833 then took 48 for a generic- +// receiver fix, so this branch's chain shifted +1 AGAIN and now runs 49-53. +// The capture sets have never moved; only the numbers have. This is the cost of +// a single global counter with concurrent PRs, and #2860 is the mechanical fix. +// +// 48 -> 49 for the round-2 capture work: object literals behind an +// identity-preserving wrapper (`const X = Object.freeze({ ... })`) now mint +// `@definition.property` for their keys. Parse-time like every entry above, and +// this one was ALSO observed as a false negative first: `analyze --force` +// against a fixture carrying the new shape returned the pre-change node set and +// read as "the query does not match", until the on-disk cache was removed by +// hand and the same run produced the node. `--force` re-runs the pipeline but +// still serves ParsedFiles from the durable store, so it does not substitute +// for this bump. +// RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING. +// +// 49 -> 50 for the TypeScript object-literal captures (R3-3): named +// object-literal keys and the identity-wrapper form now mint `@definition.property` +// in TYPESCRIPT_QUERIES, as they already did for JavaScript. Parse-time, so a +// warm cache would replay ParsedFiles carrying none of those matches and the +// keys would stay invisible. +// +// #2860 adds a CI check comparing this against the base branch — the merge-time +// re-check this ledger has asked for by hand across ten entries and four exact +// clashes. It is NOT on this branch, so until that one merges the re-check +// below is still manual. +// RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING. +// 50 -> 51 for the return-shape and shorthand captures (R3-4): keys of an +// anonymous literal in return position, and shorthand keys in both that and the +// variable-bound form. Parse-time again. +// +// The v34 hazard, and this branch has already tripped it: a build stamped 48 +// (now 50) was installed and used to analyze two repos BEFORE these captures +// existed, so caches stamped 48 exist that carry none of them. Within one PR the version +// only has to differ from main's, but an INTERMEDIATE build of the same series +// is a different capture set wearing the same number — which is exactly what +// the note above records for 33/34. +// RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING. +// 51 -> 52 is NOT needed for R3-5: that pass is scope-resolution, not +// parse-time capture, so a warm cache replays ParsedFiles that already carry +// everything it reads. Recorded because the reflex on this branch has been to +// bump, and a bump nobody needs still forces every user a full re-parse. +// +// 51 -> 52 IS needed for dispatch-guard routes (R3-7): the JS/TS providers now +// implement `extractDecoratorRoutes`, and decorator routes are worker output +// carried in the parse cache. A warm cache replays a worker result whose +// `decoratorRoutes` predates the extractor entirely, so every hand-rolled route +// stays invisible and `route_map` keeps answering empty — the exact symptom the +// change exists to fix, wearing the mask of "the extractor does not work". +// +// 52 -> 53 for the same-file constant folding that followed it. The v34 hazard +// again, and this branch has now tripped it TWICE: a build stamped 50 (now 52) +// was used to analyze before folding existed, so those caches carry the unfolded +// route set. Caught by measuring — the post-folding run came back suspiciously +// fast and would have reported the pre-folding number, which is precisely how +// "an intermediate build of the same series is a different capture set wearing +// the same number" shows up in practice. Within one PR the version only has to +// differ from main's; against a cache YOU wrote, it has to differ from itself. +// RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING. +// // 47 -> 48: #2833 makes a generic-typed FIELD usable as a call receiver. Three // parse-time changes ride on this one value: @@ -300,7 +388,7 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j // published (46, 47) is superseded by 48, so a warm cache stamped with either is // correctly invalidated. // RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING. -const SCHEMA_BUMP = 48; +const SCHEMA_BUMP = 53; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index 09695e8a4..1a44f70da 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -328,6 +328,38 @@ export interface RepoMeta { * Map keys are repo-relative paths. */ fileHashes?: Record; + /** + * Set when a run finished but the persisted edge count came back far short + * of what the pipeline produced — the B2 "refresh reports SUCCESS while the + * index is unusable" failure (observed as edges collapsing 23009 -> 2170, + * and as a missing `CodeRelation` table, which reads here as a persisted + * count of zero). + * + * Recorded rather than thrown because the metadata IS written and the DB + * does hold rows; what is false is the claim that the index is complete. + * `getIndexIncompleteReasons` turns this into `graph-write-collapsed` so + * `status` and the MCP resources report the index as incomplete instead of + * fresh. Absent on a healthy run. + */ + /** + * Fields whose property reads could not be linked because every definition of + * the name lives in ANOTHER language (R3-1). + * + * Persisted because the graph cannot answer this at query time: the unlinked + * reads mint no edge and no node, so the only record that they existed is the + * analyze pass that declined them. Without it, `context()` on such a field + * shows an empty incoming list that is byte-identical to a genuinely unread + * field — and the two demand opposite actions. + * + * Capped at analyze time; a long tail is not more actionable than a short one. + */ + crossLanguageProperties?: readonly { name: string; languages: string[] }[]; + graphWriteCollapsed?: { + /** Relationships the pipeline produced in memory. */ + expected: number; + /** Relationships readable from the DB after the write. */ + persisted: number; + }; /** * Crash-recovery dirty flag — a generic marker written to the metadata * file (gitnexus.json + its meta.json mirror) BEFORE any destructive DB diff --git a/gitnexus/src/types/pipeline.ts b/gitnexus/src/types/pipeline.ts index 00d530091..25fc2a31d 100644 --- a/gitnexus/src/types/pipeline.ts +++ b/gitnexus/src/types/pipeline.ts @@ -45,4 +45,18 @@ export interface PipelineResult { * additional COPY jobs, not map entries). */ graphEmitManifest?: GraphEmitManifest; + /** + * Property-inference facts from scope resolution (R3-1). Present so a caller + * — a test, the CLI, or a future MCP surface — can tell WHY a field has no + * ACCESSES rows: genuinely unread, ambiguous between candidates, or anchored + * only in a language the per-language inference may not cross. Those are + * three different situations with three different remedies, and without this + * they produce byte-identical empty results. + */ + propertyInference?: { + readonly ambiguous: number; + readonly ambiguousNames: readonly string[]; + readonly crossLanguage: number; + readonly crossLanguageNames: readonly { readonly name: string; readonly languages: string[] }[]; + }; } diff --git a/gitnexus/test/fixtures/dispatch-guard-app/src/server/apiRouteTable.js b/gitnexus/test/fixtures/dispatch-guard-app/src/server/apiRouteTable.js new file mode 100644 index 000000000..1fe0c4102 --- /dev/null +++ b/gitnexus/test/fixtures/dispatch-guard-app/src/server/apiRouteTable.js @@ -0,0 +1,22 @@ +/** + * The path table, in a DIFFERENT file from the handlers — the shape that makes + * per-file reconciliation insufficient. The dispatcher calls this to 404 early; + * it is a membership test, not a route, and every path it lists is served with a + * verb by `liveRoutes.js` (except `/api/live/config`, which only lives here). + */ + +export function isKnownApiPath(pathname) { + if (pathname === '/api/live/portfolio') { + return true + } + + if (pathname === '/api/live/events') { + return true + } + + if (pathname === '/api/live/config') { + return true + } + + return false +} diff --git a/gitnexus/test/fixtures/dispatch-guard-app/src/server/autoTradeRoutes.js b/gitnexus/test/fixtures/dispatch-guard-app/src/server/autoTradeRoutes.js new file mode 100644 index 000000000..23137bfc0 --- /dev/null +++ b/gitnexus/test/fixtures/dispatch-guard-app/src/server/autoTradeRoutes.js @@ -0,0 +1,36 @@ +/** + * The composed-path idiom: every route is built from a base constant rather + * than written as a literal. Before constant folding this whole module was + * invisible — and, worse, indistinguishable from a module with no routes. + */ + +const AUTO_TRADE_BASE_PATH = '/api/live/auto-trade' + +export function createAutoTradeRoutes(ctx) { + return { + async handleAutoTrade(req, res, reqCtx) { + const { pathname } = reqCtx + const autoTradeBasePath = AUTO_TRADE_BASE_PATH + + if (req.method === 'GET' && pathname === `${autoTradeBasePath}/rules`) { + return ctx.listRules() + } + + if (req.method === 'POST' && pathname === `${autoTradeBasePath}/rules`) { + return ctx.createRule() + } + + if (req.method === 'GET' && pathname === AUTO_TRADE_BASE_PATH + '/positions') { + return ctx.listPositions() + } + + // Not foldable — `ruleId` is a runtime value, so no route is claimed + // rather than a wrong one. + if (req.method === 'DELETE' && pathname === `${autoTradeBasePath}/rules/${req.ruleId}`) { + return ctx.deleteRule() + } + + return null + }, + } +} diff --git a/gitnexus/test/fixtures/dispatch-guard-app/src/server/liveRoutes.js b/gitnexus/test/fixtures/dispatch-guard-app/src/server/liveRoutes.js new file mode 100644 index 000000000..35c765cb6 --- /dev/null +++ b/gitnexus/test/fixtures/dispatch-guard-app/src/server/liveRoutes.js @@ -0,0 +1,48 @@ +/** + * A route module in the shape a raw `node:http` server actually uses: a `match` + * that ORs the paths it owns, and a `handle` that dispatches each by verb. + * No framework, no decorator, no filesystem convention — the route exists only + * as a comparison. + */ + +export function createLiveRoutes(ctx) { + return { + match(method, pathname) { + return pathname === '/api/live/portfolio' || pathname === '/api/live/events' + }, + + async handle(req, res, reqCtx) { + const { pathname } = reqCtx + + if (req.method === 'GET' && pathname === '/api/live/portfolio') { + return sendJson(res, await ctx.stores.loadPortfolio()) + } + + if (req.method === 'POST' && pathname === '/api/live/portfolio') { + return sendJson(res, await ctx.stores.resetPortfolio()) + } + + if (req.method === 'GET' && pathname === '/api/live/events') { + return sendJson(res, await ctx.stores.loadEvents()) + } + + // Parameterised: an anchored regex is the only way to express a path + // segment variable without a router. + if (req.method === 'GET' && /^\/api\/live\/runs\/[^/]+$/.test(pathname)) { + return sendJson(res, await ctx.stores.loadRun(pathname)) + } + + return notFound(res) + }, + } +} + +function sendJson(res, body) { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify(body)) +} + +function notFound(res) { + res.writeHead(404) + res.end() +} diff --git a/gitnexus/test/fixtures/dispatch-guard-app/src/server/staticServer.js b/gitnexus/test/fixtures/dispatch-guard-app/src/server/staticServer.js new file mode 100644 index 000000000..ffa2374e5 --- /dev/null +++ b/gitnexus/test/fixtures/dispatch-guard-app/src/server/staticServer.js @@ -0,0 +1,35 @@ +/** + * The negative half of the fixture: a static file server and a filesystem + * helper, both full of path-shaped string comparisons that are NOT routes. + * If any of these mint a Route node, the rule is too loose. + */ + +import path from 'node:path' + +export function serveStatic(req, res, pathname) { + // The bare-'/' normalisation idiom — a branch, not a route declaration. + const file = pathname === '/' ? '/index.html' : pathname + return readAsset(file, res) +} + +export function resolveCacheDir(dir) { + // `path` is the node:path module here; `/tmp/gitnexus-cache` is a directory. + if (path === '/tmp/gitnexus-cache') { + return dir + } + return null +} + +export function isApiRequest(pathname) { + // A namespace test, not a route: nothing serves `/api/`. + return pathname.startsWith('/api/') +} + +export function isNotHealth(pathname) { + // Inequality asserts the path is something ELSE. + return pathname !== '/api/live/health' +} + +function readAsset(file, res) { + res.end(file) +} diff --git a/gitnexus/test/fixtures/lang-resolution/javascript-const-references/config.js b/gitnexus/test/fixtures/lang-resolution/javascript-const-references/config.js new file mode 100644 index 000000000..2e5041c5e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/javascript-const-references/config.js @@ -0,0 +1,15 @@ +// A2: a module-scope const referenced only as a bare identifier. +const DEFAULT_FETCH_LIMIT = 500; + +// Inline-exported form — the common spelling. +export const INLINE_LIMIT = 250; + +export function fetchAll(limit = DEFAULT_FETCH_LIMIT) { + return Math.max(DEFAULT_FETCH_LIMIT, limit); +} + +export function pageSize() { + return DEFAULT_FETCH_LIMIT; +} + +export { DEFAULT_FETCH_LIMIT }; diff --git a/gitnexus/test/fixtures/lang-resolution/javascript-const-references/consumer.js b/gitnexus/test/fixtures/lang-resolution/javascript-const-references/consumer.js new file mode 100644 index 000000000..809949542 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/javascript-const-references/consumer.js @@ -0,0 +1,25 @@ +import { DEFAULT_FETCH_LIMIT, INLINE_LIMIT, pageSize } from './config.js'; + +// A2 cross-file: a named-import reference to a module-scope const. +export function consumerLimit() { + return DEFAULT_FETCH_LIMIT; +} + +// Control: same shape, but the const was exported inline. +export function consumerInline() { + return INLINE_LIMIT; +} + +// Control: a cross-file CALL through the same import statement resolves today. +export function consumerCall() { + return pageSize(); +} + +// Guard: a BLOCK-LOCAL const read in this same file must NOT gain an edge. +// findValueBindingInScope accepts Const/Variable, so without the same-file +// guard this pass would resurrect exactly the inert locals pruneLocalSymbols +// exists to drop. +export function localOnly() { + const localScratchValue = 7; + return Math.max(localScratchValue, 1); +} diff --git a/gitnexus/test/fixtures/lang-resolution/javascript-object-properties/ambiguous.js b/gitnexus/test/fixtures/lang-resolution/javascript-object-properties/ambiguous.js new file mode 100644 index 000000000..c63a0f414 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/javascript-object-properties/ambiguous.js @@ -0,0 +1,15 @@ +// Two DIFFERENT config objects that share a key name. A read of that name +// through an untyped receiver could mean either one, so the unique-name pass +// must emit nothing rather than pick — the safety property that keeps name +// inference from over-connecting on generic keys (id, name, data). +export const httpConfig = { + sharedTimeoutMs: 1000, +}; + +export const dbConfig = { + sharedTimeoutMs: 2000, +}; + +export function readsAmbiguous(cfg) { + return cfg.sharedTimeoutMs; +} diff --git a/gitnexus/test/fixtures/lang-resolution/javascript-object-properties/destructured.js b/gitnexus/test/fixtures/lang-resolution/javascript-object-properties/destructured.js new file mode 100644 index 000000000..90f19867b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/javascript-object-properties/destructured.js @@ -0,0 +1,44 @@ +// R2-1c: the function that IMPLEMENTS the behaviour reads its settings by +// destructuring them out of the argument. The field never appears in a +// member_expression, so before this it had no read site at all and the most +// relevant reader was missing from "who reads this setting?". +export const destructuredDefaults = { + destructuredOnlyField: 7, +}; + +export function appliesDestructured({ destructuredOnlyField = 0 }) { + return destructuredOnlyField * 2; +} + +export function appliesRenamed({ destructuredOnlyField: aliased }) { + return aliased; +} + +export function appliesShorthand({ destructuredOnlyField }) { + return destructuredOnlyField; +} + +// R2-1b: record CONSTRUCTION. Both of these SET `destructuredOnlyField` — +// the nested-under-a-key form and the returned-literal form — and neither is +// bound to a variable, so neither mints a definition. +export function buildPlan(settings) { + return { + exitContract: { + destructuredOnlyField: settings.raw ?? 0, + }, + }; +} + +export function buildFlat(settings) { + return { + destructuredOnlyField: settings.raw ?? 1, + }; +} + +// NEGATIVE CONTROL: an inline call-argument prop bag is call-site data, not a +// record with a name attached, and must stay excluded exactly as it is for +// definitions. +function consume(bag) { + return bag; +} +export const consumed = consume({ notAConstructedField: 3 }); diff --git a/gitnexus/test/fixtures/lang-resolution/javascript-object-properties/fixture-shapes.test.js b/gitnexus/test/fixtures/lang-resolution/javascript-object-properties/fixture-shapes.test.js new file mode 100644 index 000000000..ebf8a73fe --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/javascript-object-properties/fixture-shapes.test.js @@ -0,0 +1,9 @@ +// A TEST file that constructs a throwaway shape with a production field name. +// Measured on the reporting repo: four of seven JavaScript anchors for one +// field lived in `tests/`, competing with the three real ones and making every +// production read ambiguous. A read in production cannot mean any of these. +export function buildTestFixture() { + return { + productionAndTestField: 'fixture', + }; +} diff --git a/gitnexus/test/fixtures/lang-resolution/javascript-object-properties/frozen.js b/gitnexus/test/fixtures/lang-resolution/javascript-object-properties/frozen.js new file mode 100644 index 000000000..3b6c6da3a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/javascript-object-properties/frozen.js @@ -0,0 +1,38 @@ +// R2-1a: a named shape published behind an IDENTITY-PRESERVING wrapper. +// +// Freezing a config object is the idiomatic way to publish an immutable +// contract, so the fields most worth querying are exactly the ones the bare +// `value: (object)` rule cannot see — one call expression sits between the +// declarator and the literal. +export const INERT_EXIT_CONTRACT = Object.freeze({ + frozenExitModel: 'bracket', + frozenMaxHoldMs: 0, +}); + +export const SEALED_LIMITS = Object.seal({ + sealedMaxNotional: 100, +}); + +export function readsFrozen() { + return INERT_EXIT_CONTRACT.frozenMaxHoldMs; +} + +// NEGATIVE CONTROL. `buildRules` returns a value of its OWN making, so the +// literal here is an argument, not the thing `derivedRules` is bound to. +// Attributing `notAMemberOfDerived` to `derivedRules` would be a fabrication, +// which is why the wrapper allowlist is three identity functions and not +// "any call expression". +function buildRules(seed) { + return { ...seed, extra: true }; +} + +export const derivedRules = buildRules({ notAMemberOfDerived: 1 }); + +// SECOND NEGATIVE CONTROL, and the one that actually exercises the allowlist. +// The control above is rejected STRUCTURALLY (a bare identifier callee never +// matches `function: (member_expression ...)`), so it would pass even if the +// wrapper predicate were dropped entirely. `Object.entries` has the same shape +// as `Object.freeze` and differs ONLY by name — it transforms its argument +// into an array of pairs rather than returning it — so this is the case that +// fails the moment the name check stops being enforced. +export const entryPairs = Object.entries({ notAMemberOfEntries: 1 }); diff --git a/gitnexus/test/fixtures/lang-resolution/javascript-object-properties/narrow-alpha.js b/gitnexus/test/fixtures/lang-resolution/javascript-object-properties/narrow-alpha.js new file mode 100644 index 000000000..02965873d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/javascript-object-properties/narrow-alpha.js @@ -0,0 +1,5 @@ +// R2 narrowing, candidate A. Same key name as narrow-beta.js, so a +// workspace-wide uniqueness check sees two definitions and refuses. +export const alphaCfg = { + narrowedTimeoutMs: 1000, +}; diff --git a/gitnexus/test/fixtures/lang-resolution/javascript-object-properties/narrow-beta.js b/gitnexus/test/fixtures/lang-resolution/javascript-object-properties/narrow-beta.js new file mode 100644 index 000000000..53d15dc60 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/javascript-object-properties/narrow-beta.js @@ -0,0 +1,6 @@ +// R2 narrowing, candidate B — deliberately NOT imported by the reader below. +// This is the "one-off script carrying the same key" case that made strict +// workspace uniqueness decline every real read in the reporting repo. +export const betaCfg = { + narrowedTimeoutMs: 2000, +}; diff --git a/gitnexus/test/fixtures/lang-resolution/javascript-object-properties/narrow-both.js b/gitnexus/test/fixtures/lang-resolution/javascript-object-properties/narrow-both.js new file mode 100644 index 000000000..b5508c626 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/javascript-object-properties/narrow-both.js @@ -0,0 +1,11 @@ +// CONTROL: imports BOTH candidates, so direct-import evidence does not +// disambiguate and the read must stay refused. Narrowing is meant to use +// scope evidence, not to lower the bar for guessing. +import { alphaCfg } from './narrow-alpha.js'; +import { betaCfg } from './narrow-beta.js'; + +export function readsBothVisible(cfg) { + return cfg.narrowedTimeoutMs; +} + +export const bothSeen = [alphaCfg, betaCfg]; diff --git a/gitnexus/test/fixtures/lang-resolution/javascript-object-properties/narrow-reader.js b/gitnexus/test/fixtures/lang-resolution/javascript-object-properties/narrow-reader.js new file mode 100644 index 000000000..9a55a635a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/javascript-object-properties/narrow-reader.js @@ -0,0 +1,12 @@ +// Imports exactly ONE of the two definitions. The read below cannot plausibly +// mean the other — the reader cannot see it — so direct-import evidence picks +// the candidate that workspace uniqueness alone had to abandon. +import { alphaCfg } from './narrow-alpha.js'; + +export function readsNarrowed(cfg) { + return cfg.narrowedTimeoutMs; +} + +export function readsViaBinding() { + return alphaCfg.narrowedTimeoutMs; +} diff --git a/gitnexus/test/fixtures/lang-resolution/javascript-object-properties/production-reader.js b/gitnexus/test/fixtures/lang-resolution/javascript-object-properties/production-reader.js new file mode 100644 index 000000000..6c7638059 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/javascript-object-properties/production-reader.js @@ -0,0 +1,8 @@ +// The reader lives in its OWN file and imports neither anchor, so no same-file +// or direct-import tier can decide this. What is left is production-vs-test, +// which is exactly the tier under test — with a reader beside the production +// anchor, the same-file tier resolves it either way and the assertion proves +// nothing. +export function readsProductionShape(bag) { + return bag.productionAndTestField; +} diff --git a/gitnexus/test/fixtures/lang-resolution/javascript-object-properties/return-shape-typed.js b/gitnexus/test/fixtures/lang-resolution/javascript-object-properties/return-shape-typed.js new file mode 100644 index 000000000..67cdae6b9 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/javascript-object-properties/return-shape-typed.js @@ -0,0 +1,32 @@ +// R3-5: TWO producers returning the same field name — the case name inference +// must refuse, because `x.ambiguousProducedField` alone cannot say which shape +// is meant. The receiver's binding says which, so this resolves precisely. +export function producerAlpha(row) { + return { + ambiguousProducedField: row.a, + }; +} + +export function producerBeta(row) { + return { + ambiguousProducedField: row.b, + }; +} + +// BOUND to the call result, so the type binding attaches. +export function readsAlpha(row) { + const shaped = producerAlpha(row); + return shaped.ambiguousProducedField; +} + +export function readsBeta(row) { + const shaped = producerBeta(row); + return shaped.ambiguousProducedField; +} + +// THE BOUND of the mechanism: a bare parameter has no binding here, because +// typing it needs the CALLER's type to flow in. This must stay unresolved and +// fall through to name inference, which will refuse it (two producers). +export function readsUnbound(shaped) { + return shaped.ambiguousProducedField; +} diff --git a/gitnexus/test/fixtures/lang-resolution/javascript-object-properties/return-shape.js b/gitnexus/test/fixtures/lang-resolution/javascript-object-properties/return-shape.js new file mode 100644 index 000000000..cc7e49877 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/javascript-object-properties/return-shape.js @@ -0,0 +1,55 @@ +// R3-4: an anonymous literal in return position — the dominant shape in +// idiomatic JS (437 sites in one backend directory of the reporting repo), +// including the ~25-field payload of its entire signal pipeline. It binds to +// nothing, so its keys had no anchor and could not be named at all. +export function formatAlert(row) { + const shorthandOnlyField = row.shorthand; + return { + returnShapeOnlyField: row.raw, + sharedWithDeclared: row.other, + // SHORTHAND — the commonest spelling, and the one `(pair)` cannot match. + // The reporting repo's own alert payload is mostly this form. + shorthandOnlyField, + }; +} + +// A SECOND function returning a same-named key. Two distinct shapes, so two +// distinct nodes — qualifying by the owning function is what keeps them apart. +export function formatSummary(row) { + return { + summaryOnlyField: row.summary, + }; +} + +// The reader. Untyped receiver, so this is the name-inference path. +export function readsReturnShape(alert) { + return alert.returnShapeOnlyField; +} + +// The R2-1b GUARANTEE, as a fixture: a DECLARED anchor for the same name. +// `sharedWithDeclared` is both a named-object key and a return-shape key, and a +// read of it must keep resolving to the DECLARED one — otherwise indexing +// return shapes would silently move existing answers. +export const declaredHome = { + sharedWithDeclared: 1, +}; + +export function readsShared(bag) { + return bag.sharedWithDeclared; +} + +// Anonymous functions give nothing to qualify by, so their return shapes stay +// unanchored rather than colliding on a shared empty owner. +export const anonHolder = [ + function (row) { + return { anonReturnKey: row.x }; + }, +]; + +// The production anchor for a name a test fixture also constructs. A read here +// must resolve to THIS one, not to the fixture's. +export function buildProductionShape(row) { + return { + productionAndTestField: row.real, + }; +} diff --git a/gitnexus/test/fixtures/lang-resolution/javascript-object-properties/rules.js b/gitnexus/test/fixtures/lang-resolution/javascript-object-properties/rules.js new file mode 100644 index 000000000..15b3a7929 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/javascript-object-properties/rules.js @@ -0,0 +1,20 @@ +// A1/A5: a plain object literal held by a module const — no class anywhere. +export const exitRules = { + exitMinAtrMult: 1.5, + stopAtrMult: 2.0, +}; + +// A5: property WRITE on a plain object (the "where is this field SET?" case). +export function tightenExit() { + exitRules.exitMinAtrMult = 3.0; +} + +// A1: property READ through the holding variable — receiver IS typeable. +export function readViaVariable() { + return exitRules.exitMinAtrMult; +} + +// A1: property READ through an untyped param (the option-bag case). +export function applyRules(cfg) { + return cfg.exitMinAtrMult * cfg.stopAtrMult; +} diff --git a/gitnexus/test/fixtures/lang-resolution/polyglot-property-isolation/Loyalty.java b/gitnexus/test/fixtures/lang-resolution/polyglot-property-isolation/Loyalty.java new file mode 100644 index 000000000..16d731460 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/polyglot-property-isolation/Loyalty.java @@ -0,0 +1,11 @@ +// RV-5: the ONLY declaration of `loyaltyPointsBalance` in the workspace, and it +// is Java. Nothing in the JS file below can call into it. +package shop; + +public class Loyalty { + private int loyaltyPointsBalance; + + public int read() { + return loyaltyPointsBalance; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/polyglot-property-isolation/dashboard.ts b/gitnexus/test/fixtures/lang-resolution/polyglot-property-isolation/dashboard.ts new file mode 100644 index 000000000..8cf33fbca --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/polyglot-property-isolation/dashboard.ts @@ -0,0 +1,24 @@ +// The MIRROR of the Java/JS case. TypeScript sets +// `fieldFallbackOnMethodLookup: false`, so name inference does not run for it +// at all — correctly, since a type system should answer precisely. But that +// opt-out also skipped REPORTING, so this read answered the same silent empty +// as the case round 3 was filed about, in the other direction. +export function renderJsOnly(bag: { [k: string]: number }): number { + return bag.jsOnlyThreshold; +} + +// The case that makes `reportOnly` load-bearing: a TypeScript property and a +// TypeScript read of it through an untyped receiver. Name inference COULD link +// these — same language, unique name — which is precisely what +// `fieldFallbackOnMethodLookup: false` forbids. Running the pass for reporting +// must not quietly re-enable it. +// An INTERFACE member, not an object literal: the object-literal Property rule +// is JavaScript-only, so a `const X = { ... }` in a .ts file mints no node and +// there would be nothing for inference to link either way. +export interface TsBudget { + tsOnlyBudget: number; +} + +export function readsTsOnly(bag: { [k: string]: number }): number { + return bag.tsOnlyBudget; +} diff --git a/gitnexus/test/fixtures/lang-resolution/polyglot-property-isolation/settings.js b/gitnexus/test/fixtures/lang-resolution/polyglot-property-isolation/settings.js new file mode 100644 index 000000000..063584ee8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/polyglot-property-isolation/settings.js @@ -0,0 +1,33 @@ +// A JS read of the same name through an untyped receiver. Workspace-wide the +// name is unique, so unique-name inference resolved it — to a Java private +// field, across a language boundary with no call path. +export function renderLoyalty(cfg) { + return cfg.loyaltyPointsBalance; +} + +// CONTROL: a same-language target the pass SHOULD still reach, so the fix is +// shown to restrict by language rather than to disable the pass. +export const jsConfig = { + jsOnlyThreshold: 10, +}; + +export function readsJsOnly(bag) { + return bag.jsOnlyThreshold; +} + +// BOUND-RECEIVER ARM (review finding 2). The reads above have UNTYPED receivers, +// so they route through unique-name inference — the pass this fixture was +// written to police. One extra token gives the receiver a type and routes an +// identical read through `return-shape-members.ts` instead: a sibling pass that +// consumed the same whole-graph index with no language restriction, and emitted +// at the 0.9 PRECISE tier where a `minConfidence` floor cannot filter it out. +// +// `Loyalty` is declared ONLY in Java. Construction types the receiver through +// the shared (polyglot) class registry, so the producer resolves into +// `Loyalty.java` and its member genuinely lives in that same file — which is +// why a same-FILE check alone waves this through and only a same-LANGUAGE check +// stops it. Nothing here may resolve. +export function readsBoundLoyalty() { + const bound = new Loyalty(); + return bound.loyaltyPointsBalance; +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-alias-fields/config.ts b/gitnexus/test/fixtures/lang-resolution/typescript-alias-fields/config.ts new file mode 100644 index 000000000..4e9dd1b39 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-alias-fields/config.ts @@ -0,0 +1,24 @@ +// R3-3: the single most common config idiom in TypeScript. Both the named +// object-literal rule and the identity-wrapper rule were JAVASCRIPT_QUERIES +// only, so none of these keys minted a node: `context()` answered "Symbol not +// found" and a precise read through the holding variable had nothing to +// resolve to. +export const tsRuntimeConfig = { + tsConfigRetries: 3, + tsConfigTimeoutMs: 500, +}; + +export const TS_FROZEN_LIMITS = Object.freeze({ + tsFrozenMaxNotional: 100, +}); + +// The PRECISE path — the one TypeScript is supposed to use. The receiver is +// the holding variable, so this needs no name inference. +export function readsTsConfig(): number { + return tsRuntimeConfig.tsConfigRetries + TS_FROZEN_LIMITS.tsFrozenMaxNotional; +} + +// NEGATIVE CONTROL, same allowlist bound as the JavaScript rule: a non-identity +// call returns a value of its own, so the literal's keys are arguments rather +// than members of the binding. +export const tsMapped = Object.entries({ tsNotAMember: 1 }); diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-alias-fields/contracts.ts b/gitnexus/test/fixtures/lang-resolution/typescript-alias-fields/contracts.ts new file mode 100644 index 000000000..56657ac0e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-alias-fields/contracts.ts @@ -0,0 +1,54 @@ +// A4: API contracts modelled as type aliases and interfaces — the common style +// in a TS frontend. Neither the alias node nor the members of either shape were +// indexed, so there was no graph path from a field to its consumers. +export type LiveModeConfig = { + bookSlots: number; + bookNotionalUsdt: number; +}; + +export interface LiveModeIface { + ifaceSlots: number; +} + +export function renderAlias(cfg: LiveModeConfig): number { + return cfg.bookNotionalUsdt + cfg.bookSlots; +} + +export function renderIface(cfg: LiveModeIface): number { + return cfg.ifaceSlots; +} + + +// RV-4: the shapes that made an unanchored `property_signature` rule collide. +// Every inline object type below declares a UNIQUELY-named member, because a +// collision and a correct exclusion both leave exactly one node behind — +// counting ids cannot tell them apart, so the discriminator has to be a name +// that only the unanchored rule could ever produce. +export class Svc { + retries = 1; + + run(opts: { retries: number; inlineParamOnlyKey: number }): number { + return opts.retries + opts.inlineParamOnlyKey + this.retries; + } +} + +export interface Repo { + retries: number; + find(q: { retries: number; inlineQueryOnlyKey: number }): void; +} + +// Nested object type: its members are not members of the alias. +export type NestedConfig = { + host: string; + db: { nestedOnlyKey: string }; +}; + +// Inline RETURN type — the third position the unanchored rule reached. +// The TYPE annotation's member and the returned VALUE's key are named +// differently ON PURPOSE. They are separate rules with opposite expectations — +// an inline return TYPE must mint nothing (RV-4), while a returned literal's +// keys are a function's return shape and must mint (R3-4) — and sharing a name +// left the RV-4 assertion unable to tell which rule produced the node. +export function buildInline(): { inlineReturnTypeOnlyKey: number } { + return { inlineReturnValueOnlyKey: 1 } as never; +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-alias-methods/shape.ts b/gitnexus/test/fixtures/lang-resolution/typescript-alias-methods/shape.ts new file mode 100644 index 000000000..31be90a89 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-alias-methods/shape.ts @@ -0,0 +1,13 @@ +// A METHOD-shaped alias member. `schema.ts` declares the `TypeAlias|Method` +// relation pair, but nothing in the corpus emitted one — so the declaration was +// unproven, which is indistinguishable from a missing one until an analyze +// aborts with UndeclaredRelationPairError on a real repo. +export type Dispatcher = { + handlerCount: number; + dispatch(event: string): void; + teardown(): Promise; +}; + +export function runDispatcher(d: Dispatcher): void { + d.dispatch('tick'); +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-namespace-const/config.ts b/gitnexus/test/fixtures/lang-resolution/typescript-namespace-const/config.ts new file mode 100644 index 000000000..ab8a87cc5 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-namespace-const/config.ts @@ -0,0 +1,23 @@ +// RV-9: a const declared inside a TS `namespace`. Its binding scope is +// `Namespace`, not `Module`, so the module-level set built for the block-local +// filter did not contain it and its reads were dropped as if it were a local. +// +// The same shape exists in Rust (`mod`), C++ and C# — anywhere a language nests +// an importable value one level below the file root. +export namespace Limits { + export const NAMESPACED_MAX = 42; + + export function withinNamespace(): number { + return NAMESPACED_MAX; + } +} + +// CONTROL: a namespace declared INSIDE a function body is a local like anything +// else there, so its const must stay excluded. +export function makeLocalNamespace(): number { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Inner { + export const innerLocalValue = 7; + } + return Inner.innerLocalValue; +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-namespace-const/consumer.ts b/gitnexus/test/fixtures/lang-resolution/typescript-namespace-const/consumer.ts new file mode 100644 index 000000000..0a3047998 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-namespace-const/consumer.ts @@ -0,0 +1,5 @@ +import { Limits } from './config.js'; + +export function readsNamespacedConst(): number { + return Limits.NAMESPACED_MAX; +} diff --git a/gitnexus/test/fixtures/pipeline-golden/mini-repo/expected-graph.json b/gitnexus/test/fixtures/pipeline-golden/mini-repo/expected-graph.json index 48e06aea3..14fa41596 100644 --- a/gitnexus/test/fixtures/pipeline-golden/mini-repo/expected-graph.json +++ b/gitnexus/test/fixtures/pipeline-golden/mini-repo/expected-graph.json @@ -2,8 +2,8 @@ "capture": "initial capture (U8, post-U1–U7)", "fixture": "mini-repo", "totalFileCount": 7, - "symbols": 33, - "relationships": 69, + "symbols": 51, + "relationships": 95, "processes": 4, "byType": { "Class": 1, @@ -13,16 +13,20 @@ "Function": 12, "Interface": 3, "Method": 1, - "Process": 4 + "Process": 4, + "Property": 18 }, "byRelType": { + "ACCESSES": 3, "CALLS": 9, "CONTAINS": 7, - "DEFINES": 16, + "DEFINES": 26, "HAS_METHOD": 1, + "HAS_PROPERTY": 8, "IMPORTS": 12, "MEMBER_OF": 12, - "STEP_IN_PROCESS": 12 + "STEP_IN_PROCESS": 12, + "USES": 5 }, - "edgeDigest": "02858462a2acca13f09b7ae2a81d56fe858e67064552ea966cd9ca242371e029" + "edgeDigest": "5dddd1f466deeda1197eb61b480a4f3a5da67dfc0d00ace22377158366e87d00" } diff --git a/gitnexus/test/integration/context-cross-language-anchor.test.ts b/gitnexus/test/integration/context-cross-language-anchor.test.ts new file mode 100644 index 000000000..f8cffb3a6 --- /dev/null +++ b/gitnexus/test/integration/context-cross-language-anchor.test.ts @@ -0,0 +1,112 @@ +/** + * R3-1 — an empty incoming list must say WHY when the analyzer declined. + * + * Per-language inference is correct: a JavaScript read must not resolve to a + * Java field on name uniqueness alone. But declining silently makes an empty + * result for a field anchored only in another language byte-identical to an + * empty result for a field nobody reads — and those demand opposite actions + * ("look in the other language, or grep" versus "delete it"). + * + * Found out-of-sample: six fields in the reporting repo whose definitions live + * only in `apps/research-dashboard/**` answered 0 for every backend read, while + * the in-sample set — which happened to be exactly the anchored subset — scored + * 5/5. That gap is the whole finding. + * + * The graph cannot answer this at query time: the unlinked reads mint no edge + * and no node, so the only record is the analyze pass that declined them. Hence + * the fact travels through repo meta. + */ +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { LocalBackend } from '../../src/mcp/local/local-backend.js'; +import { loadMeta, saveMeta } from '../../src/storage/repo-manager.js'; +import { withTestLbugDB, type IndexedDBHandle } from '../helpers/test-indexed-db.js'; + +vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listRegisteredRepos: vi.fn().mockResolvedValue([]), + cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }), + findSiblingClones: vi.fn().mockResolvedValue([]), + }; +}); + +import { listRegisteredRepos } from '../../src/storage/repo-manager.js'; + +const SEED = [ + // The anchor: a TypeScript-side property. Its JavaScript readers produced no + // edge, which is exactly the state under test. + `CREATE (p:\`Property\` {id:'Property:apps/dash/src/api/live.ts:LiveRow.wickRatio', name:'wickRatio', filePath:'apps/dash/src/api/live.ts', startLine:4, endLine:4, content:'wickRatio: number;', description:''})`, + // A control property with no cross-language story at all. + `CREATE (q:\`Property\` {id:'Property:apps/dash/src/api/live.ts:LiveRow.plainField', name:'plainField', filePath:'apps/dash/src/api/live.ts', startLine:5, endLine:5, content:'plainField: number;', description:''})`, +]; + +type BackendHandle = IndexedDBHandle & { _backend?: LocalBackend }; + +withTestLbugDB( + 'context-cross-language-anchor', + (handle) => { + describe('context() explains a cross-language-only anchor (R3-1)', () => { + let backend: LocalBackend; + + beforeAll(async () => { + const ext = handle as BackendHandle; + if (!ext._backend) { + throw new Error('LocalBackend not initialized — afterSetup did not attach _backend'); + } + backend = ext._backend; + }); + + it('attaches the reason and the anchor languages', async () => { + const result = await backend.callTool('context', { name: 'wickRatio' }); + expect(result).not.toHaveProperty('error'); + // Asserted present, NOT guarded on. An `if (undefined) return` here + // would skip every assertion below and pass with the feature deleted — + // which is exactly how the round-2 ambiguity assertion went vacuous. + expect(result.anchorLanguages).toBeDefined(); + expect(result.anchorLanguages).toContain('typescript'); + expect(String(result.unresolved)).toMatch(/not linked/i); + // The note must not be mistakable for "unused". + expect(String(result.unresolved)).toMatch(/not evidence the field is unused/i); + }); + + it('says nothing for a property with no cross-language story', async () => { + const result = await backend.callTool('context', { name: 'plainField' }); + expect(result).not.toHaveProperty('error'); + expect(result.unresolved).toBeUndefined(); + expect(result.anchorLanguages).toBeUndefined(); + }); + }); + }, + { + seed: SEED, + poolAdapter: true, + afterSetup: async (handle) => { + const storagePath = handle.tmpHandle.dbPath; + vi.mocked(listRegisteredRepos).mockResolvedValue([ + { + name: 'test-repo', + path: '/test/repo', + storagePath, + indexedAt: new Date().toISOString(), + lastCommit: 'abc123', + stats: { files: 2, nodes: 2, communities: 0, processes: 0 }, + }, + ] as never); + // Stamp exactly what the analyze pass records when it declines to link a + // field whose only definitions are in another language. + const metaDir = path.dirname(handle.dbPath); + const meta = (await loadMeta(metaDir)) ?? {}; + await saveMeta(metaDir, { + ...meta, + crossLanguageProperties: [{ name: 'wickRatio', languages: ['typescript'] }], + } as never); + expect(fs.existsSync(metaDir)).toBe(true); + const backend = new LocalBackend(); + await backend.init(); + (handle as BackendHandle)._backend = backend; + }, + }, +); diff --git a/gitnexus/test/integration/dispatch-guard-route-pipeline.test.ts b/gitnexus/test/integration/dispatch-guard-route-pipeline.test.ts new file mode 100644 index 000000000..a8fb9153a --- /dev/null +++ b/gitnexus/test/integration/dispatch-guard-route-pipeline.test.ts @@ -0,0 +1,177 @@ +/** + * End-to-end coverage of hand-rolled dispatch-guard route ingestion (R3-7). + * + * The reported symptom was a whole tool answering empty: `route_map` returned + * `{"routes": [], "total": 0, "message": "No routes found in this project."}` + * for a repo with seventeen route modules and 113 path comparisons. Every route + * extractor before this one requires a FRAMEWORK to declare the route, and a + * raw `node:http` server has none — it declares routes by comparing the path. + * + * The unit suite (`test/unit/dispatch-guard-routes.test.ts`) pins the + * extraction rules. This one pins the parts only the pipeline can prove: that + * the routes reach the graph as `Route` nodes, that they carry the verb and the + * dispatch-guard provenance, and that the handler resolves to a real symbol. + * + * The fixture also carries a static file server whose path comparisons must NOT + * become routes — precision is the property that matters most here, since + * `route_map` presents its output as fact. + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'node:path'; +import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; +import type { PipelineResult } from '../../types/pipeline.js'; +import { DISPATCH_GUARD_SOURCE } from '../../src/core/ingestion/route-extractors/dispatch-guard.js'; + +const FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'dispatch-guard-app'); + +describe('hand-rolled dispatch-guard route ingestion pipeline', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(FIXTURE, () => {}, {}); + }, 60_000); + + interface RouteView { + readonly name: string; + readonly method: string | undefined; + readonly handlerSymbolId: string | undefined; + } + + const routes = (): RouteView[] => { + const out: RouteView[] = []; + result.graph.forEachNode((n) => { + if (n.label !== 'Route') return; + out.push({ + name: String(n.properties.name), + method: n.properties.method as string | undefined, + handlerSymbolId: n.properties.handlerSymbolId as string | undefined, + }); + }); + return out.sort((a, b) => `${a.method} ${a.name}`.localeCompare(`${b.method} ${b.name}`)); + }; + + const routeNames = (): string[] => routes().map((r) => r.name); + + it('detects routes at all — the reported symptom was zero', () => { + // Asserted as its own case because every expectation below is vacuous if + // the pipeline emits no Route nodes; `toContain` on an empty array fails + // with a message about the missing element, not about the empty set. + expect(routes().length).toBeGreaterThan(0); + }); + + it('emits one Route per verb on a path dispatched by verb', () => { + const portfolio = routes().filter((r) => r.name === '/api/live/portfolio'); + expect(portfolio.map((r) => r.method).sort()).toEqual(['GET', 'POST']); + }); + + it('converts an anchored regex guard into a parameterised route', () => { + expect(routeNames()).toContain('/api/live/runs/{param1}'); + }); + + it('resolves the enclosing function as the route handler', () => { + const portfolioGet = routes().find( + (r) => r.name === '/api/live/portfolio' && r.method === 'GET', + ); + expect(portfolioGet).toBeDefined(); + // `handle` is the object-literal method that performs the dispatch. The id + // is asserted by shape rather than pinned, so a change to id formatting + // does not read as a resolution failure. + expect(portfolioGet?.handlerSymbolId).toMatch(/handle/); + }); + + // A whole route module built from a base constant. Before folding it produced + // nothing and looked identical to a module with no routes at all. + describe('paths composed from a same-file constant', () => { + it('folds a template substitution through an alias into a real route', () => { + const rules = routes().filter((r) => r.name === '/api/live/auto-trade/rules'); + expect(rules.map((r) => r.method).sort()).toEqual(['GET', 'POST']); + }); + + it('folds + concatenation of the base constant', () => { + expect(routeNames()).toContain('/api/live/auto-trade/positions'); + }); + + it('claims nothing when a substitution is a runtime value', () => { + expect(routeNames().some((n) => n.includes('auto-trade/rules/'))).toBe(false); + }); + + it('attributes them to the composing handler', () => { + const rule = routes().find((r) => r.name === '/api/live/auto-trade/rules'); + expect(rule?.handlerSymbolId).toMatch(/handleAutoTrade/); + }); + }); + + it('records dispatch-guard provenance on the HANDLES_ROUTE edge', () => { + const reasons: string[] = []; + result.graph.forEachRelationship((r) => { + if (r.type === 'HANDLES_ROUTE') reasons.push(String(r.reason)); + }); + expect(reasons.length).toBeGreaterThan(0); + // Not `decorator-…`: the route is INFERRED from a comparison, not DECLARED + // by an annotation, and the map should say which. + expect(reasons).toContain(DISPATCH_GUARD_SOURCE); + }); + + describe('precision — what the static server must NOT contribute', () => { + // Every assertion in this block is an absence, and an absence is satisfied + // just as well by a file that was never read. Prove it WAS read first, + // otherwise the whole block is decoration. + it('ingested the static server at all', () => { + const symbols: string[] = []; + result.graph.forEachNode((n) => { + if (String(n.properties.filePath ?? '').endsWith('staticServer.js')) { + symbols.push(String(n.properties.name)); + } + }); + expect(symbols).toContain('serveStatic'); + expect(symbols).toContain('resolveCacheDir'); + }); + + it('does not mint a route for the bare-"/" normalisation branch', () => { + expect(routeNames()).not.toContain('/'); + expect(routeNames()).not.toContain('/index.html'); + }); + + it('does not mint a route for a filesystem path comparison', () => { + expect(routeNames()).not.toContain('/tmp/gitnexus-cache'); + }); + + it('does not mint a route for a startsWith namespace test', () => { + expect(routeNames()).not.toContain('/api/'); + expect(routeNames()).not.toContain('/api'); + }); + + it('does not mint a route for an inequality comparison', () => { + expect(routeNames()).not.toContain('/api/live/health'); + }); + }); + + // The reconciliation that per-file logic cannot do. `apiRouteTable.js` is a + // membership test in a SEPARATE file from the handlers, so from inside either + // file alone both halves look like routes; only the whole registry can tell + // that `/api/live/events` is one route, not two. + describe('cross-file reconciliation of the split route table', () => { + const byName = (name: string) => routes().filter((r) => r.name === name); + + it('drops the table entry when a handler file claims the URL with a verb', () => { + expect(byName('/api/live/events').map((r) => r.method)).toEqual(['GET']); + expect( + byName('/api/live/portfolio') + .map((r) => r.method) + .sort(), + ).toEqual(['GET', 'POST']); + }); + + it('keeps a table entry no handler claims with a verb', () => { + // `/api/live/config` exists only in the table. Dropping it would trade a + // duplicate for a missing route. + expect(byName('/api/live/config').map((r) => r.method)).toEqual([undefined]); + }); + + it('attributes the surviving route to the handler file, not the table', () => { + const events = byName('/api/live/events')[0]; + expect(events?.handlerSymbolId).toMatch(/handle/); + }); + }); +}); diff --git a/gitnexus/test/integration/impact-zero-caller-risk.test.ts b/gitnexus/test/integration/impact-zero-caller-risk.test.ts new file mode 100644 index 000000000..305fb51ba --- /dev/null +++ b/gitnexus/test/integration/impact-zero-caller-risk.test.ts @@ -0,0 +1,154 @@ +/** + * Integration test: an empty upstream walk is UNKNOWN risk, never LOW. + * + * `risk: LOW` asserts "safe to change". That is a claim ABOUT callers, so a + * walk that resolved NONE has nothing to base it on: the symbol is either + * genuinely unused, or reached only through a reference class the index does + * not record (a property access on a plain object, a bare-identifier read of a + * module-scope `Const` — neither mints a reference site today). Reporting LOW + * there is the false-safe signal `anyKnownRisk` already refuses to emit on the + * ambiguous-candidate path, and that #2687 removed by making an undetermined + * `impactedCount` `null` rather than `0`. + * + * Direction matters: an empty DOWNSTREAM walk says this symbol resolved no + * callees, which is not a safety verdict, so it keeps its existing risk. + */ +import { it, expect, beforeAll, vi } from 'vitest'; +import { LocalBackend } from '../../src/mcp/local/local-backend.js'; +import { listRegisteredRepos } from '../../src/storage/repo-manager.js'; +import { withTestLbugDB, type IndexedDBHandle } from '../helpers/test-indexed-db.js'; + +vi.mock('../../src/storage/repo-manager.js', () => ({ + listRegisteredRepos: vi.fn().mockResolvedValue([]), + cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }), + findSiblingClones: vi.fn().mockResolvedValue([]), +})); + +const SEED = [ + // No edges in either direction — the empty-walk case. + `CREATE (orphan:Function {id: 'Function:src/orphan.ts:orphanHelper', name: 'orphanHelper', filePath: 'src/orphan.ts', startLine: 1, endLine: 3, isExported: true, content: '', description: ''})`, + // A resolved caller -> callee pair — the control that must stay LOW. + `CREATE (used:Function {id: 'Function:src/used.ts:usedHelper', name: 'usedHelper', filePath: 'src/used.ts', startLine: 1, endLine: 3, isExported: true, content: '', description: ''})`, + `CREATE (caller:Function {id: 'Function:src/caller.ts:callerFn', name: 'callerFn', filePath: 'src/caller.ts', startLine: 1, endLine: 8, isExported: true, content: '', description: ''})`, + `MATCH (a:Function {id:'Function:src/caller.ts:callerFn'}), (b:Function {id:'Function:src/used.ts:usedHelper'}) CREATE (a)-[:CodeRelation {type:'CALLS', confidence:0.9, reason:'direct', step:0}]->(b)`, + // Two symbols sharing a name, both caller-less: forces the AMBIGUOUS + // fan-out, which builds its own candidate shape rather than returning the + // single-symbol one. + `CREATE (t1:Function {id: 'Function:src/a.ts:orphanTwin', name: 'orphanTwin', filePath: 'src/a.ts', startLine: 1, endLine: 3, isExported: true, content: '', description: ''})`, + `CREATE (t2:Function {id: 'Function:src/b.ts:orphanTwin', name: 'orphanTwin', filePath: 'src/b.ts', startLine: 1, endLine: 3, isExported: true, content: '', description: ''})`, +]; + +type BackendHandle = IndexedDBHandle & { _backend?: LocalBackend }; + +withTestLbugDB( + 'impact-zero-caller-risk', + (handle) => { + let backend: LocalBackend; + beforeAll(() => { + // Typed and null-checked, matching `caller-identity-regression.test.ts` + // in this directory. An `as any` read here turns "the harness never + // attached the backend" into an undefined-property crash several lines + // later instead of a message naming the cause. + const ext = handle as BackendHandle; + if (!ext._backend) { + throw new Error('LocalBackend not initialized — afterSetup did not attach _backend'); + } + backend = ext._backend; + }); + + it('reports UNKNOWN, not LOW, when an upstream walk resolves no callers', async () => { + const result = await backend.callTool('impact', { + target: 'orphanHelper', + direction: 'upstream', + }); + expect(result).not.toHaveProperty('error'); + expect(result.impactedCount).toBe(0); + expect(result.risk).toBe('UNKNOWN'); + }); + + // The ambiguous fan-out narrows candidates into a fresh object, and that + // shape had no `riskNote` field — so the same `UNKNOWN` arrived with no + // explanation, on the path where the reader has the LEAST context. The + // note is the whole point of the verdict. + it('carries riskNote onto ambiguous candidates too', async () => { + const result = await backend.callTool('impact', { + target: 'orphanTwin', + direction: 'upstream', + }); + expect(result.status).toBe('ambiguous'); + const candidates = result.candidates as { + risk: string; + riskNote?: string; + probeFailed?: boolean; + }[]; + expect(candidates.length).toBeGreaterThan(1); + for (const c of candidates) { + expect(c.risk).toBe('UNKNOWN'); + expect(typeof c.riskNote).toBe('string'); + expect(c.riskNote).toMatch(/not evidence/i); + } + }); + + // `UNKNOWN` on this path used to mean exactly one thing — the probe threw. + // The zero-caller branch gives it a second meaning, so a reader must still + // be able to tell a resolved-and-empty walk from a broken one. + it('marks a resolved zero-caller candidate as NOT probe-failed', async () => { + const result = await backend.callTool('impact', { + target: 'orphanTwin', + direction: 'upstream', + }); + const candidates = result.candidates as { probeFailed?: boolean }[]; + for (const c of candidates) expect(c.probeFailed).toBeUndefined(); + }); + + it('explains the withheld verdict in riskNote', async () => { + const result = await backend.callTool('impact', { + target: 'orphanHelper', + direction: 'upstream', + }); + expect(typeof result.riskNote).toBe('string'); + // The note must say absence-of-edges is not proof of disuse; an agent + // gating its own edits reads this instead of inferring safety from 0. + expect(result.riskNote).toMatch(/not evidence/i); + }); + + it('leaves a resolved caller set at LOW with no riskNote', async () => { + const result = await backend.callTool('impact', { + target: 'usedHelper', + direction: 'upstream', + }); + expect(result.impactedCount).toBeGreaterThanOrEqual(1); + expect(result.risk).toBe('LOW'); + expect(result.riskNote).toBeUndefined(); + }); + + it('does not hedge an empty DOWNSTREAM walk — that is not a safety claim', async () => { + const result = await backend.callTool('impact', { + target: 'orphanHelper', + direction: 'downstream', + }); + expect(result.impactedCount).toBe(0); + expect(result.risk).toBe('LOW'); + expect(result.riskNote).toBeUndefined(); + }); + }, + { + seed: SEED, + poolAdapter: true, + afterSetup: async (handle) => { + vi.mocked(listRegisteredRepos).mockResolvedValue([ + { + name: 'test-repo', + path: '/test/repo', + storagePath: handle.tmpHandle.dbPath, + indexedAt: new Date().toISOString(), + lastCommit: 'abc123', + stats: { files: 3, nodes: 3, communities: 0, processes: 0 }, + }, + ]); + const backend = new LocalBackend(); + await backend.init(); + (handle as any)._backend = backend; + }, + }, +); diff --git a/gitnexus/test/integration/lbug-load-overlap-errors.test.ts b/gitnexus/test/integration/lbug-load-overlap-errors.test.ts index a128f80b5..d72477a4e 100644 --- a/gitnexus/test/integration/lbug-load-overlap-errors.test.ts +++ b/gitnexus/test/integration/lbug-load-overlap-errors.test.ts @@ -185,11 +185,14 @@ describe('loadGraphToLbug overlap error paths (#2226 F2)', () => { [], ); - // Node COPY targets a MISSING csv → COPY fails at bind time ("No file - // found …"), which IGNORE_ERRORS does NOT suppress (it only skips row-level - // errors), so copyNodeCSVs throws. Emit otherwise "succeeds" (returns an - // empty result), so the only failure is the node COPY captured in - // nodeCopyError and rethrown at the FK barrier. + // Node COPY targets a MISSING csv. This is now caught by the staging-CSV + // preflight BEFORE the engine sees it, so the message is the actionable + // "Staging CSV for File is missing …" rather than LadybugDB's bind-time + // "No file found …" (which IGNORE_ERRORS does not suppress either). What + // this test pins is unchanged and is the point: whatever the node COPY + // fails with, it is captured in nodeCopyError and RETHROWN AT THE FK + // BARRIER rather than being swallowed. Emit otherwise "succeeds" + // (returns an empty result), so the node COPY is the only failure. emitMock.mockImplementation( async ( _g: unknown, @@ -205,7 +208,7 @@ describe('loadGraphToLbug overlap error paths (#2226 F2)', () => { ); await expect(adapter.loadGraphToLbug(graph, tmpBase, storagePath)).rejects.toThrow( - /COPY failed for File/, + /Staging CSV for File is missing/, ); }); }); diff --git a/gitnexus/test/integration/resolvers/javascript-const-references.test.ts b/gitnexus/test/integration/resolvers/javascript-const-references.test.ts new file mode 100644 index 000000000..2548a0ef2 --- /dev/null +++ b/gitnexus/test/integration/resolvers/javascript-const-references.test.ts @@ -0,0 +1,138 @@ +/** + * A2 — references to a module-scope `const` must produce edges. + * + * A constant read only as a BARE IDENTIFIER (`Math.max(LIMIT, n)`, a default + * parameter value, `return LIMIT`) produced no reference site at all, so + * "who uses this constant?" — the question behind every dead-code trim and + * constants refactor — answered with a confident zero rather than "unknown". + * + * The registries already accept it (`FIELD_KINDS` includes `Const`) and the + * scope query already declares it (`@declaration.const`), so this is about the + * reference SITE existing: JS/TS captured only `@reference.read.member`, which + * requires a receiver a bare identifier does not have. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'path'; +import { FIXTURES, getRelationships, runPipelineFromRepo, type PipelineResult } from './helpers.js'; + +describe('JavaScript module-scope const references (A2)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'javascript-const-references'), + () => {}, + ); + }, 60000); + + const readersOfConst = (): Set => + new Set( + getRelationships(result, 'ACCESSES') + .filter((e) => e.target === 'DEFAULT_FETCH_LIMIT') + .map((e) => e.source), + ); + + it('emits ACCESSES from same-file readers of the const', () => { + const readers = readersOfConst(); + // fetchAll reads it twice (default param + Math.max); pageSize returns it. + expect(readers).toContain('fetchAll'); + expect(readers).toContain('pageSize'); + }); + + it('emits an edge for the cross-file named-import reader', () => { + expect(readersOfConst()).toContain('consumerLimit'); + }); + + it('does not emit edges to block-local values', () => { + // The cross-file pass resolves through finalized bindings, which include + // Const/Variable — block-locals among them. Same-file hits are skipped so + // an inert local cannot gain an edge and survive pruning. + const toLocal = getRelationships(result, 'ACCESSES').filter( + (e) => e.target === 'localScratchValue', + ); + expect(toLocal).toEqual([]); + }); + + // The out-of-core path (#RV-2). Nothing in the suite exercised + // `GITNEXUS_DISK_SCOPE_INDEX`, and that is where the module-level set was + // being built from scope-STRIPPED files: it came out empty, which the filter + // read as "no def is module-level" and used to drop every + // `Const`/`Variable`/`Static` ACCESSES edge in the repo — including the ones + // this suite exists to prove exist. It failed silently, on the path large + // repos take, and no test could see it. + // + // Parity is the assertion: the seal is a memory optimization and must not + // change a single edge. + describe('under the out-of-core scope seal', () => { + let sealed: PipelineResult; + + beforeAll(async () => { + const prev = process.env.GITNEXUS_DISK_SCOPE_INDEX; + process.env.GITNEXUS_DISK_SCOPE_INDEX = '1'; + try { + sealed = await runPipelineFromRepo( + path.join(FIXTURES, 'javascript-const-references'), + () => {}, + ); + } finally { + if (prev === undefined) delete process.env.GITNEXUS_DISK_SCOPE_INDEX; + else process.env.GITNEXUS_DISK_SCOPE_INDEX = prev; + } + }, 60000); + + const sealedReaders = (): Set => + new Set( + getRelationships(sealed, 'ACCESSES') + .filter((e) => e.target === 'DEFAULT_FETCH_LIMIT') + .map((e) => e.source), + ); + + it('keeps the const edges the unsealed run produced', () => { + expect([...sealedReaders()].sort()).toEqual([...readersOfConst()].sort()); + }); + + // WHOLESALE parity, not one field's readers. + // + // The two assertions around this one each pin a single name, and that is how + // a second instance of the same defect got in: a different consumer of + // `parsed.scopes` (`buildDirectImportMap`) was also reading scope-stripped + // files under the seal, tier-2 import narrowing died repo-wide, and every + // targeted assertion here still passed because none of them covered a + // narrowed name. Comparing the whole ACCESSES set is the only shape that + // notices a loss nobody thought to name. + // + // Reported as a sorted diff rather than a bare count so a failure says WHICH + // edges moved. + it('produces an identical ACCESSES edge set to the unsealed run', () => { + const edgeSet = (r: PipelineResult): string[] => + getRelationships(r, 'ACCESSES') + .map((e) => `${e.source} -> ${e.target} (${e.rel.reason})`) + .sort(); + const unsealed = edgeSet(result); + // Guard the guard: an empty set on both sides would compare equal and + // assert nothing. + expect(unsealed.length).toBeGreaterThan(0); + expect(edgeSet(sealed)).toEqual(unsealed); + }); + + it('still withholds the block-local edge', () => { + // The filter must fail OPEN when scopes are unavailable, not be disabled: + // the block-local exclusion is a correctness property, not an optimization. + const toLocal = getRelationships(sealed, 'ACCESSES').filter( + (e) => e.target === 'localScratchValue', + ); + expect(toLocal).toEqual([]); + }); + }); + + it('targets the Const node itself, not a same-named local', () => { + const toConst = getRelationships(result, 'ACCESSES').filter( + (e) => e.target === 'DEFAULT_FETCH_LIMIT', + ); + expect(toConst.length).toBeGreaterThan(0); + for (const e of toConst) { + expect(e.targetLabel).toBe('Const'); + expect(e.targetFilePath).toContain('config.js'); + } + }); +}); diff --git a/gitnexus/test/integration/resolvers/javascript-object-properties.test.ts b/gitnexus/test/integration/resolvers/javascript-object-properties.test.ts new file mode 100644 index 000000000..159c0d694 --- /dev/null +++ b/gitnexus/test/integration/resolvers/javascript-object-properties.test.ts @@ -0,0 +1,418 @@ +/** + * A1/A5 — property access on a PLAIN OBJECT LITERAL must be answerable. + * + * Verified root cause: `Property` definition nodes are created only for + * DECLARED CLASS FIELDS. Object-literal keys mint no node, so `ACCESSES` has + * no target and "who reads/writes this config field?" returns a confident + * zero. Capture and emission are already correct and language-neutral — a + * `read`/`write` site maps to `ACCESSES` for any resolved target — so this is + * purely definition-node coverage plus receiver resolution. + * + * Two receiver shapes, deliberately separated: + * - through the holding variable (`exitRules.exitMinAtrMult`) — the receiver + * is typeable, so this must resolve precisely. + * - through an untyped param (`cfg.exitMinAtrMult`) — the option-bag shape + * that dominates idiomatic JS. Not precisely solvable without types; + * covered by name-based fallback at reduced confidence. + * + * Both halves now land. Object-literal keys bound to a variable mint the graph + * `Property` node (JAVASCRIPT_QUERIES) and the scope-resolution def + * (languages/javascript/query.ts), and the ACCESSES edges resolve for both + * receiver shapes: precisely where the receiver is typeable, and by + * workspace-unique name where it is not — at reduced confidence, refusing to + * choose when two properties share a name (see the ambiguity cases below). + * + * TRAP, learned the hard way and recorded so the next reader does not repeat + * it: under vitest the PARSE WORKER runs the BUILT `dist/` code, because + * `parse-impl.ts` resolves `../workers/parse-worker.js`, which does not exist + * under `src/`, and falls back to dist. Scope resolution runs from `src`. So a + * change to TYPESCRIPT/JAVASCRIPT_QUERIES is invisible to tests until + * `npm run build` — it reads exactly like a failed hypothesis. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'path'; +import { FIXTURES, getRelationships, runPipelineFromRepo, type PipelineResult } from './helpers.js'; + +describe('JavaScript plain-object property access (A1/A5)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'javascript-object-properties'), + () => {}, + ); + }, 60000); + + const propertyNames = (): string[] => + Array.from( + (result as unknown as { graph: { iterNodes(): Iterable } }).graph.iterNodes(), + ) + .filter((n) => n.label === 'Property') + .map((n) => String(n.properties.name)); + + const readersOf = (field: string): string[] => + getRelationships(result, 'ACCESSES') + .filter((e) => e.target === field) + .map((e) => e.source); + + it('indexes object-literal keys as Property nodes', () => { + const props = propertyNames(); + expect(props).toContain('exitMinAtrMult'); + expect(props).toContain('stopAtrMult'); + }); + + it('gives every indexed key a distinct node, not one merged symbol', () => { + // Asserted on the RAW array, not on a Set of it. `new Set([...]).size === 2` + // over two different literal strings can only ever be 2 — it cannot detect + // the merge the title promises, which is a difference in COUNT. + const props = propertyNames().filter((n) => n === 'exitMinAtrMult' || n === 'stopAtrMult'); + expect(props).toHaveLength(2); + expect(new Set(props).size).toBe(2); + }); + + it('emits ACCESSES for a read through the holding variable', () => { + expect(readersOf('exitMinAtrMult')).toContain('readViaVariable'); + }); + + it('emits ACCESSES for the property WRITE (A5)', () => { + const writes = getRelationships(result, 'ACCESSES').filter( + (e) => e.target === 'exitMinAtrMult' && (e.rel.reason ?? '').includes('write'), + ); + expect(writes.map((e) => e.source)).toContain('tightenExit'); + }); + + it('emits ACCESSES for a read through an untyped param (option bag)', () => { + expect(readersOf('exitMinAtrMult')).toContain('applyRules'); + }); + + // The safety property. Name inference is only defensible because it refuses + // to choose between candidates: two objects sharing a key name means a read + // through an untyped receiver could mean either, and a wrong edge in the + // pre-edit safety gate is worse than a missing one. Without this, the pass + // would silently link generic keys (id, name, data) across unrelated objects. + it('emits NOTHING when two objects share the key name', () => { + expect(readersOf('sharedTimeoutMs')).toEqual([]); + }); + + it('still indexes both ambiguous keys as nodes — only the EDGE is withheld', () => { + // The symbols must remain findable; it is the inference that is unsafe, + // not the definitions. + expect(propertyNames().filter((n) => n === 'sharedTimeoutMs')).toHaveLength(2); + }); + + it('marks a name-inferred edge at reduced confidence, not as precise', () => { + const inferred = getRelationships(result, 'ACCESSES').filter( + (e) => e.target === 'exitMinAtrMult' && (e.rel.reason ?? '').includes('unique-name'), + ); + expect(inferred.length).toBeGreaterThan(0); + for (const e of inferred) expect(e.rel.confidence).toBeLessThan(0.85); + }); + + // R2-1c. The function that implements a behaviour usually destructures its + // settings out of the argument rather than reaching through a receiver, so + // the most relevant reader was the one shape with no read site at all. + describe('destructured parameters (R2-1c)', () => { + it('emits ACCESSES for a destructured key with a default', () => { + expect(readersOf('destructuredOnlyField')).toContain('appliesDestructured'); + }); + + it('emits ACCESSES for bare shorthand destructuring', () => { + expect(readersOf('destructuredOnlyField')).toContain('appliesShorthand'); + }); + + // `{ field: alias }` reads `field` and binds `alias`; the READ is of the + // key, so the edge must point at the key rather than the local name. + it('follows the key, not the local alias, when renamed', () => { + expect(readersOf('destructuredOnlyField')).toContain('appliesRenamed'); + expect(propertyNames()).not.toContain('aliased'); + }); + }); + + // R2-1b. The read side answered well while "who SETS this field?" missed the + // code that stamps the value, because a record built inline is bound to no + // variable and so mints no definition to point at. + describe('record construction writes (R2-1b)', () => { + const writersOf = (field: string): string[] => + getRelationships(result, 'ACCESSES') + .filter((e) => e.target === field && (e.rel.reason ?? '').includes('write')) + .map((e) => e.source); + + it('emits a WRITE for a literal nested under a key', () => { + expect(writersOf('destructuredOnlyField')).toContain('buildPlan'); + }); + + it('emits a WRITE for a returned literal', () => { + expect(writersOf('destructuredOnlyField')).toContain('buildFlat'); + }); + + // This asserted `toHaveLength(1)` — no new definition — until R3-4 began + // anchoring returned literals, which mints exactly one here (`buildFlat`'s + // return shape). The assertion was the right instinct expressed as the + // wrong invariant: what R2-1b actually protects is that adding definitions + // must not move an answer that already resolved, and node count was a proxy + // for that. The property itself is now asserted directly, and it holds + // because narrowing ranks declared anchors above return shapes. + it('keeps the DECLARED definition winning despite a return-shape sibling', () => { + const nodes = propertyNames().filter((n) => n === 'destructuredOnlyField'); + expect(nodes.length).toBeGreaterThan(1); + // Every reader still resolves, and to the declared home — a read that had + // dropped to ambiguous would show up as a missing edge here. + for (const reader of ['appliesDestructured', 'appliesShorthand', 'appliesRenamed']) { + expect(readersOf('destructuredOnlyField')).toContain(reader); + } + }); + + it('leaves an inline call-argument prop bag alone', () => { + expect(propertyNames()).not.toContain('notAConstructedField'); + expect(writersOf('notAConstructedField')).toEqual([]); + }); + }); + + // R3-4. The dominant shape in idiomatic JS and the one with no anchor at all: + // 437 `return {` sites in a single backend directory of the reporting repo, + // including the ~25-field payload of its whole signal pipeline. The literal + // binds to nothing, so its keys could not even be named. + describe('anonymous returned object literals (R3-4)', () => { + it('indexes keys of a literal returned from a named function', () => { + expect(propertyNames()).toContain('returnShapeOnlyField'); + }); + + it('resolves a read of a return-shape key', () => { + expect(readersOf('returnShapeOnlyField')).toContain('readsReturnShape'); + }); + + // `{ symbol, interval, score }` is the commonest spelling of all and + // `(pair)` does not match it — tree-sitter models it as + // `shorthand_property_identifier`, where the key IS the value. Caught by + // dumping the golden fixture and seeing that a literal returning + // `{ level, message, timestamp: Date.now() }` had indexed only `timestamp`. + it('indexes SHORTHAND keys, not just explicit pairs', () => { + expect(propertyNames()).toContain('shorthandOnlyField'); + }); + + // Qualified by the owning function, so two functions returning the same key + // are two shapes rather than one merged symbol — the same collision + // `ownerName` prevents for variable-bound literals. + it('qualifies by the owning function', () => { + const ids = Array.from( + (result as unknown as { graph: { iterNodes(): Iterable } }).graph.iterNodes(), + ) + .filter((n) => n.label === 'Property') + .map((n) => String(n.id)); + expect(ids.some((id) => id.includes('formatAlert.returnShapeOnlyField'))).toBe(true); + expect(ids.some((id) => id.includes('formatSummary.summaryOnlyField'))).toBe(true); + }); + + // Production code outranks test fixtures. Measured on the reporting repo: + // four of the seven JavaScript anchors for one field were in `tests/`, + // competing with the three real ones and making every production read + // ambiguous. A test builds throwaway shapes with production field names; a + // read in shipped code cannot mean one. + it('does not let a test fixture compete with the production anchor', () => { + expect(readersOf('productionAndTestField')).toContain('readsProductionShape'); + const ids = Array.from( + (result as unknown as { graph: { iterNodes(): Iterable } }).graph.iterNodes(), + ) + .filter((n) => n.label === 'Property') + .map((n) => String(n.id)); + // Both anchors exist — it is the RANKING that differs, not the indexing. + expect(ids.some((id) => id.includes('buildProductionShape.productionAndTestField'))).toBe( + true, + ); + expect(ids.some((id) => id.includes('buildTestFixture.productionAndTestField'))).toBe(true); + }); + + // THE GUARANTEE that reconciles this with R2-1b. `sharedWithDeclared` is + // both a named-object key and a return-shape key; a read must still resolve + // to the DECLARED one, or indexing return shapes would silently move + // answers that already worked. + it('never outranks a declared anchor', () => { + expect(readersOf('sharedWithDeclared')).toContain('readsShared'); + const declaredWins = getRelationships(result, 'ACCESSES').filter( + (e) => e.target === 'sharedWithDeclared' && e.source === 'readsShared', + ); + expect(declaredWins.length).toBeGreaterThan(0); + }); + }); + + // R3-5. The question three rounds of reports could not answer: a field + // produced by SEVERAL functions. Name inference must refuse it — the name + // alone cannot say which shape is meant — so this replaces inference with + // evidence, joining the call-result type binding (which already existed) to + // the return-shape owner (which R3-4 created). + describe('return-shape members via the call result (R3-5)', () => { + const targetOf = (source: string): string[] => + getRelationships(result, 'ACCESSES') + .filter((e) => e.target === 'ambiguousProducedField' && e.source === source) + .map((e) => String((e.rel as { targetId?: string }).targetId ?? '')); + + it('resolves to the producer the receiver actually holds', () => { + expect(targetOf('readsAlpha').some((id) => id.includes('producerAlpha.'))).toBe(true); + expect(targetOf('readsBeta').some((id) => id.includes('producerBeta.'))).toBe(true); + }); + + // The discriminator. Both producers own a field of this name, so a name + // match cannot tell them apart — getting the RIGHT one is only possible + // because the receiver's binding names the producer. + it('does not cross the two producers', () => { + expect(targetOf('readsAlpha').some((id) => id.includes('producerBeta.'))).toBe(false); + expect(targetOf('readsBeta').some((id) => id.includes('producerAlpha.'))).toBe(false); + }); + + // Scoped to the READERS. The producer itself also touches this field — it + // constructs the key — and that edge is a different claim reached a + // different way (see the own-return-shape case below), so folding both into + // one "every edge must be precise" assertion would either forbid a correct + // write or force it to lie about its tier. + it('marks the reader edges as precise, not as name inference', () => { + const readerEdges = getRelationships(result, 'ACCESSES').filter( + (e) => + e.target === 'ambiguousProducedField' && + (e.source === 'readsAlpha' || e.source === 'readsBeta'), + ); + expect(readerEdges.length).toBeGreaterThan(0); + for (const e of readerEdges) { + expect(String(e.rel.reason ?? '')).toContain('return-shape member'); + expect(e.rel.confidence).toBeGreaterThan(0.85); + } + }); + + // Review finding 3, the telemetry half. `workspace-unique` is a claim that + // exactly one node in the workspace carries this name — a fact about the + // graph, and the label a reader trusts most. An answer reached by FILTERING + // (tests down-ranked, return shapes down-ranked) is a weaker claim, and + // reporting it under the same label said "unambiguous workspace-wide match" + // for something that was narrowed. The edge is unchanged; what it is allowed + // to say about itself is not. + it('does not claim workspace-uniqueness for an answer reached by ranking', () => { + // `sharedWithDeclared` is carried by BOTH a declared object key and a + // return-shape key, so the workspace is not unique in it. The read + // resolves only because declared anchors outrank return shapes — a + // ranking decision — and the edge must say so. + const ranked = getRelationships(result, 'ACCESSES').filter( + (e) => e.target === 'sharedWithDeclared' && e.source === 'readsShared', + ); + expect(ranked.length).toBeGreaterThan(0); + for (const e of ranked) { + expect(String(e.rel.reason ?? '')).not.toContain('(workspace-unique)'); + } + }); + + // Review finding 3, the half that was a real wrong edge. A function that + // returns `{ field: … }` WRITES the key that is its own return shape. The + // declared-outranks-return-shape ranking is right for a read through a + // receiver, but applied to this site it handed the write to a same-named + // declared const the producer never touches — and left the node the key + // actually defines with no writer at all. + it('binds a producer writing its own returned key to that key', () => { + const own = getRelationships(result, 'ACCESSES').filter( + (e) => e.source === 'producerAlpha' && e.target === 'ambiguousProducedField', + ); + expect(own.length).toBeGreaterThan(0); + for (const e of own) expect(e.targetFilePath ?? '').toBeTruthy(); + // Never the OTHER producer's key: the owner qualifier is the evidence. + expect(targetOf('producerAlpha').some((id) => id.includes('producerBeta.'))).toBe(false); + }); + + // THE BOUND, asserted so the mechanism is not mistaken for something it is + // not. A bare parameter has no binding here — typing it needs the CALLER's + // type to flow in, which is inter-procedural — so it falls through to name + // inference, which refuses because two producers share the name. + it('leaves an unbound receiver to name inference, which refuses it', () => { + expect(targetOf('readsUnbound')).toEqual([]); + }); + }); + + // R2. Strict workspace uniqueness was measurably too blunt: in the reporting + // repo `exitMinAtrMult` had 26 definitions, 16 of them in one-off scripts the + // backend has no relationship with, so every backend read was refused because + // of competitors the reader cannot even see. + describe('scope narrowing for multi-candidate names (R2)', () => { + const reasonsFor = (field: string): string[] => + getRelationships(result, 'ACCESSES') + .filter((e) => e.target === field) + .map((e) => String(e.rel.reason ?? '')); + + it('still sees two definitions of the narrowed name', () => { + // Precondition. Without this the narrowing assertions below would pass + // trivially by there being nothing to narrow. + expect(propertyNames().filter((n) => n === 'narrowedTimeoutMs')).toHaveLength(2); + }); + + it('resolves an untyped read using direct-import evidence', () => { + expect(readersOf('narrowedTimeoutMs')).toContain('readsNarrowed'); + }); + + it('records which tier resolved it, not just that something did', () => { + expect(reasonsFor('narrowedTimeoutMs').some((r) => r.includes('imported-file'))).toBe(true); + }); + + // The bound. Narrowing exists to USE scope evidence, not to lower the bar + // for guessing — a reader that can see both candidates is exactly as stuck + // as before, and must stay refused. + it('still refuses when the reader imports BOTH candidates', () => { + expect(readersOf('narrowedTimeoutMs')).not.toContain('readsBothVisible'); + }); + + // Same-file evidence that is itself ambiguous must stop the walk rather + // than fall through to a weaker tier. + it('keeps refusing two same-named keys in the reading file', () => { + expect(readersOf('sharedTimeoutMs')).toEqual([]); + }); + + // This assertion was VACUOUS when written: it read the stat off a + // `scopeResolution` field that PipelineResult does not have, so the + // `undefined` guard swallowed it and the whole test passed with the + // production code deleted. The facts are now published as + // `propertyInference`, and the guard is an assertion rather than an escape. + it('reports the names it could not resolve, not only a count', () => { + const inference = result.propertyInference; + expect(inference).toBeDefined(); + // Both halves. Asserting only the empty edge set is satisfied equally by + // "the ambiguity gate fired" and "the name was never looked up at all", + // so the counter must be shown to have MOVED. + expect(inference!.ambiguous).toBeGreaterThan(0); + expect(inference!.ambiguousNames).toContain('sharedTimeoutMs'); + }); + }); + + // R2-1a. Reported as the cheapest remaining win and it is: freezing a config + // object is how JS publishes an immutable contract, so the shape whose fields + // are most worth querying was the one shape the rule could not see. + describe('identity-preserving wrappers (R2-1a)', () => { + it('indexes keys of a literal wrapped in Object.freeze', () => { + const props = propertyNames(); + expect(props).toContain('frozenExitModel'); + expect(props).toContain('frozenMaxHoldMs'); + }); + + it('indexes keys wrapped in Object.seal', () => { + expect(propertyNames()).toContain('sealedMaxNotional'); + }); + + it('resolves a read through the frozen binding', () => { + expect(readersOf('frozenMaxHoldMs')).toContain('readsFrozen'); + }); + + // The bound of the fix. Only freeze/seal/preventExtensions return the + // argument they were given; for any other call the literal is an argument + // and the binding holds the callee's return value, so minting members here + // would attribute fields to an object that does not have them. + it('does NOT index a literal passed to a non-identity call', () => { + expect(propertyNames()).not.toContain('notAMemberOfDerived'); + }); + + // The case above is rejected structurally (identifier callee), so it holds + // even with no allowlist at all. `Object.entries` differs from + // `Object.freeze` by name alone, so this is the assertion that actually + // pins the predicate. + it('does NOT index Object.entries — same shape, non-identity name', () => { + expect(propertyNames()).not.toContain('notAMemberOfEntries'); + }); + }); +}); + +interface PropNode { + readonly label: string; + readonly properties: Record; +} diff --git a/gitnexus/test/integration/resolvers/polyglot-property-isolation.test.ts b/gitnexus/test/integration/resolvers/polyglot-property-isolation.test.ts new file mode 100644 index 000000000..004431abc --- /dev/null +++ b/gitnexus/test/integration/resolvers/polyglot-property-isolation.test.ts @@ -0,0 +1,131 @@ +/** + * RV-5 — unique-name property inference must not cross a language boundary. + * + * The pass indexed `Property` nodes from the whole shared graph. Per-language + * gating (`fieldFallbackOnMethodLookup`) decides whether the pass RUNS for a + * language; it never restricted which nodes could be TARGETS. So the only + * carrier of a name might be in another language entirely, and a read here + * resolved to it on name uniqueness alone — no owner, no file, no call path. + * + * Confidence does not mitigate it: `minConfidence` defaults to 0, so a consumer + * gets the edge unless it opts out explicitly. + * + * Every other fixture is single-language, so this could not be caught by + * construction anywhere in the suite. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'path'; +import { FIXTURES, getRelationships, runPipelineFromRepo, type PipelineResult } from './helpers.js'; + +describe('cross-language property inference (RV-5)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'polyglot-property-isolation'), + () => {}, + ); + }, 60000); + + const readersOf = (field: string): string[] => + getRelationships(result, 'ACCESSES') + .filter((e) => e.target === field) + .map((e) => e.source); + + it('does not link a JS read to a Java field of the same name', () => { + expect(readersOf('loyaltyPointsBalance')).not.toContain('renderLoyalty'); + }); + + // The SAME boundary, reached through the other pass. `renderLoyalty` above has + // an untyped receiver, so it routes through unique-name inference — the pass + // this fixture was written to police. Typing the receiver by construction + // routes an identical read through `return-shape-members.ts` instead, which + // consumed the same whole-graph index with no language restriction and emitted + // at 0.9 rather than 0.5 — the PRECISE tier, where a `minConfidence` floor + // cannot filter the result out. + // + // Note WHY a same-file check was not enough here. `new Loyalty()` types the + // receiver through the shared class registry, which is polyglot, so the + // producer resolves into `Loyalty.java` and its member genuinely lives in that + // same file. File equality is satisfied; only the language restriction stops + // the edge. + describe('the bound-receiver path (review finding 2)', () => { + const targetsOf = (source: string): string[] => + getRelationships(result, 'ACCESSES') + .filter((e) => e.source === source) + .map((e) => e.targetFilePath ?? ''); + + it('never reaches the Java field through the bound path', () => { + // Asserted on TARGET FILE, not on absence of the name: the leak this + // catches is an edge that exists and points into another language, which + // a name-only assertion would not distinguish from a correct local edge. + expect(targetsOf('readsBoundLoyalty').some((f) => f.includes('Loyalty.java'))).toBe(false); + }); + }); + + // The other half: restricting by language must not disable the pass. + it('still resolves a same-language unique name', () => { + expect(readersOf('jsOnlyThreshold')).toContain('readsJsOnly'); + }); + + // R3-1. Declining is correct; being SILENT about declining is not. An empty + // result here is byte-identical to "this field is unused", and the difference + // matters enormously — one says look elsewhere, the other says delete it. + // Reported out-of-sample: six fields in the reporting repo whose only + // definitions are TypeScript answered 0 for every backend read. + describe('reports the cross-language anchor (R3-1)', () => { + // NO `if (undefined) return` escape here. An earlier version of these + // assertions read the fact off a `scopeResolution` field that does not + // exist on PipelineResult, so every one of them bailed at that guard and + // passed with the production code deleted. The field is now published, and + // asserting it is present is the first thing these check. + const inference = (): NonNullable => { + const v = result.propertyInference; + expect(v).toBeDefined(); + return v!; + }; + + it('counts the sites it declined for language reasons', () => { + expect(inference().crossLanguage).toBeGreaterThan(0); + }); + + it('names the field and the language its anchor actually lives in', () => { + const hit = inference().crossLanguageNames.find((e) => e.name === 'loyaltyPointsBalance'); + expect(hit).toBeDefined(); + // The actionable half: not just "we declined" but "look in Java". + expect(hit?.languages).toContain('java'); + }); + + // Ambiguity and cross-language are different failures with different + // remedies — better receiver typing versus an anchor in this language — so + // collapsing them would tell a reader the wrong thing to do. + it('does not count a cross-language decline as an ambiguity', () => { + expect(inference().ambiguousNames).not.toContain('loyaltyPointsBalance'); + }); + + // The MIRROR, found by asking what else shares this shape rather than + // waiting for it to be reported. TypeScript opts out of name inference + // (`fieldFallbackOnMethodLookup: false`) because a type system should + // answer precisely — that stays. But skipping the pass wholesale also + // skipped its reporting, so a TypeScript read anchored only in JavaScript + // gave the identical silent empty this whole item is about. + // + // Detection is not inference: counting what could not be linked asserts + // nothing about what it means, so the opt-out loses nothing. + it('reports the same fact for a language that opts OUT of name inference', () => { + const hit = inference().crossLanguageNames.find((e) => e.name === 'jsOnlyThreshold'); + expect(hit).toBeDefined(); + expect(hit?.languages).toContain('javascript'); + }); + + // The assertion that makes `reportOnly` load-bearing. The cross-language + // case cannot show it — the language filter blocks those edges anyway — so + // this is a SAME-language TypeScript read that name inference could link, + // and which `fieldFallbackOnMethodLookup: false` forbids linking. Running + // the pass for reporting must not quietly re-enable inference. + it('still emits no edge for the opted-out language', () => { + expect(readersOf('tsOnlyBudget')).not.toContain('readsTsOnly'); + expect(readersOf('jsOnlyThreshold')).not.toContain('renderJsOnly'); + }); + }); +}); diff --git a/gitnexus/test/integration/resolvers/typescript-alias-fields.test.ts b/gitnexus/test/integration/resolvers/typescript-alias-fields.test.ts new file mode 100644 index 000000000..1cab69f7d --- /dev/null +++ b/gitnexus/test/integration/resolvers/typescript-alias-fields.test.ts @@ -0,0 +1,196 @@ +/** + * A4 — TypeScript type aliases and interface members must be indexed. + * + * A TS frontend models its API contracts as `type X = { … }` and `interface`, + * so a field on one is the thing you ask "who breaks if I remove this?" about. + * Three gaps made that unanswerable, all in the TypeScript PARSE query: + * + * 1. No `type_alias_declaration` -> `@definition.type`, so the alias minted + * NO NODE AT ALL and `context({name:'LiveModeConfig'})` said "Symbol not + * found". TypeScript was the only language missing this — Rust + * (`type_item`), Kotlin (`type_alias`), Swift (`typealias_declaration`) + * and Dart all emit it. + * 2. No `property_signature` pattern, so INTERFACE members minted no + * `Property` nodes either — the upstream report's "class/interface index + * fine" is only half right. + * 3. Alias members likewise had no node. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import { FIXTURES, getRelationships, runPipelineFromRepo, type PipelineResult } from './helpers.js'; +import path from 'path'; + +interface LabelledNode { + readonly label: string; + readonly properties: Record; +} + +describe('TypeScript type-alias and interface members (A4)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'typescript-alias-fields'), () => {}); + }, 60000); + + const nodesOfLabel = (label: string): string[] => + Array.from( + (result as unknown as { graph: { iterNodes(): Iterable } }).graph.iterNodes(), + ) + .filter((n) => n.label === label) + .map((n) => String(n.properties.name)); + + const readersOf = (field: string): string[] => + getRelationships(result, 'ACCESSES') + .filter((e) => e.target === field) + .map((e) => e.source); + + it('indexes the type alias as a symbol', () => { + // Previously "Symbol not found" — the alias existed for scope resolution + // but never became a graph node. + expect(nodesOfLabel('TypeAlias')).toContain('LiveModeConfig'); + }); + + it('indexes type-alias members as Property nodes', () => { + const props = nodesOfLabel('Property'); + expect(props).toContain('bookNotionalUsdt'); + expect(props).toContain('bookSlots'); + }); + + it('indexes interface members as Property nodes', () => { + expect(nodesOfLabel('Property')).toContain('ifaceSlots'); + }); + + // Member edges land through the PRECISE path only. The shape is a class-like + // scope (`interface_declaration` and `type_alias_declaration value: + // (object_type)` both emit `@scope.class`) and `property_signature` emits + // `@declaration.property`, so a typed receiver resolves to the shape's scope + // and finds the member there. + // + // There is deliberately NO name-based safety net: TypeScript sets + // `fieldFallbackOnMethodLookup: false` (scope-resolver.ts) because name + // matching over-connects in a typed language, and the unique-name pass honors + // that opt-out. The precise path is the only route for TS, by design. + it('links an interface field to its consumer', () => { + expect(readersOf('ifaceSlots')).toContain('renderIface'); + }); + + it('links an alias field to its consumer', () => { + expect(readersOf('bookNotionalUsdt')).toContain('renderAlias'); + }); + + // R2-2. Owning the members was only half of it: with no edge INTO the type, + // `context()` on an exported contract answered `incoming: {}`, so "what + // breaks if I remove this field?" — the question a contract type exists to + // answer — had nothing to walk. Measured on the reporting repo, all 324 + // TypeAlias nodes and every Interface node had DEFINES as their ONLY + // incoming edge, because TypeScript captured no type references at all. + // RV-4. `property_signature` occurs in EVERY object_type, not only in a + // declared shape, so inline parameter types, inline return types and nested + // object types matched too and the enclosing-container walk hung them off the + // nearest class/interface/alias. `addNode` is first-write-wins, so when the + // inline member shared a name with a real one the two symbols merged onto a + // single node and every answer about that field described the merge. + // + // Each inline member below is UNIQUELY named on purpose. A merge and a + // correct exclusion both leave exactly one node behind, so counting ids + // cannot tell them apart — the only discriminator is a name that the + // unanchored rule alone could produce. Measured against it, all four appeared: + // `Svc.inlineParamOnlyKey`, `Repo.inlineQueryOnlyKey`, + // `NestedConfig.nestedOnlyKey` and `buildInline.inlineReturnOnlyKey`. + describe('shape anchoring (RV-4)', () => { + const propertyIds = (): string[] => + Array.from( + (result as unknown as { graph: { iterNodes(): Iterable } }).graph.iterNodes(), + ) + .filter((n) => n.label === 'Property') + .map((n) => String(n.id)); + + it('does not attribute an inline PARAMETER type member to the class', () => { + expect(propertyIds().some((id) => id.includes('inlineParamOnlyKey'))).toBe(false); + }); + + it('does not attribute an inline parameter type member to the interface', () => { + expect(propertyIds().some((id) => id.includes('inlineQueryOnlyKey'))).toBe(false); + }); + + it('does not attribute a NESTED object type member to the alias', () => { + expect(propertyIds().some((id) => id.includes('nestedOnlyKey'))).toBe(false); + }); + + it('does not mint a member for an inline RETURN type', () => { + // The TYPE's member, not the returned value's key — those are different + // rules with opposite expectations, and the fixture names them apart so + // this assertion cannot be satisfied by the wrong one. + expect(propertyIds().some((id) => id.includes('inlineReturnTypeOnlyKey'))).toBe(false); + }); + + // The other half: anchoring must not cost real members. + it('still indexes every member of a declared shape', () => { + const ids = propertyIds(); + for (const expected of [ + 'LiveModeConfig.bookSlots', + 'LiveModeConfig.bookNotionalUsdt', + 'LiveModeIface.ifaceSlots', + 'NestedConfig.host', + 'Svc.retries', + 'Repo.retries', + ]) { + expect(ids.some((id) => id.endsWith(expected))).toBe(true); + } + }); + }); + + // R3-3. Found while building a fixture for the opt-out reporting change, not + // from a report: the object-literal rules were JavaScript-only, so the most + // common config idiom in TypeScript had invisible keys. + describe('object-literal keys in TypeScript (R3-3)', () => { + const names = (): string[] => + Array.from( + (result as unknown as { graph: { iterNodes(): Iterable } }).graph.iterNodes(), + ) + .filter((n) => n.label === 'Property') + .map((n) => String(n.properties.name)); + + it('indexes keys of a named object literal', () => { + expect(names()).toContain('tsConfigRetries'); + expect(names()).toContain('tsConfigTimeoutMs'); + }); + + it('indexes keys behind an identity-preserving wrapper', () => { + expect(names()).toContain('tsFrozenMaxNotional'); + }); + + // The half that matters for TypeScript specifically. It opts out of + // name inference, so the value of minting these nodes is that the PRECISE + // path — a read through the holding variable — now has something to + // resolve to. + it('resolves a precise read through the holding variable', () => { + expect(readersOf('tsConfigRetries')).toContain('readsTsConfig'); + expect(readersOf('tsFrozenMaxNotional')).toContain('readsTsConfig'); + }); + + it('keeps the same allowlist bound as the JavaScript rule', () => { + expect(names()).not.toContain('tsNotAMember'); + }); + }); + + describe('type consumers (R2-2)', () => { + const usersOf = (typeName: string): string[] => + getRelationships(result, 'USES') + .filter((e) => e.target === typeName) + .map((e) => e.source); + + it('links a parameter annotation to the alias it names', () => { + expect(usersOf('LiveModeConfig')).toContain('renderAlias'); + }); + + it('links a parameter annotation to the interface it names', () => { + expect(usersOf('LiveModeIface')).toContain('renderIface'); + }); + }); +}); + +interface PropNode { + readonly id: string; + readonly label: string; + readonly properties: Record; +} diff --git a/gitnexus/test/integration/resolvers/typescript-namespace-const.test.ts b/gitnexus/test/integration/resolvers/typescript-namespace-const.test.ts new file mode 100644 index 000000000..394c59efb --- /dev/null +++ b/gitnexus/test/integration/resolvers/typescript-namespace-const.test.ts @@ -0,0 +1,41 @@ +/** + * RV-9 — a const bound in a `Namespace` scope is module-level, not a local. + * + * The block-local filter added for A2 keeps a read of a block-scoped `const` + * from minting an edge, because such an edge would retain exactly the inert + * locals `pruneLocalSymbols` exists to drop. It decided "is this module-level?" + * by asking `kind === 'Module'`, which is true of the file root and of nothing + * else — so a value declared in a TS `namespace` (or a Rust `mod`, or a C++ / + * C# namespace) was classified as a function-local and its reads were dropped. + * + * The feature simply did not work there. Reported as a gap rather than a + * regression: no pre-existing edge was deleted, because the other languages' + * read/write captures are member-shaped and target `Property`, not + * `Const`/`Variable`/`Static`. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'path'; +import { FIXTURES, getRelationships, runPipelineFromRepo, type PipelineResult } from './helpers.js'; + +describe('TypeScript namespace-scoped const references (RV-9)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'typescript-namespace-const'), () => {}); + }, 60000); + + const readersOf = (name: string): string[] => + getRelationships(result, 'ACCESSES') + .filter((e) => e.target === name) + .map((e) => e.source); + + it('emits an edge for a read of a namespace-scoped const', () => { + expect(readersOf('NAMESPACED_MAX')).toContain('withinNamespace'); + }); + + // The bound. A namespace nested in a function body is a local like anything + // else declared there, so widening "module level" must not reach into one. + it('still withholds an edge to a const in a function-local namespace', () => { + expect(readersOf('innerLocalValue')).toEqual([]); + }); +}); diff --git a/gitnexus/test/integration/resolvers/value-ref-locality.test.ts b/gitnexus/test/integration/resolvers/value-ref-locality.test.ts new file mode 100644 index 000000000..9a1d6ee45 --- /dev/null +++ b/gitnexus/test/integration/resolvers/value-ref-locality.test.ts @@ -0,0 +1,129 @@ +/** + * The locality filter for value references, and the invariant triple it has to + * hold at once. + * + * A bare-identifier read (A2) makes module-scope constants answerable, but the + * same capture matches a read of a function-local `const`, and an edge to one of + * those retains exactly the inert symbols `pruneLocalSymbols` exists to drop. + * So references to `Const`/`Variable`/`Static` are filtered — and the shape of + * that filter is the whole story: + * + * 1. a function-local value MUST NOT keep an edge, + * 2. a module-scope value MUST keep one, + * 3. a CLASS MEMBER must keep one too — and this is the case a + * "module-level?" allowlist silently gets wrong. + * + * (3) is why the filter is a BLOCKLIST of function-local defs rather than an + * allowlist of module-level ones. A Java field, a C# field and a Python class + * attribute are none of module-level, and none of function-local. Under an + * allowlist they fall outside the allowed set and every one of their ACCESSES + * edges disappears — a whole edge class, in three languages, reported as + * "nothing reads this field". + * + * ── What each half of this file actually gates. Read before trusting it. ── + * + * The JS half gates the MECHANISM. Measured by instrumenting the bridge: for + * `javascript-const-references` it sees exactly two value-ACCESSES candidates, + * `DEFAULT_FETCH_LIMIT` (blocked=false) and `localScratchValue` (blocked=true). + * Inverting the filter's sense fails these tests. + * + * The Java half gates the OUTCOME, and deliberately not the mechanism, because + * the mechanism is not reachable from there: instrumenting the same bridge over + * `java-write-access` shows **zero** value-ACCESSES candidates — Java field + * references resolve to a `Property` target, and `isValueDefinitionLabel` covers + * only `Const`/`Static`/`Variable`, so the filter is never consulted. Those + * edges come from a different emitter entirely. So this half cannot fail when + * only the filter regresses, and saying otherwise would make it the kind of test + * that looks like a gate and is not one. + * + * It earns its place anyway: it asserts the user-visible answer ("does the graph + * know who reads this field?") by TARGET rather than by `reason`, which the + * per-language suites cannot do — they filter on `rel.reason === 'read'|'write'` + * while the bridge stamps `scope-resolution: read|write`, so a bridge-side + * change is invisible to them in either direction. If a future change ever makes + * the bridge the sole emitter for class members, this is the assertion that + * notices when they vanish. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'node:path'; +import { runPipelineFromRepo } from '../../../src/core/ingestion/pipeline.js'; +import type { PipelineResult } from '../../../types/pipeline.js'; + +const LANG_FIXTURES = path.resolve(__dirname, '..', '..', 'fixtures', 'lang-resolution'); + +interface AccessEdge { + readonly source: string; + readonly target: string; + readonly targetLabel: string; + readonly reason: string; +} + +const accessesOf = (result: PipelineResult): AccessEdge[] => { + const out: AccessEdge[] = []; + result.graph.forEachRelationship((r) => { + if (r.type !== 'ACCESSES') return; + const source = result.graph.getNode(r.sourceId); + const target = result.graph.getNode(r.targetId); + out.push({ + source: String(source?.properties.name ?? ''), + target: String(target?.properties.name ?? ''), + targetLabel: String(target?.label ?? ''), + reason: String(r.reason ?? ''), + }); + }); + return out; +}; + +describe('value-reference locality filter', () => { + describe('class members survive it (the allowlist regression)', () => { + let java: PipelineResult; + + beforeAll(async () => { + java = await runPipelineFromRepo(path.join(LANG_FIXTURES, 'java-write-access'), () => {}, {}); + }, 60_000); + + it('keeps ACCESSES to Java instance fields', () => { + const targets = accessesOf(java).map((e) => e.target); + // Asserted as a non-empty set FIRST: every `toContain` below is vacuous + // if the fixture stopped producing ACCESSES entirely, which is precisely + // the regression this file exists to catch. + expect(targets.length).toBeGreaterThan(0); + expect(targets).toContain('name'); + expect(targets).toContain('address'); + }); + + it('resolves them to a member node, not to a stray local', () => { + const memberEdges = accessesOf(java).filter( + (e) => e.target === 'name' || e.target === 'address', + ); + for (const edge of memberEdges) { + expect(edge.targetLabel).toBe('Property'); + } + }); + }); + + describe('the two cases the filter exists for still hold', () => { + let js: PipelineResult; + + beforeAll(async () => { + js = await runPipelineFromRepo( + path.join(LANG_FIXTURES, 'javascript-const-references'), + () => {}, + {}, + ); + }, 60_000); + + it('keeps a module-scope const read', () => { + const targets = accessesOf(js).map((e) => e.target); + expect(targets.length).toBeGreaterThan(0); + expect(targets).toContain('DEFAULT_FETCH_LIMIT'); + }); + + it('drops a function-local const read', () => { + // The whole reason the filter exists. `localScratchValue` is declared and + // read inside one function; an edge to it retains a symbol + // `pruneLocalSymbols` would otherwise remove. + expect(accessesOf(js).map((e) => e.target)).not.toContain('localScratchValue'); + }); + }); +}); diff --git a/gitnexus/test/integration/structural-pair-coverage.test.ts b/gitnexus/test/integration/structural-pair-coverage.test.ts index 0d525fc2f..ffa76cee0 100644 --- a/gitnexus/test/integration/structural-pair-coverage.test.ts +++ b/gitnexus/test/integration/structural-pair-coverage.test.ts @@ -109,6 +109,51 @@ const NON_BRIDGE_CORPUS = [ emitter: 'tools-phase HANDLES_TOOL', sentinels: ['Class|Tool'], }, + { + // A TypeScript object-type alias owns its members, so it emits + // HAS_PROPERTY from a `TypeAlias` — a label on the ELEVEN-table list this + // suite exists for, and one no rule reaches. Shipped once without the pair + // declared: emit threw `UndeclaredRelationPairError` and the whole analyze + // died on any repo containing `type X = { ... }`. Every resolver test still + // passed, because they build an in-memory graph and never write to the DB — + // this suite is the only place that difference shows up. + // `TypeAlias` USED to be off the generated grid, which is why round 1 hand- + // declared its pairs. It is now in `LINKABLE_LABELS` (the def→graph-node + // bridge needs it), which makes it a SCOPE_BRIDGE source and target, so the + // cross-product generates these pairs and the hand declarations were + // removed as redundant. + // + // The sentinel is still load-bearing, for a different reason than before: + // it now depends on `TypeAlias` being in `LINKABLE_LABELS`. Take it out and + // the pair stops being generated AND the hand declaration is gone, so this + // fails — which is exactly the state that also silently breaks alias + // consumer edges. `Interface|Property` was dropped from this entry because + // it is tautological in the ordinary way: both labels were always in the + // cross-product, so nothing about it could ever fail. + fixture: 'typescript-alias-fields', + emitter: 'object-type alias HAS_PROPERTY', + sentinels: ['TypeAlias|Property'], + }, + { + // Nothing in the corpus contained a method-shaped alias member, so nothing + // proved `TypeAlias|Method` was the right pair for what is actually + // emitted — a pair no emitter exercises is indistinguishable from a missing + // one until an analyze aborts on a real repo. + fixture: 'typescript-alias-methods', + emitter: 'object-type alias HAS_METHOD', + sentinels: ['TypeAlias|Method'], + }, + { + // The other direction on the same fixture (R2-2): an annotation naming a + // declared type emits USES INTO a `TypeAlias`, so the pair is + // `Function|TypeAlias` rather than the `TypeAlias|Property` above. Same + // eleven-table label, a different table, and a separate way for the same + // class of failure to reach a released build — the entry above would stay + // green with this one undeclared. + fixture: 'typescript-alias-fields', + emitter: 'type-annotation USES', + sentinels: ['Function|TypeAlias', 'Function|Interface'], + }, ] as const satisfies readonly CorpusEntry[]; /* diff --git a/gitnexus/test/unit/dispatch-guard-routes.test.ts b/gitnexus/test/unit/dispatch-guard-routes.test.ts new file mode 100644 index 000000000..2195fd148 --- /dev/null +++ b/gitnexus/test/unit/dispatch-guard-routes.test.ts @@ -0,0 +1,615 @@ +/** + * Hand-rolled dispatch-guard route extraction. + * + * The gap this closes is a whole TOOL answering empty: `route_map` reported + * "No routes found in this project" for a repo with seventeen route modules, + * because every route extractor before this one needs a framework to declare + * the route. A raw `node:http` server declares it by comparing the path. + * + * The bar here is precision, not recall — `route_map` presents its output as + * fact, so a route that does not exist is worse than a route that is missing. + * Roughly half of these cases are therefore assertions that something is NOT + * extracted. + */ +import { describe, expect, it } from 'vitest'; +import Parser from 'tree-sitter'; +import JavaScript from 'tree-sitter-javascript'; +import TypeScript from 'tree-sitter-typescript'; +import { + extractDispatchGuardRoutes, + reconcileDispatchGuardRoutes, + regexToRoutePath, + DISPATCH_GUARD_SOURCE, +} from '../../src/core/ingestion/route-extractors/dispatch-guard.js'; + +const parser = new Parser(); +parser.setLanguage(JavaScript); + +const extract = (source: string, filePath = 'src/server/routes.js') => + extractDispatchGuardRoutes(parser.parse(source), filePath).map((r) => ({ + routePath: r.routePath, + httpMethod: r.httpMethod, + handlerName: r.handlerName, + source: r.source, + })); + +const paths = (source: string): string[] => extract(source).map((r) => r.routePath); + +describe('dispatch-guard route extraction', () => { + describe('the dominant idiom', () => { + it('extracts a verb-qualified path comparison', () => { + const routes = extract(` + export async function handle(req, res, reqCtx) { + const { pathname } = reqCtx + if (req.method === 'GET' && pathname === '/api/live/portfolio') { + return sendJson(res, await loadPortfolio()) + } + } + `); + expect(routes).toEqual([ + { + routePath: '/api/live/portfolio', + httpMethod: 'GET', + handlerName: 'handle', + source: DISPATCH_GUARD_SOURCE, + }, + ]); + }); + + it('reads the verb when the comparison order is reversed', () => { + expect( + extract(` + function handle(req) { + if ('POST' === req.method && '/api/orders' === pathname) { return 1 } + } + `), + ).toMatchObject([{ routePath: '/api/orders', httpMethod: 'POST' }]); + }); + + it('distributes an outer verb across an inner OR of paths', () => { + const routes = extract(` + function handle(req) { + if (req.method === 'GET' && (pathname === '/api/a' || pathname === '/api/b')) { return 1 } + } + `); + expect(routes).toMatchObject([ + { routePath: '/api/a', httpMethod: 'GET' }, + { routePath: '/api/b', httpMethod: 'GET' }, + ]); + }); + + it('inherits a verb from an ENCLOSING if, not just a sibling', () => { + expect( + extract(` + function handle(req) { + if (req.method === 'DELETE') { + if (pathname === '/api/session') { return 1 } + } + } + `), + ).toMatchObject([{ routePath: '/api/session', httpMethod: 'DELETE' }]); + }); + + // The inverted case, and the reason the ancestor walk tracks which branch it + // came from: in the `else`, the method is precisely NOT POST, so inheriting + // POST would label the route with the one verb it cannot have. + it('refuses to inherit a verb from an if whose ELSE branch holds the comparison', () => { + const routes = extract(` + function handle(req) { + if (req.method === 'POST') { + save() + } else if (pathname === '/api/report') { + return 1 + } + } + `); + expect(routes).toMatchObject([{ routePath: '/api/report', httpMethod: '' }]); + }); + + it('extracts a verb-less path guard', () => { + expect( + extract(` + function match(method, pathname) { + return pathname === '/api/health' + } + `), + ).toMatchObject([{ routePath: '/api/health', httpMethod: '', handlerName: 'match' }]); + }); + }); + + describe('what must NOT become a route', () => { + it('ignores a comparison against something that is not a request path', () => { + // `mode` is not a path expression, so `/full` is just a string. + expect(paths(`function f() { if (mode === '/full') { return 1 } }`)).toEqual([]); + }); + + it('ignores a path-shaped literal compared to a filesystem path variable', () => { + // `path` is excluded on purpose — in Node it is overwhelmingly node:path + // or a file location, never the request path. + expect(paths(`function f() { if (path === '/tmp/cache') { return 1 } }`)).toEqual([]); + }); + + it('ignores startsWith namespace tests', () => { + // A prefix test asks "do I own this?" — minting `/api/` would claim a + // route nothing serves. + expect( + paths(`function f() { if (pathname.startsWith('/api/')) { return route(pathname) } }`), + ).toEqual([]); + }); + + it('ignores a bare "/" normalisation with no verb', () => { + // The static-file idiom, verbatim from the reporting repo. + expect( + paths(`function serve() { const file = pathname === '/' ? '/index.html' : pathname }`), + ).toEqual([]); + }); + + it('DOES extract a bare "/" when a verb makes the intent unambiguous', () => { + expect( + extract( + `function handle(req) { if (req.method === 'GET' && pathname === '/') { return 1 } }`, + ), + ).toMatchObject([{ routePath: '/', httpMethod: 'GET' }]); + }); + + it('ignores a non-equality comparison', () => { + expect(paths(`function f() { if (pathname !== '/api/health') { return 1 } }`)).toEqual([]); + }); + + it('ignores an absolute URL', () => { + expect( + paths(`function f() { if (pathname === 'https://x.test/api/a') { return 1 } }`), + ).toEqual([]); + }); + + it('ignores a template string whose substitution is not a known constant', () => { + expect(paths('function f() { if (pathname === `/api/${id}`) { return 1 } }')).toEqual([]); + }); + + it('ignores a verb literal that is not an HTTP verb', () => { + const routes = extract(` + function handle(req) { + if (req.method === 'SUBSCRIBE' && pathname === '/api/feed') { return 1 } + } + `); + expect(routes).toMatchObject([{ routePath: '/api/feed', httpMethod: '' }]); + }); + }); + + // BOOLEAN POLARITY. The module refuses to inherit a verb from an `if` whose + // `else` branch holds the comparison, because that branch runs precisely when + // the condition did NOT hold. `!` is the same fact written as an operator, and + // it was not handled — a stated invariant with half an implementation, which + // is worse than an absent one because the doc comment reads as covered. + // + // Every case below was reproduced against the unguarded extractor before the + // fix: `!(path)` INVENTED a route, and `!(verb) && path` emitted the one verb + // the branch guarantees the request does not have. + describe('negated conditions', () => { + it('claims nothing when the path comparison is negated', () => { + expect(paths(`function h(req) { if (!(pathname === '/api/admin')) { return 1 } }`)).toEqual( + [], + ); + }); + + it('claims nothing when the whole guard is negated', () => { + expect( + paths( + `function h(req) { if (!(req.method === 'POST' && pathname === '/api/w')) { return 1 } }`, + ), + ).toEqual([]); + }); + + it('keeps the path but drops a NEGATED verb rather than inverting it', () => { + // The path is still evidence — this branch is reached for `/api/x`. The + // verb is not: `!(method === 'GET')` says every method EXCEPT GET, which + // no single value can express, so the honest answer is verb-less. + expect( + extract( + `function h(req) { if (!(req.method === 'GET') && pathname === '/api/x') { return 1 } }`, + ), + ).toMatchObject([{ routePath: '/api/x', httpMethod: '' }]); + }); + + it('treats double negation as positive', () => { + // PARITY, not presence. A rule keyed on "is there a `!` above me" would + // refuse this, which is a real route. + expect(paths(`function h(req) { if (!!(pathname === '/api/z')) { return 1 } }`)).toEqual([ + '/api/z', + ]); + }); + + it('does not let an outer negation leak into the branch BODY', () => { + // Polarity is a property of the expression, not of the statements a branch + // contains: the inner comparison is positive where it is written. + expect( + paths(` + function h(req) { + if (!(req.method === 'GET')) { + if (pathname === '/api/inner') { return 1 } + } + } + `), + ).toEqual(['/api/inner']); + }); + + it('negates a regex path test too', () => { + expect( + paths(`function h(req) { if (!/^\\/api\\/runs\\/[^/]+$/.test(pathname)) { return 1 } }`), + ).toEqual([]); + }); + }); + + // Not in any report — the same dispatch written with different syntax. A + // graph that waits for a bug report per shape stays permanently one idiom + // behind the code it indexes. + describe('switch dispatch', () => { + it('extracts every string-literal case of a switch on the path', () => { + expect( + extract(` + function handle(req, pathname) { + switch (pathname) { + case '/api/health': return ok() + case '/api/version': return version() + default: return notFound() + } + } + `), + ).toMatchObject([ + { routePath: '/api/health', httpMethod: '', handlerName: 'handle' }, + { routePath: '/api/version', httpMethod: '', handlerName: 'handle' }, + ]); + }); + + it('applies a verb governing the whole switch to every arm', () => { + expect( + extract(` + function handle(req, pathname) { + if (req.method === 'POST') { + switch (pathname) { + case '/api/a': return a() + case '/api/b': return b() + } + } + } + `), + ).toMatchObject([ + { routePath: '/api/a', httpMethod: 'POST' }, + { routePath: '/api/b', httpMethod: 'POST' }, + ]); + }); + + it('ignores a switch on something that is not a request path', () => { + // The file must mention a path token, or PATH_TOKEN_HINT skips it before + // the discriminant rule is ever consulted and this asserts nothing. The + // real route below is the proof the walk ran. + expect( + paths(` + function f(kind, pathname) { + switch (kind) { case '/full': return 1 } + if (pathname === '/api/real') { return 2 } + } + `), + ).toEqual(['/api/real']); + }); + + it('ignores non-path cases in a switch that is on the path', () => { + expect( + paths(` + function handle(pathname) { + switch (pathname) { + case '/api/a': return 1 + case 'unknown': return 2 + } + } + `), + ).toEqual(['/api/a']); + }); + }); + + // A composed path is not an exotic shape — one of the reporting repo's + // seventeen route modules writes every one of its ~20 routes this way, and + // without folding that file contributes NOTHING while looking exactly like a + // file that has no routes. + describe('paths composed from same-file constants', () => { + it('folds a template substitution naming a module-level constant', () => { + expect( + extract( + 'const BASE = "/api/live/auto-trade"\n' + + 'function handle(req) {\n' + + ' if (req.method === "GET" && pathname === `${BASE}/rules`) { return 1 }\n' + + '}', + ), + ).toMatchObject([{ routePath: '/api/live/auto-trade/rules', httpMethod: 'GET' }]); + }); + + it('follows an alias hop, which is how the reporting repo writes it', () => { + // `const autoTradeBasePath = AUTO_TRADE_BASE_PATH` inside the handler, + // with the literal at module scope. + expect( + paths( + 'const AUTO_TRADE_BASE_PATH = "/api/live/auto-trade"\n' + + 'function handle(req) {\n' + + ' const autoTradeBasePath = AUTO_TRADE_BASE_PATH\n' + + ' if (pathname === `${autoTradeBasePath}/positions`) { return 1 }\n' + + '}', + ), + ).toEqual(['/api/live/auto-trade/positions']); + }); + + it('folds + concatenation', () => { + expect( + paths( + 'const BASE = "/api/v2"\n' + + 'function handle() { if (pathname === BASE + "/orders") { return 1 } }', + ), + ).toEqual(['/api/v2/orders']); + }); + + it('folds a bare constant with no suffix', () => { + expect( + paths( + 'const HEALTH = "/api/health"\nfunction handle() { if (pathname === HEALTH) { return 1 } }', + ), + ).toEqual(['/api/health']); + }); + + // The refusals. A partially-folded path is a WRONG route, and a wrong route + // is worse than a missing one — the whole premise of this module. + it('refuses a name declared twice with different values', () => { + expect( + paths( + 'const BASE = "/api/a"\n' + + 'function other() { const BASE = "/api/b"; return BASE }\n' + + 'function handle() { if (pathname === `${BASE}/x`) { return 1 } }', + ), + ).toEqual([]); + }); + + it('refuses when only part of the template resolves', () => { + expect( + paths( + 'const BASE = "/api"\n' + + 'function handle(id) { if (pathname === `${BASE}/x/${id}`) { return 1 } }', + ), + ).toEqual([]); + }); + + it('refuses a constant bound to a call result', () => { + expect( + paths( + 'const BASE = buildBase()\nfunction handle() { if (pathname === `${BASE}/x`) { return 1 } }', + ), + ).toEqual([]); + }); + + it('still rejects a folded value that is not path-shaped', () => { + expect( + paths( + 'const MODE = "full"\nfunction handle() { if (pathname === `${MODE}/x`) { return 1 } }', + ), + ).toEqual([]); + }); + }); + + describe('parameterised routes from anchored regexes', () => { + it('converts a single-segment wildcard to a named parameter', () => { + expect( + extract(` + function handle(req) { + if (req.method === 'GET' && /^\\/api\\/research-runs\\/[^/]+$/.test(pathname)) { return 1 } + } + `), + ).toMatchObject([{ routePath: '/api/research-runs/{param1}', httpMethod: 'GET' }]); + }); + + it('numbers multiple parameters in order', () => { + expect(regexToRoutePath('^\\/api\\/runs\\/[^/]+\\/experiments\\/[^/]+$')).toBe( + '/api/runs/{param1}/experiments/{param2}', + ); + }); + + it('accepts an escaped slash inside the wildcard class', () => { + expect(regexToRoutePath('^\\/api\\/x\\/[^\\/]+$')).toBe('/api/x/{param1}'); + }); + + // Bail cases. A route path is a claim about what the server serves, so a + // pattern that cannot be translated exactly is dropped, not approximated. + it('refuses an unanchored pattern', () => { + expect(regexToRoutePath('\\/api\\/x')).toBeNull(); + expect(regexToRoutePath('^\\/api\\/x')).toBeNull(); + }); + + it('refuses an optional group', () => { + expect(regexToRoutePath('^\\/api\\/runs\\/[^/]+\\/artifacts(?:\\/.*)?$')).toBeNull(); + }); + + it('refuses an alternation and a bare wildcard', () => { + expect(regexToRoutePath('^\\/api\\/(a|b)$')).toBeNull(); + expect(regexToRoutePath('^\\/api\\/.*$')).toBeNull(); + }); + + it('refuses a character-class escape', () => { + expect(regexToRoutePath('^\\/api\\/runs\\/\\d+$')).toBeNull(); + }); + + it('ignores a regex tested against something that is not a request path', () => { + expect(paths(`function f() { if (/^\\/api\\/x$/.test(filename)) { return 1 } }`)).toEqual([]); + }); + }); + + describe('handler attribution', () => { + it('names an object-literal method handler', () => { + // The route-module shape the reporting repo uses throughout. + expect( + extract(` + export function createRoutes(ctx) { + return { + async handle(req, res, reqCtx) { + const { pathname } = reqCtx + if (req.method === 'GET' && pathname === '/api/live/events') { return 1 } + }, + } + } + `), + ).toMatchObject([{ routePath: '/api/live/events', handlerName: 'handle' }]); + }); + + it('names an arrow function bound to a const', () => { + expect( + extract(` + const dispatch = (req) => { + if (req.method === 'GET' && pathname === '/api/ping') { return 1 } + } + `), + ).toMatchObject([{ routePath: '/api/ping', handlerName: 'dispatch' }]); + }); + + it('reports no handler for a top-level comparison', () => { + expect(extract(`if (pathname === '/api/top') { go() }`)).toMatchObject([ + { routePath: '/api/top', handlerName: undefined }, + ]); + }); + }); + + describe('per-file dedup', () => { + it('collapses a repeated (url, verb) pair', () => { + const routes = extract(` + function handle(req) { + if (req.method === 'GET' && pathname === '/api/a') { return 1 } + if (req.method === 'GET' && pathname === '/api/a') { return 2 } + } + `); + expect(routes).toHaveLength(1); + }); + + it('keeps distinct verbs on the same URL as separate routes', () => { + expect( + extract(` + function handle(req) { + if (req.method === 'GET' && pathname === '/api/a') { return 1 } + if (req.method === 'DELETE' && pathname === '/api/a') { return 2 } + } + `), + ).toHaveLength(2); + }); + }); + + // Whole-repo reconciliation. Deliberately NOT per-file: the reporting repo + // keeps its path table (`isKnownApiPath`) in one module and its handlers in + // sixteen others, so a per-file rule sees each half separately and the map + // ends up listing every route twice — once verb-less with the table as its + // "handler", once properly. Measured there: 94 routes, 34 of them shadows. + describe('cross-file reconciliation', () => { + const route = (routePath: string, httpMethod: string, source = DISPATCH_GUARD_SOURCE) => ({ + routePath, + httpMethod, + source, + }); + + it('drops a verb-less guard route when another file claims the URL with a verb', () => { + expect( + reconcileDispatchGuardRoutes([ + route('/api/live/health', ''), // the table + route('/api/live/health', 'GET'), // the handler + ]), + ).toEqual([route('/api/live/health', 'GET')]); + }); + + it('keeps a verb-less guard route no verb claims', () => { + const only = [route('/api/plans/examples/{param1}', ''), route('/api/other', 'GET')]; + expect(reconcileDispatchGuardRoutes(only)).toEqual(only); + }); + + it('keeps every verb on a multi-verb URL', () => { + const multi = [route('/api/x', 'GET'), route('/api/x', 'POST'), route('/api/x', '')]; + expect(reconcileDispatchGuardRoutes(multi)).toEqual([ + route('/api/x', 'GET'), + route('/api/x', 'POST'), + ]); + }); + + // A framework route without a verb is method-agnostic BY DECLARATION — a + // Django function view, a Laravel resource. That is a fact, not a weaker + // observation of the same thing, so the rule must not reach it. + it('never drops a non-dispatch-guard route', () => { + const mixed = [ + { routePath: '/api/x', httpMethod: '', source: undefined }, + route('/api/x', 'GET'), + ]; + expect(reconcileDispatchGuardRoutes(mixed)).toEqual(mixed); + }); + + it('does not let a framework verb suppress a guard route', () => { + const mixed = [ + route('/api/x', ''), + { routePath: '/api/x', httpMethod: 'GET', source: undefined }, + ]; + expect(reconcileDispatchGuardRoutes(mixed)).toEqual(mixed); + }); + }); + + // Both providers are wired to this extractor, and TypeScript is where the + // grammar can differ — an annotated parameter, a non-null assertion, an `as` + // cast all wrap nodes the rules read. Asserted rather than assumed. + describe('TypeScript', () => { + const tsParser = new Parser(); + tsParser.setLanguage(TypeScript.typescript); + + const tsPaths = (source: string): string[] => + extractDispatchGuardRoutes(tsParser.parse(source), 'src/server/routes.ts').map( + (r) => r.routePath, + ); + + it('extracts through annotated parameters', () => { + expect( + tsPaths(` + export async function handle(req: IncomingMessage, pathname: string): Promise { + if (req.method === 'GET' && pathname === '/api/live/portfolio') { return } + } + `), + ).toEqual(['/api/live/portfolio']); + }); + + it('extracts a switch on a typed discriminant', () => { + expect( + tsPaths(` + function handle(pathname: string): number { + switch (pathname) { + case '/api/health': return 1 + default: return 0 + } + } + `), + ).toEqual(['/api/health']); + }); + + it('folds a typed constant', () => { + expect( + tsPaths( + 'const BASE: string = "/api/v1"\n' + + 'function handle(pathname: string) { if (pathname === `${BASE}/orders`) { return 1 } }', + ), + ).toEqual(['/api/v1/orders']); + }); + }); + + describe('path expressions the rule accepts', () => { + it('accepts a member access ending in .pathname', () => { + expect( + paths(`function f(req) { if (new URL(req.url, base).pathname === '/api/x') { return 1 } }`), + ).toEqual(['/api/x']); + }); + + it('accepts raw req.url', () => { + expect(paths(`function f(req) { if (req.url === '/api/x') { return 1 } }`)).toEqual([ + '/api/x', + ]); + }); + + it('rejects a bare .url on an unrelated receiver', () => { + // `link.url` is not a request path; only req/request carry the raw form. + expect(paths(`function f(link) { if (link.url === '/api/x') { return 1 } }`)).toEqual([]); + }); + }); +}); diff --git a/gitnexus/test/unit/graph-collapse-wiring.test.ts b/gitnexus/test/unit/graph-collapse-wiring.test.ts new file mode 100644 index 000000000..0cc38986f --- /dev/null +++ b/gitnexus/test/unit/graph-collapse-wiring.test.ts @@ -0,0 +1,81 @@ +/** + * The NUMBERS fed to `detectGraphWriteCollapse`, which is where every defect + * in it turned out to live (review finding 3). + * + * The predicate itself was probed hard and held. What did not hold was + * everything around it: the expected count omitted streamed edges, an + * unreadable edge count arrived as a measured zero, a total loss was exempted + * for being small, and a detected collapse still reported success. Only the + * pure helper had tests; nothing exercised the wiring at all. + */ +import { describe, it, expect } from 'vitest'; +import { + detectGraphWriteCollapse, + GRAPH_WRITE_COLLAPSE_MIN_EDGES, +} from '../../src/core/index-freshness.js'; + +/** + * The `expected` count as `run-analyze` computes it. Kept as a tiny local + * mirror rather than an import because the production expression is inline in + * a 3000-line function; what matters is that the manifest term is present and + * that its absence is observable. + */ +const expectedRelationships = (inMemory: number, streamedRows: number | undefined): number => + inMemory + (streamedRows ?? 0); + +describe('graph-collapse wiring: the expected count (3a)', () => { + it('counts streamed edges that never entered the heap', () => { + // Streaming moves the bulk types out of `relationshipCount` at parse time. + // With 200 in memory and 9800 streamed, a DB holding 4000 is a real + // collapse — but against the bare in-memory count it looks like a 20x + // SURPLUS and the ratio passes trivially. + const bare = 200; + const streamed = 9800; + expect(detectGraphWriteCollapse(bare, 4000)).toBeUndefined(); + expect(detectGraphWriteCollapse(expectedRelationships(bare, streamed), 4000)).toEqual({ + expected: 10000, + persisted: 4000, + }); + }); + + it('is unchanged when streaming is inactive', () => { + expect(expectedRelationships(10000, undefined)).toBe(10000); + }); +}); + +describe('graph-collapse wiring: an unreadable count is not zero (3b)', () => { + // `getLbugStats` initialised its edge total to 0 and ran the query inside a + // swallowing catch, so a WAL/lock throw during finalize — documented on this + // exact call — produced a measured-looking 0 and certified a HEALTHY index as + // a total collapse. + it('says nothing when the edge count could not be taken', () => { + expect(detectGraphWriteCollapse(10000, undefined)).toBeUndefined(); + }); + + it('still reports a genuine zero that WAS measured', () => { + expect(detectGraphWriteCollapse(10000, 0)).toEqual({ expected: 10000, persisted: 0 }); + }); +}); + +describe('graph-collapse wiring: total loss is never exempt (3c)', () => { + it('reports a small repo that lost every edge', () => { + const small = GRAPH_WRITE_COLLAPSE_MIN_EDGES - 1; + expect(detectGraphWriteCollapse(small, 0)).toEqual({ expected: small, persisted: 0 }); + }); + + it('keeps exempting a small repo that lost only some', () => { + const small = GRAPH_WRITE_COLLAPSE_MIN_EDGES - 1; + expect(detectGraphWriteCollapse(small, small - 1)).toBeUndefined(); + }); +}); + +describe('graph-collapse wiring: incremental writes are not comparable (3a)', () => { + // An incremental run persists only the changed subgraph while both counts are + // whole-scope. A 10,000-edge index whose incremental rewrite lost 200 + // replacements reads 9,800 of 10,000 — above the ratio — so a corrupt index + // would be certified complete. `run-analyze` therefore skips the check + // entirely on that path; this pins the arithmetic that makes skipping right. + it('cannot see a real incremental loss through whole-scope counts', () => { + expect(detectGraphWriteCollapse(10000, 9800)).toBeUndefined(); + }); +}); diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index e7bf7c392..91b2f29d2 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -131,8 +131,33 @@ describe('PARSE_CACHE_VERSION', () => { // already 44); only the merge-time diff against origin/main surfaced it. What // the pin DOES do is fail loudly the moment the constant and this expectation // drift apart, which is what forces the re-check to happen at all. - // Moved 45 -> 46 for method-level Spring `@RequestMapping` routes (#2824): + // Moved 45 -> 46 for the JavaScript bare-identifier read captures, the + // object-literal `@definition.property` rule and the TypeScript shape-member + // captures (A1/A2/A4/A5) — all parse-time, so a v45 warm cache serves entries + // carrying neither the new reference sites nor the new Property nodes. + // + // This branch first took 45 and COLLIDED with #2837 above, which merged + // first: the TENTH ledger entry and the FOURTH exact clash, and the second in + // a row. Same lesson as the note above — the pin cannot detect the tie, since + // both sides asserted `toBe(45)` and that passes while main is already 45. + // Only the merge-time diff against origin/main surfaces it. + // + // Moved 46 -> 47 for method-level Spring `@RequestMapping` routes (#2857): // cached ParseWorkerResults otherwise replay the pre-fix empty route set. + // That PR read this branch's claim on 46 and took 47 rather than colliding — + // the FIFTH clash, and the first the ledger's convention actually prevented. + // It only moved the collision up one step, though: this branch's own 47 and + // everything above it had to be renumbered +1 at merge time. Capture sets + // unchanged; only the numbers moved. + // + // Moved 51 -> 52 for dispatch-guard routes (R3-7): the JS/TS providers now + // implement `extractDecoratorRoutes`, and decorator routes are worker output + // carried in the cache. A v50 warm cache replays a worker result whose + // `decoratorRoutes` predates the extractor, so `route_map` keeps answering + // empty — the exact symptom the change fixes, disguised as "it does not work". + // Moved 52 -> 53 for the same-file constant folding that followed, because a + // build stamped 50 (now 52) had already been used to analyze without it. + // // // Moved 47 -> 48 for #2833's three parse-time changes: C++ // `field_declaration` captures for `template_type` and qualified generic @@ -151,8 +176,8 @@ describe('PARSE_CACHE_VERSION', () => { // does do is fail loudly the moment the constant and this expectation drift // apart, which is what forces the merge-time diff against origin/main to // happen at all. - it('pins SCHEMA_BUMP to 48 so concurrent bumps cannot silently collide (#2833)', () => { - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(48); + it('pins SCHEMA_BUMP to 53 so concurrent bumps cannot silently collide (#2766)', () => { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(53); }); it('embeds the gitnexus package version (so upgrades invalidate the cache)', () => { diff --git a/gitnexus/test/unit/index-freshness-graph-collapse.test.ts b/gitnexus/test/unit/index-freshness-graph-collapse.test.ts new file mode 100644 index 000000000..bc610c3c3 --- /dev/null +++ b/gitnexus/test/unit/index-freshness-graph-collapse.test.ts @@ -0,0 +1,148 @@ +/** + * B2 — a refresh that reports SUCCESS while leaving the index unusable. + * + * The dangerous variant of a broken refresh: metadata IS written, so the index + * reads as fresh, hooks re-arm, and every tool answers from a graph missing + * most of its edges — which is indistinguishable from a codebase that + * genuinely has no such relationships. Observed as edges collapsing + * 23009 -> 2170, and as a `CodeRelation` table that never materialized (which + * reads back as a persisted count of zero). + * + * `analyze` compares the relationship count the pipeline produced against what + * the DB hands back after the write and records `graphWriteCollapsed`. This + * covers the translation of that record into the operator-facing reason, which + * is what `status` and the MCP resources report. + */ +import { describe, it, expect } from 'vitest'; +import { + detectGraphWriteCollapse, + getIndexIncompleteReasons, + GRAPH_WRITE_COLLAPSE_MIN_EDGES, + GRAPH_WRITE_COLLAPSE_RATIO, + INDEX_INCOMPLETE_REASONS, +} from '../../src/core/index-freshness.js'; + +describe('detectGraphWriteCollapse (B2 detection)', () => { + it('flags the reported field failure (23009 built, 2170 persisted)', () => { + expect(detectGraphWriteCollapse(23009, 2170)).toEqual({ expected: 23009, persisted: 2170 }); + }); + + it('flags a missing relation table, which reads back as zero persisted', () => { + expect(detectGraphWriteCollapse(23009, 0)).toEqual({ expected: 23009, persisted: 0 }); + }); + + it('stays silent on a healthy write', () => { + expect(detectGraphWriteCollapse(23009, 23009)).toBeUndefined(); + }); + + it('stays silent when MORE rows persist than the call graph built (--pdg)', () => { + // PDG layers write into the same table, so persisted > expected is normal. + expect(detectGraphWriteCollapse(1000, 4000)).toBeUndefined(); + }); + + // REGRESSION. A non-numeric `expected` does not merely skip the guards, it + // INVERTS them: `undefined < 100` is false so the small-repo exemption never + // fires, and `0 >= undefined * 0.5` is `0 >= NaN`, also false, so the ratio + // check "passes" too. Shipped briefly and reported healthy runs as total + // collapses — the exact false certainty this check exists to prevent. + it('never fires when the expected count is not a number', () => { + expect(detectGraphWriteCollapse(undefined as unknown as number, 0)).toBeUndefined(); + expect(detectGraphWriteCollapse(NaN, 0)).toBeUndefined(); + expect(detectGraphWriteCollapse(Infinity, 0)).toBeUndefined(); + }); + + it('never fires when the persisted count is not a number', () => { + // `getLbugStats` returns `{}` under some mocks/degraded paths, so + // `stats.edges` arrives as undefined rather than a measured zero. + expect(detectGraphWriteCollapse(23009, undefined)).toBeUndefined(); + expect(detectGraphWriteCollapse(23009, NaN)).toBeUndefined(); + }); + + it('is fail-safe when the expected count is unavailable', () => { + // An implementation that offloads relationships out of memory may report 0; + // a false "your index is broken" is worse than a missed one. + expect(detectGraphWriteCollapse(0, 0)).toBeUndefined(); + expect(detectGraphWriteCollapse(0, 5000)).toBeUndefined(); + }); + + it('exempts small repos where the ratio is meaningless', () => { + // A PARTIAL shortfall under the threshold — the case the exemption was + // written for ("a handful of edges lost to legitimate filtering"). + const justUnder = GRAPH_WRITE_COLLAPSE_MIN_EDGES - 1; + expect(detectGraphWriteCollapse(justUnder, justUnder - 1)).toBeUndefined(); + expect(detectGraphWriteCollapse(justUnder, 1)).toBeUndefined(); + }); + + // This assertion previously read `detectGraphWriteCollapse(99, 0) === undefined`, + // pinning the defect rather than the behaviour: the exemption tested + // `expected` before looking at `persisted` at all, so a repo that lost EVERY + // edge was excused for being small, metadata stayed fresh and the CLI + // reported success. Losing all of a small graph is still losing all of it. + it('never exempts a TOTAL loss, however small the repo', () => { + expect(detectGraphWriteCollapse(GRAPH_WRITE_COLLAPSE_MIN_EDGES - 1, 0)).toEqual({ + expected: GRAPH_WRITE_COLLAPSE_MIN_EDGES - 1, + persisted: 0, + }); + expect(detectGraphWriteCollapse(1, 0)).toEqual({ expected: 1, persisted: 0 }); + }); + + // The boundary the total-loss rule must NOT cross: zero expected is the + // fail-safe "cannot measure" case, not a collapse. + it('still says nothing when nothing was expected', () => { + expect(detectGraphWriteCollapse(0, 0)).toBeUndefined(); + }); + + // An unreadable edge count is not a measured zero. `getLbugStats` now returns + // `undefined` when the query threw, and the total-loss rule must not treat + // that as a total loss. + it('does not call an unreadable count a total loss', () => { + expect(detectGraphWriteCollapse(50, undefined)).toBeUndefined(); + expect(detectGraphWriteCollapse(5000, undefined)).toBeUndefined(); + }); + + it('applies exactly at the minimum-edge boundary', () => { + expect(detectGraphWriteCollapse(GRAPH_WRITE_COLLAPSE_MIN_EDGES, 0)).toEqual({ + expected: GRAPH_WRITE_COLLAPSE_MIN_EDGES, + persisted: 0, + }); + }); + + it('treats the ratio as inclusive — exactly at threshold is not a collapse', () => { + const expected = 1000; + const atThreshold = expected * GRAPH_WRITE_COLLAPSE_RATIO; + expect(detectGraphWriteCollapse(expected, atThreshold)).toBeUndefined(); + expect(detectGraphWriteCollapse(expected, atThreshold - 1)).toBeDefined(); + }); +}); + +describe('graph-write-collapsed incomplete reason (B2)', () => { + it('is part of the stable reason vocabulary', () => { + expect(INDEX_INCOMPLETE_REASONS).toContain('graph-write-collapsed'); + }); + + it('reports a collapsed write as incomplete rather than fresh', () => { + expect( + getIndexIncompleteReasons({ graphWriteCollapsed: { expected: 23009, persisted: 2170 } }), + ).toEqual(['graph-write-collapsed']); + }); + + it('treats a missing relation table (zero persisted) the same way', () => { + expect( + getIndexIncompleteReasons({ graphWriteCollapsed: { expected: 23009, persisted: 0 } }), + ).toEqual(['graph-write-collapsed']); + }); + + it('says nothing on a healthy run', () => { + expect(getIndexIncompleteReasons({})).toEqual([]); + expect(getIndexIncompleteReasons(null)).toEqual([]); + }); + + it('reports alongside other reasons rather than masking them', () => { + const reasons = getIndexIncompleteReasons({ + incrementalInProgress: { startedAt: 1, toWriteCount: 0 }, + graphWriteCollapsed: { expected: 500, persisted: 10 }, + }); + expect(reasons).toContain('incremental-in-progress'); + expect(reasons).toContain('graph-write-collapsed'); + }); +}); diff --git a/gitnexus/test/unit/lbug-missing-staging-csv.test.ts b/gitnexus/test/unit/lbug-missing-staging-csv.test.ts new file mode 100644 index 000000000..3540298fa --- /dev/null +++ b/gitnexus/test/unit/lbug-missing-staging-csv.test.ts @@ -0,0 +1,53 @@ +/** + * B1 — a staging CSV that vanishes mid-run must fail legibly. + * + * Only tables with rows > 0 enter the COPY manifest (csv-generator.ts), so a + * manifest entry whose file is absent was written during this run and removed + * since — a second `gitnexus analyze` on the same repo (both use + * `.gitnexus/csv`) or an external cleanup of `.gitnexus/`. + * + * Raw, the operator gets two engine-level messages naming neither cause nor + * remedy: "Binder exception: No file found that matches the pattern + * .gitnexus/csv/file.csv", then "ENOENT .gitnexus/csv/rel_Folder_File.csv". + * Both appear verbatim in the upstream field reports on a forced rebuild. + */ +import { describe, it, expect } from 'vitest'; +import { missingStagingCsvError } from '../../src/core/lbug/lbug-adapter.js'; + +describe('missing staging CSV (B1)', () => { + const err = missingStagingCsvError('File', '/repo/.gitnexus/csv/file.csv', 23009); + + it('names the table and the exact path that is missing', () => { + expect(err.message).toContain('File'); + expect(err.message).toContain('/repo/.gitnexus/csv/file.csv'); + }); + + it('reports how much was staged, so the loss is quantified not vague', () => { + expect(err.message).toContain('23,009'); + }); + + it('states that the file existed during this run — not that it was never built', () => { + // The distinction matters: "never written" would send the operator hunting + // a generation bug, when the real cause is removal after the fact. + expect(err.message).toMatch(/removed mid-run|during this run/); + }); + + it('names both causes the field reports point at', () => { + expect(err.message).toContain('gitnexus analyze'); + expect(err.message).toContain('.gitnexus/csv'); + }); + + it('ends in an action, not just a diagnosis', () => { + expect(err.message).toContain('--force'); + }); + + it('formats the relationship-pair case too (the rel_Folder_File ENOENT)', () => { + const relErr = missingStagingCsvError( + 'Folder -> File', + '/repo/.gitnexus/csv/rel_Folder_File.csv', + 120, + ); + expect(relErr.message).toContain('Folder -> File'); + expect(relErr.message).toContain('rel_Folder_File.csv'); + }); +}); diff --git a/gitnexus/test/unit/process-processor.test.ts b/gitnexus/test/unit/process-processor.test.ts index 09600c6de..6265a99db 100644 --- a/gitnexus/test/unit/process-processor.test.ts +++ b/gitnexus/test/unit/process-processor.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect, vi } from 'vitest'; import { processProcesses, + traceFromEntryPoint, + buildSinkFunctionSet, type ProcessDetectionConfig, } from '../../src/core/ingestion/process-processor.js'; import { computeDynamicMaxProcesses } from '../../src/core/ingestion/pipeline-phases/processes.js'; @@ -560,3 +562,265 @@ describe('processProcesses', () => { }); }); }); + +/** + * D1/D2 — the trace walk must reach DEEP flows, not just shallow ones. + * + * The walk stops after a fixed NUMBER of traces, so traversal order decides + * which traces those are. Breadth-first reached every shallow terminal before + * any deep one, so the quota filled with the shortest paths in the graph and + * the walk stopped — `maxTraceDepth` was never approached. + * + * Measured on a 75k-node repo before the fix: of 300 processes NONE exceeded 7 + * steps and 90% were 3-4, so a multi-hop business flow had no process that + * could represent it and `query` could only rank the mechanical pairs that did + * exist. What looked like a ranking problem was a construction problem. + * + * This fixture is that shape in miniature: one deep chain competing with enough + * shallow branches to exhaust the trace budget before the chain is reached. + */ +describe('process depth (D1/D2)', () => { + // Drives the walk DIRECTLY. Through `processProcesses` this is unobservable: + // `findEntryPoints` returns several starting points, so the deep chain is + // traced from inside it whatever the traversal order does — a test there + // passes under BOTH traversals and guards nothing. + const cfg = { maxTraceDepth: 10, maxBranching: 4, maxProcesses: 75, minSteps: 3 }; + + const deepAndShallow = (order: readonly string[]): Map => { + // Fan-out is capped at maxBranching (4), so the budget is exhausted BELOW + // the entry: three shallow branches carrying four immediate terminals each + // = 12 traces, exactly the walk budget (maxBranching * 3). Breadth-first + // records all twelve and stops before descending the deep branch at all. + const calls = new Map(); + calls.set('entry', [...order]); + for (const b of ['s1', 's2', 's3']) { + calls.set(b, [`${b}_l1`, `${b}_l2`, `${b}_l3`, `${b}_l4`]); + } + for (let i = 1; i <= 7; i++) calls.set(`d${i}`, [`d${i + 1}`]); + return calls; + }; + + it('descends a deep chain instead of spending the budget on shallow branches', () => { + const traces = traceFromEntryPoint('entry', deepAndShallow(['d1', 's1', 's2', 's3']), cfg); + const deepest = Math.max(0, ...traces.map((t) => t.length)); + // Shallow terminals are 3 nodes. Anything longer proves it descended. + expect(deepest).toBeGreaterThan(3); + }); + + // Sibling ORDER, which the walk previously got backwards: `slice` selected + // the first N callees while `pop()` explored them last-first, so the budget + // went to the LAST-declared branch. For `main() { init(); …; shutdown(); }` + // that spends the walk on `shutdown` and can drop `init` — the earliest steps + // of a flow, which is the opposite of what a process describes. + // + // The consequence is honest and worth pinning: with a fixed trace budget, a + // deep branch declared AFTER enough shallow ones is not reached. That is a + // budget limitation, not a traversal one, and it must not be silent. + it('follows source order, so an early deep branch wins and a late one may not', () => { + const early = traceFromEntryPoint('entry', deepAndShallow(['d1', 's1', 's2', 's3']), cfg); + const late = traceFromEntryPoint('entry', deepAndShallow(['s1', 's2', 's3', 'd1']), cfg); + expect(Math.max(0, ...early.map((t) => t.length))).toBeGreaterThan(3); + // Not asserted as a desirable outcome — asserted so a change to the budget + // shows up here rather than silently altering which flows exist. + expect(Math.max(0, ...late.map((t) => t.length))).toBe(3); + }); + // The `processProcesses`-level depth test that used to sit here was VACUOUS, + // and the note at the top of this describe says exactly why: `findEntryPoints` + // returns several starting points, so the deep chain gets traced from inside + // it whatever the traversal does. Measured: under breadth-first the same + // fixture still yielded a deepest stepCount of 8, so the assertion passed + // with the production change reverted and guarded nothing. + // + // What IS observable at this level is which traces survive SELECTION, and + // that is asserted in the diversity describe below. Traversal order is + // asserted against `traceFromEntryPoint` directly, above. +}); + +describe('sink-terminated flows (R3-6)', () => { + const addFn = ( + graph: ReturnType, + id: string, + line: number, + ): void => { + graph.addNode({ + id, + label: 'Function', + properties: { + name: id.split(':')[1], + filePath: 'src/flow.ts', + startLine: line, + endLine: line + 2, + }, + }); + }; + const addCall = ( + graph: ReturnType, + from: string, + to: string, + ): void => { + graph.addRelationship({ + id: `rel:${from}->${to}`, + sourceId: from, + targetId: to, + type: 'CALLS', + confidence: 1, + reason: 'test', + }); + }; + + /** + * The shape the whole item is about: a business flow whose meaningful + * endpoint CALLS ONWARD into helpers. `placeOrder` is where the program does + * something; `formatDate` is merely where control stops. + */ + const flowGraph = (): ReturnType => { + const graph = createKnowledgeGraph(); + addFn(graph, 'func:scan', 1); + addFn(graph, 'func:score', 10); + addFn(graph, 'func:placeOrder', 20); + addFn(graph, 'func:formatDate', 30); + addFn(graph, 'func:pad', 40); + addCall(graph, 'func:scan', 'func:score'); + addCall(graph, 'func:score', 'func:placeOrder'); + addCall(graph, 'func:placeOrder', 'func:formatDate'); + addCall(graph, 'func:formatDate', 'func:pad'); + return graph; + }; + + // `placeOrder` spans lines 20-22, so an outward action on line 21 belongs to + // it — the attribution the file-level FETCHES edge could not express. + const ORDER_SITE = [{ filePath: 'src/flow.ts', lineNumber: 21 }]; + + it('ends a trace where the program reaches outward', async () => { + const result = await processProcesses(flowGraph(), [], undefined, {}, ORDER_SITE); + expect(result.processes.map((p) => p.terminalId)).toContain('func:placeOrder'); + }); + + // The half a naive implementation gets wrong: emitting the sink trace at the + // walk and then letting subset-removal delete it one step later is a no-op, + // because a sink-terminated flow is BY DEFINITION a prefix of the longer + // chain that runs on past it. + it('keeps the sink flow even though it is a prefix of a longer chain', async () => { + const result = await processProcesses(flowGraph(), [], undefined, {}, ORDER_SITE); + const terminals = result.processes.map((p) => p.terminalId); + expect(terminals).toContain('func:placeOrder'); + // The longer chain still exists — the two answer different questions. + expect(terminals).toContain('func:pad'); + }); + + it('ranks the sink flow above the leaf chain', async () => { + const result = await processProcesses(flowGraph(), [], undefined, {}, ORDER_SITE); + const first = result.processes[0]?.terminalId; + expect(first).toBe('func:placeOrder'); + }); + + // Without sites, behaviour must be exactly what it was. + it('changes nothing when no outward action is known', async () => { + const result = await processProcesses(flowGraph(), [], undefined, {}, []); + expect(result.processes.map((p) => p.terminalId)).not.toContain('func:placeOrder'); + }); + + it('attributes a site to the INNERMOST enclosing function', () => { + const graph = createKnowledgeGraph(); + // An outer function spanning the inner one; the inner performs the call. + graph.addNode({ + id: 'func:outer', + label: 'Function', + properties: { name: 'outer', filePath: 'src/a.ts', startLine: 1, endLine: 50 }, + }); + graph.addNode({ + id: 'func:inner', + label: 'Function', + properties: { name: 'inner', filePath: 'src/a.ts', startLine: 10, endLine: 20 }, + }); + const sinks = buildSinkFunctionSet(graph, [{ filePath: 'src/a.ts', lineNumber: 15 }]); + expect(sinks.has('func:inner')).toBe(true); + expect(sinks.has('func:outer')).toBe(false); + }); +}); + +describe('process selection diversity (R2-3)', () => { + const addFn = (graph: ReturnType, id: string): void => { + graph.addNode({ + id, + label: 'Function', + properties: { name: id.split(':')[1], filePath: 'src/a.ts', startLine: 1, endLine: 2 }, + }); + }; + const addCall = ( + graph: ReturnType, + from: string, + to: string, + ): void => { + graph.addRelationship({ + id: `rel:${from}->${to}`, + sourceId: from, + targetId: to, + type: 'CALLS', + confidence: 1, + reason: 'test', + }); + }; + + // The shape that crowded the reporting repo's list: many entry points whose + // deepest chains all bottom out in the SAME utility, plus a shorter flow + // ending somewhere of its own. Ranking on depth alone hands every slot to + // the first group and the reader learns one thing many times. + it('does not let one terminal take every slot', async () => { + const graph = createKnowledgeGraph(); + + // Six entry points, each with a 5-node chain into one shared utility. + addFn(graph, 'func:sharedUtil'); + for (let e = 1; e <= 6; e++) { + let prev = `func:entry${e}`; + addFn(graph, prev); + for (let i = 1; i <= 3; i++) { + const mid = `func:e${e}_m${i}`; + addFn(graph, mid); + addCall(graph, prev, mid); + prev = mid; + } + addCall(graph, prev, 'func:sharedUtil'); + } + + // One shorter, distinct flow — the "business flow" analogue. + addFn(graph, 'func:ownEntry'); + addFn(graph, 'func:ownMid'); + addFn(graph, 'func:ownTerminal'); + addCall(graph, 'func:ownEntry', 'func:ownMid'); + addCall(graph, 'func:ownMid', 'func:ownTerminal'); + + const result = await processProcesses(graph, [], undefined, { maxProcesses: 4 }); + const terminals = result.processes.map((p) => p.terminalId); + const sharedCount = terminals.filter((t) => t === 'func:sharedUtil').length; + + // Under depth-only ranking every one of the four slots goes to a + // five-node chain ending in sharedUtil. + expect(sharedCount).toBeLessThan(terminals.length); + expect(new Set(terminals).size).toBeGreaterThan(1); + }); + + it('keeps a shorter flow with its own terminal rather than a fifth duplicate', async () => { + const graph = createKnowledgeGraph(); + addFn(graph, 'func:sharedUtil'); + for (let e = 1; e <= 6; e++) { + let prev = `func:entry${e}`; + addFn(graph, prev); + for (let i = 1; i <= 3; i++) { + const mid = `func:e${e}_m${i}`; + addFn(graph, mid); + addCall(graph, prev, mid); + prev = mid; + } + addCall(graph, prev, 'func:sharedUtil'); + } + addFn(graph, 'func:ownEntry'); + addFn(graph, 'func:ownMid'); + addFn(graph, 'func:ownTerminal'); + addCall(graph, 'func:ownEntry', 'func:ownMid'); + addCall(graph, 'func:ownMid', 'func:ownTerminal'); + + const result = await processProcesses(graph, [], undefined, { maxProcesses: 4 }); + expect(result.processes.map((p) => p.terminalId)).toContain('func:ownTerminal'); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/property-name-index.test.ts b/gitnexus/test/unit/scope-resolution/property-name-index.test.ts new file mode 100644 index 000000000..ec08650b9 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/property-name-index.test.ts @@ -0,0 +1,97 @@ +/** + * The `Property`-by-name index is built ONCE and shared across language passes. + * + * It is a whole-graph node scan and language-agnostic, so rebuilding it inside + * every qualifying language pass repeats that scan N times — the pattern + * `phase.ts` already hoisted out for `sharedNodeLookup`, whose comment records + * why it matters: on a large repo a small language's full copy overlaps the + * next language's and contributes to the scope-resolution memory peak. + * + * Sharing is only safe because the per-language restriction moved to LOOKUP + * time. These tests pin both halves — that the index is genuinely whole-graph, + * and that a language still cannot see another language's properties through + * it. + */ +import { describe, it, expect } from 'vitest'; +import { buildPropertyNameIndex } from '../../../src/core/ingestion/scope-resolution/passes/unique-name-properties.js'; +import { createKnowledgeGraph } from '../../../src/core/graph/graph.js'; + +const addProperty = ( + graph: ReturnType, + id: string, + name: string, + filePath: string, +): void => { + graph.addNode({ + id, + label: 'Property', + properties: { name, filePath, startLine: 1, endLine: 1 }, + }); +}; + +describe('buildPropertyNameIndex', () => { + it('indexes properties from every language in one pass', () => { + const graph = createKnowledgeGraph(); + addProperty(graph, 'Property:a.js:cfg.shared', 'shared', 'a.js'); + addProperty(graph, 'Property:B.java:B.shared', 'shared', 'B.java'); + addProperty(graph, 'Property:a.js:cfg.jsOnly', 'jsOnly', 'a.js'); + + const index = buildPropertyNameIndex(graph); + + // Whole-graph: both carriers of `shared` are present. The language + // restriction is NOT applied here — that is the point of sharing it. + expect( + index + .get('shared') + ?.map((c) => c.filePath) + .sort(), + ).toEqual(['B.java', 'a.js']); + expect(index.get('jsOnly')).toHaveLength(1); + }); + + it('ignores non-Property nodes and nodes with no usable name or path', () => { + const graph = createKnowledgeGraph(); + graph.addNode({ + id: 'Function:a.js:run', + label: 'Function', + properties: { name: 'run', filePath: 'a.js', startLine: 1, endLine: 2 }, + }); + graph.addNode({ + id: 'Property:a.js:noPath', + label: 'Property', + properties: { name: 'noPath', startLine: 1, endLine: 1 }, + }); + + const index = buildPropertyNameIndex(graph); + expect(index.get('run')).toBeUndefined(); + // A node with no filePath cannot be language-filtered later, so it must not + // enter the index at all — otherwise it would be visible to EVERY language. + expect(index.get('noPath')).toBeUndefined(); + }); + + it('does not double-count a node seen twice', () => { + const graph = createKnowledgeGraph(); + addProperty(graph, 'Property:a.js:cfg.dup', 'dup', 'a.js'); + addProperty(graph, 'Property:a.js:cfg.dup', 'dup', 'a.js'); + + expect(buildPropertyNameIndex(graph).get('dup')).toHaveLength(1); + }); + + it('scans the graph exactly once', () => { + // The reason the index is hoisted at all. Counting iterations is what + // separates "shared" from "rebuilt per language and happens to agree". + const graph = createKnowledgeGraph(); + addProperty(graph, 'Property:a.js:cfg.one', 'one', 'a.js'); + let scans = 0; + const counting = { + ...graph, + iterNodes: () => { + scans++; + return graph.iterNodes(); + }, + } as unknown as ReturnType; + + buildPropertyNameIndex(counting); + expect(scans).toBe(1); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/typescript/typescript-captures.test.ts b/gitnexus/test/unit/scope-resolution/typescript/typescript-captures.test.ts index c97d41e19..fe9acfb3f 100644 --- a/gitnexus/test/unit/scope-resolution/typescript/typescript-captures.test.ts +++ b/gitnexus/test/unit/scope-resolution/typescript/typescript-captures.test.ts @@ -142,12 +142,32 @@ describe('emitTsScopeCaptures — declarations', () => { expect(m!['@declaration.name'].text).toBe('Status'); }); - it('captures type-alias declarations under @declaration.type', () => { - const m = findMatch('type ID = string;', (t) => t.includes('@declaration.type')); + // The tag is `@declaration.type_alias`, matching Kotlin and Dart. It was + // `@declaration.type`, which `normalizeNodeLabel` does not recognize — it + // accepts `typealias` / `type_alias` and has no `type` case — so the capture + // fired but mapped to NO label and TypeScript aliases produced no + // scope-resolution def at all. This test passed the whole time because it + // asserted only that the capture existed, never that it resolved to + // anything; the label assertion below is what stops a dead tag being pinned + // again. + it('captures type-alias declarations under @declaration.type_alias', () => { + const m = findMatch('type ID = string;', (t) => t.includes('@declaration.type_alias')); expect(m).toBeDefined(); expect(m!['@declaration.name'].text).toBe('ID'); }); + it('maps the type-alias capture to a real NodeLabel', () => { + const m = findMatch('type ID = string;', (t) => t.includes('@declaration.type_alias')); + const anchor = Object.keys(m!).find( + (k) => k.startsWith('@declaration.') && k !== '@declaration.name', + ); + expect(anchor).toBeDefined(); + // The kind string the extractor derives from the anchor must be one + // `normalizeNodeLabel` accepts, or the declaration silently vanishes. + const kind = anchor!.slice('@declaration.'.length); + expect(['typealias', 'type_alias']).toContain(kind); + }); + it('captures namespace declarations under @declaration.namespace', () => { const m = findMatch('namespace NS { class A {} }', (t) => t.includes('@declaration.namespace')); expect(m).toBeDefined(); diff --git a/gitnexus/test/unit/shipped-skills-sync.test.ts b/gitnexus/test/unit/shipped-skills-sync.test.ts index 107fd9e59..6a1077275 100644 --- a/gitnexus/test/unit/shipped-skills-sync.test.ts +++ b/gitnexus/test/unit/shipped-skills-sync.test.ts @@ -145,6 +145,31 @@ describe('intended standard-skill improvements stay in every applicable copy', ( } }); + // The risk scale's own escape hatch. `UNKNOWN` means the walk could not + // answer, and an agent that reads it as a low rung proceeds on a zero — the + // one reading the verdict exists to prevent. + // + // This assertion exists because its absence let real drift ship: the canonical + // `.claude/` copy lost the UNKNOWN block while the plugin mirror kept it, and + // this suite passed 54/54 with the two copies contradicting each other. The + // byte-identical check above covers only the plan/work/review/lfg family, and + // the fragment lists are the only guard the standard skills get — so a fragment + // that is not listed is a fragment nothing protects. + it('keeps the UNKNOWN-risk guidance in every impact-analysis copy', () => { + const required = [ + '| **Zero callers found** | **UNKNOWN** |', + '`UNKNOWN` is not a low rung on this scale', + 'Confirm with a text search before', + ]; + const copies = standardSkillCopies('gitnexus-impact-analysis'); + // Guard the guard: an empty copy list would make every loop below vacuous. + expect(copies.length).toBeGreaterThan(1); + for (const file of copies) { + const content = fs.readFileSync(file, 'utf-8'); + for (const fragment of required) expect(content).toContain(fragment); + } + }); + it('documents the current tools, schema, and cross-repo trace in every guide copy', () => { const required = [ '`route_map`',