Commit graph

263 commits

Author SHA1 Message Date
Gergo Magyar
36141609a1 fix(scope): fail closed on namespace factory returns the lookup cannot prove
Review follow-up for the namespace factory return lookup (#3498). Each case
below either resolved to the wrong class or crashed; each now types the
receiver correctly or records a receiver-unresolved drop.

- Disk mode: the namespace-name set walked `scopeTree.byId`, which throws
  after the out-of-core seal, so an inline-only `rp.make().m()` partition
  aborted analyze under GITNEXUS_DISK_SCOPE_INDEX=1. It is now built from the
  finalized `scopes.imports` edges, resolved namespace edges only. This also
  removes the run-to-run edge/drop drift the lazy build caused.
- Hoisted method returns: TS and Dart hoist a method's return annotation to
  the module under the method name, and Go binds receiver methods at package
  scope, so `m.create()` could take `WidgetFactory.create(): Widget`. The
  return binding must now start where a module-local `Function` of that name
  is declared.
- `import pkg.sub` binds `pkg` but targets `pkg/sub.py`; such an edge only
  counts when its target file is the module the local name binds.
- A dotted return (`-> models.Order`) is not followed further: the next step
  would read `models` in the caller's namespace map, not the producer's.
- Zig: the `const x = rp.make()` binding decided namespace-vs-file-struct from
  the filename's case and lost `list.push`-style edges on lowercase
  file-structs. That binding form is reverted; the inline form is unaffected.
- Shared-position drops: a site's identity is now `receiver.member`, so
  `rp.untyped().untyped()` records its outer drop while a call and its own
  callee read still count once.

Tests: temp-repo regression rows for each case in the TypeScript, Go and
Python suites, a lowercase file-struct row in Zig, an awaited Dart row (an
awaited Future is not unwrapped yet, so it records a drop), and data-driven
unit cases for recordSiteMember.

Corpus A/B against origin/main: identical CALLS/ACCESSES edges outside the
new fixtures; the drop set outside them is unchanged from the previous head.
Receiver bench: +1 call drop (the awaited Dart row). Dart scope-capture
fingerprint moves for the same fixture row only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 08:45:32 +00:00
Gergo Magyar
a22b4634c2
fix(zig): type locals from namespace-module factory calls
`const rp = @import("mod.zig"); const x = rp.make(); x.m()` bound `x` to
the namespace handle `rp` itself: the call-return rule reads a non-value
head as the type being constructed (`Counter.init()`), and import bindings
are not values. The member name was gone before the shared chain follow
could use the new namespace return-type lookup.

For a module-level handle on a lowercase file (a namespace module, by Zig
convention), bind the dotted callee `rp.make` so the shared chain follow
types it from `make`'s return annotation. File-struct handles
(`@import("Page.zig")`, any spelling of the handle) keep naming the
struct, so `Page.init()` is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 08:22:29 +01:00
Gergo Magyar
a65bd7bae3
fix(dart): type locals from prefixed-import factory calls
`import 'mod.dart' as rp; final x = rp.make(); x.m()` gave `x` no type:
local call inference accepted only a bare callee (`Callee()`), and the
prefix-stripping type normalizer would have reduced `rp.make` to `make`.

Bind a local initialized by exactly `prefix.member(...)` (direct or
awaited) to the dotted callee and keep its prefix, so the shared chain
follow types it from the namespace function's return annotation. Only
call-derived bindings keep the prefix; annotated `prefix.Type` spellings
are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 08:22:29 +01:00
Gergo Magyar
cddbe135c0
fix(scope): follow namespace factory return types and record untyped drops
`import mod as rp; x = rp.make(); x.m()` lost the receiver type even when
`make` is declared `-> C`: `x` was bound to the raw dotted name `rp.make`,
and nothing followed a dotted name to a function's return type. The inline
`rp.make().m()` failed the same way in the compound resolver, which read
`ns.f()` only as a construction.

Resolve `ns.f` to `f`'s return annotation when `ns` is a namespace import,
the fact `propagateImportedReturnTypes` already mirrors for a named import.
The post-finalize chain follow uses it for bindings and the compound
resolver for inline calls. Only sites that emitted nothing change: the
full lang-resolution corpus emits the identical 2,838 CALLS/ACCESSES edges.

Record the drops that left `impact` at "exact":
- a plain receiver bound to a namespace function with no usable return type
  (`x = ns.untyped()`);
- an outer call whose `file:line:col` key a resolved inner site already
  claimed (`ns.untyped().m()`, `s.repo.Delete(id)`). Only a duplicate
  capture of the same member hides a drop now.

Receiver baseline: call drops 128 -> 151, reads 44 -> 48, ten shape-arm
cells INVISIBLE-GAP -> VISIBLE-GAP; every new drop has no CALLS edge to its
member (BASELINE.md lists them). The python.factory-qualified accuracy gap
is removed; the python-scope fingerprint and capture golden gain the new
fixture only.

Fixes #3498

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 08:22:29 +01:00
Abhigyan Patwari
9df1e98350
fix(routes): scope inline Express handlers and report api_impact lower bound (#3643)
* fix(routes): scope inline Express handlers and report api_impact lower bound

Inline Express handlers are anonymous Function nodes named by position, but
the route facts only recorded a handler name for bare identifiers. Routes
with inline handlers therefore had no handlerSymbolId: every route in a file
got the union of its handlers' response keys and every flow starting there.

Record the positional name for inline handlers, read response keys from the
resolved handler when a file registers several routes, and let processes
link exactly by handlerSymbolId as they already do for named handlers.

Count, per route, the files that pass its URL to a callee not proven to be
a fetch wrapper (excluding proven consumers and the route's own file). The
count is stored as Route.unverifiedConsumers; api_impact reports
epistemic: 'lower-bound' with that count when it is non-zero. Older indexes
without the column fall back to the previous query.

Closes #3488

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(routes): match unverified consumers once per URL

Memoize route matching per distinct fetch URL, skip the proven-consumer
scan when no unproven call matches a route, and build the Route query's
optional columns from a list instead of two booleans.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(group): keep inline handlers attributed to their registrar

Inline Express handlers now carry their positional Function name in the
route facts so ingestion can resolve a handlerSymbolId. Group HTTP
contracts read the same facts, but that name is a graph identity rather
than a symbol name the contract lookup can match, so the provider lost its
enclosing-registrar attribution. Ignore the name for inline handlers there.

Rebaseline the two bench guards the branch moved:
- emit-persistence: route.csv gains the header-only unverifiedConsumers
  field; stripping it restores the prior fingerprint exactly.
- receiver-resolution: the new fixture's req.query.path read on an untyped
  inline-handler parameter adds one receiver-unresolved read (191 -> 192).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-10-20 17:44:08 +01:00
Gergő Magyar
01af833875
fix(scope): preserve lexical bindings in HTTP wrapper detection (#3632)
* docs(scope): clarify lexical binding ownership and wrapper provenance

Checkpoint for #3630. Publishing the implemented source, regression tests, and benchmark harness as a draft PR; full-suite and final scaling validation remain in progress.

* fix(scope): publish #3630 docs/plans

* fix(scope): publish #3630 gitnexus/bench/lexical-binding-provenance

* fix(scope): publish #3630 gitnexus/bench/lexical-binding-provenance/fixtures

* fix(scope): publish #3630 gitnexus/bench/lexical-binding-provenance/results

* fix(scope): publish #3630 gitnexus/src/core/ingestion

* fix(scope): publish #3630 gitnexus/src/core/ingestion/languages/typescript

* fix(scope): publish #3630 gitnexus/src/core/ingestion/languages

* fix(scope): publish #3630 gitnexus/src/core/ingestion/pipeline-phases

* fix(scope): publish #3630 gitnexus/src/core/ingestion/route-extractors

* fix(scope): publish #3630 gitnexus/src/core/ingestion/workers

* fix(scope): publish #3630 gitnexus/test/fixtures/lang-resolution/fetch-wrapper-target-identity/lib

* fix(scope): publish #3630 gitnexus/test/fixtures/lang-resolution/fetch-wrapper-target-identity

* fix(scope): publish #3630 gitnexus/test/fixtures/lang-resolution/fetch-wrapper-target-identity/packages/http

* fix(scope): publish #3630 gitnexus/test/fixtures/lang-resolution/fetch-wrapper-target-identity/packages/http/src

* fix(scope): publish #3630 gitnexus/test/fixtures/lang-resolution/fetch-wrapper-target-identity/server

* fix(scope): publish #3630 gitnexus/test/fixtures/lang-resolution/fetch-wrapper-target-identity/web

* fix(scope): publish #3630 gitnexus/test/integration/resolvers

* fix(scope): publish #3630 gitnexus/test/unit

* fix(scope): publish #3630 gitnexus/test/unit/group

* fix(scope): publish #3630 gitnexus/test/unit/scope-resolution

* fix(scope): publish #3630 gitnexus/test/unit/scope-resolution/typescript

* fix(scope): publish #3630 gitnexus-shared/src/scope-resolution

* fix(scope): treat wrapped assignment targets as writes

`(app) = new Map()`, `(app as T) = x` and `app! = x` assign the same binding as
`app = x`, but route-fact invalidation and the shared pattern walker stopped at
the wrapper, so Express provenance survived the write and produced a false
route. Recurse through parenthesized, as/satisfies and non-null targets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(routes): resolve wrappers through overloads, declarator lists and barrels

Three wrapper shapes lost their identity:

- Bodyless overload signatures were indexed as separate definitions sharing
  the implementation's binding, so the binding looked ambiguous and both local
  and imported calls were rejected. Signatures now declare no callable.
- Facts for `const base = 0, request = (url) => fetch(url)` followed the first
  declarator. The captured name now selects its own declarator.
- An import resolved to a workspace barrel stopped at index.ts. Workers now
  record named, star and imported-then-exported re-exports, and resolution
  follows them to the declaring module: local and named exports shadow
  `export *`, star exports never carry `default`, and conflicting star exports
  block the name. No repository-wide name fallback is restored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(scope): assert closure ownership against the declaration

The closure-write assertion compared one lookup with itself, which also passes
when both are undefined. Compare it with the outer declaration and the shadowing
parameter, and keep the memoization check explicit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(routes): share lexical helpers and release per-file HTTP contexts

- Share binding keys, parameter patterns and transparent expression wrappers
  from lexical-bindings instead of four local copies; drop the unused
  moduleStem export; name the inferred-source predicates in lookup.
- Key the per-file HTTP context by its tree rather than its ParsedFile.
  Workers retain ParsedFiles for the whole chunk, which kept every file's AST
  and traversal maps alive until the chunk flushed.
- Index re-exports by name, memoize barrel walks per starting export, and keep
  facts without import targets as-is instead of copying them.
- Move TS-only wrapper cases into the TS describe.each instead of branching
  inside the per-language loop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(routes): keep wrapper identity for HOCs, CommonJS, object keys and fetch packages

Four definition shapes that main linked by name lost their wrapper identity:

- `const load = useCallback((url) => fetch(url))` and curried HOCs produced no
  facts because definition lookup stopped at the call; it now follows the
  callable argument, as the structural definition capture does.
- `module.exports = { request }`, `exports.x = …` and `module.exports.x = …`
  were not exports, so destructured `require` callers missed the wrapper.
  CommonJS export assignments are recorded when `module`/`exports` are the
  unshadowed globals, including functions defined inline in the export object.
- An object-literal key or method name was resolved as a lexical binding and
  collided with a same-named module wrapper. Only binding identifiers now name
  a definition's binding.
- `import fetch from 'node-fetch'` no longer counted as fetch. Imports of known
  fetch implementations (node-fetch, cross-fetch, isomorphic-fetch, the fetch
  member of undici and whatwg-fetch) are fetch evidence again; parameters and
  other local names still are not.

Reference identifiers are also resolved only when their name is bound to a
callable or class region; other identifiers cannot add a reachability edge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(routes): drive the forwarding fallback case from data and cover re-export merges

Replace the boolean-switched forwarding fallback case with table rows instead
of branching inside the test, and assert that barrel re-export facts survive
worker sub-batch merges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(bench): reject stale or dirty call-graph exports

The committed comparison named a candidate revision that is not on this branch,
and nothing stopped it. Comparison now requires both exports to come from clean
checkouts and the candidate export to come from the checkout running the
comparison.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(bench): regenerate the call-graph comparison on the reviewed head

Clean baseline 565e50276 and clean candidate 8a5f7b738 on the frozen 432-file
corpus (fingerprint 88be1faf...): exactly one CALLS target changes, the
annotated closure probe `declaredOwner.writeAndRead` now calls `Base.m`
instead of `Foo.m`. The other 431 CALLS and all 11 METHOD_OVERRIDES edges are
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(bench): emit export provenance and review notes from the comparison

The comparison report carried export provenance and the expected-change
explanation only as hand-added fields, which a regeneration dropped. Emit
both exports' revisions, dirty flags and fingerprints, the unchanged CALLS
and METHOD_OVERRIDES counts, and an optional `--note` explanation directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(bench): record calibrated scaling reports and the regenerated comparison

Duration-calibrated-v2 runs (5 warmups, 15 measured batches, 500 ms pilot
target) pinned to the same CPU, baseline then candidate back to back:

- Baseline 565e50276 exceeds the 1.8 normalized ceiling on six arms at 4x
  input: position-index 7.92, position-deep-completed 4.65, nested-helpers
  4.82, nested-helper-chain 14.05, reverse-chain 4.31, name-collisions 2.61
  (medians).
- Candidate 94f1a055b passes --check: every arm stays at or below 1.32 at 4x
  for both minimum and median, with correct semantic guards on every arm.

The comparison is regenerated from clean exports with its provenance and
review note emitted by the tool: one expected CALLS target change, 431 CALLS
and 11 METHOD_OVERRIDES unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(scope): replace recursion with explicit stacks in new traversals

The binding work added five recursive traversals whose depth follows input
shape: region summaries (callable nesting), barrel resolution (re-export
chain length), pattern binding names (destructuring depth), route-fact write
invalidation (pattern depth), and the position index's max-end tree. Each
now uses an explicit stack with the same visiting order and results.

Regression cases cover the inputs the recursive versions could not handle:
a 20,000-deep destructuring pattern and a 50,000-module re-export chain both
threw RangeError before this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(routes): avoid a full-tree script walk when building lexical bindings

Route-fact extraction ran 1.78x slower than main on a 512-function file
(87 ms vs 49 ms) because createLexicalBindings walked every AST node in script
to detect collapsed parameter/body environments, which only functions with
parameter defaults or computed keys can have.

- Find functions with the native type search, skip plain parameter lists
  without any subtree search, and walk only the formals and bodies of the
  rest. Detection is unchanged, except that functions inside syntax-error
  regions are now also checked, which can only add fail-closed ambiguity.
- Read each node's id and start position once per lookup instead of
  repeatedly crossing into native getters.

The same extraction now takes about 56 ms against 51 ms on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(routes): walk wrapper evidence with a tree cursor

The wrapper context visited every node through namedChildren, which
materializes each child as a SyntaxNode and records a parent edge by node
id. A tree cursor reads node types and offsets natively, so only the node
types the handlers inspect become SyntaxNodes. Declared identifiers are
keyed by start offset, and the walk records the ancestor facts later
queries need (stored binding, default export, declarator of a name)
instead of a parent map.

On the 512-wrapper scaling input, context construction drops from about
58 ms to 48 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(bench): regenerate the call-graph comparison on the cursor walk

The candidate export at 6061ca71b has the same fingerprint as the previous
candidate: the traversal rewrite changes no edge. The one expected target
change and the 431 unchanged CALLS and 11 METHOD_OVERRIDES remain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(bench): record final scaling reports on the cursor walk

Baseline 565e50276 and candidate 61de364ff ran back to back on CPU 23
with the duration-calibrated-v2 protocol. Every candidate check stays at
or below a 1.32 normalized factor against the 1.8 ceiling; the baseline
fails six of nine arms. Workload fingerprints agree except the documented
name-collision false positive in the baseline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(plan): checkpoint the final review, scaling and suite results for #3630

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(routes): treat export aliases of one wrapper as one star-export target

Star-export candidates were deduplicated by the resolved export name, so
`export { request, request as send }` reached through two barrels counted
as two targets and blocked the import, dropping the consumer's FETCHES
edge. Candidates are now keyed by the declaration each export name names,
matching ECMAScript ResolveExport's binding comparison; distinct
declarations exported under the same name stay ambiguous.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(bench): regenerate scaling and call-graph reports on the alias fix

Baseline 565e50276 and candidate 4d62bd045 ran back to back on CPU 23.
Every candidate check stays at or below 1.22 against the 1.8 ceiling;
the baseline still fails six of nine arms. The CALLS/METHOD_OVERRIDES
export is unchanged (fingerprint 4db24f57), so the comparison only
records the new candidate revision.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(plan): record the export-alias fix and the regenerated reports for #3630

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(scope): keep the type a write gives an undeclared name

A constructor write to a name no scope declares (`client = new ApiClient()`
in sloppy JavaScript) declares nothing, so the lookup stays absent. The
carried constructor fact was dropped on that exit, which lost the receiver
type main had inferred for later reads in the writing scope. The absent
result now carries it, and findReceiverTypeBinding uses it before the
namespace fallbacks. Only complete-claim providers carry such a fact.

Tests pin the carried type per inferred source, the undeclared write, and
preservePosition across a module redirect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(routes): close the wrapper identity gaps found in review

- CommonJS wrappers captured as their assignment (`exports.x = function`,
  `module.exports.x = () =>`) produce facts again: findFunction follows the
  assignment's right side.
- Name-only fallback considers only definitions that can be the target: an
  unresolvable import names a module-level export, an undeclared global a
  top-level function. Nested and unexported helpers no longer erase a
  wrapper's edges.
- Only possible wrappers and module-level functions are emitted, and the
  routes phase builds the index only when routes and candidate calls exist.
- A rebound binding (later assignment, `var` initializer over a function
  declaration, reassigned export) proves no wrapper across files.
- `(<T>x) = ...` is a write; transparent wrappers skip type arguments.
- Calls inside recovered syntax are still collected; instance-field
  initializers resolve after the module body, so a later declaration is
  visible.
- Local exports are always recorded, so a star export conflicting with a
  non-callable export is ambiguous, and fully resolved barrel frames are
  shared across walks.
- HTTP hooks run per file under a guard; a failing file or one without a
  ParsedFile still records its function names so fallback declines.
- namesFetch is iterative, large patterns no longer spread into push, the
  facts cache is keyed by the name node, and the provider context cache
  checks its ParsedFile.

Tests cover each case; the behavior rows fail on the previous source.
The TS-only call cases no longer branch inside the test file, call targets
are asserted exactly, the function-type list is drift-checked, and the
persistence test joins the cross-platform worker list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(routes): describe wrapper fallback, barrel and recall boundaries

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(bench): count the new CommonJS fixture's dropped write site

`lib/cjs-member.js` in the fetch-wrapper target-identity fixture writes
`module.exports.arrowRequest`, a member write whose receiver is not typed.
That adds one `receiver-unresolved` write (190 -> 191 all-kind drops,
19 -> 20 writes). Call drops and shape-arm states are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(bench): regenerate scaling and call-graph reports on the review fixes

Baseline 565e50276 and candidate 528ea1478 ran back to back on CPU 23.
Every candidate check stays at or below 1.27 against the 1.8 ceiling; the
baseline still fails six of nine arms. The CALLS/METHOD_OVERRIDES export
is unchanged (fingerprint 4db24f57), so the comparison only records the
new candidate revision.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(plan): checkpoint the review fixes and regenerated reports for #3630

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(routes): share CommonJS checks and trim per-node work

- `isCommonJsGlobal`/`isModuleExports` in lexical-bindings replace the two
  copies in fetch-wrapper and framework-route-facts (both resolve the same
  scope from the same start position).
- LEXICAL_FUNCTION_TYPES is derived from the TypeScript FUNCTION_NODE_TYPES
  instead of a third hand-kept copy; the drift test still pins it.
- `unwrap` reads UNWRAPPED_TYPES, the set the cursor walk mirrors, and the
  class-body region is no longer shadowed under the name `deferred`.
- framework-route-facts reads each node's type once per loop iteration.
- The position index returns the latest-starting scope when it already
  contains the point, before the max-end tree search; that entry is the
  search's rightmost match.
- carried() returns a shared empty set for argument forms that cannot carry
  a value, the resolution file set is built without intermediate arrays,
  and the routes phase reads the call list once.

No behavior change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(bench): regenerate scaling and call-graph reports on the refactor

Baseline 565e50276 and candidate 3db8657ba ran back to back on CPU 23.
Every candidate check stays at or below 1.32 against the 1.8 ceiling; the
baseline still fails six of nine arms. The CALLS/METHOD_OVERRIDES export
is unchanged (fingerprint 4db24f57). The later main merge (e2a92dab8)
touches no measured file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(plan): checkpoint the simplification pass and its reports for #3630

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Address PR review feedback (#3632)

- Destructuring defaults (`{ fetch = globalThis.fetch } = {}`, renamed,
  array and local forms) are alias evidence for their leaf binding and
  no longer count a second initialization, which also invalidated
  bindings like `[send = other] = values`.
- `export default (request)` records the default export: the value is
  unwrapped before the identifier check.
- The HTTP fallback records and the collapsed-environment test row now
  say what they stand for.

Regression rows cover each form in JS, TS and TSX and fail on the
previous source; two fixture consumers cover imported wrappers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(bench): refresh lexical binding reports after review fixes

Regenerate clean baseline and candidate measurements at c263c4f72.
All candidate scaling checks pass, the semantic delta remains one CALLS
target, and receiver-resolution counts match the existing baseline.

* docs(plan): checkpoint refreshed review-fix measurements

Record c263c4f72 review fixes, the regenerated scaling and semantic
results, and the passing receiver and CI checks.

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:36:06 +01:00
Abhigyan Patwari
565e50276d
fix(routes): resolve Express registrations made through helper parameters (#3629)
* fix(routes): resolve Express registrations made through helper parameters (#3487)

#3603 made Express receivers provenance-proven, which removed the receiver-
name false positives but also every registration on a function parameter:
a parameter has no binding value, so `app.get(...)` inside a helper never
resolved. On GitNexus's own server that dropped `* /api/mcp`
(`mountMCPEndpoints(app, ...)`) and `GET /` (`registerWebUI(app, ...)`),
and the two SSE progress routes registered by
`mountSSEProgress(app, '/api/.../progress', jobs)` were never found at all
(#3487, item a).

Named function values in the route facts now carry an identity and the
binding id of each parameter (a TypeScript `this` parameter takes no
position, and a parameter written anywhere in the body, nested closures
included, takes none either). Calls to plain functions and to namespace or
default import members are recorded as invocations, keeping only the
arguments that can carry an allocation or a literal path; arguments after a
spread have no position. The resolver resolves a registration whose
receiver, literal path or mounted child is a parameter through each call
site of its function, once per site and in the helper's file. This is one
hop only: a site argument that is itself a parameter does not resolve. When
no call site is recorded, the call is evaluated as written, so an unknown
mount path still suppresses its child router instead of exposing its paths
at the root.

Only calls whose callee is named like a function that registers on its
parameters are evaluated, which keeps resolution at its previous cost on
repositories with many helper calls. A function value's invalidation key now
includes its identity, so a property write on one function
(`Button.displayName = ...`) no longer disables every other. The group
layer shares the resolver, so it gets the same routes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(routes): forward mounted-child parameters and aliased helper imports

Review follow-ups for the helper-parameter forwarding:

- A helper that forwards only its mounted router (`function mountChild(child)
  { app.use('/api', child) }`) was never resolved, because the forwarded
  function was chosen from the receiver and path alone. Any parameter among
  the receiver and arguments now selects it, as long as all come from one
  function.
- A helper called through an aliased import (`{ mountSSEProgress as
  mountProgress }`) was filtered out before its call sites were recorded,
  since the filter compared only the local name. It now also accepts the
  imported name.
- A write to a same-named local declared in a nested block (`{ let app;
  app = other; }`) discarded the outer parameter. A write to a binding that
  a nested block, function or catch clause declares no longer counts.
- The fixture's `registerWebUI` uses its static directory.

The release tool-accuracy gate now passes `routes.helper-registration`, so
its known-gap allowance is removed. Its expected file moves from the caller
(`api.ts`) to the helper (`progress.ts`): an Express route belongs to the
file of its `app.get` call, which is also where its handler and response
keys are, as for every directly registered route.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(routes): treat destructuring reassignment as a parameter write

`[app] = [other]` reassigns `app`, but the write check compared the whole
target text (`[app]`) with the parameter name, so the parameter stayed
forwardable and later registrations used the call-site argument. A
destructuring target now writes each name it binds; a member target still
writes only a property. Also drops an unused local flagged by CodeQL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(routes): follow helpers exported under another name

`export { mountRoutes as setup }` records the export as a binding, so the
call-site filter knew the helper only as `mountRoutes` and skipped callers
importing `setup`. An export of a binding to a named function now adds its
exported name too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-10-18 22:13:10 +01:00
Abhigyan Patwari
aa33a6e94c
fix(routes): link base-URL fetch calls made through wrapper chains (#3628)
* fix(routes): link base-URL fetch calls made through wrapper chains (#3488)

GitNexus's own web client calls `fetchWithTimeout(`${_backendUrl}/api/repos`)`,
which reaches fetch through `resilientFetch` and a local `fetchImpl` alias.
`api_impact` found none of those consumers, for three independent reasons:

- normalizeFetchURL rejected any URL that did not start with `/`, so a
  leading base-URL binding dropped the call. It now strips a leading
  `${...}` run followed by `/`, with the same rules as the group layer's
  consumer normalizer (#3111); the helper moves to route-path.ts so both
  layers share it. A path whose every segment is a param (`${base}/${id}`,
  `${base}/${id}.json`) is rejected, since it would match every route of
  that depth. Interpolations are collapsed brace-balanced before the query
  split, so `${repo ? `?repo=${repo}` : ''}` no longer ends the path, and
  one glued to the last static segment is treated as a query or suffix.
- The wrapper call collector required a `/`-leading literal, so the same
  template never became a wrapper candidate.
- Wrapper detection only looked five AST levels deep for a call to an
  identifier named `fetch`. Arrow-const wrappers sit deeper than that, and
  `resilientFetch` calls an alias of `globalThis.fetch`. Detection now walks
  the whole definition and accepts `globalThis`/`window`/`self.fetch` and
  aliases whose value names fetch (through `??`, `||`, `?:`), read from the
  AST rather than the text. It records whether the first parameter reaches
  fetch's URL argument and which callees receive that parameter. The routes
  phase closes the wrapper set over that forwarding: `fetchWithTimeout` is a
  wrapper because it forwards its URL to `resilientFetch`. A callee declared
  in the caller's file or imported from a relative module must carry a URL
  in exactly that module; a package-imported name is followed only when
  every recorded function of that name carries one. A fetch caller whose
  first parameter is not the URL (`track(event)`) or a same-named function
  elsewhere (an IPC `request(channel)`) lends nothing.

When `[param]` matches several sibling routes (`/api/analyze/upload` and
`/api/analyze/:jobId`), the fetch now links to the route whose segments
match by kind instead of the first one registered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(routes): match wrapper calls by declaring module and decline ambiguous fetch matches

Review follow-ups for #3488:

- A propagated wrapper's name went into a repo-wide set, so a literal call
  to an unrelated same-named function in another module was taken as a
  fetch. Wrapper call candidates now carry the module declaring a bare
  callee when the file declares or imports it relatively, and must reach a
  wrapper defined in exactly that module. Package and member callees keep
  the name match.
- An alias declared in a nested helper (`const fetchImpl = globalThis.fetch`)
  marked an outer call to a same-named parameter as a fetch. An alias now
  counts only where its declaring function encloses the call.
- A first parameter redeclared inside the function (`{ const url = ...;
  fetch(url) }`) was taken as the URL. A shadowed parameter is no longer
  followed.
- A fetch whose `[param]` ties between sibling static routes
  (`/api/kinds/foo`, `/api/kinds/bar`) linked to the first registered one.
  A tie now links none.
- The fixture's `resilientFetch` returns the last response instead of
  rejecting with `undefined`, and its client encodes the repo query value.

The release tool-accuracy gate now passes `api.template-wrapper`, so its
known-gap allowance is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(routes): keep call-site wrapper names and ignore helpers that never run

- A wrapper call through a renamed relative import (`import { request as
  send }`) stored the exported name, so a configured `fetchWrappers` entry
  naming the call-site `send` no longer matched. The call keeps its
  call-site name and carries the declared name separately for the module
  lookup.
- A nested helper bound to a local that is never referenced again (`const
  later = () => resilientFetch(url)`) made its enclosing function forward
  its URL. Calls inside such a helper are ignored; a helper that is called
  or passed on, such as a retry callback, still counts.
- The fixture's `resilientFetch` no longer retries after a timeout or abort.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(routes): let a nested parameter shadow a fetch alias

A function parameter between a fetch alias and a call shadows the alias
there (`function send(fetchImpl) { return fetchImpl(event) }` beside an
outer `const fetchImpl = globalThis.fetch`), so that call no longer marks
the enclosing function as a fetch wrapper. The fixture's retry helper also
stops returning a stale 5xx response after a later attempt throws.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(routes): drop fetch aliases whose name is redeclared with another value

A block that redeclares an alias name with a non-fetch value (`{ const
fetchImpl = other; fetchImpl('/x') }`) let the inner call read as a fetch.
A name declared anywhere in the function with a value that does not name
fetch is no longer an alias, which covers every block-scoped shadow without
lexical scope tracking.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(routes): ignore uncalled nested declarations and shadowed global objects

- An uncalled nested `function helper() { fetch('/x') }` made its enclosing
  function a fetch wrapper. Nested function declarations that are never
  referenced are now ignored, as unused function-valued locals already were.
- `window.fetch(url)` through a parameter or local named `window` (or
  `self`, `globalThis`) was read as the platform fetch. Such a root is now
  that value's method. A bare `fetch` parameter is still taken as fetch,
  since injecting a fetch implementation is the common reason for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(bench): refresh the receiver-resolution baseline for the fetch fixture

The new `fetch-base-url-wrapper` fixture in `test/fixtures/lang-resolution`
adds one unresolved TypeScript chain-field call to the committed check
corpus (127 to 128 call drops). Regenerated with
`measure.mjs --update-baseline` after a fresh build and cleared parse caches;
no other counter moved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-10-18 22:11:37 +01:00
azizur100389
702c917377
fix(python): retain nullable factory receiver types (#3595)
* fix(python): retain nullable factory receiver types

* test(accuracy): remove repaired Python gap allowances

* test(bench): refresh Python capture fingerprint

* fix(python): type `None or call()` assignments like `call() or None`

`None or make_user()` always evaluates to the call, but only the
`call() or None` order matched, so the variable lost the factory's
return type. Add the mirrored boolean pattern, matching the two
conditional-expression orders the PR already supports.

Fixture gains `none_or` (resolves to User.save) and `none_and` (stays
unresolved: `None and x` is always None). The capture golden changes only
for the edited fixture, and the python-scope baseline is re-recorded
(`measure.mjs --check` passes, scaling 1.021).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:39:40 +01:00
Gergő Magyar
f8209df2e2
fix(routes): resolve Express receivers and router mount paths (#3603)
* fix(routes): resolve Express receiver provenance and mounted paths

Require statically proven framework receivers, compose Router mount paths,
and share the same provider resolution between ingestion and group routes.
Preserve worker and parse-cache parity with per-file facts.

* fix(review): apply review findings

Invalidate exported Routers through namespace property mutations and retain
uncertain incoming mounts so invalid parents cannot revive local paths.

* refactor(routes): reuse export checks and fixture writer

* fix(routes): evaluate fluent call receivers once

* fix(review): preserve provider paths and uncertain mounts

* fix(test): distinguish parameterized router mutation cases

* docs: remove Express route implementation plan
2026-10-16 18:25:40 +01:00
azizur100389
94f167a90f
fix(routes): reject middleware and route metadata false positives (#3561)
* fix(routes): reject middleware and route metadata false positives

* fix(routes): invalidate cached phantom routes on upgrade

* test(routes): cover cache upgrades across merged schema versions

Verify that schemas 137 through 140 invalidate both parse stores after merging main into PR #3561 and advancing the schema to 141.

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-10-16 08:02:41 +00:00
Richie Wee
b51f25606c
fix(routes): match configured fetch wrapper calls that pass type arguments (#3567)
Some checks failed
Devcontainer Smoke / Build devcontainer image (push) Has been cancelled
Devcontainer Smoke / Config-transform unit tests (push) Has been cancelled
Skill copy sync / shipped skills drift guard (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
CodeQL / Analyze (python) (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-web) (push) Waiting to run
2026-10-15 20:08:56 +03:00
Nguyễn Đăng Minh Lực
5c3961756b
fix(ingestion): preserve Go method ownership across files (#3559)
* fix(ingestion): re-point Go struct member edges when struct is declared in a sibling file (#3558)

findEnclosingClassInfo mints a Go method's owner id from the file
containing the method (Struct:<methodFile>:<Type>), while the Struct
node is keyed by the file declaring the struct (parse-worker uses
declFile). Same package, different file => the HAS_METHOD edge's
sourceId points at a node that does not exist and the edge is silently
dropped at COPY into LadybugDB.

Add reconcileGoStructOwnerEdges, run at the end of the parse phase
after ALL worker chunks are merged so every package's structs are
visible. It rewrites only edges that would otherwise dangle, and only
when exactly one Struct node shares the dangling id's package directory
and simple name; 0 or >=2 candidates keep the old drop behavior (a
wrong owner is worse than a dropped edge).

Index build is lazy: dangling Struct sources are rare (struct fields
never dangle — they live in the struct's own file), so the common
non-Go or fully-same-file case pays two per-type edge scans and no
whole-node pass.

Regression test: test/integration/go-cross-file-struct-owner.test.ts
(fails-before: 1 HAS_METHOD edge; passes-after: 3).

* fix(ingestion): scope Go struct-owner reconcile by language and package

Address PR #3559 review findings:

- Scope the pass to Go: dangling HAS_METHOD/HAS_PROPERTY owner ids whose
  Struct:<file>:<name> file is not Go source (Rust, Swift, ...) are never
  re-pointed, and only Struct nodes stamped language 'go' (the marker the
  worker sets on every declared symbol) are candidates. Negative test pins
  a Rust-shaped dangling edge staying untouched beside a same-name Go
  struct.

- Key candidate structs by (dir, declared package clause, name) instead of
  (dir, name): one directory can carry 'package pkg' and external-test
  'package pkg_test' files declaring the same type name, which the old key
  saw as ambiguous and dropped fail-closed. Clauses are read via the shared
  inferGoPackageName derivation with a call-scoped cache. Integration test
  covers the 3-file layout (pkg decl, pkg_test decl, pkg_test methods file)
  and asserts the edge lands on the pkg_test.Widget node.

- Keep the direct call in parse-impl with a TODO: the pass needs a
  graph-level post-merge hook, which no LanguageProvider/ScopeResolver hook
  provides today.

* refactor(ingestion): remove Go graph repair from shared parse phase

* fix(ingestion): resolve Go ownership during ingestion (language-provider.ts)

* Add resolveDetachedOwnerName function to Go config

* Refactor method owner population logic in Go

* Refactor receiver binding to handle unnamed receivers

* Enhance route handler method resolution logic

Refactor route handler to improve ownership resolution and method indexing.

* Add emitPostResolutionEdges to scope resolver

* fix(ingestion): resolve Go ownership during ingestion (ownership.ts)

* fix(ingestion): resolve Go ownership during ingestion (ast-helpers.ts)

* Add resolveDetachedOwnerName to provider methods

* Update SCHEMA_BUMP to 141

Increment schema version to 141 for new features and fixes.

* fix(ingestion): resolve Go ownership during ingestion (decl.go)

* fix(ingestion): resolve Go ownership during ingestion (external_decl_test.go)

* fix(ingestion): resolve Go ownership during ingestion (external_methods_test.go)

* fix(ingestion): resolve Go ownership during ingestion (foreign.rs)

* fix(ingestion): resolve Go ownership during ingestion (go.mod)

* fix(ingestion): resolve Go ownership during ingestion (local.go)

* fix(ingestion): resolve Go ownership during ingestion (methods.go)

* fix(ingestion): resolve Go ownership during ingestion (go-cross-file-struct-owner.test.ts)

* Refactor comments and method registration in tests

Updated comments for clarity and modified method registration to include qualified names and ownership.

* fix(ingestion): resolve Go ownership during ingestion (has-method.test.ts)

* Update SCHEMA_BUMP to 141 in tests

* refactor(ingestion): remove obsolete Go struct owner reconciliation

* fix(go): model build constraints for method ownership

* test(go): cover build constraint grammar and platform variants

* fix(go): resolve method owners across compatible build variants

* test(go): verify variant ownership through ingestion pipeline

This test verifies that build-variant owners are consistent across different workers, the semantic registry, and the graph. It sets up various scenarios to ensure that ownership is correctly resolved and that there are no dangling edges in the ownership graph.

* fix(go): reject impossible known constraints when peer is unknown

Address PR #3559 review feedback. Regression coverage follows in this update.

* feat(ingestion): allow providers to group incremental resolution files

Add the optional language-neutral hook used by the Go ownership writeback fix for PR #3559.

* fix(go): group sibling files for incremental ownership resolution

Use the existing Go package-directory key for the provider hook.

* fix(incremental): collect sibling resolution groups for changed paths

Keep group keys local to each provider and include current siblings of deleted files.

* fix(analyze): persist ownership changes in unchanged Go siblings

Expand changed and deleted resolution groups before importer traversal. Use the same effective file set for deletion and writeback so package-wide ownership changes reach the database.

* test(incremental): cover provider-local resolution groups

Cover changed, added, deleted and unrelated paths for the new sibling expansion helper.

* test(go): compare incremental ownership writes with cold rebuilds

Reproduce ownership changes after deleting and restoring a Windows declaration while the shared method file stays unchanged. Verify incremental writes and persisted ownership parity with forced rebuilds.

* test(go): exclude impossible owners with unknown method constraints

Use real parsing to cover impossible owner edges and valid owners hidden by impossible competitors.

* test(go): pin filename rules and unknown constraint overlap

Document Go first-dot filename behavior and verify impossible known constraints in both operand orders.

* test(go): register incremental ownership coverage across platforms

Include the new database-backed Go ownership suite in the cross-platform test list.

* test(go): schedule incremental ownership suite with native database tests

Complete PR #3559 review fixes. Local validation: 47 focused tests across five suites passed; build and source/test type checks passed. The full suite was interrupted without a result.

* refactor(ingestion): centralize worker owner hook forwarding

Pass the language provider into the private cached owner lookup instead of repeating four owner hooks at five call sites. Keep qualified-name selection explicit and preserve the exported helper signature.

Validation: CLI/shared build and test typecheck pass; repository lint has no errors; 591 tests pass with one skipped across 13 ownership and worker suites. Web typecheck is blocked by uninstalled web dependencies. Graph checks are unavailable because this checkout has no registered index.

* test(rename): add Go cross-file fixture module

Part of the cross-file method ownership regression for PR #3559. The completed fixture uses real indexing, public rename preview/apply, warm cache replay and post-rename reindexing. All 14 Go rename integration cases pass locally.

* test(rename): add cross-file fixture before/types.go

* test(rename): add cross-file fixture before/methods.go

* test(rename): add cross-file fixture before/callers.go

* test(rename): add cross-file fixture after/go.mod

* test(rename): add cross-file fixture after/types.go

* test(rename): add cross-file fixture after/methods.go

* test(rename): add cross-file fixture after/callers.go

* Add test case for renaming pointer receiver method

Complete the real persisted-index regression fixture for PR #3559. The type, pointer receiver method and callers are in separate Go files. Public rename preview/apply must make exactly four edits across the structural interface family while preserving an unrelated same-name method, comments and strings.

The fixture checks six exact ownership, interface and call edges before rename, after warm parse-cache replay and after reindexing the edited sources. All 14 Go rename integration cases pass. Formatting and the CLI typecheck pass. Graph impact/detect-changes could not run because this checkout has no registered index.

---------

Co-authored-by: System administrator <root@nixos-infra.home>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-10-15 10:36:13 +00:00
Gergő Magyar
8fa8e64388
fix(rename): extend safe refactoring and flow coverage across languages (#3557) 2026-10-13 08:38:17 +03:00
Gergő Magyar
191a954bf6
fix(python): resolve calls through module-returning accessors (#3542)
Some checks failed
Trivy Image Scan / Trivy (gitnexus-cli) (push) Has been cancelled
Trivy Image Scan / Trivy (gitnexus-web) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Gitleaks / gitleaks (push) Has been cancelled
Publish / Classify release event (push) Has been cancelled
Scorecard / Scorecard analysis (push) Has been cancelled
Publish / RC guard (marker + release-PR skip) (push) Has been cancelled
Publish / ci (push) Has been cancelled
Publish / Publish to npm (push) Has been cancelled
Publish / Build & Push RC Docker images (push) Has been cancelled
* test(accuracy): retire repaired Python accessor gaps

* fix(python): connect module accessor resolution

* fix(python): resolve calls through module-returning accessors

Bind `_pc().target()` and `m = _pc(); m.target()` to the module's
function when a local accessor returns an imported module, and count
capped-out dropped names in the impact lower bound.

Closes #3497

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

* refactor(python): share module-accessor position key (#3542)

- Reuse the capture-side positionKey in the receiver namespace resolver
  instead of a second line:col helper plus an inline template.
- Note why the synthetic { moduleScope, scopes: [] } input is sufficient
  for createNamespaceTargetCache (sealed disk-backed ParsedFile shape).

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

* refactor(python): reuse subtype-dispatch positionKey (#3542)

- Export the existing line:col positionKey from subtype-dispatch instead of
  defining an identical copy in module-accessors, so both Python capture
  side channels key positions through one helper.
- Apply prettier to the Function-scope indexing line.

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

* fix(ci): unique test titles and rebaseline Python benches (#3542)

- impact-epistemic-lower-bound: `%j` rendered null, NaN and Infinity all as
  `null`, so the test-completeness audit rejected the duplicate identities.
  Use `%s` so every parameterized case has a distinct title.
- python-scope: capture fingerprint changes with the new module-accessor
  capture facts (golden captures were already updated).
- receiver-resolution: +4 Python call drops (125 -> 129). The two-step
  `x = f(); x.m()` form is now counted as an unresolved call instead of
  vanishing: fastapi-depends `router.get/post` (unknown, x3) and
  python-method-chain-binding `user.get_city()` (in-program; `User` has no
  `get_city`, so the site is genuinely unresolvable).

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

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 18:41:51 +01:00
Gergő Magyar
999cc9f913
fix(deps): upgrade native tree-sitter to 0.25.1 (#3531)
* fix(deps): upgrade native tree-sitter to 0.25.1

* fix(tree-sitter): preserve sync loading and update verified capture baselines

Route C# scope queries through the synchronous native grammar loader so the compiled analyze module remains require-compatible on Node 22.18. Refresh only the audited empty-Python-module and renamed-PHP-fixture captures, and pin the runtime assertion to 0.25.1.

Validated 479 targeted tests, all 16 scope-capture benchmark cases, Python capture/import guards, compiled C# worker parsing, build, typecheck and formatting. Performance budgets are unchanged.

* fix(tree-sitter): refresh upstream vendors and preserve parser semantics

* fix(ci): refresh prebuilds and support Windows ARM

Refresh macOS, Linux ARM, and Windows x64 grammar binaries from the successful native CI builds. Pin node-gyp 12.4.0 so the Windows ARM jobs recognize Visual Studio 2026.

Use local Git filtering for E2E to avoid PR-files API timeouts on generated parser diffs.

Validation: 143 focused tests, Actionlint, and all 16 scope-capture benchmark checks passed. Full npm test run started; native database suites still running.

* fix(ci): refresh Windows ARM grammar prebuilds

Import all seven Windows ARM grammar artifacts from successful native build run 37965761760. Artifact filenames and SHA-256 hashes verified; all 42 native build jobs passed.

Validation: 31 prebuild coverage tests passed.

* fix(bench): stabilize scope capture timing guard

Warm each input size seven times and use 21 median samples while retaining isolated timing blocks. Yield between languages so native finalizers release allocations outside the measured sections. Keep all fixture sizes, fingerprints, scaling formulas, and budgets unchanged.

Validation: all 16 language guards passed with a 2 GB heap limit (1.38 deep-chain ratio; 1518 MB peak RSS). Linear and quadratic controls passed their expected guard outcomes. Formatting and graph change analysis passed. Full npm test is running without failures so far.

* fix(parser): reduce TypeScript capture traversal cost

* fix(test): await parser worker readiness before teardown

* fix(parser): load C# synchronously in workers

* fix(grpc): resolve native proto detections against their own file

With the native proto grammar active, sibling .proto files that declare
the same short service name under different packages tied in the
directory-based resolveProtoConflict heuristic and both providers were
dropped. A proto-origin detection already knows its defining file, so
use that file's entry before falling back to the heuristic.

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

* ci(packaging): assert native grammars load from the installed tarball

The packaged-install smoke only ran `gitnexus --version`, so a grammar
that failed to load from the published package (bundled runtime, patched
peer manifests, vendored prebuilds, C# sync loader, Protobuf wrapper)
would ship silently; the dev-tree ABI smoke also accepts vendored
grammars that report unavailable. Parse a sample for every registered
grammar plus the native Protobuf plugin from the installed package, on
the main thread and in a worker thread.

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

* ci(tree-sitter): track compatibility workarounds instead of 0.25 readiness

The daily readiness report still targeted tree-sitter@0.25.0, so after the
upgrade it would keep reporting "Blocked" and never close its tracking issue.
Repurpose it to track what we carry for the bundled runtime: derive the target
from the package.json pin, list each pack-time peer patch from
prepare-tree-sitter-bundle.cjs with its drop condition (bump, wait for an
upstream release, transitive, or intentionally pinned), and reword the
buckets. The workflow adopts the issue opened under the old title.

Also update the grammar-update-monitor header and auto-PR text, which still
described the 0.21.1 runtime and a tree-sitter-c hold.

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

* test(dart): assert both nested labels target the loop; fix Zig call-shape doc

The nested-label CFG test only jumped to `outer` and checked reachability,
which still passes when a jump is retargeted to the inner loop (its exit
reaches the outer header). Jump to both labels and assert the exact
`continue` edges; dropping the inner label now fails the test.

Update the Zig callable-flow comment: call_expression now wraps actuals in a
required `arguments:` field.

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

---------

Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 13:07:59 +03:00
Gergő Magyar
82347f0f07
fix(scope): resolve local imports by lexical ownership (#3532) 2026-10-10 08:13:55 +03:00
azizur100389
50aa4be3b2
fix(routes): prefer production handlers and preserve app.all (#3505)
Some checks failed
Trivy Image Scan / Trivy (gitnexus-cli) (push) Has been cancelled
Trivy Image Scan / Trivy (gitnexus-web) (push) Has been cancelled
Gitleaks / gitleaks (push) Has been cancelled
Publish / Classify release event (push) Has been cancelled
Scorecard / Scorecard analysis (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Publish / Build & Push RC Docker images (push) Has been cancelled
Publish / RC guard (marker + release-PR skip) (push) Has been cancelled
Publish / ci (push) Has been cancelled
Publish / Publish to npm (push) Has been cancelled
2026-10-08 23:53:55 +03:00
azizur100389
ff922c0a3b
fix(python): resolve calls through aliased package re-exports (#3504) 2026-10-08 13:51:20 +03:00
Parafee41
4ccba12004
fix(scope): keep nested declarations out of module bindings (#3502) 2026-10-08 11:05:41 +03:00
Gergő Magyar
16d7e9477b
fix(embeddings): reuse completed vectors after interrupted analyze (#3463) 2026-10-04 09:26:02 +01:00
articultur
668fac7635
fix(search): report partially missing FTS indexes in query results (#3448) 2026-10-03 07:36:27 +01:00
Gergő Magyar
f99dde8aa3
fix(mcp): discover positional SDK tool registrations (#3450)
Some checks are pending
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
2026-10-02 22:57:23 +00:00
azizur100389
a8f18f00b9
fix(swift): avoid false calls for injected closure properties (#3434) 2026-10-02 23:31:14 +01:00
Gergő Magyar
e42122a0f6
feat(go): index gin/echo routes and report them in impact (#3402) (#3417) 2026-10-01 10:22:45 +01:00
azizur100389
acb65b95b6
fix(fastapi): propagate package router mount prefixes (#3408)
Some checks failed
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Gitleaks / gitleaks (push) Has been cancelled
Publish / Classify release event (push) Has been cancelled
Scorecard / Scorecard analysis (push) Has been cancelled
Trivy Image Scan / Trivy (gitnexus-cli) (push) Has been cancelled
Trivy Image Scan / Trivy (gitnexus-web) (push) Has been cancelled
Publish / RC guard (marker + release-PR skip) (push) Has been cancelled
Publish / ci (push) Has been cancelled
Publish / Publish to npm (push) Has been cancelled
Publish / Build & Push RC Docker images (push) Has been cancelled
* fix(fastapi): carry package router mount prefixes to child routes

* fix(fastapi): address review feedback on nested router prefixes (#3408)

- Skip unprefixed includes in the parse-impl legacy loop so a bare
  include_router in another file no longer shadows the real prefix.
- Union exact-file prefixes with legacy long/short prefixes via a shared
  mergeMountPrefixes helper in both ingestion and the group extractor.
- Join the parent APIRouter(prefix=...) between the mount prefix and the
  child include prefix.
- Resolve the group layer over every repo path (empty files included) so
  absolute-import ambiguity matches ingestion.
- Memoize (file, prefix) frames so diamond-shaped include graphs stay
  linear; drop the stack.pop() non-null assertion.
- Accept extra keyword arguments and a trailing comma in unprefixed
  include_router calls without double-firing on prefix= calls.
- Document that pass-through is limited to a host named `router`.
- Bump parse-cache SCHEMA_BUMP to 123 for the new capture fields.

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

* fix(fastapi): seed prefix propagation from bare router mounts (#3408)

- A router mounted without a prefix now seeds traversal with an empty
  prefix (only when no prefixed mount targets the same file), so its own
  APIRouter(prefix=...) reaches unprefixed children on both surfaces.
- An all-empty chain records nothing and leaves the child on its legacy
  fallback.
- The bare-mount integration test no longer asserts that the test app's
  unprefixed mount is absent; it pins only that the real prefix survives.

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

* fix(fastapi): capture include prefixes after nested-call arguments (#3408)

- Let the Shape A/B and unprefixed include_router patterns step over one
  level of nested calls such as dependencies=[Depends(auth)], so a
  prefix= written after them is captured by the worker (the group
  layer's tree-sitter patterns already handled this shape).
- Replace the unit test that pinned the dropped prefix with one that
  pins the captured prefixes and the unprefixed Depends-only edge; add a
  group-layer parity test.
- Correct the diamond test comment to 2^39 root-to-leaf paths.

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

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 14:49:44 +01:00
Gergő Magyar
0bcddec8e6
fix(python): keep uncertain decorated receivers unresolved (#3405)
Some checks are pending
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
* fix(python): suppress uncertain decorated receivers

* fix(ci): keep uncertain Python receivers out of method arity

Unrecognized decorators now leave the receiver kind unproven, but the
first parameter is still the implicit receiver slot for ordinary bound
calls. Method extraction stopped stripping it, so decorated methods
reported one extra parameter and shifted capture arity metadata.

Share the uncertain-receiver classification between type-binding
synthesis and parameter extraction, then refresh the Python capture
golden and benchmark fingerprint for the intended capture change.

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

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 19:22:10 +00:00
EVA
f6e70016d6
fix(python): resolve mixin self calls to subtype implementations (#3390)
* fix(python): resolve missing mixin self members through subtypes

* fix: honor Python effective MRO and static mixin targets

* fix: bind Python subtype dispatch to receiver provenance

* fix(python): limit mixin fanout to instance receivers

* fix(python): capture call arity and invalidate stale parsed facts

* fix(python): count bound receivers by method context

Preserve static, free, nested and typed variadic parameters; test renamed target receivers without weakening incompatible-arity rejection. Regenerate capture goldens for receiver metadata and eight mixin fixtures. Record the deliberate missing_target coverage outcome: CI measured Python call drops 5->6 and total call drops 113->114; no shape or scaling threshold relaxed.

* test(python): require a call capture before checking unknown arity

* fix(python): bind subtype dispatch to receiver definition

* test(python): include conditional renamed-receiver target

* fix(python): prove positional mixin targets and report partial coverage

Preserve upstream notebook coordinate mapping and maintainer changes. Reject incompatible/implicit-class targets, retain proven targets across ambiguous alternatives, and report capped or unresolved coverage without confusing edge deduplication.

* fix(python): isolate subtype call proof and preserve lookup boundaries

---------

Co-authored-by: Eva <eva@100yen.org>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-09-28 09:42:56 +05:30
Gergő Magyar
51fb64c976
fix(swift): model Swift modules like the compiler (nested packages, Xcode targets, linear visibility) (#3387)
* fix(swift): discover nested Package.swift manifests for module grouping

A Swift monorepo laid out as Core/<pkg>/Package.swift has no root manifest
and no root Sources/, so every Swift file fell into one __default__ module.
The Swift resolver now walks the repo (bounded, skipping dot, ignored, and
Xcode bundle directories) and adds each nested package's targets, keyed by
repo-relative directory and ordered deepest-first so first-match grouping
picks the most specific target. Import resolution keeps the root view.

Refs #3355

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

* fix(swift): bound implicit IMPORTS edges by a total budget

Implicit same-module IMPORTS are n*(n-1) edges per module, and the graph
keeps every relationship in one Map capped by V8 at 2^24 entries. Emission
now fills a 4M-edge budget smallest module first and skips, with a warning,
any module that does not fit, so no module layout can crash analyze.

Refs #3355

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

* fix(swift): skip pairwise sibling passes for oversized modules

The target-siblings and sibling-type-bindings passes copy every file's
declarations into every other file of a module, so heap grows as n^2:
about 3.8 GB at 1,000 files with 15 defs each, about 15 GB at 2,000.
Modules over 1,000 files now skip both passes with a warning and resolve
through the global name fallback. GITNEXUS_SWIFT_MAX_MODULE_FILES changes
the ceiling.

Refs #3355

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

* test(swift): cover nested SwiftPM packages end to end

A fixture with three nested Package.swift manifests (two declaring a target
named Net) and no root manifest. Each target gets its own implicit IMPORTS,
none cross packages, and Config() resolves to the caller's own package.
The Swift capture golden and scope-capture fingerprint grow with the new
fixture corpus only.

Refs #3355

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

* fix(review): apply review findings

- Rebase nested target paths with the existing Zig path helpers, which also
  reject Windows drive paths and paths that resolve to the repo root (whose
  empty prefix would group every file into one target).
- Document GITNEXUS_SWIFT_MAX_MODULE_FILES in the README env table.
- State that skipped modules resolve through lower-confidence fallback edges,
  why nested-type fragments still run for oversized modules, and fix a stale
  loader name in the target-grouping header.

Refs #3355

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

* test(swift): cover every skipped Xcode bundle suffix in the package walk

Refs #3355

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

* fix(swift): model modules the way the compiler does

Replace the caps from the first round with representations that stay
linear, and derive module identity from the same sources the compiler uses.

- Same-module visibility is one File -> Module IMPORTS edge per file
  (reason `module-membership`) instead of an edge per ordered file pair.
  Incremental importer expansion treats files sharing a Module hub as
  importers of each other. The 4M edge budget is gone.
- Sibling declarations and type bindings live in one shared table per
  module in the namespace channel C# uses since #1871, instead of being
  copied into every file. The 1,000-file ceiling and
  GITNEXUS_SWIFT_MAX_MODULE_FILES are gone.
- Modules come from root and nested SwiftPM manifests (Sources, Source,
  src, srcs; plugins under Plugins; the newest Package@swift-X.Y.swift)
  and from Xcode native targets in project.pbxproj, including Xcode 16
  synchronized folders. Target paths are matched from the repo root, so
  a vendored copy of the same layout is no longer grouped into a root
  target (this reverses #2931's floating match).
- `import X` resolves to the modules named X instead of any folder named
  X. A name no module carries is external when every manifest and
  project was read completely.
- The global-name-fallback veto uses the same membership, so test
  targets, custom-path targets and Xcode targets have module identity.

Refs #3355

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

* fix(bench): move the Swift package bench to module grouping

The bench still imported the removed groupSwiftFilesBySpmTarget. Its
first-wins probe keeps its meaning under root-anchored grouping: the
clash file sits under Sources/Mod0, and the Sources/Mod1 further down its
path is a vendored copy.

Plugins are now non-importable modules under Plugins/, so parse_targets
counts importable source targets (still 3) and parse_binary_skipped also
checks that the plugin is recorded that way. Baseline values unchanged;
the notes say why the definitions moved.

Refs #3355

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

* fix(swift): match compiler module names, manifests, and target filters

- Module names follow the compiler's c99 mangling (`my-lib` imports as
  `my_lib`); Xcode targets use a literal PRODUCT_MODULE_NAME or
  PRODUCT_NAME when the project sets one.
- Package manifests are one-file modules, as SwiftPM compiles them. Files
  outside every target are one-file modules once every manifest and
  project was read; otherwise they keep the shared __default__ module.
- Xcode 16 synchronized-folder exceptions add a file to a target that
  does not list the folder, and remove it from one that does.
- SwiftPM `sources:` / `exclude:` narrow a target; a computed list marks
  the manifest unreadable.
- The default target folder is chosen once per package, as SwiftPM does,
  instead of per target.

Refs #3355

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

* Address PR review feedback (#3387)

- Inferred Swift folders keep SwiftPM's first predefined parent when a
  target name repeats (Sources before srcs).
- The pbxproj parser rejects a \U escape without four hex digits instead
  of decoding garbage, so the project reads as incomplete.
- Extension owners are stamped in every Xcode membership of a shared file.
- A plugin name never reaches a plugin: neither the fallback veto nor the
  folder-index fallback treats a known non-importable module as imported.
- A file an Xcode target compiles keeps that membership even when it also
  lies under a SwiftPM target directory.
- The workspace scan bounds the queue, not only the directories read.
- Integration tests require each module hub to exist before comparing.

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

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 06:24:35 +01:00
azizur100389
5fc518d2cb
fix(dart): resolve package imports by pubspec identity (#3369)
* fix(dart): resolve package imports by pubspec identity

* fix(dart): keep package-identity edges out of the cycle check

Pubspec identity edges invalidate importers when a manifest changes. They cannot form an init cycle, so the cycle query excludes them before the row cap. Discovery reads each manifest once, with a size bound, and resolution shares one package-URI parser with those edges.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3369)

- Reject package URIs with an empty library path so they do not emit identity edges
- Skip the pubspec permission test where chmod cannot deny reads
- Document that the Dart heap probe is not a uniqueTarget spelling

Note: pre-existing failure in test/unit/incremental-index-extension-dml-gate.test.ts not addressed by this PR.
Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(dart): list package directories through a no-follow descriptor

A directory replaced by a symlink between the parent listing and the next visit must not be traversed. The walk opens it with O_DIRECTORY|O_NOFOLLOW and lists that inode.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

* fix(mcp): keep the Dart identity reason out of MCP startup

The cycle query still excludes the same reason string. The constant now lives with the other non-initializing import reasons, so MCP startup does not load a language provider.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3369)

Open discovered pubspecs and child directories through the parent directory inode on Linux, so replacing that directory with a symlink cannot redirect the walk.

Note: pre-existing failure in test/unit/incremental-index-extension-dml-gate.test.ts (worker pool startup timeout) not addressed by this PR.
Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(dart): reject Windows junctions during pubspec walk

* Address PR review feedback (#3369)

Refuse pubspec discovery that cannot set O_NOFOLLOW, and verify macOS child opens against the pinned directory chain instead of reopening a mutable path.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(dart): bound live pubspec descriptors and close the macOS check-then-open

A deep directory chain held one descriptor per level until open failed with EMFILE, and macOS child opens statted the path before using it. Refuse the next directory at 64 live handles, and stat only the descriptor opened with O_NOFOLLOW.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(dart): open pubspecs non-blocking so a FIFO cannot hang discovery

A listed pubspec can be replaced by a FIFO before open. O_RDONLY alone waits inside open for a writer, so the file-type check never runs. O_NONBLOCK returns immediately and the walk rejects the non-regular file.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(dart): cap names read from each pubspec directory

readdir kept every entry before the visit budget could run, so one huge directory could allocate without bound. Read the listing one name at a time and fail closed past 100,000 entries.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(dart): reject a pubspec that grows while its descriptor is read

The size cap was taken from the stat before the read, so a file that grew in that window could be parsed from a short prefix. Re-stat the same descriptor afterward and fail closed when the size no longer matches the bytes captured.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ci): rebaseline the Dart scope-capture fingerprint for package-import fixtures

The benchmark hashes every dart-* fixture. The new package-import corpus adds six Dart files and 33 capture groups. Parking that directory restores the previous fingerprint, so this is corpus growth, not a capture change.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-26 13:21:09 +01:00
Gergő Magyar
e06c2dd13e
fix(impact): fail closed on id-less targets and follow ??/||/?: callable values (#3354) (#3373)
* fix(impact): fail closed on id-less targets and follow ??/||/?: callable values (#3354)

#3354 reports `impact` returning a byte-identical 1037/CRITICAL/`exact`
result for three unrelated targets, with only `target.name` differing.
The reporter's `target` had no `id` and no `filePath`, which the traversal
path always emits, and a synthetic reproduction of their monorepo (pnpm,
Cloudflare worker-configuration.d.ts in five packages, Hono, a Durable
Object) resolves every target correctly on main. So the identical result
was not reproduced. The repro did surface two real gaps and one hardening
point:

- `_runImpactBFS` now throws when the target has no node id. Every
  caller already catches, so impact reports `impactedCount: null,
  risk: UNKNOWN` instead of a normal-looking blast radius that cannot be
  about this symbol.
- Callable-value flow followed only a single designator on the RHS, so
  `const sweep = env.__sweep ?? runSweep; await sweep(env)` produced no
  flow and `scheduled` was missing as a caller of `runSweep`, while the
  result still claimed `epistemic: exact`. Each branch of `??`, `||`,
  `or`, and `?:` now flows into the binding (language-neutral: operator
  and field names, no language checks). Parse cache bumped 104 -> 112
  (105-111 are claimed by open PR #3326).
- A whitespace-only `target_uid` (strict adapters materialize omitted
  optional strings) is treated as omitted and falls back to the name.

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

* fix(mcp): treat a non-string uid as omitted instead of throwing (#3354)

Review follow-up on #3373. `query.uid?.trim()` called `.trim()` on a
client-supplied value, and the MCP envelope is not type-validated, so
`context({uid: 42})` threw a TypeError that `context()` does not catch.
Before #3373 the same input ended as a structured not_found.

A non-string uid now counts as omitted, which matches how
normalizeToolParams already treats a non-string `target_uid`. The impact
and trace not-found messages print a trimmed uid only when it is a
non-blank string, so a strict adapter sending " " sees the name it
searched for instead of `' '`.

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

* fix(ingestion): expand ??/?:/ternary branches through a provider hook (#3354)

Review follow-up on #3373. The shared value-alternatives rule keys on
tree-sitter field names, and several grammars spell the same construct
differently, so the expansion never fired for them:
- Kotlin `elvis_expression` has no fields.
- Swift uses `value`/`if_nil` and `if_true`/`if_false`.
- Dart uses `first`/`second`, and its conditional has no `condition` field.
- Python `a if c else b` has no fields.
The `'?:'` operator entry was dead, since no bundled grammar emits it.

Add an optional `valueAlternatives` hook to CallableFlowCaptureOptions,
consulted before the shared rule, and implement it in the Kotlin, Swift,
Dart, Python and Ruby providers. Shared code still names no language.

Ruby's statement-bodied `if`/`unless`/`elsif` also carries
`condition`/`consequence`/`alternative`, so the shared ternary rule dug
an identifier out of an arbitrary statement (`g = h; 0` flowed `h`) and
produced a wrong CALLS edge. The Ruby hook now keeps a multi-statement
branch as one opaque source, as before #3373.

Tests: new provider fixtures for Python, Kotlin, Swift, Dart and Ruby
(including a Ruby negative), and TS chain, parenthesized, callable-left
and `&&` negative cases. Captures goldens gain one entry each for the
new fixtures. SCHEMA_BUMP stays 112 (same unreleased PR).

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

* fix(ingestion): keep long ||/??/?: chains linear in callable-flow capture (#3354)

Expanding value-selecting sources into per-branch flows made long chains
super-linear and, past a few thousand operands, a stack overflow. A
generated `w === "k0" || w === "k1" || ...` keyword table took 6.7 s at
1000 operands and 48 s at 2000, and threw RangeError at 8000.

Two causes, both fixed without changing any emitted capture:

- valueAlternatives recursed once per operator level and spread the
  partial results at every level. It is now an explicit stack that
  writes to one output array. The left-to-right order, the paren
  unwrapping, and the provider-hook contract (`[node]` means opaque) are
  unchanged.
- Every alternative ran its visibility walks from its own leaf, which
  can be as deep as the chain is long, all the way to the root. Also,
  tree-sitter's `parent` re-descends from the root, so each step costs
  the node's depth. The walks now jump between "anchor" nodes, the only
  nodes any check can match: region ids and formal owners. The nearest
  anchor is memoized per node across the file, and parents come from a
  map recorded by the one DFS the synthesizer already does.

After the fix: 0.34 s / 0.23 s / 1.6 s at 1000 / 2000 / 8000 operands.
That is within about 1.2x of main without the expansion; what remains
is tree-sitter query time. Capture fingerprints are byte-identical
before and after the fix for all 16 scope-capture bench languages and
for the Python harness.

A `typescript-deep-chain` case added to the scope-capture bench guards
the scaling: 1.11-1.22 now, 7.5-8.0 before. A unit test pins a
10000-operand chain: it must still yield the seed for a callable
operand, the copy for a formal at the deepest leaf, and the invoke.

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

* fix(kotlin): see through braced if-branches in callable alternatives (#3354)

tree-sitter-kotlin wraps a braced branch as
`control_structure_body > statements > <expr>`, so for any non-empty
block kotlinValueAlternatives saw exactly one named child (`statements`)
and pushed the wrapper itself as the branch value. operandSyntax emits
nothing for a `statements` node, so
`val run = if (c) { ::f } else { ::g }; run()` produced no flow edges,
and the "multi-statement block stays opaque" guard could never fire.

Descend one level through `statements` and require exactly one
non-comment expression there. Empty blocks (`{}` has no named children),
multi-statement blocks, and `if` without `else` still return the whole
`if` as one opaque source. The doc comment now describes that.

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

* fix(ruby): skip only the multi-statement branch in callable alternatives (#3354)

rubyValueAlternatives returned `[node]` (opaque) as soon as one branch
held more than one statement. Two things went wrong because of that:

- At the top level, `run = if c then g = h; 0 else method(:f) end`
  dropped the single-statement `else`, so `f` got no flow edge.
- In an elsif chain, the outer `if` pushed the `elsif` node as a branch.
  The shared loop called the hook on it again, got `[elsif]` back, and
  used the whole elsif subtree as one source. So in
  `if a then method(:run_a) elsif b then g = h; 0 else method(:run_b) end`
  the `run_b` edge was lost.

The hook now walks the elsif chain itself and skips each multi-statement
branch, while every single-statement branch still becomes an
alternative. This cannot add a wrong edge: each emitted alternative is a
value the conditional really evaluates to, and nothing is taken from the
skipped branch. Before, that branch did not contribute a resolvable
value either. A whole conditional used as a source becomes a
qualified-name seed, which resolves to nothing when it has more than one
identifier leaf. With a single identifier leaf, it could even seed the
condition variable. When no branch is a single statement, the
conditional still stays one opaque source, as before.

Ruby captures golden: ruby-callable-alternatives/app.rb goes from 33 to
58 capture groups. 23 of them come from the new fixture functions
(checked against the old source). The other 2 are the new branch
alternatives, `statement_if -> run_sweep` and `elsif_chain -> run_b`.

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

* fix(ingestion): flow the right operand of && / and into callable bindings (#3354)

`a && b` / `a and b` yields `a` when it is falsy and `b` otherwise. A
falsy value is never a callable, so the right operand is the only one
that can be invoked later. The capture left `&&` unexpanded, and the
compound source became a qualified seed that resolves to nothing:
`const run = x && f; run()` gained no edge to `f` while impact claimed
`exact`. Python `x and f or g` reached only `g`.

The shared expansion now maps `&&` / `and` to the right branch only.
It recurses, so `x and f or g` reaches both `f` and `g`. Where `&&`
yields a boolean (Java, C#, Go, Rust, C, C++, PHP, Zig), the
destination cannot be invoked, so the flow never meets a call. A
before/after CALLS diff over all 83 lang-resolution fixtures that
contain `&&` or `and` shows exactly one new edge,
logicalAnd -> runAndRight. PHP and Ruby bind low-precedence `and`
looser than `=`, so `$g = $x and $y` never reaches this rule.

The Python golden digest changes only for the extended
python-callable-alternatives fixture.

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

* fix(ingestion): keep operator branches of a value-selecting source opaque (#3354)

The fan-out sent every branch of `??` / `||` / `or` / `?:` to
emitAssignmentFact on its own, including branches that compute a value.
`Handlers.fallback === run || fb` then emitted the comparison as a seed
whose qualified text sliced to receiver `Handlers`, member `run`, and
resolveBoundMemberCandidates minted a CALLS edge to `Handlers.run`. The
same happened for `this.state !== run ?? this.fallback`,
`this.state + run || this.fallback`, and Python
`self.state != run or self.fallback`. Before the fan-out, the whole
compound source was one opaque seed.

A branch that is a binary operator expression now contributes nothing.
The check uses the field vocabulary valueBranches already reads (a
`left`/`right` pair, or an `operator`/`operators`/`op` token after the
expression start), not grammar type names. Member accesses that field
their `.`/`->` as `operator` (Ruby `call`, C/C++ `field_expression`) also
field a member name through the list memberParts uses, now shared as
memberNameNode, so they stay designators. Unary `&f`/`*fp` lead with
their operator and stay designators. Call results were already dropped
by emitAssignmentFact, and lambdas and callable references are unchanged.

CALLS-edge diff over 87 lang-resolution fixtures (505 -> 501 edges):
only the four false edges above were removed, and none were added.

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

* test(ingestion): pin the LEFT operand of ?? / or / elvis per provider (#3354)

The Python, Kotlin, Swift and Dart "override ?? fn" cases put an
unresolvable parameter on the left, so a fan-out that kept only the
last operand still passed them. Each fixture now adds a callableLeft
case with a real function on the left and a parameter on the right
(`run_left or fallback`, `::runLeft ?: fallback`, `runLeft ?? fallback`),
and each language asserts `callableLeft → runLeft`.

Mutation check: dropping the left branch from the shared `??`/`||`/`or`
rule and making the four provider hooks return only their last operand
fails all four new tests, while the existing right-operand tests still
pass.

Capture goldens were regenerated with UPDATE_GOLDEN=1:
- python app.py: 35 -> 85 groups. 35 -> 75 is stale drift from
  8ad0d8db7, which added the comparison cases without regenerating.
  75 -> 85 is the new case: 2 declarations, 2 scopes, the variable,
  the `run()` reference, and 4 callable-flow captures (seed -> run_left,
  copy <- fallback, formal, invoke).
- swift App.swift: 25 -> 36 groups for the same new case, plus
  Swift's type-binding for the fallback parameter.

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

* test(bench): re-baseline scope-capture fingerprints for #3354 callable alternatives

This PR makes a callable chosen by a value-selecting source flow every
branch it can yield (??, ||, or, && right operand, ternary, statement
if, elvis). It also keeps an operator branch opaque. Both change
@callable-flow captures, and the PR adds *-callable-alternatives
regression fixtures that these benches glob.

To verify, the BASE (merge-base 233ca2849) and HEAD emitters were run
over the same HEAD fixture corpus plus the synthetic source. Every
added or removed match is an @callable-flow.* match on one of those
sources. The pre-existing corpus is byte-identical for all 16
scope-capture languages and for Python. Emitter delta, then corpus
growth:

- ruby:       +5/-0   app.rb (+1 file, +58 groups)
- swift:      +6/-3   App.swift (+1 file, +36 groups)
- dart:       +6/-3   app.dart (+1 file, +33 groups)
- kotlin:     +8/-2   App.kt (+1 file, +71 groups)
- typescript: +18/-14 4 files (+288 groups)
- python:     +11/-7  app.py (+1 file, +85 groups)

The removed matches are whole-expression seeds and copies that carried
a qualified name of the compound source. They are replaced by one
seed or copy per operand. Synthetic scaling counts are unchanged and
every scaling ratio stays under budget. Per-language notes are in
baselines.json under _rebaselined_3354_callable_alternatives.

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

* refactor(mcp): share one blank-uid check across impact, trace and symbol lookup (#3354)

The "trimmed uid, or omitted when blank/non-string" rule was inlined four
times, three of them trimming twice. nonBlankUid() owns it now; behaviour is
unchanged. The whitespace and empty target_uid tests collapse into one it.each.

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

* docs(cfg): correct the Kotlin and Python grammar-field notes (#3354)

The Kotlin CFG visitor claimed no control-flow node has fields; a parse of
the vendored grammar shows if_expression fields condition/consequence/
alternative (when/for/while/do/try/elvis are fieldless). The Python harvest
note listed conditional_expression's children like field names; the node is
fieldless and they are positional. Both notes were misleading reviewers of
the #3354 value-alternatives hooks. Comment-only.

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

* test(ingestion): skip optional-grammar suites in callable-alternatives providers test (#3354)

Kotlin, Swift and Dart grammars are optional installs. Guard their describe
blocks with isLanguageAvailable, as swift.test.ts and dart.test.ts do, so an
install without one of them skips instead of failing.

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

* test(ingestion): probe the Dart parser before enabling its providers suite (#3354)

isLanguageAvailable only proves the module loaded; tree-sitter-dart can still
fail on setLanguage. Probe loadParser/loadLanguage and skip on failure, the
same guard dart.test.ts uses.

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

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 21:43:24 +01:00
azizur100389
a578747455
fix(swift): resolve nested constructors in extensions (#3308)
* fix(swift): resolve nested constructors in extensions

* fix(swift): preserve qualified extension owners

* Address PR review feedback (#3308)

Recover qualified Swift extension owners through public / attribute prefixes, and stop last-dot-guessing when source text is present.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(swift): keep attribute text from stealing extension owners

Bound header recovery so @available messages cannot rekey a fragment, and still inject nested types when the Class scope has no bindings.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(swift): nest comments and keep the public extension fixture valid

Review follow-up: skip nested /* */ in the header scan, put the Inner.Entry decoy in a parsed file, and mark Outer/Container/Entry public so the live fixture is valid Swift.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(swift): recover Unicode identifiers as extension owners

The header regex was ASCII-only, so extension Café.Container keyed as Caf and dropped nested-type siblings. Match ID_Start/ID_Continue segments instead.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(swift): skip raw strings and decode UTF-8 scope columns

Header recovery treated #"..."# as an ordinary quote and sliced Tree-sitter byte columns as JS offsets, so a same-line Café prefix or a raw attribute message could steal or drop the extension owner.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-19 09:46:53 +01:00
Parafee41
795cf0e151
fix(swift): resolve inherited protocol extension calls (#3309)
* fix(swift): resolve inherited protocol extension calls

* fix(scope): gate inherited implicit receiver lookup

* fix(swift): resolve call result types by exact callee

* test(swift): align cache and local call expectations

* fix(scope): reconcile replay diagnostics

* fix(swift): preserve exact callable return types

* fix(swift): capture throwing async call results

* test(swift): refresh capture golden

* test(swift): refresh scope capture baseline

* fix(swift): require explicit callable returns

* fix(scope): preserve duplicate return metadata

* chore(scope): align index documentation

* Address PR review feedback (#3309)

Stamp only protocol/class-extension members (nested QN + SPM buckets),
arity-narrow implicit-this across MRO, and keep Swift type peeling out
of shared workspace-index via stripTypePreservingDecoration.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3309)

Stamp extension members even when the extension declares a nested type, keep inherited class members ahead of protocol-extension defaults, and report replay-only interface-dispatch fan-out drops.

Note: pre-existing failure in gitnexus tsc against an older gitnexus-shared dist not addressed by this PR.
Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3309)

Walk inherited implicit-this owners nearest-first so a nearer override wins, and tighten Swift owner-stamp tests.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-19 07:44:35 +01:00
azizur100389
3f5ca8cdb7
fix(storage): guard stale file-lock reclamation (#3234)
* fix(storage): guard stale file-lock reclamation

* fix(storage): close lock recovery failure paths

* Address PR review feedback (#3234)

- Flush the lock-child stderr diagnostic before process.exit
- Treat explicit NaN timeouts as the default ceiling
- Document non-retryable guard timeouts on the worker IPC contract

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(storage): stop mislabeling live lock waits as orphan recovery

A brief peer inspect must not attach guardPath or send operators to
RUNBOOK delete steps. Refuse lock-free embeddings sync, stop --watch
only on a true guard timeout, and drop an unreadable self-created
guard before failing closed.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

* Address PR review feedback (#3234)

- Verify lock/guard absence before degrading a denied main-lock create
- Launch the third contender from unlinkSync, not the dead rename path
- Document group-lock timeouts for unrecoverable guard leftovers

Co-authored-by: Cursor <cursoragent@cursor.com>

* style: prettier index-lock reclaim guard tests

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(storage): treat O_EXCL as the lock-file presence check

CodeQL flagged existsSync-then-wx on analyze.lock. Create with wx first and
only reclaim unreadable leftovers after grace, so a successor is never
unlinked from a lost race.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-12 11:47:12 +01:00
JaysonAlbert
e7141ab0c1
fix(schema): persist Spring constructor-to-bean injection edges (#3239)
Co-authored-by: Jayson Albert <momeijw@gamil.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-09-10 16:03:53 +01:00
Abhinav Pandey
2220f4d851
fix(resolution): label fallback guesses and preserve export visibility (#3190)
* fix(resolution): distinguish name guesses and preserve export visibility

* test(go): keep method enrichment fixture in one package

* fix(resolution): address split review edge cases and evidence reporting

* fix(exports): recognize imported and expression-local receivers

* fix(resolution): align export and target evidence with language scope

* fix(ingestion): preserve lexical import provenance through resolution

* test(ci): rebalance Windows shards from measured slow suites

* Address PR review feedback (#3190)

- Label constructor unique-name guesses as global-name-fallback and run language vetoes
- Tighten Go qualified, Rust crate::, and Ruby class-reopen fallback guards
- Ignore for-loop shadowed CommonJS receivers and exclude guesses from the resolved-call census
- Refresh FinalizeOutput and hook docs for lexical binding scopes

Co-authored-by: Cursor <cursoragent@cursor.com>

* Tighten review-feedback leftovers on fallback visibility.

Qualified Go calls still respect export and test-package rules, nested Rust src/ stays a module segment, and top-level conditional this.x is treated as CommonJS.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

* Address PR review feedback (#3190)

- Distinguish Swift package prefixes when comparing target modules
- Document lexical import binding and handledSites refusal marking
- Drop the stale ci-scope-parity workflow claim and prototype-safe export verdicts

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3190)

Supply caller source on Ruby visibility cases so they exercise the named allow branches instead of the missing-text bypass.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(resolution): label unique constructor types as name guesses

A workspace-unique class hit in findClassBindingInScope was treated as
an in-scope bind, so Go/JS constructor-form sites skipped the guess
label and the Go unexported veto.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(resolution): keep qualified constructors precise after unique-name split

Bare constructor unique-name hits stay guesses so Go can veto an
unexported type. A written qualifier is now carried as rawQualifiedName
so `new pkg.Foo()` and `models.Box[T]{}` can still recover the unique
class without that veto.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(bench): rebaseline Go/Java scope-capture fingerprints for constructor qualifiers

Generic Go composite literals and qualified Java `new pkg.Foo()` now
carry @reference.qualified-name on existing constructor matches.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(resolution): treat import-reached unique constructors as precise

C++ #include and Rust re-exports do not mint a lexical class binding.
A unique type in an imported file (or imported directory) is therefore
a real bind, not a name guess, so those CALLS edges stay import-resolved.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(resolution): require named or resolved imports for constructor precision

Bare Go Box[T]{} is not package-qualified, and a sibling-file import of a different name is not constructor visibility.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-10 11:38:19 +01:00
Navid EMAD
506432017f
fix(zig): model callable-value references, and stop reporting their absence as exact (#3219)
Some checks are pending
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
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
Scorecard / Scorecard analysis (push) Waiting to run
* fix(impact): stop reporting 'exact' over unmodelled callable-value references

A function named in VALUE position — `bridge.accessor(Element.getNamespaceUri,
null, .{})`, `{ onClick: handler }`, a comparator handed to a sort — is
registered somewhere rather than called. The registration is modelled (a
`value-ref` site becomes a USES edge; Kythe `ref` vs `ref/call`, Joern
METHOD_REF), but the invocation THROUGH the stored value is not: it happens
later via a struct field, a registry lookup or comptime reflection.

impact()/context() nonetheless reported such a target as `epistemic: 'exact'`.
For lightpanda-io/browser that meant the DOM `Element.namespaceURI` accessor
came back with two internal callers, LOW risk and a claim of completeness —
worse than no answer, because 'exact' tells the reader not to look further.
tools.ts defines 'lower-bound' as "the walk provably missed callers", which is
precisely this case.

computeEpistemicBoundary now probes for inbound USES edges stamped with the
value-ref reason and hedges when it finds any, contributing a boundary note and
a new `causes.callableValueReferences` (unit: distinct referrer symbols).

Read from the graph, not from index metadata: unlike a dropped receiver — which
leaves no edge to find and therefore needs a persisted summary — a value
reference IS in the graph. So the signal needs no re-index and no analyzer
change, and it works on indexes written before this commit.

The cause gets its own slot rather than joining `dispatchBoundary`: a value
referrer is neither an implementation nor an interface-level consumer, and the
two differ in what the reader should do about them — a dispatch boundary is
irreducible, a callable value usually becomes traceable once the provider
models the store/load that carries it.

Language-neutral: every provider that emits a `value-ref` capture participates.
The writer and the reader now share VALUE_REF_EDGE_REASON, because drift
between them would fail silently — the probe would match nothing and every
answer would go back to claiming certainty.

* fix(zig): emit `value-ref` for a callable named in value position

`mapReferenceKindToEdgeType` has handled the registration-vs-invocation case
since #2437, and TypeScript, JavaScript and C++ all emit `value-ref`. Zig
emitted zero — so a Zig function handed somewhere as a VALUE was absent from
the graph entirely.

That is not a corner: Zig's JS bridge is built out of this one shape,

    pub const namespaceURI = bridge.accessor(Element.getNamespaceUri, null, .{});

and `bridge.{accessor,function,indexed,…}` appears 2,047 times across 257 files
in lightpanda-io/browser — the project's whole JS<->Zig surface, none of it
reaching the graph. `impact` on `Element.getNamespaceUri`, which IS the DOM
`Element.namespaceURI` accessor, answered with its two in-file callers.

Three query rules, tagging `@reference.value-ref` on: a bare identifier in
argument position (`bridge.accessor(_tagName, …)`), a qualified one
(`bridge.accessor(Element.getNamespaceUri, …)`), and a const binding initialiser
(`pub const defaultHandler = onReset;`). Everything downstream already existed —
no new edge type, no schema change, no capture-machinery change, no baseline
edited.

One grammar detail is load-bearing: in tree-sitter-zig, call arguments are
DIRECT children of `call_expression` — there is no `arguments` node, only
builtins have one — so the callee has to be consumed explicitly by `function:`.
Without that binding the same rule also matches the callee of `foo(bar)` and
mints a USES edge shadowing the call's own CALLS edge. There is a test for it.

The rules are deliberately broad — `js.Bridge(Element)` and `register(count)`
match too — because the callable gate in the property-dispatch pass
(Function/Method/Constructor only) is the filter, the same design that keeps
TypeScript's `{ port: DEFAULT_PORT }` from registering anything. Measured on the
real corpus: all 3,169 emitted edges land on a Method (3,146) or Function (23).

Deliberately NOT modelled: the terminal invoke. The value reaches
`Accessor.init` -> a struct field -> `Factory.zig` reflection (`inline for`,
`@typeInfo`) -> `Caller.zig`'s `@call(.auto, func, args)` over `func: anytype`.
Resolving that needs comptime evaluation. The point is to stop dropping the
reference; the shortfall is now reported as `epistemic: "lower-bound"` by the
preceding commit instead of being papered over.

Measured on lightpanda-io/browser (698 .zig files), wiped index both times:
30,222 nodes / 71,070 edges -> 30,222 nodes / 74,229 edges. The delta is
entirely `USES` (3,169 after vs 10 before, and every one is a value-ref); CALLS,
ACCESSES, IMPORTS, HAS_METHOD, DEFINES and the rest are unchanged — purely
additive, no node invented.

The fixture joins the existing `zig-idioms` corpus rather than adding a new
one, because `bench/receiver-resolution` uses `test/fixtures/lang-resolution` as
its `--check` corpus. That gate, `scope-capture` (15 languages),
`zig-cross-file-resolution` and every other bench `--check` pass unchanged.

* fix(review): resolve qualified value references through their owner, and stop
hedging on registrations the analyzer already followed

Five findings from the review bot on #3219; four valid, all addressed.

1. QUALIFIED VALUE REFERENCES RESOLVED BY TAIL NAME (the serious one).
   `bridge.accessor(Element.getNamespaceUri, …)` was resolved with
   `findCallableBindingInScope(site.inScope, site.name, …)`, which never sees
   the receiver and gives LOCAL bindings precedence. Reproduced:

       const Element = @This();
       pub fn getThing(...)                 // main.getThing
       pub const JsApi = struct {
           fn getThing(...)                 // JsApi.getThing
           pub const thing = bridge.accessor(Element.getThing, null, .{});
       };

   emitted `USES JsApi → JsApi.getThing` — a WRONG edge, which is worse than
   the missing edge this PR set out to fix.

   `resolveValueRefTarget` now resolves a site carrying an explicit receiver
   through `findClassBindingInScope` → `findOwnedMember` (the machinery
   `receiver-bound-calls` already uses), gated on `CALL_TARGET_TYPES` because
   `findOwnedMember` also answers with fields. A bare site keeps the lexical
   walk, which is what an unqualified name means. When the owner cannot be
   resolved the site is DECLINED rather than falling back: declining costs a
   reference, falling back mints a confident edge to the wrong function, and
   the missing reference is now reported as `lower-bound` anyway.

   Cost on lightpanda-io/browser: value-ref edges 3,169 → 2,799 (−12%). Those
   370 were tail-name coincidences, not registrations — all 94 `Element.zig`
   `JsApi` entries survive, the cross-container case resolves and is correctly
   attributed (`IntersectionObserverEntry.JsApi` → `IntersectionObserverEntry.
   getTarget#0`, not the enclosing observer), and both acceptance probes are
   unchanged: `getNamespaceUri` 7/3/LOW/lower-bound, `getTagNameLower`
   31/10/HIGH/exact.

2. A FAILED PROBE READ AS "NO BOUNDARY". `.catch(() => [])` turned an
   unanswerable query into count 0 and no note, so `exact` could be published
   on the strength of a question that was never asked. It now returns `null`
   and emits a boundary note. This file's own `loadMeta` comment states the
   rule: a probe failing must never read as certainty.

3. NOT EVERY VALUE REFERENCE IS AN UNMODELLED INVOCATION. Where
   `emitPropertyDispatchCalls` sweep 2 synthesized the dispatch (reason
   `property-dispatch`), the walk did NOT provably miss the caller, so hedging
   was noise over an answer that was computed — and a signal that fires on
   every JS/TS hook table stops carrying information. A second probe excludes
   those targets. Zig never sets a property key, so the motivating case is
   untouched. The exclusion is symbol-level, not per-edge, because the graph
   does not record which registration produced which synthesized call; that
   residual is documented at the call site.

4. `LIMIT 50` SILENTLY UNDERSTATED THE COUNT. `rows.length` over a capped row
   set published a ceiling as the documented symbol count. Replaced with
   `COUNT(DISTINCT other.id)` — bounded work without a bounded answer, the
   shape `countByType` in the same file already uses.

5. THE TEST DID NOT PIN THE CALLER COUNT its own comment promised. Added
   `expect(result.impactedCount).toBe(2)`.

New regression tests: qualified references bind the written owner and not the
nearer lexical match; an unresolvable receiver emits nothing rather than a
wrong edge; a dispatch-modelled registration stays `exact`; a probe that cannot
run hedges instead of claiming certainty.

Known limitation, pinned by a test rather than left implicit: when a `@This()`
alias's NAME differs from its container's (`const Element = @This();` inside
`main.zig`), the receiver does not resolve and the reference is declined. The
provider has `rewriteZigThisAlias` for this, but it is applied to type nodes
and extending it to reference receivers would change existing CALL-site
behaviour. Lightpanda's `const Foo = @This();` in `Foo.zig` convention makes
the names coincide, which is why the corpus is unaffected.

* fix(review): bump the parse-cache schema and resolve module-owned value references

Review round 2 on #3219. Four inline items, all reproduced against the
worktree before deciding.

P1 — the new captures could stay INERT on a warm parse cache. Adding
`@reference.value-ref` rules to `ZIG_SCOPE_QUERY` changes
`ParsedFile.referenceSites`, which is a PARSE-TIME fact, but `SCHEMA_BUMP`
stayed at 93. A repo indexed before this branch and re-analyzed after it
replays the old, empty site list for every unchanged `.zig` file — `--force`
included, since shards are content-addressed — so no USES edge is emitted, the
boundary probe measures a real zero, and `impact` on a registered accessor goes
back to `epistemic: "exact"`. #3399 un-fixed on the incremental path most users
are on, with every cold-run test still green. DECISIONS D1-1's "zero changes to
… the schema" conflated the graph schema with the cache schema.

Bumped 93 -> 98, not 94: #3190 claims 94 and #3179 claims 94 through 97 in one
PR. `incremental-parse-cache.test.ts` re-pinned, with 93-97 added to the taken
list. Re-check against origin/main and open PRs immediately before merging.

P2 — a qualified value reference through a MODULE was declined with no hedge.
R1-2 resolves a written receiver with `findClassBindingInScope`, which requires
`isClassLike`; a namespace-only `@import` handle is not class-like, so

    const dom_utils = @import("dom_utils.zig");   // no `@This()` in that file
    pub const comparator = bridge.accessor(dom_utils.compare, null, .{});

resolved to nothing. That is not the conservative half of R1-2's trade-off: a
declined site emits NO edge, so there is nothing for the probe to read and
`impact` on `compare` reports `exact`. Silence, not a hedge — and the pass
comment claiming otherwise was wrong on this path.

`resolveValueRefTarget` now tries the second kind of owner a qualified name can
have. `findNamespaceValueRefTarget` reads the file's `namespace` import edges
for the handle and the target module's own `origin: 'local'` module-scope
bindings for the member — the same channel `receiver-bound-calls` Case 1
already trusts for `dom_utils.compare()`, with Case 1's three guards for Case
1's reasons: `isNamespaceNameShadowed`, local-origin bindings only, and two
distinct defs under one name resolve nothing. `CALL_TARGET_TYPES` gates module
owners exactly as it gates container owners. Language-neutral: it reads generic
namespace import edges, names no language.

Still declined, deliberately: a receiver this index knows under no name at all
(the `@This()`-alias case, owners outside the workspace). There the alternative
is a confident edge to a lexically-nearer function the source did not name.

P2 — `impact-callable-value-references.test.ts` was in neither vitest list.
It opens a real engine via `withTestLbugDB(poolAdapter: true)`, so TESTING.md
puts it in the `lbug-db` include list and the `default` exclude list; it was in
neither, so `default` also collected it into the parallel pool. Added next to
its `impact-epistemic-lower-bound` sibling in both arrays.

P3 — DECISIONS.md was stale against HEAD and embedded host paths. D2-3 still
described the `LIMIT 50` that R1-5 removed and D1-3 still described the
receiver-blind resolution that R1-2 replaced; both now carry explicit
"superseded by" pointers. The `~/code/...` and mise-node paths are replaced
with placeholders.

Two smaller corrections the new cause made necessary:

- `formatImpactResult`'s `lower-bound` header hard-coded "callers binding via
  DI / dynamic dispatch", which now contradicts the value-reference bullet
  printed directly under it. The bullets carry the cause; the header only
  states that the count is a floor.
- `tools.ts` said a `causes.callableValueReferences` of 0 means "nothing was
  missed". The dispatch exclusion is symbol-level, not edge-level, so a target
  with both a followed registration and an unfollowed escape also reads 0. The
  docs now say a 0 means "no unfollowed registration was proven".

Fixture: `src/webapi/dom_utils.zig` (namespace-only) plus three cases in
`Element.zig` — the module-qualified registration, a non-callable module member,
and a `u8` parameter shadowing the handle. The shadow case was verified to FAIL
with the guard disabled, so it is not passing for an unrelated reason.

Gates: `tsc --noEmit` clean, `npm run build` clean, prettier clean.
`resolvers/` 3,630 passed / 3 skipped; `unit/scope-resolution` 2,010 passed;
`impact-callable-value-references` 7 passed under `lbug-db`;
`incremental-parse-cache` 40 passed; eval formatters 104 passed. Bench --check
all PASS with no baseline edited: receiver-resolution, zig-cross-file-resolution,
scope-emission, callable-value-flow, scope-capture (15 languages), python-scope.

* fix(review): guard the class receiver against a shadowing binding, and pin the dispatchability partition

Review round 3 (`gitnexus-check` bot on `cf53bbaa`). Three findings, each
reproduced against the code before deciding.

R3-1 (Error, valid, REPRODUCED) — a CLASS receiver could resolve through a
shadowing value binding. `findClassBindingInScope` is a class-only walk: it
filters the scope chain by `isClassLike`, so it steps over a nearer binding that
is a value and keeps climbing — and past the chain entirely, into a
qualified-name fallback that answers with the unique workspace definition of the
name. A `u8` parameter named `Ticker`, in a file that neither declares nor
imports the `Ticker` container another file defines, therefore emitted
`register(Ticker.fire)` as a confident USES edge to that container's method.
That is exactly the wrong-edge failure R1-2 exists to prevent, arriving through
the class channel instead of the lexical one.

Fixed with `isOwnerNameShadowedBySomethingElse` — a sibling of
`isNamespaceNameShadowed` with one extra clause. The plain namespace guard could
NOT be reused: a container is often its own local declaration
(`fn make() { const Local = struct {…}; register(Local.go); }`), and reading that
binding as its own shadow suppresses precisely the resolutions this path exists
to make — the #2723 mistake, one channel over. So a scope that binds the name
answers immediately, and the answer is "not shadowed" only when one of that
scope's own bindings IS the def just resolved.

Both halves are pinned and both were verified to fail when the guard is
weakened: the parameter case fails with no guard at all, the local-container
case fails with the plain `isNamespaceNameShadowed`.

R3-2 (Warning; mechanism correct, unreachable today; fragility fixed instead) —
the dispatch exclusion could suppress an unfollowed registration. The bot is
right about the code: sweep 2 synthesizes CALLS only for a registration whose
site carried a `propertyKey`, while the exclusion zeroes the note on ANY inbound
`property-dispatch` CALLS edge. It is not reachable in the current rule set, and
the reason is measured rather than assumed: `@reference.value-ref` is emitted by
exactly three languages — JavaScript (2 rules), TypeScript (2), Zig (3) — every
JS/TS rule also captures `@reference.property-key` (both are object-literal
shapes) and no Zig rule does. A dispatchable registration is therefore always a
JS/TS one, an undispatchable one always a Zig one, and they cannot meet on one
symbol.

Rejected: splitting the edge `reason` into dispatchable / undispatchable. It is
the precise fix, but it is a graph-content change that churns whichever side
keeps the old literal — the Zig, TypeScript and probe suites all pin
'scope-resolution: value-ref' by hand as a drift canary — and it buys nothing
against a case no rule can produce.

What was actually wrong is that the exclusion's soundness rested on a
coincidence recorded nowhere, in files nobody reading `local-backend.ts` would
open. Fixed at both ends: the exclusion site now states the invariant, the three
facts it rests on and the two options for when it breaks; and
`value-ref-dispatchability.test.ts` fails the day it does — a JS/TS rule for a
bare callback argument, a Zig rule that grows a key, or a fourth language
emitting `value-ref` at all. Verified to fire (adding a property key to a Zig
value-ref rule fails the Zig case), and its rule splitter has its own guard test
so the suite cannot pass vacuously. `ZIG_SCOPE_QUERY` is exported for that test
only.

R3-3 (Nit, valid) — a test comment claimed the wrong epistemic result. The
`declines a qualified reference whose receiver cannot be resolved` case said the
shortfall shows up as `lower-bound`. It does not: with no edge there is no
evidence and the target stays `exact`. The pass docstring was corrected in round
2 and this comment was missed. It now says the decline costs the reference AND
the hedge, and why that is still the right trade.

Gates: tsc --noEmit clean, npm run build clean, prettier clean.
`test/integration/resolvers` 3,632 passed / 3 skipped (70 files);
`test/unit/scope-resolution` 2,015 passed (120 files);
`impact-callable-value-references` 7 passed under `lbug-db`. Bench --check:
receiver-resolution, zig-cross-file-resolution, scope-capture (15 languages),
scope-emission PASS with no baseline edited; callable-value-flow failed once on
its TIMING budget (2.006 > 1.9) with a byte-identical fingerprint, then passed
twice at 1.788 / 1.813 — machine load, not a regression.

* fix(review): let a file's own `@import` outrank the workspace-wide class fallback

Found by running the local preflight harness before pushing rather than after.
One of its five findings is a real hole in R2-2; the rest are documentation that
overclaimed.

R4-1 (valid, REPRODUCED) — the container channel preempted the file's own
`@import`. R2-2 added the module channel as a FALLBACK after
`findClassBindingInScope`, and that order is wrong: `findClassBindingInScope`
does not stop at the scope chain. When its `isClassLike` walk misses — and a
namespace handle binds a Module, so it always misses — it falls back to
`scopes.qualifiedNames`, a workspace-wide index, and answers with the unique def
of that name anywhere in the repo. A container named `dom_utils` in a file
`Element.zig` never imports therefore captured
`bridge.accessor(dom_utils.compare, …)`, binding
`Method:src/webapi/decoy.zig:dom_utils.compare#2` while the module channel that
would have answered correctly was never reached. R3-1's shadow guard cannot
catch it: the import binds at MODULE scope, which that guard treats as the floor.

Fixed by trying the module channel FIRST. An `@import` written in this file is
the strongest available statement about what the name means here and outranks a
global uniqueness guess; when the handle is not an import of this file the
channel answers nothing and the container path runs exactly as before. Pinned by
`decoy.zig` and a strengthened assertion on the existing module-owner test,
which fails on the old order.

R4-2 — the cause documentation named shapes nothing captures. `tools.ts`
illustrated `callableValueReferences` with "a callback argument", "a stored
function pointer" and `qsort(xs, n, sz, compareItems)`. Only Zig captures a call
argument or a const initialiser; JS/TS capture only object-literal property
values, and C has no value-ref rule, so the `qsort` example is counted in no
language. Both cause blocks now name the captured shapes and say that a bare
JS/TS callback argument is not among them, so a 0 does not rule it out.

R4-3 — the same block exempted itself from the re-index caveat this PR proves it
needs. "Read from the graph, so it needs no index-time metadata" is true of the
probe and false of the edges: an index built before a language emitted these
captures has none and reports 0 — the warm-cache failure R2-1 bumped
SCHEMA_BUMP for. It now says to re-analyze before reading a 0 as measured.

R4-4 — the dispatchability canary was narrower than its own promise. Its header
claimed it fails on "a fourth language emitting value-ref at all"; it reads
`languages/<dir>/query.ts`, so Vue — which owns no query and borrows
`emitTsScopeCaptures` / `emitJsScopeCaptures` — emits value-refs while the
assertion lists three languages, and a capture synthesized in code is invisible
to it. The case now asserts on query OWNERS, which is what it checks and is
sound because a delegating language inherits the rules it borrows; the header
states the synthesized-capture gap instead of letting a green tick imply it away.

R4-5 — the BARE docstring described a lexical walk that is not one.
`findCallableBindingInScope` applies the callable predicate WHILE walking, so a
nearer parameter or local is stepped over: the defect R3-1 fixed on the container
channel, unguarded here, pre-existing since #2437 and reachable in JS/TS. Out of
scope for #3399, so behaviour is unchanged and the sentence now says what the
walk does rather than implying a guarantee it does not give.

Gates: tsc --noEmit clean, npm run build clean, prettier clean.
`test/integration/resolvers` 3,632 passed / 3 skipped (70 files);
`test/unit/scope-resolution` 2,015 passed (120 files);
`impact-callable-value-references` 7 passed under `lbug-db`. Bench --check:
receiver-resolution, zig-cross-file-resolution, scope-capture (15 languages),
scope-emission, callable-value-flow all PASS with no baseline edited.

* fix(review): honour the hub opt-in for value references, so `hub.fn` means one thing

Review round 5 (`gitnexus-check` bot on `1c7c05ff`). One finding, valid and
reproduced before fixing.

`findNamespaceValueRefTarget` accepted only `ref.origin === 'local'`. R2-2
recorded that as deliberate — "the `namespaceExportsIncludeImportedNames` hub
opt-in is a provider decision this language-neutral pass does not make" — and
that reasoning was wrong twice over. Zig sets the flag
(`languages/zig/scope-resolver.ts:41`, measured on ghostty and tigerbeetle before
it landed), and the pass does have the provider in scope: `runScopeResolution`
takes one and already forwards several of its hooks to other passes.

The result was the exact asymmetry R2-2 argued against in its own first
paragraph. A Zig hub declares nothing — every name it publishes it imported — so
requiring a local declaration declines every member reached through one:

    // hub.zig
    pub const scale = @import("dom_utils.zig").scale;

    // Element.zig
    const hub = @import("hub.zig");
    pub fn callsThroughTheHub(v: u8) u8 { return hub.scale(v); }   // resolved
    pub const scaled = bridge.accessor(hub.scale, null, .{});      // declined

One name meaning two different things depending on whether a `(` follows it.

Fixed by forwarding `provider.namespaceExportsIncludeImportedNames` into the pass
and consulting the published channel when it is set — the same question
`receiver-bound-calls` Case 1 asks, through the same `lookupBindingsAt` read
`findExportedDefIncludingImportedNames` performs for the CALL form. The pass
still names no language; it asks the provider, which is the sanctioned hook.

Precedence is unchanged where it mattered: a locally declared member still wins
over a republished one, two distinct defs under one name still resolve nothing,
and `CALL_TARGET_TYPES` still gates the answer — pinned by `hub.DEFAULT_NS`, a
re-exported CONSTANT, which stays unregistered. Languages that do not opt in are
unaffected: the parameter defaults to `false`, and passing `false` was verified
to fail the new hub test, so it is load-bearing. The fixture republishes a member
(`scale`) that nothing else uses, so the hub assertion cannot be satisfied by an
edge another case emitted.

Gates: tsc --noEmit clean, npm run build clean, prettier clean.
`test/integration/resolvers` 3,634 passed / 3 skipped (70 files);
`test/unit/scope-resolution` 2,015 passed (120 files);
`impact-callable-value-references` 7 passed under `lbug-db`. Bench --check:
receiver-resolution, zig-cross-file-resolution, scope-capture (15 languages),
scope-emission, callable-value-flow all PASS with no baseline edited.

Also recorded in DECISIONS.md: `/autofix` returned "No successful autofix run"
because the `PR Autofix` run on `1c7c05ff` failed at `actions/upload-artifact`
with a 403 from GitHub's artifact storage, not because of anything in this
branch. This push triggers a fresh run.

* fix(review): inspect the module scope in the owner-shadow guard, and check the TSX suffix

The `gitnexus-check` pass on `bd6e577e` carried three findings of its own, and I
answered the later `1c7c05ff` pass without noticing them. Recording that as a
process failure too: bot reviews are per-head, and a later pass does not
necessarily repeat an earlier one's findings.

R6-1 (Error, valid, REPRODUCED) — the shadow guard stopped one rung short.
`isOwnerNameShadowedBySomethingElse` returned `false` on reaching the module
scope, justified as "a container declared there IS the binding, and the caller
already resolved it". That holds when the owner came from the scope chain and
fails when it came from the workspace-wide qualified-name fallback:

    // Gauge.zig — never imported by Element.zig
    const Gauge = @This();
    pub fn read(self: *Gauge) u8 { … }

    // Element.zig
    const Gauge = @import("dom_utils.zig").DEFAULT_NS;          // NOT a container
    pub const level = bridge.accessor(Gauge.read, null, .{});   // → Gauge.zig's read

`findClassBindingInScope` steps over the module-scope binding because it is not
class-like, answers from `scopes.qualifiedNames`, and the guard waved it through.

Worth recording: the first fixture attempt did NOT reproduce. A local
`const Gauge: u8 = 3;` also claims the workspace qualified name `Gauge`, leaving
two candidates, and the fallback refuses to guess between two — so the shape
defeated itself. Binding the name by IMPORT claims no qualified name, the
fallback stays unique, and it fires. A negative result on the first shape was not
evidence the finding was wrong.

Fixed by inspecting the module scope as the last rung instead of skipping it.
The identity exemption is what makes that safe where `isNamespaceNameShadowed`
cannot do it (#2723: a namespace import writes its own name into the module scope
and would read as its own shadow) — the binding that IS the owner exempts itself,
and only a binding to something else answers `true`. `lookupBindingsAt` is
consulted at that scope and only there, because an imported alias lives in the
finalized channel rather than in `scope.bindings`.

R6-2 — the hub re-export finding, already fixed in `5d8fe9d8`; the same defect
restated on the later head.

R6-3 (valid, fixed) — the dispatchability canary omitted the TSX suffix.
`getTsScopeQuery` analyzes a `.tsx` file with `TYPESCRIPT_SCOPE_QUERY +
TSX_JSX_QUERY_SUFFIX`, and the test read only the base, so a `value-ref` rule
added to the suffix would be emitted in TSX analysis with the canary green. The
suffix is now exported and concatenated into the check; verified load-bearing by
adding an unkeyed `jsx_expression` value-ref rule to it, which fails the
TypeScript case.

Gates: tsc --noEmit clean, npm run build clean, prettier clean.
`test/integration/resolvers` 3,635 passed / 3 skipped (70 files);
`test/unit/scope-resolution` 2,015 passed (120 files);
`impact-callable-value-references` 7 passed under `lbug-db`. Bench --check:
receiver-resolution, zig-cross-file-resolution, scope-capture (15 languages),
scope-emission, callable-value-flow all PASS with no baseline edited.

* perf(bench): gate callable-value reference resolution on linear scaling

`resolveValueRefTarget` (#3399) replaced one lexical walk with four
channels, and the last of them — a qualified receiver resolved through
`findClassBindingInScope` — falls back to `scopes.qualifiedNames`, a
WORKSPACE-WIDE index consulted once per site. Keyed that is O(1); scanned
it is O(files) per site, and a registration table that costs O(sites)
today costs O(sites x files) tomorrow. Nothing in the suite can see that:
the fixtures are single-file, and the corpus it actually matters on is
lightpanda-io/browser, where `bridge.{accessor,function,…}` appears 2,047
times across 257 files.

`bench/value-ref-resolution/measure.mjs` builds two synthetic Zig corpora
of identical shape 4x apart in file count, and times ONLY the per-site
resolution loop — extraction, `reconcileOwnership` and finalize are setup.
`linear_factor` is `(t_large/t_small)/(N_large/N_small)`: measured
1.00-1.09 across runs, with `us_per_site` flat at 1.4-1.5 between the arms.

Correctness comes first, because a timing gate alone is satisfied by a fast
wrong answer: exact site/resolved/declined counts per arm plus an
order-independent sha256 over every (site -> resolved target) pair. The
corpus exercises all four channels — CONTAINER, NAMESPACE, HUB and BARE —
and carries two DECLINE controls per module (a non-callable namespace
member, a non-callable bare argument), so a widened callable gate moves
`declined` instead of hiding inside the timing.

Verified load-bearing rather than assumed: making the qualified lookup scan
a workspace-sized collection leaves the fingerprint IDENTICAL and takes
`linear_factor` to 3.752 against a slack of 1.375 — the regression class
this exists for is exactly the one no correctness gate can see.

Zig is the corpus because it is the only language whose provider sets
`namespaceExportsIncludeImportedNames`, so it is the only one that can
exercise the hub channel at all; the pass itself names no language.

`resolveValueRefTarget` is exported for the bench. Timing
`emitPropertyDispatchCalls` instead would fold the signal into edge
emission, and re-implementing the channel order in the bench would pin the
bench's idea of the function rather than the function.

* feat(zig): index every build package in the repo, not only the root one

Zig already had workspace setup — `loadZigBuildConfig` has parsed
`build.zig.zon` `.path` deps, the root `build.zig`'s named modules and each
build module's own `addImport` table since #1432, threaded through
`ScopeResolver.loadResolutionConfig` exactly as tsconfig is for TypeScript.
What it did not have is the part `tsconfigFor` supplies: per-package scope.

`loadZigBuildConfig` reads `<repoRoot>/build.zig{,.zon}` and nothing else, so
a repo laying its packages out as `packages/<name>/build.zig` — no root build
files at all — got `null`, and EVERY bare `@import("<module>")` in it went
unresolved. Cross-file resolution silently degraded to relative imports.
Measured on a two-package probe before this change: `config = null`,
`@import("core")` from `packages/app/src/main.zig` → `null`.

`loadZigWorkspaceIndex` discovers packages the way `findTsconfigFiles`
discovers configs — one bounded breadth-first walk that skips the hardcoded
ignore set — and `zigPackageFor` is the `tsconfigFor` analogue: the nearest
enclosing package governs a file, deepest-first, with no fall-through to an
outer package. Fall-through is what makes a vendored dependency's
`@import("config")` resolve to the outer repo's `config` module, the same
failure `loadTsconfigIndex` documents for a package declaring no `baseUrl`.

`resolveZigImportInternal` is UNCHANGED — impact analysis puts it at HIGH risk
with 6 dependents, and it does not need to move: it is handed one package's
config, and which config it receives is the only difference. Its 48 existing
tests pass untouched. A nested package's paths are rebased to repo-relative at
load time (`.path = "../core"` → `packages/core`, which the root-relative
reading rejects outright as an escape); the ROOT package keeps its raw
spelling, which is what `parseZigBuildZon` promises and its tests pin, so a
single-package repo is byte-identical.

`loadImportConfigs` — which runs unconditionally for every repo, Zig or not —
keeps calling the root-only loader, so no non-Zig repo pays for the walk. That
is the split TypeScript already has between the cheap `loadTsconfigPaths` and
the repo-walking `loadTsconfigIndex`, which `loadResolutionConfig` reaches only
during a language pass.

The `zig-monorepo` fixture carries the discriminating case rather than only the
happy path: `tool` binds the alias `core` to its OWN `src/core.zig`, so a
repo-wide flattened module map — the shape a workspace index invites — would
point `measure` at `packages/core`, a confident edge into a package `tool` does
not depend on. That is worse than the unresolved import this fixes, and only
per-package scoping keeps them apart.

Tests: 7 unit cases pinning the index (including `loadZigBuildConfig` answering
`null` on the same fixture, side by side) and 3 integration cases pinning the
EDGES — cross-package CALLS from the module root AND from a non-root file, and
`tool` not crossing over. Verified load-bearing: restricting the index to the
root package fails all three.

Second consumer caught by the integration test and fixed with it:
`populateZigWorkspaceStaticGating` reads the same `resolutionConfig`, per file
because two files of that pass can belong to different packages.

Gates: build clean, `tsc --noEmit` clean, prettier clean, eslint 0 errors.
`test/integration/resolvers` 3,768 passed / 4 skipped, `test/unit/scope-resolution`
2,015 passed. Bench `--check` with NO baseline edited: `receiver-resolution`
(whose corpus is `test/fixtures/lang-resolution`, where the fixture lands),
`zig-cross-file-resolution`, `value-ref-resolution`, `scope-capture` (15
languages), `import-target`, `callable-value-flow`, `scope-emission`.

* perf(zig): dequeue the package walk by head index, not `shift()`

`findZigPackageDirs` pushes children while it drains the queue, which keeps
the array in a mode where `Array.prototype.shift()` memmoves the whole
remainder rather than taking V8's left-trimming fast path — so the walk is
quadratic in the frontier, bounded only by `ZIG_SCAN_MAX_DIRS` (20,000).

Measured at that bound rather than estimated from the move count: 53 ms at
fan-out 4 and 81 ms at fan-out 20, against 0.8 ms with a head index — 66-106x,
and paid before a single config is read.

FIFO order is unchanged, and the package ordering never depended on the walk
anyway: `loadZigWorkspaceIndex` sorts by (dir length desc, localeCompare).
Memory is unchanged too — the entries were already retained by the pushes;
`shift()` only dropped the head.

`findTsconfigFiles` and `loadNodeWorkspacePackages` carry the same walk with
the same bound and are equally affected. Deliberately NOT fixed here: they are
pre-existing, outside this PR's diff, and reach TypeScript/Node import
resolution, which nothing in this change set covers. Filed separately.

Reported by gitnexus-check on aea0ab06.

* chore: drop DECISIONS.md from the branch

Review feedback: the working log does not belong in the repository. The
reasoning it carried that is still load-bearing lives in the code comments
and in the PR description.

* perf(bench): gate value-ref resolution on scaling alone, not wall clock

A millisecond ceiling measures the runner. This repo has been bitten by that
twice already — bench/callable-value-flow's widening_overhead failed at 2.07
and 1.975 against a 1.9 budget on a shared runner while the code was correct,
both times on a sub-11ms measurement — so the arm is dropped rather than
loosened. `ms_budget` is gone from both arms and from `--check`; `min_ms`
and `us_per_site` are still reported, and nothing compares them to anything.

The remaining timing gate is the ratio, reshaped after
bench/parse-dispatch-rounds/baselines.json: `_what` / `_triage` notes, a
`_measured` block recording samples for context, and min-of-15 reps instead
of 7 (bench/import-target measured N=5 tripping its own budget about one run
in twenty, N=15 holding).

Budget 1.6 — 1.40x the measured maximum over 12 runs (0.907 .. 1.146), the
~1.5x headroom its siblings use on ratios.

Re-verified rather than re-quoted, and the previous note was wrong: replacing
QualifiedNameIndex.get with a full scan moves linear_factor from ~1.0 to 2.07,
not the ~3.7 recorded. Only 1 of the 17 value-ref sites per module reaches
that workspace-wide fallback. 1.6 sits clear of both ends. The note also
records the trap the first attempt fell into: patching gitnexus-shared/src
changes nothing, because the bench resolves the built package.

* fix(review): settle namespace precedence on the name, before the type gate

findNamespaceValueRefTarget's local lookup applied CALL_TARGET_TYPES while
selecting, so a target module declaring a NON-callable under the name answered
nothing and fell through to the published channel — binding a re-exported
callable under a name the module's own declaration owns. findExportedDef does
not do that: it returns any local def and lets its caller's type gate reject
it, so findExportedDefIncludingImportedNames never reaches the imported names
for a name the file declares. `x.f` and `x.f()` must not disagree about which
module owns f.

Not reachable through valid Zig today (a container cannot declare a name
twice, and Zig is the only provider setting namespaceExportsIncludeImportedNames),
which is why the regression test builds the indexes directly instead of adding
a fixture — there is no valid source to write. Its second case fails with the
guard removed.

Three smaller corrections from the same review:

- ZigBuildZonConfig.pathDeps promised the raw `.path` string; a nested package
  stores the normalized repo-relative value. The interface now documents both
  spellings and why either is safe to hand to normalizeZigDepPath. Comment
  only — no behaviour change.
- The 'single-package repo' compatibility test ran against zig-idioms, which
  declares libs/geo as a path dep and that directory has its own build.zig, so
  the walk finds two packages and the name was a claim rather than a check. It
  now runs against libs/geo itself and asserts the package list is exactly
  ['']; a second test pins the multi-package case, including that a file inside
  libs/geo is governed by that package and not by the root.
- The lower-bound header asserted 'some callers are not traced'.
  callableValueReferenceBoundaries hedges when its probe could not RUN and says
  whether the symbol is registered is unknown, so the header claimed an
  omission nothing established. It now says the count may be incomplete and
  names no cause; the per-cause bullets underneath carry that.

* feat(zig): bind a `@This()` alias to the container it names

`@This()` IS the enclosing container, and `const Self = @This();` is how most
Zig files say so. The container is minted under the FILE STEM and the alias
bound nothing class-like: a file-level alias mints no Const at all
(isZigFileThisAlias suppresses it so it cannot shadow the type for `w: *Widget`),
a container-level one mints a Variable every isClassLike walk steps over. So
`Self.member` resolved to nothing — not a wrong edge, no edge, and a caller
list missing it is the false confidence #3399 is about. This was the PR's
declared known limitation.

bindZigThisAliases binds the alias name to its container definition in
indexes.bindingAugmentations — the sanctioned post-finalize channel (I8),
consulted only AFTER a scope's own bindings, so it can never outrank a real
local declaration and can only answer where nothing answered before. Nothing
is replaced or removed. No query, capture or SCHEMA_BUMP change.

It runs inside populateZigRangeBindings, sharing that pass's parsed tree: a
pass of its own would re-parse every Zig file on a cold tree cache. Only
aliases declared directly in a container body or at file level are bound — the
same set collectZigThisAliases recognizes — because a function-local alias
belongs in that function's scope.

Measured cold-index before/after, same command, same build:

  tigerbeetle (246 .zig)  CALLS 17,066 -> 17,140 (+74), USES 437 -> 443,
                          MEMBER_OF 5,131 -> 5,143; every other edge type
                          unchanged, all 24 node-label counts identical
  mach (132 .zig)         CALLS 7,600 -> 7,615 (+15), MEMBER_OF +1, USES flat

The +74 matches a source census of 72 `Alias.member(` call sites in
tigerbeetle files whose alias differs from the stem. Spot-checked end to end:
src/aof.zig:636 writes `try AOF.init(io, output_path)` inside AOFType, and the
edge AOFType.merge -> AOFType.init#2 is present after and absent before. Node
totals move only through the derived layers (Community 524->527, Process
774->763) — no source symbol added or removed.

Scale of what was dropped: 73 of ghostty's 185 `@This()` files, 93 of
tigerbeetle's 94 and 8 of mach's 42 spell the alias differently from the stem,
carrying 302 `Alias.member` references between them, 96 of those calls.

Fixture zig-idioms/src/webapi/Widget.zig exercises both paths — a file-level
`Self` and a container-level `Me` in Metrics — through a call and a
registration. Five cases; three fail with the binding disabled, and the two
that do not are the controls: Element.zig's stem-spelled alias must keep
resolving byte-identically, and the alias name must not become resolvable from
another file.

* docs(review): stop claiming what the alias pass does not do

Two comments asserted things the adjacent code does not support.

The alias pass's call site said it ran before the payload walk "so a subject
spelled through the alias resolves here too". It does not: the payload walk
types subjects through findReceiverTypeBinding, which reads typeBindings plus
the namespace/workspace type channels and never bindingAugmentations, where
bindZigThisAliases writes. Measured on `for (Self.items) |it|` — `it` is bound
neither before nor after. The pass is in that loop for the tree and nothing
else, which is now what the comment says.

Nor is that a gap to close by also writing a typeBinding: no container name has
one, the file stem included, so a payload subject written `Type.member`
resolves for no spelling at all. Giving the alias an entry would make it behave
unlike the container it names. Recorded at the call site so the next reader
does not re-derive it.

The module-shadow test said Element.zig declares `const Gauge: u8 = 3;`. It
binds `Gauge` by IMPORT, and the distinction is the point of the fixture: a
local declaration would also claim the workspace qualified name `Gauge`,
leaving two candidates, and the fallback refuses to guess between two — so the
case the test exists for would never be reached. The comment now says which
binding it is and why the other shape would be self-defeating.

Comment-only: node and edge counts are byte-identical across a full re-index
(52,083 / 165,261 both sides).

* docs(zig): stop describing loadZigBuildConfig as root-only in the present tense

It takes a `packageDir` since this branch and reads
`path.join(repoRoot, packageDir, name)`; `loadZigWorkspaceIndex` is what
supplies it one, per package. Two comments still described the historical
root-only invocation as the function's current behaviour:

- the monorepo integration-test header, flagged by review;
- loadZigWorkspaceIndex's own docstring — the same sentence, in the function
  that calls it WITH a packageDir a few lines below, so fixing only the test
  copy would have left the worse of the two.

Both now attribute root-only reading to the CALL (no `packageDir`), which is
what the argument actually rests on: a monorepo has no root build files, so
that call answers null and every bare @import goes unresolved. The trailing
note about `loadImportConfigs` is reworded the same way — it calls the loader
for the root package alone; the loader is not root-bound.

Comment-only: node and edge counts byte-identical across a full re-index
(52,083 / 165,261 both sides), and detect-changes reports the two hunks
overlap no indexed symbol.

* fix(zig): a written namespace handle owns its own decline

`findNamespaceValueRefTarget` returning `undefined` conflated two different
answers: "no namespace import named this receiver" and "the module this file
named does not expose that member as a callable". Only the first should fall
through to the container channel. The second did too, and
`findClassBindingInScope`'s miss path answers from the WORKSPACE-wide
qualified-name index — so a same-named container in a file this one never
imported supplied the member the written module does not have.

The owner-shadow guard does not stop it, which is the part that is not obvious:
a plain `const utils = @import("utils.zig");` records a namespace IMPORT EDGE,
not a module-scope binding, so the guard finds nothing bound under the name and
reads the container as unshadowed. It catches `const Gauge = @import(x).MEMBER`
(a real binding) and misses the handle form.

Reproduced before fixing, not argued: `decoy.zig`'s `dom_utils` struct gains
`onlyOnDecoy`, a callable `dom_utils.zig` does not have, and `Element.zig`
registers `dom_utils.onlyOnDecoy`. That minted `JsApi -> onlyOnDecoy` — a
confident USES edge into a file `Element.zig` never imports, the wrong-edge
failure this PR exists to avoid, arriving through the container channel after
the namespace channel said no.

The channel now returns 'owned' for every outcome reached once the receiver is
established as this file's unshadowed namespace handle, and the caller declines
on it. A locally shadowed handle still falls through, because there the name
does not mean the import at that site and the container channel's guard is the
right decider.

Bench fingerprint and counts unchanged; no baseline edited.

* fix(zig): reject an absolute nested-package `.path` before rebasing it

The nested-package branch prefixes the package directory and THEN normalizes,
so an absolute `.path` stops looking absolute on the way: `packages/app/` plus
`/src` is `packages/app//src`, which is relative by inspection.
`normalizeZigDepPath` drops the empty segment and the dep lands on
`packages/app/src` — a directory that really exists — so a dependency pointing
outside the repository is fabricated into an in-repo resolution. The root
package was never affected: its prefix is empty, so the value reached the check
as written.

`isAbsoluteZigDepPath` is now asked of the value AS WRITTEN, before any
prefixing, and `normalizeZigDepPath` asks the same helper so the two cannot
drift. `..` is deliberately not handled there: `../core` escapes the package
but not the repo, and rebasing it is what the branch exists to do —
`normalizeZigDepPath` still rejects what escapes the ROOT afterwards.

Note for the reviewer: `path.posix.join(pkg, depPath)` does NOT fix this.
`join('packages/app/', '/dep')` is `packages/app/dep` — it strips the leading
slash too, producing the same fabricated path without rejecting anything.
Measured before writing the fix.

Regression test pins it through the real loader: `packages/app` declares
`.escapes = .{ .path = "/src" }`, and the test fails without the guard.

`impact normalizeZigDepPath` is HIGH (14 impacted, 4 direct, exact). The edit
is behaviour-preserving for that function — the same two conditions moved into
a named helper it calls — and its existing absolute-path suite, POSIX, Windows
drive and UNC spellings included, passes unchanged.

* docs(mcp): stop defining lower-bound as proof that callers were missed

The CLI header was corrected in round 8; the MCP tool contract still made the
assertion the CLI stopped making. `context` said lower-bound "means callers
exist that this view provably does not list" and `impact` said "the walk
provably missed callers" — but `callableValueReferenceBoundaries` also
publishes lower-bound when its probe could not RUN, and says in its own note
that whether the symbol is registered is unknown. A client following the
contract would read an unanswered question as evidence of an omission.

Both now define it as a FLOOR with two possible causes — the walk provably
missed callers, or a probe that would have established completeness could not
run — and point at `boundaries` for which. That keeps the common case exactly
as strong as it was; it only stops the contract asserting the one case it
cannot support. The `causes.callableValueReferences` bullet already documented
the probe-failure branch, so the headline was contradicting the body.

`local-backend.ts` quotes that definition to justify hedging; the quote is
updated to name which half it relies on.

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-09-09 20:12:17 +00:00
mengkaka
4154b63131
feat(indexing): add Objective-C semantic indexing support (#3179)
* docs: add Objective-C fork provider notes

* feat(objective-c): add deterministic provider and grammar

* feat(objective-c): finalize provider MVP

* fix(objective-c): harden provider integration

* fix(objective-c): normalize bare macro markers

* docs(objective-c): integrate provider documentation

* fix(objective-c): harden resolution and header classification

* fix(objective-c): complete provider follow-ups

* fix: address Objective-C review follow-ups

* chore: format Objective-C grammar sources

* fix(objective-c): harden review follow-ups

* Address PR review feedback (#3179)

Keep Objective-C chunking and macro recovery aligned with the grammar, and stop Community MEMBER_OF edges from leaking into symbol context.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address follow-up review on ObjC chunking and language fallback.

Keep preprocessor directive text from changing file-scope brace depth, group real ivar nodes, skip header modifiers, and restore Rakefile/Gemfile detection through getLanguageFromFilename.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Parse Objective-C headers with the objc grammar in embeddings.

ensureAndParse and structural extraction now use the same content classifier as ingest, including method snippets from .h files, so Protocol/Category/Class chunks are not re-parsed as C++.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3179)

Keep file-scope macro elision off C line splices and @interface/@protocol/@implementation bodies, and attach ivar attributes to the following instance variable when chunking.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(bench): rebaseline Objective-C CSV emit

* feat(objective-c): add workspace resolution and linear emit benches

Plain .h files are classified as C++, so the ObjC pass could not
resolve #import of those headers. Load a C/C#-style workspace once
per pass, and keep protocol-candidate USES linear.

Refs #3179

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3179)

- Compare LadybugDB labels() as a scalar when excluding Community MEMBER_OF edges.
- Walk superclass members, skip file-static C sibling defs, and ignore comments in ObjC header/macro scans.

Note: pre-existing failure in objective-c-provider integration (worker-pool ready timeout) not addressed by this PR.
Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3179)

Emit Objective-C declaration captures so compilation-unit siblings can share
header/implementation bindings, and keep class vs protocol visibility groups
distinct.

Note: pre-existing failure in worker-pool startup (GITNEXUS_WORKER_READY_TIMEOUT_MS) not addressed by this PR.
Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3179)

Emit every comma-separated property/ivar declarator, and count @interface
after a multiline block comment closes so in-declaration macros stay intact.

Note: pre-existing failure in worker-pool startup (GITNEXUS_WORKER_READY_TIMEOUT_MS) not addressed by this PR.
Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: ximengkai <ximengkai@soyoung.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 09:40:21 +00:00
Parafee41
1c1cbf111e
fix(zig): resolve cross-file static gates (#3185)
Some checks failed
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Gitleaks / gitleaks (push) Has been cancelled
Publish / Classify release event (push) Has been cancelled
Scorecard / Scorecard analysis (push) Has been cancelled
Trivy Image Scan / Trivy (gitnexus-cli) (push) Has been cancelled
Trivy Image Scan / Trivy (gitnexus-web) (push) Has been cancelled
Publish / Build & Push RC Docker images (push) Has been cancelled
Publish / RC guard (marker + release-PR skip) (push) Has been cancelled
Publish / ci (push) Has been cancelled
Publish / Publish to npm (push) Has been cancelled
* fix(zig): resolve cross-file static gates

* Fix Zig workspace import alias enrichment

* Handle extensionless Zig workspace imports

* Reuse Zig import resolution for static gates

* Document Zig workspace static gating

* Harden Zig workspace reference enrichment

* Benchmark Zig cross-file static gating

* Enforce linear Zig benchmark scaling

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-09-07 07:19:30 +01:00
Garrett Griffin
3c2b14aff5
feat(zig): mark CALLS edges inside comptime-false branches as staticGated (#3161)
* feat(zig): static-gating analysis module + fixture (ported from feat/zig-static-gated-edges-v2)

Squashes c6fe922c, 2f3c8e9e, fab088f4, 9b58af74, aef2ae83, 86b892ef, d5657861,
f3780b3a: file-local comptime bool constants, cross-file flag resolution via the
@import alias map, re-aliased const chains, == / != against known bools,
else / else-if branch awareness. The module is self-contained; the hooks that
call it land in the next commit.

* feat(zig): stamp static-gated call sites through the scope pipeline

Wires the ported gating module into the scope-resolution pipeline that
now emits every Zig CALLS edge (PR #1432), replacing the parse-worker /
call-processor hooks of the original branch, which targeted the legacy
DAG path the merged provider no longer uses.

Data flow, one new fact carried end to end:

  emitZigScopeCaptures     stamps `@reference.static-gated` on a call
                           capture whose anchor lies in a statically dead
                           range (body of `if (CONST_FALSE)`, else of
                           `if (CONST_TRUE)`), via the module's new
                           `collectZigStaticGatedRanges` (line/col ranges,
                           because a Capture keeps no node)
  scope-extractor          marker -> `ReferenceSite.staticGated`
  buildReference           -> `Reference.staticGated`
  references-to-edges,     -> `GraphRelationship.staticGated` on the
  free-call-fallback,         emitted CALLS edge (both emit paths, plus
  edges.ts (tryEmitEdge*)     the generic bridge)
  local-backend impact     -> `staticGated` on impact frontier edges

Same marker idiom as Go's `@reference.callee-position` / `embedded-pointer`:
zero-range, present or absent, so every ungated site's capture set is
byte-identical and no other language changes.

SCHEMA_BUMP 92 -> 93: parse-time captures changed.

Cross-file constants (`if (cfg.FOO)` with `cfg = @import("cfg.zig")`) are
NOT stamped yet: the module resolves them through `lookupBoolsForPath`, but
the capture emitter runs per file in the parse worker with only
`{ path, content }`, so it cannot see the sibling source. The two positive
cross-file cases in zig-static-gating.test.ts are `it.skip` with that
reason; the negative ones pass unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ciQ3MTjpQXkCoNntZR9zG

* feat(graph): add staticGated edge property

Adds an optional `staticGated?: boolean` field to `GraphRelationship`
that flags edges originating in code branches known at index time to
be unreachable in production — e.g. `if (CONST_FALSE)` blocks where
the condition reduces to a comptime-known `false`.

Schema + persistence wiring:

- `gitnexus-shared/src/graph/types.ts` — additive optional field on
  `GraphRelationship`; absent edges read identically to live ones.
- `gitnexus/src/core/lbug/schema.ts` — `staticGated BOOLEAN` column
  on the `CodeRelation` REL table.
- `gitnexus/src/core/lbug/csv-generator.ts` — appends a
  `staticGated` column (0/1) to the `relations.csv` written for
  bulk COPY ingest.
- `gitnexus/src/core/lbug/lbug-adapter.ts` — fallback per-row
  `MATCH ... CREATE` insert reads the optional column and threads
  it into the relationship properties.

No language has populated this field yet — the Zig hookup lands in
the next commit. Existing DBs need a re-index for the new column to
appear; existing readers are unchanged because the field is
optional and absent on every other language's edges.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
(cherry picked from commit c8cd5efe27a77a5d1b3f05e3f4e89069b5c6b19e)

* fix(zig): gate bare literal branches; AND the flag over deduplicated free-call sites

PR #3161 review, two findings:

1. `stampZigStaticGating` returned early when the file declared no boolean
   constants, but `collectZigStaticGatedRanges` also folds bare literals, so
   `if (false) { foo(); }` in a constant-free file went unstamped. The early
   return is gone; the range walk runs for every file.

2. `emitFreeCallFallback` deduplicates CALLS edges per (caller, callee) and
   wrote `staticGated` from whichever site it met first, so a callee reached
   from one live site and one dead site was gated or not by traversal order.
   Emission is now deferred to the end of each file's sites and the flag is
   the AND over every site that collapsed into the edge: one live site keeps
   the edge live. The other emit path keys its dedup on the site range and
   was not affected; `collapseByCallerTarget` in the generic bridge would
   have the same shape but no language that sets the marker opts into it.

Fixture + tests: `gated_bare_literal`, `live_and_gated_same_callee` (live
site first) and `gated_then_live_same_callee` (dead site first) in
zig-static-gating.test.ts. All 70 resolver suites (3,603 tests) pass with
the shared emitter change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ciQ3MTjpQXkCoNntZR9zG

* test(zig): move the SCHEMA_BUMP pin to 93; rebaseline emit fingerprints for the staticGated column

Three CI failures on f4954963, all consequences of this PR:

- test/unit/incremental-parse-cache.test.ts pins SCHEMA_BUMP so concurrent
  bumps cannot collide; 92 -> 93 for #3161 (parse-time call captures gain
  `@reference.static-gated`), 92 added to the taken list.
- bench/emit-persistence `measure.mjs --check` and `measure-streaming.mjs
  --check`: byte-identity fingerprints drift because every relationship row
  now ends in a `staticGated` cell. Regenerated with the inverse-operation
  evidence recorded under `_rebaselined_3161_static_gated_column` in both
  baseline files: stripping ONLY the new column from the emitted CSVs
  reproduces the prior fingerprints exactly (36 files, 3 rel_* files differ,
  33 byte-identical; 36,000 PDG rows each +2 bytes), so no row moved between
  pair files or reordered. Timing and retention gates passed throughout.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ciQ3MTjpQXkCoNntZR9zG

* docs(graph): state the CALLS contract on staticGated; say "provably unreachable at compile time"

Review on #3161 (magyargergo): the flag must not redefine what a CALLS
edge means. The field's doc now says so explicitly: CALLS still means
"there is a resolved call site from A to B", never "B is reachable from
A"; `staticGated` is additional, statically provable path-feasibility
metadata, an opt-in analysis layer that no core pass acts on. The edge is
emitted, persisted, traversed and counted exactly as before.

Wording: "unreachable in production" -> "provably unreachable from the
indexed source at compile time" on GraphRelationship, ReferenceSite and
Reference. The index has no production build configuration and should not
claim one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ciQ3MTjpQXkCoNntZR9zG

* feat(zig): surface staticGated on impact byDepth items; gate if-expressions and negated/parenthesized conditions

Addresses the tri-review on #3161.

- impact: the depth traversal already selected r.staticGated but dropped it
  when building the byDepth item. Forward it (present only when true) and
  document the field on the impact tool's byDepth contract. Traversal and
  ranking still do not act on it; that stays opt-in for consumers.
- zig-static-gating: walk `if_expression` (`const x = if (c) a() else b();`)
  in addition to `if_statement`. The expression form has no field names and
  no else_clause wrapper, so the arms are located positionally
  (`ifExpressionArms`). Labeled-block arms are covered.
- evalCond: `parenthesized_expression` is transparent, so `!(A and B)` and
  `((FLAG))` fold. Prefix `!` has no unary node in tree-sitter-zig; the
  header now says exactly which shapes fold instead of "simple negation".
- fixture + tests: nine new cases (negation x2, parentheses x2,
  if-expression then/else/labeled-block x5). Cross-file `@import` constants
  remain skipped and now cite the tracking issue #3162.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ciQ3MTjpQXkCoNntZR9zG

* refactor(zig): drop the unreachable cross-file gating builders; document the real wiring

gitnexus-check on b77cb4ed: `buildZigImportAliasMap` / `buildZigRawImportAliasMap`
and the per-call ancestor walk (`isCallStaticGated`, `ifBranchDirection`,
`nodesEqual`) had no caller anywhere. They were ported from the legacy
call-processor design; the scope-resolution provider stamps ranges via
`collectZigStaticGatedRanges` instead, and nothing populates the cross-file
seam yet (#3162). Remove them so the module exports only what runs.

The evaluator keeps `importAliases` + `lookupBoolsForPath` (the seam #3162
will fill); the header now says so explicitly and points at the actual
wire-up (`stampZigStaticGating` in languages/zig/captures.ts) instead of the
retired `configs/zig.ts` hook.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ciQ3MTjpQXkCoNntZR9zG

* refactor(zig): reuse descendantsOfType and tighten static-gated capture matching

Walk if-nodes through tree-sitter instead of a hand-rolled stack, return the original capture array when nothing is gated, and register the marker as a known sub-tag so it cannot be mistaken for an anchor.

Co-authored-by: Cursor <cursoragent@cursor.com>

* style(zig): wrap a long else-clause assignment to satisfy prettier

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 19:49:16 +01:00
Navid EMAD
932d937085
feat: add Zig language support (#1432)
* feat: add Zig language support

Adds a Zig LanguageProvider grounded in @tree-sitter-grammars/tree-sitter-zig 1.1.2.
The grammar's published peerOptional `tree-sitter@^0.22.1` is suppressed via an
npm `overrides` entry that aliases the peer to the bundled `tree-sitter@0.21.x`;
load-time smoke testing confirmed ABI compatibility.

v1 capabilities:
  - .zig file detection + Prism syntax mapping
  - Top-level + nested function_declaration as Function/Method
  - struct/enum/union (anonymous in the grammar — owner name resolved from the
    enclosing variable_declaration in class/field/method extractors)
  - container_field as struct/union fields and enum variants
  - top-level const/var as Variable nodes
  - free + member call_expression as @call edges
  - @import("./foo.zig") local-file resolution; std and external packages
    return empty (no ghost edges)
  - pub keyword detection for export checking
  - no heritage hooks (Zig has no inheritance; queries never emit @heritage.*)

Generic extractor changes (backward-compatible):
  - field-extractors/generic.ts and method-extractors/generic.ts: empty
    `bodyNodeTypes` falls back to the type declaration node itself as its own
    body container — needed because Zig's struct_declaration directly contains
    its container_field children. (The `extractOwnerName` hook this commit
    originally introduced now exists upstream; Zig just configures it.)

Out of scope (deferred):
  - usingnamespace, build.zig.zon package graph, comptime/anytype
  - scope-resolution hooks (emitScopeCaptures, interpretImport, …): Zig is
    classified `experimental` and uses the generic fallback resolution path
  - cross-package imports (std, deps)

Tests:
  - new fixture test/fixtures/sample-code/simple.zig
  - Zig describe block in tree-sitter-languages integration test
  - simple.zig added to parsing.test.ts fixture-existence list
  - Zig added to ingestion-utils detection unit test
  - Zig smoke case in parser-loader-abi.test.ts
  - Zig grammar registered in the grammar-literal validation gate

* feat(zig): integrate build.zig.zon resolution + Union label from PR 1096

Ports the additive pieces of grgisme's standalone Zig provider PR
(https://github.com/abhigyanpatwari/GitNexus/pull/1096) onto the rebased
provider:

  - build.zig.zon `.path` dependency resolution for bare-name
    @import("pkg") (parseZigBuildZon / loadZigBuildZon in
    language-config.ts, resolveZigImportInternal in import-resolvers/zig.ts,
    wired through ImportConfigs.zigBuildZon). `.url` deps and
    repo-escaping paths return null cleanly. 13 unit tests.
  - `union(enum)` containers now produce `Union` nodes (not Struct):
    'Union' added to ClassLikeNodeLabel + CLASS_LIKE_LABELS, the Zig query
    tags @definition.union, CONTAINER_TYPE_TO_LABEL maps union_declaration
    to 'Union'. The label was already plumbed graph-wide on main.
  - /^build$/ entry-point pattern (build.zig).
  - zig-basic lang-resolution fixture + resolvers integration test.

Adapted to current main while porting:

  - labelOverride relabels container-nested fns Function → Method
    (mirrors isKotlinClassMethod); the structure phase no longer derives
    Method from the legacy method-extraction path for plain
    @definition.function captures.
  - IMPORTS/CALLS edges require scope-resolution hooks
    (emitScopeCaptures / interpretImport) since the legacy DAG removal;
    Zig does not implement them yet, so the integration test documents
    that with a skipped import-edge case. The resolver itself is wired
    into the resolver factory and becomes live when the hooks land.

Not ported: named-bindings extractor (the legacy namedBindingExtractor
API no longer exists) and the bespoke field extractor (the generic
factory's extractOwnerName / empty-bodyNodeTypes hooks cover Zig).

Co-authored-by: Garrett Griffin-Morales <grgisme@gmail.com>

* feat(zig): scope-resolution hooks — IMPORTS and CALLS edges (Ring 3)

Implements the registry-primary scope-resolution path for Zig, the
prerequisite for cross-file edges since the legacy DAG removal. Adds the
standard per-language stack under languages/zig/:

  - query.ts: scope query (containers as Class scopes, blocks, functions),
    declarations (container anchors placed on the container node itself so
    the def lands in its own Class scope and the name binding auto-hoists
    to the parent — populateClassOwnedMembers needs the class-like def
    among the class scope's ownedDefs), @import statements (#eq?-gated
    builtin), parameter/constructor type bindings, and call/constructor
    reference sites. The grammar is required lazily (optionalDependency).
  - captures.ts: emitZigScopeCaptures — groups query matches, drops the
    plain-variable group for container/import bindings (their dedicated
    rules bind the name), and relabels container-nested fns
    @declaration.function → @declaration.method (labelOverride parity).
  - interpret.ts: namespace-kind imports (const x = @import("…")) and
    type bindings — self-parameter convention marks the receiver, Zig
    sigils (*, ?, [], error unions, const) stripped from type names while
    dotted qualifiers (mod.T) are preserved for Case-3 namespace-prefix
    receiver dispatch.
  - simple-hooks.ts: parameter bindings stay function-local (Go
    rationale), local-over-import merge precedence, bounds-check arity
    (always 'unknown' today — no synthesized arity metadata).
  - scope-resolver.ts: emit-side wiring; build.zig.zon threads through
    loadResolutionConfig into the same resolveZigImportInternal the legacy
    resolver config wraps. fieldFallbackOnMethodLookup off (statically
    typed). Registered in SCOPE_RESOLVERS.

The resolvers integration test un-skips the import-edge case and gains
CALLS assertions: free call (main → helper) and receiver-bound method
dispatch through a namespace-qualified constructor
(var p = pioneer.Pioneer{…}; p.tick() → main → tick).

* fix(zig): Union is class-like + missing-grammar warning (review pass)

Self-review findings on the Zig branch:

  - scope/walkers.ts `isClassLike` and finalize-algorithm's
    CALLABLE_OR_TYPE_LIKE did not include 'Union': a `union(enum)`
    container's methods got no ownerId from populateClassOwnedMembers, so
    method dispatch on union receivers silently dropped. Widened both
    sets; the zig-basic fixture gains a Tag method + a CALLS assertion
    (main → isEnergy) that fails without the widening (verified by
    reverting).
  - optional-grammars.ts now lists tree-sitter-zig with an npm `probe`
    (it is an optionalDependency, not vendored): users with .zig files
    and no prebuild get the standard one-line stderr warning instead of
    a silently degraded index.
  - Deduplicated the container-method predicate: `isZigContainerMethod`
    + ZIG_CONTAINER_TYPES now live once in languages/zig/captures.ts and
    feed both the provider labelOverride and the scope-capture relabel.
  - README language matrices: Zig row now claims Type Annotations,
    Constructor Inference, and Config (build.zig.zon) — all true since
    the scope-resolution hooks landed.

* fix(zig): anchor @declaration.variable to the binding identifier

`(variable_declaration (identifier) @declaration.name)` matched EVERY
identifier child of the node, so `const first = target;` also declared a
phantom local named `target` in the enclosing block. That phantom shadowed
the real function for later references (and starved callable-value-flow
seeds of a target). The `.` anchor pins the pattern to the first named
child — the bound name.

Regression test in resolvers/zig.test.ts pins the capture set.

* feat(zig): callable-value-flow captures + main's per-language conformance gates

Post-rebase catch-up: since this branch forked, main added three "every
registered language must appear here" tests. Each needs a Zig entry:

- callable-value-flow (#2522): Zig now emits `@callable-flow.*` facts via
  `synthesizeCallableFlowCaptures` (ZIG_CALLABLE_CAPTURE_OPTIONS in
  zig/captures.ts). tree-sitter-zig's `call_expression` carries arguments
  as direct children with no wrapper node, which the shared helper could
  not decompose, so this adds a language-neutral `extractCallArguments`
  hook (mirror of `extractFunctionParameters`; `undefined` = shared path).
  Zig joins the provider matrix as 'matrix' with a real assign→copy→
  argument→invoke case.
- external-import-conformance (#2953): `@import("std")` beside a decoy
  `src/std.zig` resolves to nothing; the decoy stays reachable via the
  relative spelling. Zig holds the property (no suffix fallback), so it is
  a case, not a KNOWN_GAPS entry.
- import-target-index-reuse contract (#2909): membership-probe-only
  fixture (minimumScans: 0, same shape as Rust).

* fix(zig): address gitnexus-check review findings

One commit per the bot's list so each item is easy to check off:

- parser-loader: Zig row gains `userSkippable: true`, so
  `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` (=1 or a list naming `zig`) disables it
  at analyze time like swift/dart/kotlin — as `optional-grammars.ts` already
  documented. Covered in parser-loader-skip-optional.test.ts.
- export-detection: `zigExportChecker` stops at the first declaration it
  reaches. A non-`pub` fn inside `pub const T = struct {…}` was reported
  exported because the walk continued up to the wrapper.
- import-resolvers/zig: a `.path = "."` dep normalizes to '' and no longer
  grows a leading slash (`/src/main.zig` could never match).
- language-config: build.zig.zon parsing strips `//` comments string-aware
  (a `//` inside `.url = "https://…"` survives) and matches braces while
  skipping string literals, so a commented-out `.path` cannot declare a dep
  and a `}` in a comment/string cannot truncate the block.
- method-extractors/configs/zig: the leading `self` receiver is excluded
  from `parameters` (Rust parity). Fixing that exposed a worse bug: the
  `parameters` node is a plain child of `function_declaration`, not a
  `parameters:` field, so `childForFieldName('parameters')` was always null
  and every Zig method had no parameters, no receiver and `isStatic: true`.
  One `zigParameterList` helper now feeds all three readers.
- variable-extractors/configs/zig: container (`struct`/`enum`/`union`) and
  `@import` bindings are skipped via the same predicate the scope captures
  use (`isZigContainerOrImportBinding`), instead of the comment merely
  claiming they were.
- tree-sitter-languages.test.ts: the "missing grammar" case now forces the
  absent-binding path through the loader's runtime opt-out on a fresh module
  instead of passing vacuously when the package is installed.

New: test/unit/zig-extractors.test.ts (exports, receiver/parameters,
variable guard); zig-import-resolver.test.ts gains the `.` dep, comment and
brace cases.

The `createFieldExtractor` heads-up needs no change: the added branch is
unreachable for every existing config (none has empty `bodyNodeTypes`).

* fix(zig): address second gitnexus-check review pass

- import-resolvers/zig: `..` above the repository root now returns null
  instead of aliasing a same-named root file (`../bar.zig` from `main.zig`
  is not `bar.zig`); the stale "extension is stripped and re-added" comment
  is corrected to what the code does.
- variable-extractors/configs/zig `extractType`: read the `type:` field only.
  The positional fallback returned the INITIALIZER of `const f = target;`
  as its type and gave up on compound annotations (`*Foo`, `?[]const u8`).
  The comment claiming 1.1.2 has no `type` field on variable_declaration
  was wrong (verified by AST dump) — and it is what led the review to
  suspect the callable-flow `extractAssignment` callback, which was
  already correct for `extern var f: T;`.
- receiver detection: only a FIRST parameter named `self` is the receiver.
  `emitZigScopeCaptures` tags first-position parameters
  (`@type-binding.first-parameter`), `interpretZigTypeBinding` requires the
  tag as well as the name, so `zigReceiverBinding` no longer turns
  `fn f(a: u32, self: T)` into an instance method.
- resolvers/zig.test.ts: both suites `describe.skipIf(!zigAvailable)`
  (Swift/Dart pattern) — the grammar is an optionalDependency.
- tree-sitter-languages.test.ts: the Zig parsing case gates on
  `isLanguageAvailable` instead of a catch-all `return`, so an installed
  grammar that fails to load fails the test; comment no longer calls Dart
  and Swift npm optionalDependencies (they are vendored).
- test/helpers/literal-collectors: `DIR_LANG` gains `zig`, so literals under
  `languages/zig/**` are validated against the Zig grammar alone rather than
  against every grammar.
- walkers.ts `isShapeLike` doc: Union IS included (via isClassLike, wired by
  Zig's union member container); Typedef remains the only deferred one.
- language-config `ZigBuildZonConfig.pathDeps` doc: values are the raw
  `.path` strings; the resolver normalizes.

Tests: zig-import-resolver (+1), zig-extractors (+3).

* fix(zig): address third gitnexus-check review pass

- language-config `parseZigBuildZon`: the `.dependencies = .{` header and
  the per-entry `.<name> = .{` headers are matched only OUTSIDE string
  literals (per-offset string mask + `matchZonHeader`). A `.name` or
  `.description` value spelling `.dependencies = .{ .fake = .{ .path = … } }`
  used to be taken as the block and returned the fake dep instead of the
  real top-level one.
- import-resolvers/zig: an absolute import (`@import("/foo.zig")`) returns
  null. The path walker skipped every empty component, so the leading `/`
  vanished and `/foo.zig` resolved as importer-relative `src/foo.zig` — an
  in-repo edge for an import Zig rejects as outside the module path.
- tree-sitter-languages.test.ts: the Zig parsing case gates on the PACKAGE
  being installed (`createRequire().resolve`, minus a deliberate
  `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` opt-out), not on `isLanguageAvailable`,
  which is false for absent AND for installed-but-broken bindings — so a
  load failure (ABI mismatch, bad export) now fails the test instead of
  skipping it, as the comment already claimed.
- walkers.ts `isShapeLike` doc: `Union` sits in `isClassLike` because that
  is the label set the ownership walkers consult, not because unions
  inherit — Zig has no inheritance and no heritage hooks. The previous
  wording ("inheritance-capable owner") said otherwise.
- Not re-fixed (already addressed in the second pass, findings carried
  over): "receiver = any parameter named self" — `interpretZigTypeBinding`
  only sources a first-position parameter as `self`; `zigReceiverBinding`'s
  doc now states that invariant. "`DIR_LANG` has no zig entry" — it does.
  Extended one level out: `BASENAME_LANGS` (`zig.ts`) and `PREFIX_LANGS`
  (`ZIG_`) map to the Zig grammar too, so extractor configs and the
  export-detection set are validated against Zig alone. That immediately
  caught a dead `childForFieldName('parameters')` in
  method-extractors/configs/zig `zigParameterList` (there is no such field;
  the named-child lookup was already the one doing the work) — removed.

Tests: zig-import-resolver (+2: absolute path, header inside a string);
both fail on the previous code.

* fix(zig): address fourth gitnexus-check review pass

- language-config: `parseZigBuildZon` only accepts a `.path` that is a
  DIRECT field of a dependency entry. Nested blocks inside the entry body
  are blanked (string-aware, offsets preserved) before the `.path` regex
  runs, and a match starting inside a string literal is rejected, so
  `.dep = .{ .url = "…", .meta = .{ .path = "x" } }` no longer becomes a
  path dep. Regression test in zig-import-resolver.test.ts (fails on the
  previous code).
- tree-sitter-languages test: the "grammar is absent" case now drives the
  loader's real `source.load()` catch branch — `node:module` is stood in
  with a `createRequire` whose require throws MODULE_NOT_FOUND for
  `@tree-sitter-grammars/tree-sitter-zig` and delegates everything else —
  instead of the `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` opt-out, which has its
  own test. It also asserts the opt-out flag is NOT set and that other
  grammars still load (non-fatal optional failure).

Not re-fixed:
- "Return type is read from the wrong tree-sitter field": tree-sitter-zig
  1.1.2 has NO `return_type` field on function_declaration — the type after
  `)` is the `type` field (AST dump: `builtin_type "i32" field=type`; the
  proposed `childForFieldName('return_type')` is null for every function).
  A pin test in zig-extractors.test.ts asserts both the grammar fact and
  that `returnType` is extracted (`void`, `!*Counter`).
- "`DIR_LANG` has no zig entry": it does (added in the first pass and
  answered again in the third); the finding is carried over unchanged.

* fix(zig): address fifth gitnexus-check review pass

- tree-sitter-languages test: the Zig "functions, structs, enums, and
  imports" case now asserts the `import.source` capture for
  `const std = @import("std");` (the fixture's only import), so a query
  change that drops Zig import matching fails it instead of passing
  unchanged.

Not re-fixed:
- "Return type is read from the wrong tree-sitter field": carried over from
  the fourth pass unchanged. tree-sitter-zig 1.1.2 has no `return_type`
  field on function_declaration; the return type IS the `type` field, and
  the pin test added in the fourth-pass commit
  (zig-extractors.test.ts, `childForFieldName('return_type')` is null,
  `returnType` = `void` / `!*Counter`) proves it.
- "`DIR_LANG` has no zig entry": carried over unchanged for the third time;
  the entry exists since the second-pass commit.

* feat(zig): export fn visibility, opaque containers, named test blocks, member ownership

Ports the parts of upstream PR #305 (closed, unmerged) that our Zig
provider lacked, plus two gaps found while porting.

- `export fn` / `export var` (C-ABI linkage, never `pub`) are exported;
  the pub/export predicate is now shared by the export checker and the
  method/variable extractors' visibility (`hasZigVisibilityKeyword`).
- `const H = opaque { … }` is a Struct-labelled container (it may own
  methods, never fields) in both the structure queries and the scope
  query; ZIG_CONTAINER_TYPES is the single source for the extractor
  configs.
- `test "name" { … }` blocks are Function nodes named by the string
  node WITH quotes, so `test "add"` beside `fn add` cannot merge onto
  Function:<file>:add; `test_declaration` joins FUNCTION_NODE_TYPES and
  the Zig method config names it in the enclosing-function walk, so
  calls inside a test attribute to the test. Anonymous `test {}` and
  decl-tests `test add {}` are scopes without a node (an empty-name hook
  result stops the walk instead of falling through to the identifier of
  the function under test).
- Empty container bodies (`struct {}`, `opaque {}`) no longer mint a
  nameless Property: tree-sitter-zig 1.1.2 recovers them as a
  container_field with a MISSING identifier; #not-eq? guards in both
  queries and the field extractor drop it.
- Owner walk (`findEnclosingClassInfo`): an anonymous container bound
  by the enclosing `variable_declaration` takes the binding identifier,
  same shape as the Go `type_spec` branch. Before this NO Zig member
  had an owner — zero HAS_METHOD / HAS_PROPERTY edges for Zig.

Not ported from #305, deliberately: `builtInNames` (a bare-name call-site
drop filter; `alloc`/`free`/`append`/`print` are the most common user
method names in Zig and `std.*` receivers are already external via the
import binding), `usingnamespace` (removed in Zig 0.15), `@cImport`,
`build.zig` ignore, and the web-app changes.

* test(zig): absent-grammar case owns GITNEXUS_SKIP_OPTIONAL_GRAMMARS

The loader parses the opt-out variable lazily once per module copy, so
under `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=zig` (or `all`) — a supported way
to run — the fresh loader took the opt-out branch and the "not the
opt-out path" assertion failed before the absent-binding path ran.

Clear the variable for the fresh module and restore it in `finally`,
instead of returning early: the branch stays exercised in every
environment. Verified: fails on the previous code under `=zig`, passes
with and without the variable now.

* fix(zig): declare the Union relation pairs — analyze aborted on any union

`gitnexus analyze` exited 1 on every Zig repository that declares a
`union` (including the zig-basic fixture itself): the member-ownership
commit made `union_declaration` a MEMBER_OWNER, so HAS_PROPERTY /
HAS_METHOD edges are emitted FROM a Union node, but `Union` was not in
LINKABLE_LABELS, so the schema's scope-bridge cross product never
generated a `FROM Union` pair and LadybugDB rejected the edge
(`labelPair: "Union|Property"`). Resolver tests stayed green because
they never write to the DB.

- `Union` joins LINKABLE_LABELS (also bridges `Tag{…}` constructor
  references); the three hand-written `→ Union` target pairs move to the
  generated half, per the STRUCTURAL_PAIR_DDL rule.
- structural-pair-coverage gains an optional-grammar corpus with
  zig-basic (`Union|Property`, `Union|Method` sentinels), skipped when
  the grammar is absent.
- Rust `union_item` note updated: the three gates it cited are widened.

Note for reviewers: the DDL fingerprint changes (#2808), so existing
indexes are rebuilt on next analyze.

* feat(zig): resolve path deps through the dep's build.zig and src/root.zig

The bare-name resolver only knew `src/<name>.zig` and `src/main.zig`.
`zig init` has written `src/root.zig` for libraries since 0.12, so the
default library layout never resolved. Now: the root the dep's own
build.zig declares (`b.addModule("<name>", .{ .root_source_file =
b.path("…") })`, name-matched module first), then src/root.zig,
src/<name>.zig, src/main.zig. `normalizeZigDepPath` is shared by the
loader and the resolver.

* feat(zig): Const/Variable defs, member imports, receiver typing, generic type constructors

Coverage gaps found by indexing idiomatic Zig against the branch:

- Const / Variable nodes: ZIG_QUERIES had no @definition.const /
  @definition.variable, so `pub const VERSION`, error sets and type
  aliases were absent and zigVariableConfig never ran. Rules are gated on
  the literal `const` / `var` keyword — tree-sitter-zig 1.1.2 parses
  statement assignments (`x = 5;`, `x += 1;`, `_ = expr;`) as keyword-
  less `variable_declaration`s, and the scope query minted a phantom
  local per assignment and one `_` per discard. Container and @import
  bindings are skipped via `shouldSkipDefinitionCapture`.
- Imports: `const X = @import("x.zig").X` (named / alias), `const X =
  ns.X` where `ns` is an @import binding of the file (promoted to a
  named import), and `pub usingnamespace @import(...)` (wildcard, with
  `expandsWildcardTo`). All three lost the file-level IMPORTS edge.
- Receiver typing: `var x: T = undefined` / decl literals `const x: T =
  .init()` (annotation), `var c = T.init()` / `mod.T.init()` (call
  return), `List(u8){}` (instantiation literal); `normalizeZigTypeName`
  drops the comptime argument list.
- Generic type constructors `fn List(comptime T: type) type { return
  struct {…}; }`: the returned container is a Struct/Union/Enum named
  after the fn, owns its members (HAS_METHOD / HAS_PROPERTY), binds in
  the module scope beside the Function def, and is emitted ahead of it
  so a named import binds the type.
- `export` vs `pub`: `visibility` is now `pub`-only (Zig-module fact);
  `isExported` keeps `pub|export` (visible outside the unit, as C's
  external linkage). `export fn` without `pub` is not reachable from
  other Zig files.
- Extractor configs share `zigContainerName`; ast-helpers' owner walk
  learns the type-constructor shape.

Tests: zig-idioms fixture (10 resolver cases), extractor/interpret unit
cases for each rule.

* fix(zig): address sixth gitnexus-check review pass

- Windows absolute `.path` deps (`C:\x`, `C:/x`) return null from
  `normalizeZigDepPath` like POSIX ones; a `/`-only check let them
  through as repo-relative.
- `parseZigBuildZon` accepts the `.dependencies = .{` header only at
  brace depth 1 (a direct field of the file's `.{`), so a same-named
  field nested in an earlier struct cannot hijack the block.
- `importsExecuteWhereWritten: false` on the provider: `@import` is
  compile-time name lookup (as C `#include`, Rust `use`); a body-level
  `@import` is no longer marked `runsOnlyWhenCalled`.
- Namespace imports record the MODULE as `importedName`
  (`zigModuleNameOf`: last path segment without `.zig`), per the shared
  contract; the local handle stays `localName`.
- Keyword-less `<ident> = @import(…)` (`_ = @import("x.zig")` in a test
  block) is a `side-effect` import: file edge, no binding. Only
  `const`/`var` declarations bind a name or feed alias promotion.
- `extractZigFunctionName` doc: an empty name is falsy, so the enclosing-
  function walk skips the test node and continues to the File; it does
  not "end" there.

Not re-fixed: "DIR_LANG has no zig entry" — carried over for the fourth
pass in a row; `test/helpers/literal-collectors.ts` has had `zig` in
`DIR_LANG` (line 91) and `BASENAME_LANGS` since the second-pass commit.

Regression tests: absolute-path spellings, nested `.dependencies`
decoy, namespace/side-effect interpretation, function-scoped `@import`
not deferred (all four fail on the previous source).

* fix(zig): address seventh gitnexus-check review pass

- The scope query's `@import` binding rules are keyword-gated (`"const"` /
  `"var"`, first-child anchored) like every other binding rule, and the
  keyword-less `<ident> = @import(…)` statement has its own
  `@import.side-effect` rule. Tree-sitter queries cannot express "no
  keyword child", so that rule also matches the keyword shapes and
  `emitZigScopeCaptures` drops those (they are the binding rules'
  matches). Behaviour is unchanged from the sixth-pass fix — the existing
  side-effect test covers it — the query text now carries the guard the
  finding asked for.

Not re-fixed: "DIR_LANG has no zig entry" — fifth pass in a row;
`test/helpers/literal-collectors.ts` has had `zig` in `DIR_LANG` since
the second-pass commit. Left for a human reviewer to close.

* fix(zig): resolve @import of the repo's own build.zig modules (F3)

Bare-name imports were resolved through build.zig.zon path deps only, so
the module a repo's ROOT build.zig declares for itself —
`b.addModule("lightpanda", .{ .root_source_file = b.path("src/lightpanda.zig") })`,
imported by name from 378/567 Lightpanda files — never produced an IMPORTS
edge, and nothing reached through `lp.X` resolved. A repo with a build.zig
but no build.zig.zon got no resolution config at all.

- language-config: `parseZigRootModules` (static scan of the root build.zig:
  `addModule("<name>", …root_source_file = b.path("<p>.zig")…)`, and
  `createModule`/`addModule` bindings named via `addImport("<name>", m)` or
  `.imports = &.{ .{ .name, .module = m } }`; generated / `.url` / computed
  modules are skipped) → `ZigBuildZonConfig.rootModules`.
- `loadZigBuildZon` → `loadZigBuildConfig`: reads the zon AND the root
  build.zig; null only when neither contributes.
- resolver: root modules are consulted before path deps; std/builtin/root
  still never resolve.
- fixtures: zig-idioms gains a Lightpanda-shaped root module (+ decoy
  `addOptions().createModule()`); new zig-rootmodule (build.zig, no zon).

Corpus (Lightpanda): IMPORTS 3014→3389 (378 edges to src/lightpanda.zig,
was 0), CALLS 13885→13989, ns.f() 79.3%→83.3%,
param.m() type=ns-qualified 43→46/417.

* fix(zig): import every @import in expression position; resolve @import("x").f()

Both query sets only saw `@import` as the value of a const/var or under
`usingnamespace`, so an @import in any other position produced no file
edge: Lightpanda's `pub const Interfaces = .{ @import("a.zig"), … }`
registration table (288 modules), call arguments
(`CounterEnum("size", @import("ArenaPool.zig").BucketSize)`), comparison
operands (`JsApi == @import("x.zig").JsApi`) and member-call receivers
(`try @import("dump.zig").root(...)`) — 417 of 3,401 in-repo import pairs
had no IMPORTS edge, and the 80 inline-receiver calls resolved 0 times.

Scope query: a catch-all `@import.inline` rule matches every `@import`
builtin; `emitZigScopeCaptures` drops the ones a binding rule (or the
keyword-less side-effect rule) already claimed (by string-node id) so a
bound import is never doubled, emits the rest as side-effect imports once
per distinct source per file, and binds a member-call receiver as a
namespace import whose local name is the builtin's own text — the
`@reference.receiver` text on that call is identical, so the shared
namespace-receiver lookup (Case 1) resolves the member in the imported
module.

ZIG_QUERIES: the three variable_declaration/usingnamespace-anchored
`@import` rules collapse into the same single builtin rule (the structure
phase only skips import matches; one match per builtin keeps
tree-sitter-languages' exact-capture assertion intact).

Lightpanda corpus (zig-corpus-check, before → after): IMPORTS 3014 → 3426,
in-repo pairs missing 417 → 5 (4 under a default-ignored `cache/` dir, 1 a
commented-out import the census regex counts), `@import(..).f()` 0/80 →
72/80 (the 8 left are `@import("root")` and non-import builtins the census
mislabels), CALLS 13885 → 13959; every other line unchanged.

* feat(zig): model file-structs — a file with top-level fields is a Struct named after the file

In Zig every file is a struct; one that declares top-level fields is an
instantiable type whose name is the file stem (`Page.zig` declares `Page`,
`@typeName` agrees), and its top-level `fn`s taking `self` are its methods.
Lightpanda spells 413 of 567 files this way and, before this, `page.getArena()`
on a `page: *Page` parameter resolved 23 of 993 times (2.3 %) — `impact` on
`Page.getArena` reported 0 callers for 159 call sites, and 2,395 top-level
fields were ownerless Property nodes.

Definition phase: `((source_file (container_field …)) @definition.struct)` +
the class extractor names it from the file path (`zigContainerName(source_file,
filePath)`); top-level fns/fields are owned through the new
`LanguageProvider.resolveFileTypeOwner` hook (consulted by
`findEnclosingClassInfo` when the walk reaches the tree root, and by the
method/field extractors' owner lookup) — ids become `Method:<file>:Page.getArena#0`
/ `Property:<file>:Page.session` with HAS_METHOD / HAS_PROPERTY edges.

Scope phase: `emitZigScopeCaptures` emits a Class scope over the whole file
(same range as the Module scope, nested under it — the pair `canParentScope`
already admits) plus a Struct def anchored on it; member NAME bindings are
hoisted back to the Module scope by `zigBindingScopeFor` so `Page.init()`
(namespace member) keeps working, while ownedDefs stay in the Class scope so
`populateClassOwnedMembers` stamps the owner. The file-level `const Page =
@This();` alias no longer mints a Const (it would shadow the Struct); `@This()`
aliases in type position (`self: *SigHandler` in Sighandler.zig, nested
`Self`) are rewritten to the container name so receivers resolve. A namespace
import of a `.zig` file gets a NAMED twin of the file stem so `x: *Page` in the
importer binds the type as well as the module.

Shared, additive: `resolveFileTypeOwner` hook; `filePath` threaded to
`ClassExtractionConfig.extractName` / `extractOwnerName`; nameless
`definition.struct` passes `getLabelFromCaptures` like `definition.class`
already did (extractor synthesizes the name).

Lightpanda corpus (zig-corpus-check): param receivers typed by a file import
23/993 → 993/993; `self.m()` 99.2 → 100 %; annotated locals 11.6 → 23.2 %;
CALLS 13,885 → 15,689; HAS_METHOD 1,477 → 8,003; ownerless Property 2,395 →
276; Function/Method 8,378/1,518 → 2,562/7,330; no row regressed.
Fixture `zig-filestruct` (Page/Session/Sighandler/util) + unit tests pin the
shape, the stem naming, the alias rewrite, the namespace twin and the
unchanged namespace-file behaviour.

* test(zig): expression-position import case sees the file-struct type twin

* fix(zig): stop reading member calls `x.f(arg)` as direct calls named `f`

tree-sitter-zig spells `field_expression` as `object:`/`member:`; the shared
callable-flow reader only knew `property`/`field`/`method`, so every Zig
member call collapsed to a DIRECT call named after the member and the
solver fanned each argument out to every same-named callable
(4,761 cap warnings on Lightpanda, `Global.deinit -> Global.deinit`
self-loops through `pub const release = deinit;`).

Shared (grammar-neutral, receiver-gated):
- `memberParts` also reads `member` (only C/C++ `offsetof_expression` and
  JS `class_body` expose that field, without a receiver field).
- A member call is a field-stored-callable invoke only when a MEMBER store
  (`o->run = handler`, `self.f = target`) or a declared callable-typed field
  is visible — a same-named plain binding no longer gates it.
- `direct-callee-name` requires a direct designator: `.init(x)`,
  `' '.join(x)`, `string.Join(x)` name no callee to seed by simple name.
Zig: formals are numbered without the leading `self`, so `r.run(target)`
joins `cb` and yields `Runner.run -> target`.

Goldens for python/csharp regenerated: the only drift is the dropped
`direct-callee-name` on `' '.join(...)`, `text.strip().ljust(...)`,
`string.Join(...)`.

Lightpanda: cap-warnings 4761 -> 2, cvf self-loops 10 -> 0,
CALLS 13885 -> 13857 (28 removed, all callable-value-flow: 10 self-loops,
17 same-name fan-out, 1 lost `on -> TypeErased.start`; +1 correct
`Arena.alloc -> allocator`).

* fix(zig): bind container field types so `self.field.m()` resolves (F5)

A container's field types were never bound on its Class scope: the scope
query's `container_field` rule captured only the name, and
`emitZigScopeCaptures` synthesized no `@type-binding.field` group. The
compound resolver reads member types from that scope
(`typeOfMemberOnClass` → `classScope.typeBindings.get(field)`), so
`self.session.name()`, `self.counter.incr()` — Lightpanda's dominant
cross-object call shape — resolved 9 of 2803 times (0.3 %).

- query.ts: capture `type: (_)? @declaration.field-type` on
  `container_field` (enum variants have none).
- captures.ts: per typed field, push a `@type-binding.field` group (name =
  field, type = the type text, `@This()` aliases rewritten to the container
  name like parameter types); anonymous inline containers are skipped. The
  binding lands on the container's Class scope — the file's Class scope for
  a file-struct — since `zigBindingScopeFor` hoists only declaration names.
- query.ts/captures.ts: `const page = self.page;` / `var s = self.session;`
  one-level field aliases become `@type-binding.alias` bindings whose "type"
  is the RHS path; the resolver's member-alias branch re-resolves it as a
  receiver chain. Import aliases (`const Counter = counter.Counter;`) are
  dropped — they are named imports.
- interpret.ts: `@type-binding.field` → 'annotation',
  `@type-binding.alias` → 'assignment-inferred' (an annotation on the same
  binding wins).

Corpus (Lightpanda, zig-corpus-check): self.field.m() 11/2803 (0.4 %) →
1433/2803 (51.1 %); ident.m() bound=local-field-access 29/1902 (1.5 %) →
307/1902 (16.1 %); chain.m() 146/5022 (2.9 %) → 642/5022 (12.8 %);
CALLS 15838 → 18066. self.m() / free f() / ns.f() unchanged.

Tests: unit (zig-extractors) — one @type-binding.field per typed field with
sigils stripped and aliases rewritten; the binding hosted on the container's
Class scope (file-struct: the file's Class scope, not Module) with the
written spelling as declaredSpelling; field aliases bound to the RHS path
and never for import aliases. Integration (zig-idioms `holder.zig`,
zig-filestruct `Page.zig`): `viaField → incr` ×2 into counter.zig,
`viaAlias → get/twice`, `sessionName → name` / `sessionLabel → name` into
Session.zig. All fail without the change. The optional-payload capture
`if (self.opt) |c| c.incr()` is not asserted (F6).

* fix(zig): `pub const X = @import(…)` at file scope republishes X (reexportsName)

Lightpanda's `lightpanda.zig` is one long list of `pub const Arena =
@import("Arena.zig");`, and most files name their types through it (`const
lp = @import("lightpanda"); const Arena = lp.Arena;`, `arena: *lp.Arena`).
The scope side treated those bindings as plain imports of the hub file, so
the hub never published the names it re-exports and a third file's `const
Arena = lp.Arena;` (promoted to a named import of `Arena` from the hub) found
nothing.

`emitZigScopeCaptures` now marks named/alias import groups whose declaration
is a file-level `pub const` — the `@import(...).X` form, the alias promotion
`pub const Bar = ns.Bar`, and the file-struct type twin of `pub const Arena =
@import("Arena.zig")` — and `interpretZigImport` sets the shared contract's
`reexportsName: true` on them (the Python `__init__.py` shape, consumed by
`buildReexportClosures`). Private and fn-local bindings stay unflagged.

Not covered here: a receiver ANNOTATED with the dotted hub path (`arena:
*lp.Arena`) — Case 3 of the receiver-bound pass looks the member up with
`findExportedDef`, which only sees locally declared names; following
re-exports there is a shared change left for a follow-up.

* fix(zig): type receivers through `const X = <type expr>;` aliases (F7)

`const LocalAlias = Local;`, `const T2 = Thing;` (alias of an alias/import)
and `const B = util.List(u8);` (an INSTANTIATED generic type constructor)
were plain `@declaration.variable` bindings, so `LocalAlias.mk()`,
`var l = LocalAlias.mk(); l.go()`, `T2.make()`, `B.init()`, `B{}` and
`var x: B` all typed nothing (review repro r3-flow b1..b9; Lightpanda:
`pub const Proto = HtmlElement;` x68, `const Allocator = std.mem.Allocator`
x104, `pub const KeyIterator = GenericIterator(...)`, fn-local
`const R = ...(...)`).

Model: a `@type-binding.alias` binding of the alias NAME to the value's type
text — Rust's `let x = y` / JS's `const B = Foo`, source
'assignment-inferred' — NOT a TypeAlias def. Reasons: (1) the shared
machinery already chains typeBindings (`followChainedRef` in the extractor,
`followChainPostFinalize` after propagation), so `var l = LocalAlias.mk()`
and `var x: B` reach the target through the alias with no new shared code;
(2) nothing shared follows a `TypeAlias` def to its target — `isShapeLike`
only makes the alias itself a member owner (TS object-type aliases) — so a
relabel would have needed language-named shared code; (3) graph node ids
are UNCHANGED: every alias stays `Const:<file>:X`. `normalizeZigTypeName`
already drops the comptime arguments, so `util.List(u8)` binds `util.List`
and resolves through the namespace import (Case 3). The identifier /
member shapes also take `var` (`var cur = orig; cur.go()` — the cursor
idiom, same binding as Rust's `let x = y`).

Heuristic, stated as such: a CALL value is kept only when the callee's last
identifier is TitleCase (Zig's naming convention for types), because the
grammar cannot tell `util.List(u8)` from `util.makeThing()` and the latter
belongs to the call-return rules; the call-return group is dropped for the
same TitleCase shape so the two never race on match order. Import bindings
(`const Stack = @import("x.zig").Stack`), promoted namespace-member aliases,
enum/decl literals (`.foo`) and the `type:` annotation of
`var b: T = undefined;` are excluded.

Not done: the two-hop `pub const bridge = js.Bridge(T); bridge.accessor()`
chain. `js.Bridge` is a Function that RETURNS `bridge.Builder(T)` (a call,
not a container), so the alias binds `js.Bridge`, Case 3 finds a Function
with no members in js.zig, and Case 3b is skipped for a namespace head.
Following that hop needs a namespace-member return-type route in shared
code (or a Zig `resolveQualifiedReceiverMember` hook that re-implements
member lookup without the model); left for a follow-up.

Corpus (Lightpanda, `harness/zig-corpus-check.mjs`): CALLS 15838 -> 16051
(+213, 0 removed), `ident.m() bound=local-alias` 6/63 -> 36/63,
`local-call` 159 -> 176, `module/unknown` 110 -> 116, `local-other`
271 -> 273; `self.m()`, `free f()`, `ns.f()` unchanged or up.

* fix(zig): one alias rule set — F7's alias rules subsume F5's field-access alias rules

* fix(zig): type locals through try/catch/orelse, return types and payload captures

F6 of the gitnexus-check review. Three gaps in the value flow that types a
local receiver, all measured on Lightpanda:

1. `@type-binding.call-return` needed the `call_expression` as the DIRECT
   value child, so `const p = try Page.init(…)` (2,551 sites), `… catch
   return` (410) and `… orelse return` typed nothing. The rule is now one
   keyword-gated declaration match; `emitZigScopeCaptures` unwraps `try`,
   `catch`, `orelse` and parentheses (`zigUnwrapValue`) and decides what the
   value types (`zigCallReturnTypeOf`): a module-level receiver still names
   the type (`Counter.init()` → Counter, Rust `Foo::new()`); a free call
   binds the callee name (`makeThing`); a member call on a fn-LOCAL receiver
   (parameter / local / payload — Zig forbids shadowing, so "declared in the
   fn" is exact) binds the compound `node.asElement()` the shared resolver
   walks to the method's return type — instead of typing `el` as `Node`. A
   TitleCase callee (`List(u8)`) is a type constructor and binds nothing.

2. No `@type-binding.return` existed. `fn make() !*Thing` now binds
   `make ↦ Thing` in the enclosing scope (Module for free fns, the container's
   Class scope for methods, where the compound resolver reads it). Builtins,
   `type`, `@TypeOf(…)` and comptime type parameters (`?*T`) bind nothing;
   `@This()` / `Self` returns name the container. `normalizeZigTypeName` now
   strips the error union BEFORE the payload's sigils, so
   `Allocator.Error!*Page` → `Page` (it used to leave `*Page`).

3. Payload captures had no binding at all. `populateZigRangeBindings`
   (registered as `populateRangeBindings`) types `for (items) |it| / |*it|`,
   `for (items, 0..) |it, i|`, `if (opt) |v|`, `if (call()) |v|`,
   `while (it.next()) |x|` from the SUBJECT's written type minus one layer
   (`[]T` element, `?T` payload) — declining when the layer is not visible
   (`ArrayList(T)`) — and the same projection for `const t = items[i]` /
   `opt.?` / `ptr.*`. `catch |err|` and `switch` prongs are skipped.

Corpus (Lightpanda, gate before → after): CALLS 15838 → 17853;
`ident.m() bound=local-try/catch/orelse` 11/1298 → 584/1298;
`local-call` 159/1282 → 398/1282; `payload` 82/1085 → 233/1085;
`other-recv:call_expression` 5/991 → 457/991; `local-other` 271 → 364;
`self.m()` 100 %, `free f()` 97.5 %, `ns.f()` 83.4 % unchanged; nothing down.

Not covered: `const t = ns.f()` (a namespace fn's return type across files —
Case 3 has no path from a namespace head to a callable's return binding),
and expression receivers (`items[i].run()`, `o.?.run()`).

* fix(zig): resolve leftover fixture merge markers (Page.zig)

* fix(zig): reconcile F6 value inference with F7 aliases and F5 field bindings

- A fn-local TitleCase receiver (`const R = generic.List(u8); var l =
  R.init();`) is a type alias (F7), not a value local: `R.init()` names the
  type `R` like `Counter.init()` does at module level, so `l` chains
  R → util.List → push. F6's local-receiver rule now excludes TitleCase heads.
- The F6 unit helper only collects the value-inferred / return kinds it
  owns; F5 field and F7 alias bindings for the same names are asserted in
  their own suites.

* fix(zig): give function-local and anonymous containers an identity (F8)

`const R = struct {…}` declared inside a fn (Lightpanda's reflection.zig
has ~20, one per builder) all collapsed onto one `Struct:<file>:R` with one
`R.get`; anonymous containers (`std.sort.pdq(…, struct { fn lessThan … }
.lessThan)`, `const byte_size = struct { fn it … }.it;`, `?struct { min,
max }` field types) had no identity at all, so their fns were OWNERLESS
Methods (`Method:<file>:lessThan#3`) that collided across a file.

`zigContainerName` now yields the graph IDENTITY on both phases:
  - function-local named: `<enclosing callable>$<name>` — `Reflect.string$R`
    (Java local-class `$` chain; `populateClassOwnedMembers` leaves it whole);
  - anonymous: `<host>$<ordinal>` — `build$1`, `Outer$1`, `Page$1`
    (javac's `Outer$1` numbering per host, in source order);
  - a `test` host is keyed `test@L<line>` (its string does not survive the
    class extractor's qualified-name normalization).
`zigContainerBindingName` keeps the spelling code writes (`R`) for scope
bindings and `@This()` alias rewrites (`@declaration.binding-name`).

Structure phase: bare `(struct|enum|union|opaque_declaration)` rules mint the
local/anonymous nodes via the class extractor; `shouldSkipDefinitionCapture`
keeps exactly one rule per container (`zigContainerAnchor`); a new
grammar-neutral `resolveContainerTypeOwner` provider hook lets the shared
owner walk name a container from context, so `Method:<file>:Reflect.string$R
.get#0` and its HAS_METHOD source agree by construction. Scope phase: the
wrapper group splits name/binding-name for locals and anonymous containers
get synthesized `@declaration.<kind>` defs (`is-synthetic`).

Lightpanda: ownerless Methods 14 → 0, fns without a node 55 → 0, ownerless
Properties 276 → 5, HAS_METHOD 8003 → 8074, HAS_PROPERTY 7275 → 7403,
CALLS 15838 → 15857, Struct 1905 → 2199; no resolution bucket dropped.

* fix(zig): re-add the implicit receiver on the call side so both method-call spellings reach the callback formal

`extractFunctionParameters` sliced the leading `self` off the formals, which
lined up `r.run(target)` (target@0 ↔ cb@0) but lost the explicit spelling
`Runner.run(&r, target)` (&r@0, target@1 ↔ cb@0): the callback never joined
its formal and `run → target` was missing (PR #1432 review by koriyoshi2041).

Formals are numbered once per function while the receiver differs per call
shape, so the fix lives in `extractCallArguments`: keep `self` as formal 0
and prepend the receiver as actual 0 when the callee is a member call on a
VALUE receiver — chain head is a fn-local name that is not TitleCase, the
same value-vs-type rule F6 uses. Namespace / type / decl-literal receivers
(`Runner.init(cb)`, `helpers.apply(cb)`, `List(u8).init`, `.init(cb)`) get
no prepend. Known residual gap, documented: a module-level value receiver
(`global_runner.run(cb)`) is not fn-local and still misses.

Tests: the F2 integration case now asserts both spellings plus a namespace
call; the unit contract pins `self@0, cb@1` and the per-call actual index.

* fix(zig): address eighth gitnexus-check review pass

- Named dependency modules: `parseZigBuildModuleRoots` scanned
  `addModule("<name>", .{ … })` with a `[^}]*` regex, so a nested field
  before `.root_source_file` (`.imports = &.{ .{ … } }`) ended the match
  at the inner `}` and demoted the module to an unnamed fallback — the
  first exe/lib root in the file then answered `@import("<name>")`. The
  named lookup now walks the balanced `addModule(…)` argument list
  (same scanner as `parseZigRootModules`, comment-stripped, string-aware);
  the unnamed fallbacks are unchanged. Regression test in
  `zig-import-resolver.test.ts`.
- Type-position `@import`: `var x: @import("m.zig").T = undefined;` was
  read as an import binding of `x` on both sides — the query rules match
  the `type:` child like a value, and `isZigContainerOrImportBinding`
  scanned every named child — so `x` was never declared and became a
  named import of `T`. The helper now skips the `type:` field, and
  `emitZigScopeCaptures` drops binding-rule matches whose `@import` sits
  in the annotation (`isZigTypePositionImport`) without claiming the
  source, so `x` binds as a variable and the file edge survives as a
  side-effect import. Regression tests in `zig-extractors.test.ts`
  (variable extractor + scope captures).
- Union in the class-capture skip guard: the parse-worker's inline
  class-like predicate lacked `Union`, so a `Union` definition bypassed
  `shouldSkipClassCapture` unlike every other `ClassLikeNodeLabel`.
  Added the label; no Zig behavior changes (Zig defines no skip hook), so
  no test.
- File-owned method ids in `findEnclosingFunctionId`: the arity lookup
  used `findEnclosingClassNode` while the owner came from the file-owner
  aware `cachedFindEnclosingClassInfo`, so a Zig file-struct's top-level
  fn produced `Method::Page.get` without the `#<arity>` suffix. It now
  uses `findEnclosingClassNodeOrFileOwner`, the definition-phase lookup.
  Consistency fix only: `ParseWorkerResult.calls` / `.assignments` (the
  sole consumers of this id) are merged but not read since #942 — CALLS
  edges come from the scope pipeline, whose ids were already right — so
  no observable graph change and no test.

Not re-fixed:
- `test/helpers/literal-collectors.ts` `DIR_LANG` has no `zig` entry
  (raised for the seventh time): the entry exists (`zig:
  SupportedLanguages.Zig`, added by the second-pass commit), so
  `languages/zig/**` literals are already validated against the Zig
  grammar alone; documented in the PR body since the fifth pass.

* fix(zig): keyword-gate the constructor type-binding rules

The three `@type-binding.constructor` rules (`const p = T{…}`, `mod.T{…}`,
`List(u8){…}`) matched any `variable_declaration` with an identifier and a
`struct_initializer`, keyword or not — and tree-sitter-zig 1.1.2 parses a
re-assignment `p = T{…};` (and `_ = T{…};`) as the same node type. So an
assignment minted a constructor binding for `p` in its own block, and one
for `_`. Zig's static typing makes the extra binding redundant (`p`
already carries its type from its declaration: annotation, constructor or
inferred value), so it cost little, but it declared nothing and stood out
against every other binding rule (`@declaration.variable`, the import
rules, the call-return rules), which are keyword-gated for exactly this
shape. Split each rule into `"const" .` / `"var" .` variants, like the
call-return rules.

Regression test in `zig-extractors.test.ts`: `p = T{…}`, `q = mod.T{…}`,
`l = List(u8){}` and `_ = T{…}` after their declarations yield only the
three declaration bindings (fails on the previous query). The zig,
callable-value-flow, grammar-literal and tree-sitter-languages suites are
unchanged.

Raised twice by gitnexus-check (passes on 2026-08-18 12:42 and 12:56).

* fix(zig): re-baseline the callable-flow capture fingerprints, keep `await f<T>(x)` a direct callee

The `benchmarks (GITNEXUS_BENCH)` CI job gates two capture fingerprints
that this branch's shared callable-flow change (3e62b99a) moved without
re-baselining: `bench/python-scope/baseline-fingerprint.txt` and four
languages in `bench/scope-capture/baselines.json` (csharp, cpp,
typescript, kotlin). Both `--check` runs pass on origin/main and failed
on this branch; every other language matched its baseline on the same
run.

Drift, verified by dumping the canonical matches on both trees:
- csharp / kotlin / python: exactly the intended change — a MEMBER call
  (`string.Join(x)`, `.forEach { }`, `' '.join(x)`, `.ljust(w)`) no
  longer carries `direct-callee-name`; the argument fact is unchanged.
- cpp: `choice.select(1)` (cpp-deleted-overload) drops an INDIRECT
  invoke + its synthetic `@reference.call.free` that were gated only by
  the same-named free `select` binding; the site is a genuine method
  call already captured as `@reference.call.member`. capture_groups_fp
  4605 -> 4601.
- typescript: `await svc.verify<T>(x)` (member) and `initializer()(cb)`
  (call-of-call) drop the name as intended. But `await verifyToken<T>(x)`
  — a DIRECT call — lost it too, because tree-sitter-typescript parses
  `await f<T>(x)` as `call_expression(function: await_expression(f),
  type_arguments, …)` and the new direct-designator gate saw an
  await_expression, not `f`. `wrappedExpression` now unwraps
  `await_expression` (no named field, so the field-based unwrap missed
  it), restoring parity with main for the direct spelling while the
  member spelling stays nameless. Regression test added; it fails
  without the unwrap on both assertions.

Gates run locally: scope-capture --check (15 languages), python-scope
--check, import-target --check, tsc, eslint, prettier, the callable-flow
/ golden / tripwire / resolver test files (33 files), full suite with
coverage (82.9/71.5/89.1/86.4 vs 26/23/28/27 thresholds).

* test(zig): fail CI when the optional Zig grammar is absent

`@tree-sitter-grammars/tree-sitter-zig` is an optionalDependency, so every
Zig suite gates on `isLanguageAvailable(Zig)` and the ABI load-smoke accepts
a clean load failure for an optional grammar. Both are the right contract for
a platform with no prebuild, and together they leave a hole: if the grammar
never installed on any CI runner, this PR would merge with all eight Zig
resolver suites plus the structure-phase suite reported green-by-skip, having
never executed the native Zig parser once.

Close it with the `GITNEXUS_REQUIRE_FTS` idiom already used for the FTS
suites. `GITNEXUS_REQUIRE_ZIG=1` declares "this runner has a prebuild, the
grammar MUST be here", and a missing grammar becomes a failure instead of a
skip. tree-sitter-zig@1.1.2 publishes prebuilds for {darwin,linux,win32}-
{x64,arm64}, so the flag is set on two required jobs that all run on covered
platforms: the sharded ubuntu `tests` job and the three-OS `abi-assert` job.

- test/helpers/optional-grammar.ts: the registry mapping a language to its
  require-variable, plus `describeGrammarPresence`, a presence assertion that
  FAILS when required-but-absent. Deliberately a separate test rather than
  flipping the suites from skip to fail: a skipped suite reports success, so
  only a failing test can turn "Zig never ran" into a red job.
- parser-loader-abi.test.ts: the optional exemption is revoked for a language
  the environment declares required, so an ABI-broken Zig binding fails the
  smoke instead of passing as a clean absence.
- optional-grammar-gate.test.ts: pins the two ways the gate could silently
  never fire — reading a variable name CI does not set, or accepting a value
  CI does not write.

Nothing changes for a run that leaves the variable unset: local runs and any
future prebuild-less platform still skip. Verified both directions --
`GITNEXUS_REQUIRE_ZIG=1` alone: 149 passed, 0 skipped; with
`GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` forcing the grammar away it fails 2 tests
with an actionable message; with the skip flag but no require flag it is
green-by-skip exactly as before.

Addresses the test-only blocker in the gitnexus-check review of #1432.

* fix(zig): type the optional-grammar gate by grammar key, not language

The gitnexus-check finding on parser-loader-abi.test.ts:155 is right, and
none of the gates caught it: `tsconfig.json` includes only `src/**/*`, so
neither `tsc --noEmit` nor CI typechecks the test tree, and vitest strips
types without checking them. Confirmed with a scoped tsc run over the file:
`error TS2345: Argument of type 'string' is not assignable to parameter of
type 'SupportedLanguages'`.

Not fixed with the proposed `key as SupportedLanguages` cast, which would
assert something false: `listGrammarSources()` yields one row per SOURCES
entry, including variants like `typescript:tsx` that are not enum members.
`isOptionalGrammarRequired` now takes the grammar KEY it is really given,
and the registry keeps a `satisfies Partial<Record<SupportedLanguages,
string>>` so every key we write is still pinned to a real language.

Two new cases cover the failure mode the type error was pointing at — a
registry key that can never match what the ABI smoke passes, leaving the
gate configured-looking and permanently inert: every OPTIONAL_GRAMMAR_ENV
key must be a key `listGrammarSources()` yields and must be marked optional
there, and an unregistered variant (`typescript:tsx`) must not be required
even with the variable set.

Same blind spot, two more latent errors in files this PR adds, both fixed:
`Parser.Language` is not an exported member (use the `setLanguage` parameter
type, as parser-loader-abi.test.ts already does), and the `ParsedImport`
filter did not narrow the union, so `localName` was read through a `!` on an
arm that has no such property — now a type predicate. The one remaining
error under the same probe, in `resolvers/callable-value-flow.test.ts:319`,
predates this branch (authored 2026-07-17, on main) and is left alone.

structural-pair-coverage's optional-grammar case switches from
`it.concurrent.each` to `it.concurrent.for`: only `for` passes the test
context as a second argument (`each`'s callback is `(...args: T[])`), and
that context carries the dynamic `skip()` the per-language gate calls.
Behaviour is unchanged — grammar present: 10 passed; grammar forced away:
9 passed, 1 skipped.

Whole test tree typechecking is a separate, much larger job: the same probe
over `test/**` minus fixtures reports 734 pre-existing errors across the
repo. Out of scope here.

* fix(zig): address tenth gitnexus-check review pass

- `normalizeZigDepPath`: normalize backslashes BEFORE the absolute-path
  check. A UNC dep (`\\server\share\dep`) used to slip past the check and
  normalize to the repo-relative `server/share/dep`; root-relative `\dep`
  had the same hole. Both now return null. Regression case added to the
  absolute-spellings test with the files those misreadings would resolve.
- `bindPayloads`: a pointer capture `for (pages) |*p|` now records `*Page`
  (declaredSpelling) instead of `Page` — the `*` is an anonymous payload
  child before the identifier. Method dispatch is unchanged (`rawName`
  strips the sigil), but a deref projection `const q = p.*;` now sees the
  pointer layer. New fixture fn `viaPtrCaptureDeref` + assertion; fails on
  the previous code (verified by stashing the src fix).
- `optional-grammar-gate.test.ts`: renamed the `typescript:tsx` case — the
  key IS a registry row; what makes it inert is the missing gate entry. Now
  also asserts a key no registry yields.
- `structural-pair-coverage.test.ts`: header updated — ten tables (not
  eleven) are absent from every rule's target side; `Union` left the set
  when Zig made it linkable.
- `language-classification.ts`: doc comment now names zig in the
  experimental set (added after Ring 1).

Not re-fixed (invalid findings):
- "owner-hook contract wired to an undeclared variable": stale-diff read —
  `findEnclosingClassInfo` declares `resolveFileTypeOwner` /
  `resolveContainerTypeOwner` as optional parameters (ast-helpers.ts:905,
  917) and parse-worker threads them at every call site; tsc compiles clean.
- "optional Zig grammar added unconditionally to the parsing fixture
  suite": the cited block only `fs.readFile`s the committed fixture file to
  assert it is non-empty — no parser or grammar load is involved.

* fix(scope-resolution): mark construction-site CALLS edges in reason (opt-in), enable for Zig

PR #1432 human review, item 2: a Zig struct literal `T{ .f = x }` (no
parens) is modelled as a CALLS edge to the type — the Rust `T { .. }` /
Go `T{}` shape — and nothing on the edge told it apart from an invocation
(`get_next_spawn → SpawnRequest` from seven `return SpawnRequest{ … }`).

`ScopeResolver.markConstructionSites` (default off): when set, the edge
emitted for a `callForm === 'constructor'` site gets ` (constructor)`
appended to its reason, in both emit paths — `local-call (constructor)` /
`import-resolved (constructor)` in the free-call fallback and
`scope-resolution: call (constructor)` in the reference bridge. The Zig
resolver opts in. `Reference` gains an optional `callForm`, copied from
the site by `buildReference`, so the bridge can see the form.

Why `reason` and not a property or edge type: relationships carry no
arbitrary properties, a new column changes the relation DDL and moves
SCHEMA_FINGERPRINT, and `reason` is the channel the IMPLEMENTS `-pointer`
receiver form already uses. Why opt-in: the unsuffixed strings are a
pinned contract asserted verbatim by the other language suites
(php/cpp constructor calls expect exactly `import-resolved`); every
non-Zig edge stays byte-identical.

Tests: `references-to-edges-call-form.test.ts` pins both vocabularies
and the default-off behaviour; `zig.test.ts` asserts
`Reflect.string → Accessor` / `Reflect.url → Accessor` carry
`local-call (constructor)` next to a plain invocation, and that every
marked edge targets a Struct.

* feat(zig): track qualified struct literals (`mod.T{…}`) as construction sites

PR #1432 re-test (issue comment on 97571d23): 163 qualified literals
`mod.Type{ … }` in a real project produced no CALLS edge at all, so only
same-file and imported-name literals were tracked as construction sites.

One query rule captures `(struct_initializer (field_expression object
member))` as `@reference.call.constructor` WITH the receiver. Captured as a
free constructor instead, the site resolves by its simple tail and a
workspace-unique `Thing` answers for `c.Thing{}` whichever module the
source named (measured: c.zig defines no `Thing`, the edge went to a.zig's).
With the receiver the site takes the receiver-bound namespace case — the
path `mod.fn()` takes — which resolves inside the module the receiver is
bound to: `a.Thing{}` / `b.Thing{}` bind their own files, `c.Thing{}` binds
nothing, `std.Thread.Mutex{}` binds nothing next to a local `Mutex`.

That case's edge now goes through `constructionSiteReason` too, so the
opt-in marker (`import-resolved (constructor)` / `global (constructor)`)
reaches it; `markConstructionSites` joins `ReceiverBoundProviderSubset`.
Byte-identical for every provider that does not set the flag.

Also answers the twelfth gitnexus-check pass: the `bodyNodeSet.size === 0`
guard on the extractor factories' no-wrapper branch is deliberate (a config
with wrappers whose node lacks one is a bodiless declaration); the two
comments now say so instead of reading as a universal last resort. Go's
method config, the only other empty-`bodyNodeTypes` config, never reaches
the branch (its `extract()` gates on method/function nodes the class-node
caller never passes).

Tests: new `zig-qualified-literal` fixture (same-named `Thing` in two
modules, a module without it, an external `std` qualifier next to a local
and an imported `Mutex`); `zig-basic` pins `pioneer.Pioneer{…}` and the
union `pioneer.Tag{…}` as marked construction sites.

* feat(zig): resolve hub re-exports, enum-variant receivers and type-named receivers (real-project audit)

Audit of three real Zig projects indexed with this branch (tigerbeetle 246
files, mach 132, ghostty 788): method reachability was 63 % / 35 % / 55 %,
and three shapes accounted for most of the misses.

1. Hub modules. Zig projects publish types through a file made only of
   re-exports (`pub const Terminal = @import("Terminal.zig");`, `pub const
   PRNG = @import("prng.zig");`, `pub const Thing = @import("thing.zig")
   .Thing;`). Such a file owns NO local binding, and `findExportedDef` reads
   local bindings only — so `terminal.Terminal.init()`, `t: stdx.Thing`,
   `var p = stdx.PRNG.from_seed()` and `h: stdx.BoundedArrayType(u8, 4)`
   all resolved to nothing. Measured before → after: CALLS into ghostty's
   `src/terminal/` from outside it 46 → 253 (150 `terminal.Terminal.` sites
   alone); into tigerbeetle's `stdx` hub from outside it 837 → 1500 (136
   static calls, 289 annotations). Method reachability: tigerbeetle
   2249 → 2272 of 3544, ghostty 2766 → 2865 of 5016, mach 1047 → 1051 of
   2967 (mach's hub publishes generic instantiations, `pub const Quat =
   q.Quat(f32)`, a shape this commit does not cover).
   `findExportedDefIncludingImportedNames` reads the finalized channel
   (origin import / namespace / reexport, def already resolved to the
   declaring file), refusing a name bound to two distinct defs. Opt-in per
   provider (`namespaceExportsIncludeImportedNames`): a module's imports are
   not its exports in most languages; Zig opts in because a hub member a
   consumer can name is public by construction. Used by receiver-bound Case
   1, Case 3, the compound resolver's namespace branch, and a new Case 2
   route that resolves a namespace-qualified class receiver (`stdx.PRNG`)
   through the same lookup.

2. Enum variants as receivers. `Operation.create_accounts.event_max()`
   (147 sites in tigerbeetle): a variant has no written type, but it has
   one — the enum itself. `emitZigScopeCaptures` now emits a field type
   binding per enum variant, so the field walk that already handles
   `self.session.name()` types `Op.create` as `Op`.

3. Receivers named after their type. `self` is a convention, not a rule:
   tigerbeetle writes `replica: *Replica` (777 of 1127 methods), mach
   `pool: *@This()` (764 of 833). Reading only `self` as the receiver
   labelled all of them `isStatic: true`, counted the receiver in their
   arity (`Counter.incr#1`) and sourced the scope binding as a plain
   parameter. `zigReceiverParameter` is the single rule for both phases:
   the FIRST parameter when named `self`, or typed as the enclosing
   container (`@This()`, its binding name, a `const X = @This();` alias),
   pointers / const / optionals stripped.

Fixtures `zig-hub` and `zig-receivers` pin each shape, including the
refusals: a private hub import does not leak, a foreign-typed first
parameter is not a receiver, a factory stays static.

* fix(zig): address thirteenth gitnexus-check review pass

- File-struct receivers named after the file stem were always static: the
  method builder called `isStatic` / `extractReceiverType` /
  `extractParameters` without the extractor context's `filePath`, so
  `zigReceiverParameter` could not name a file-struct (`fn add(ledger:
  *Ledger)` in `Ledger.zig`, no `Self` alias) and the fn came out static
  with the receiver in its arity (`Ledger.add#2`) — an id the scope side,
  which always has the path, never produces, so its CALLS edges went
  nowhere. `MethodExtractionConfig` now passes `filePath` as an optional
  trailing argument to those three hooks (same shape as
  `extractOwnerName`); the Zig config threads it through, every other
  config ignores it. Regression tests in `zig-extractors.test.ts` (unit)
  and `resolvers/zig.test.ts` (new `Ledger.zig` in the `zig-receivers`
  fixture: ids, `isStatic`, and the three CALLS edges); both fail on the
  previous source.
- `LINKABLE_LABELS` comment: the remaining `CLASS_KINDS` entries include
  `Namespace`.

Not re-fixed:
- "Ownerless-method assertion regex cannot match `.zig` graph IDs": the
  `[^:]+` segment consumes the whole file path (dots included) up to the
  second colon, and `[^.]+#\d+$` then matches only an owner-less name —
  `Method:src/Sorter.zig:lessThan#3` → true, `…:Sorter.sortBoth$1.lessThan#3`
  → false, checked with node.
- "Public namespace imports are never marked as re-exports": the shared
  `ParsedImport` namespace variant has no `reexportsName` field and
  `contributesReexportEdge` excludes namespace drafts on `base.kind` by
  contract; a `pub const X = @import("x.zig")` hub member is exposed
  through `findExportedDefIncludingImportedNames` instead, which is what
  the audit commit added for exactly that shape.
- "Private Zig namespace imports are treated as public hub exports": a
  private import cannot be named through the hub in code that compiles,
  and `findExportedDef` applies the same no-visibility rule to local
  defs; the finalized binding channel carries no `pub` bit to check.
- "Range binding mutates finalized scopes" and "unconditionally adds an
  optional Zig grammar to the fixture suite": refuted in the tenth and
  eleventh pass notes of the PR body, unchanged since.

* fix(zig): close the adversarial review's ten findings (8.2–8.12)

PR #1432 review 5095267917 on 34c53473 retained eight P1 and two P2
findings; each is reproduced on the new `zig-chains` / `zig-buildmodules`
fixtures with the decoy that made the old answer wrong, and pinned by a
test named after its number.

- 8.2 per-build-module import tables (`parseZigBuildModules`): a source
  resolves a bare name through its own module's `addImport` table (root
  file, else deepest root directory), fails closed when same-directory
  modules disagree, and follows `addImport("api", dep.module("core"))`
  through the dep's `addModule`; repo-wide names and zon deps remain the
  fallback.
- 8.3 module-level value receivers (`zigHostValueNames`) prepend the
  implicit `self` like fn-locals, so `global_runner.run(cb)` joins `cb@1`.
- 8.4 deep member aliases (`@import("lib.zig").B.work`, `lib.B.work`)
  keep the written owner: the module is bound as a namespace and the
  alias's use sites are rewritten to `receiver . member`; only one-level
  aliases are promoted to named imports.
- 8.5 container-hosted containers get owner-qualified identities
  (`A.Item`, `B.Item`, `Outer.Inner`), minted by the bare-container rule,
  while the scope keeps the lexical binding.
- 8.6 result-location `.init(…)` / `.{…}` under an annotation, a return
  type or a field type emit the call / construction site with the
  expected type as receiver.
- 8.7 Zig arm in bench/import-target (five dispatchers, config-free
  fingerprint) + baselines row; `--check` passes.
- 8.9 fn-local `@import` bindings and their uses are keyed per callable
  (`m$f_sib_a`), so sibling fns no longer share one namespace bucket.
- 8.10 `ScopeResolver.resolveNamespaceChains` (opt-in, Zig only): Case 1
  / Case 2 / Case 3 and the compound resolver walk a qualified receiver
  segment by segment — republished modules, nested types, enum variants
  through the module — refusing ambiguous hops. Off, every lookup keeps
  its one-hop split; the 70 resolver suites are unchanged.
- 8.11 `@import("a.zig").Thing{}` binds the module as a namespace in type
  position; `List(u8){}` / `lists.List(u8){}` get constructor sites.
- 8.12 a fieldless file whose top-level fn takes the file's own type
  (`self: *@This()`, `self: *Self`) is a file-struct; two over-matching
  ZIG_QUERIES rules are filtered by `shouldSkipDefinitionCapture`.

Also asserts the committed `opmod.Op.lookup.event_max()` call in zig-hub.

* fix(zig): address gitnexus-check findings on 215f70e3

- receiver-bound Case 3 wraps its reason in constructionSiteReason, like
  Case 1 and the nested-type route of Case 2 (one vocabulary per provider)
- resolveZigImportInternal rejects drive-qualified absolute imports
  (C:\foo.zig), the same test normalizeZigDepPath applies; unit case added
- the compound resolver's chain seed also tries the whole receiver as the
  qualified class (opmod.Op), as a bare class-name head already does
- markConstructionSites contract text names the receiver-bound routes

The return_type field claim is refuted: tree-sitter-zig exposes a fn's
return type as the type field (checked on the grammar).

* fix(zig): a build-module alias bound to an unindexed root fails closed

resolveThroughBuildModules returned undefined when the containing module
bound the alias to a file that is not indexed, which let the repo-wide
addModule map answer under the same name (gitnexus-check on 5299c552).
The module's table is the authority for its aliases: bound-but-unindexed
is null, only an unbound name falls through. Unit case with a same-named
repo-wide decoy, plus the outside-module file that still reaches it.

* fix(zig): an unindexed root module fails closed, never a same-named zon dep

The root build.zig's addModule declaration is authoritative for a bare
name when it binds it; a root that is not indexed used to fall through
to a build.zig.zon path dep of the same name — a different declaration
answering under the name (gitnexus-check on fe24b37f). Same rule as the
build-module tables. Unit case added.

* Address PR review feedback (#1432)

Tighten Zig build-module parsing, receiver/merge helpers, and container queries that gitnexus-check flagged on the open threads.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#1432)

Attach the paren-matcher doc comment to findZigParenEnd instead of zigTopLevelStaticRoot.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Simplify Zig review-feedback helpers after #1432.

Reuse ZON brace/string walkers for top-level root_source_file, drop the dead bind flag and one-off staticRoot wrapper, and merge bindings via a first-wins map.

Co-authored-by: Cursor <cursoragent@cursor.com>

* bench(receiver-resolution): rebaseline for the Zig lang-resolution fixtures

The receiver-resolution gate (#2856/#2899) landed on main after this branch
forked and counts call drops over test/fixtures/lang-resolution, which this
branch extends with the zig-* fixture projects. Regenerated with
`measure.mjs --update-baseline`: callDrops 102 -> 113, all 11 new drops in
.zig files, shape `no-chain`.

Every new drop is a call whose callee has no node in the corpus, not a
resolver regression: `std.Build.Module.addImport` in the three build.zig
fixtures (7, classified in-program), `std.sort.pdq` in Sorter.zig (2,
unknown), `std.Thread.Mutex{}` / `std.mem.Allocator{}` literals in
zig-qualified-literal (2, unknown, the fixture asserts std stays external),
and one `.init()` decl literal on a generic instantiation
(`const u: Stack(u16) = .init()`, in-program). Shape arm unchanged.

---------

Co-authored-by: Garrett Griffin-Morales <grgisme@gmail.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 13:29:42 +01:00
glier
dea396a13c
feat(ingestion): resolve Spring messaging destinations into Destination nodes (#3132) 2026-09-02 11:46:50 +01:00
Gergő Magyar
5a1e5c8803
feat(kotlin): ingest Spring HTTP routes as decoratorRoutes (#3133)
* feat(kotlin): ingest Spring HTTP routes as decoratorRoutes (#3130)

Kotlin RestControllers now emit analyze Route nodes, handler attribution, and folded constant paths on the ingestion decoratorRoutes channel, matching Java Spring without changing Java or Python extractors.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(review): reject abstract/sealed Kotlin RestControllers and keep Java brace parsing

Code review required fail-closed admission for abstract and sealed classes, and Kotlin-owned method=[...] translation so Java extractors do not start emitting routes from bracket arrays.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs(kotlin): note ingestion-side Spring decoratorRoutes wiring

The module comment still described Kotlin as group-layer-only. Ingestion now uses the same constant-fold hooks as Java.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(kotlin): keep empty Spring path arrays and trailing commas (#3133)

Empty [] / arrayOf() class or method paths are no prefix, not a skip, matching the group-layer contract. Trailing commas in annotation argument lists no longer fail parseSpringAnnotationArguments.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(kotlin): flatten empty Spring path classification

Match class-level empty [] / arrayOf() handling to the method-level branch so the fail-closed path is not nested.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 23:06:56 +01:00
glier
f7a58cf188
feat(ingestion): capture Spring handler annotation arguments and template publishes (#3128)
* feat(ingestion): capture Spring handler annotation arguments and template publishes

Non-HTTP handler recognition already resolves the annotation NAME through
imports, aliases, use-site targets and package visibility. What it never
captured is the annotation's ARGUMENTS, so the destination a listener binds to
was invisible: `@KafkaListener(topics = ...)` and `@RabbitListener(queues = ...)`
name it with different attributes, and a producer names it by position. The
publishing side was missing entirely, which left every messaging edge
one-directional by construction.

Consumer side: `SpringNonHttpHandlerAnnotationFact` gains an optional
`args?: readonly { name?: string; text: string }[]`. `name` is optional because
positional and named arguments are genuinely different shapes, not because it
is sometimes unknown.

Producer side: `KafkaTemplate.send`, `RabbitTemplate.convertAndSend`,
`JmsTemplate.convertAndSend` and `StreamBridge.send` for both languages.

Arguments are captured as SYNTAX, never as a resolved address. At capture time
imports are not final, a sibling file's constants do not exist yet and
configuration has not been read — the same reason annotation-name resolution
was deferred. Resolution belongs to a later phase; doing it here would be a
layering error that happens to work on simple inputs.

The parse cache schema moves 82 -> 83. Both new facts ride the existing
worker -> main side channel, which is replayed verbatim from
`ParsedFile.captureSideChannel`, so a warm v82 cache would skip the workers and
hand back annotation facts with no `args` and an empty producer list. Measured
on the fixture app: a warm all-cache-hit run (`usedWorkerPool=false`,
`reparsedFileCount=0`) reproduces 6 Java and 7 Kotlin producer facts from the
store alone — exactly the state a pre-change cache would have served as zero.

Tests cover both languages across literal, constant and configuration-key
destinations, and pin the PREVIOUS behaviour too: handlers captured before are
still captured, and shapes that must not produce a fact still do not.

* test(ingestion): cover the handler and template shapes capture left unpinned

Auditing the argument capture against its own definition of done turned up
three annotations and three templates that work but that nothing asserts, so a
regression in them would land silently.

Handler side: `@EventListener` and `@ServiceActivator` were only ever checked
for RECOGNITION, never for arguments, in either language, and Kotlin
`@RabbitListener` appeared in no argument test at all. Both annotations carry an
address just as `topics` and `queues` do — an event listener names it as a type
and an integration endpoint names it as a channel — so leaving them unpinned
left a third of the handler family covered by nothing.

Producer side: Kafka was the only template whose destination was written three
ways. Rabbit, JMS and the stream bridge each appeared with a single spelling, so
nothing said that a constant or a configuration-bound name produces a fact for
them too. The same fixtures pin the negative that `RabbitTemplate.send` and
`JmsTemplate.send` stay unrecognized, since only the method that belongs to the
template counts.

Each test asserts the previous behaviour alongside the new one: the handler is
still recognized and still named the same, and only then are its arguments
checked. A test that looked at arguments alone would keep passing if recognition
itself broke.

Verified against the parent commit that this is coverage, not repair: every
Java and Kotlin fixture in the suite produces a byte-identical capture side
channel on both revisions once arguments and producer facts are set aside.
Mutating `StreamBridge` out of the template table, and making Kotlin annotation
arguments return nothing, each fail the new tests.

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

* fix(ingestion): stop Spring capture from inventing arguments and receivers

Five defects in the capture-time Spring messaging facts, all of which put
data that is wrong — not data that is missing — into a durable store.

Facts built from recovered syntax. After a syntax error tree-sitter keeps
parsing by guessing boundaries, so the tree stays well formed while
describing text nobody wrote. An unterminated `kafkaTemplate.send(TOPIC,`
absorbed the next method's source and offered it as two more arguments;
`@KafkaListener(topics = "orders", groupId =` reported a `groupId` whose
value was an empty `{}` borrowed from the method body. Both now fail
closed: a producer call with an unparsed argument list yields no fact at
all, and an annotation with one reports no arguments. Neither carries a
state that could mean "published somewhere unreadable", so the choice was
between silence and a plausible lie.

Arguments were not normalized though the receiver beside them was. The
receiver already collapsed a wrapped chain to one spelling; the argument
kept its newlines and the ENCLOSING block's indentation, so the same
constant compared unequal to itself at two nesting depths, and again in a
CRLF checkout. Receiver and argument now share one normalizer.

That normalizer damaged multi-line literals. Its doc comment promised to
keep the rewrite away from nested string literals, and delivered that only
for single-line ones: a Java text block or Kotlin raw string whose newline
sat next to a dot lost the newline, changing the value. The normalizer is
now literal-aware, which makes the promise true for both.

The receiver name match accepted only one decoration. Matching the type
name as a suffix recognized `orderKafkaTemplate` and dropped
`kafkaTemplateDlq`, `kafkaTemplateV2`, `kafkaTemplate2`, `KAFKA_TEMPLATE`,
`kafka_template`, `streamBridge2`, `STREAM_BRIDGE`, and `rabbitTemplate1` —
including the `static final` constant spelling, which is exactly the shape
this capture exists to find. The name is now folded on `_`/`$` and matched
as a substring. The bare-identifier gate still runs first and is what keeps
`config.get("a.kafkaTemplate")`, `templates["k"]`, and `getTemplate()` out.

Ownership was attributed one level too deep. With no boundary types the
ancestor walk passed through a nested type body, so a publish in the field
initializer of a class declared inside a method was attributed to that
method, which may never run it. The identical construct at the top level of
a class already yielded no fact; a type body is now a boundary so the rule
reads the same at every depth, while a publish in a METHOD of a nested or
anonymous type is still attributed to that method.

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

* perf(ingestion): read Kotlin handler annotation arguments on evidence

Kotlin asked for annotation arguments unconditionally, for every annotated
function with any annotation, while Java made the same decision in two
passes and paid only for callables that carry a handler annotation.
Measured on 200 annotated NON-handler functions in one file, the Kotlin
side-channel payload went from 41069 bytes to 78797 — a doubling, crossing
the worker boundary and landing in the durable store, for data no consumer
reads today.

The reason Kotlin had no prefilter is real and is preserved: an import
alias (`EventListener as SpringEvent`) gives a handler annotation a local
name no list can contain, so discarding CALLABLES by simple name would lose
them before the post-import resolver runs. That argument covers capturing
the annotation; it does not cover reading its arguments, because the alias
is not a mystery at capture time. The import header states both the local
name and the FQN it stands for, so the existing relevance predicate can be
asked about the IMPORTED name and the answer carried back to the alias.

Kotlin now runs Java's two passes, with that alias set widening the first
one. Every annotated function still produces a fact with the same name and
use-site target as before — the non-handler payload is 41069 bytes again,
byte for byte what it cost before arguments existed — while handlers, and
handlers reached only through an alias, keep their arguments.

Also corrects two comments that described behavior the code did not have.
The Java capture claimed an economy Kotlin was not making; it now describes
both languages. The argument opt-in on both DI modules claimed it kept
argument text off the wire, but every DI fact already carries the
annotation's full source text — what the opt-in avoids is a second, parsed
copy, and the comment now says so.

The test file is renamed: `spring-handler-annotation-arguments` differed
from the pre-existing `spring-annotation-arguments` by one word in the
middle, though they cover different mechanisms — an AST capture versus a
text parser. It is now `spring-argument-fact-capture`, after the module it
exercises.

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

* docs(ingestion): correct the argument-text contract the normalizer outgrew

Both `SpringNonHttpHandlerAnnotationFact.args` and `SpringArgumentFact.text`
promised the value stays "exactly as written". That was true when the field was
added and stopped being true in the same branch, when argument text started
going through `normalizeSpringFactText` so that one destination written across
two lines would not compare unequal to the same reference on one line.

A consumer reading only the interface would have assumed a source spelling the
fact does not retain — and the indentation such a consumer would have seen is
the enclosing block's, not a property of the expression at all.

Both docs now state the single rewrite and its reason, and still say plainly
that nothing is resolved. Reported by the review bot on #3128; the claim was
introduced by this branch, not inherited.

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

* style(ingestion): apply Prettier to the four files CI flagged

`quality / format` runs `prettier --check .` and four files from this branch had
drifted: two line-width wraps and two of the opposite kind, where a call fits on
one line. No behaviour change — tsc clean, the four affected suites still pass
126 tests.

Worth noting why the pre-commit hook did not catch it: lint-staged formats
staged files, but a rebase replays commits without running hooks, so anything
that only becomes unformatted relative to a moved base slips through. Checking
the whole diff against `prettier --check` before pushing is the reliable step.

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

* test(ingestion): publish Kotlin named arguments through a Kotlin template

Kotlin forbids named arguments when the callee is a Java method: parameter
names are not guaranteed to survive into bytecode, so the compiler refuses
`kafkaTemplate.send(topic = ..., data = ...)` for the Spring `KafkaTemplate`
imported from `org.springframework.kafka.core`. Every named-argument example
in this feature was written that way, which asserted capture on source that
could never compile.

The path itself is real and stays covered. The classifier matches on the
receiver's NAME, so a template declared in Kotlin is recognized exactly like
the Spring one, and named arguments to it are legal. Each affected example now
declares that template and publishes through it; the assertions are unchanged
except for the one receiver spelling they name.

The fixture's `publishWithNamedArguments` had no test reading it at all, so it
carried the illegal shape into an app fixture for nothing. It is removed, and
the two pipeline expectations that counted its publish drop a row.

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

* fix(ingestion): withhold a broker when the receiver name matches two templates

Widening the receiver-name rule from a suffix to a substring — needed to accept
`kafkaTemplateDlq`, `KAFKA_TEMPLATE`, and the rest — also let ONE receiver
satisfy TWO signatures. `KafkaTemplate` and `StreamBridge` both publish through
`send`, `RabbitTemplate` and `JmsTemplate` both through `convertAndSend`, so
`streamBridgeKafkaTemplate.send(...)` matched twice and the loop returned
whichever came first in the list: kafka, by declaration order alone.

The receiver's TYPE is deliberately never resolved here, so nothing in this
module can rank the two matches. Neither the longest match, nor the last one,
nor the order of the signature list is evidence about the bean: that name reads
equally as a KafkaTemplate fronted by a stream binding or a StreamBridge named
after the broker behind it. Publishing one of them as the template turned an
unanswered question into a definite attribution a consumer has no way to
distinguish from a resolved one — a publish routed to the wrong broker.

An ambiguous receiver now yields no fact at all. That costs a rare publish,
stays recoverable by a later phase that owns type information, and is the
failure this capture already prefers everywhere else. Both outcomes are pinned:
three receivers naming two templates yield nothing, while decorated names that
merely look long (`orderStreamBridge`, `streamingKafkaTemplate`) still resolve.

The `typeName` contract said "suffix", which the same widening had made false.

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

* docs(ingestion): correct two argument contracts this change set invalidated

Sweeping the Spring capture comments for claims the feature commits outgrew
turned up two more, both about what a MISSING argument list means.

`SpringNonHttpHandlerAnnotationFact.args` promised that absence means the
annotation was written without an argument list. Reading Kotlin arguments on
evidence gave absence a second cause: Kotlin still produces a fact for every
annotated function — it has no name prefilter, so an import alias cannot hide a
handler — but reads arguments only for callables carrying a handler annotation,
so a non-handler fact has no arguments however its annotation was written. Java
produces facts for handler-bearing callables only, so there the old reading
still holds. The field now states both causes and which language has which.

`SpringArgumentFact.name` said a template call gives its destination by
position. That is true of Java, which has no named arguments, but Kotlin names
call arguments whenever the callee is declared in Kotlin, and this module
captures the key when it does — the reason the field exists for calls at all.

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

* fix(ingestion): make the Kotlin argument reader refuse recovered syntax itself

`kotlinValueArgumentFacts` is exported and already has a caller in another
module, and its contract said the caller MUST reject a recovered list first.
Both callers did. But a guard that every future caller has to remember is the
same fragility this change set exists to remove — the Java twin is safe only
because it is module-private with one call site.

It now returns `null` for a recovered list, so the decision is unavoidable at
the type level, and each caller answers in the way its fact requires: a producer
call drops the whole fact, having no state for "published somewhere unreadable",
while an annotation reports no arguments and collapses into the marker form.
Both say "nothing here to resolve", which is true.

No behaviour change — the same 126 tests across the three affected suites pass,
including the truncated-annotation and truncated-call cases that pin the
fail-closed path.

Raised as hardening in the maintainer review of #3128.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-09-01 14:35:19 +01:00
Gergő Magyar
e1e2464960
feat(kotlin): bind Spring config consumers on Kotlin sources (#3126)
* feat(kotlin): bind Spring @Value and ConfigurationProperties consumers

Kotlin sources were skipping the Java-only config-binding attach path, so mixed JVM apps under-reported blast radius for Kotlin placeholders. Capture from the live AST, serialize on the existing side channel, and reuse the shared binder. (U1-U4)

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(review): require exact Spring annotation FQNs and skip raw-string escapes

Reject similarly named third-party imports and leave Kotlin triple-quoted bodies undecoded so capture stays fail-closed.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(kotlin): use only grammar-valid Kotlin string and class-name nodes

The coverage shard failed the #1920 literal gate on invented string node types and a Java-style name field.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(kotlin): fail closed on non-literal prefixes and persist unresolved config markers

Reject constant and boolean @ConfigurationProperties arguments, scope nested Value shadows to their owner, and rewrite drifted consumer files when a config key is deleted.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(kotlin): add config-consumer capture benchmark and fix JVM feature stamps

The Kotlin capture path had no performance or behavior guard: a file-wide
lexical shadow regression silently dropped two of every three facts. The new
bench arm fingerprints @Value / @ConfigurationProperties facts from an
explicit-import control against a wildcard-import corpus whose files each
declare a sibling nested `Value` type, so parity between the arms is the
regression gate, and CI runs it with scaling + widening budgets.

Broadening spring.config-bindings to .kt also means Kotlin-only JVM repos now
stamp the feature, which the two exact-map orchestration expectations still
denied.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(kotlin): let an explicit import shadow the Spring wildcard import

importedAs accepted a star import of the Spring package even when the same
simple name was explicitly bound to another type, so a file importing
com.example.Value alongside the Spring annotation package emitted a false
@Value consumer fact. Kotlin resolves the explicit import first, so the
wildcard branch now only applies when the name is otherwise unbound.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:32:09 +00:00
DuduPhudu
48106d3c00
fix(ingestion): index NestJS decorator routes so api_impact and route_map stop reporting live endpoints as non-existent (#3017) 2026-08-27 08:35:36 +01:00
DuduPhudu
2c0fb7753c
fix(group): stop reporting what could not be measured as a measurement of zero (#3012)
* fix: surface unreadable group indexes and escape raw NUL bytes in source

Two independent diagnostics failures, both of which turn a real error into a
confident, benign-looking answer.

**Unreadable member repos (#3011).** `syncGroup` wrapped `initLbug` plus all
contract extraction for each member in a bare `catch {}` that pushed the repo
onto `missingRepos` and discarded the error. A LadybugDB storage-version
mismatch therefore surfaced as "repo not found", `group sync` printed
`0 contracts, 0 cross-links` and exited 0, and the existing contracts.json was
overwritten with an empty registry. The two states need different answers from
the operator — a missing repo must be indexed, an unreadable one is usually
version skew or a lock — so they are now separate:

- the caught error is logged with the repo, group path and lbug path
- `unreadableRepos` is tracked alongside `missingRepos` on `SyncResult`,
  persisted (optionally, so older registries still parse) on `ContractRegistry`,
  and threaded through `GroupService` sync/status
- `group sync` reports both before the cascade counts, since an unread repo is
  the likely explanation for a small or empty count
- `group status` reports unreadable repos separately; calling them "missing"
  actively misdescribed them
- when EVERY configured repo fails to open, the write is skipped: an extraction
  that read nothing is not evidence the group has no contracts, and replacing a
  good registry with an empty one loses data while reporting success

**Raw NUL bytes (#3010).** `sync.ts` and `free-call-fallback.ts` each used a NUL
as a join delimiter, written as a literal 0x00 instead of `\0`. Identical at
runtime, but it makes the file test as binary: `file(1)` reports `data`, ugrep
returns empty with exit 1 — indistinguishable from "no match", with no message —
and BSD grep replaces matching lines with "Binary file ... matches". A search
that should hit comes back as a confident "not present". Both now use the escape,
and a unit test fails on any raw control byte in src/ so it cannot silently
return.

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

* test(hygiene): guard every tracked source file against a raw NUL, not just src/

The guard added with the NUL escapes only scanned gitnexus/src for .ts/.tsx.
Neither prior recurrence of this defect in this repo was in that scope:
b620773b1 was gitnexus/bench/cpp-qualified-ns/measure.mjs and 38d737bb5 was a
fixture under gitnexus/test. A guard that cannot see where the bug has actually
landed twice is not a guard.

Drive the file list from `git ls-files` at the repository root over
.ts/.tsx/.js/.jsx/.mjs/.cjs/.mts/.cts — 2483 files instead of 828 — and split
the byte class, which is the part that matters:

  - 0x00 is a hard failure repo-wide. It is the byte git's binary heuristic
    keys on, so it is the one that costs a file its diff (and, on the base side
    of a PR, its inline-comment anchors and its three-way merge).
  - The wider C0 class stays scoped to gitnexus/src. A repo-wide scan finds
    exactly one hit, test/unit/logger.test.ts:146, and that 0x1b is a
    legitimate ANSI-escape fixture that is the subject of the test. Widening
    this half would go red on day one.

Read Buffers and scan bytes instead of decoding each file to latin1, through a
bounded read pool: 1.5 s for 2483 files, against 8-21 s previously for 828.

Add a negative fixture — a planted 0x00 and 0x1b run through the same scanning
helper — so a future refactor of the collector cannot leave a permanently green
guard, plus an assertion that the collected set still reaches bench/, test/ and
.mjs, which goes red if the scope is ever narrowed back.

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

* fix(group): report a cross-repo impact built from an incomplete bridge as truncated

When a sync cannot read a member repo, that repo's contracts and every
cross-link touching them are simply absent from bridge.lbug. Nothing in the
impact walk could notice: the only incompleteness channel on a
GroupImpactResult is truncationFields(), which is driven by fan-out state
(truncatedRepos / localPartial / fanoutTimedOut), and a repo missing from the
bridge sets none of them.

So `group impact` on a symbol whose one downstream consumer lives in an
unreadable repo returned `{ cross: [], truncated: false }` — "complete: nothing
in another repo depends on this". That is a wrong answer, not an empty one, for
a tool an agent uses to license a delete or a rename.

BridgeMeta now records unreadableRepos alongside missingRepos, writeBridge
persists it when non-empty, and runGroupImpact folds a non-empty
unreadableRepos ∪ missingRepos into truncated / riskEpistemic: 'lower-bound',
naming the repos in truncatedRepos.

The reason is a new 'incomplete-sync' rather than the existing 'partial'
because the remedy differs: 'timeout' and 'partial' are runtime limits the same
query can clear on a retry, while this one clears only when `gitnexus group
sync` succeeds. Runtime limits still take precedence when both apply, since
those are what the caller can act on immediately.

The risk VALUE is never clamped down — mergeRisk is monotone in the traversed
crossing count, so an incomplete bridge can only under-report. Marking the floor
is what makes that legible.

Both shape changes are additive and optional, so a bridge written before this
still reads.

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

* fix(group): say truthfully what a sync did to contracts.json

Review follow-ups to the unreadable-repo diagnostics. Every item below is a
place where the code still answered a question it could not answer.

1. The CLI announced a write it did not perform. `group sync` printed "Wrote
   contracts.json (0 contracts, 0 cross-links)" unconditionally, including on
   the path that deliberately left the file alone. SyncResult now carries
   registryOutcome ('written' | 'preserved' | 'not-attempted'), the CLI prints
   from it, and group_sync returns it so an agent that calls group_sync then
   group_contracts can tell why the counts disagree.

2. Refusing to write anything on total failure threw away the diagnostic
   describing the run that just happened. `group status` reads contracts.json
   from disk, so the operator who saw the sync fail and ran status to find out
   why read the PREVIOUS sync's file: no unreadable list, an old lastSync, a
   healthy-looking group — or worse, the previous run's unreadable list
   presented as this one's. The skip is now targeted: contracts, crossLinks,
   repoSnapshots and generatedAt carry forward verbatim, only missingRepos and
   unreadableRepos are refreshed. generatedAt stays put because it dates the
   contracts, which are still the previous run's. With no prior file, or an
   unparseable one, nothing is written at all.

3. Per-repo extraction is now all-or-nothing. Extractors run in sequence and
   any one can throw; appending each one's results straight to autoContracts
   meant a repo whose HTTP extractor succeeded and whose gRPC extractor then
   failed contributed a partial set to the registry, while the same run told
   the operator that repo's "contracts are omitted from this sync".

4. readRegistry gains an opt-in strict mode, and syncGroup uses it. The lenient
   `catch { return []; }` converted "I could not read the registry" into "no
   repo is registered": every configured repo then resolved to MISSING, the
   total-failure guard stayed off (it needs a load error), and a good
   contracts.json was replaced by an empty one at exit 0. That is an unreadable
   condition reported as missing, one frame above the code this branch fixes.
   The default stays lenient for the other nine callers; ENOENT stays lenient
   in both modes.

5. Absence of unreadableRepos keeps meaning "not recorded". The loader spreads
   the key in only when present instead of defaulting to [], and getStatus
   passes undefined through, so a legacy registry no longer reads as "the last
   sync found none unreadable". getStatus also gates both list fields on
   Array.isArray: it reads through readContractRegistry, which is a bare
   JSON.parse cast, so a corrupt string in either slot used to reach
   cli/group.ts and die in .join(', ') — the command whose job is explaining an
   unreadable thing, crashing on one.

6. Smaller, same theme: the per-repo warning passes the Error itself rather
   than err.message, so pino keeps the stack; the total-failure warning no
   longer fires on a dry run, where it described a file the call was never
   going to touch and which need not exist; the status table's MISSING legend
   stops re-conflating the two states; the sync warning drops its
   GITNEXUS_LOG_LEVEL=warn hint, which would only have suppressed output (pino
   emits warn at the default info level, so the reason was already printed);
   and the group_sync tool description and its idempotency comment now describe
   what the tool actually does.

Testing. The original four cases could not see the change they were named
after. Mutation testing showed two survivors: dropping the ===
configuredRepoCount conjunct, which turns "every repo failed" into "any repo
failed" and would silently freeze contracts.json for a group where one of five
repos is skewed; and deleting both logger.warn calls, the stated purpose of the
change. Both survived because every case configured exactly one repo and
nothing read the log. There is now a two-repo case running the real per-repo
loop, an all-missing case, a _captureLogger assertion on the level 40 record,
partial-extraction cases, and strict-read cases. All five mutants are killed,
each by exactly one test.

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

* fix(group): tighten the registry list gates and stop naming a truncation reason on complete results

Three follow-ups from the check bot's pass over the previous commits.

1. `detect.includes` was missing from both group-sync test fixtures, so they did
   not satisfy the `GroupConfig` they claim to construct. It went unnoticed
   because `tsconfig.json` is src-only; `tsconfig.test.json` reports it. The
   older of the two fixtures carried the gap in from the original commit.

2. `runGroupImpact` named its truncation reason in a variable computed before
   the truncated check, so on a fully complete result the variable read
   'incomplete-sync'. `truncationFields` discards the reason when `truncated` is
   false, so nothing surfaced — but a value that is wrong whenever it is unused
   is a trap for the next reader. Computed inline at the one call site that can
   consult it, which is also how the neighbouring call sites are written.

3. `Array.isArray` alone let a corrupt registry through. `['app/backend']` and
   `[{repo:'x'}]` are both arrays, and only the second reaches `cli/group.ts`'s
   `.join(', ')` — as `[object Object]`, a measurement the operator can read but
   cannot act on. Both readers now go through one `recordedRepoList` helper that
   requires an array of strings; anything else is "not recorded", the same as
   absent. Two more rows in the corrupt-value table cover it.

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

* fix(group): keep readRegistry's signature, and stop describing unreadableRepos as index-only

Two items from the check bot's blocking pass.

1. `readRegistry` gained an optional `opts` parameter last commit. That is
   source-compatible — every zero-argument call still compiles and behaves
   identically — but the contract check treats any parameter-list change on a
   symbol with outside callers as a break, and it is right that the safest
   version of this change touches that signature not at all. The strict read is
   now its own export, `readRegistryStrict()`, over a shared private body.
   `readRegistry()` is byte-identical to what it was; `syncGroup` is the only
   caller of the strict one, and the mode is legible at the call site instead of
   hiding in an options bag.

2. `unreadableRepos` is described everywhere as "the index could not be opened".
   That was accurate before this branch and is not now: making per-repo
   extraction all-or-nothing means a repo also lands there when an extractor
   throws partway with the index open fine. The two belong in one bucket
   because the consequence is one thing — none of that repo's contracts are in
   this sync — but the docs have to say so, or an operator reads `unreadableRepos`
   as a storage diagnosis and goes looking at LadybugDB for an extractor bug.
   Corrected on `ContractRegistry`, `BridgeMeta`, `SyncResult`, the `group_sync`
   tool description, and the `group sync` console output, which now says
   "Could not extract contracts from" rather than "Could not read the index for".

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

* fix(cli): stop calling an unreadable registry an old one in group status

`getStatus` reports `unreadableRepos` as `undefined` for two different reasons:
the field is genuinely absent, or it held something that was not a list of repo
paths and the shape gate declined to guess. The status line named only the
first — "registry predates this field" — so a corrupt value read as a merely
old registry.

That is the same shape of wrong answer this command exists to stop giving: a
condition we could not read, presented as a benign one we understand. The line
now names both, and asks for a sync either way, which is the fix in both cases.

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

* fix(group): close the three fail-open paths left on the safety boundaries

Follow-ups from the re-review of 31c2b6e81. All three of its blocking findings
reproduce; each is a place where unknown state still resolved to a confident
benign answer, which is the one thing this branch exists to stop.

1. Strict registry reading accepted malformed rows. `[{}]` is a JSON array, so
   it passed the shape check: every configured repo then failed to resolve into
   `missingRepos`, none produced a load ERROR, the total-failure guard stayed
   off, and a good contracts.json was replaced with an empty one at exit 0 —
   the same fail-open the strict mode was added to close, one level down from
   the file to the rows inside it. Strict mode now requires `name`, `path` and
   `storagePath` on every row and rejects the WHOLE registry if any row fails.
   Rejecting rather than filtering is the point: dropping bad rows would report
   the repos they name as unregistered, which is the same wrong answer again.
   `indexedAt` / `lastCommit` are deliberately not required — callers already
   default them, so demanding them would trade a fail-open for a fail-shut on a
   legitimate legacy registry.

2. A failed bridge publication could make impact look complete. `writeBridge`
   swaps `bridge.lbug` and writes `meta.json` as two operations, and this branch
   made that meta load-bearing: `runGroupImpact` derives its truncation fields
   from it. A sync interrupted between the two steps therefore left a NEW bridge
   beside the PREVIOUS sync's metadata, and an impact query read that as
   "complete". Fixed from both ends. The write path removes the old meta before
   the swap, so the window leaves metadata ABSENT rather than stale. The read
   path treats absent-or-unparseable meta (`version: 0`) as unknown provenance
   and reports a floor, which also covers the caught `writeBridge` failure in
   `syncGroup`. Over-reporting truncation on a bridge that is actually fine is
   the safe direction, and the next successful sync clears it.

3. `preserved` was returned when there was nothing to preserve. On a group's
   first all-unreadable sync the outcome was set before the prior registry was
   read, so the CLI told an operator "the contracts from the previous sync are
   preserved" about a file that had never existed. Split out as
   `no-prior-registry`, with its own console message.

Also widened the NUL guard to the source languages it claimed to cover. The
commit that added it said "every tracked source file" while the collector
stopped at the JS/TS family, so a raw NUL in tracked Python, Java, Go, Rust,
C/C++, Ruby, PHP, Kotlin, Swift, C# or shell would still have turned those files
binary unnoticed. Measured before widening: 2315 non-JS tracked source files,
zero hits, so this was an unforced gap rather than a tradeoff. A planted `.py`
fixture and a collector-coverage assertion keep it honest.

Every fix is mutation-verified: reverting each one individually turns its own
tests red (3, 2, 2, 1 and 1 failures respectively), and all pass together.

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

* fix(group): record the empty unreadable measurement instead of dropping it

Both writers omitted `unreadableRepos` when it was empty, which made the
tri-state this branch introduced unreachable in its most common case.

`ContractRegistry.unreadableRepos` is optional on the TYPE so a registry written
before the field existed still parses, and absence there means "not recorded".
But a sync that read every repo successfully HAS measured it, and `[]` is that
measurement. Dropping it collapsed "measured, none" into "never recorded", so
after every clean sync `gitnexus group status` printed

    Last sync unreadable repos: not recorded
    (the registry predates this field, or its value could not be read)
    Re-run `gitnexus group sync` to record it.

about the sync that had just succeeded. The distinction is only worth having if
the writer commits to it, so both `contracts.json` and the bridge's `meta.json`
now record the field whenever the sync supplied it, `[]` included.

The check bot found this on the bridge writer and attributed the consequence to
`group status`. The consequence is real but it is not the bridge's: `getStatus`
reads `contracts.json` and never touches `BridgeMeta`, whose only consumer is
`runGroupImpact` — where absent and empty are already equivalent. So the
user-visible half was in the registry writer, one file over from where it was
reported, and both are fixed.

Also fills in `DetectConfig.includes` (and `workspace_deps`) across the group
test fixtures that predate those fields. These are pre-existing on main and are
a no-op at runtime — `undefined` and `false` are both falsy at the gate — but
they are the same defect the bot flagged as an error in the new fixtures, and
`tsconfig.test.json` reported eleven of them. That file is not in CI, which is
why they survived; the group tree is now clean of them.

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

* test(group): stop two bridge-metadata tests claiming coverage they do not have

Both were named for the swap window and neither injects a swap failure.

"drops the previous meta.json before swapping the database file" runs two
successful writeBridge calls. Its assertions hold with the removal in either
position, because writeBridge overwrites meta.json at the end regardless — so
it cannot pin the ordering it is named for. Renamed to what it does cover, the
successful-rebuild replacement, with the limit stated in the body rather than
left for the next reader to discover.

"leaves NO meta.json when the swap fails partway" removes the file by hand
after a successful write, so it exercises readBridgeMeta's missing-file
contract, not writeBridge. That contract is worth pinning on its own — version 0
is the signal runGroupImpact fails closed on — so the test stays, under a name
that says so.

The ordering itself is pinned in bridge-meta-swap-window.test.ts, which mocks
retryRename to throw on the bridge.lbug swap and asserts the previous sync's
metadata cannot survive it. Both renamed tests now point there, so the coverage
is findable from the place someone would look for it.

No production code changes.

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

* fix(group): pair bridge metadata to its database instead of deleting it

The previous commit closed the swap/metadata window by removing meta.json before
the database swap, so the window would fail to "absent" rather than "stale". That
was the wrong trade, and it destroyed recoverable state.

The old database's move to `.bak` sits inside a catch that swallows failures, not
just "no existing db". When that rename fails — a held read-only handle does this
on Windows, and a long-lived MCP server holds one — the failure is swallowed, the
following `tmp -> bridge.lbug` throws, and writeBridge exits with the OLD database
still in place and perfectly valid. Its metadata was already deleted. Cross-repo
impact then answers "we cannot say" for that group until some future sync
succeeds, and if the cause is a held handle or permissions there is no such sync.
A working feature, destroyed permanently to close a narrow window.

Deleting also only chose which way the window failed; it never closed it.

So destroy nothing, and make the pair self-describing instead: writeBridge stamps
the database's size and mtime into the metadata it writes, and
`bridgeMetaMatchesFile` lets a reader ask whether the two still belong together.
`runGroupImpact` treats a mismatch the same as absent metadata — provenance
unknown, report a floor. A metadata file left over from an earlier sync cannot
match a freshly renamed database, and a sync that fails before the swap leaves a
matching pair untouched. Metadata written before the stamp existed is
unverifiable rather than stale, and is accepted: failing those closed would mark
every pre-existing bridge incomplete, trading a narrow window for a repo-wide
regression.

The swap-window test now distinguishes the two failure shapes, because they want
different answers. When every rename fails the old database never moves, so the
surviving metadata still matches it and impact keeps answering from it. When only
the final rename fails the old database has already reached `.bak` and no
database is in place, so the metadata correctly matches nothing — and
`ensureBridgeReady` fails loudly on the absent file, which beats a silent floor.

Mutation-verified: reinstating the delete, neutering the pairing check, and
dropping the stamp each turn 2, 3 and 3 tests red respectively.

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

* chore: keep TypeScript diffs readable after a NUL leaves the tree

Git decides a pair is binary when EITHER blob carries a NUL, and it only
sniffs the first 8000 bytes. `gitnexus/src/core/group/sync.ts` carried one
at byte 5132 on main. This branch removes it, but the base side still has
it, so the file renders as "Binary files differ" in the pull request: no
hunks, no inline comments, and no three-way merge — however clean the head
side is. A head-side byte guard cannot detect that, by construction, since
it only ever sees the working tree.

Setting the `diff` attribute stops the heuristic from hiding the change.
It does not mark the files binary, does not imply `text`, and does not
change how blobs are stored, normalized, or checked out — the root
`* text=auto eol=lf` still governs all of that. It affects diff generation
and rendering only.

Locally this turns the branch's own sync.ts diff from `Bin 17612 -> 25346
bytes` into 154 insertions and 16 deletions.

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

* fix(group): answer "provenance unknown" for malformed bridge metadata

`readBridgeMeta` guarded the read and the parse but not the SHAPE of what it
parsed, then cast the result. `runGroupImpact` spread both repo lists straight
into a Set, so a `meta.json` whose `missingRepos` held an object threw a
TypeError out of the entire cross-repo query — and threw it from a point after
`ensureBridgeReady` had taken the bridge lease and before the `try` whose
`finally` releases it, so every such query also leaked a refcount the cached
handle could never get back. A malformed file is a reason to answer "we cannot
say", never a reason to crash the question.

The shape gate now lives where the metadata is read, mirroring the one
`service.ts` already applies to the registry's copies of these same two lists.
Each list is judged independently: a garbage `unreadableRepos` no longer
discards a `missingRepos` that was genuinely measured. A list that was present
but unusable is dropped rather than normalized to `[]`, because an unreadable
value is not a measurement of zero — the new reader-side `repoListsUnreadable`
carries that distinction, and `runGroupImpact` folds it into the same
provenance-unknown verdict it already reaches for `version: 0` and for
metadata that does not pair with the database beside it.

A root that is not an object is closed too. `JSON.parse` succeeds on `null`,
`7` and `[]`; the first threw on `.version`, and the other two read `undefined`
and sailed through the version gate as if the bridge had been vouched for.

Both provenance values moved inside the protected region and are initialized
fail-closed, so a future throw between the lease and the walk releases rather
than wedges.

`repoListsUnreadable` is reader-side only: the sole `writeBridgeMeta` call site
builds a fresh literal, so nothing persists it and no schema version moves.

Mutation-verified: reverting the shape gate alone turns 4 tests red — the three
malformed-list scenarios plus the handle-release regression.

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

* fix(storage): reject registry rows that cannot identify a repo

The strict read's row gate gave `typeof v === 'string'`, and `typeof '' ===
'string'`. A row whose `name` was blank therefore passed as resolvable, then
matched nothing in `defaultResolveHandle` — putting every configured repo in
`missingRepos` and presenting an unusable registry as a clean answer about an
empty one. That is the same unreadable-as-missing fail-open the strict mode
exists to close, one level further in. A blank `storagePath` is worse than
useless: it joins to a relative `lbug` under the current directory, so the sync
opens an index that is not the repo's.

Both now have to be non-blank after trimming. `path` stays at the bare string
check, on the same reasoning that already exempts `indexedAt`/`lastCommit`:
require only what resolution depends on to IDENTIFY the repo. This gate rejects
the whole registry and the registry is machine-wide, so a field tightened past
what identification needs would let one blank value in one row break every
group sync on the machine — including groups whose repos all resolve. A blank
`path` still yields a working handle; `defaultResolveHandle` does read it, but
only for the pool id and `repoPath`, neither of which decides whether the row
names a repo.

The error now says what is actually wrong instead of naming three fields that
are all present.

Mutation-verified in both directions: dropping the trim turns the three
rejection tests red, and applying the wider fix that was considered and
declined — tightening `path` too — turns exactly the counter-case red, so that
test genuinely pins the narrow reading rather than passing either way.

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

* fix(group): bound the per-repo contract staging append

`autoContracts.push(...repoContracts)` passes every staged contract as a
separate argument, and the engine caps how many arguments one call may take.
That cap is a function of the host's available stack, so it is a different
number on every machine — this one accepts a 125k-element spread and dies at
150k.

The spread itself is not new; what it carries is. Before staging, this line
appended a single extractor's output as it came back. Staging made it carry the
whole repo's, which is enough for a large repo to raise `RangeError: Maximum
call stack size exceeded` on the one line whose job is to commit work that just
succeeded. The throw lands in the catch below, so the sync reports a repo whose
extractors all ran cleanly as one whose index could not be read — a crash
wearing the costume of a diagnostic.

A bounded loop replaces it: the count a repo can stage is now bounded by memory
rather than by how much stack the process happened to get.

The guard is structural, not size-based, and deliberately so. A "make the
fixture big enough to crash" test passes against unfixed code on any host with
a larger stack, which is exactly the guarantee a regression gate cannot give
up. It walks the AST and locates the region by role — the `const` staging
buffer typed `StoredContract[]`, then the extractor `try` that is a direct
statement of the block declaring it — so renaming either identifier keeps it
pointed at the same code. `.apply()` is rejected alongside spread, being the
same hazard in different syntax.

Direct statements only, because `syncGroup` wraps this whole section in its own
try/finally for the lease sweep, and that ancestor reads the buffer too.
Matching any enclosing `try` pulls in the entire function body — including the
two windowed-manifest spreads, which are bounded by the window size and are not
what this fixes.

Mutation-verified in both directions: restoring the spread turns the gate red
naming that line alone; deleting a manifest-window spread, and separately
adding a third one, both leave it green.

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

* fix(group): keep unreadable repos out of manifest contracts too

Per-repo staging closed one door: a repo whose extractor threw contributes
nothing through the direct path. Deferred manifest resolution was a second
door, still open. It derives its known-repo set from the resolved-handle map,
which kept an entry for a repo the same run had already declared unreadable —
so the sync re-opened that index and resolved symbols against a database it had
just told the operator it could not read.

Deleting the handle in the catch stops the re-open, but it does not satisfy
R2 on its own: `ManifestExtractor` resolves both endpoints of a link and emits
a contract for each, and for an endpoint with no executor that contract is
still emitted with a synthetic UID. The registry ended up naming a repo the
same run reported unreadable.

So the emitted output is filtered by ENDPOINT, not by link. Dropping the whole
link would delete the healthy partner's contract as well — a repo losing its
own output because a neighbour's index would not open, which is wider than the
requirement and destroys good data to suppress bad. A cross-link is different:
it asserts something about a pair, so if either end is unreadable there is
nothing left to anchor it to, and a half-anchored link is exactly the
confident-about-what-it-could-not-read answer the registry must not give.

Deleting the handle also changed what the operator gets told, so the warning is
split. An unreadable repo IS configured; letting it fall into the "references
repos not in config.repos" branch states something false and sends the reader
to edit group.yaml for a problem only re-indexing fixes. It now gets its own
message naming what was actually omitted.

Mutation-verified four ways: reverting the endpoint filter turns three
scenarios red; the over-broad whole-link variant turns the healthy-partner
scenario red and nothing else; removing the handle delete turns the
no-re-open scenario red; and reverting the warning split turns the operator-
message scenario red. Every assertion reads the written contracts.json rather
than the in-memory result.

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

* refactor(group): keep readBridgeMeta's signature stable across the shape gate

The shape gate landed by widening the return type to a reader-only
`ReadBridgeMeta extends BridgeMeta`. That is source-compatible — a covariant
return, one added optional field, every existing caller unaffected, typecheck
and suite clean — but the contract check reads it as a changed signature with a
caller left behind, and blocks the merge on it. This branch already hit the
same wall on `readRegistry` and settled it the same way: leave the signature
alone and make the difference legible some other way.

So the flag moves onto `BridgeMeta` itself as an optional, documented,
never-persisted field, and `readBridgeMeta` goes back to the exact signature
its callers already compile against.

That is the better shape here anyway. The reader-only subtype would have split
the validation two ways: `openBridgeDbReadOnly` and `bridgeExists` both gate on
`meta.version`, and the normalization that comes with the gate is what stops a
`version: null` in a hand-edited meta.json from reading as `undefined` and
sailing through `version > 0` as though the bridge had been vouched for. One
type keeps all three callers behind the same guard.

Nothing persists the flag: `writeBridgeMeta`'s only caller builds a fresh
literal, so it cannot round-trip to disk.

No behavior change — pure type restructuring. 927 tests pass, typecheck clean.

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

* fix(group): stop treating a half-written bridge stamp as a verified match

`bridgeMetaMatchesFile` joined its two `undefined` checks with `||`, so
metadata carrying a size and no mtime — or the reverse — returned `true`, the
same answer it gives a fully verified pair.

A stamp is a PAIR. Both halves absent is the legacy shape: metadata written
before stamping existed, which cannot be verified either way and is accepted
deliberately, because failing it closed would mark every pre-existing bridge
incomplete until re-synced. Exactly one half present is not that. Something
wrote a stamp and did not finish, which is precisely the condition stamping was
added to detect — so the check handed back "verified" for the one shape that
most deserves suspicion, and a cross-repo impact query built on it would report
a confident answer about a database its metadata cannot vouch for.

The two states are now separated: neither half present accepts, exactly one
rejects as provenance-unknown, both compare against the file as before.

Found by the repository's own contract check, not by the plan.

Mutation-verified: restoring the `||` form turns both half-stamp cases red
while the legacy and fully-stamped controls stay green, so the pair genuinely
pins the distinction rather than passing either way.

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

* fix(group): pair unstamped bridge metadata by write order, before any open

Unstamped metadata was waved through: `bridgeMetaMatchesFile` returned "matches"
for any pair with no stamp to check, so the stale-meta-beside-a-new-database
window stayed open for every bridge written before stamping existed, and
`runGroupImpact` spent that metadata's completeness as fact.

`writeBridge` renames the database into place and writes the metadata after, so
`meta.mtime >= db.mtime` holds for any pair written together — including by
builds that predate the stamp. A database strictly newer than the metadata
beside it can only come from a swap whose metadata write did not land. That is
the fallback now. It is a heuristic on write order, not proof of provenance, and
it is wrong in two directions: a stale metadata file touched after the swap
still reads as paired, and a pair whose clock stepped backwards between the two
writes reads as unpaired. Both are recorded at the code; the second is the safe
direction. Equality counts as paired, or a coarse-granularity filesystem would
reject every legacy bridge for a reason that is about the filesystem.

The verdict is now taken in `ensureBridgeReady` BEFORE the database is opened,
and carried on the metadata rather than recomputed afterwards.

That ordering is load-bearing, not tidiness. Impact and trace both open the
bridge and only then ask about provenance, so on any platform or LadybugDB
build where a read-only open advances the file's mtime, every pre-stamp bridge
would report provenance-unknown from its first query onward — the exact
repo-wide regression this rule was chosen to avoid, arriving as a silent
downgrade rather than an error. It does not happen on Linux, which was measured.
It cannot be measured on Windows: pinning it by really opening the database
needs an in-process write→read reopen of the same bridge.lbug, which is a
documented limitation there. Rather than ship a Windows-skipped test and leave
the assumption unverified on the platform whose file semantics are most likely
to differ, the check moved ahead of the open so no platform has to be trusted.

The new guard forces the hostile case on every platform: the open is stubbed to
advance the database's mtime, and the verdict must still be "paired". It is
registered in the cross-platform list so the Windows and macOS shards run it,
and it has a control so it cannot pass vacuously.

Two existing fixtures mocked `readBridgeMeta` to return a stamped-era version
while never writing a meta.json — a state production cannot reach, since a
non-zero version can only come from a file that exists. They now write the
metadata their own mock claims to have read, rather than the helper being
loosened to accept metadata it cannot stat.

Mutation-verified twice: reverting the write-order branch turns both rejection
cases red while all four legacy-accept cases stay green, and moving the pairing
call back after the open turns the ordering guard red on its own.

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

* refactor(group): compute cross-repo completeness in one place

Three surfaces can return a partial cross-repo answer — impact, trace, and the
contract listing — and each decided for itself whether it was complete. Impact
carried the structured triple; trace said it in prose, if at all. An agent
reading a not-found trace had no machine-readable way to tell "there is no
path" from "there may be a path in a repo this sync could not read", which is
the difference between an answer and a floor.

`crossRepoCompleteness` is now the one computation, and its input deliberately
does not name where any of it came from. `BridgeMeta` is not in the signature
and must not be: `groupContracts` answers the same question from contracts.json
and never opens a bridge, so `version`, `repoListsUnreadable` and
`pairedWithDatabase` do not exist on that path. Each caller derives its own
`provenanceUnknown` — the bridge callers through `bridgeProvenanceUnknown`,
which stays separate for exactly that reason — and passes the boolean in.

Scope arrives as a predicate rather than a repo list or a subgroup, so
narrowing a query's scope stays a change to one argument at the call site.

The trace results now carry `truncated` / `truncationReason` / `riskEpistemic`
like impact does. `notes` is untouched; it remains an addition to the machine
channel, never the channel.

One correction to the approach as written: it said to pass the trace's two
endpoint repos as the predicate, but a destination trace declares no `to`. It
asks where a call lands, so any member may hold the answer — and an unreadable
provider repo is precisely how "no outgoing ContractLink leaves this repo"
becomes a wrong answer rather than an empty one. Filtering that path to the
`from` repo would have reintroduced the bug this unit exists to close, so it
passes every repo and a test pins it.

Two pre-existing paths become consistent with the vocabulary as a result: a
crossing-capped result now reports `truncationReason: 'partial'` alongside the
`truncated` flag it already set, and the destination path's `ambiguous` returns
now report the cap its `ok` and `not_found` siblings already reported. Both are
additive — no field is removed, and no `truncated` flips from true to false.

`truncationFields` returns a discriminated union now, so `truncationReason`
reads without a fallback on the branch where it cannot be absent.

Mutation-verified: reverting the provenance fold alone — one line in the shared
helper — turns 8 tests red across both surfaces, 2 new trace scenarios and 6
existing impact ones, which is the point of there being one helper.

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

* fix(group): narrow the incomplete-repo set to the query's declared scope

A subgroup-scoped impact query was marked a lower bound by repos it had
explicitly excluded. The fan-out already drops every neighbour outside the
subgroup, so those repos could not have contributed a crossing to the answer —
and a completeness marker that fires on results it does not describe is how a
caller learns to ignore the marker.

The scope is the query's DECLARED one, not the one the walk reached. An
incomplete repo's contracts are absent from the bridge by definition, so it is
never in the traversed set; filtering on what was traversed would empty the
intersection on every query and silently restore the fail-open this channel
exists to close.

Declared scope here is the subgroup PLUS the query's own repo, which the
approach did not account for. The walk starts from that repo's contracts in the
bridge, so when it is the repo the sync could not read there are no crossings
to find under any scope — and a subgroup excluding it would have turned that
vacuum into a confident "nothing depends on this", for a tool an agent uses to
license a delete. That case reported a floor before this change, so narrowing to
the subgroup alone would have been a regression. The union only ever widens the
in-scope set, so it cannot re-mark a repo the query excluded.

Membership goes through the existing `repoInSubgroup` in both clauses, `exact`
for the origin equality, rather than growing a second notion of what it means
for a repo path to be in scope.

Sound only while `MAX_SUPPORTED_CROSS_DEPTH` is 1 — at depth 2 an out-of-scope
repo can sit between two in-scope ones — and that constraint is recorded at the
intersection.

Unscoped queries are byte-for-byte unchanged: `repoInSubgroup` answers true for
an absent subgroup, so the intersection is the whole set.

Mutation-verified: restoring the unfiltered predicate turns exactly the two
scoped cases red while the unscoped control and both in-scope guards stay green.

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

* fix(group): keep the preserved registry and the bridge from disagreeing

A total-failure sync refreshed contracts.json's diagnostic lists and left
meta.json alone. But meta.json, not contracts.json, is where runGroupImpact
reads completeness from — so the registry said "this sync could not read
app/backend" while a cross-repo query answered `{ cross: [], truncated: false }`.
Two surfaces describing the same run, one of them wrong, and the wrong one is
the machine-readable one an agent uses to license a delete.

The preserve path now refreshes the same two fields in the metadata. The
database stays untouched: it still holds the contracts being preserved, and
rebuilding it here would be the one write that could lose them.

Refreshing metadata is not free, though, and the obvious version of it is a
fail-open. The rewrite moves meta.json's mtime to now while bridge.lbug's stays
old, so an unstamped pair whose database is NEWER than its metadata — the shape
the write-order rule exists to reject — would come out of a preserve sync
passing the check. Writing "no stamp" does not help; the write-order comparison
is exactly what the moved mtime defeats. The verdict has to be recorded in the
metadata, because the refresh cannot avoid moving the mtime.

So `provenanceUnknown` is persisted whenever the existing pair does not already
check out, the existing stamp fields are carried through verbatim rather than
dropped, and `bridgeMetaMatchesFile` rejects the marker ahead of both the stamp
and the write-order heuristic. A pair that already matched is re-stamped
instead, which also upgrades a legacy unstamped-but-paired bridge to an exact
stamp. No preserve run can increase the number of pairs that pass the check.
The marker self-clears: `writeBridge` builds fresh metadata and never sets it.

`BridgeMeta` carries two reader-side fields documented as never persisted, and
this is the first code in the repo that reads metadata and writes it back. Both
are stripped explicitly before every write. `pairedWithDatabase` is the
dangerous one — persisted, it would tell every future reader the pair had been
verified — and a test seeds both on disk to pin that neither survives.

The write is not wrapped in a catch, unlike writeBridge on the success path.
There contracts.json is canonical and already written, so a stale bridge is a
recoverable degradation; here the write IS the guard against a confident wrong
answer, and swallowing its failure would reinstate the fail-open it closes.
`writeContractRegistry` above is unguarded into the same directory for the same
reason.

A group with neither file writes nothing: `readBridgeMeta` already answers
`version: 0` for an absent file, so a written one would say what the absence
already says while inventing state for a bridge that has never existed.

Mutation-verified three ways: dropping the marker write turns 6 red including
both laundering scenarios; moving the marker check below the stamp branches
turns the unstamped-laundering case red; removing the field stripping turns the
never-persisted test red. Each restored byte-exactly and re-verified.

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

* fix(group): report group_contracts' completeness in the shared vocabulary

`group_contracts` returned contracts and cross-links and said nothing about
whether that listing was the whole story. An agent reading it after a sync that
could not open half the group got a confident-looking list with no way to tell
it was a floor — the same fail-open the impact path already closed, on a surface
that had no channel for the answer at all.

It now returns the registry's two diagnostic lists and the structured triple,
folded through the same helper the impact and trace surfaces use, so the three
cannot drift. The helper takes no `BridgeMeta` precisely so this path — which
reads contracts.json and never opens a bridge — can share it.

The three registry states stay distinguishable, which is the point:
  - key absent: the registry predates the field and has no opinion about which
    indexes opened, so the key is omitted rather than invented as `[]`, and the
    listing reports a floor. It cannot say which repos the sync failed to read,
    so it cannot claim to be complete.
  - key present and empty: measured, clean, not truncated.
  - key present and populated: the repos, and a floor.

`incompleteRepos` is dropped on this surface alone: both lists it derives from
are returned verbatim beside it, and a third name for the same repos is drift
waiting to happen.

The import is lazy, matching `groupImpact` and `groupTrace` in this same class.
`cross-impact.js` statically pulls the native LadybugDB binding through
`bridge-db.js`, and `service.ts` is loaded by every `gitnexus group` subcommand
including ones that touch no database.

One fix inside the same file that this unit forced: the registry loader gated
`missingRepos` with a bare `Array.isArray`, which admits `[{repo:'x'}]`. That
was inert while nothing read the list, but this change both returns it and folds
it into the completeness answer — so an unreadable value would have been printed
as a repo name and would have flipped `truncated` on garbage. It now uses the
same `recordedRepoList` gate `group status` already applies to the same field.
`missingRepos` has always been required, so unlike `unreadableRepos` it has no
"not recorded" state to preserve and an unreadable value degrades to empty.

Mutation-verified: reverting the fold alone turns 14 tests red and leaves the
control — the contract and cross-link payload this tool has always returned —
green.

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

* fix(cli): stop dropping group contracts' completeness fields on the way out

`group contracts --json` destructured `{ contracts, crossLinks }` from the
service payload and rebuilt an object from just those two. Everything else the
service returned was discarded on the way to stdout — so the completeness
fields the MCP tool now carries were invisible at the CLI, and the two surfaces
disagreed about the same registry.

It prints the payload whole now. A field added to the service reaches `--json`
without a matching edit here, which is the point: the re-serialized subset was
a second place that had to be remembered, and it was not.

The human-readable path gains the same signal in words. A listing built from a
sync that could not read part of the group shows counts that are a floor, not a
census, and it named neither fact. It now says so and names the repos when the
registry recorded them — and says the sync did not record which repos it could
read when it did not, because a listing that cannot say what it is missing is
still incomplete.

Mutation-verified: restoring the re-serialized subset turns the `--json` case
and the control red.

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

* fix(group): tell a missing registry entry apart from an unreadable one

`group status` printed MISSING for both "this repo has no row in the registry"
and "the registry itself could not be read", so an operator whose registry.json
was corrupt was told every repo was unregistered — and sent to re-register them
instead of to the one file that was actually broken.

The two are now separate. `missing` keeps its old meaning and still flags every
unusable repo, so an older consumer is unaffected; `unresolvable` is additive,
always present, and carries the reason that produced it.

This is the one caller that has to make that distinction, so it takes the
strict global-registry read. `readRegistry`'s `catch { return [] }` collapses a
malformed registry into an empty one, which is indistinguishable from a genuine
absence and is exactly what produced the wrong label. The cost is accepted
knowingly and recorded at the call site: the strict read rejects the whole
registry when any row fails to identify a repo, so one malformed row renders
every member unresolvable — including members whose own rows are fine. That is
the honest verdict, and it is reported as an unresolved state rather than a
clean one.

Choosing between the two labels needs to know whether a row exists at all,
which `registryIdentifies` answers by mirroring the two tiers the resolver
matches a bare group-config value on — registry name, case-insensitively, and
repo path. It deliberately stops short of the hashed-id and partial-name tiers:
those exist to be generous about what an operator typed, while this only picks
a label, and a looser match would relabel a genuine registry miss as an
unresolvable row — the same conflation this change removes, pointed the other
way.

The plan's third failure mode — a row that resolves but whose storage path
cannot be opened — turns out to be unreachable: `loadMeta` returns null on
every error and `checkStaleness` catches everything, so nothing after
`resolveRepo` inside the try can throw. The reachable per-repo case is
`resolveRepo` itself throwing, as it does for two registered clones sharing a
name, and that is what the tests drive end to end through the real CLI. The
code still handles the plan's case correctly if those helpers ever start
throwing.

Mutation-verified: reverting the split turns 6 unit and 2 CLI cases red while
both controls — a genuine miss, and a healthy group — stay green.

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

* fix(cli): say what the preserve path actually does to contracts.json

The sync summary announced "Did NOT write contracts.json" on the branch that
writes it. The preserve path rewrites the file — keeping the previous sync's
contracts and cross-links, replacing only the two diagnostic lists — so an
operator who checked the mtime and found it moved was told the opposite of what
had happened, on the command this PR exists to make legible.

It now says the previous contracts were kept and names what changed.

The no-prior-registry branch is narrowed for the same reason. It claimed
nothing at all was written, and that is no longer true either: this path still
records the run against an existing bridge's metadata. The claim is now scoped
to contracts.json, which is the file it can actually speak for.

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

* fix(group): stop the total-failure log promising a preservation that did not happen

The warning fired before the prior registry was read, so it could only ever
promise one of the two things that might be true — and it promised the wrong
one to every group that has never synced: "keeping the contracts from the
previous sync" about a file that does not exist. The console line for that same
run, driven by `registryOutcome`, said the opposite.

It now lives inside the branch, after the read, with one message per outcome
chosen at the point the outcome is decided. The log and the console cannot
disagree, because the same fact selects both.

Both messages keep the warn level and the two repo lists.

Mutation-verified: reverting the split turns the no-prior-registry case red
while the preserved case — whose claim was already true — stays green. The
dry-run test's log filter was also widened to the sentence both messages share,
or the new wording would have made that assertion match nothing and pass
regardless.

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

* fix(group): make the bridge-failure warning describe what the code guarantees

The warning after a failed `writeBridge` promised that cross-repo impact would
report `truncated` until a sync succeeded. Nothing on that path produces that
signal.

The swap is the last step: `writeBridge` builds the new database in a staging
directory and only then moves the old one aside. A failure during the build
therefore leaves the previous sync's `bridge.lbug` exactly where it was, beside
the `meta.json` stamped for it — a pair that passes `bridgeMetaMatchesFile`
with the previous run's `unreadableRepos`. The next cross-repo query answers
`truncated: false` from superseded contracts, which is the opposite of what the
operator was told to expect, and worse than being told nothing.

The warning now says what is actually true: contracts.json is intact and
canonical, the bridge was not replaced, cross-repo queries may still answer from
the previous sync's contracts, and nothing marks them as superseded.

The metadata is deliberately NOT re-stamped to make the original promise true.
That would recreate exactly the metadata/database mis-pairing the stamping on
the preserve path exists to prevent, and the comment at the warning records it.

The claim is asserted against captured log output rather than left to the state
tests. Those check which pairs match and what the preserve path writes; every
one of them stays green while this sentence reverts to promising a truncation.
An unasserted user-facing branch is the defect class this change is closing, so
it does not get to close it while remaining one.

No filesystem shape makes the real `writeBridge` fail while
`writeContractRegistry` succeeds — they write into the same directory one line
apart — so the failure is armed through a pass-through wrapper on the file's
existing mock. It delegates byte-for-byte unless a test arms it, and is reset
around the new suite.

Mutation-verified: restoring the original wording turns its own assertion red
and nothing else.

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

* docs(mcp): name every registry outcome group_sync can actually return

The tool's description told agents `registryOutcome` is 'written' or
'preserved'. It has a third reachable value: 'no-prior-registry', returned when
nothing could be read AND there was no previous contracts.json to carry
forward. An agent calling this tool against a group that has never synced got a
value its own tool description said did not exist, and no way to tell it apart
from the case where the previous contracts survive.

The distinction is the whole point of the value. After 'preserved' there is a
registry to read — stale, but real. After 'no-prior-registry' there is nothing
on disk at all, so a following group_contracts or group_impact has no registry
rather than an old one. Those need different responses from the caller.

'not-attempted' stays undocumented because it is unreachable through this tool,
and a guard asserts it stays that way.

The code comment above the annotations claimed the preserve path does NOT write
contracts.json. It does — it rewrites the file, keeping the previous contracts
and cross-links and refreshing only the two diagnostic lists, which the CLI's
own summary was corrected to say a few commits ago. Left alone it would have
re-seeded the same wrong claim next to the text that now states it correctly.

Mutation-verified: deleting the 'no-prior-registry' sentence turns the guard red.

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

* docs(mcp): explain structural incompleteness on the impact tool and status resource

The impact tool's GROUP MODE paragraph described one cause of truncation — the
fan-out running out of room — and left an agent to assume that was the only one.
So a `truncated: true` carrying `truncationReason: 'incomplete-sync'` read as
"retry with a smaller scope", when retrying returns the identical floor forever:
the repos are absent from the bridge itself, and only a re-sync puts them back.
The old text also said the response carries the truncation fields "when it stops
early", which is wrong for that case — `truncatedRepos` names repos even when
ZERO crossings to them were attempted, because their contracts were never in the
bridge to cross to.

The paragraph now branches on the reason and gives each its remedy: 'timeout'
and 'partial' are runtime limits where a retry or a larger budget can help;
'incomplete-sync' is structural and the remedy is `group_sync`.

The reason union is now derived from an exported `as const` array rather than
written as a bare type. A type-only union gives a guard nothing to enumerate, so
the guard has to hand-list the members — and then it passes forever the moment a
fourth is added, which is the exact regression it exists to catch. The guard
iterates the runtime array instead. Verified by appending a probe member and
watching it go red, then removing it. The resolved type is unchanged; every
importer uses `import type` and none needed an edit.

The status resource said "Group index / contract staleness" and nothing about
the distinctions its payload now carries. It explains all of them: a repo absent
from the registry versus one whose entry could not be resolved, and the
`unreadableRepos` tri-state where an ABSENT key is not an empty one — absent
means the last sync never recorded what it could read, so cross-repo answers for
that group are a floor.

The description an MCP client actually receives lives in `getResourceTemplates`,
not in the context resource's inventory line the plan pointed at. Both now carry
the vocabulary, so the two surfaces cannot disagree about the same payload.

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

* feat(group): serialize group syncs behind a fail-closed per-group lock

Two concurrent syncs of one group could lose one another's writes. Both read
the prior registry, both built contracts, both wrote — last writer won, and the
loser's work was gone with nothing reporting it. A group sync is long and
expensive and is exactly the operation whose lost update destroys contracts.

`syncGroup` now takes a lock for the whole persist section, acquired exactly
once. `acquireIndexLock` is not reentrant, so a second acquisition anywhere
below would deadlock the happy path rather than an edge case; `withGroupSyncLock`
has one call site and nothing inside it re-acquires.

The lock lives on a dedicated `sync-lock` directory inside the group directory,
mirroring the registry lock's dedicated directory rather than reusing the
resource's own — a lock directory that could collide with a per-repo index slot
repeats a bug the registry lock's comment already warns about.

It fails CLOSED, which is the opposite of `withRegistryLock` and deliberately
so. That one degrades to unlocked because it guards a sub-second JSON merge on
a latency-critical path; here running unprotected is the outcome the lock
exists to prevent. Three exits are covered: a timeout, an unwritable lock
directory, and the lock-free degradation the primitive performs silently.

That third exit needed a change in `index-lock.ts`, and it is the one declared
exception to keeping this work inside core/group/. `acquireIndexLock` answers a
read-only or permission-denied filesystem with a no-op handle that is
byte-identical in shape to a real one, so a caller for whom lock-free is not an
acceptable outcome could not tell the difference. It now carries an optional
`lockFree` marker. The change is additive by construction: no signature moves,
no control flow changes, nothing about when or how a lock is taken changes, and
every caller that ignores the field behaves exactly as before.

A filesystem probe inside the group module was considered and rejected on
evidence: `selectBackend` returns `socket` on Linux and Windows, where
`acquireViaSocket` never touches the filesystem and this branch cannot occur —
so a probe would refuse syncs on the two platforms that never degrade while
missing the one that does.

The timeout ceiling is a named 600s constant passed explicitly. The magnitude
matches the primitive's own analyze-sized default because a group sync is
analyze-shaped and a legitimately queued second sync must be able to wait out a
full first one. Passing it explicitly is about the override, not the magnitude:
`resolveTimeoutMs` resolves `GITNEXUS_INDEX_LOCK_TIMEOUT_MS <= 0` to Infinity,
which would turn fail-closed into a hang.

Cross-process exclusion is proved with a real spawned holder, not an in-process
mock, which cannot demonstrate the property this exists for. The lock-free
scenario pins `GITNEXUS_INDEX_LOCK_BACKEND=file` — unpinned it would pass on
two of three platforms while measuring nothing — and produces the failure by
injecting EACCES on one syscall rather than by chmod, so it runs identically on
Windows instead of being skipped there.

The CLI reports the failure through pino rather than a bare stderr write, which
this package lints as an error to keep that migration moving, and the test reads
the `msg` field rather than a raw substring — matching on the raw text would
have passed only by accident of quoting and would go green again if the line
were downgraded.

Nothing is skipped on any platform, and the test is registered for the
cross-platform shards.

Mutation-verified: removing the lock acquisition turns 6 scenarios red;
removing the lock-free rejection turns the degradation scenario red on its own.

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

* fix(group): make the sync-lock timeout name a cause it can establish

The fail-closed lock surfaced the primitive's own timeout message to users for
the first time, and that message says the wait was on "another gitnexus
analyze" — a cause its detection path cannot establish. It is the same
confident-about-what-it-could-not-determine claim this PR exists to remove,
inherited rather than written.

The wrapper now throws its own. It names the group, the lock directory, the
operation, and the elapsed wait, and it says plainly that nothing was written.

The holder clause branches on `holderKnown`. The socket backend exposes no owner
metadata and reports a placeholder pid of -1, so on that backend — and on the
file backend's malformed or vanished-lock timeouts — the message says the lock
stayed held but the backend cannot identify who held it, rather than printing a
pid that means nothing.

The elapsed wait is measured by the wrapper. `IndexLockTimeoutError` carries
only `holder` and `holderKnown`; the figure exists solely inside the string
being replaced, so it had to be taken rather than read.

One pre-existing assertion changed with it: the timeout case asserted
`'Timed out after 600000ms'` from the inherited text, which is precisely the
message this replaces.

Mutation-verified: restoring the inherited message turns the three assertion
cases red and leaves the control — a real acquisition that succeeds — green.

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

* fix(group): stop a losing sync from downgrading the one that beat it to the lock

Serializing is not ordering. Both syncs run extraction outside the critical
section, so a total-failure sync that acquires second reads the winner's fresh
registry as `prior` and rewrites it with all-unreadable lists. The lock alone
does not prevent that — it only decides who goes second, and the loser then
overwrites a healthy registry with a description of its own failure.
Deterministically, not as a rare interleave.

The guard is a compare-and-swap on the registry file's own identity: stat
before acquiring, re-stat after, and write nothing when they differ. Identity is
presence plus size, mtime and inode — `writeContractRegistry` publishes through
write-then-rename, so a real replacement always changes the inode even if size
and mtime happen to collide.

Deliberately NOT keyed on `generatedAt`, for two independent reasons. It is
stamped when the registry object is built, before the lock is acquired, so a
winner that waited would write a value older than the loser's start. And the
preserve path carries it forward verbatim by design — it dates the contracts,
not the write — so after any preserve sync it does not date the write at all,
leaving the comparison blind on exactly the pairing this guards. A file-identity
compare also needs no cross-process clock agreement.

The skip reports the existing `preserved` outcome. Nothing was written and a
prior registry was kept, which is what that value already means; a new one would
falsify the guard asserting the sync tool's description names every reachable
outcome, and would fall through the CLI's outcome chain, which has no fallback.

The bridge metadata refresh is skipped too, which the plan did not specify.
`refreshPreservedBridgeMeta` stamps THIS run's repo lists into meta.json, and
meta.json is where cross-repo impact reads completeness — so writing it would
report as unaccounted-for exactly the repos the winning sync had just accounted
for. That is the same downgrade being refused, one file over. Skipping both is
what makes `preserved` an honest answer here.

Mutation-verified: removing the after-stat and the skip turns the three decisive
cases red while both non-misfire controls stay green.

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

* refactor(group): run the bridge swap inside the caller's critical section

The bridge swap needed the group lock, and could not take it: `syncGroup`
already holds it when it calls `writeBridge`, and `acquireIndexLock` is not
reentrant. Acquiring inside the swap would deadlock every sync on the happy
path rather than on an edge case.

So the body splits the way this repo already splits this shape — a lock-free
`writeBridgeUnlocked` whose precondition is that the caller holds the lock, and
a thin `writeBridge` wrapper that acquires it for direct callers, mirroring
`registerRepoUnlocked` / `withRegistryLock`. `syncGroup` calls the inner one;
everything else keeps calling `writeBridge` and is now serialized by it.

`writeBridge`'s exported signature is byte-identical to before, so no caller
changed and nothing about the exported surface moved.

The precondition is enforced by a comment naming the single production call
site, which is what the existing precedent does. A type could carry it, but the
repo's own answer to this question is a comment, and diverging here would make
this the odd one out for no additional guarantee.

`refreshPreservedBridgeMeta` is deliberately left unsplit. Its one caller is
already inside the critical section and it has no test callers, so an acquiring
wrapper would be dead code standing in for a guarantee the caller already
provides — and moving the lock inside it would be the second acquisition this
change exists to avoid.

Scope: this delivers writer-writer exclusion only. The reader-side promotion of
a leftover `.bak` into place runs on ordinary reads, outside any lock, and is
not claimed here — the pairing check remains the reader's defense. Confirmed as
live behavior while writing the crash-recovery test, which asserts on file
state rather than through `bridgeExists` for exactly that reason.

One test file beyond the two the unit named had to change: a suite mocks
`bridge-db` to inject a `writeBridge` failure and exercise the bridge-write
warning. Once the sync calls `writeBridgeUnlocked`, that fault was being
injected into a function the path no longer calls, and the test went red. The
mock is repointed.

Mutation-verified three ways. Pointing the sync back at the acquiring wrapper
deadlocks a single UNCONTENDED sync — the evidence that the nesting defect is
real and that this split is what prevents it. Removing the wrapper's
acquisition turns the direct-write exclusion case red. Making the lock-free half
acquire for itself turns the held-lock case red.

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

* test(hygiene): reach every tracked text file with the raw-byte guard

The guard claimed to protect tracked source from a raw NUL — the byte that
makes git classify a file binary and costs it its diff, its inline comments and
its three-way merge on GitHub. It matched on an end-anchored extension regex
covering the JavaScript family, so most of what this repo tracks was never
looked at: JSON, YAML, TOML, Markdown, snapshots, SQL, protobuf, the .NET
project files, the shell and batch scripts.

Worse, an extension regex cannot reach a file that has none. `Dockerfile`,
`CODEOWNERS`, `LICENSE`, the husky hook and every bare dotfile were unreachable
by construction — no amount of widening the pattern would have covered them —
so a second basename filter had to exist for the claim to be true.

It stays an allowlist rather than becoming "everything git tracks", because the
repo legitimately tracks binaries whose extensions must stay out.

The two filters together now collect every one of the 5000 tracked files except
31 — the 30 native prebuilds and one PNG — and those 31 are exactly the files
that carry a NUL. The allowlist no longer has a gap that is not a genuine
binary.

The planted-fixture cases route through the collector's own predicate rather
than straight into the scanner. The pre-existing fixture test bypassed the
filter entirely, so it could only ever prove the byte locator worked, never
that the collector would hand it the file — which is precisely how the gap
survived.

Mutation-verified both ways: removing the basename filter drops `.gitignore`
and `Dockerfile` from the planted results, and reverting the extension regex
drops `.json` and `.md`.

One added case is a preservation pin rather than proof — that tracked binary
formats stay out passes either way, and guards the allowlist from becoming a
denylist later.

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

* test(hygiene): stop the byte guard reading the vendored grammar tree

Widening the guard to every tracked text format also pulled in the vendored
tree-sitter grammars, and those are where the bytes are: four generated
`parser.c` files come to 62 MB between them, Kotlin's alone 33.7 MB. Excluding
that root drops 76 files but 66% of the bytes the scan reads — 97 MB down to
33 MB.

The exclusion is a single anchored prefix, matched case-sensitively with
`startsWith`, and both halves of that matter. A `vendor` path-SEGMENT match
would also drop first-party fixtures this repo tracks under directories named
`vendor` and `Vendor` — a Kotlin one, a PHP one, and three files under
gitnexus-web — silently narrowing coverage while the assertion pinned the loss
in place. Case-insensitivity would do the same to a `Vendor` directory at the
excluded root's own level.

The root is named in the guard itself, so the claim that it covers every
tracked text file stays honest about the one place it deliberately does not
look.

The cost comment was wrong and is now measured rather than estimated. It said
"the scan is ~10 ms" — ambiguous between locating the byte and reading the
files, and stale in its byte basis. Locating is ~14 ms; the reads dominate it
by two orders of magnitude, which is the actual reason for the concurrency pool
and the actual reason this exclusion is worth having. Every figure was
re-derived from the finished file rather than carried over from a draft.

The header's claim that `git ls-files` "never descends into vendor" was already
false — vendored code is tracked, so all 106 of its files were being reported
and read. Corrected here, where the distinction becomes load-bearing.

Registered in the cross-platform list first and given a shard weight second.
The weight table is only consulted for files already in that list, so a weight
entry alone is inert and the shard test filters unregistered keys without
complaining. The three-way split stays within 1.01x of ideal.

Mutation-verified three ways: a case-insensitive segment match, a
case-sensitive segment match, and a case-insensitive anchored prefix each turn
an assertion red.

The casing half was initially unfalsifiable — nothing tracked is named
`gitnexus/Vendor/`, so a tracked-set assertion could not distinguish it. Rather
than leave the claim unpinned or invent a fixture, it is pinned on the
predicate with a synthetic path; the tracked-set assertions pin the anchoring.

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

* test(group): make the strict-read test able to see which read ran

The file bound both registry exports to one mock:

    readRegistry:       (...args) => readRegistryMock(...args),
    readRegistryStrict: (...args) => readRegistryMock(...args),

so the case named for the strict read asserted a behavior it could not
attribute. Point the production call at the lenient export and every assertion
still holds, because the mock answers the same way whichever one is called.

That is not a hypothetical. With this file as it was, and `syncGroup` mutated to
call `readRegistry` instead of `readRegistryStrict`, all 32 tests passed — the
suite was blind to the exact substitution it exists to prevent, and the fix it
guards could have been reverted without a single red.

The exports now have separate mocks: the lenient one always resolves an empty
list, which is its real contract, and only the strict one is armed by the cases
that need a failure. The named case also asserts directly that the strict read
was called and the lenient one was not, so the attribution is explicit rather
than implied by an outcome.

No tests added — the unit is about what the existing ones can see.

Mutation-verified: the same substitution now turns 24 cases red, including the
named one, and everything stays green unmutated.

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

* test(group): pin the CLI output branches this PR introduced

The three sync outcomes and the status table's new labels had no assertions.
Every one of them is a sentence about what happened on disk, and this PR
corrected several that were false — a preserve branch that announced it had not
written the file it rewrites, a status table that called an unreadable registry
a missing entry. Text that describes state, with nothing pinning it, is how
those got wrong in the first place.

Six cases drive the real CLI end to end, through the two shapes that need no
indexed repo: members absent from the registry, and members registered at a
storage path with no index file, which makes every repo unreadable. The file
header claimed no LadybugDB-backed command was driven end to end; that is no
longer true and it now says so.

Each branch was suppressed in turn and its assertion goes red — all five that
the plan named.

One of those mutations first reported PASS, and the cause is worth recording: the
string being suppressed also appears inside a neighbouring branch's comment, so
the harness silenced the wrong line. That is a bad mutation, not a weak test.
The harness now asserts the marker it suppresses is unique before trusting the
result, and the redone check goes red.

The plan's sixth scenario is already covered by an existing case that asserts
both labels in one table, so it is not duplicated. A seventh case was added
beyond the plan: without a populated-list case, "prints neither line" would pass
just as well against a CLI that never printed that line at all.

Adds about 15s of measured spawn time locally; CI runs these against the built
dist, which is materially faster per spawn.

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

* test(group): assert the MCP payloads by exact shape, not by partial match

Nothing asserted what the group tools actually return. The sync response's
unreadable list and registry outcome, and the contract listing's incompleteness
fields, are documented in the tool descriptions an agent reads — and could have
been dropped in a refactor without a single test noticing.

The assertions are exact-shape rather than partial. A `toMatchObject` would let
a dropped key pass, which is precisely the regression these exist to catch: the
failure mode is an absent field, and a partial match is defined not to see one.
Absences are additionally asserted explicitly.

The tri-state has to survive the response boundary, and it is the reason exact
shape matters here more than usual. An absent `unreadableRepos` means the sync
never recorded what it could read, so the listing is a floor; an empty list
means it measured none; a populated list names them. Collapsing absent into
empty turns "we do not know" into "we checked, it is fine" — so a mutation that
replaces the conditional spread with `?? []` is covered specifically, not just
the outright deletion.

Mutation-verified per field: removing either sync forwarding line, deleting the
conditional spread, replacing it with the invent-empty form, dropping the
truncation triple, or hardcoding the provenance flag each turns an assertion
red.

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

* docs(group): stop the bridge input narrowing what unreadableRepos means

The same field had three definitions. The registry and the bridge metadata both
say it covers a repo this sync could not extract from — an index that would not
open, or an extractor that threw partway through, one bucket because the
consequence is one thing. The bridge input said only "whose index could not be
opened", which describes one cause and silently excludes the other.

It now points at the registry's definition instead of restating it a third
time. A definition written once and referenced cannot drift; three copies of it
already had.

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

* docs(group): record what the mtime pairing does and does not prove

The write-order fallback is a heuristic standing in for provenance, and a
future reader deciding whether to lean on it needs to know where it breaks
before they do. Both directions are now stated where the function is read
rather than only in the plan that introduced it.

The false-accept direction is a non-monotonic wall clock — mtime is realtime,
so an NTP step back, a snapshot restore, or container skew between the two
writes can leave a mis-paired set reading as ordered. Coarse filesystem
granularity is explicitly called out as NOT being that hazard, because it looks
like it: it collapses a pair written together to equal times, and equal is
accepted, which is the right answer for that pair.

The false-reject direction is any copy or restore that rewrites the database's
mtime after the metadata's. An intact legacy pair is demoted to a lower bound
and stays there until a sync re-stamps it, because nothing on the read path can
tell it apart from the swap window it imitates.

That second direction corrects a claim made while planning this work: that the
rule could only ever demote pairs already broken. It cannot. `cp -r` and
`rsync` without timestamp preservation both produce it on a healthy group, and
saying otherwise where the code is read would leave a future reader to discover
it the hard way.

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

* fix(storage): stop a corrupt registry quoting its own bytes into errors

`JSON.parse`'s SyntaxError embeds a window of the source around the failure —
V8 gives exactly ten characters either side — and the strict read rethrew it
untouched. The registry persists HTTPS remote URLs with their userinfo, so a
file that breaks next to one puts the credential into the error:

    Unexpected token 'L', ..."end.git"},LEAKCAN4RY"... is not valid JSON

The parse now has its own guarded region and reports the path and the failure
class, matching the two corrupt-registry errors already in this function.

The original error is discarded — not logged, not attached as `cause`. This
codebase's convention elsewhere is to hand the logger the Error so it captures
stack and cause, and following that convention here is precisely what would put
the byte window into the log. Under MCP stdio that log is written to the
client's log file on disk, so the thrown-error channel was never the only one
that mattered. The `catch` takes no binding, so the error cannot be reused by
accident later.

That was not theoretical: a sibling commit routes this message into
`unresolvableReason`, which `group status` returns to MCP clients and prints in
the CLI table. Every channel was traced — throw, cause, inspect with the full
chain, the logger, and both downstream consumers.

The leaking shape is narrower than it first appears, and worth recording. The
windowed message only fires when the parser fails at a value-start or trailing
position; a break inside a quoted string yields an unterminated-string error
carrying no window. So a plain mid-URL truncation does not leak — a short write
landing over a longer one does, leaving a URL fragment where a value was
expected. That is a reachable shape for the one machine-wide file every
gitnexus process writes.

The test asserts the message still names the path and the corruption class, not
only that the secret is absent. Asserting absence alone would stay green if the
message became empty.

Mutation-verified: restoring the raw rethrow brings the token back verbatim.

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

* docs(storage): drop the stale lenient call-site count

The docstring said keeping `readRegistry`'s signature untouched leaves "its
nine other call sites" unaffected. There were thirteen when the discrepancy was
noticed and fourteen by the time it was fixed. The same figure appeared in the
test file's header.

Replaced rather than corrected. A count in prose next to code that moves is a
claim that goes stale without anything failing — which is the defect class this
change set exists to remove, so re-seeding a fresh number would be repeating it
with a longer fuse. The argument was never about the quantity: leaving the
signature alone keeps every lenient caller provably unaffected whether there is
one or fifty.

Also withdrawn while here: the claim that the bridge schema-version guards
diverge between call sites. They do not — the two forms are complements for
every value a writer can produce, there are three sites rather than the two
claimed, and all three agree. Recording a divergence that does not exist would
leave a future reader chasing it.

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

* docs(group): add an auditable finding-to-commit map

The Definition of Done claims every review finding has exactly one commit and
that reverting it reintroduces that finding and no other. Without a map that
claim is only checkable by whoever holds the review report, which is one person
for a short time.

The map lists all 28 primary findings against their commits, the three findings
whose suggested fix was deliberately not implemented and what shipped instead,
and the four defects found while executing that no reviewer raised.

It also records the revert contract honestly. Revertability is
dependency-aware, not absolute: the shared completeness helper has three
consumers, so reverting it alone does not build. That coupled set is named
rather than left for someone to discover mid-revert.

Two sections exist because the work produced them, not because the plan asked.
Six claims in the plan turned out to be contradicted by the code — among them a
scope predicate that would have reintroduced the bug its unit was closing, and
an assertion about the mtime rule that was simply wrong. Recording only the
findings would leave the impression the plan was followed as written. Five
residual risks are listed for the same reason, including that R14 is not met on
this PR: the diff attribute works locally but GitHub reads it from the base
side, so this PR's own sync.ts stays binary in the web view and every PR after
it renders as text.

Not under docs/ — that path is gitignored, so a map written there would never
reach the PR and the audit it exists for could not be performed by anyone else.

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

* fix(group): read a version that is not a version as no provenance

Raised by the check bot on this PR, and real — the bot found one symptom of it;
the field splits four gates apart, not one.

`readBridgeMeta` accepted any numeric `version`, and `0` is this file's word for
"no provenance". A parseable but impossible value — negative, fractional — is
not a schema version, and each gate that reads the field disagreed about it:

  ensureBridgeReady      `> 0 && !== CURRENT`  → opens the bridge
  openBridgeDbReadOnly   `> 0 && !== CURRENT`  → opens the bridge
  bridgeExists           `=== 0 || === CURRENT` → says it is not there
  bridgeProvenanceUnknown `=== 0`               → reports the answer complete

Four verdicts about one file, and the last one is a fail-open of exactly the
class this PR exists to close: a bridge nothing can vouch for, reported as
fully accounted for.

The suggested fix was to widen the provenance check to `<= 0`. That closes the
reported symptom and leaves `bridgeExists` still disagreeing with both openers,
so it is fixed at the reader instead: a version that is not a positive integer
normalizes to the sentinel the gates were all written against. One change, four
gates agreeing by construction, rather than teaching each of them the same new
case and hoping the fifth reader remembers.

Infinity is covered too, though by the pre-existing type check rather than the
range one — JSON cannot carry it, so it arrives as `null`. Recorded at the test
so the case is not mistaken for proof of the range check.

Mutation-verified: restoring the loose numeric check turns the negative and
fractional cases red.

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

* fix(group): stop a malformed contracts.json reading as an unresolvable registry entry

Raised by the check bot on this PR. Its stated mechanism was wrong — `loadMeta`
returns null on every error and `checkStaleness` catches everything, so neither
can throw — but its conclusion was right, and there is a concrete path it did
not name.

`readContractRegistry` is a bare `JSON.parse(content) as ContractRegistry` with
no shape check, and the snapshot lookup guarded only the registry object:

    registry?.repoSnapshots[repoPath]

The `?.` covers `registry` being null, not `repoSnapshots` being absent. A
contracts.json without that field — a legacy file, a hand-edit, a truncated
write — throws `TypeError: Cannot read properties of undefined`, which lands in
the catch that labels failures as unresolvable GLOBAL-registry entries. So a
group whose own contracts file is malformed reported every repo as a broken
registry row, sending the operator to repair a file that was fine.

An error from one cause presented as another, which is the defect this PR has
been removing everywhere else.

The optional chain closes the crash. The try is also narrowed to the call that
earns the label: only `resolveRepo` sits inside it now, so "did not resolve"
describes something that actually failed to resolve rather than whatever else
happened to throw nearby. The comment records why the other two calls in that
block cannot throw, so the next reader does not have to re-derive it.

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

* refactor(group): give the completeness fold a module no native binding reaches

The shared fold ended up in `cross-impact.ts`, which statically imports
`bridge-db.ts` and through it the native LadybugDB binding. `groupContracts`
therefore reached it through `await import('./cross-impact.js')` — loading that
whole module graph to run a Set union and a ternary. Measured: 44-51ms and
8.4MB of RSS on first call, paid once per MCP server and once per
`gitnexus group contracts` invocation.

`completeness.ts` holds the vocabulary and the fold and imports nothing but
types. `service.ts` imports it statically; the lazy import and the comment
justifying it both go. `cross-impact.ts` re-exports so the three surfaces still
have one import site for the vocabulary.

Three other duplications collapse into the same move.

`traceCompleteness` was hand-writing `{truncated, truncationReason,
riskEpistemic}` — a third writer of the pair `truncationFields` exists to keep
mechanically linked (#2787), in the file the consolidation had just touched. It
calls the helper now.

`recordedRepoList` existed twice, byte-identical, one copy's docblock saying it
mirrored the other. That gate is the predicate the whole
absent-vs-empty-vs-populated distinction rests on, applied to the same two
lists on both the registry and the bridge — tightening one copy would have
fixed one surface silently. One definition now.

The trace's scope predicate compared repo paths with `===` while its sibling in
`cross-impact.ts`, added in the same change, went through `repoInSubgroup` with
a comment about not growing a second notion of membership. It had grown one:
the helper normalizes separators and strips trailing slashes, so the same
group.yaml spelling could be in scope for impact and out of scope for trace.

Also here: `registryIdentifies` was a third, weaker copy of the registry's path
rule — it skipped `realpath`, so a symlinked row would not match where the real
resolver would. It uses `canonicalizePath`/`registryPathEquals` now.
`contracts.json` is no longer respelled as a literal in `sync.ts`; `storage.ts`
owns the name it reads and writes. And the runtime-truncation predicate is
bound once instead of written out at both the flag and the reason, where
forgetting the second would label a retry-able answer `incomplete-sync`.

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

* fix(group): give the lost-the-race sync its own outcome instead of overloading preserved

A sync that finds contracts.json replaced while it waited for the lock reported
`registryOutcome: 'preserved'`. That value already meant something else, and the
two differ in exactly the thing the value is for: `preserved` rewrites the file
with this run's diagnostics; this path does not touch it and deliberately does
not record them.

So both surfaces stated something false about disk. The tool description told
agents `preserved` means "contracts.json was rewritten ... refreshing only
missingRepos/unreadableRepos to describe THIS run (the file changed)". The CLI
said "only the unreadable/missing repo lists were refreshed to describe THIS
run". On the lost-race branch nothing was written and the log line beside it
says so outright.

That is the defect class this whole change set removes, reintroduced by the
change set itself — and the reasoning recorded at the time makes it worse, not
better: a new value was rejected because it "would fall through cli/group.ts's
outcome chain, which has no fallback branch". A renderer limitation decided a
domain value, and the description then had to cover two states with one
sentence that fits one of them.

`superseded` is its own outcome now, described in its own words to agents and
rendered in its own words at the CLI. The registry on disk is FRESHER than this
response's diagnostics, which is the opposite of every other non-written
outcome and is why an agent needs to tell them apart.

The CLI renders from a `Record` keyed on the union, so the next outcome fails
the build here rather than printing nothing — the gap that made folding the
state in look like the cheap option.

The description guard is scoped per clause rather than over the whole string.
It forbade "untouched" anywhere, which was right when one clause could only lie
in that direction and wrong now that another clause is accurately untouched. It
also asserts the superseded clause says so, or the two collapse back into one
word for two states.

Found by the quality pass over this branch, not by review.

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

* test(group): read bytes and stat through one handle, not two path lookups

CodeQL flagged both sites as `js/file-system-race`, high severity, and it is
right about the shape. `stat(path)` followed by `readFile(path)` is two
independent path resolutions with a window between them — the classic
check-then-use race.

It also made the assertions weaker than they read. These two tests exist to
prove a specific file was left untouched, and two lookups can land on different
inodes, so "the bytes and the mtime are both unchanged" was not actually a
statement about one file. The distinction is the whole point here rather than a
technicality.

`snapshotFile` opens the path once and takes both answers from that handle. The
race is gone because there is no second lookup, and the assertion now genuinely
concerns one inode.

I had previously triaged these as below the ruleset's threshold and left them
for the repository owner. That was wrong: they carry
`security_severity_level: high`, and the branch ruleset gates on
`high_or_higher`, so they were blocking the merge rather than sitting under it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.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-26 09:37:16 +01:00
azizur100389
b77d6f662b
fix(kotlin): resolve imports from declared packages (#2990)
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 / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / Classify release event (push) Waiting to run
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
2026-08-18 20:47:30 -07:00
azizur100389
7f0ab16ffe
feat(routes): support JS data route tables (#2972)
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / Classify release event (push) Waiting to run
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
2026-08-18 04:39:45 +01:00
MyShining
fe3d7e56be
feat(spring): detect non-HTTP handler entry points (#2891)
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / Classify release event (push) Waiting to run
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
2026-08-16 15:16:21 +01:00