Commit graph

1804 commits

Author SHA1 Message Date
ReidenXerx
6df6fb8501 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>
2026-08-06 21:40:14 +03:00
ReidenXerx
2ed6504dbd 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>
2026-08-06 21:34:40 +03:00
ReidenXerx
fb11102689 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>
2026-08-06 21:27:54 +03:00
ReidenXerx
a93d2e973f 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>
2026-08-06 21:16:52 +03:00
ReidenXerx
e4bfff7f08 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>
2026-08-06 21:14:00 +03:00
ReidenXerx
85800fc4e2 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>
2026-08-06 21:13:40 +03:00
ReidenXerx
19974b49d8 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>
2026-08-06 20:32:38 +03:00
ReidenXerx
2e29a03467 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>
2026-08-06 20:18:08 +03:00
ReidenXerx
98f9f5405f 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>
2026-08-06 20:09:05 +03:00
ReidenXerx
64150a98cf 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>
2026-08-06 19:54:46 +03:00
ReidenXerx
152a0ee201 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>
2026-08-06 17:43:26 +03:00
ReidenXerx
b8892e02dc 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>
2026-08-06 17:43:26 +03:00
ReidenXerx
989681bbd2 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>
2026-08-06 17:43:26 +03:00
ReidenXerx
35f58305b9 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>
2026-08-06 17:43:26 +03:00
ReidenXerx
1320cc6db3 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>
2026-08-06 17:43:26 +03:00
ReidenXerx
a49d33a792 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>
2026-08-06 17:43:26 +03:00
ReidenXerx
1dbdbf5f23 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>
2026-08-06 17:43:26 +03:00
ReidenXerx
6664e5c0a5 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>
2026-08-06 17:43:26 +03:00
ReidenXerx
448868f891 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>
2026-08-06 17:43:26 +03:00
ReidenXerx
6ec8dfc3cd 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>
2026-08-06 17:43:26 +03:00
ReidenXerx
629bf3a9ed 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>
2026-08-06 17:43:25 +03:00
ReidenXerx
541ab122de 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>
2026-08-06 17:43:25 +03:00
ReidenXerx
841a62ae36 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>
2026-08-06 17:43:25 +03:00
ReidenXerx
e11bb186bc 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>
2026-08-06 17:43:25 +03:00
ReidenXerx
1e451bb8a4 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>
2026-08-06 17:43:25 +03:00
ReidenXerx
5ebc060682 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>
2026-08-06 17:43:25 +03:00
ReidenXerx
3d9ed40276 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>
2026-08-06 17:43:06 +03:00
ReidenXerx
7eabdd46d6 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>
2026-08-06 17:43:06 +03:00
ReidenXerx
be75ee373f 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>
2026-08-06 17:43:05 +03:00
ReidenXerx
8ddb9c1d2a 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>
2026-08-06 17:42:32 +03:00
drdave
021ac30376
feat(cli): add a bunx lane so bun-only machines can run gitnexus (#2765)
Some checks failed
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
Skill copy sync / shipped skills drift guard (push) Has been cancelled
* feat(cli): add a bunx lane to the runner ladder

The ladder assumed a Node toolchain: global gitnexus, then pnpm dlx or
npx in some order, with npx as the last resort. On a bun-only machine
npm, npx and pnpm are all absent, so every rung fell through to npx and
both the emitted hint and the generated .gitnexus/run.cjs produced a
command the machine could not run at all.

Add bun as a fourth mode, invoked as an install-free bunx one-shot, on
two rungs:

  - npm 11+ with no pnpm to fall back on — bunx dodges the same arborist
    install crash the pnpm rung exists for (#1939);
  - npm and pnpm both absent — previously the dead end described above.

Every pre-existing outcome is preserved: pnpm still wins on npm 11+, npx
still wins on npm < 11, and pnpm still wins over bunx when npm is absent.
Regression tests pin each of those. The bun PATH probe is lazy, so a
machine with a Node toolchain pays no extra scan and the stale-index hook
budget is unchanged.

bunx takes no allow-build equivalent: bun's --trust is a bun add/install
flag that writes trustedDependencies into a project package.json, which a
one-shot has none of, so the argv stays flag-free.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016R9psS9gJ73MRyquoBoPKg

* fix(lbug): restore the prebuilt native binary when install scripts were skipped

Without this the new bunx lane resolves to a command that still fails:
bun skips lifecycle scripts for a bunx fetch, so @ladybugdb/core's
install script never copies lbugjs.node up from its per-platform
sub-package and every native command dead-ends on 'LadybugDB native
binary (lbugjs.node) is missing'.

The existing guidance cannot rescue that case. It offers pnpm
--allow-build, a global install, or adding trustedDependencies to a
project package.json — bunx has no project package.json to add to, no
per-invocation opt-in, and re-extracts the package on every run, so an
out-of-band repair is wiped before the next invocation. In-process
recovery is the only thing that can work.

Recovery is cheap because nothing is actually absent: the binary is
already on disk in @ladybugdb/core-<platform>-<arch>, and the skipped
script only copied it up. Redo that copy (prebuilt only — never a source
build, never a network fetch) before reporting failure. Best-effort by
construction: read-only node_modules, an absent sub-package or an
unsupported platform all fall through to the existing diagnostics
unchanged, which a test pins.

Also covers pnpm dlx without --allow-build and npm --ignore-scripts.

Declare trustedDependencies so a plain `bun install` in this repo
produces a working native binary too — the remedy the error message
already prescribes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016R9psS9gJ73MRyquoBoPKg

* fix(ai-context): name every install-free runner in the generated bootstrap note

The emitted gitnexus:start block told a reader with no runner yet to run
`npx gitnexus analyze`, falling back to a global npm install. Both name
binaries a bun-only machine does not have, so the generated AGENTS.md and
CLAUDE.md offered it no reachable bootstrap path.

List npx, bunx and pnpm dlx instead of resolving one. The block is
committed, so emitting the command this machine happens to resolve would
make two contributors on different package managers rewrite it at each
other on every analyze — the per-machine churn #1706 removed. Naming all
three keeps the note machine-independent and correct everywhere.

Regenerates this repo's own committed block to match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016R9psS9gJ73MRyquoBoPKg

* fix(cli): address PR #2765 review — bunx liveness, restore diagnostics, docs

Addresses all five review comments on #2765.

P1 — `hasBun()` was a PATH-existence check only, so a present-but-broken
`bunx` shim (partial uninstall, failed `bun upgrade`) was selected with no
functional validation. Because selecting `bun` also suppresses the npm-11
npx-crash warning, the result was a silent dead end: no diagnostic, and a
`bunx gitnexus@latest analyze` command that only fails at execution time.
Add `probeRuns()` — a real `bunx --version` liveness probe, gated behind the
cheap spawn-free PATH scan so machines with npm/pnpm still pay nothing. It
ignores the output on purpose (a banner or unparseable version still counts
as alive); only a spawn failure, non-zero exit, or timeout rejects. Injectable
via a new `bunRuns` dep so the mode tests stay host-independent.

P2 — the `gitnexus-cli` skill (and both shipped mirrors) still described the
pre-bunx ladder, stranding exactly this PR's audience: a bun-only machine
whose agent bootstraps from that file was told to use npx/npm/pnpm, none of
which exist there. All three copies now name `bunx` in the ladder and the
bootstrap fallback, with a `shipped-skills-sync` fragment assertion so the
gap is CI-caught (these copies are not byte-compared, only the engineering
family is).

P2 — `restorePrebuiltNativeBinary` collapsed every failure into `false`, so an
EACCES/EROFS from `copyFileSync` was indistinguishable from "no prebuilt
sub-package exists". Users on a read-only `node_modules` layer (a baked
container image mounted read-only — a common CI pattern) got the generic
lifecycle-script advice, which cannot fix a non-writable filesystem. Return a
`RestoreOutcome` instead and route `copy-failed` to its own message.

P2 — document that `trustedDependencies` only takes effect for `bun install` /
`pnpm install` run inside this repo: it does nothing for a `bunx` one-shot or
for a consumer's `bun add gitnexus`. The note sits on
`restorePrebuiltNativeBinary` so a future maintainer cannot mistake that
function for redundant and delete the thing the bunx path actually relies on.

P3 — the `binary_missing` bun advice told `bunx` one-shot users to edit a
package.json they do not have, and listed 1 of the 3 packages this package
now trusts. Both repair messages now share one `BUN_REPAIR_LINES` const with
the full package list and a `bun install -g gitnexus` alternative.

Also: shortened the bootstrap note and raised the CLAUDE.md block budget
2900 -> 2950. The note has to name every install-free runner (that is the
point of the bun lane), and main's own growth since this PR's last green CI
had already pushed the generated block over the old ceiling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015epfxkEMsmHFNVSFQqAkB4

* refactor(cli): simplify the #2765 review fixes

Cleanup pass over the previous commit — no intended behavior change except
the doctor status line noted below.

Reuse: `probeRuns()` duplicated `probeVersion()`'s entire spawn setup — same
argv, timeout, `windowsHide`, and the CVE-2024-27980 Windows-shim workaround —
in a file with two byte-identical committed copies, so the shim rule lived at
four sites. Its docstring's own objection was to the RETURN SHAPE, not to
reuse, so `probeVersion` now returns `{ ran, major, minor }` and `hasBun` reads
`.ran`. Existing callers only read `major`/`minor`, so nothing else changes.

Also dropped a pointless `const runs = () => …` thunk (`&&` already
short-circuits), and deleted a new test that was a character-for-character
duplicate of `falls back to npx when npm is null-absent and pnpm is also
absent` — its cheapest-first-gate rationale moved into that test's comment.

Correctness in the budget comment: the claim that the bun rung is free because
"pnpm is absent there, so its probe never ran" was wrong. `formatAnalyzeCommand`
spawns `pnpm --version` unconditionally when no global `gitnexus` is on PATH —
that spawn IS how pnpm presence is discovered. Real worst case is 5 subprocesses
/ ~8s, and the 8s needs Windows (`shell: true` spawns cmd.exe for an absent
pnpm); on POSIX an absent pnpm ENOENTs in ~1ms. Comment now says that. Likewise
"a machine with npm or pnpm never pays" was wrong for npm 11+ without pnpm —
that IS the rung that pays.

Altitude: `copy-failed` changed only the message text while still returning
`kind: 'binary_missing'`, so `doctor` would have printed "✗ lbugjs.node missing"
directly above a message saying the binary IS present — exactly the
contradiction #2672 removed. Added a `binary_unwritable` kind, a doctor case,
and a `nativeStatusCases` row. The binary-missing message construction moved
out of `checkLbugNative` into `unrestorableBinaryFailure`, typed
`Exclude<RestoreOutcome, 'restored'>` so a new outcome forces a decision
instead of silently inheriting the lifecycle-script advice.

Drift: the trusted-package list was hand-spelled in five places in
native-check.ts, with "matches gitnexus/package.json" asserted only in a
comment. All five now render from one `NATIVE_BUILD_PACKAGES` const (rendered
output is byte-identical), and the test reads the list out of package.json
instead of restating it, so a fourth native package fails the test rather than
silently shipping stale advice.

Finally, replaced the absolute CLAUDE.md block cap with the ratio the two prior
justifications actually appealed to (`< 5465 * 0.55`). Raising 2700 -> 2900 ->
2950 was a ratchet with no ratchet: an absolute cap can only fail on the PR
that adds the character, and the fix is always to nudge the number. Also fixed
a stale runner ladder in skills-steering.test.ts that still omitted bunx.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015epfxkEMsmHFNVSFQqAkB4

---------

Co-authored-by: drdave-flexnteos <revenaugh.david@gmail.com>
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>
2026-08-06 08:40:36 +00:00
dependabot[bot]
a55763feb3
chore(deps)(deps): bump @tailwindcss/vite in /gitnexus-web (#2845)
Bumps [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) from 4.3.2 to 4.3.3.
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.3/packages/@tailwindcss-vite)

---
updated-dependencies:
- dependency-name: "@tailwindcss/vite"
  dependency-version: 4.3.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Abhigyan Patwari <126312502+abhigyanpatwari@users.noreply.github.com>
2026-08-06 09:24:16 +01:00
dependabot[bot]
817893df97
chore(deps)(deps-dev): bump wait-on in /gitnexus-web (#2844)
Bumps [wait-on](https://github.com/jeffbski/wait-on) from 9.0.10 to 9.1.0.
- [Release notes](https://github.com/jeffbski/wait-on/releases)
- [Commits](https://github.com/jeffbski/wait-on/compare/v9.0.10...v9.1.0)

---
updated-dependencies:
- dependency-name: wait-on
  dependency-version: 9.1.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-06 08:20:57 +01:00
dependabot[bot]
3e959aefcf
chore(deps)(deps-dev): bump @playwright/test in /gitnexus-web (#2846)
Bumps [@playwright/test](https://github.com/microsoft/playwright) from 1.61.1 to 1.62.0.
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.61.1...v1.62.0)

---
updated-dependencies:
- dependency-name: "@playwright/test"
  dependency-version: 1.62.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-06 08:20:40 +01:00
dependabot[bot]
c99ed24aed
chore(deps)(deps): bump react-i18next in /gitnexus-web (#2847)
Bumps [react-i18next](https://github.com/i18next/react-i18next) from 17.0.10 to 17.0.11.
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.10...v17.0.11)

---
updated-dependencies:
- dependency-name: react-i18next
  dependency-version: 17.0.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-06 08:20:19 +01:00
dependabot[bot]
b529e65b98
chore(deps)(deps): bump tailwindcss from 4.3.2 to 4.3.3 in /gitnexus-web (#2849)
Bumps [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) from 4.3.2 to 4.3.3.
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.3/packages/tailwindcss)

---
updated-dependencies:
- dependency-name: tailwindcss
  dependency-version: 4.3.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-06 08:20:02 +01:00
dependabot[bot]
60d244b726
chore(deps)(deps-dev): bump tsx from 4.23.1 to 4.23.4 in /gitnexus (#2850)
Bumps [tsx](https://github.com/privatenumber/tsx) from 4.23.1 to 4.23.4.
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.23.1...v4.23.4)

---
updated-dependencies:
- dependency-name: tsx
  dependency-version: 4.23.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-06 08:19:46 +01:00
dependabot[bot]
29929b7488
chore(deps): bump docker/login-action from 4.4.0 to 4.6.0 (#2851)
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.4.0 to 4.6.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](af1e73f918...dbcb813823)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-06 08:19:19 +01:00
dependabot[bot]
911fdb1ae1
chore(deps): bump ossf/scorecard-action from 2.4.3 to 2.4.4 (#2852)
Bumps [ossf/scorecard-action](https://github.com/ossf/scorecard-action) from 2.4.3 to 2.4.4.
- [Release notes](https://github.com/ossf/scorecard-action/releases)
- [Changelog](https://github.com/ossf/scorecard-action/blob/main/RELEASE.md)
- [Commits](4eaacf0543...2d1146689b)

---
updated-dependencies:
- dependency-name: ossf/scorecard-action
  dependency-version: 2.4.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-06 08:19:01 +01:00
dependabot[bot]
665e7bb44a
chore(deps): bump release-drafter/release-drafter from 7.6.0 to 7.7.0 (#2853)
Bumps [release-drafter/release-drafter](https://github.com/release-drafter/release-drafter) from 7.6.0 to 7.7.0.
- [Release notes](https://github.com/release-drafter/release-drafter/releases)
- [Commits](eada3c96a6...34d80673e0)

---
updated-dependencies:
- dependency-name: release-drafter/release-drafter
  dependency-version: 7.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-06 08:18:01 +01:00
Shifra Williams
f2717c6a7c
feat(render): add one-click deploy to render support (#2804)
Some checks are pending
Scorecard / Scorecard analysis (push) Waiting to run
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Skill copy sync / shipped skills drift guard (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
2026-08-06 00:19:44 +00:00
Gergő Magyar
a033b04c46
fix(go): scope and define each type_spec, not the type_declaration (#2837) (#2843) 2026-08-06 00:55:48 +01:00
Gergő Magyar
aaa78f9590
fix(scope-resolution): fan out interface dispatch from Case 3b receivers (#2832) (#2842)
* fix(scope-resolution): fan out interface dispatch from Case 3b receivers (#2832)

Case 3b (chain-typebinding) folds a receiver through the same
`resolveCompoundReceiverClass` call and the same `[owner, ...mroFor(owner)]`
walk Case 0 uses, but emitted its edge without calling
`emitInterfaceDispatchFor`. When that fold landed on an Interface the site got
one edge to the interface's bodiless declaration and none to any
implementation — the defect #2813 reported for field receivers, in the half
#2829 did not cover.

The gap was a property of how a receiver was SPELLED rather than of what it
resolved to. `d.repo.save()` contains a dot, so it took Case 0 and fanned out;
binding the identical field to a local first — `const r = d.repo; r.save()` —
made the receiver a bare name with a dotted typeBinding, which is Case 3b, and
lost every implementation edge.

`ownerDef` is the receiver's own folded type, matching Case 0's `currentClass`
and Case 4's `ownerDef`, not the owner of the member the MRO walk settled on:
a receiver folding to a concrete class that merely inherits an interface method
must not fan out, because its runtime type is that class. The closure
self-gates on `ownerDef.type !== 'Interface'`, so the call is inert for every
concrete receiver and needs no language check. Confidence is the 0.85 literal
this case's own primary emits, so dispatch edges never outrank the edge they
hang off; Case 4's site.kind-dependent value has no counterpart here because
Case 3b's primary does not vary that way.

The new fixture pins the route as well as the fix. `const r = d.repo` reaches
Case 3b and nothing else can take the site: Case 0 needs a `.`/`(` in the
receiver name or a minted receiver chain, and `encodeReceiverChain` returns
undefined for the empty step list a bare identifier produces; Case 4 excludes
itself on the dot. Before the fix the primary assertion passed while the
fan-out came back empty — the exact "reached Case 3b and stopped at the
declaration" signature.

Resolution-side only: this changes what the resolver produces, not how it is
stored, so no SCHEMA_BUMP applies. An existing index must be re-analyzed to
show the new edges.

Follow-up from #2829.

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

* docs(scope-resolution): record Case 3b's interface-dispatch fan-out in I4 (#2832)

Invariant I4 documented the fan-out as something "Cases 0 and 4 both perform"
and spelled out Case 0.5's exclusion, while saying nothing about Case 3b —
which is what made 3b's missing fan-out an undocumented asymmetry rather than
a deliberate exclusion someone could defend or point at.

With the fan-out added, Case 0.5 is the only case that folds or walks to a
receiver type without dispatching to implementations, and its exclusion is
gated behind `resolveThisViaEnclosingClass`. Saying so explicitly keeps the
next reader from having to re-derive which cases fan out by reading the pass.

Comment-only; `detect-changes --scope staged` reports no graph change.

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

* test(scope-resolution): add the concrete-implementor control for Case 3b (#2832)

The Case 3b fan-out shipped with one negative control — a chain folding to
PlainCache, a class that implements nothing. That proves only the weak claim:
no interface anywhere near the site, no fan-out.

Add the stronger negative. SqlRepo implements Repo, so an interface IS in
scope and `save` is a name Repo declares, yet the receiver's folded type is
the concrete class and nothing may fan out. This is the control that fails if
a later change fans out from the interface a member is DECLARED in rather than
from the receiver's own folded type.

The comment says what the control cannot do, too: it cannot catch "member
owner passed instead of folded type" in TypeScript, because an implementing
class always declares the member itself, so the MRO walk never settles on the
interface's bodiless declaration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NdZtWXQJUGB1ZGLH2YNw3o

* docs(scope-resolution): correct four overclaims the review found (#2832)

A multi-lane review of this PR reproduced, against the real pipeline, that
several claims in the comments and one test name assert more than the code
delivers. No behavior changes here — only the text, and one test rename.

1. The test comment gave the WRONG REASON why the concrete-implementor control
   cannot catch "member owner passed instead of folded type". It said an
   implementing class always declares the member itself; `class C extends Base
   implements I {}` is valid TypeScript and inherits it. The real reason is that
   TypeScript's MRO chain never contains an implemented interface, so the walk
   cannot settle on an interface declaration for a concrete receiver. The
   mutation IS expressible where a concrete class inherits a `default` interface
   method (Java, Kotlin) — reproduced during review — so this is language-scoped,
   not inherent, and a follow-up fixture is tracked.

2. "fans out to every implementation of the folded interface" certified a
   completeness that does not exist. TypeScript emits heritage edges for
   `class_declaration` only (languages/typescript/captures.ts:749, stated in its
   own docstring at :732-733), so `abstract class X implements I` and `interface
   B extends A` produce no heritage edge and still dead-end on the bodiless
   declaration. Renamed to name the shape actually covered, with a KNOWN GAP
   note. The gap is in the capture layer and predates this fan-out.

3. Invariant I4 said Case 0.5 is the ONLY case that resolves a receiver type
   without fanning out. Cases 3 and 5 do too, by direct lookup rather than a fold
   or MRO walk. The sentence now says which distinction it means and states the
   reachability argument (no known language reaches Case 3 with an Interface —
   every one that could strips the namespace qualifier first, sending it to
   Case 4) instead of implying a completed audit.

4. The gate's rationale claimed the `ownerDef.type !== 'Interface'` test is right
   for every non-Interface receiver. An abstract-class receiver also dead-ends on
   a declaration-only member and does not fan out. Noted, with why widening the
   gate belongs to Cases 0 and 4 across all languages rather than to #2832.

Also completes the module-level case ladder, which still credited the fan-out to
Case 0 alone and omitted it from the Case 3b entry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NdZtWXQJUGB1ZGLH2YNw3o

* docs(scope-resolution): name Case 2 in the I4 exclusion list too (#2832)

The first pass at this correction listed Cases 3 and 5 as the other cases that
resolve a receiver type without fanning out, and was itself incomplete: Case 2
also walks an MRO and its binding admits `Interface`. It is excluded for a
different reason than 3 and 5 — its receiver IS the type name, so the site is
static dispatch and a fan-out would be wrong, whereas 3 and 5 resolve by direct
lookup rather than a fold or MRO walk. Say both rather than enumerate one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NdZtWXQJUGB1ZGLH2YNw3o

* fix(scope-resolution): close the two gaps the #2842 review left open

Both were pre-existing and reached by Cases 0 and 4 as well; the Case 3b
fan-out only widened the population of sites that hit them. Researched against
the real TypeScript compiler and the language service before choosing
semantics, plus how comparable tools draw the same lines.

1. THE FAN-OUT COULD TARGET A STATIC MEMBER

`class C implements I { static save() {} }` does not satisfy `I` — TypeScript
rejects it as TS2420, "Property 'save' is missing" — so an edge from an
`I`-typed receiver to a static member names a target dispatch can never
produce. The closure picked targets with `pickOverload`, which applies no
static filter, while the surrounding cases pick their own primary with
`pickFirstNonStaticOnly`: the speculative edges were picked with weaker rules
than the certain edge they hang off. A same-name static+instance pair also made
`pickOverload` return OVERLOAD_AMBIGUOUS, suppressing the CORRECT edge too, so
this was a false negative as well as a false positive.

Every comparable tool draws this line: tsserver partitions static from instance
results, clangd gates on `isVirtual()` (C++ forbids virtual statics), jdtls
filters abstract-or-static, and class-hierarchy analysis expands only VIRTUAL
call sites.

The guard prefers `provider.isStaticOnly` where a language declares it and
falls back to the graph node's `isStatic`. That order is load-bearing, not
stylistic: the method extractor derives `isStatic` from the OWNER type as well
as the member (`staticOwnerTypes`), and the JVM config lists
`object_declaration` — so reading the flag first would delete Kotlin `object`
implementations, which are singleton INSTANCES and genuinely reachable. Kotlin
is the only hook implementor and marks exactly the companion-promoted set;
Ruby's `singleton_class` (`def self.foo`) is correctly filtered by the
fallback.

2. TYPESCRIPT HERITAGE WAS CLASS-ONLY

`interface B extends A` and `abstract class X implements I` emitted no heritage
edge at all, so the subtype closure had nothing to descend and both shapes
dead-ended on a bodiless declaration — including the very example the closure's
own docstring cites as the reason it exists. Since Case 3b's dotted-alias
binding survives qualifier-stripping only in TS/JS, this was the language that
actually reaches the new path.

The two shapes reach their bases differently: an abstract class carries the
same `class_heritage` child a concrete one does, while an interface's bases
hang off `extends_type_clause` directly. That clause's `type` field is
`multiple: true`, so `childForFieldName('type')` would silently drop `C` from
`interface B extends A, C` — hence iterating named children.

Deliberately NOT structural matching. TypeScript is structurally typed, so a
class satisfies an interface without `implements`, but tsc's own navigation is
declaration-only and says why: "users are typically only interested in explicit
implementations... The type checker doesn't let us make the distinction between
structurally compatible implementations and explicit implementations, so we
must use the AST." scip-typescript reached the same design independently. gopls
does match structurally, but only because Go has no `implements` keyword to
prefer.

Abstract declarations are still walked THROUGH rather than targeted — the rule
everywhere is "does it have a body?", which is what `isDeclarationOnly`
already tests.

VERSIONING. The capture change is parse-time, so a v43 warm cache would serve
entries missing the new matches: SCHEMA_BUMP 43 -> 44 with its pin test moved
in the same commit, verified against origin/main at a857f4c5a (still 43).
Rebaselined only the `typescript` scope-capture fingerprint, justified by a
capture-name histogram diff over the same 145-file corpus: the only deltas are
@reference.inherits 17 -> 20 and its paired @reference.name 245 -> 248, emitted
together by `emitTsInheritanceBase`. Every other capture count is byte-identical
and javascript is unchanged, the language having no interfaces.

Tests: the fan-out now covers a static-shadowing subclass, a concrete class
below an abstract intermediate, and an implementor of an extending interface.
Resolvers 3176 passed; all five bench gates pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NdZtWXQJUGB1ZGLH2YNw3o

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 15:50:08 +01:00
Parafee41
905a1e191a
fix(mcp): ignore CR-only line ending diffs (#2839)
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-08-05 12:17:44 +01:00
Gergő Magyar
9372b17049
fix(python): resolve calls through an unaliased dotted namespace import (#2826) (#2828)
* fix(python): resolve calls through an unaliased dotted namespace import (#2826)

`import pkg.db` followed by `pkg.db.session_scope()` emitted no CALLS edge,
while all three sibling spellings resolved. In a codebase whose style guide
mandates absolute imports this is close to the only cross-module call form
used, so `impact()` reported `impactedCount: 0, risk: LOW, epistemic: exact`
for functions with dozens of real callers — a dropped caller reading as a
verified all-clear.

The resolution path was never missing; one map was keyed on the wrong half of
the import. `interpretPythonImport`'s plain arm splits `import pkg.db` into
`localName: 'pkg'` (the name Python actually binds) and
`importedName: 'pkg.db'`, and finalize carries both onto the edge as
`localName` / `targetExportedName`. `collectNamespaceTargets` keyed only on
`localName`, but the receiver text captured at the call site is the whole
dotted path — Python's query binds the attribute's `object` field with a
wildcard, so `pkg.db.session_scope()` yields the receiver `pkg.db`. Case 0
declines it (a module is not a class) and falls through, Case 1 looks up
`pkg.db` and misses, and Case 1.5 needs `resolveQualifiedReceiverMember`,
which only the C++ provider implements. The site drops silently.

Key the map on the dotted import path as well — gated on a provider opt-in,
not on the edge shape. The shape alone cannot decide it: Swift's
`import Foo.Bar` produces the identical pair (`localName: 'Foo'`,
`targetExportedName: 'Foo.Bar'`), but there the FIRST segment is the resolved
target and `Foo.Bar` names a nested type. Minting a key for it would hand
`resolveConstructionExpressionClass` an authoritative namespace — that branch
deliberately does not fall through on a miss — and break `Foo.Bar(x)`
construction that resolves correctly today. Hence
`ScopeResolver.namespaceReceiverIncludesImportPath`, which only Python sets.

The root-segment check on the added key does real work: `import pkg.db as pdb`
binds only `pdb`, so writing `pkg.db.f()` there is a NameError, and its edge
(localName `pdb`, path `pkg.db`) is correctly rejected.

Two same-package imports stay separate — `import pkg.db` + `import pkg.cache`
key `pkg.db` and `pkg.cache` independently, so neither call can land in the
other's module; the shared `pkg` bucket keeps its existing ambiguity rather
than gaining any.

Tests: five integration rows (the issue's own repro, the three sibling
spellings as controls, non-crossing two-package imports, a three-segment
receiver, and dotted construction) plus a unit pin on the keying rule that
asserts a Swift-shaped edge mints nothing. All five integration rows fail on
the pre-fix tree; the controls pass on both, which is what makes them
controls. Resolver integration suite 3024 passed / 1 skipped / 0 failed;
scope-resolution unit suite 1446 passed.

This changes what the resolver produces, not how it is stored — no schema or
version constant applies, and an existing index needs a re-analyze to show
the new edges.

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

* fix(resolution): shadow-test a dotted namespace key by its root segment (#2826)

`isNamespaceNameShadowed` walks the scope chain looking for a binding, type
binding, lexical name, or owned def named exactly `namespaceName`. Once a
namespace key can be a dotted import path, that string never matches anything:
`import pkg.db` binds `pkg`, so a local `pkg = Decoy()` shadows the import,
but the guard was asked about `pkg.db` and answered "not shadowed".

The consequence is not a missed edge but a wrong one. The caller treats a
verified namespace as authoritative and deliberately does not fall through to
the workspace-wide simple-name heuristics, so an unguarded shadowed receiver
resolves construction against the imported module instead of the local value.

Test the first dot-separated segment instead. Single-segment names are
unaffected — their root is themselves — so every pre-existing row keeps its
behaviour.

This ships with the key that first routes a dotted name into the guard rather
than after it: the previous commit is what makes the defect reachable.

The new pin fails on the pre-fix guard (verified by reverting the four
comparisons and re-running: 1 failed / 5 passed), so it discriminates rather
than merely passing.

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

* test(python): pin the callee name on the dotted-construction row (#2826)

The row asserted only that `builds` reached `pkg/db.py`. That module also
exports `session_scope`, so a regression that resolved the construction to the
wrong member of the right module would have kept the test green — it pinned
the file, not the answer.

Assert the exact edge set for the caller instead. Verified against the current
tree with a scratch probe: `builds -> Model@pkg/db.py` is the only edge the
file produces.

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

* docs(plans): include the #2826 engineering plan in the PR

`.gitignore` keeps `docs/*` local because planning output is normally
throwaway. Force-added here at the reviewer's request so the plan travels with
the work it drove: it records the evidence chain behind the fix, the two places
the plan turned out to be wrong, and the follow-ups deliberately left out of
scope.

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

* refactor(resolution): make the namespace shadow guard shared (#2826)

`isNamespaceNameShadowed` lived module-private in `compound-receiver.ts` with a
single caller. The namespace map it guards has three consumers, and the next
commit adds the guard to a second one, so it moves to `scope/walkers.ts`
alongside the other scope-chain primitives rather than being duplicated.

Behaviour is unchanged — this is a move plus documentation. Two notes were
added because both are easy to get wrong later:

- Fails closed on a missing scope or a parent cycle. For every caller,
  suppressing costs a missing edge while trusting a corrupt scope chain costs a
  wrong one, so the bias is deliberate.
- It reads `scope.bindings` DIRECTLY rather than through `lookupBindingsAt`,
  which is the opposite of the fix #2745 applied to Rust's `headBoundLocally`.
  There the question was "is this name bound at all?", so missing finalize's
  import channels lost real bindings. Here the question is "does something
  LOCAL shadow the import?", and the import's own finalized binding is exactly
  what must not count — routing this through `lookupBindingsAt` would find every
  namespace import shadowing itself and suppress the lot. Verified against a
  target module carrying a self-named def, which still resolves.

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

* fix(python): close the three remaining namespace-receiver gaps (#2826)

Three defects the first fix left behind. All three were confirmed by probe
before being touched, and a fourth suspected gap was disproved the same way.

## 1. Case 1 resolved through an import a local had shadowed

`namespaceTargets` is collected per FILE, but Case 1 in `receiver-bound-calls`
consulted it with no lexical guard at all, so

    import pkg.db
    def f(pkg):            # parameter shadows the package
        return pkg.db.session_scope()

emitted an edge to pkg/db.py. That is a WRONG edge, and it predates the dotted
key: the single-segment spelling (`import single` + `def f(single)`) failed
identically. The compound-receiver construction path has applied this guard
since #2770; Case 1 simply never did. Now both use the shared guard.

## 2 + 3. The root key named the leaf module, not the package

These read as two gaps and are one. `import a.b.c` binds ONE name — `a` — but
makes three attribute paths callable, naming three different files:

    a      → a/__init__.py
    a.b    → a/b/__init__.py
    a.b.c  → a/b/c.py

The map keyed only `a`, pointed at the LEAF. So `a.helper()` resolved into
a/b/c.py whenever that module happened to export `helper` — silently preferring
a decoy over the real definition in the package — and `a.b.mid()` resolved to
nothing at all. One wrong edge and one missing edge from a single mis-keying.

Fixing it needs per-language knowledge the shared collector cannot have: which
prefixes are reachable, and which file each names. The `__init__.py` convention
is Python's alone, and the edge shape is ambiguous across languages — Swift's
`import Foo.Bar` produces an identical `localName`/`targetExportedName` pair
that means the opposite thing. So the previous commit's boolean opt-in is
replaced by `ScopeResolver.namespaceReceiverPaths`, which returns every
spelling with the file it names; absent or declining, the shared default
(bound name → own target) is unchanged for every other language.

Prefix files are proposed, not asserted — `moduleFileExists` drops any the
workspace never parsed, so a PEP-420 namespace package contributes no key
rather than one pointing at a missing file.

## Disproved: C# was not a fourth gap

The plan listed C# `using System.Collections.Generic` +
`System.Collections.Generic.List` as the same class of bug. It is not: a probe
shows `My.Deep.Space.Helpers.Work()` already resolves through the FQN namespace
bindings in `walkers.ts`. No change made, and the claim is withdrawn rather
than carried forward as a known gap.

## Testing

Integration: the shadow block asserts the exact surviving edge set (an
absence-only assertion would also pass if the guard over-suppressed and killed
the clean rows); the prefix block asserts all three spellings land on their own
file, with `helper` defined in BOTH package and leaf so a wrong edge is visible
rather than merely possible. Unit: 16 rows on the keying contract, including
that a Swift-shaped edge mints nothing and an alias import keys neither the
path nor the root.

Resolver integration 3024 passed / 1 skipped / 0 failed; scope-resolution unit
1452 passed; tsc clean in both packages.

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

* fix(python): probe both path separators when resolving a prefix package (#2826)

Workspace file paths are not normalized to POSIX at ingestion — `import-target`
already re-normalizes at five other comparison points, and `moduleScopeByFile`
is keyed by the raw `ParsedFile.filePath`. The prefix probe built only the `/`
spelling, so on Windows it would compare `a/b/__init__.py` against an
`a\b\__init__.py` key, find nothing, and mint no prefix keys at all.

That fails quietly, which is the worst shape for it: `a.b.mid()` simply goes
back to unresolved on one platform, with no drop recorded and every test on
POSIX still green. Probe both spellings and key whichever the workspace
actually holds.

The new row is mutation-tested — reverting to the `/`-only probe turns it red
(1 failed / 10 passed), so it pins the behaviour rather than passing alongside
it.

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

* fix(python): correct three defects a multi-lane review found in this PR (#2826)

All three were introduced by this PR's own earlier commits, and none was found
by re-reading the diff — each came from a lane attacking an angle the author
had not.

## 1. The shadow guard ran BEFORE the map lookup it gates

Case 1 evaluated `isNamespaceNameShadowed` unconditionally, then consulted
`namespaceTargets`. So every call/read/write site with an explicit receiver, in
every language, paid a scope-chain walk (a Set allocation, three Map lookups and
a linear `ownedDefs` scan per level) ahead of an O(1) hash miss that was going to
decline it anyway.

The proof it was an oversight rather than a decision sits in this same PR: the
sibling guard in `compound-receiver.ts` reads the map first and only guards on a
hit. Two call sites of one shared function, opposite order. Semantics are
identical either way — a miss yields `undefined` regardless — which is exactly
why it survived several readings.

## 2. Prefix packages were anchored on the import spelling, not the resolved leaf

`pythonNamespaceReceiverPaths` built `a/__init__.py` from the dotted path joined
at the workspace root, never consulting the file the import actually resolved
to. But `resolvePythonImportTarget` resolves off-root in two of its three tiers,
so `import utils.db` can land on `libs/common/utils/db.py`. That produced a
wrong edge where a same-named `utils/` package exists at the root, and produced
NOTHING in a `src/` layout — the prefix feature was inert for the most common
Python project shape, silently.

Now the prefix directories are derived by walking back from the resolved leaf,
which is exact for root, `src/` and off-root layouts alike. It also inherits the
leaf's own separator, which subsumes the previous dual-separator probe: that
probe was dead code anyway, because `filesystem-walker.ts` normalizes `\` to `/`
before a path ever becomes a `ParsedFile.filePath`. Its test row is removed
rather than left asserting an unreachable state.

## 3. Keying the root at `__init__.py` INSTEAD of the leaf lost re-exports

`findExportedDef` accepts only a binding whose `origin === 'local'`. The
canonical Python package re-exports from its submodules — `from .b.c import
helper` in `__init__.py` — which is an IMPORT binding, so it is rejected. Keying
the prefix solely at the package therefore turned `a.helper()` from a correct
edge into no edge at all for the most common package shape.

Every fixture in this PR defined its members locally in `__init__.py`, which is
precisely the one layout where that mistake is invisible.

The prefix now keys the package FIRST and the leaf behind it. A real definition
in `__init__.py` still wins over a same-named decoy deeper in the package, and a
name merely re-exported there still resolves through the leaf. Ordering is the
contract, so the unit rows assert the exact arrays rather than membership.

## Testing

New rows: off-root layout with a decoy `utils/` at the root, and a `src/` layout.
Both mutation-tested — reverting to the spelling-anchored build turns them red.
The re-export case was verified end-to-end with a scratch fixture whose
`__init__.py` only re-exports (`uses -> helper@a/b/c.py`).

Resolver integration 3131 passed / 1 skipped / 0 failed — unchanged from before
these fixes, so they regress nothing. Scope-resolution unit 1459 passed.
tsc clean in both packages.

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

* fix(resolution): stop the namespace shadow guard AT the module scope (#2826)

CI caught a regression this PR introduced: `cjs-exports-assignment.test.ts`
lost both of its cross-file rows —

    cross-file require() member call resolves
      expected [] to deeply equal [ 'handle' ]
    an `exports` parameter does not hijack the module (UMD factory)
      expected [] to deeply equal [ 'publicApi' ]

— i.e. `const svc = require('./svc'); svc.handle()` stopped resolving in
JavaScript.

Cause: in CommonJS the namespace import IS a variable declaration. One
statement produces both the ImportEdge and a module-scope `const` binding, so
the guard, by inspecting the module scope, found the import's own name there and
read it as a shadow of itself — suppressing exactly the receivers it exists to
enable.

The guard's own contract sentence already said the right thing: "a declaration
BETWEEN the call site and its module scope". The module scope is the floor of
that walk, not a rung on it. It now returns at Module without inspecting it.

Nothing is lost on the suppression side: a genuine shadow is a parameter, a
local, or a nested declaration, and all of those live in scopes strictly inside
the module. The Python rows that pin suppression (`def f(pkg): pkg.db.f()` and
its single-segment `import single` twin) still pass, because a parameter is an
inner scope.

Worth recording for the next reader: two independent review lanes examined this
exact scenario and both REFUTED it, reasoning that `require()` yields an
ImportEdge in `scope.imports` rather than a local binding. That is true for
Python's `import x` and false for CommonJS, where one statement is both. My own
probe used a Python fixture and so could not surface it either. Agreement
between reviewers was not evidence; the test corpus was.

Verified: cjs-exports-assignment 36/36, the #2826 integration rows 7/7,
scope-resolution unit 126/126.

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

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 11:35:27 +01:00
Parafee41
a857f4c5a6
docs(taint): document per-language model files (#2809)
* docs(taint): document per-language model files

* docs(taint): link language-specific model tests

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-08-05 09:15:32 +01:00
Shifra Williams
a6a8aa788c
feat(serve): validate and port-scope the origin/proxy configuration surface (#2820) 2026-08-05 06:52:39 +01:00
dependabot[bot]
f36c3eb678
chore(deps)(deps): bump js-yaml from 5.2.2 to 5.2.3 in /gitnexus (#2831)
Some checks are pending
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 5.2.2 to 5.2.3.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/5.2.2...5.2.3)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 5.2.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 21:16:34 +00:00
Gergő Magyar
cabd5b82f9
fix(go): model Go method sets exactly so interface satisfaction is decidable (#2813) (#2829)
* test(go): pin calls through an interface-typed struct field (#2813)

A call through an interface-typed struct field never reaches the
implementation: the CALLS edge stops at the interface DECLARATION, so
`impact()` on the implementing method reports 0 callers. This commit adds
the executable statement of that defect; the fixes follow.

Two stacked defects produce it, and either alone is enough to reproduce —
which is why no existing fixture could observe it:

  D1  `buildDetectionIndexes` skips every POINTER-receiver method, so a
      struct whose methods are all `func (r *T)` has an empty method set,
      structurally satisfies nothing, and gets no IMPLEMENTS edge. Go's
      rule is that the method set of *T includes pointer-receiver methods,
      and idiomatic Go stores *T in an interface-typed field.
  D2  Case 0 (compound receiver) emits its primary edge and short-circuits
      without the interface-dispatch fan-out Case 4 performs. A struct
      field receiver `s.orderRepo` contains a dot and so always takes
      Case 0; a local or parameter receiver is a bare name and reaches
      Case 4.

Every implementor in both pre-existing structural-dispatch fixtures uses a
VALUE receiver, and the one pointer-receiver type is pinned as a negative
(`not.toContain('PointerOnlyThing -> PointerOnly')`), so the corpus could
not see D1 by construction. The new fixture is pointer-receiver
throughout, cross-package, and carries concrete-field controls in the same
structs.

Failing-first, verified against this tree: 7 of the 11 new assertions fail
and 4 pass. The 4 that pass are exactly the controls that must not
regress — the primary edge to the interface declaration, the concrete-field
call, the absence of fan-out on a concrete field, and the partial-signature
negative — so the suite discriminates rather than merely failing.

Two recorded artifacts move here because the FIXTURE was added, not
because capture output changed:

  - test/fixtures/go-captures-golden/expected-captures.json — regenerated
    additively (32 insertions, 0 deletions).
  - bench/scope-capture/baselines.json — go fingerprint, fixture_count
    102 -> 110.

Both are regenerated in this commit rather than deferred to the end of the
series: the fixture is their only cause, no later commit touches capture
emission, so they cannot re-drift and every commit stays green. The check
that this is corpus growth and not a capture regression is that go was the
only one of 15 language fingerprints to move on the same run.

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

* fix(go): count pointer-receiver methods toward structural interface satisfaction (#2813)

D1 of two stacked defects. `buildDetectionIndexes` skipped every method whose
receiver is a pointer, so a struct declaring `func (r *OrderRepo) DeleteItem(...)`
had an EMPTY method set, structurally satisfied nothing, and produced no
IMPLEMENTS edge at all.

Go's method-set rule is per-type, and there are two types involved: the method
set of `T` holds only value-receiver methods, while the method set of `*T` holds
both. #1966 implemented the `T` reading, which is exactly right for `T` — and
leaves `*T` permanently empty. GitNexus models one Struct node per type with no
separate `*T` node, so only one of the two can be represented, and the `T`
reading is the one idiomatic Go almost never uses: methods take pointer
receivers so they can mutate, and `*T` is what gets stored in an interface-typed
field.

The cost was silence rather than caution. With no IMPLEMENTS edge, a call
through an interface-typed field resolved to the interface DECLARATION and
`impact()` on the implementing method returned 0 callers — byte-identical to a
symbol that genuinely has none, which is what made the reporter's blast-radius
check unusable rather than merely incomplete.

This picks the `*T` reading: the graph now answers "which types provide this
interface's behaviour", and no longer proves `var x I = T{}` invalid. The trade
is deliberate and was checked against every consumer of IMPLEMENTS before being
made — MRO/METHOD_IMPLEMENTS derivation, community clustering, the
receiver-dispatch fan-out index, and the epistemic heritage probe. None performs
value-assignability checking.

Two negative pins encoded the #1966 decision and are REVERSED here rather than
deleted, each keeping a comment that explains why the polarity moved:
  - go.test.ts: `PointerOnlyThing -> PointerOnly` now expected to be emitted.
  - go-hooks.test.ts: the pointer-receiver-only unit case now expects the
    implementor instead of `undefined`.

`goReceiverKind` is still stamped in method-owners.ts — it is the hook a future
value/pointer-aware model would read — but is deliberately no longer a filter.
Its now-dead local predicate and type alias are removed so the file no longer
carries a helper asserting the reverted rule.

Measured on the #2813 fixture, this commit alone: the two IMPLEMENTS assertions
flip to passing (6 pass, up from 4) while the five interface-dispatch fan-out
assertions still fail — those are D2, fixed in the next commit. Keeping the two
commits separate is what makes that attribution visible.

Go unit suite: 91 passed.

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

* fix(resolution): fan out interface dispatch from a compound receiver (#2813)

D2 of two stacked defects, and the one that closes the issue. Case 0
(compound receiver) emitted its primary edge and short-circuited without the
interface-dispatch fan-out that Case 4 performs, so a call whose receiver is a
struct FIELD stopped at the interface's method DECLARATION and never reached
any implementation.

The gap was a property of receiver SYNTAX rather than of types. Case 0 is
selected by `receiverName.includes('.')`, so a field receiver (`s.orderRepo`)
always lands there, while the very same interface reached through a local or a
parameter is a bare name and falls through to Case 4 — which fans out
correctly. Field-held interfaces, i.e. dependency injection, were the half that
silently lost every implementation edge; the pre-existing fixtures exercise the
local and parameter forms only, which is why the suite was green.

The fix is the call Case 4 already makes, placed after Case 0's primary
`tryEmitEdge` and before its `handledSites.add`. It stays language-agnostic
(AGENTS.md section 42): `emitInterfaceDispatchFor` self-gates on
`ownerDef.type !== 'Interface'`, so a receiver that folds to a Struct emits
nothing extra and no language check is needed. Confidence is Case 0's own 0.85
literal, not Case 4's site.kind-dependent value — Case 0 has no read/write arm
to mirror.

The case ladder itself is untouched: invariant I4 in contract/scope-resolver.ts
makes the ordering a contract, so the fan-out is added INSIDE Case 0 rather
than by reordering or merging cases.

Also flips a second, previously unnoticed encoding of the #1966 value-only
reading that the full sweep surfaced: the exact-set assertion at
go.test.ts:361 enumerates every structural IMPLEMENTS edge, and D1 correctly
adds `PointerOnlyThing -> PointerOnly` to it. It is D1 fallout rather than D2's,
but D1 had already landed; recording it here with its reason beats amending a
commit whose separate measurability is the point.

Measured:
  - #2813 suite: 11 of 11 pass (was 7 failing after D1 alone, which fixed only
    the two IMPLEMENTS rows).
  - go.test.ts: 160 passed.
  - Full cross-language sweep, test/integration/resolvers: 3027 passed,
    1 skipped, across 52 files. The single failure in that run was the
    exact-set assertion above, fixed here; no other language regressed.

`detect_changes` rates this HIGH (6 affected flows, all EmitReceiverBoundCalls
at step 1) — inherent to editing a hub symbol in the resolution pipeline. The
sweep above is the empirical answer to that label.

An existing index must be re-analyzed to show the new edges; this changes what
the resolver produces, not how it is stored, so no SCHEMA_BUMP applies.

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

* test(go): pin the heritage edges that make impact() hedge an interface-bound count (#2813)

The epistemic half of the issue, resolved by MEASUREMENT rather than by new
code, and pinned at its mechanism.

The reporter's disqualifying complaint was that `impact()` reported
`impactedCount 0, epistemic "exact", risk LOW` for a method reachable only
through an interface-typed field — byte-identical to what it reports for a
symbol that genuinely has no callers. A zero therefore could not be used
defensively, which was the entire use case.

That verdict comes from `computeEpistemicBoundary`, which has two producers and
neither fired: the call sites were not DROPPED (they resolved, just to the
interface declaration, so the #2744 receiver-typing producer saw nothing), and
its heritage probe walks IMPLEMENTS/METHOD_IMPLEMENTS edges out of the queried
symbol — of which there were none, because the pointer-receiver exclusion (D1)
meant no such edge was ever emitted.

Restoring those edges fixes the epistemics as a side effect, so the planned
conditional change to local-backend.ts is NOT needed. Measured on this fixture
against the fixed tree:

  impact(OrderRepo.DeleteItem, upstream)
    before: impactedCount 0,  epistemic "exact"
    after:  impactedCount 3,  epistemic "lower-bound", with an interface
            boundary note; the three callers are OrderHandlers.Delete,
            PickService.StartSession and WaveService.Release — all correct.

  impact(CartRepo.Get, upstream)  [concrete receiver, no interface]
    after:  impactedCount 1,  epistemic "exact"

The second row is the one that matters for trust: the hedge discriminates
instead of firing on everything, so "exact" still means exact.

This test asserts the METHOD_IMPLEMENTS edges the probe walks. Pinning the
mechanism keeps the resolver suite from reaching into the MCP layer while still
failing loudly if the edges regress; the impact() numbers above are recorded in
the commit message and PR body rather than re-asserted here.

#2813 suite: 12 passed.

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

* fix(go): model Go method sets exactly so interface satisfaction is decidable (#2813)

Replaces the approximate structural-interface model with the rules the Go spec
actually defines, so the graph answers what the compiler answers instead of a
useful-but-wrong summary of it. Three answers were provably wrong before; all
three are now exact and covered.

Method sets (go.dev/ref/spec#Method_sets):
  MS(T)  = methods declared with receiver T
  MS(*T) = methods declared with receiver *T OR T

Promotion (#Struct_types):
  S embeds T  -> MS(S) and MS(*S) get promoted methods with receiver T;
                 MS(*S) ALSO gets those with receiver *T
  S embeds *T -> MS(S) AND MS(*S) both get receiver T or *T

Identifier identity (#Uniqueness_of_identifiers): "Two identifiers are different
if they are spelled differently, OR IF THEY APPEAR IN DIFFERENT PACKAGES AND ARE
NOT EXPORTED."

  func (b *Base) Ping()      // pointer receiver
  type ByValue struct{ Base }
  type ByPointer struct{ *Base }

  type            before          exact answer
  Base            IMPLEMENTS      only *Base implements
  ByValue         IMPLEMENTS      only *ByValue implements
  ByPointer       IMPLEMENTS      the VALUE type implements

All three were the same edge. Two of the three were wrong, and nothing in the
graph could tell them apart.

Worse, in a different direction:

  package sealed;  type Sealed interface { seal() }
  package foreign; func (t *T) seal() {}

`foreign.T` cannot implement `sealed.Sealed` in Go — `seal` is unexported, so the
two identifiers are DIFFERENT. Matching on the bare name emitted a FALSE
IMPLEMENTS edge, and the interface-dispatch fan-out then turned it into an
impossible CALLS edge. That is the entire basis of the sealed-interface idiom.

- `methodSetKey` qualifies UNEXPORTED method names with their declaring package,
  leaving exported names unqualified (which is what makes cross-package
  satisfaction work at all). Exactness, not a heuristic: the sealed case now
  emits no edge, while the legitimate same-package implementor is retained.
- `collectStructMethodEntries` builds MS(T) and MS(*T) together and applies the
  promotion table above. The embed FORM is load-bearing, so it is now captured:
  `@reference.embedded-pointer` records `*T` versus `T`, which the parser
  previously discarded (the `*` is an unnamed token).
- Detection returns `{ structDefId, receiverForm }`. `receiverForm: 'pointer'`
  means the value type does NOT implement and only `*T` does — the fact
  `var x I = T{}` turns on.
- The form rides in the edge `reason` (`-structural-implements-pointer`).
  Relationships carry no arbitrary properties, so a new field would change the
  relation DDL, move SCHEMA_FINGERPRINT and force a rebuild for a fact a string
  already expresses. Value-form implementors keep the ORIGINAL unsuffixed
  reason, so a consumer matching the old string now sees exactly the assignable
  set — which is what that string always claimed to mean.

- `emitInterfaceDispatchFor` walks the SUBTYPE CLOSURE (IMPLEMENTS + EXTENDS) and
  skips bodiless declarations, instead of stopping at depth 1. Two reproduced
  Java shapes emitted an edge to a second abstract declaration while the only
  class with a body got nothing: a sub-interface that re-declares the method, and
  an abstract base between interface and implementation. Both now reach the
  implementation and neither emits the declaration edge.
- The fan-out is bounded by `MAX_INTERFACE_DISPATCH_FANOUT` (32,
  `GITNEXUS_MAX_INTERFACE_DISPATCH_FANOUT`) and reports what it dropped, mirroring
  `MAX_PROPERTY_DISPATCH_FANOUT`. A bare cap would silently discard valid dispatch
  targets, which is the same false-safe silence this issue is about.
- Corrects a rationale comment that was factually wrong about the code 70 lines
  above it (Case 0 DOES branch on `site.kind`, at :713-716; what it lacks is a
  read/write branch in its reason/confidence computation).
- Updates both copies of the case-ladder contract, which still described the
  fan-out as Case-4-exclusive.

The embed-pointer marker is PARSE-TIME capture emission, so a warm cache would
replay the pre-marker capture set and the distinction would never appear —
silently, the v27/v30 failure mode. 43 and not 40 because origin/main allocated
40, 41 and 42 while this branch was in review, which is exactly the window this
file's history records both prior EXACT clashes landing in. Pin moved with it.
RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGE.

- Go unit: 93 passed, including new rows pinning that `populateGoOwners` stamps
  `goReceiverKind` (previously the field had no reader and could rot silently)
  and that a pointer-receiver-only type implements in POINTER form only.
- Cross-language sweep, test/integration/resolvers: 3034 passed, 1 skipped,
  52 files, zero regressions.
- scope-capture bench: PASS (15 languages). Go is the ONLY fingerprint that
  moved, which is the check that this is a Go capture change and not a
  cross-language regression; rebaselined with rationale.
- Also closes review gaps in this PR's own tests: the concrete-field control was
  vacuous with respect to the type gate (repointed at a struct that IS an
  implementor), the two-service-file row could not distinguish the two files it
  is named for (both ends now file-qualified), plus new rows for signature
  mismatch, emitted confidence, and an exact N-by-M fan-out bound.

An existing index must be re-analyzed; the schema bump forces it.

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

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 21:52:37 +01:00
Gergő Magyar
c1103f38f2
fix: type an inference-typed class field so it can act as a call receiver (#2807) (#2810)
* test(helpers): add the shared temp-repo lifecycle helper

`createTempDirPool` gives a suite one owner for its temp fixture repos —
create on demand, remove them all in one `afterAll` — instead of a hand-rolled
mkdtemp/rmSync pair per file. The PDG receiver pin added in the next commit
uses it.

Cherry-picked verbatim from ec36c6dda on the #2802 branch, where it was
extracted to collapse five hand-rolled cleanups. Identical content, so if both
branches land the add resolves as a duplicate rather than a divergence.

Refs #2807

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

* fix(typescript): type a class field from its initializer so it can be a receiver

A field whose type had to be inferred from its initializer produced no CALLS
edge at all — not a truncated chain, nothing. `this.p.inner().compute(x)` lost
`Outer.inner` too, an ordinary named-receiver call, because `typeOfMemberOnClass`
found no `typeBindings` entry for `p` and `foldReceiverChain` declines at its
first untypeable step rather than folding on a guessed owner.

The initializer was never invisible: `new Outer()` emitted its own constructor
edge exactly as the annotated twin does. What was missing was the step turning
that initializer into a TYPE BINDING, i.e. capture patterns for the two shapes
the query never covered:

  private p = new Outer();                       // public_field_definition value:
  private p; constructor() { this.p = new … }    // this.<field> = new …

Both are `@type-binding.constructor`, so `annotation` still outranks them in
`typeBindingStrength` and an annotated field keeps resolving through its
annotation. The assignment form carries a narrow `@type-binding.this-field`
marker on its `(this)` node — anchorCaptureFor takes the broadest range, so the
statement stays the anchor — which `tsBindingScopeFor` reads to hoist the
binding onto the Class scope, the only place `typeOfMemberOnClass` looks. The
marker must stay specific to that pattern: hoisting every constructor-inferred
binding would move method-local `const o = new Outer()` out of its own scope.

Kotlin and Swift needed no such pattern for the initializer form because one
grammar node (property_declaration) covers both a local and a stored property;
TypeScript splits them, and only the local half was ever covered.

Both self-diffing pins flip and gain rows: a method-assigned field, and a
deliberately mistyped `private p: Mismatch = new Outer()` that asserts the
source-strength tie-break executably. That row also pins a pre-existing
artifact — `Inner.compute` still resolves through the hoisted module-level
return-type binding — verified byte-identical on the pre-fix tree.

Fixes #2807

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

* fix(javascript): type a class field from its initializer so it can be a receiver

JavaScript has no field annotations at all, so a class field's type can only
ever come from its initializer — which made this the strictly worse half of
#2807: `class C { p = new Outer(); }` gave `this.p` no type, and
`this.p.inner()` emitted nothing.

`synthesizeConstructorFieldBindings` in captures.ts already covered the sibling
shape, `this.p = new Outer()`, which is why THAT row resolved — but it only
walks `constructor` bodies, so a field initialized at its declaration matched
no pattern anywhere.

Adds the `field_definition` + `value: (new_expression)` patterns (the JS grammar
names the field `property:`, not `name:`), anchored so the binding lands in the
class body scope where `typeOfMemberOnClass` reads it. No hook change needed:
`jsBindingScopeFor` already delegates to `tsBindingScopeFor`, so it inherits the
`@type-binding.this-field` branch too.

Measured: `InferredField.run` now emits `Outer.inner`, exact parity with both
the local-const control and the constructor-assigned row. The second chain link
(`Inner.compute`) stays absent in ALL THREE rows — that is JavaScript's separate
return-type-inference gap, not this one.

Refs #2807

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

* fix(python): infer an instance field's type from the constructor it calls

`self.outer = Outer()` in `__init__` bound nothing, so `self.outer.inner()`
had no receiver type and the fold declined the whole chain — the Python half
of #2807. An annotated field (`self.outer: Outer = ...`) or one assigned from
an annotated parameter already worked.

`synthesizeConstructorFieldTypeBindings` deliberately refused to infer "from
arbitrary unannotated RHS expressions ... not a name-only guess". A CALL is not
that: Python has no `new`, so a call to a plain (or dotted) name is the only
syntactic construction form there is, and it is the same positive evidence
every other language reads from `= new X()`. A bare name, subscript, await or
comprehension is still refused.

Adds it as a THIRD and weakest tier. The existing explicit/parameter boolean
becomes a rank, so precedence is now explicit annotation > parameter annotation
> construction, and a later same-tier assignment still wins (the last write in
`__init__` is the live one). `interpretPythonTypeBinding` maps the new marker to
`constructor-inferred` (strength 1) — checked before the parameter branch, which
would otherwise have read the absent parameter marker as `annotation` and
promoted a guess to the strongest tier.

The Class-scope hoist needed no change: `@type-binding.instance-field` already
carries it in `pythonBindingScopeFor`.

Measured: `AssignedField.run` now emits `Outer.inner`, exact parity with the
annotated-field and local-const rows.

Refs #2807

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

* fix(ruby): infer an instance variable's type from the constructor it calls

`@service = UserService.new` in `initialize` bound nothing, so `@service.inner`
had no receiver type and the fold declined the whole chain — the Ruby half of
#2807. An instance variable is the ONLY way a Ruby object gets a field, and
Ruby has no annotations, so this was the single shape that could have worked
and did not: the existing constructor-inferred patterns bind a local
(`x = Foo.new`) and a constant (`SERVICE = Foo.new`), never an ivar.

Adds the plain and `Foo::Bar` qualified ivar forms. `@type-binding.name` is
captured on the `instance_variable` node so the bound name keeps its `@` sigil
and matches the receiver text at the call site verbatim — the resolver compares
spellings, and `service` would never have matched `@service`.

`rubyBindingScopeFor` gains a Class hoist gated on a narrow
`@type-binding.ivar-field` marker riding the same node: an ivar declares a field
of the enclosing class, so the binding must live on the Class scope or no other
method can see it. Gated on the dedicated marker, never on
`@type-binding.constructor` at large, which also fires for `x = Foo.new` locals
that must stay in their own method.

Measured: `AssignedField.run` now emits BOTH chain links, exact parity with the
local-const control.

Refs #2807

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

* test(resolvers): pin inference-typed field receivers across eight languages

#2807 was filed against TypeScript, but the defect class is cross-language:
"can a field whose type is inferred act as a call receiver". This measures all
eight languages where the shape exists at all, in one table.

The metric is parity with EACH LANGUAGE'S OWN CONTROL ROW, not "both chain
links present". JavaScript, Python, Dart and PHP lose the second link
(`Inner.compute`) even for a plain local, because nothing annotates `inner()`'s
return type — a separate return-type-inference gap. Scoring against "both
links" would have accused those four of a bug they do not have; scoring against
their own control isolates the field-typing question cleanly.

Recorded state: TypeScript, JavaScript, Python and Ruby now match their
controls. Kotlin and PHP already did before #2807 and are pinned so the shared
fold cannot regress them unnoticed — the languages that got receiver typing for
free are precisely the ones nobody re-checks.

Two rows stay pinned BROKEN, at their exact current value:

  Dart  — real and narrow: the annotated control resolves, the inferred one
          does not. Its bindings are synthesized in dart/captures.ts rather
          than by a query, so the fix is its own change.
  Swift — blocked by a different defect found while measuring: with several
          classes each defining `run`, every `run`'s edges are attributed to
          the FIRST-declared one, which collects duplicates while its siblings
          — including the ANNOTATED control — collect none. Receiver typing
          cannot be measured there until that is fixed, and "fixing" it against
          this observable would be fitting to a broken measurement.

Both gap rows carry a `callerExists` probe in the same assertion object, so an
empty list can never read as "resolved fine, wrong node id", plus a whole-matrix
guard that every language keeps a resolving control — that is what makes a gap
row mean "broken" instead of "fixture never worked".

Targets are deduplicated before comparison: Swift emits one edge more than once
per call site, and edge multiplicity is a different question from whether the
receiver typed at all.

Refs #2807

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

* fix(python): a method call on the receiver is not a construction

Review finding on f4e1ead0d. `constructorCallTypeName` accepted ANY call with
an identifier or attribute callee, so `self.p = self.build()` bound `p` to the
non-type `"self.build"` — and because that shares the weakest tier with a real
construction, a later such assignment DISPLACED an earlier `self.p = Outer()`
and left the field untyped again.

Measured before the fix: `self.p = Outer()` followed by `self.p = self.rebuild()`
emitted no CALLS edge at all from a method chaining off `self.p`, and
`self.q = self.make()` bound a type name that resolves to nothing. After:
the real construction survives the reassignment, and a pure method call binds
nothing rather than something wrong.

Rejects a callee rooted at the receiver name. `models.Outer()` still binds —
only `self`-rooted callees are refused, which is exactly the method-call shape.

The matrix gains a `reassigned-from-method-call` row that fails without this
rejection; that discrimination is the only reason the row exists.

Refs #2807

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

* fix(swift): resolve a method def to its own node when the labels disagree

Two classes in one Swift file each declaring `func run` collapsed onto one
node: every call in BOTH bodies was attributed to whichever `run` registered
first, which collected duplicate edges while its twin collected none. Renaming
one method fixed it; moving it to another file fixed it; so the collision was
name-keyed and per-file, not positional.

Root cause is a LABEL split, not a name. Swift's structure phase emits a type's
methods as `Function` nodes, while the scope extractor derives `Method` from the
`@declaration.method` anchor. Every key in `resolveDefGraphId` — qualified,
parameter-types, arity, shape — is label-scoped, so such a pair misses all of
them and lands on the bottom fallback, `simpleKey(filePath, name)`, which is
deliberately label-agnostic and first-write-wins.

Fixed at both ends:

  - Swift qualifies a method def as `<Type>.<method>`, matching the qualifier
    the structure phase already encoded in the node id. `class`, `struct` and
    `extension` all parse to `class_declaration`, so one ancestor walk covers
    them; a generic `class Box<T>` and an `extension Foo` wrapping a `user_type`
    both reduce to the bare owner name.
  - The bridge retries the qualified keys under the sibling callable label.
    Gated on the name containing a dot: `A.run` names one construct whatever the
    label, while a bare `run` is exactly the top-level-vs-method aliasing the
    label was added to prevent, so the original guarantee is untouched.

This also unmasked Swift's #2807 row. `let p = Outer()` had always bound
correctly — its edges were being credited to the wrong caller, so the
inference-typed receiver looked broken when it was not. `InferredField.run` now
emits `Outer.inner`, matching its control.

Verified on the full resolver + CFG suite: 3165 passed, 0 failed, against a
3164-passing baseline.

Refs #2807

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

* fix(dart): declare inference-typed class fields so they can be receivers

`var b = Outer();` produced no `@declaration.property` capture at all — no
Property node, and nothing for the capture layer to hang a type binding on — so
`b.inner()` could not type its receiver while the annotated twin
`Outer b = Outer();` resolved fine (#2807).

The gap was in the query, one layer below where the binding is emitted: both
class-field patterns require a leading `(type_identifier)` or `(nullable_type)`,
i.e. a WRITTEN type. Dart puts the keyword there instead for an inferred field,
and spells it two ways — `inferred_type` for `var`, `final_builtin` for `final`
and `late final`. Covering only `var` would have left the more idiomatic Dart
style broken, so both are matched.

With the field declared, the capture layer types it from the constructor its
initializer calls, as `constructor-inferred` — the weakest source, and the
annotated branch returns before it, so an annotated field is untouched. Only a
direct construction is accepted (a bare identifier followed by a `selector`
carrying an `argument_part`, the same shape `findDirectCallValue` accepts for
locals); a literal, member call or await is left alone rather than guessed at.

Note this is the LOCAL/field split that made the gap invisible: `emitVarTypeBinding`
already handled `initialized_variable_definition`, but a class field is
`declaration(<keyword>, initialized_identifier_list(initialized_identifier))`.

`InferredField.run` now emits `Outer.inner`, matching its control. Dart's
`var r; C() { r = Outer(); }` shape stays pinned as a known gap: Dart writes the
field with no receiver prefix, so binding it means treating assignment to a bare
identifier as a field write, indistinguishable from a constructor-local.

Refs #2807

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

* test(resolvers): record Swift and Dart reaching parity in the matrix

Both languages' inference-typed field rows move from KNOWN GAP to resolving,
which is the self-diffing signal this file was built to produce: closing either
gap failed it with the newly resolved ids in the diff.

The header table and prose are corrected together with the rows, as the file's
own instructions require — including WHY Swift moved. Its `let p = Outer()`
binding had always been correct; a separate label-split defect attributed the
second same-named method's calls to the first, which masked this row entirely.
Recording that is the point: a future reader comparing the table against the
code needs to know the row was never a receiver-typing failure.

One row stays pinned: Dart's `var r; C() { r = Outer(); }`. Dart writes fields
without a receiver prefix, so binding it means treating assignment to a bare
identifier as a field write — indistinguishable from a constructor-local until
the field set is known. Idiomatic Dart writes `final r = Outer();`, which the
inferred-field row now covers.

Every language keeps its resolving control row, so the remaining gap still means
"broken" rather than "fixture never worked".

Refs #2807

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

* fix(dart): type a field from a constructor assigned to it

`var r; C() { r = Outer(); }` bound nothing, so `r.inner()` had no receiver
type — the last inference-typed field shape still failing after the initializer
form was fixed (#2807).

Dart is the one language here that writes a field with NO receiver prefix, so
`r = Outer()` inside a constructor is syntactically identical to assigning a
constructor-local. That ambiguity is why this was initially left pinned — but
the field set IS knowable: the class body declares `var r`, which the
initializer fix already turned into a property declaration. So a bare name binds
exactly when Dart itself resolves it to the field: the enclosing class declares
it AND the enclosing body declares no local of that name. A `this.`-prefixed
write is unambiguous and needs neither test.

The shadowing case is asserted, not assumed: with a body-local `var s = Outer()`
in scope, the field stays unbound while the local still resolves on its own.

Binds `constructor-inferred` (weakest source, so an annotation still wins), and
only for a direct construction — an identifier followed by a `selector` carrying
an `argument_part`, the same shape accepted for locals. The narrow
`@type-binding.dart-field` marker drives the Class-scope hoist in
`dartBindingScopeFor`; gating on it rather than on `@type-binding.constructor`
at large is what keeps genuine locals in their own scope.

All three shapes now match their control: bare `r = Outer()`, `this.s = …`, and
a non-constructor `setUp()` assignment.

Refs #2807

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

* fix(swift): type an optional field and read through its force-unwrap

Swift cannot declare a stored property with neither a type nor an initializer,
so its "declare now, assign in init" idiom is an OPTIONAL field read back
through a force-unwrap. That shape resolved nothing, and it was broken in two
independent places — each alone leaves it broken:

  1. `var a: Outer?` parses as `type_annotation(optional_type(user_type(…)))`,
     but the property-annotation pattern required the `user_type` to be a DIRECT
     child, so an optional field was never typed at all. The pattern added here
     captures the INNER `type_identifier`, so the binding is `Outer` without
     relying on `stripOptional` reducing an `Outer?` spelling.
  2. `self.a!` is a `postfix_expression`, which the receiver walk did not peel,
     so even a typed field could not be read through the unwrap.

For (2), `postfix_expression` is NOT added to `TRANSPARENT_RECEIVER_WRAPPERS`
outright: unlike TypeScript's `non_null_expression` — which is only ever `!` —
Swift's node also carries user-defined postfix operators, which can return
anything. Peeling those would type the receiver as the operand and mint a
confidently WRONG owner, the failure mode compound-receiver.ts calls strictly
worse than no edge. So the peel is operator-gated: transparent only when the
node's text ends in `!`, which is provably type-preserving.

Verified: force-unwrap `self.a!.inner()`, optional chain `self.b?.inner()`, and
the plain annotated field all resolve; previously only the plain one did.

The gate keeps this off every other language — `postfix_expression` is not a
node type the other grammars produce here — and the full resolver + CFG suite is
green at 3166 passed / 0 failed, against a 3165 baseline.

Refs #2807

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

* test(resolvers): close the last two matrix gaps

Dart's `assigned-field` and Swift's new `optional-assigned-field` rows now
resolve, leaving no known-gap row in the matrix: every language reaches parity
with its own control on both the initializer and the assigned shape it can
express.

The Swift row is new because the shape it covers did not exist in the fixture:
Swift cannot declare a stored property with neither type nor initializer, so its
assigned form is an optional field written in `init` and read through a
force-unwrap — a shape that needed both an optional-annotation pattern and an
operator-gated receiver peel, which is why the row's comment names both.

The header records how the two hard cases were fixed, including the Dart
shadowing rule the fix depends on: a bare `r = Outer()` binds only when the class
declares that field and the body declares no local of the same name.

Refs #2807

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

* chore(bench): rebaseline the receiver-resolution and scope-capture gates

Both gates are exact-match, so the improvements in this branch fail CI until the
baselines move and the movement is explained. Caught by running the CI gates
locally — the resolver and CFG suites are green throughout and never see these.

receiver-resolution — three shapes moved to RESOLVES, no drop-count changed:

  ruby.fieldReceiverCall     INVISIBLE-GAP -> RESOLVES  (`@ivar = Foo.new`)
  swift.decoratedFieldType   INVISIBLE-GAP -> RESOLVES  (`var a: Outer?`)
  kotlin.nonNullAssert       VISIBLE-GAP   -> RESOLVES  (`x!!` receiver)

scope-capture — swift and typescript fingerprints, both ADD captures and remove
none; the per-language `_rebaselined_inferred_field_receiver_2807` notes carry
the detail and the prior digests. The other 13 languages are unchanged, which is
the check that this is the intended emission and not a capture regression.

CORRECTION to d5d878033's message, which claimed the operator-gated
`postfix_expression` peel "keeps this off every other language — postfix_expression
is not a node type the other grammars produce here". That is wrong: Kotlin's
grammar produces it too, and `kotlin.nonNullAssert` moving to RESOLVES is the
proof. The peel is still correct there — Kotlin `!!` is a non-null assertion with
exactly the type-preserving semantics the `!` gate tests for — but it is a
BEHAVIOUR CHANGE IN KOTLIN, not Swift-only as stated. The gate is what surfaced
it; the claim should have been verified rather than asserted.

Refs #2807

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

* fix(cache): bump SCHEMA_BUMP for the six-language capture change, + review fixes

SCHEMA_BUMP 39 -> 40. THIS IS THE MERGE-BLOCKER of the review: every language
change in this PR is PARSE-TIME capture emission, and `analyze` skips tree-sitter
dispatch for byte-unchanged chunks (GUARDRAILS.md:34), so a warm cache replays
the pre-fix capture set verbatim and the new receiver edges never appear —
silently, no error. Exactly the v27/v30 failure mode this file already documents.
The PR description's claim that "no schema or version constant applies" was
wrong on both counts: a bump IS required, and a plain re-analyze does NOT
surface the captures without it. Re-check against origin/main before merging —
main was also at 39 when 40 was allocated, and this file records eight prior
collisions.

Also from the review:

- dart/simple-hooks.ts hand-rolled a 9-line parent walk byte-identical to the
  shared `walkToScope(innermost, tree, 'Class')` that TypeScript and Ruby call
  in one line in this same PR. Now uses the helper.
- utils/call-analysis.ts: the doc framed the postfix-`!` peel as Swift-only. It
  is not — Kotlin `!!` parses as the same node and is peeled too, which the
  receiver-resolution bench proved (kotlin.nonNullAssert VISIBLE-GAP ->
  RESOLVES). The comment now says so, and names the `!` gate rather than the
  language as the bound.
- test/helpers/temp-dir-pool.ts: its doc claimed four consumers; on THIS branch
  only `pdg-chained-receiver-callees` uses it (the other three convert on
  #2802). Corrected, and the byte-identical-to-#2802 intent recorded.
- inferred-field-receiver-matrix: adds the Dart shadowing assertion the header
  comment already CLAIMED to make but never did. First attempt was vacuous —
  `var s = Outer()` is a declaration, so it never produced the bare
  `assignment_expression` the guard inspects; removing the guard did not fail
  the row. Fixture corrected to `var s; s = Outer();`, and mutation-verified:
  guard present 35 pass, guard removed the row goes red.

Refs #2807

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

* test(cache): move the SCHEMA_BUMP pin to 40

The pin at incremental-parse-cache.test.ts asserts the exact value on purpose —
it exists to catch two branches claiming one number, and it has earned that
eight times. Bumping the constant to 40 without moving the pin turned it red.

Found by the Codex (gpt-5.6-sol) review leg, which flagged it as a
deterministic committed-test failure. The Claude lanes could not have caught it:
they were dispatched before the bump landed.

The comment now records the 39 -> 40 movement and its reason, matching the
existing convention in that block.

Refs #2807

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

* fix(dart): treat every binder as a field shadow, not just local declarations

Review P1, reproduced by two independent reviewers. `emitDartFieldAssignmentBindings`
binds a bare `r = Outer()` to the FIELD when the class declares `r` and the body
declares no local `r` — but the shadow set was built by walking for
`initialized_variable_definition` only. That is one binder form out of many, so a
formal PARAMETER named like a field slipped through:

    void reset(Alpha r) { r = Alpha(); }   // r is the PARAMETER

retyped the FIELD to `Alpha`, fabricating an edge AND destroying the correct
`Beta` binding the constructor had established. The mutation test shows exactly
that: the pre-fix result is not a missing edge but a WRONG one (`Other.inner#0`
instead of `Outer.inner#0`) — the failure mode compound-receiver.ts:519-537 calls
strictly worse than no edge.

The node types were chosen from real grammar output, not assumed. Two facts drove
the design: formal parameters live on the SIBLING `method_signature`, never inside
`function_body`, so no walk of the body could ever have seen them; and
`formal_parameter` carries a `name` field only when typed — untyped, `this.` and
`super.` forms do not. `collectDartBodyShadows` therefore walks the signature AND
the body, collecting formal/closure/local-function/named/optional params,
`this.`/`super.` constructor params, catch bindings, for-in variables, and both
local-declarator forms. A parameter shape whose name cannot be read contributes
nothing — declining to bind is the safe direction.

A 27-case binder sweep passes: 26 shadow shapes bind nothing, the no-binder
control still binds.

Four new matrix rows (param, closure param, catch, loop var) assert a surviving
POSITIVE target rather than an empty list — deliberately, because the pre-fix
value is a different non-empty target, so these rows cannot pass vacuously the way
an empty-assert row can. Mutation-verified: reverting captures.ts turns exactly
those four red and leaves every pre-existing row green.

SCHEMA_BUMP is already at 40 on this branch for the six-language capture change and
has not shipped, so it covers this too; re-check against origin/main before merge.

Refs #2807

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

* fix(ruby): don't bind a class-object @ivar as an instance field

Review P1. The `@ivar = Foo.new` patterns added on this branch hoist to the
enclosing Class scope without asking WHOSE `self` owns the ivar. In Ruby an ivar
written in singleton context belongs to the class object, not to instances, so

    def self.build; @pool = Alpha.new; end
    class << self; def make; @cache = Alpha.new; end; end

bound `@pool`/`@cache` as INSTANCE fields, fabricating edges from instance methods
that read an ivar which is never assigned on an instance.

Three corrections came out of fixing it:

1. The detection premise was wrong. `def self.build` is NOT a `method` node with a
   `self` receiver — it is its own node type, `singleton_method`, and
   `childForFieldName('receiver')` returns NONE on it. Matching on a receiver field
   would have detected nothing, silently. Detection is by node type:
   `singleton_method` / `singleton_class`.

2. A THIRD form exists that the review did not name: a class-body-level
   `class C; @shared = Outer.new;` is the same defect (self is the class object),
   and is likewise new on this branch — before it, `left: (instance_variable)`
   matched nothing at all.

3. Dropping only the `@type-binding.ivar-field` marker is NOT sufficient, and the
   class-body case is what proves it: with the marker gone the binding falls back
   to its innermost scope, which at class-body level ALREADY IS the Class scope, so
   it still lands in the wrong place. The whole match is therefore discarded.

The check lives in `languages/ruby/captures.ts` because `Capture` carries only
`{name, range, text}` — no AST node — so `rubyBindingScopeFor` structurally cannot
ask whose `self` owns the ivar. All Ruby logic stays under `languages/ruby/`.
`method` alone is not a sufficient "instance" signal, since a `def` inside
`class << self` is reached through a `method` node first.

Cost relative to main is zero: a class-object ivar goes back to binding nothing,
exactly as before these patterns existed.

The three new rows are structurally two-sided, not just mutation-checked: each
empty row is paired with a non-empty `*-instance-ivar` row on the SAME fixture
class, so breaking the hoist entirely turns the partner red while an unconditional
hoist turns the empty row red. Mutation-verified in both directions.

Refs #2807

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

* fix(typescript,javascript): bind `this.field = new X()` only inside a class method

Review P0, the most serious finding of the tri-review and reproduced by two
independent reviewers. The `this.<field> = new X()` patterns added on this branch
were CONTEXT-FREE: they matched anywhere in the file, and `tsBindingScopeFor`
hoisted to the nearest enclosing Class without asking whose `this` that was. Since
the binding lands on the same Class scope with the same `constructor-inferred`
source as the field-initializer pattern, and pass4CollectTypeBindings prefers the
later match on `>=`, it OVERWROTE the class's real field type. Reproduced from a
non-arrow callback, an object-literal method, a static method, and module level.

Fixed STRUCTURALLY, in the query: both patterns are now nested under
`class_body -> method_definition -> body: (statement_block) -> (expression_statement)`,
which kills the callback, object-literal and top-level triggers with no runtime
code and mirrors JavaScript's `synthesizeConstructorFieldBindings` discipline.
TypeScript still accepts ANY method, not just `constructor`, so the setter case
this branch deliberately supports keeps working.

`static` needed one emit-side guard: it is an ANONYMOUS token on `method_definition`
with no field name, and tree-sitter patterns cannot negate an anonymous token
(checked against node-types.json), so `isStaticMethodThis` drops it in captures.ts.
`simple-hooks.ts` is comment-only — the unconditional Class hoist is now documented
as safe BECAUSE the marker's producers are bounded, with a note that widening them
means re-establishing that.

Also fixes a `.ts`/`.js` disagreement the narrowing itself created: JavaScript's
synthesis matched `method_definition` ANYWHERE, so an object literal containing a
method named `constructor` still typed the enclosing class's field. Measured on
identical source — JS emitted `p -> Alien`, narrowed TS emitted nothing — and
closed with a `node.parent?.type !== 'class_body'` guard in javascript/captures.ts.
The two languages must not disagree about the same source.

Deliberately NOT matched (a missing binding, never a wrong one — JS declines these
too): an assignment in a nested block, or inside an arrow where `this` genuinely IS
the instance.

Evidence the narrowing removed nothing legitimate: `bench/scope-capture --check`
passes with the TypeScript AND JavaScript fingerprints BYTE-IDENTICAL. The five new
matrix rows use an `Alien` class that also declares `inner()`, so a regression SWAPS
the target rather than emptying the set — they cannot pass vacuously. Mutation
test: reverting the source turns exactly those rows red (`+ "Alien.inner#0"`,
`- "Outer.inner#0"`).

SCHEMA_BUMP stays at 40 — this PR's existing bump covers the capture change being
narrowed, and the buggy variant never shipped outside this branch.

Refs #2807

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

* fix(resolution): consult the sibling callable label in the position key too

Review P1. This branch added a sibling Method<->Function retry to the qualified
keys in `resolveDefGraphId`, but not to the #2699 POSITION key or its fail-closed
guard, which both stayed scoped to `def.type`. Since the premise of the whole fix
is that Swift defs are `Method` while nodes are `Function`, the position lookup
missed and the fail-closed guard could NEVER FIRE for exactly the case the retry
serves — so a function-local `func helper` inside `Host.run` was deterministically
aliased onto the class method `Host.helper`, even with differing arity.

Two earlier reviewers REFUTED this by arguing the guard runs before `lookupTagged`.
That is true and irrelevant: the guard is scoped to `def.type`, so in the
label-split case it is unreachable. Recording it because two independent lanes
agreeing on a refutation is not proof.

`siblingCallableLabel(label)` is now the single definition, consulted by all three
key families:
  - position key: retried under the sibling label, gated on `posHit === undefined`
    so an AMBIGUOUS_POSITION tombstone still falls through to the name keys rather
    than being resolved by relabelling. Deliberately NOT dot-gated — a position key
    is not a name, so the aliasing risk the dot gate exists for does not apply.
  - fail-closed guard: mirrored unconditionally (it only ever returns undefined).
  - qualified retry: dot gate untouched.

Measured before -> after on a Swift fixture: `Host.helper#1 -> sink` (the local
body's call credited to the public 1-arg method) becomes
`Host.run.helper@8:8#2 -> sink`, with the local's own node no longer edgeless.

SCOPE CORRECTION to the P1 report: only the first consequence is a bridge defect.
The second — "`run`'s call to the local resolves to the method" — is NOT reachable
from ids.ts. Both defs carry qualifiedName `Host.helper` and label `Method`, and
the binding hands the target side the class-member def, so the scope walk in
free-call-fallback picks the member. No def->node mapping can change that; it is
pinned as an explicitly labelled KNOWN GAP rather than left implied.

Verification, on shared code so the full bar: resolvers+cfg 3170 passed / 1 skipped
/ 0 failed; `bench/receiver-resolution --check` OK; `bench/scope-capture --check`
PASS (15 languages, Swift fingerprint unchanged) — i.e. the bridge change altered
no capture output. The 3170 reconciles against the 3167 pre-existing at a5bf4c2da
plus exactly 3 new tests; 3167 differs from the older 3166 baseline because
0418b0aac added the matrix's only known-gap row, which emits one extra `it`.

Mutation test: with both arms reverted, 3 of the 5 new cases go red, each arm
pinned independently — the guard case registers no position key, the position case
registers no local-name key.

Refs #2807

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

* refactor: apply cleanup-review findings across the receiver-typing change

Four parallel quality lanes (reuse, simplification, efficiency, altitude) over
`origin/main...HEAD`. Eleven fixes; both exact-match bench gates hold with every
capture fingerprint BYTE-IDENTICAL, so none of this changed what the analyser emits.

Reuse — stop re-rolling helpers that already exist:
- `walkToScope` moved out of the TypeScript provider into a language-neutral
  `utils/scope-tree-walk.ts`. Ruby and Dart had begun importing it FROM
  `languages/typescript/`, which made three unrelated providers depend on the TS
  module for a generic `Scope`/`ScopeTree` walk. Python's hand-rolled copy — the
  one this PR's new `self.x = Outer()` path routes through — is folded in, so all
  six languages now share one traversal.
- Swift stops string-parsing a type name. `swiftEnclosingTypeName` split on `<`
  and `.`; `swiftBaseTypeIdentifier` + `swiftQualifiedBaseTail` do it structurally
  and correctly skip the sibling `type_arguments` node, which the string form only
  guessed at. `findEnclosingTypeDeclaration` replaces the inlined ancestor walk.
- TypeScript uses the canonical `hasKeyword(method, 'static')`. The previous
  `child.type === 'static'` is the exact form `isStaticMember` documents as
  grammar-version-fragile: "`static` can appear as an unnamed token or as a
  keyword node depending on grammar version; check text."
- Both new test suites use `cleanupTempDirSync`, which exists because a pipeline
  test's open handle surfaces as EBUSY/EPERM on Windows and `force` does not
  suppress it. This repo shards Windows CI.

LATENT DEFECT, found by the reuse lane and fixed: `var a = X(), b = Y();` parses
as ONE `declaration` with two declarators, and the query matches it once per
declarator with the SAME node — so the first-descendant search handed every
declarator the FIRST one's initializer. `b` resolved as `X`. Now reads
`nameNode.nextNamedSibling`, which is both correct and free. Pinned by a
`multi-declarator-inferred-field` row ordered so the declarator under test is the
second; reverting the fix turns exactly that row red with the wrong edge.

Efficiency — measured, not asserted:
- Dart's shadow set was built eagerly for EVERY method body and discarded 87-100%
  of the time (a `this.`-prefixed write never reads it). Now lazy and memoised per
  body, gated on `fields.has()`. Semantics are unchanged: the set is body-wide, so
  deferring construction cannot change its contents.
  Worth recording WHY CI could never have caught this: `bench/scope-capture` gates
  the SCALING RATIO, and the work is linear — ratio stays 1.0 against a 1.5 budget
  while a constant-factor regression passes straight through.
- `isTransparentReceiverWrapper` crossed the `node.type` native getter twice on the
  common path. One hoisted read, and — since absent and ungated are distinguishable —
  one `get` replaces `has`+`get`.

Simplification:
- One `Map<string, string | null>` replaces the parallel Set + Map that both
  expressed "this wrapper is transparent", with `null` meaning unconditional.
- `ids.ts` computed `siblingCallableLabel` twice under two names. The three retry
  blocks are deliberately NOT collapsed — they use different key builders and
  materially different gates.
- Python's `interpret.ts` nesting was only a consequence of arm ORDER; swapping the
  arms is unconditionally equivalent (the two differ only when both markers are
  present, and both orders then yield `constructor-inferred`).
- One `isDirectConstruction` predicate replaces the construction-shape test that
  had been written four times in dart/captures.ts.

Deliberately NOT done, each needing a fingerprint rebaseline or new node ids:
unifying the six `@type-binding.*-field` markers into one canonical capture (it
would change Python's anchor semantics, which must be verified not assumed); a
Swift `labelOverride` mirroring Kotlin's four-line fix, which is the real cure for
the Method/Function split the bridge currently compensates for; generalising the
Swift optional-annotation pattern to `(type_annotation (_))` so the existing
strippers handle every wrapper; and merging the TS query with the JS walker, which
also carries a JSDoc branch no query can express.

Refs #2807

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

* test(swift): regenerate the Swift captures golden for the optional-annotation pattern

CI caught what my local runs did not: `swift-captures-golden.test.ts` pins
`emitSwiftScopeCaptures` output across every `swift-*` fixture, and this branch
changes that output. It is a THIRD capture gate, separate from the two
exact-match benches already rebaselined here — `bench/scope-capture` hashes a
different corpus, so its Swift fingerprint moving did not imply this one, and
passing it was not evidence this was clean.

The drift is digest-only: 37 changed lines, 37 in each direction, no capture
entry added or removed. That is the expected shape for
`(type_annotation (optional_type (user_type …)))` making optional properties emit
an annotation binding they previously did not, plus the `@declaration.qualified_name`
now carried on Swift method declarations.

Regenerated with the mechanism the test itself prescribes (`UPDATE_GOLDEN=1`),
not by relaxing the assertion. Verified after: all Swift unit + resolver suites
green (4 files, 124 tests), and `bench/receiver-resolution --check` still exactly
matches its baseline.

Refs #2807

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

* fix: three more wrong-owner defects, found by a second review round

A second tri-review of this PR found THREE new P1 wrong-owner defects — every one
of them in code the FIRST round had already fixed. All three are the same root
shape: an incomplete ENUMERATION of binder or scope forms. That class has now
bitten this branch four times (formal parameters, then these), so two of the
three fixes below deliberately attack the class rather than the instance.

1. DART 3 PATTERN BINDERS (P1, reproduced by two independent lanes).
   `addDartBinderName` enumerated five binder node types, and every Dart 3
   pattern form parses into node types in NONE of them — so a pattern-bound local
   did not count as a shadow and its write retyped the CLASS FIELD:
       class Host {
         var session = Cache();
         void load() { final (session, count) = (Session(), 2); }
         void use() { session.ping(); }   // resolved Session.ping, not Cache.ping
       }
   The grammar hides every rule that would carry a binder (`_pattern_field`,
   `_list_pattern_element`, `_guarded_pattern`, …), inlining children onto the
   enclosing visible pattern node, so binders land as direct `identifier` children
   of just two leaf types. Covers all 10 pattern types that can hold one; the
   eight container types are defence, since the grammar demonstrably inlines
   identifiers onto containers already.
   THE COMPOUNDING PART: a grammar-derived coverage guard reads `nodeTypeInfo` and
   fails if the grammar declares a `*pattern*` type the fixtures do not exercise.
   A grammar bump adding an 11th type now turns the suite red instead of silently
   reopening this bug a third time.

2. RUBY BLOCK-RECEIVER `self` REBINDING (P1 here, both Claude lanes + Codex, which
   rated it P2 — the engines agreed the defect is real and disagreed on severity).
   `isRubyInstanceIvarWrite` enumerated `singleton_method`/`singleton_class` as the
   ways `self` gets rebound. A `def` inside a BLOCK attaches to the block's
   receiver, so `Struct.new(:x) do def warm; @a = Beta.new; end end`,
   `Class.new do … end`, `class_eval`, and `other.instance_eval { @a = … }` all
   published onto the nearest LEXICAL class.
   Deliberately NOT fixed by listing rebinding call names: that set is OPEN —
   `def helper(&blk) = Foo.class_eval(&blk)` rebinds a block it merely receives,
   and nothing in the block's own syntax reveals it. An allow-list of "safe"
   iterators would be the same defect one level down. The rule is structural:
   crossing ANY block boundary makes ownership unprovable, so discard. Complete by
   construction rather than by enumeration.
   ACCEPTED COST, asserted not hidden: `[1].each { @shared = X.new }` in an
   instance method really is the instance's `self`, and this drops it — that block
   is syntactically identical to the `instance_eval` one. It has its own row
   (`plain-block-self-ivar`) so the loss is visible rather than discovered later.

3. STATIC FIELD INITIALIZERS (P1, found by Codex/gpt-5.6-sol, corroborated).
   A `static` field initializer was captured as an ordinary instance binding, and
   since both land on one Class scope at the same `constructor-inferred` strength,
   the later wins the `>=` tie-break — so a static field retyped the instance
   field of the same name (`this.p.hit()` -> `Wrong.hit`). Unguarded in BOTH
   `javascript/query.ts` and `typescript/query.ts`; the existing
   `isStaticMethodThis` only ever covered the `this.x =` assignment form.
   Two things surfaced while fixing it: the TS `annotation` pattern collides
   identically and is PRE-EXISTING, not introduced here; and JS `static
   constructor(){}` had no guard where TS did — the .ts/.js divergence this PR's
   own comment claimed could not happen.
   Dart has no same-name twin (the language forbids it), but a static method's
   receiver-less write named a library-level variable and DISPLACED the
   constructor's binding. Fixed narrowly, with a counterweight row
   (`static-field-declaration-still-types-its-receiver`) that goes red if anyone
   widens the guard into "drop every static binding" — reading a static by bare
   name from an instance method is ordinary Dart and must keep working.
   ACCEPTED COST: `typeBindings` has one map per Class scope with no static/
   instance split, so a static field is dropped rather than recorded separately,
   losing typing on a TS/JS `Host.p.hit()` static receiver chain. Missed edge over
   wrong edge, per compound-receiver.ts:519-537.

Every new row asserts a SURVIVING POSITIVE target, never an empty set: the pre-fix
value in each case is a DIFFERENT non-empty target, so none can pass vacuously —
the trap this branch already fell into once. Mutation-verified per fix: reverting
each turns exactly its own rows red (17 Dart, 6 Ruby blocks, 5 static) with every
pre-existing row green.

Matrix 49 -> 80 tests. Siblings 510 passed. tsc clean. scope-capture PASS (15
languages, all ratios within gate).

Refs #2807

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

* fix(dart): mask a shadowed field on the READ side, not just the write side

The review critic refused to pass this PR while this was open, and it was right
to: this is the same wrong-owner shape as the three defects fixed in the previous
commit, except this one is introduced BY this PR rather than merely missed by it.

THE DEFECT. Typing an unannotated field from a constructor assignment
(`var conn; Host() { conn = Alpha(); }` binds `conn` on the CLASS scope) is this
PR's whole point. `emitDartFieldAssignmentBindings` correctly declines to WRITE
that binding when a member body rebinds the name — but the shadow set gated
writes ONLY. `collectDartBodyShadows` had exactly one call site, inside the
bare-name write branch. Nothing consulted it on the read side, so a bare-name
READ of a shadowing binder the resolver cannot type walked straight past the
local and hit the field binding this feature mints:

    class Host {
      var conn;
      Host() { conn = Alpha(); }
      void probe(List<Beta> xs) {
        for (final conn in xs) { conn.inner(); }   // conn is a Beta element
      }
    }
    // resolved Alpha.inner, not Beta.inner

Reproduced in SEVEN binder shapes, not the one the review reported: for-in
(`final` and `var`), untyped formal parameter, plain local `var`, catch binding,
closure parameter, and record pattern. Delete the constructor and the same read
emits NOTHING — which is what proves this PR introduced it. "No edge" became
"wrong edge", the one failure mode compound-receiver.ts:519-537 exists to prevent.

THE FIX uses `Scope.ownsReceivers` (#2701), the primitive that already exists for
exactly this, rather than inventing a mechanism. `scope/walkers.ts` consults
`typeBindings` FIRST at every scope and only then honours the mask, so a shadow
the resolver CAN type still wins — an annotated `void probe(Beta conn)` keeps
`Beta`, because `synthesizeDartSignatureBindings` anchors parameter bindings on
the same body node and they land on the same Function scope. The mask fires only
where the alternative was a fabricated type.

Plumbing follows TypeScript's `@receiver-owner.this` precedent: the marker rides
the same synthesized match as `@scope.function` and sits outside the `@scope.`
namespace so `anchorCaptureFor` cannot mistake it for the anchor. Dart differs
only in that its function scopes are synthesized in captures.ts rather than
declared in the .scm, so the names travel as capture TEXT — a `CaptureMatch`
carries no AST node, so the reader cannot re-derive them.

SCOPE, and the costs taken knowingly rather than hidden. The mask is
`shadows ∩ fields` and nothing wider. Masking every locally bound name would
also fix a library-level `var logger = Logger();` shadowed by a loop variable,
but it changes resolution for code this PR never touched. Three consequences are
documented on `dartShadowedFieldsCapture`, not buried: the wider case is left
open; an ANNOTATED field shadowed by a binder is masked too (correct Dart, but it
touches resolution predating #2807); and `mixin` bodies are reached, since the
grammar gives them a `class_body`.

PERFORMANCE, measured rather than asserted. The mask is emitted eagerly in Pass A,
where `collectDartBodyShadows` used to be lazy — the replaced comment recorded
87-100% of eagerly built sets being discarded, ~15% of Dart emission. Actual cost
on the scope-capture large corpus, median of 3: 405.6ms with the mask vs 390.0ms
without, ≈ +4%. Fingerprint and capture_groups are byte-identical across both
arms, so no corpus fixture emits a mask at all — that 4% is the cost of the CHECK
alone. Not visible to `bench/scope-capture`, which gates the scaling RATIO and is
blind to a linear constant factor; stated here because the gate cannot state it.
(3 samples per arm, blocked not interleaved — an estimate, not a rigorous number.)
A per-file memo keyed by node span makes both passes share one walk per body, so
the write side no longer pays a second one.

SCHEMA_BUMP 40 -> 41 with its exact-value pin, since capture emission changed.
Re-check against origin/main immediately before merge — main was 39 at commit time.

Mutation-verified both directions, which is the part that matters:
  - unwire `scopeOwnsReceivers`, rebuild -> exactly 2 rows red
    (`loop-var-read-does-not-see-the-field`, `pattern-read-does-not-see-the-field`),
    83/85 green.
  - over-widen the mask (drop the `shadows.has` test) -> 28 Dart rows red,
    including `unshadowed-read-in-a-shadowing-class-still-resolves`.
The three control rows stay green under the first mutation BY DESIGN — they guard
overreach, not the defect; the second mutation is what proves they are live. Pre/post
on the trigger row: `{Class:Alien, Alien.inner#0, Outer.inner#0}` -> `{Class:Alien,
Alien.inner#0}`, so no row can pass vacuously.

Matrix 80 -> 85 tests. Sweep 3220 passed (was 3215; exactly +5). tsc clean. All four
capture gates green: receiver-resolution OK, scope-capture PASS (15 languages, no
fingerprint moved, nothing rebaselined), callable-value-flow PASS, swift golden 9.

Refs #2807

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

* fix(ts,js): let each language name its own class-field node type

CI caught a defect the whole review round missed. `grammar-literal-validation`:

    1 dead grammar literal(s) found:
      - node-type "field_definition" — languages/typescript/captures.ts:0
        — not valid in [typescript]

`isStaticClassFieldBinding` held BOTH spellings in one set —
`public_field_definition` (TypeScript) and `field_definition` (JavaScript) — so
that one predicate could serve both languages. But the predicate lives in
`typescript/captures.ts`, and the gate checks every literal against the grammar
of the FILE it appears in. `field_definition` is not a TypeScript node type.

The literal was NOT dead code: `javascript/captures.ts:42` imports the predicate
and calls it against real JS nodes, so the guard worked. The gate is still right
to fail it, and for exactly the reason this predicate's own docblock gives for
preferring `hasKeyword` over a node-type test — "a node-type test silently stops
firing on a grammar bump and every static field starts retyping its instance
twin again". A literal already dead in its own file is that failure shipped
pre-broken: nothing in the TypeScript file would ever have told us.

Each language now names its own node type and passes it in
(`TS_CLASS_FIELD_DEFINITION_TYPES` / `JS_CLASS_FIELD_DEFINITION_TYPES`), so every
literal is checked against the grammar it belongs to. The `hasKeyword` logic and
the static/instance reasoning stay shared and unchanged — only the node-type set
moves to the caller.

WHY THE LOCAL SWEEP DID NOT CATCH IT: I ran `test/integration/resolvers` and
`test/integration/cfg`. The gate is `test/integration/grammar-literal-validation.
test.ts`, in the parent directory. Scoping a sweep to the subdirectories a change
touches is precisely how a cross-cutting gate gets skipped.

grammar-literal-validation 4 passed. tsc clean. Full `test/integration` +
`test/unit/scope-resolution`: 6305 passed, 14 failed — all 14 in e2e/environment
suites (fts-extension-e2e 9, analyze-heap-oom-e2e, cli-e2e,
analyze-wal-checkpoint-failure, plus interproc-taint and parse-impl-env-reads,
which BOTH pass in isolation and fail only under 28-worker load). CI runs the
same files green.

Refs #2807

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

* test(dart,ts): pin all seven read-side binder shapes; correct a wrong accepted-cost claim

Two review findings, one of which turned out to be a documentation defect rather
than the design defect it was filed as.

S8 — THE READ-SIDE FIX PINNED 2 OF THE 7 SHAPES IT REPORTED REPRODUCING.
`ab2f48c17` reported the wrong-edge defect reproducing in seven binder shapes and
landed rows for two. The stated mitigation was that all seven route through one
`collectDartBodyShadows` enumeration whose completeness the grammar-derived
coverage guard protects. That mitigation is NARROWER THAN CLAIMED: the guard
filters `nodeTypeInfo` on `type.includes('pattern')`, so it covers the pattern
family and NOT catch bindings, closure parameters, plain locals, or formal
parameters. Narrowing `addDartBinderName`'s catch arm would have turned no row red.

All seven were re-measured by unwiring `dartScopeOwnsReceivers` and rebuilding.
Every one gained the wrong edge `Outer.inner#0` — none had to be dropped as
non-reproducing. Five new rows: formal parameter, plain local `var`, catch
binding, closure parameter, for-in `var`.

Non-vacuity established structurally, not by assertion: the Dart AST was dumped
first to confirm each fixture produces the node `addDartBinderName` actually
inspects (`formal_parameter`, `initialized_variable_definition`,
`catch_parameters`, `for_loop_parts`). The catch row uses a bare `catch (zf)`
rather than `on Err catch` deliberately — an `on` clause names a type, which
would make the row measure type resolution instead of the mask.

S7 — THE ACCEPTED-COST COMMENT WAS WRONG, AND THAT IS THE FINDING.
It claimed dropping a static field's binding trades a wrong edge for a missed one.
Measured on a same-name twin, that is false:

    read                     with the drop      without it
    this.p  (instance twin)  Outer  correct     Alien  wrong
    Host.p  (static twin)    Outer  WRONG       Alien  correct
    Host.q  (static, no twin) none — missed     Alien  correct

The wrong edge did not disappear. It MOVED to the static read, which now picks up
the instance twin's type. Only the no-twin case is a genuine missed edge. The
trade is still right — `this.p` is far more common than `Host.p` — but it was
documented as safer than it is, and a reader deciding whether to revisit it was
being given the wrong picture.

NAMESPACING WAS EVALUATED AND DELIBERATELY NOT DONE. `Host.p.hit()` resolves
through `foldReceiverChain` in shared `compound-receiver.ts`, which explicitly
discards whether a chain's base was a class reference or a value (:519-527). The
class-constant bit exists only on the text-cascade path (`currentIsClassConstant`)
and is consumed solely by `isConstructionSelectorHop`; TS/JS take the fold, not
the cascade. `Scope.typeBindings` is `ReadonlyMap<string, TypeRef>` with no static
field. `ownsReceivers` cannot help — it is a suppressor that can only REMOVE a
binding, never route to a second one. A real fix needs `FoldState` to carry the
bit plus a key convention in shared code (an AGENTS.md:42 hook if not
language-neutral), it crosses the worker boundary so it needs a SCHEMA_BUMP, and
`compound-receiver.ts:826` iterates every binding for `fieldFallback` so a
namespaced key would leak straight back in as an ordinary field. Not a cheap or
safe change — and it would have been made with ZERO existing tests pinning
static-read behaviour.

So: smallest safe step instead. Two rows pin the measured behaviour
(`static-read-of-a-same-name-twin-picks-up-the-instance-type` asserts the positive
wrong target, not an empty set; `static-read-without-a-twin-loses-its-type` is a
known-gap), and the comment now says what actually happens. Anyone who revisits
this starts from measurements rather than from a claim.

No SCHEMA_BUMP: the `captures.ts` change is comment-only — verified, the diff has
no non-comment added lines.

Mutation red-rows 2/85 -> 7/90; each new row fails with a strictly larger set
(`+Outer.inner#0`), so none can pass vacuously. Overreach control still live:
dropping `shadows.has` turns 28 rows red including
`unshadowed-read-in-a-shadowing-class-still-resolves`.

Matrix 85 -> 92 tests. Sweep 3231 passed, 0 failed. tsc clean. All four gates
green, no fingerprint moved, nothing rebaselined.

Refs #2807

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

* fix(python): stop a dotted callee from fabricating a constructor type

S4 and S5 from the review round. They are ONE defect, not two, and the real one
is wider than the review described. Both live in a 32-line block THIS PR adds
(`@@ -116,0 +117,32 @@` — a pure addition), so neither is pre-existing.

THE DEFECT. `constructorCallTypeName` rejected only a callee rooted at the
receiver and returned every other dotted callee whole to `resolveTypeRef`, which
resolves dotted names through `QualifiedNameIndex` — and that index matches the
TRAILING SEGMENT against a class of that name even when the callee is a method on
an unrelated object:

    class Alpha:
        def ping(self): return 1
    class Factory:
        def Alpha(self): return "not an Alpha"    # a METHOD
    class Host:
        def __init__(self, f): self.svc = f.Alpha()   # svc is a str
        def run(self): return self.svc.ping()
    # measured: Host.run -> Alpha.ping, fabricated

WIDER THAN FILED: the review framed the trigger as a callee rooted at an
`__init__` PARAMETER. Measured, the root's binding form is irrelevant — a
module-level variable (`shared_factory.Alpha()`) fabricates identically. Any rule
written against what the root binds to would have fixed half the defect and left
the other half looking fixed. Both fabrications now have rows.

S5 IS A SYMPTOM, NOT A SECOND DEFECT. `self.conn = Outer()` then
`self.conn = Registry.get()` typed the field as `"Registry.get"` (resolving to
nothing, so the edge vanished) only because the dotted arm accepted
`Registry.get` as a constructor in the first place. Once dotted callees yield no
candidate, there is nothing weak left to displace with and `Outer` survives. So
`>=` is untouched and NO second mechanism was added: between two REAL
constructions last-write-wins is correct, and the existing `ReassignedField`
matrix row depends on it. Tightening the tie-break would have been the wrong fix
to a symptom.

THE FIX: accept a bare `identifier` callee only. Refusing ambiguous evidence at
CAPTURE time rather than resolving-then-rejecting is deliberate — the target-kind
route is not reachable from this file (`resolveTypeRef` already filters
`TYPE_KINDS`; the fabrication comes from a trailing-segment match in
`scope/walkers.ts`), and the root-alias route would collide with PR #2828, which
is rewriting exactly how an unaliased dotted namespace import resolves. This
change is orthogonal to #2828 by construction: it changes what is CAPTURED, never
how a name is looked up, and touches none of its files.

WHAT THE DOTTED ARM WAS ACTUALLY BUYING: nothing. The review (and this PR's own
docblock) justified it with `self.u = models.User()`. Measured, that shape emits
NO edge before or after this change — an instance field's binding lands in CLASS
scope, which never reaches the namespace split. The shape that really resolves is
the module-level local `u = models.User()`, which comes from `query.ts` and is
untouched here. The arm's entire measured contribution was fabrications, which is
what made the fix cheap.

#2828 COMPATIBILITY, checked not assumed: `import pkg.user` -> `self.u =
pkg.user.User()` resolves to nothing both before and after, so this cannot stop it
resolving. No test row pins that shape ON PURPOSE — asserting its current empty
state would plant a tripwire that goes red the moment #2828 lands. If #2828 also
teaches the FIELD path the namespace split, re-enabling dotted field callees
becomes a live option; the docblock says so, and says why redoing it capture-side
would re-open the fabrication.

SCHEMA_BUMP 41 -> 42 with its pin. This is parse-time capture emission: after the
fix `self.svc = f.Alpha()` emits no `@type-binding.constructor` capture at all, so
a v41 warm cache replays the pre-fix capture set for byte-unchanged files and
keeps serving the fabricated edge (GUARDRAILS.md:34). A within-PR re-bump, not a
collision fix — 40/41/42 are all this unmerged branch's, and `origin/main` is at
39. Re-check against origin/main immediately before merging.

Mutation-verified in BOTH directions, which is what shows the fix is placed at the
right width rather than merely working:
  - revert the fix     -> exactly 3 red: both S4 fabrication rows + the S5
                          displacement row (8 green)
  - reject EVERY callee -> exactly 3 red: the three positive-typing rows (8 green);
                          the S4 rows correctly stay green
The two mutations hit DISJOINT row sets — too loose and too tight each break a
different half.

No row asserts an empty set: the three "must not type" rows call `Alien.ping()` as
a witness so a regression SWAPS a target in rather than emptying. Non-vacuity is
asserted in the test itself — one guard checks every caller node is live, another
asserts the `Alpha` class / `Factory.Alpha` method name collision the fabrication
NEEDS is actually present, so the rows cannot rot into passing for the wrong reason.

Sweep 3268 passed, 0 failed. Python unit + python.test.ts 342 passed. tsc clean.
All four gates green — no bench cell moved, nothing rebaselined.

Refs #2807

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

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 19:31:25 +01:00