feat: close reported graph blind spots in reference resolution, analyze and storage (#2856)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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:<file>:<key>` 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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 `<function>.<key>`, 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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
`<producer>.<member>` 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.<member>` by name across the graph, which is
the fabrication this commit removes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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_<reason>` 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCptYZWRgnnJ821rzebQyj

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
This commit is contained in:
DuduPhudu 2026-08-08 11:58:14 +03:00 committed by GitHub
parent 49f34e128a
commit 223ac7010d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
94 changed files with 7186 additions and 155 deletions

View file

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

View file

@ -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: <N>` — it returns statement-level `affectedStatements` over CDG + REACHING_DEF and inter-procedural symbols in `interproceduralByDepth`/`byDepth`; no-layer/degraded PDG results are UNKNOWN-risk notes (`--pdg` layer). CLI equivalent: `node .gitnexus/run.cjs impact "symbolName" --direction upstream --mode pdg --line <N> --repo .`.
- **MUST analyze graph changes before committing.** Use `detect_changes({scope: "all"})` (MCP) or `node .gitnexus/run.cjs detect-changes --scope all --repo .` (CLI fallback). 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.

View file

@ -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<MyPhaseOutput> = {
};
```
### 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)

View file

@ -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: <N>` — it returns statement-level `affectedStatements` over CDG + REACHING_DEF and inter-procedural symbols in `interproceduralByDepth`/`byDepth`; no-layer/degraded PDG results are UNKNOWN-risk notes (`--pdg` layer). CLI equivalent: `node .gitnexus/run.cjs impact "symbolName" --direction upstream --mode pdg --line <N> --repo .`.
- **MUST analyze graph changes before committing.** Use `detect_changes({scope: "all"})` (MCP) or `node .gitnexus/run.cjs detect-changes --scope all --repo .` (CLI fallback). 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.

View file

@ -52,6 +52,12 @@ Format: **Trigger → Instruction → Reason**. Append new Signs when the same m
- **Do:** Re-run plain `npx gitnexus analyze` — no `--embeddings` flag needed. A retained `embeddingCheckpoint` in the index metadata forces embedding generation for exactly the pending nodes regardless of flags, and clears once they succeed. `--drop-embeddings` abandons the pending nodes instead of retrying them; `--force` also discards the checkpoint (with a warning) and rebuilds without resuming it.
- **Why:** A long analyze run against a flaky HTTP embedding endpoint tolerates bounded sub-batch failures instead of aborting the whole run: it deletes the affected nodes' embedding rows (so they hold zero rows, never a partial set) and records those nodes as pending in `embeddingCheckpoint`. `stats.embeddings` stays an honest, non-zero count of everything that did succeed, so this state never trips the "Embeddings vanished" Sign above — `embedding-checkpoint-pending` is the only reliable signal.
### 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.

View file

@ -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_<idx>_<entry>`), 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

View file

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

View file

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

View file

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

View file

@ -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) || [];

View file

@ -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_<reason>` 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`."
}

View file

