From 223ac7010dd38c91257d187fbb83c7f57b35a58f Mon Sep 17 00:00:00 2001 From: DuduPhudu <34869259+ReidenXerx@users.noreply.github.com> Date: Sat, 8 Aug 2026 11:58:14 +0300 Subject: [PATCH] feat: close reported graph blind spots in reference resolution, analyze and storage (#2856) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mcp): report UNKNOWN risk when an upstream impact walk finds no callers `risk: LOW` asserts "safe to change" — a claim ABOUT callers. An upstream walk that resolved none has nothing to base it on: the symbol may be 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). Seeding LOW from an empty result 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`. Zero-caller upstream results now report risk UNKNOWN with a riskNote saying absence of edges is not evidence of disuse. Downstream is untouched: an empty downstream walk reports resolved callees, not safety. Co-Authored-By: Claude Opus 5 (1M context) * feat(javascript): emit ACCESSES for bare-identifier reads of module-scope consts A constant read only as a bare identifier — `Math.max(LIMIT, n)`, a default parameter value, `return LIMIT` — minted no reference site at all, because JS captured only `@reference.read.member`, which requires a receiver a bare identifier does not have. So "who uses this constant?", the question behind every dead-code trim and constants refactor, answered with a confident zero in both directions. The rest of the machinery was already in place: `FIELD_KINDS` accepts `Const`, the scope query already declares it via `@declaration.const`, and `read` maps to ACCESSES for any resolved target. This adds the missing capture in VALUE POSITIONS ONLY (call arguments, default-parameter values, return statements) — a blanket `(identifier)` rule would mint a site for every token in the file, which is unaffordable at repo scale and would keep alive the block-local symbols `pruneLocalSymbols` exists to drop. Cross-file readers are NOT yet covered: the site exists and a call through the same import statement resolves, but a value-kind def does not link across the import edge. Recorded as a todo with the investigation. PARSE_CACHE_VERSION bumped 44 -> 45: this is parse-time capture emission, so a warm cache replays the pre-change capture set and the new edges never appear — observed directly, a full `analyze --force` produced a byte-identical graph until the cache was cleared by hand. Co-Authored-By: Claude Opus 5 (1M context) * test(javascript): pin A1/A5 plain-object property acceptance criteria Fixture plus todo specs for the four shapes plain-object property access has to answer: object-literal keys indexed as Property nodes, a read through the holding variable, a property WRITE, and a read through an untyped param. Records the investigation so the work is resumable: the parse-query pattern scoped to literals bound to a variable matches correctly (verified against the raw JAVASCRIPT_QUERIES), but no Property node reaches the graph and local-symbol-pruner is not the cause — it drops only Const/Variable/Static. The remaining gate is in the parse worker's node-creation path. No production code — specs only, so the suite stays green. Co-Authored-By: Claude Opus 5 (1M context) * feat(javascript): index object-literal keys of a named object as Property nodes Idiomatic JS models configuration as an object literal, not a class, but Property definition nodes existed only for DECLARED CLASS FIELDS. A config field therefore had no symbol at all: `context({name: 'exitMinAtrMult'})` answered "not found" for a field read and written throughout a live code path, and ACCESSES had no target to point at. Both halves are added for keys of a literal BOUND TO A VARIABLE — the parse query mints the graph node, the scope query mints the def the resolver can aim at. Unbound literals are deliberately excluded: an inline call argument or a JSX prop bag is call-site data, not a named surface other code references, so a node per key there would add volume without adding an answerable question. This lands the definition-node half only. The ACCESSES edges still require receiver resolution — typing the const that holds the literal to the literal's scope for the precise case, and name-based matching at reduced confidence for the untyped-param (option bag) case. Both are recorded as todos with the mechanism each needs. Also records a trap that cost a wrong conclusion: under vitest the parse worker runs the BUILT dist code (parse-impl resolves parse-worker.js, absent under src/, and falls back to dist), so parse-query changes are invisible to tests until `npm run build`. Co-Authored-By: Claude Opus 5 (1M context) * test(cache): move the SCHEMA_BUMP pin to 45 The pin is the guard that makes two branches claiming one cache-schema number fail loudly instead of silently serving each other's entries, so a bump is only half-done until the pin moves with it. The bump itself landed with the JavaScript bare-identifier captures; this is the other half. Caught by the guard working exactly as designed — the suite failed with "expected 45 to be 44" rather than letting a mismatched pair through. Co-Authored-By: Claude Opus 5 (1M context) * feat(scope-resolution): resolve plain-object property access by unique name Idiomatic JS reads configuration off an object whose receiver cannot be typed — an options bag passed as a parameter, a destructured handle, an imported literal. No precise pass resolves 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. A last-resort pass runs after every precise pass and sees only what they left behind. For each still-unresolved read/write site it asks whether exactly ONE Property in the workspace carries that name. If so the read almost certainly means it. If two or more do, nothing is emitted and the site is COUNTED as ambiguous — 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. Uniqueness is the right gate because it recovers exactly the names worth recovering: distinctive domain fields (exitMinAtrMult, bookNotionalUsdt) are unique in a repo and resolve, while generic keys (id, name, data) are not and are skipped — which is where name matching would over-connect. Bounded four ways: - Confidence 0.5, the global tier, with the inference named in the reason, so a consumer can filter inferences without losing scope-resolved edges. - Never second-guesses a precise result: sites already resolved are excluded, because first-write-wins stops a duplicate but NOT a second edge to a different target. - Honors `fieldFallbackOnMethodLookup`. A statically-typed language opts out of name matching precisely because it over-connects; inferring an ACCESSES edge by name is the same claim and must obey the same opt-out. - Requires an explicit receiver — a bare identifier is not a property access, and matching one by name would link a local to an unrelated key. Indexes graph nodes rather than scope defs because an object-literal key mints a Property NODE but no scope-resolution DEF: `localDefs` and `scope.bindings` are both empty for exactly the population this serves. Co-Authored-By: Claude Opus 5 (1M context) * feat(analyze): record a collapsed graph write instead of reporting fresh The dangerous half 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 — indistinguishable from a codebase that genuinely has no such relationships. Reported in the field as edges collapsing 23009 -> 2170 and as a CodeRelation table that never materialized. `analyze` now compares the relationship count the pipeline PRODUCED against what the DB hands back after the write. Both numbers are already in scope at the same point, so the shortfall is provable rather than inferred — no comparison against the previous index, which cannot distinguish a failed write from a repo that legitimately shrank. A missing relation table needs no special case: it reads back as a persisted count of zero. On a collapse the run records `graphWriteCollapsed` in metadata, which `getIndexIncompleteReasons` turns into `graph-write-collapsed` so status and the MCP resources report the index INCOMPLETE rather than fresh. 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 when the expected count is unavailable — 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. Co-Authored-By: Claude Opus 5 (1M context) * fix(ingestion): qualify object-literal Property ids by their owning object Two config objects in one file that share a key name generated the same `Property::` id and COLLAPSED INTO ONE node, so two distinct settings became a single symbol. Worse, the merged name then looked workspace-unique to name inference, which happily resolved reads of it to a node representing both — a wrong edge in the pre-edit safety gate, which is precisely what the unique-name pass is bounded to avoid. `objectLiteralOwnerInfo` already existed for exactly this ("so two constructors in one file that both define `bar` stay distinct nodes") but was gated to `Method`. `Property` now opts in. `findObjectLiteralBindingInfo` returns `ownerName` only when asked. Its `Method` ids must stay byte-identical — qualifying them would rewrite every object-literal method id in every indexed repo — while object-literal KEYS, indexed only since A1/A5, have no such history to preserve. Found by a test written for the ambiguity path rather than by review: the suite reported one node where two were expected, and an edge where none should exist. Both are now pinned, along with the detection boundaries of the B2 collapse check, which was previously an untestable inline expression and is now a pure function. Co-Authored-By: Claude Opus 5 (1M context) * feat(typescript): index type aliases and shape members as symbols A TS frontend models its API contracts as `type X = { … }` and `interface`, so a field on one is exactly what "who breaks if I remove this?" is asked about. Three gaps made that unanswerable, all in the TypeScript queries: 1. No `type_alias_declaration` -> `@definition.type`, so an alias minted NO NODE AT ALL and a context() lookup on an exported contract type answered "Symbol not found". TypeScript was the ONLY language missing this — Rust (type_item), Kotlin (type_alias), Swift (typealias_declaration) and Dart all emit it. The alias was declared for scope resolution but never became a graph symbol. 2. No `property_signature` in the parse query, so INTERFACE members minted no Property nodes either — the upstream report's "class/interface index fine" holds only for the type, not its fields. 3. No `property_signature` in the scope query, so even with nodes present the resolver had no member declaration to aim at. Its sibling `method_signature` -> `@declaration.method` already existed; only properties were missing. Interface bodies and object-type aliases both spell members as property_signature, so one pattern per query covers both shapes. Lands the SYMBOLS, not yet the ACCESSES edges: the shape is already a class-like scope and now has member declarations, but no edge forms — the remaining link is owner/type-binding, recorded as todos with the diagnosis. Note TypeScript sets fieldFallbackOnMethodLookup:false, so unlike JavaScript there is deliberately no name-based fallback here; the precise path is the only route by design. Co-Authored-By: Claude Opus 5 (1M context) * test(golden): accept interface members in the mini-repo snapshot Drift is entirely the new TypeScript shape-member indexing: the fixture's three interfaces (ValidationResult 2, DbRecord 3, LogEntry 3) contribute exactly 8 Property nodes, each with exactly one HAS_PROPERTY owner edge. Verified before regenerating rather than after: every pre-existing count is untouched (CALLS 9, IMPORTS 12, DEFINES 16, HAS_METHOD 1, MEMBER_OF 12, STEP_IN_PROCESS 12), so nothing was rewired — the digest moved only because 8 edges were added. The fixture's inline `return { valid: false, … }` literals correctly produced nothing, confirming the object-literal rule stays scoped to variable-bound literals. Co-Authored-By: Claude Opus 5 (1M context) * fix(analyze): never report a collapse from a non-numeric count The B2 check reported healthy runs as total graph-write collapses. A non-numeric `expected` (a graph implementation reporting no total, a lightweight pipeline result) does not 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" as well. Both bounds silently evaporate and every such run is flagged. That is precisely the failure this check was written to catch, reproduced inside the check itself: an unmeasurable quantity treated as a measured zero. Both sides are now validated as finite numbers before any comparison. `persisted` is also passed as UNKNOWN rather than zero when the DB was not demonstrably readable: `getLbugStats` flattens "no connection", "query threw" and "empty table" all into `edges: 0`, so `stats.nodes > 0` is used as independent evidence the read happened at all. Caught by the existing run-analyze suites, not by the new unit tests — those exercised the pure function with well-formed numbers and were blind to the integration's actual inputs. Both cases are now pinned. Co-Authored-By: Claude Opus 5 (1M context) * feat(typescript): make object-type aliases own their members A TS object-type alias declares the same `property_signature` members as the interface beside it and answers the same question, but was not a member owner: its fields were minted with bare ids and no owner edge, so two aliases in one file sharing a field name collapsed onto one node, while the identical interface resolved normally. `type_alias_declaration` joins CLASS_CONTAINER_TYPES (and CONTAINER_TYPE_TO_LABEL, as that set's invariant requires — a container missing there gets orphaned member edges or a wrong owner label). Aliases with no object type (`type Id = string`) declare no members, so they own nothing and are unaffected. This also lands the INTERFACE field -> consumer edges, verified on the mini-repo fixture rather than only on a purpose-built one: `saveToDb` now links to `ValidationResult.value`, and `formatLogEntry` to `LogEntry.level` and `LogEntry.message` — three real contract-field reads that previously had no graph path at all. Golden updated: +3 ACCESSES, no node changes. The ALIAS field -> consumer edge is still not linked and is recorded as a todo with the exact blocker: resolving a receiver typed as the alias needs the NAME to resolve to a class-like def, and `isClassLike` is Class|Interface|Struct|Record|Enum|Trait. That predicate is read from ~12 sites including MRO and heritage, and every language mints TypeAlias, so widening it would enrol aliases in linearizations where they do not belong. Widening only the scope index was tried and reverted — the type-name walkers gate on it independently, so it fixed nothing and left dead code. That needs a deliberate "shape-like" concept, not more call-site widening. Co-Authored-By: Claude Opus 5 (1M context) * docs(test): record the traced diagnosis for the unlinked alias field edge Traced to the end rather than left as "needs investigation", so the next attempt starts from facts: 1. Graph side is COMPLETE and symmetric with the interface — Property:...:LiveModeConfig.bookSlots is owner-qualified and carries HAS_PROPERTY. 2. Resolution DOES reach resolveClassBindingForName('LiveModeConfig') (instrumented) and misses. 3. It misses because the module scope binds LiveModeIface:Interface, renderAlias, renderIface — and not LiveModeConfig. The alias has no binding on the receiver's scope chain at all. 4. The TS scope query tags aliases @declaration.type, but normalizeNodeLabel accepts only typealias / type_alias and has no "type" case, so it returns undefined. Kotlin and Dart use @declaration.type_alias; TypeScript is alone on the dead tag. 5. Retagging is NECESSARY BUT NOT SUFFICIENT — tried, and the binding still does not appear, so a second gate exists in how a declaration anchored on a node that is ALSO a @scope.class anchor is attached: the alias appears to bind inside its own scope rather than hoisting to Module, where interface_declaration evidently does hoist. An isShapeLike predicate (the nominal-vs-structural split: shapes declare members, nominal types participate in MRO) plus a mirrored findShapeBindingInScope were built and REVERTED along with the retag. With no binding on the chain they never fire, and shipping inert widening is worse than shipping none — the same standard applied to the earlier scope-index attempt. The design is recorded here; it is worth doing once step 5 is fixed, and it also unblocks Rust's parked union_item, which the MEMBER_OWNER_NODE_TYPES comment documents as the same gap in another language. Co-Authored-By: Claude Opus 5 (1M context) * feat(scope-resolution): resolve cross-file value references, skip block-locals Two halves of the same question, "who uses this constant?". CROSS-FILE. `resolveReferenceSites` runs against the registries and, as its own comment says, "imports live in finalized bindings the registries can't see" — which is why free CALLS need `emitFreeCallFallback`. Reads had no counterpart, so `import { LIMIT }` followed by a bare use resolved to nothing while a CALL through the very same import statement resolved fine. This adds the read/write counterpart, reusing `findValueBindingInScope` (which walks the FINALIZED chain) rather than inventing a lookup. Confidence 0.9: the import names the def, so this is precise resolution, not inference. BLOCK-LOCALS. Bare-identifier capture also matches a read of a block-local `const`, and an edge to one keeps alive exactly the inert locals `pruneLocalSymbols` exists to drop — a pruned node becomes a retained node plus an edge, in every function of every indexed repo. Emission now takes the set of value defs bound at MODULE scope and drops ACCESSES to Const/Variable/Static outside it. The cross-file pass carries the same guarantee structurally: a def in another file cannot be a block-local of this one, so it skips same-file hits entirely. The block-local leak was already shipped in the intra-file A2 commit and was found only because a test was written for the guard rather than the feature — the same way the object-literal id collision surfaced. Verified on the full resolver matrix: 3172 tests, golden unchanged. Co-Authored-By: Claude Opus 5 (1M context) * fix(lbug): diagnose a vanished staging CSV instead of surfacing a Binder error A forced rebuild could fail with "COPY failed for File: Binder exception: No file found that matches the pattern .gitnexus/csv/file.csv" and then an ENOENT on .gitnexus/csv/rel_Folder_File.csv — two engine-level messages that name neither a cause nor a remedy, which is where several field reports end. Only tables with rows > 0 enter the COPY manifest (csv-generator.ts), so an absent file was WRITTEN during this run and removed since. Both COPY loops now preflight and say exactly that, with the row count, both causes the reports point at (a second `gitnexus analyze` on the same repo — they share .gitnexus/csv — or an external cleanup of .gitnexus/), and the action to take. Scope note, deliberately narrow: this does not attempt to fix WAL corruption or checkpoint rotation. Those already have detection and recovery hints (isWalCorruptionError, WAL_RECOVERY_SUGGESTION, the configurable wal-checkpoint-threshold), and the ~6000 lines added to lbug/ + storage/ since v1.6.9 — index-lock.ts most of all, which serializes writers and plausibly closes the concurrent-run class outright — postdate every report in the window. Guessing at unreproducible durability faults would be speculation; making the one failure with NO handling legible is not. An existing overlap test induced this exact scenario (a manifest entry pointing at a missing csv) and asserted on the engine's wording. Its intent — that a node-COPY failure is rethrown at the FK barrier rather than swallowed — is unchanged and still asserted; only the message it matches moved. Co-Authored-By: Claude Opus 5 (1M context) * feat(scope-resolution): split shape-like from class-like, linking alias fields Completes A4: a field on a TypeScript object-type alias now links to the code that reads it, the last unanswerable half of "who breaks if I remove this?" for a TS frontend that models contracts as `type X = { … }`. `isClassLike` answered 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 An object-type alias is (1) and emphatically not (2) — it has no supertypes and no place in a linearization. Widening `isClassLike` to buy (1) would have enrolled every language's aliases (Rust type_item, Kotlin/Swift/Dart typealias, C typedef) into MRO and heritage, so the two questions now get two predicates. Call sites split by which they ask, and their names already said which: `resolveInheritanceBaseInScope` and `resolveQualifiedInheritanceBase` keep `isClassLike`; receiver typing and member OWNERSHIP take `isShapeLike`. Three parts, each necessary and none sufficient alone: - `findShapeBindingInScope`, mirroring `findValueBindingInScope`'s established relationship to `findClassBindingInScope` (same walker, different accepted def-type), consulted only AFTER the class lookup misses so a class of the same name always wins. - `populateClassOwnedMembers` uses it, so alias members get an `ownerId` and are registered under the alias. Without this the receiver resolved to the alias and then found no members under it. - The TS scope query tags aliases `@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 spelled it this way; TypeScript alone was on the dead tag. An earlier attempt concluded a further "scope-attachment gate" existed. That was wrong and is worth recording: scope extraction runs in the parse WORKER, which loads built `dist`, so the retag was never executed. Rebuilt, the alias hoists to Module scope exactly as the interface does. Same trap as the parse query — `src` edits to anything the worker runs are invisible until `npm run build`. Typedef and Union stay out of `isShapeLike` deliberately: they belong conceptually (the union_item note on MEMBER_OWNER_NODE_TYPES records the same gap) but neither is wired as a member container, so including them would widen a predicate nothing exercises. Verified on the full resolver matrix: 3173 tests, golden unchanged. Co-Authored-By: Claude Opus 5 (1M context) * test(typescript): pin the type-alias capture to a tag that maps to a label The capture test asserted `@declaration.type`, the tag that `normalizeNodeLabel` does not recognize (it accepts typealias / type_alias and has no "type" case). So the test passed for as long as the tag was broken: it checked only that the capture FIRED, never that it resolved to anything, while TypeScript aliases produced no scope-resolution def at all. Updated to the working tag and given a second assertion that the derived kind string is one the label mapper accepts — the property that actually matters, and the one whose absence let a dead tag sit pinned. Co-Authored-By: Claude Opus 5 (1M context) * fix(lbug): declare TypeAlias member pairs so analyze does not abort Making object-type aliases member owners emits HAS_PROPERTY from a `TypeAlias`, and the relation schema declared no such pair. The emit therefore threw `UndeclaredRelationPairError` and the ENTIRE analyze died on any repo containing `type X = { ... }` — a hard stop, not a dropped edge. Found by running the analyzer over a real 16k-node TypeScript repo, not by a test. `Method` is declared alongside `Property`: a member written `type Handler = { onClick(): void }` is a method_signature and would fail in exactly the same way. Why every existing test missed it: the resolver suites build an in-memory graph via `runPipelineFromRepo` and never write to LadybugDB, so the schema constraint was never exercised. `structural-pair-coverage.test.ts` is the one suite that does run the emitters against the declared pairs — and its own docstring names the gap: coverage is bounded by NON_BRIDGE_CORPUS, "a new structural emitter should land with an entry here". This adds that entry, pinning TypeAlias|Property and Interface|Property as sentinels. Verified the guard is not vacuous: removing the pair again makes the suite fail with undeclaredPairs: ["TypeAlias|Property"]. Co-Authored-By: Claude Opus 5 (1M context) * fix(processes): trace depth-first so multi-hop flows are detected D1 ("query ranks frontend components above the backend module that owns the concept") and D2 ("processes is dominated by trivial mechanical chains") are the same defect, and neither is about ranking or selection. The walk stops after a fixed NUMBER of traces, so 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: 10` was never approached. Measured on a real repo before the fix: of 300 processes NONE exceeded 7 steps and 90% were 3-4. A multi-hop business flow (signal → order → exit) therefore had no process that could represent it, and `query` could only rank the mechanical pairs that did exist. Step 4 of the caller already sorts by length and dedupes by endpoint — it was always asking for the deepest traces this walk could give it. 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 depth ceiling — only the order differs. Measured on the same 16k-node repo, same build and flags, BFS vs DFS (an earlier comparison was discarded as confounded — it crossed builds and --pdg): steps 6-8: 50 → 168 (3.4x) totals: 844 → 806 and the reported query moved from `LiveSetupView → Cn` (a React component) to `ReconcilePositions → IsTpInProfit / WithHeld / ShouldNotify` — server-side exit management, which is what was asked for. `traceFromEntryPoint` is exported for the test. Traversal order is unobservable through `processProcesses`: `findEntryPoints` supplies several starting points, so a deep chain is traced from inside it whatever the order does. A test at that level passes under BOTH traversals — the first version of this test did exactly that and guarded nothing. Driving the walk directly, it fails under breadth-first with "expected 3 to be greater than 3". Co-Authored-By: Claude Opus 5 (1M context) * docs(test): correct a stale status note left behind by a later fix The A1/A5 header still said "edge resolution REMAINING ... neither is implemented". Both shapes resolve — the typeable receiver precisely, the untyped one by workspace-unique name — and the tests below assert exactly that, so the note contradicted the file it sat on. It was accurate when written and went stale when the work continued past it. Left as-is it would tell a reviewer that a landed feature is missing. The TRAP note is kept: the parse worker still runs built dist under vitest, and that is still the trap it describes. Co-Authored-By: Claude Opus 5 (1M context) * feat(scope-resolution): index literals behind identity-preserving wrappers `export const INERT_EXIT_CONTRACT = Object.freeze({ ... })` minted no `Property` node for any of its keys. The object-literal rule matches `variable_declarator > value: (object)` as a DIRECT child, and freezing puts a call expression in between — so the shape whose fields are most worth querying was the one shape the rule could not see. Freezing a config object is how JS publishes an immutable contract, which is why this reads as a confident zero on exactly the fields a reader cares about. The allowlist is three functions, not "any call". `Object.freeze`, `seal` and `preventExtensions` RETURN THE ARGUMENT THEY WERE GIVEN, which is what makes the literal's keys members of the bound name. For `const x = compute({ a: 1 })` the literal is an argument and `x` holds compute's return value, so attributing `a` to `x` would be a fabrication. Two negative controls, because the obvious one is vacuous: a bare-identifier callee is rejected structurally and would pass with no allowlist at all, so the assertion that actually pins the predicate uses `Object.entries` — identical shape, differing only by name. Verified load-bearing by adding `entries` to the allowlist and watching that test alone fail. SCHEMA_BUMP 46 -> 47: parse-time emission, so a warm cache replays the pre-fix capture set. Observed as a false negative first — `analyze --force` returned the old node set until the on-disk cache was removed by hand. Co-Authored-By: Claude Opus 5 (1M context) * fix(scope-resolution): narrow multi-candidate property names by scope Workspace uniqueness was the wrong denominator. Measured on the reporting repo: `exitMinAtrMult` has 26 `Property` definitions — 16 in one-off `scripts/`, 7 in the frontend, one in a test, and exactly ONE in the backend that reads it. Every backend read was refused because of competitors the reader cannot see. The gate was not too permissive or too strict, it was scope-blind. A name with several definitions is now narrowed before being abandoned: same-file first, then files the reading file directly imports, using the finalized import graph rather than a path-shape heuristic. Exactly one survivor at the first non-empty tier resolves; anything else stays refused. A tier holding several candidates stops the walk instead of falling through — local evidence that is itself ambiguous still contradicts reaching further out. Confidence stays 0.5 at every tier. Narrowing changes which candidate is chosen, not the kind of claim: it is still a name match, and the round-1 contract is that filtering on confidence drops all name inference at once. The reason string now names the tier that fired. Ambiguity reporting goes from a count to the actual names (capped), because a count says a gap exists while the names say which fields are unanswerable. Measured on that repo, backend readers of `exitMinAtrMult` go 0 -> 24 and total readers 9 -> 45, including the two call sites in `oppositeSignalExitManager.js` the report singled out. Both narrowing tests were mutation-checked by dropping the import evidence and confirming they, and only they, fail. Co-Authored-By: Claude Opus 5 (1M context) * feat(scope-resolution): capture destructured parameter keys as property reads `function exit({ exitMinAtrMult = 0 })` reads that property off whatever the caller passes, exactly as `cfg.exitMinAtrMult` would. It never appears in a member_expression, so it had no reference site at all — and this is the shape the function that IMPLEMENTS a behaviour uses, so the most relevant reader was the one systematically missing from "who reads this setting?". Uses a distinct `@reference.read.destructured` anchor rather than `@reference.read.member`. The latter is filtered emit-side to matches with a member_expression ancestor, because calls and writes share its shape, and a destructuring pattern has none — reusing the tag would have been silently dropped by that filter. The `read.` head already maps to a read kind, so no mapping change is needed. Scoped to formal_parameters. A destructuring binding elsewhere (`const { x } = require('m')`) is frequently an import rather than a field read, and minting a property read there would attribute module bindings to unrelated same-named keys. All three cases (default value, bare shorthand, renamed key) mutation-checked by removing the patterns and confirming those three tests, and only those, fail. The renamed case also asserts the edge points at the KEY and that the local alias mints nothing. Co-Authored-By: Claude Opus 5 (1M context) * fix(scope-resolution): link type consumers to the type they name An exported contract type owned its members after round 1 and still answered `incoming: {}`, so "what breaks if I remove this field?" — the question a contract type exists to answer — had no edge to walk. Measured on the reporting repo: all 324 TypeAlias nodes AND every Interface node had DEFINES as their only incoming edge. Two independent causes, and the second is why the first was not enough. TypeScript captured no type references at all — only cpp and csharp did — so an annotation naming a declared type minted no reference site. Added for annotations, generic arguments and `as` assertions, anchored to those contexts rather than a bare `(type_identifier)`, which would also match the name in `type X = …` and make every declaration a consumer of itself. That alone fixed interfaces and left aliases still empty. `TypeAlias` was missing from `LINKABLE_LABELS`, so alias graph nodes were never indexed in `nodeLookup` and `resolveDefGraphId` could not bridge a def to its node — the edge was dropped AFTER a successful lookup. `CLASS_KINDS` has always listed TypeAlias and the ClassRegistry returned the def correctly, which is what made this read as a resolution failure; instrumenting the lookup showed it returning the right def all along and moved the search one table over. Exactly the bug already documented two entries above it for Trait. Fixes every language that spells an alias this way — TypeScript, Kotlin, Dart and Rust all emit `@declaration.type_alias`. Co-Authored-By: Claude Opus 5 (1M context) * feat(scope-resolution): capture record construction as property writes The read side answered well after the narrowing work while "who SETS this field?" still missed the code that stamps the value. A record built inline — `return { exitContract: { exitMinAtrMult: settings.x } }` — is bound to no variable, so it minted no definition and its keys referenced nothing. Modelled as WRITE REFERENCES, deliberately not definitions. 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 fields resolvable — measured at 26 competing definitions for one field on the reporting repo, which is what made every backend read unanswerable in the first place. 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 — and is asserted as such. The enclosing literal is the receiver and it is anonymous, so these route through the same narrowing and the same refusal-to-guess as every other untyped receiver. Verified on the reporting repo: `entryPlan.js` went from no rows to `selectExitEnvelope` as a writer of `exitMinAtrMult`. Both captures mutation-checked. Co-Authored-By: Claude Opus 5 (1M context) * feat(processes): select round-robin by terminal so the list is not one flow repeated Ranking was `sort by length` alone, so the top of the list was one behaviour described many ways: eleven of the top fourteen processes on the reporting repo were four entry points crossed with three terminals of the SAME date-window utility cluster. Genuine call chains, but a reader learns one thing from fourteen entries, and the repo's own domain flows sat below them. Selection now round-robins across TERMINALS, deepest first. Depth still orders within a terminal and still leads the list; what changes is that no terminal takes a second slot until every other has had a first. Keying on the entry point 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. Measured on that repo: distinct terminals in the top 20 went 3 -> 20, and its domain flows (`ReconcilePositions -> ...`) moved into the top 4%. Two things this deliberately does not claim. 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, measured, and reverted for moving nothing. And a business flow still cannot be a process in its own right: the walk only emits at a leaf, at max depth, or on a cycle, so a flow whose meaningful endpoint calls onward survives only as whatever leaf it bottoms out in. Both are recorded in the code so neither reads as settled. Co-Authored-By: Claude Opus 5 (1M context) * test(structural-pairs): pin the type-annotation USES pair R2-2 emits USES INTO a `TypeAlias`, so the pair is `Function|TypeAlias` — a different table from the `TypeAlias|Property` entry added in round 1, and one that entry stays green without. `TypeAlias` is on the eleven-table list this suite exists for, and an undeclared pair does not degrade: it throws `UndeclaredRelationPairError` and kills the entire analyze on any repo containing an annotated type. Every resolver suite still passes, because they build an in-memory graph and never write to the DB. That exact failure shipped once in this PR already. Two emitters into the same label, each with its own way to reach a released build. Co-Authored-By: Claude Opus 5 (1M context) * fix(scope-resolution): build the module-level set before the out-of-core seal Review blocker. Under `GITNEXUS_DISK_SCOPE_INDEX=1` the seal replaces every ParsedFile with a scope-STRIPPED copy, and the block-local filter's set was built after it — so it walked `scopes: []` for every file, came out empty, and the filter read that as "no def is module-level" and dropped EVERY `Const`/`Variable`/`Static` ACCESSES edge in the repo. All languages, all files, including the module-scope-const edges this PR exists to add. Nothing threw and nothing logged, on the path the largest repos take: the exact confident-empty answer the PR is about. Built above the seal now, from `parsedFiles`, and passed as `undefined` rather than an empty set when no scope was inspectable — an empty set is a legitimate answer ("this repo has no module-level value defs") and must not be indistinguishable from "could not look". Fails open; the block-local exclusion is still asserted under the seal, since that is correctness rather than optimization. Also widens module level past `kind === 'Module'`. A `Namespace` scope (TS `namespace`, Rust `mod`, C++/C# `namespace`) holds importable values too, and treating its consts as function-locals dropped their reads. Included only when the whole chain to the root is Module/Namespace, so a namespace declared inside a function body stays local — asserted both ways. That fixture then failed for a third reason: `@reference.read.identifier` existed ONLY in the JavaScript query, so A2 did not work for TypeScript at all. Added there, and both languages widened to `variable_declarator value:` and `binary_expression` operands — the gaps review named between what A2 claimed and what it matched. Nothing covered `GITNEXUS_DISK_SCOPE_INDEX`. The new parity test asserts the seal changes no edge, and was verified against an emulation of the original bug: same-file readers vanish and only the cross-file reader survives. Co-Authored-By: Claude Opus 5 (1M context) * fix(typescript): anchor property_signature to declared shapes Review blocker, and it reproduces end to end. `property_signature` occurs in EVERY object_type in the TS grammar, not only in an interface body or an alias's object type, so inline parameter types, inline return types and nested object types all matched — and the enclosing-container walk hung each one off the nearest class, interface or alias. Measured against the unanchored rule, all four appeared as members of shapes that do not have them: Property:contracts.ts:Svc.inlineParamOnlyKey Property:contracts.ts:Repo.inlineQueryOnlyKey Property:contracts.ts:NestedConfig.nestedOnlyKey Property:contracts.ts:buildInline.inlineReturnOnlyKey@46:33 When the inline member shares a name with a real one — `run(opts: { retries: number })` inside a class that declares `retries` — `addNode` is first-write-wins and the two distinct symbols merge onto one node, so every context()/impact()/rename() answer about that field describes the merge. 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 nested object types are excluded by the same anchor rather than by a second rule. The first version of these tests was VACUOUS and is recorded here because the reason generalizes: a collision and a correct exclusion both leave exactly one node behind, so counting ids cannot distinguish them. Every inline member in the fixture is now uniquely named, which is the only thing that discriminates — verified by restoring the unanchored rule and watching exactly those four assertions fail. A fifth test asserts anchoring costs no real member. Co-Authored-By: Claude Opus 5 (1M context) * fix(analyze): correct the numbers feeding the graph-write-collapse guard Review blocker. The predicate itself held under adversarial probing; every defect was in what it was handed and what happened after it fired. (a) `expected` was wrong twice. Under `GraphEmitSink` streaming the bulk types leave the heap at parse time and never enter `relationshipCount`, so the count understated the real volume by most of it and the ratio passed trivially — on `force === true` runs, which include crash recovery AND the `analyze --force` retry this check's own warning tells the operator to run. Adds the manifest totals, the same correction the buffer-pool hint in this file already makes for the same reason. Separately, an incremental run persists only the changed subgraph while both counts are whole-scope: a 10,000-edge index that lost 200 replacements reads 9,800 and is certified complete. The check is skipped on that path rather than answered wrongly. (b) A throwing edge count became a measured zero. `getLbugStats` initialised its total to 0 and ran the query in a swallowing catch, so WAL/lock contention during finalize — documented on this exact call — reported a healthy index as a total collapse. It now returns `number | undefined`, and the caller requires both a readable node count and a defined edge count. (c) A total loss was exempted for being small. The min-edges rule tested `expected` before looking at `persisted` at all, so `expected = 99, persisted = 0` — every edge gone — stayed fresh and reported success. Total loss is now decided first. The existing test asserted the defect; it now asserts a PARTIAL shortfall, which is the case the exemption was written for. (d) A detected collapse reported success and exited 0. It is different in kind from the other incomplete reasons: those describe a run that did what it said and left work for later, this one means most of your edges are gone and every query answers a confident empty. The CLI now prints INCOMPLETE with the counts and sets a non-zero exit code, and the flag crosses IPC so the worker cannot send a clean `complete` either. Nothing exercised this wiring — only the pure helper. Adds tests for all four, each written so the pre-fix arithmetic fails it. Co-Authored-By: Claude Opus 5 (1M context) * fix(scope-resolution): keep unique-name property inference inside one language The pass indexed `Property` nodes from the whole shared graph. Per-language gating decides whether it RUNS for a language; it never restricted which nodes could be TARGETS. So the only carrier of a name could be in another language entirely, and a read here resolved to it on name uniqueness alone — no owner, no file, no call path. Reproduced: a Java class declaring `private int loyaltyPointsBalance` and a JS `cfg.loyaltyPointsBalance` on an untyped parameter produced an ACCESSES edge from the JS function to the Java private field. Confidence does not mitigate it, because `minConfidence` defaults to 0 — the tier is only a filter for consumers who ask for one. Candidates are now restricted to files in the language's own `parsedFiles`, which is a precise restriction rather than a heuristic and needs no new node property. Every other fixture in the suite is single-language, so this could not be caught anywhere by construction. The new fixture is deliberately polyglot and asserts both halves: no cross-language edge, and a same-language unique name still resolves. Known and not addressed here: the index is still O(total graph nodes) and is rebuilt once per qualifying language, the per-language whole-graph-scan pattern `phase.ts` hoisted out for `sharedNodeLookup`. Hoisting it belongs with that machinery rather than in this fix. Co-Authored-By: Claude Opus 5 (1M context) * fix(processes): explore siblings in source order, log the exhausted budget `slice(0, maxBranching)` selected the FIRST N callees while `pop()` explored them LAST-first, so the trace budget went to the last-declared branch. For `main() { init(); loadConfig(); run(); shutdown(); }` the walk spends itself on `shutdown` and can drop `init` — the earliest steps of a flow, which is the opposite of what a process describes. Selecting first-N and exploring last-first was simply inconsistent; pushing in reverse makes the stack pop in source order. Measured on the reporting repo, this costs depth: 6-8 step processes go 168 -> 146 of 816. Still roughly three times the pre-PR baseline of 50, and the right trade — a deep branch is no longer reached by accident of being declared last. The remaining limit is the BUDGET, not the traversal: with a fixed quota a deep branch declared after enough shallow ones is not reached at all. That is now asserted in both directions rather than left implicit, and the walk logs when it stops with branches unexplored — a silently truncating cap reads as "this is everything", the same confident-empty answer this work is about, and the repo already sets that precedent for `dispatchFanoutSkipped`. Removes the second depth test, which was vacuous: the note twelve lines above it already said a `processProcesses`-level depth assertion passes under BOTH traversals, and measured it does — breadth-first yields the same deepest stepCount of 8, so it passed with the production change reverted. Traversal order is asserted against `traceFromEntryPoint` directly; what is observable at the pipeline level is which traces survive selection, which the diversity tests cover. Also renames `queue` to `stack` and corrects the BFS references in the module docstring and the function's own JSDoc, which is what an IDE hover shows. Co-Authored-By: Claude Opus 5 (1M context) * fix(impact): carry riskNote onto ambiguous candidates and separate UNKNOWN's two meanings Two problems on the ambiguous fan-out, which builds its own candidate object rather than returning the single-symbol shape. The narrowed type had no `riskNote` field and never read one, so a candidate that resolved and found no callers reported `risk: UNKNOWN` with nothing attached — losing the entire point of the change on the path where the reader has the least context, since the name is ambiguous there by definition. And `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 could no longer be told apart from a broken one. Candidates now carry `probeFailed`, and the comment asserting the old reading is corrected. Also aligns `gitnexus-web`, which review flagged as giving a different verdict for the same symbol. That surface answers in prose rather than an enum, and its message said the symbol "appears to be unused (not called by anything)" — the identical false certainty in words. It now carries the same MEANING rather than the same field. Downstream wording is unchanged: no outgoing dependencies really is a fact about the symbol itself. Co-Authored-By: Claude Opus 5 (1M context) * test: replace assertions that cannot fail Four from review, each satisfied by the defect it was meant to catch. `new Set(props).size === 2` over two different literal strings can only ever be 2, so it could not detect the node merge its title promises — that is a difference in COUNT, now asserted on the raw array. The ambiguity test asserted only an empty edge set, which is satisfied equally by "the gate fired" and "the name was never looked up". It now also requires the ambiguity counter to have moved. `Interface|Property` was listed as a structural-pair sentinel beside `TypeAlias|Property`, but both its labels are in the SCOPE_BRIDGE cross-product so the pair is generated by construction and the sentinel cannot fail. Dropped rather than left reading as coverage; `TypeAlias|Property` is the load-bearing one. `TypeAlias|Method` was declared in the schema with no fixture emitting it — a declared pair no emitter exercises is indistinguishable from a missing one until an analyze aborts on a real repo. Adds a method-shaped alias member, and the suite requires sentinels to actually appear, so it is not vacuous. Co-Authored-By: Claude Opus 5 (1M context) * docs: document the new incomplete reason, the UNKNOWN verdict and the id churn Review found the code changes landed without the guidance around them, and an agent following this repo's own rules would have been told the wrong thing. `graph-write-collapsed` joined `INDEX_INCOMPLETE_REASONS` with no Sign block and no recovery section, while the precedent it cites (`embedding-checkpoint-pending`) has both — so `gitnexus status` would surface a new string naming silent wrong answers with nothing explaining trigger or remedy. Added to RUNBOOK and GUARDRAILS, including why this reason alone also fails the exit code. `AGENTS.md` said MUST warn on HIGH or CRITICAL and never mentioned UNKNOWN, and the shipped impact skill's risk table had no UNKNOWN row and still implied few-callers ⇒ LOW. An agent obeying those rules literally sees `risk: UNKNOWN` and proceeds, which negates the change the verdict exists to make. Both copies of both skills updated. `MIGRATION.md` now records that process ids do not survive this release — positional ids plus depth-first tracing, source-order siblings and round-robin selection mean `proc_7_handle` is a different flow afterwards. Bounded honestly: nothing in-repo joins on a raw process id, so it is index churn, not a broken consumer. `ARCHITECTURE.md`'s scope-resolution stage list gains the two new stages. The guide skill's node list gains `Property` and `TypeAlias` — the two node types this work most prominently creates. Also, on the pair-CSV preflight review asked to confirm: the hard abort IS deliberate, because a fallback recovering zero rows is the confident-empty failure this work targets. But the transient the message itself names — a second concurrent analyze sharing `.gitnexus/csv` — is a race, so the check now re-looks three times over ~150ms before declaring the file gone. Long enough to ride out a rename, far too short to mask a file that is genuinely missing. Co-Authored-By: Claude Opus 5 (1M context) * fix: drop redundant TypeAlias pairs and keep bare identifiers off class members Two regressions the full suite caught after the review fixes, both real. `schema-pair-coverage` failed with eleven hand-declared pairs that a rule now generates. Adding `TypeAlias` to `LINKABLE_LABELS` — needed so `resolveDefGraphId` can bridge an alias def to its node — also makes it a SCOPE_BRIDGE source and target, so the cross-product produces `File|TypeAlias`, `TypeAlias|Property` and nine others that round 1 had declared by hand. Removed; the invariant is that no pair is both generated and hand-declared. This also changes what the structural-pair sentinel means, and the comment is corrected rather than left overstating it: `TypeAlias|Property` is no longer load-bearing because the label is off the generated grid — it is load-bearing because it now depends on `TypeAlias` being IN `LINKABLE_LABELS`. Remove it and the pair stops being generated while the hand declaration is gone, which is the same state that silently breaks alias consumer edges. `block-scope-shadowing` failed because a bare identifier resolved to a class `Property`. `class Box { baseUrl = '...'; pick() { const baseUrl = ...; return baseUrl; } }` linked the block-local read to `Box.baseUrl`, duplicating the legitimate `this.baseUrl` edge. A bare identifier is not a member access: with no receiver there is no object whose property it could be, and in JS/TS a field read needs `this.`. Receiver-less read/write sites no longer accept `Property` hits; callables stay reachable, so `cb = save` naming a top-level function is unaffected. That defect PREDATES this branch's TypeScript captures — JavaScript has emitted bare-identifier reads since A2 and no class fixture exercised the shadow. The TS parity added here is what surfaced it. Golden snapshot regenerated after verifying the drift line by line: exactly +5 USES from type annotations in the mini-repo, every pre-existing count unchanged, so nothing was rewired. Co-Authored-By: Claude Opus 5 (1M context) * perf(scope-resolution): share the property-name index across language passes Review follow-up. `indexPropertyNodesByName` scanned every node in the graph and was rebuilt inside each qualifying language pass, reintroducing exactly the pattern `phase.ts` hoisted out for `sharedNodeLookup` — whose comment records why it matters: "the previous per-language rebuild burned that CPU+heap N times and, on a huge repo, a tiny language's full-graph copy overlapped the next language's — a real contributor to the scope-resolution memory peak." Built once in `phase.ts` beside `sharedNodeLookup` and `sharedFnNodeIndex`, and threaded through the same `prebuilt*` seam, so tests and isolated calls still build their own. Sharing is only safe because the per-language restriction MOVED rather than disappeared: the shared index is whole-graph, and candidates are filtered to the language's own files at lookup time. That also fixes a subtlety the per-language build had backwards — the cap now applies to the FILTERED set, so a name carried by forty properties across a polyglot monorepo but only two in the language being resolved is still answerable, where a global cap would have refused it. The tri-state at the lookup boundary is deliberate and the three outcomes are not interchangeable: no property of this name in this language (nothing to say, and NOT an ambiguity), too many to choose between (reportable), or a list to narrow. Caught mid-change by the polyglot fixture: an intermediate state shared the index without moving the filter, and the cross-language edge came straight back. That test earning its keep twice is the reason it exists. Co-Authored-By: Claude Opus 5 (1M context) * feat(scope-resolution): report when a field's only anchor is another language Round 3, found OUT-OF-SAMPLE — six field names appearing in no prior report, so nothing here was tuned against them. All six answered 0 backend ACCESSES while their definitions sat in `apps/research-dashboard/**`: TypeScript only. The in-sample set scored 5/5 and the out-of-sample set 0/6, and the gap is entirely this. Per-language inference (`3c5eadc7`) is right and stays. What was wrong is that declining is INVISIBLE: an empty result for a field anchored only in TypeScript is byte-identical to an empty result for a field nobody reads. One says "look in the other language or grep"; the other says "delete it". That is the same confident-empty failure this series exists to remove, one surface over — and this time the missing fact is about the ANALYZER's reach rather than the code. Declines are now counted and named, with the languages the anchors actually live in, kept SEPARATE from ambiguity because the remedies differ: ambiguity wants better receiver typing, this wants an anchor in the reading language. Collapsing them would tell a reader the wrong thing to do. A non-zero count warns at analyze time regardless of dev mode. The facts are published as `PipelineResult.propertyInference`, which they had to be for any of this to be testable — and that exposed a second defect. The round-2 ambiguity assertion, which I told the reviewer of #2856 I had strengthened, read its stat off a `scopeResolution` field that does not exist on PipelineResult: the `if (undefined) return` guard swallowed it and the test passed with the production code deleted. Both that assertion and the new ones now read the published field, and the guard is an assertion rather than an escape. Verified by deleting the counter and watching them fail. Reported by the same round-3 method note that caught it: verifying a fix against the cases it was written for only proves those cases pass. Co-Authored-By: Claude Opus 5 (1M context) * feat(context): explain an empty property result caused by a cross-language anchor The other half of R3-1. The analyze pass now knows which fields it declined to link because every definition of the name lives in another language; this puts that fact where it is actually read. `context()` on such a field previously returned an incoming list byte-identical to a genuinely unread field. The two demand opposite actions — "look in the other language, or grep" versus "delete it" — so the difference has to travel with the answer: unresolved: property reads of this name were NOT linked: every definition of it is typescript, 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: ['typescript'] Carried through repo meta because the graph cannot answer it: the unlinked reads mint no edge and no node, so the only record is the pass that declined them. Keyed on the NAME, not on the resolved label. Gating on `=== 'Property'` was tried first and is wrong — the label reads `''` on this path for a plain Property node, so the gate silently suppressed the entire feature while every test still passed. Caught by asserting the field is DEFINED rather than guarding on it, which is the same anti-pattern that made two earlier assertions vacuous. The meta list only ever contains property names, so matching the name is itself the type check. Cached per (index, indexedAt): `ensureInitialized` deliberately avoids a per-call `loadMeta` because every tool routes through it, so this re-reads exactly when a re-analyze could have changed the answer and never otherwise. Co-Authored-By: Claude Opus 5 (1M context) * feat(scope-resolution): report declined property reads for opt-out languages too Generalizing R3-1 rather than waiting for it to be re-reported in the other direction. The reported case was a JavaScript read whose only anchor was TypeScript; the mirror — a TypeScript read anchored only in JavaScript — was still silent, because a language that sets `fieldFallbackOnMethodLookup: false` had the whole pass skipped, and skipping emission also skipped REPORTING. Detection is not inference. Counting what could not be linked asserts nothing about what it means, so `reportOnly` runs the pass for its facts while emitting no edge, and the opt-out keeps protecting exactly what it protected before. Two things this turned up that a single-instance fix would have missed: The cross-language fixture could NOT prove `reportOnly` is load-bearing — the per-language candidate filter already blocks those edges, so the assertion passed with the flag forced off. The case that discriminates is a SAME-language TypeScript read that name inference could legitimately link and the opt-out forbids; forcing the flag off there emits `readsTsOnly -> tsOnlyBudget`, which is the violation. Getting to that case surfaced a sibling gap, recorded but NOT fixed here: the object-literal `Property` rule is JavaScript-only, so `const CONFIG = { ... }` in a `.ts` file mints no node and its keys are invisible. The first draft of this fixture used exactly that shape and could not discriminate for that reason. It is the TypeScript half of R2-1a and wants its own change, not a rider on this one. Co-Authored-By: Claude Opus 5 (1M context) * feat(typescript): index object-literal keys, as JavaScript already did The sibling recorded in `0c5a4f64` and deliberately left out of it. Both the named object-literal rule (A1/A5) and the identity-wrapper rule (R2-1a) lived only in JAVASCRIPT_QUERIES, so the single most common config idiom in TypeScript — export const tsRuntimeConfig = { tsConfigRetries: 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 gain no name-based inference. What they gain is the PRECISE path, which is the route TypeScript is meant to use: `tsRuntimeConfig.tsConfigRetries` has a typeable receiver and now resolves. A read through an untyped receiver stays unresolved and, since `0c5a4f64`, is 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 — with the same `Object.entries` negative control pinning the allowlist. Found by fixture, not by report: the first draft of the `reportOnly` test used a TS `const CONFIG = { ... }` as its discriminator and could not discriminate, because the shape mints nothing. That is the whole argument for sweeping a class instead of waiting for each instance to be filed. SCHEMA_BUMP 47 -> 48: parse-time, so a warm cache replays ParsedFiles carrying none of these matches and the keys stay invisible. Co-Authored-By: Claude Opus 5 (1M context) * feat(scope-resolution): anchor anonymous returned object literals to their function The last gap round 3 named, and the dominant shape in idiomatic JS: 437 `return {` sites in a single backend directory of the reporting repo, including the ~25-field payload of its entire signal pipeline. The literal binds to nothing, so its keys could not even be named — "who reads wickRatio?" had no symbol to ask about. The enclosing FUNCTION is the owner: the literal is that function's return shape, a contract its callers consume. Keys qualify as `.`, so two functions returning the same name stay two shapes rather than one merged symbol, and multiple returns in one function stay distinct by position. RECONCILING THIS WITH R2-1b, which deliberately modelled returned keys as WRITES to avoid adding same-named competitors to narrowing. These are definitions, but narrowing now ranks DECLARED anchors — named literals, class fields, interface and alias members — strictly above return shapes. A name that already resolved keeps resolving to what it resolved to before, so the competitor problem R2-1b was avoiding cannot come back. Mutation-checked: dropping that ranking breaks five pre-existing R2 resolutions. That also required an R2-1b assertion to change, and the change is a strengthening rather than a concession. It asserted `toHaveLength(1)` — no new definition — as a proxy for "adding definitions must not move an existing answer". The proxy is now false while the property still holds, so the property itself is asserted directly. No `HAS_PROPERTY` edge from the function: that would be a `Function|Property` relation pair the schema does not declare, and an undeclared pair does not degrade — it throws and kills the whole analyze. That already shipped once in this PR. Two things found by dumping rather than assuming, both fixed here: SHORTHAND keys were not matched at all. `return { symbol, interval, score }` is the commonest spelling and the reporting repo's own payload is mostly this form, but tree-sitter models it as `shorthand_property_identifier`, which `(pair)` does not match. Caught by dumping the golden fixture and seeing a literal returning `{ level, message, timestamp: Date.now() }` had indexed only `timestamp`. Now covered in return position AND in the variable-bound rule, which had the same gap. Provenance was flagged by owner-presence, which mislabelled the anonymous case: a callback's return shape yields no name to qualify by, so it looked like a DECLARED anchor and would have outranked real declarations. Flagged by position now — a different question from whether a name could be derived. SCHEMA_BUMP 48 -> 49. Within one PR the version only has to differ from main's, but a build stamped 48 was installed and used to analyze before these captures existed, so caches stamped 48 carry none of them — the intermediate-build hazard this ledger already records for 33/34. Golden regenerated after verifying the drift: exactly +10 Property and +10 DEFINES, every pre-existing count unchanged. Co-Authored-By: Claude Opus 5 (1M context) * fix(scope-resolution): rank production anchors above test fixtures Found by testing R3-4 on the reporting repo instead of on its fixtures. Anchoring returned literals took `wickRatio` from 6 definitions to 13 — and backend reads still resolved to nothing, because SEVEN of the new JavaScript anchors compete and four of them are in `tests/`. A test constructs throwaway shapes carrying production field names; a read in shipped code cannot mean one of them. Applied before the declared/return-shape split, because "is this the shipped program" is the stronger signal — a declaration inside a test fixture is still a test fixture. Skipped when the READER is itself a test, since a read there legitimately means the test's own shape. The first version of this test was vacuous and the mutation check caught it: the reader sat in the same file as the production anchor, so the same-file tier resolved it whether or not this tier existed. The reader now lives in a file that imports neither anchor, which leaves production-vs-test as the only thing that can decide. Honest about what this does NOT do: it narrows `wickRatio` from seven candidates to three, and three functions in different files each returning that field is GENUINELY ambiguous — refusing is correct, and the ambiguity is now counted and named rather than silent. The reported question ("who reads wickRatio?") is answerable only where one producer exists; where several do, the honest answer is the list of producers, which R3-4 made nameable for the first time. Co-Authored-By: Claude Opus 5 (1M context) * feat(scope-resolution): resolve members through a call result's return shape The question three rounds of reports could not answer, and the one narrowing must refuse by design: a field produced by SEVERAL functions. A read of `spike.wickRatio` could mean any producer, so name inference correctly declines and no amount of tier-tuning changes that. It needs evidence, not inference. The evidence existed in two halves that had never been joined. The call-result type binding (`const alert = formatSpikeAlert(row)` binds `alert` to a TypeRef whose rawName is the callee) predates all of this work; it simply had nothing to resolve to when the callee returned an anonymous literal, because an anonymous literal named nothing. R3-4 gave it a name. Joining them: const alert = formatSpikeAlert(row); alert.wickRatio -> Property:...:formatSpikeAlert.wickRatio Precise, at ordinary emission confidence, and it works EXACTLY where narrowing cannot: several producers sharing a field name stop being competitors because the receiver says which one. Runs before the name fallback and claims its sites, so a precise answer is never second-guessed by a name match. Measured on the reporting repo: 1,410 precise edges, and all six fields round 3 verified OUT-OF-SAMPLE go from 0 backend readers to 7, 11, 10, 7, 6 and 14. Round 3 scored 0/6 on that set; this is 6/6. The bound is asserted, not just documented: a read off a BARE PARAMETER has no binding here, because typing it needs the caller's type to flow in — that is inter-procedural and genuinely larger. Those reads still fall through to name inference and are still reported when it declines. The fixture has two producers sharing a field name precisely so the test cannot pass by name matching, and mutation-checking the owner lookup fails it. No SCHEMA_BUMP: this is scope resolution, not parse-time capture, so a warm cache already carries everything it reads. Noted in the ledger because the reflex on this branch has been to bump, and an unnecessary bump costs every user a full re-parse. Co-Authored-By: Claude Opus 5 (1M context) * Revert "return-shape anchoring" (R3-4/R3-5): it degrades query Reverts af5eec5c, c764847a and 4f93f32e. The capability was real and measured — all six fields round 3 verified OUT-OF-SAMPLE went from 0 backend readers to 7/11/10/7/6/14, 0/6 to 6/6, via 1,410 precise return-shape edges. It is reverted anyway, because it costs more than it buys in its current form. `cli-limit-e2e` caught it. Bisected to af5eec5c: on the mini-repo fixture, `query('message')` returned two processes before and NONE after. The mechanism is not window displacement — that hypothesis was tested with a partition that kept function-local property keys from taking window slots, and it changed nothing. Indexing the keys of every returned literal adds many nodes whose names are ordinary words, which moves the BM25 CORPUS statistics: "message" gets less discriminating, and `createLogEntry` — the callable that actually carries the processes — stops ranking at all. A corpus-level effect is not repairable by a tie-break. Trading a regression in `query`, one of the core tools, for coverage in `context` is the wrong trade, and shipping it because the number was good would be the same mistake this PR spent three rounds removing: a confident answer that is worse than the honest one. What the work established, and what re-landing needs: - The mechanism is right. Joining the existing call-result type binding to a named return shape resolves `alert.wickRatio` by EVIDENCE, which is why it succeeds exactly where name inference must refuse. - The cost is search dilution, and it needs to be measured on BM25 ranking BEFORE the capture lands — not discovered by a downstream e2e test. - The likely shape of the fix is keeping return-shape keys out of the text search corpus while keeping them in the graph, which needs persisted provenance rather than the in-memory flag used here. Kept: everything through 8972d223, which is verified green. Co-Authored-By: Claude Opus 5 (1M context) * feat(search): give the index a notion of DETAIL symbols, and re-land R3-4/R3-5 Reverts the revert. The return-shape work was correct and measured — 1,410 precise edges, and all six fields round 3 verified out-of-sample going 0/6 to 6/6 — and it was dropped for a regression that was really a MISSING LAYER: the search index had no way to say "this symbol is queryable but is not a concept a text search should surface on its own". Indexing the keys of anonymous returned literals adds many nodes whose names are ordinary words (`message`, `value`, `timestamp`). Without that notion they compete on equal terms in FTS, push the CALLABLES named after the same concept past the search's row cap, and `query('message')` returned two processes before and none after. The layer, rather than a workaround: - `Property.isDetail`, persisted. A Property-only column, which that table already precedents with `declaredType`, set where the key is minted. - `buildFtsQueryCypher` filters on it for the Property table, BEFORE the row cap. That placement is the whole point: rows crowded out never reach the caller, so no downstream re-ranking can recover them. Two downstream fixes were tried first — a tie-break and a partition of the merge window — and recovered nothing, which is what located the real seam. - `IS NULL`-tolerant, so an index written before the column existed still answers instead of returning nothing. Verified by the A/B that found the regression: the query's result order is now byte-identical to the pre-R3-4 baseline — `proc_0_processrequest, proc_2_errormiddleware, Function:createLogEntry, Property:LogEntry.message` — with the return-shape coverage retained. The determinism guard then caught prose in the new DDL comment containing the token this repo scans for, which would have read as an unordered query. Reworded; that suite is doing exactly its job. Co-Authored-By: Claude Opus 5 (1M context) * feat(processes): let a flow end where the program reaches outward The item three rounds kept circling. A trace was 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 could never be a process in its own right. Ranking could not fix that; the flow was never a candidate to rank. What blocked it was signal granularity, and the fix is the layer that was missing rather than a heuristic. 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 cannot end a trace — 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: no new extraction, no new relation pair, no schema change. Innermost wins, so a closure that performs the call is the sink rather than the function spanning it. Three touch points, and the second is the one that makes or breaks it: - the walk emits at a sink AND CONTINUES, so `placeOrder` is an endpoint while `placeOrder -> formatDate` still exists separately; - subset-removal PRESERVES sink-terminated traces. A sink flow is by definition a prefix of the chain that runs past it, so emitting one at the walk and deleting it one step later would have been a no-op. Mutation- checked: removing this preservation fails all three sink tests, including the one asserting the sink is reached at all; - selection ranks sink-terminated above leaf-terminated, then by depth. `processes` now declares `parse` as a dependency. It historically avoided that on the grounds the dependency was spurious for a progress counter — it is no longer spurious, so it is declared rather than reached for implicitly, and the read fails open so a pipeline without that output detects no sinks instead of losing every process. Bounded honestly: this fires where fetch/ORM extraction fires. On the reporting repo it will do nothing until route detection handles hand-rolled dispatchers, since that codebase routes with `pathname === '/api/...'` on raw node:http and produces zero Route nodes — a separate gap, and the next one worth closing. Co-Authored-By: Claude Opus 5 (1M context) * docs(processes): the comment above the sink ranking still described it as unreachable R3-6 taught the walk what a sink is, but the block explaining the ranking still carried the paragraph written when that was out of reach — "a business flow still cannot be a process in its own right ... fixing that means teaching the walk what a sink is" — sitting directly above the code that does exactly that. A reader arriving at `rankedByInterest` would take the limitation as current. The measured-false fan-in finding stays; it is still true and still worth not re-deriving. What replaces the stale half is the bound that IS current: sinks fire where fetch/ORM extraction fires, so a codebase whose outward calls are not detected as such still sees leaf-terminated traces only. Co-Authored-By: Claude Opus 5 (1M context) * feat(routes): read a route that is declared by a comparison, not by a framework `route_map` on the reporting repo returned {"routes": [], "total": 0, "message": "No routes found in this project."} for a codebase with SEVENTEEN route modules, an `apiRouteTable.js`, and 113 path comparisons. Not a partial answer — a statement about the code, and a false one. Same confident-empty class as the rest of this branch, except here it takes out a whole tool. Four route-discovery paths existed — filesystem convention, single-file framework route, cross-file framework route, decorator — and every one of them needs a FRAMEWORK to declare the route. A raw `node:http` server declares it the only way the language offers: if (req.method === 'GET' && pathname === '/api/live/portfolio') { … } A path, a verb, and a handler. Nothing in the pipeline could read it. The failure modes are not symmetric, so the rules are weighted accordingly: a route this misses is a coverage limit, a route it invents is `route_map` asserting something false. A comparison therefore qualifies only against a demonstrable request path (`pathname`, `*.pathname`, `req.url`; `path` is excluded — in Node it is overwhelmingly `node:path` or a file location), and anything untranslatable is dropped rather than approximated: - `pathname.startsWith('/api/')` is a namespace test; minting `/api` would claim a route nobody serves; - a bare `pathname === '/'` with no verb is more often the static-file normalisation branch (`pathname === '/' ? '/index.html' : pathname`) than a route — WITH a verb the intent is unambiguous, so that form IS taken; - an anchored regex converts only when its body is a literal path plus single-segment wildcards, so `/^\/api\/research-runs\/[^/]+$/` becomes `/api/research-runs/{param1}` while an optional group or an alternation bails. Three things went in that nobody reported, each found by measuring rather than by a second report. `switch (pathname) { case '/api/x': }` is the same dispatch in different syntax, and waiting for a bug report per shape is how a graph stays permanently one idiom behind the code it indexes. The reconciliation had to move up a level. 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 lists every route twice — once verb-less with the table as its "handler", once properly. Measured: 22 of the first 94 routes were that shadow. Only the whole registry can tell them apart, so the rule lives in the routes phase and touches dispatch-guard routes only — a framework route without a verb is method-agnostic BY DECLARATION (a Django function view, a Laravel resource), a fact rather than a weaker observation. And a path composed from a constant needed folding. One of those seventeen modules writes every one of its routes as `` `${autoTradeBasePath}/rules` ``, where the base is an alias of a module-level literal. Refusing that lost the whole file — and lost it INVISIBLY, since a module with unfoldable paths and a module with no routes are the same empty answer. Same-file only, literals only, one alias hop, and it refuses on ambiguity: a name declared twice with different values is dropped rather than guessed, because a partially-folded path is a wrong route and a wrong route is the failure this module exists to avoid. Wiring is a LanguageProvider hook, not a language check in shared code. `extractDecoratorRoutes` was already the general "route from this file's own AST" channel rather than a decorator-only one — express routes have flowed through it as `decorator-express.get` for a while — so the transport, the `(method, url)` dedup and the handler-symbol resolution all apply unchanged. `ExtractedDecoratorRoute.source` carries the one thing that genuinely differs: a decorator route is DECLARED, a dispatch-guard route is INFERRED. The walk is gated behind a substring pre-filter so it costs nothing on files that cannot produce a route, and the gate is sound by construction — every rule reaches a route only through `isPathExpression`, which needs one of exactly those tokens. SCHEMA_BUMP 49 -> 51, two entries. Decorator routes are worker output carried in the parse cache, so a warm cache replays results predating the extractor and `route_map` stays empty — the symptom this fixes, wearing the mask of "the extractor does not work". The second bump is the v34 hazard tripping again: a build stamped 50 had already been used to analyze before folding existed, so caches stamped 50 carry the unfolded route set. Caught by measuring — the post-folding run came back suspiciously fast and would have reported the pre-folding number. Co-Authored-By: Claude Opus 5 (1M context) * fix(scope-resolution): ask whether a value def is FUNCTION-LOCAL, not whether it is module-level The locality filter for value references was written as an ALLOWLIST of module-scope defs, and that shape cannot express a class member. A value def has three homes, not two: module level, a function body, and a CLASS body. Java and C# fields and Python class attributes live in the third, so an allowlist keyed on "module level" excludes every one of them by construction. The guard written to make that safe could not fire either. The set arms 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, which is exactly the state the guard exists to distinguish from "there genuinely are none". Inverting it removes the class. A blocklist of defs positively identified as function-local fails safe: a Java field, a Python class attribute, or a language whose scopes could not be inspected is emitted rather than dropped. That also retires the arming flag — an empty blocklist and an uninspected one mean the same thing, and both mean "emit". The failure mode moves from "silently deletes an edge class" to "retains an inert local", which is the right direction for a tool whose stated principle is that a confident empty answer is the worst outcome. MEASURED, because the review that prompted this reported it as a P0 deleting every Java/C#/Python field ACCESSES edge, and that half does not reproduce. Instrumenting the bridge over `java-write-access` shows ZERO value-ACCESSES candidates reaching the filter: Java field references resolve to a `Property` target and `isValueDefinitionLabel` covers only Const/Static/Variable, so the filter is never consulted there. Pipeline-level edge sets are byte-identical with the filter forced on and forced off, across four shapes — Java cross-file field writes, Java cross-file constant reads, Java bare same-class constant reads, and a Python module-constant/class-attribute mix. The defect is real and latent; the blast radius is not. Fixed anyway, because the predicate asks the wrong question and the next change that makes the bridge the sole emitter would ship the deletion for real. New `value-ref-locality.test.ts` pins the invariant triple — local dropped, module-scope kept, class member kept — by TARGET rather than by `reason`. The per-language suites filter on `rel.reason === 'read'|'write'` while the bridge stamps `scope-resolution: read|write`, so they are blind to bridge-side change in both directions. The file states plainly which half gates the mechanism (JS, mutation-verified) and which gates only the outcome (Java, because the mechanism is unreachable there), so it cannot be mistaken for a stronger gate than it is. Co-Authored-By: Claude Opus 5 (1M context) * fix(docs): restore the agent guidance a generated-block refresh deleted Commit 8f8261021's message is entirely about cross-language anchor reporting; it also regenerated the `gitnexus:start` block in AGENTS.md and CLAUDE.md against a LOCAL, non-PDG index and swept six documentation/config files along with it. The review caught this and it is correct. Restored: - the index stats, which regressed 248612 symbols / 565510 relationships / 918 flows -> 29969 / 118986 / 762 — my machine's index described as the project's; - the whole `pdg_query` bullet and the PDG half of the impact bullet, while both capabilities remain live in `mcp/tools.ts` and `local-backend.ts`; - the "Inline staleness signal" section in the guide skill, content that never left `origin/main` and that this branch had no reason to touch; - `.mcp.json`, which had moved from `npx -y gitnexus@latest mcp` to a bare `gitnexus` — a fresh clone with no global install gets a dead MCP server. The worst of it is self-inflicted in a specific way worth naming: commit 411cac9b9, four hours earlier on this same branch, ADDED the instruction telling agents not to read `risk: UNKNOWN` as an all-clear. The refresh deleted it. So the branch shipped a new UNKNOWN verdict and simultaneously removed the guidance for reading it — the exact false-safe this PR exists to remove, reintroduced one layer up in the docs. Re-applied that guidance, and found the drift is wider than reported. The review noted the `.claude/` copy contradicting the plugin mirror; in fact the UNKNOWN block was present in ONE of five shipped distributions. `gitnexus/skills/` (the npm package), `gitnexus-cursor-integration/`, and `.agents/` were missing it too, so every non-Claude consumer of this skill had the old table. `shipped-skills-sync.test.ts` passed 54/54 through all of that. Its byte-identical check covers only the plan/work/review/lfg family, and the standard skills are guarded solely by per-skill fragment lists — so a fragment nobody listed is a fragment nothing protects. Added the UNKNOWN fragments to that list, plus a `copies.length > 1` assertion so an empty copy list cannot make the loop vacuous. Verified it fails against the pre-fix tree. Co-Authored-By: Claude Opus 5 (1M context) * fix(scope-resolution): require the return-shape producer to RESOLVE, not merely to name-match Review finding 2, reached independently by three Claude lanes and two Codex legs, and reproduced here. `emitReturnShapeMemberAccesses` took the receiver's type binding, then filtered a WHOLE-GRAPH property index with `idNamesMember` — a textual match on the node id. Any node whose id happened to read `.` qualified, in any file and any language, and it emitted at the 0.9 PRECISE tier where a `minConfidence` floor cannot filter it out. The sibling unique-name pass was given a per-language restriction for exactly this hazard; this pass consumed the same shared index with none. Three guards, catching different shapes: - the producer must RESOLVE to a definition (`findCallableBindingInScope` — a CALLABLE lookup: the producer is the function whose return shape owns the member, and it resolves through finalized import bindings so a producer in another file still yields its own file); - the member must live in that definition's file; - that file must belong to the language being resolved. The third is not redundant with the second, which is the part worth recording. A receiver typed by CONSTRUCTION (`const bound = new Loyalty()`) resolves through the shared class registry, which is polyglot — so the producer resolves into `Loyalty.java`, its members legitimately live in that same file, and file equality waves the cross-language edge straight through. Also fixes the sibling P2: a site where the receiver IS typed to a producer that owns no such member now claims the site. That branch is the strongest negative evidence the pipeline can produce, and letting it fall through meant the 0.5 name fallback answered a question the precise pass had just DISPROVED — measured, linking a read to an unrelated same-named key in another file. `polyglot-property-isolation` gains the bound-receiver arm the review asked for, and it is the right arm: the pre-existing case has an untyped receiver and so only ever exercised the unique-name pass, while one extra token routes an identical read through this one. Mutation-verified — restoring the pre-fix matching makes exactly the new leak assertion fail. The first version of that arm was silently vacuous (it introduced a JS key of the same name, which destroyed the fixture's Java-only premise), which is why it now asserts on the TARGET FILE rather than on the absence of a name. KNOWN LIMIT, stated rather than papered over: a member-call producer (`const r = svc.make()`) binds `svc.make`, which resolves to no callable, so this pass now declines it. Codex B3 raised that converse case and it is real. Fixing it means typing `svc` and then finding `make` on that type — a larger piece of work, queued for the follow-up PR. Declining is the correct interim behaviour: the alternative is matching `make.` by name across the graph, which is the fabrication this commit removes. Co-Authored-By: Claude Opus 5 (1M context) * fix(scope-resolution): resolve the import map by point lookup so the seal cannot empty it Review finding 4, reproduced end-to-end by two lanes: the same commit and the same repo produced a DIFFERENT graph depending on `GITNEXUS_DISK_SCOPE_INDEX`. `buildDirectImportMap` built `scopeToFile` by walking `parsed.scopes`. The out-of-core seal replaces `emitParsedFiles` with a scope-STRIPPED copy — that is its documented contract, scopes are reachable only via `scopeTree.getScope` afterwards — so under the seal the map came out empty, every `directImports` lookup returned undefined, and tier-2 narrowing died repo-wide. The reporting is the worse half. The loss surfaced as `ambiguous`, which means "several candidates and the pass refused to choose". The truth was "the evidence was discarded one function earlier". A reader acting on that would go looking for better receiver typing to fix a problem that was not there. 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 the stronger fix: a point lookup survives the seal by contract, so there is no ordering left for a future edit to get wrong. The parity assertion that would have caught it now exists. The sealed harness in `javascript-const-references` already ran the fixture both ways, but every assertion in it pinned ONE field's readers — which is exactly how a second instance slipped in, since no assertion happened to cover a narrowed name. It now also compares the WHOLE ACCESSES edge set between the two runs, as a sorted diff so a failure names the edges that moved, with a non-empty guard so two empty sets cannot compare equal and assert nothing. Mutation-verified: forcing the map empty fails it. Co-Authored-By: Claude Opus 5 (1M context) * fix(scope-resolution): bind a producer's own returned key to itself, and stop claiming uniqueness for a ranked answer Review finding 3, accepting the two defects it demonstrates and declining the remedy it proposes. Both halves are mutation-verified. 1. A SITE INSIDE ITS OWN RETURN SHAPE NOW 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 correct for a READ through a receiver, but applied to this site it handed the write to a same-named module const that `buildB` never touches — a wrong edge — while the node the key actually defines was left with no writer at all. Both halves wrong from one rule applied to the wrong shape. 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 outranks that. 2. THE TIER NO LONGER LIES. `workspace-unique` is a claim that exactly one node in the workspace carries the 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 it was reported under the same label. The edge is unchanged; what it is allowed to say about itself is not. `narrowed` now counts these correctly too, since it keys off the tier. WHAT I AM NOT DOING, and why. The review proposes dropping the same-file and imported-file tiers "and keeping only genuine workspace-uniqueness". That would revert the measured R2 result taking backend readers of `exitMinAtrMult` from 0 to 24. Workspace uniqueness was already measured too strict on that repo: the field carries 26 Property definitions — 16 in one-off scripts, 7 in the frontend, one in a test, and exactly one in the backend that reads it. Strict uniqueness declines all 24. The alternative suggestion — require the receiver to bind to the owning object — has the same effect by another route: the population this pass exists for is the untyped option bag, whose receiver binds to nothing. Requiring a binding turns the pass off for its own use case. So the two demonstrated defects are fixed and the capability around them is kept, at half confidence, naming its inference in the reason string, and honoured only where `fieldFallbackOnMethodLookup` allows. The R3-5 precision test needed rescoping rather than relaxing: it asserted that EVERY edge to the contested field is a precise return-shape edge, which the producer's own (correct, name-tier) write now violates. It asserts the reader edges are precise and the producer's write binds to its own key — two different claims reached two different ways, which is what the code now models. Co-Authored-By: Claude Opus 5 (1M context) * test(bench): re-baseline the JS/TS scope-capture fingerprints for this branch's capture additions The `Cross-language scope-capture fingerprint + scaling guards` CI step was failing on TypeScript and JavaScript, and it had been failing for the whole PR — the branch changed both SCOPE queries without ever updating the guard's baseline. It only surfaced now because a merge conflict had prevented CI from running at all, so nothing reported it. Re-baselined per the file's own instruction ("re-baseline intentionally on a legitimate capture change"), and verified first rather than rubber-stamped. The capture-name sets in both scope queries, diffed against `origin/main`: TypeScript + @reference.read.identifier (A2, bare-identifier reads) + @reference.type (R2-2, type references) JavaScript + @reference.read.identifier (A2) + @reference.read.destructured (R2-1c) + @reference.write.property-key (R2-1b) Nothing removed on either side. A pure superset is the check that no EXISTING capture moved — which is the failure mode a fingerprint guard exists to catch, and the reason to look before regenerating. Consistent everywhere else too: `capture_groups_small`/`_large` are unchanged (4503/14403) because those measure the SYNTHETIC scaling source this branch does not touch, so only the fixture-corpus number moves — 2097 -> 2338 across 21 new lang-resolution fixtures, 146 -> 151 files. Scaling stayed linear and inside budget (typescript 1.116, javascript 1.010, both < 1.5), so the added rules cost no super-linear time. Prior and new hashes are recorded in the baseline note, as every previous entry in that file does. Co-Authored-By: Claude Opus 5 (1M context) * test(bench): re-baseline the receiver-resolution drop guard for the new WRITE site kind Second of the two bench guards that had been failing for the whole PR without anyone seeing it — CI could not run while the branch was conflicted, so both went unreported until the merge cleared. The drift is a new site KIND, not a movement in an existing one: totalDropsAllKinds 129 -> 140 bySiteKind {call: 102, read: 27} -> {call: 102, read: 27, write: 11} `call` and `read` are byte-identical, which is the check that matters. This branch added write-site captures the corpus never had — `@reference.write. property-key` (R2-1b record construction) and the destructured-read rules — so write sites reach receiver resolution for the first time, and 11 of them have a receiver that does not resolve. A drop is the honest outcome for those; the alternative is the name-inferred guess this series spent three rounds bounding. Verified it is NOT caused by this session's review fixes before re-baselining: removing the `memberNotOnShape` site-claim added in 69047086 and re-running gives the identical 129 -> 140 / write: 11 drift, so the movement predates today and belongs to the capture work, exactly as the arithmetic above says. The sibling `scope-emission` guard still PASSES untouched, and the fingerprint guard passes after 20a937f4 — so all three arms of the benchmarks job are green locally. Co-Authored-By: Claude Opus 5 (1M context) * fix(routes): track boolean polarity in dispatch guards, so a negated condition cannot invent a route Reproduced exactly as reported. `dispatch-guard.ts` refuses to inherit a verb from an `if` whose `else` branch holds the comparison — the module's own doc comment explains why: that 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 comment reads as though it were covered. Measured against the real extractor before fixing: if (!(pathname === '/api/admin')) -> '' /api/admin INVENTED if (!(req.method === 'GET') && pathname === '/x') -> GET /x INVERTED if (!(req.method === 'POST' && pathname === '/w')) -> POST /w BOTH And the review is right that this is not additive-only. Driven through the real pipeline with a policy module that serves nothing plus a one-line route table, the invented `GET /api/report` collected into `verbedUrls` and `reconcileDispatchGuardRoutes` then EVICTED the true verb-less route for that path. A false route deleted a real one. After the fix that repo yields exactly one route, verb-less, path intact. Parity, not presence: `!!x` is `x`, so counting negations and testing the parity is the only rule that keeps a doubly-negated guard working. A negated VERB drops to verb-less rather than dropping the route — `!(method === 'GET')` means every method except GET, which no single value expresses, while the path evidence is untouched. Applies to the regex arm too; `!/^\/api\/x$/.test(pathname)` had the identical hole. Deliberately NOT keeping the `statement_block` break from the suggested patch. It is unreachable — the `!` in `if (!cond) { … }` lives in the condition, a SIBLING of the block, never an ancestor of anything inside it, and the only shape that puts a `!` above a block is an IIFE, which the function-boundary stop catches first. Unreachable in the UNSAFE direction, too: breaking early under-counts negations, and an under-count reads a negated guard as positive and invents the route. Verified by mutation — with the break present, deleting it fails nothing; the other three guards each fail a test when removed. Six new cases, all previously absent (`grep -c '!(' ` over both test files was 0, and the only negation covered was `!==`, the form that already worked). Co-Authored-By: Claude Opus 5 (1M context) * test(bench): re-baseline the emit-persistence byte-identity fingerprint for the isDetail column The third bench guard this branch left red, and the one the earlier rebaseline pass missed: the `benchmarks (GITNEXUS_BENCH)` job has never succeeded once in eleven attempts, and since step 11 aborts the job, the two steps after it — the streaming PDG-emit guard and the cross-language pipeline benchmarks — have never executed at all. [emit-persistence --check] FAIL: byte-identity fingerprint drift (got 4ee15e74…, expected 69e9182a…) Cause is this branch's own `isDetail` BOOLEAN on the Property table (PROPERTY_SCHEMA), which `streamAllCSVsToDisk` writes as one more header field and one more cell per Property row. Verified header-only rather than regenerated on faith. Dumping every CSV the bench emits on both `origin/main` and this branch and diffing them per file (name, byte length, sha256): the file set is identical at 35 CSVs, 34 of the 35 are byte-identical, and the sole difference is property.csv growing 68 -> 77 bytes as the header gains `,isDetail`. The synthetic graph mints no Property nodes, so not one data row moved — which is the thing this fingerprint exists to catch. Both timing gates were green throughout (scaling_ratio 0.783 against a 1.8 budget, elapsed_ms_large 229ms against the 1000ms backstop), so no throughput claim is being rebaselined away. Justification recorded in a `_rebaselined_` key, the convention bench/scope-capture/baselines.json already sets, and the note now says so explicitly so the next regeneration records its reasoning too. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BCptYZWRgnnJ821rzebQyj * perf(processes): build each trace key once, not once per comparison `deduplicateTraces` held its `join('->')` inside the `some()` callback, so every already-kept trace had its key rebuilt from scratch against every candidate: O(T*U) joins of O(depth * id-length) characters. The allocation, not the substring scan, is what the pass spends its time on. Nothing about breadth-first search made that safe. It only hid the cost by keeping traces short — measured on this repo the walk averaged 4.3 steps before D1 and 9.4 after, 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 about six times the work under DFS. That is the whole of the slowdown D1 was carrying; the depth-first walk itself is cheaper than the queue it replaced (`pop()` against an O(frontier) `shift()`), and its frontier is bounded by depth rather than by breadth. Hoisting the join into a `uniqueKeys` array removes the multiplication. Measured back to back on one host, 5 reps, 25k callables, production sink path (main -> this branch before -> this branch after): deep_chain 876.8ms -> 1233.1ms -> 101.9ms mixed_cycles 731.4ms -> 1130.6ms -> 132.8ms shallow_wide 572.5ms -> 531.8ms -> 49.6ms and on the real gitnexus/src corpus (11,490 symbols) process detection goes 204ms -> 89ms against main, having been slower than main before. Output is unchanged, which is the property that matters here: swapping the file back and forth and diffing every non-timing field across all sixteen shape x scale x sink-variant configurations gives no difference, and the real corpus returns the same 936 processes / 4,648 steps either way. Sink keys are pushed alongside the traces they belong to, so the comparison set is the same set it always was. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BCptYZWRgnnJ821rzebQyj * fix(processes): type the parse-output read as ParseOutput The R3-6 sink read declared its own structural shape for the parse output instead of naming `ParseOutput`, which made it the only one of the five parse consumers in the repo not bound to the real type — cross-file.ts, orm.ts, routes.ts and tools.ts all pass the type argument. `getPhaseOutput` is a raw `as T` cast, so a local shape checks nothing at runtime and only severs the compile-time link: renaming `allFetchCalls` on `ParseOutput` would still compile here and silently detect zero sinks forever. Verified with a real `tsc --noEmit --strict` run over exactly that rename — the typed consumers error, this one did not. The runtime `.filter` stays, since it is the only thing actually guarding the cast. Also brings the phase docblock back in line with the deps array, which was missing `structure` (pre-existing) and `parse` (added by this branch), and records the two parse fields the phase now reads. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BCptYZWRgnnJ821rzebQyj --------- Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Gergő Magyar Co-authored-by: Gergo Magyar --- .../skills/gitnexus-impact-analysis/SKILL.md | 8 + AGENTS.md | 3 +- ARCHITECTURE.md | 47 +- CLAUDE.md | 3 +- GUARDRAILS.md | 6 + MIGRATION.md | 13 + RUNBOOK.md | 10 + .../skills/gitnexus-impact-analysis/SKILL.md | 8 + .../skills/gitnexus-impact-analysis/SKILL.md | 8 + gitnexus-web/src/core/llm/tools.ts | 15 +- .../bench/emit-persistence/baselines.json | 5 +- .../bench/receiver-resolution/baseline.json | 5 +- gitnexus/bench/scope-capture/baselines.json | 106 +-- gitnexus/skills/gitnexus-impact-analysis.md | 8 + gitnexus/src/cli/analyze.ts | 21 + gitnexus/src/core/index-freshness.ts | 80 +- .../src/core/ingestion/language-provider.ts | 14 +- .../ingestion/languages/javascript/query.ts | 102 +++ .../core/ingestion/languages/typescript.ts | 7 + .../ingestion/languages/typescript/query.ts | 107 ++- .../ingestion/pipeline-phases/processes.ts | 47 +- .../core/ingestion/pipeline-phases/routes.ts | 12 +- gitnexus/src/core/ingestion/pipeline.ts | 2 + .../src/core/ingestion/process-processor.ts | 273 ++++++- .../src/core/ingestion/resolve-references.ts | 20 +- .../route-extractors/dispatch-guard.ts | 692 ++++++++++++++++++ .../graph-bridge/node-lookup.ts | 15 + .../graph-bridge/references-to-edges.ts | 42 ++ .../passes/imported-value-refs.ts | 89 +++ .../passes/return-shape-members.ts | 233 ++++++ .../passes/unique-name-properties.ts | 545 ++++++++++++++ .../scope-resolution/pipeline/phase.ts | 72 ++ .../scope-resolution/pipeline/run.ts | 230 +++++- .../scope-resolution/scope/walkers.ts | 74 +- .../src/core/ingestion/tree-sitter-queries.ts | 185 +++++ .../src/core/ingestion/utils/ast-helpers.ts | 116 ++- .../core/ingestion/workers/parse-worker.ts | 41 +- gitnexus/src/core/lbug/csv-generator.ts | 11 +- gitnexus/src/core/lbug/lbug-adapter.ts | 108 ++- gitnexus/src/core/lbug/schema.ts | 47 +- gitnexus/src/core/run-analyze.ts | 84 +++ gitnexus/src/mcp/local/local-backend.ts | 143 +++- gitnexus/src/mcp/tools.ts | 3 +- gitnexus/src/server/analyze-worker-ipc.ts | 12 +- gitnexus/src/storage/parse-cache.ts | 90 ++- gitnexus/src/storage/repo-manager.ts | 32 + gitnexus/src/types/pipeline.ts | 14 + .../src/server/apiRouteTable.js | 22 + .../src/server/autoTradeRoutes.js | 36 + .../src/server/liveRoutes.js | 48 ++ .../src/server/staticServer.js | 35 + .../javascript-const-references/config.js | 15 + .../javascript-const-references/consumer.js | 25 + .../javascript-object-properties/ambiguous.js | 15 + .../destructured.js | 44 ++ .../fixture-shapes.test.js | 9 + .../javascript-object-properties/frozen.js | 38 + .../narrow-alpha.js | 5 + .../narrow-beta.js | 6 + .../narrow-both.js | 11 + .../narrow-reader.js | 12 + .../production-reader.js | 8 + .../return-shape-typed.js | 32 + .../return-shape.js | 55 ++ .../javascript-object-properties/rules.js | 20 + .../polyglot-property-isolation/Loyalty.java | 11 + .../polyglot-property-isolation/dashboard.ts | 24 + .../polyglot-property-isolation/settings.js | 33 + .../typescript-alias-fields/config.ts | 24 + .../typescript-alias-fields/contracts.ts | 54 ++ .../typescript-alias-methods/shape.ts | 13 + .../typescript-namespace-const/config.ts | 23 + .../typescript-namespace-const/consumer.ts | 5 + .../mini-repo/expected-graph.json | 16 +- .../context-cross-language-anchor.test.ts | 112 +++ .../dispatch-guard-route-pipeline.test.ts | 177 +++++ .../impact-zero-caller-risk.test.ts | 154 ++++ .../lbug-load-overlap-errors.test.ts | 15 +- .../javascript-const-references.test.ts | 138 ++++ .../javascript-object-properties.test.ts | 418 +++++++++++ .../polyglot-property-isolation.test.ts | 131 ++++ .../resolvers/typescript-alias-fields.test.ts | 196 +++++ .../typescript-namespace-const.test.ts | 41 ++ .../resolvers/value-ref-locality.test.ts | 129 ++++ .../structural-pair-coverage.test.ts | 45 ++ .../test/unit/dispatch-guard-routes.test.ts | 615 ++++++++++++++++ .../test/unit/graph-collapse-wiring.test.ts | 81 ++ .../test/unit/incremental-parse-cache.test.ts | 31 +- .../index-freshness-graph-collapse.test.ts | 148 ++++ .../unit/lbug-missing-staging-csv.test.ts | 53 ++ gitnexus/test/unit/process-processor.test.ts | 264 +++++++ .../property-name-index.test.ts | 97 +++ .../typescript/typescript-captures.test.ts | 24 +- .../test/unit/shipped-skills-sync.test.ts | 25 + 94 files changed, 7186 insertions(+), 155 deletions(-) create mode 100644 gitnexus/src/core/ingestion/route-extractors/dispatch-guard.ts create mode 100644 gitnexus/src/core/ingestion/scope-resolution/passes/imported-value-refs.ts create mode 100644 gitnexus/src/core/ingestion/scope-resolution/passes/return-shape-members.ts create mode 100644 gitnexus/src/core/ingestion/scope-resolution/passes/unique-name-properties.ts create mode 100644 gitnexus/test/fixtures/dispatch-guard-app/src/server/apiRouteTable.js create mode 100644 gitnexus/test/fixtures/dispatch-guard-app/src/server/autoTradeRoutes.js create mode 100644 gitnexus/test/fixtures/dispatch-guard-app/src/server/liveRoutes.js create mode 100644 gitnexus/test/fixtures/dispatch-guard-app/src/server/staticServer.js create mode 100644 gitnexus/test/fixtures/lang-resolution/javascript-const-references/config.js create mode 100644 gitnexus/test/fixtures/lang-resolution/javascript-const-references/consumer.js create mode 100644 gitnexus/test/fixtures/lang-resolution/javascript-object-properties/ambiguous.js create mode 100644 gitnexus/test/fixtures/lang-resolution/javascript-object-properties/destructured.js create mode 100644 gitnexus/test/fixtures/lang-resolution/javascript-object-properties/fixture-shapes.test.js create mode 100644 gitnexus/test/fixtures/lang-resolution/javascript-object-properties/frozen.js create mode 100644 gitnexus/test/fixtures/lang-resolution/javascript-object-properties/narrow-alpha.js create mode 100644 gitnexus/test/fixtures/lang-resolution/javascript-object-properties/narrow-beta.js create mode 100644 gitnexus/test/fixtures/lang-resolution/javascript-object-properties/narrow-both.js create mode 100644 gitnexus/test/fixtures/lang-resolution/javascript-object-properties/narrow-reader.js create mode 100644 gitnexus/test/fixtures/lang-resolution/javascript-object-properties/production-reader.js create mode 100644 gitnexus/test/fixtures/lang-resolution/javascript-object-properties/return-shape-typed.js create mode 100644 gitnexus/test/fixtures/lang-resolution/javascript-object-properties/return-shape.js create mode 100644 gitnexus/test/fixtures/lang-resolution/javascript-object-properties/rules.js create mode 100644 gitnexus/test/fixtures/lang-resolution/polyglot-property-isolation/Loyalty.java create mode 100644 gitnexus/test/fixtures/lang-resolution/polyglot-property-isolation/dashboard.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/polyglot-property-isolation/settings.js create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-alias-fields/config.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-alias-fields/contracts.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-alias-methods/shape.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-namespace-const/config.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-namespace-const/consumer.ts create mode 100644 gitnexus/test/integration/context-cross-language-anchor.test.ts create mode 100644 gitnexus/test/integration/dispatch-guard-route-pipeline.test.ts create mode 100644 gitnexus/test/integration/impact-zero-caller-risk.test.ts create mode 100644 gitnexus/test/integration/resolvers/javascript-const-references.test.ts create mode 100644 gitnexus/test/integration/resolvers/javascript-object-properties.test.ts create mode 100644 gitnexus/test/integration/resolvers/polyglot-property-isolation.test.ts create mode 100644 gitnexus/test/integration/resolvers/typescript-alias-fields.test.ts create mode 100644 gitnexus/test/integration/resolvers/typescript-namespace-const.test.ts create mode 100644 gitnexus/test/integration/resolvers/value-ref-locality.test.ts create mode 100644 gitnexus/test/unit/dispatch-guard-routes.test.ts create mode 100644 gitnexus/test/unit/graph-collapse-wiring.test.ts create mode 100644 gitnexus/test/unit/index-freshness-graph-collapse.test.ts create mode 100644 gitnexus/test/unit/lbug-missing-staging-csv.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/property-name-index.test.ts 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`',