@ -200,10 +200,11 @@
},
"countArm": {
"callDrops": 102,
"totalDropsAllKinds": 129,
"totalDropsAllKinds": 140,
"bySiteKind": {
"call": 102,
"read": 27
"read": 27,
"write": 11
},
"callDropsByExtension": {
".java": 49,

View file

@ -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 <file>), 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 `NamespaceRecord` analyze abort (DECLARATIVES / USE AFTER STANDARD ERROR ON <file>), 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<User>` (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<Entity_n> repo;` (bare template_type) and `std::vector<Entity_n> items;` (qualified_identifier wrapping a template_type) \u2014 plus the header declaring `template <typename T> 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<User>` (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<Entity_n> repo;` (bare template_type) and `std::vector<Entity_n> items;` (qualified_identifier wrapping a template_type) plus the header declaring `template <typename T> 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<T>), 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<T>), 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<T>()`); 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.<field> = 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.<field> = 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,

View file

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

View file

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

View file

@ -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<RepoMeta, 'incrementalInProgress' | 'embeddingCheckpoint'> | null | undefined,
meta:
| Pick<RepoMeta, 'incrementalInProgress' | 'embeddingCheckpoint' | 'graphWriteCollapsed'>
| 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)

View file

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

View file

@ -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. */

View file

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

View file

@ -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)
`;
/**

View file

@ -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<ProcessesOutput> = {
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<ProcessesOutput> = {
});
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<ParseOutput>(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<ProcessesOutput> = {
});
},
{ maxProcesses: dynamicMaxProcesses, minSteps: 3 },
outwardActionSites,
);
if (isDev) {

View file

@ -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<RoutesOutput> = {
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),
});
}

View file

@ -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,
};
};

View file

@ -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<ProcessDetectionConfig> = {},
/**
* 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<ProcessDetectionResult> => {
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<string, string[][]>();
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<NodeLabel> = new Set<NodeLabel>([
'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<string> {
const sinks = new Set<string>();
if (sites.length === 0) return sinks;
const byFile = new Map<string, { id: string; start: number; end: number }[]>();
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);
}
}

View file

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

View file

@ -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-<name>`. */
export const DISPATCH_GUARD_SOURCE = 'dispatch-guard-route';
const HTTP_VERBS: ReadonlySet<string> = new Set([
'GET',
'POST',
'PUT',
'PATCH',
'DELETE',
'HEAD',
'OPTIONS',
]);
const EQUALITY_OPERATORS: ReadonlySet<string> = 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<string> = new Set([
'pathname',
'pathName',
'urlPath',
'routePath',
'reqPath',
'requestPath',
]);
/** `req.url` / `request.url` — the raw form, before a URL parse. */
const RAW_URL_RECEIVERS: ReadonlySet<string> = 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<string> = 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<string, string>;
/** 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<string, string>(); // name -> literal
const alias = new Map<string, string>(); // name -> other name
const ambiguous = new Set<string>();
const record = (map: Map<string, string>, 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<string, string>();
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<string>();
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<T extends ReconcilableRoute>(
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)),
);
}

View file

@ -278,6 +278,21 @@ export const LINKABLE_LABELS: ReadonlySet<NodeLabel> = new Set<NodeLabel>([
// 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).

View file

@ -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<string>;
/**
* 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<string>,
): { 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

View file

@ -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<string>,
): ImportedValueRefStats {
let emitted = 0;
const seen = new Set<string>();
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 };
}

View file

@ -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 `<owner>.<member>`?
*
* 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<string>,
propertyNameIndex: PropertyNameIndex,
/** Sites this pass resolves, so the name fallback leaves them alone. */
handledSink: Set<string>,
): ReturnShapeMemberStats {
let emitted = 0;
let memberNotOnShape = 0;
const seen = new Set<string>();
// 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.<member>` 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 `<producer>.<member>` 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 `<producer>.<member>` 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 };
}

View file

@ -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<ScopeId, readonly ImportEdge[]>;
}
/** 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<string, readonly PropertyCandidate[]>;
export function buildPropertyNameIndex(graph: KnowledgeGraph): PropertyNameIndex {
const byName = new Map<string, PropertyCandidate[]>();
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<string>,
): 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<string, ReadonlySet<string>> {
// 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<string, Set<string>>();
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<string>();
byFile.set(fromFile, set);
}
set.add(edge.targetFile);
}
}
return byFile;
}
/** Is this Property id qualified by `<owner>.`, 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<string> | 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<string>,
/** 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<string, ReadonlySet<string>>()
: buildDirectImportMap(indexes, finalized);
let emitted = 0;
let ambiguous = 0;
let narrowed = 0;
let crossLanguageOnly = 0;
const ambiguousNames = new Set<string>();
/** name -> the languages its definitions actually live in. */
const crossLanguageAnchors = new Map<string, Set<string>>();
const seen = new Set<string>();
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() })),
};
}

View file

@ -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<ScopeResolutionOutput> = {
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<ScopeResolutionOutput> = {
// 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<string, Set<string>>();
let ambiguousPropertyReads = 0;
const ambiguousPropertyNames = new Set<string>();
let pdgEmitManifest: PdgEmitManifest | undefined;
let pdgSinkSettled = false;
try {
@ -430,6 +462,7 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
files,
resolutionConfig,
prebuiltNodeLookup: sharedNodeLookup,
prebuiltPropertyNameIndex: sharedPropertyNameIndex,
prebuiltFunctionNodeIndex: sharedFnNodeIndex,
preExtractedParsedFiles: preExtractedByPath,
scopeIndexStorePath: parsedFileStorePath,
@ -538,6 +571,36 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
`[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<ScopeResolutionOutput> = {
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() })),
},
};
},
};

View file

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

View file

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

View file

@ -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
; <function>.<key> 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
; <function>.<key> 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

View file

@ -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<string, string> = {
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:<file>:bar` id. Left undefined by
* {@link findObjectLiteralBindingInfo}, whose ids stay exactly as they were.
* `bar` collapse onto a single `Method:<file>: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:<file>:<key>` 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 `<owner>.<member>`.
* 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 } : {}),
};
};

View file

@ -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-<decoratorName>`. 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:<file>: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 } : {}),
},
});

View file

@ -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<string, BufferedCSVWriter>();
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(','),
);

View file

@ -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<boolean> => {
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}

View file

@ -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\`,

View file

@ -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<string> | 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 (9098%) ──────────────────────────────────
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,

View file

@ -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<string, unknown> | 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<string, readonly string[]> }
>();
private async crossLanguagePropertiesFor(
repo: RepoHandle,
): Promise<ReadonlyMap<string, readonly string[]>> {
const cached = this.crossLanguagePropertyCache.get(repo.lbugPath);
if (cached !== undefined && cached.indexedAt === repo.indexedAt) return cached.byName;
const byName = new Map<string, readonly string[]>();
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: {

View file

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

View file

@ -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,
};
}

View file

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

View file

@ -328,6 +328,38 @@ export interface RepoMeta {
* Map keys are repo-relative paths.
*/
fileHashes?: Record<string, string>;
/**
* 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

View file

@ -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[] }[];
};
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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',
};
}

View file

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

View file

@ -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,
};

View file

@ -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,
};

View file

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

View file

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

View file

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

View file

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

View file

@ -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,
};
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<void>;
};
export function runDispatcher(d: Dispatcher): void {
d.dispatch('tick');
}

View file

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

View file

@ -0,0 +1,5 @@
import { Limits } from './config.js';
export function readsNamespacedConst(): number {
return Limits.NAMESPACED_MAX;
}

View file

@ -2,8 +2,8 @@
"capture": "initial capture (U8, post-U1U7)",
"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"
}

View file

@ -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<typeof import('../../src/storage/repo-manager.js')>();
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;
},
},
);

View file

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

View file

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

View file

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

View file

@ -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<string> =>
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<string> =>
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');
}
});
});

View file

@ -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<PropNode> } }).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<PropNode> } }).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<PropNode> } }).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<string, unknown>;
}

View file

@ -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<PipelineResult['propertyInference']> => {
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');
});
});
});

View file

@ -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<string, unknown>;
}
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<LabelledNode> } }).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<PropNode> } }).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<PropNode> } }).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<string, unknown>;
}

View file

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

View file

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

View file

@ -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[];
/*

View file

@ -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<void> {
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([]);
});
});
});

View file

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

View file

@ -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)', () => {

View file

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

View file

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

View file

@ -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<string, string[]> => {
// 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<string, string[]>();
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<typeof createKnowledgeGraph>,
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<typeof createKnowledgeGraph>,
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<typeof createKnowledgeGraph> => {
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<typeof createKnowledgeGraph>, 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<typeof createKnowledgeGraph>,
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');
});
});

View file

@ -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<typeof createKnowledgeGraph>,
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<typeof createKnowledgeGraph>;
buildPropertyNameIndex(counting);
expect(scans).toBe(1);
});
});

View file

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

View file

@ -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`',