Commit graph

205 commits

Author SHA1 Message Date
dependabot[bot]
31c9d9223e
chore(deps): bump the codeql-action group with 3 updates (#3056)
Bumps the codeql-action group with 3 updates: [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action) and [github/codeql-action/upload-sarif](https://github.com/github/codeql-action).


Updates `github/codeql-action/init` from 4.37.6 to 4.37.7
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](5595ccaf91...ff2f1c621b)

Updates `github/codeql-action/analyze` from 4.37.6 to 4.37.7
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](5595ccaf91...ff2f1c621b)

Updates `github/codeql-action/upload-sarif` from 4.37.6 to 4.37.7
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](5595ccaf91...ff2f1c621b)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.37.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.37.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.37.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-08-27 13:29:52 +01:00
dependabot[bot]
414687ad10
chore(deps): bump docker/setup-buildx-action from 4.2.0 to 4.3.0 (#3057)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 4.2.0 to 4.3.0.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](bb05f3f551...37fe631027)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-27 08:43:33 +01:00
dependabot[bot]
c056d136ad
chore(deps): bump actions/attest-build-provenance from 4.1.1 to 4.2.2 (#3005)
Bumps [actions/attest-build-provenance](https://github.com/actions/attest-build-provenance) from 4.1.1 to 4.2.2.
- [Release notes](https://github.com/actions/attest-build-provenance/releases)
- [Changelog](https://github.com/actions/attest-build-provenance/blob/main/RELEASE.md)
- [Commits](0f67c3f485...4d101475d8)

---
updated-dependencies:
- dependency-name: actions/attest-build-provenance
  dependency-version: 4.2.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-08-24 09:46:14 +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
dependabot[bot]
02008e0288
chore(deps): bump the codeql-action group with 3 updates (#2947)
Bumps the codeql-action group with 3 updates: [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action) and [github/codeql-action/upload-sarif](https://github.com/github/codeql-action).


Updates `github/codeql-action/init` from 4.37.3 to 4.37.6
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](e4fba868fa...5595ccaf91)

Updates `github/codeql-action/analyze` from 4.37.3 to 4.37.6
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](e4fba868fa...5595ccaf91)

Updates `github/codeql-action/upload-sarif` from 4.37.3 to 4.37.6
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](e4fba868fa...5595ccaf91)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.37.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.37.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.37.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-13 06:22:13 +01:00
dependabot[bot]
8c2452a4e8
chore(deps): bump dorny/paths-filter from 4.0.2 to 4.0.3 (#2948)
Bumps [dorny/paths-filter](https://github.com/dorny/paths-filter) from 4.0.2 to 4.0.3.
- [Release notes](https://github.com/dorny/paths-filter/releases)
- [Changelog](https://github.com/dorny/paths-filter/blob/master/CHANGELOG.md)
- [Commits](7b450fff21...ceb8a2b8f2)

---
updated-dependencies:
- dependency-name: dorny/paths-filter
  dependency-version: 4.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-13 06:21:54 +01:00
Gergő Magyar
18bc51dfd2
perf(import-resolvers): index every scanning resolver, consolidate the memo, gate every registered language (#2911)
* perf(import-resolvers): build buildSuffixIndex's dirMap lazily (#2903)

`buildSuffixIndex` eagerly built three maps. `dirMap` is the array-valued one —
one entry per directory suffix per file, so O(files x depth) in entries and
array churn — and only four call sites ever read it, all via `getFilesInDir`:
`import-resolvers/{php,csharp,jvm}.ts` and `import-resolvers/configs/python.ts`.

Ruby (through workspace-file-index), the TypeScript scope resolver, Vue's
import-target and the include-extractor never ask a directory question, and
built it anyway. Since #2880 these indexes are retained for a whole resolution
pass rather than rebuilt per import, so that waste is now resident memory.

Deferring it to the first `getFilesInDir` call is behaviour-identical — same
key, same descending-suffix order, same per-bucket push order, same
`substring(lastIndexOf('.'))` extension clamp. The builder assigns the MAP on
completion, so a repeated miss cannot rebuild it.

Measured on `buildSuffixIndex` alone, 32k paths, index built and
`getFilesInDir` never called:

  C# layout, 13 segments   79,018,680 -> 66,580,488 B   -15.74%
  Ruby layout, 11 segments 60,752,792 -> 48,656,856 B   -19.91%

and on the whole retained WorkspaceFileIndex the bench measures:

  csharp 32k  73.62 -> 61.76 MiB   ruby 32k  55.26 -> 43.69 MiB

When `getFilesInDir` IS called the footprint is unchanged, so the deferral is
never a loss. No new retention: all five construction sites already hold both
input arrays alive beside the index.

The laziness is pinned structurally rather than by timing. The test's corpus is
a `string[]` whose elements are accessor properties, so an indexed read is
observable and the read count IS the pass count: 14 after construction, still
14 after any number of get/getInsensitive, 28 after the first `getFilesInDir`,
28 after five more. Memoizing the decision instead of the map would read 42.

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

* perf(php): resolve imports from a per-run index, not a scan per import (#2901)

PHP was the last language whose import resolution scanned the workspace per
import. Both `resolvePhpImportTarget` and `resolvePhpImportTargetInternal`
materialized two full arrays from the Set on every call, then passed
`undefined` as the `index` argument — so `resolvePhpImportInternal` fell
through to `suffixResolve`'s linear `findIndex`, once per extension per path
part. Measured at 20,000 files: 96.40 ms per import.

**Handing it the shared SuffixIndex would have moved IMPORTS edges.** All three
index-fed sites answer a different question than the scan they short-circuit,
each found by differential with a concrete witness:

  1. `getInsensitive` — the scan leg is `allFiles.has(path)`, exact whole-path
     with no case-insensitive counterpart; the shared index answers a ci SUFFIX
     probe.
  2. `getFilesInDir` — the scan is root-anchored `startsWith(nsDir + '/')`;
     `dirMap` is keyed on every directory SUFFIX, so a vendor copy can win.
  3. `suffixResolve` — the scan's `endsWith('/' + S)` matches only a PROPER
     suffix; `buildSuffixIndex` indexes j=0, so a root-level `Foo.php` starts
     resolving `use Foo` where it returned null.
  3b. the scan's `endsWith(p) || lower.endsWith(lower(p))` has a second
     disjunct that subsumes the first, so it is purely first-in-Set-order and
     case-insensitive; `get(S) || getInsensitive(S)` lets a case-exact hit
     anywhere beat an earlier ci hit.

So this is not Ruby's #2880 shape. Both sites take `getWorkspaceFileIndex` for
the memoized arrays and hand the internal resolver a PARITY `SuffixIndex`
memoized on the same Set identity: `getInsensitive` disabled, `get`
implementing the scan's real rule via the shared ci lookup plus one O(files)
whole-path correction map, `getFilesInDir` root-anchored in Set order.

  no composer.json    96.40 -> 0.036 ms/import steady state
  with composer.json 100.19 -> 0.068 ms/import steady state

Also closes PHP's last per-import traversal, in `import-resolvers/php.ts`: its
namespace-directory scan ran whenever `getFilesInDir` came back EMPTY, not
merely when no index was supplied — despite the comment above it claiming
"only when SuffixIndex unavailable". An empty bucket is already the answer, so
the scan could only confirm it, at one full pass per import whose namespace
matches a PSR-4 prefix but whose directory has no direct `.php` child
(measured 11 traversals for 10 imports; now 1). Moving it into the `else` is
safe because the bucket is a SUPERSET of what the scan finds — a root-anchored
direct child `nsDir/<x>.php` has its directory exactly equal to `nsDir`, and a
directory is always one of its own suffixes, so both index shapes contain it.

Nine mutations of the new code are caught, including M1 "pass the raw shared
index" (the naive fix) at 23 arms. The adapter guard reads 600 instead of 1
under a defensive `new Set(allFilePaths)` — the #1918 P1 hazard the unit
differential is structurally blind to.

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

* perf(java): index import resolution instead of scanning per import (#2908)

Java scanned the whole workspace twice per import: once for the three-tier
direct match, and again INSIDE the progressive prefix-stripping loop — so a
single unresolvable import cost one full pass per stripped segment. No WeakMap,
no index, and it is registered in `SCOPE_RESOLVERS`, so it ran in production.

This is byte-for-byte the C# shape #2878 fixed, so Java now reads the same
machinery: `getWorkspaceFileIndex` for `normToRaw` + the segment-suffix index,
and a Java-owned `PackageDirIndex` WeakMap over `buildPackageDirIndex(_, n =>
n.endsWith('.java'))` read through `firstFileDirectlyInPkgDir`. Structure
mirrors C#'s `narrowContext` / `resolveDirectMatch` /
`resolveByProgressiveStripping`.

  20k files, 256 imports, 7-in-8 unresolvable:  8.05 -> 0.62 ms/import
  steady state once the index is built:         0.0036 ms/import

Tie-breaks preserved, and Java's are NOT identical to C#'s:

  - tier 1 `break`s on the exact match, so an exact whole-path hit wins even
    when a suffix or directory-child hit came earlier in iteration order —
    hence `normToRaw.get` before `index.get`, which conflates them;
  - the stripping loop instead returns at the FIRST hit of `f === tailFile ||
    f.endsWith('/' + tailFile)` and only yields its directory child after the
    scan completes, so the conflated `index.get` is the correct lookup THERE.
    Applying tier 1's exact-wins rule inside the loop is a real behaviour
    change (mutation M6);
  - `.*` wildcard stripping stays ahead of everything;
  - `firstFileDirectlyInPkgDir` reproduces Java's at-root/at-nested predicate
    exactly, including the first-`indexOf` rule — proved algebraically rather
    than assumed: the `atRoot` branch matches iff `dir === pathLike`, which is
    `D.indexOf(P) === 0 === D.length - P.length`, and the `atNested` branch's
    first occurrence in `f` is the first occurrence in `D` shifted by one.

Six mutations are caught; a seventh (swapping the two index builds) is a true
equivalence and is recorded as such. Hand-derivation also corrected four cases
where the legacy code resolves and I had predicted null — including
`java.util.List` reaching a local `util/List.java`, because Java has no
in-repo-namespace gate like C#'s #1881. That is preserved here and filed
separately as #2910; the parity test pins it so the fix is visible.

The adapter guard reads 800 instead of 2 under a defensive
`new Set(allFilePaths)`. Two traversals is correct: the workspace index and the
package-dir index are separate WeakMaps and each iterates the Set once, the
same accounting as C#.

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

* perf(cobol): index COPY resolution instead of two scans per statement (#2908)

`cobolScopeResolver.resolveImportTarget` ran two full workspace scans per
`COPY`, each calling `path.extname` + `path.basename` + `.toUpperCase()` on
every entry: tier 1 over `.cpy`/`.copybook`, tier 2 over `.cbl`/`.cob`/
`.cobol`. No WeakMap, no index, and registered in `SCOPE_RESOLVERS`.

Two uppercased-basename maps, one per tier, filled in a SINGLE pass over the
Set and memoized on Set identity. Lookup is
`copybooks.get(upper) ?? sources.get(upper) ?? null`.

  20k files, 500 COPY operands:  3879-4082 -> 10.5-11.7 us/import  (~350-369x)
  steady state once built:       0.253 us/import

Tie-breaks preserved:

  - TIER ORDER. A `.cpy` match beats a `.cbl` match even when the source file
    appears EARLIER in Set-iteration order. This is the one a naive
    single-map rewrite silently breaks, so it gets its own fixture.
  - Within a tier, first in Set-iteration order wins (`if (!tier.has(...))`,
    mirroring the scans' first-match return).
  - The key is built with the identical call sequence,
    `basename(fp, extname(fp).toLowerCase()).toUpperCase()`, so `Foo.CPY` still
    keys under `FOO.CPY` rather than `FOO`.
  - `path` stays in the loop rather than hand-rolled `/`-slicing, so backslash
    handling is unchanged on every platform — pinned by a `dir\sub\BOOK.cpy`
    case.

All six mutations are caught: collapsing the tiers, within-tier last-wins,
dropping the target uppercase, dropping the extension lowercase, hand-rolled
slicing, and the adapter's defensive copy. The first five are caught by the
differential and are invisible to the adapter guard; the sixth is the reverse,
which is the layering working as intended — the guard reads 600 instead of 1.

`COBOL_SOURCE_EXTENSIONS` was being re-allocated on every call; hoisted to
module scope beside `COPYBOOK_EXTENSIONS`.

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

* perf(csharp): index the csproj leg's namespace-directory scan (#2902)

#2878 moved C#'s no-csproj leg onto memoized indexes; the csproj leg kept a
per-import full scan in `resolveCSharpImportInternal` step 3, measured at
~1.10 ms per import at 50,000 `.cs` files.

**The fix the issue proposed would have moved edges.** It suggested skipping
the fallback when an exhaustive index is available, on the assumption that
step 2's `getFilesInDir` answers the same question. It does not: step 2's
`dirMap` is keyed on segment-aligned directory suffixes, while step 3's
`normalized.indexOf(dirPrefix + '/')` is an UNANCHORED substring match, so
step 3 finds a strict superset — and it runs only when step 2 came back empty,
so those extra hits are observable, not shadowed:

  dirPrefix 'ubModels'  step 2 []  step 3 ['src/SubModels/Widget.cs']
  dirPrefix 'rc/Models' step 2 []  step 3 src/Models/* AND vendor/mysrc/Models/*

So the predicate is kept byte-for-byte and made fast instead. It depends only
on the file's directory (the needle ends with `/`, so every occurrence lies
wholly inside `D + '/'`), which reduces to the `package-dir-index` formula
minus the anchoring leading slash. `PackageDirIndex` itself cannot be reused
for the same reason — its matcher is anchored.

The index is memoized on the `normalizedFileList` array identity and built
lazily at the point step 3 is first reached, so BCL usings — which `continue`
out at the root-namespace gate — never pay for it. Candidates come from an
exact last-segment bucket when `dirPrefix` contains a slash, a last-segment
key sweep when it does not, and `singleSegmentDirs` when it is empty.
Positions rather than paths, merged and sorted when several directories match,
so file-list order survives.

  App.Missing @ {App, src}  1103.0 -> 7.6 us   (145x, and flat in file count:
                                                7.3 @10k, 7.6 @50k, 8.4 @200k)
  App.Missing @ {App, ''}    626.7 -> 108.5 us
  App @ {App, ''}           1077.9 -> 2.0 us   (539x)
  App.Ns8 @ {App, src}         0.6 -> 0.6 us   (step-2 hit, untouched)

`relative === ''` is preserved exactly, including the no-`projectDir` case
where the needle is a bare `/` and the answer is "every `.cs` whose directory
has no slash of its own" — `getFilesInDir('', '.cs')` cannot answer that over
repo-relative paths, so it has its own arm.

13 of 14 mutations are caught, including M1, the naive skip-when-indexed
cleanup, at 9 arms. The survivor drops the empty-prefix fast path and is a
true equivalence. M9 initially survived and exposed a real corpus gap — no
non-`.cs` file lived inside a directory — now covered.

The remaining non-constant term is the slash-free sweep, O(distinct last
segments): 456 us at 200k files on a unique-name layout, but 7.9 us on a
`SrcN/Models` layout, which is how C# repos are actually laid out. Closing the
unique-name case needs a character-suffix map over segments — the
O(files x depth) memory shape `package-dir-index.ts` cites #2649 to avoid — so
it is documented in the code as a design change rather than tuned here.

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

* test(scope-resolution): assert index reuse for every registered language (#2909)

Index reuse was asserted by nine hand-written per-language files, so the
guarantee existed exactly for the languages someone remembered — and #2908 is
the proof that is not good enough: Java and COBOL were registered, quadratic
and unguarded until this branch. `resolveImportTarget` is a required member of
`ScopeResolver` with one signature and 16 registrations, so "calling it N times
against a stable `allFilePaths` must not traverse the set N times" is a
property of the CONTRACT.

`import-target-index-reuse.contract.test.ts` drives every entry of
`SCOPE_RESOLVERS`, modelled on `construction-syntax-wiring.test.ts` — the
established shape here for a property plus a justified inventory. Measured
counts, all memoized:

  c 1  cobol 1  cpp 1  csharp 2  dart 1  go 1  java 2  javascript 2
  kotlin 1  php 1  python 1  ruby 1  rust 0  swift 1  typescript 2  vue 2

**`KNOWN_UNINDEXED` is empty.** The audit that produced it also cleared C, C++,
Rust, Swift, TypeScript, Vue and JavaScript by hand — Rust's memo lives in
`qualified-call.ts::moduleIndexFor`, C's and Swift's loops are inside their
WeakMap builders. The empty map stays as a mechanism: a 17th language cannot
opt out silently, and the inventory arm fails when a registered resolver has no
fixture.

Two things the assertion had to get right:
  - it is `scans(200) === scans(2)`, not `scans === 1`. Per-language counts
    legitimately differ (C# and Java build two indexes), and comparing two
    counts needs no per-language expected value.
  - Rust legitimately scans ZERO times — it answers every leg with
    `allFilePaths.has(candidate)` probes — so the floor is a per-language
    `minimumScans`, 1 for fifteen languages and 0 for Rust with the reason on
    the interface. Paired with a `hitTarget` that must resolve non-null, so the
    property cannot pass vacuously on a resolver that stopped answering.
Miss targets are distinct per import, which defeats the TS/JS/Vue per-target
`resolveCache`.

Also unifies the instrument. Kotlin and Python counted index BUILDS from
production; the other seven count traversals of a `CountingSet`. The build
counter is strictly weaker — a scan added BESIDE a reused index moves no build
count, which is exactly the mutation `baselines.json` `_blind_spot` records as
invisible to every timing arm — and it costs two production modules that ship
in the bundle purely for tests, holding module-global state every test must
`reset()`. Both guards migrate to `CountingSet`, and
`languages/{kotlin,python}/index-stats.ts` plus both call sites are gone, for
-59 lines of shipped source.

(Mechanical note: the two `index-stats.ts` file deletions appear in the #2901
commit rather than this one. They were staged with `git rm` while a concurrent
commit swept the index. The final tree is correct; only that attribution is
off, and rewriting a sibling commit to move them was not worth the risk.)

Coverage went up in the swap: Kotlin's old "rebuilds when the file set is a
different object" arm (3 sets, 3 builds) would have PASSED under a defensive
adapter copy. Its replacement fails, as do all six arms across the two files.

Verified by mutation: `new Set(allFilePaths)` inserted into the kotlin, python
and go adapters fails exactly those three and no others —
`python: 200 imports cost 201 traversals, 2 cost 3`.

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

* test(import-target): gate the four newly-indexed resolvers, retighten heap

The bench covered go/csharp/dart/ruby/kotlin. The four resolvers indexed on
this branch shipped unmeasured, and #2903's memory win was not locked in.

**php, java and cobol join the shared corpus**, each with the two load-bearing
properties the header requires: imports scale with file count, and most imports
MISS so the full cascade runs (resolve rates php 36.0%, java 34.4%,
cobol 36.0%). Java's miss families were measured rather than assumed, since it
has no in-repo-namespace gate (#2910): `java.*` 1041 imports and
`com.google.*` 1006, both resolving 0. COBOL's collide layout repeats a
bookname across BOTH extension tiers, so it reaches the copybook-over-source
tie-break rather than only the basename map.

**`csharp_csproj` is a sixth LANGS entry**, not a new arm dimension — an entry
needs five small additions and inherits all five arms and all seven gates,
where a context axis would have to be threaded through `buildRepo`,
`resolveAll`, `identityPass`, the report shape and every gate. `buildFiles`
aliases it to `csharp`, so the two share one corpus by construction and cannot
drift. Two configs (`{App, 'src'}`, `{Lib, ''}`) produce all three `dirPrefix`
shapes — slashed, slash-free and empty — in five arms instead of ten:

  App.Ns{d}      30.6%  src/Ns{d}        step 2 hit
  App.Missing{n} 25.5%  src/Missing{n}   step 3, last-segment bucket
  Lib            14.0%  (empty)          step 3, singleSegmentDirs
  Lib.Missing{n} 12.0%  Missing{n}       step 3, KEY SWEEP — the one
                                         non-constant path
  BCL / Ghost    12.4%  —                root-namespace-gate control

**2221 of 3200 imports reach the indexed leg**, only 12.4% `continue` out. What
that arm pins is stated plainly rather than overclaimed: step 3 answers null
for all 2221 here (the hits land at step 2), so it gates that leg's COST and
its null answers; its positive tie-breaks stay pinned by the unit parity test.

**Heap ceilings retightened.** #2903 dropped the measured figures, leaving the
1.5x ceilings at ~1.9x — a straight revert to the old size would have passed:

  csharp 116,000,000 -> 98,000,000 B   (measured 61.76 MiB)
  ruby    87,000,000 -> 69,000,000 B   (measured 43.69 MiB)
  php    new 106,000,000 B             (measured 67.29 MiB)
  java   new 154,000,000 B             (measured 97.32 MiB, the largest in the
                                        file — Maven layout is 18 segments)

php and java are gated because both retained NOTHING across imports at BASE and
now retain the O(files x depth) suffix index — the same argument that gates C#.
cobol is not: two `Map<basename, path>`, O(files) with no depth term, and its
retained delta does not clear measurement noise, so a ceiling would gate
nothing. `csharp_csproj` is not: same corpus, same index, a duplicate number —
its one distinguishing footprint, the lazily-built `dirMap` its `getFilesInDir`
forces back, is measured at +20.8% and recorded as a residual instead, because
gating it would licence eager-dirMap everywhere.

csharp's `depth_ratio` also fell 3.318 -> 2.31 (the no-csproj leg never asks a
directory question, so the deep arm stopped paying an eager dirMap build).
Budget 5 -> 3.5, restoring the file's 1.5x convention — and `_arms_note` says
plainly that 3.5 does NOT lock that win in, because locking it needs ~2.9,
which is 1.25x over a 1.05x spread and the kind of tightening `_triage` warns
buys flake rather than signal.

All five pre-existing languages are byte-identical: 25 cells x 5 fields = 125
values, 0 mismatches. The new arms were proven live by a doctored baseline
(cobol ceiling 0.01, php heap 1000 B, java resolved 999) producing three
correctly-worded failures and exit 1.

Wall-clock 10.9 -> 26.1 s, php and csharp_csproj ~11 s of it — both cascades
end in `suffixResolve`'s ~50-extension probe, and both gate the two largest
wins on this branch, so neither is a candidate to drop.

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

* perf(javascript): build the suffix index JS resolution never had

JavaScript's `PassCache` was TypeScript's minus one field: `index`. So JS
called the shared `resolveTsTarget` with `ctx.index === undefined`, and
`import-resolvers/standard.ts` fell through to `suffixResolve`'s linear
`findIndex` — scanning the materialized path list once per extension (~39)
per path part, per import.

  2000 files   6448.9 -> 28.5 us/import   (TypeScript: 25.0)
  8000 files  25972.6 -> 27.4 us/import   (TypeScript: 27.0)

Per-import scaling over 4x the files: 4.12x -> 1.09x.

**Every instrument on this branch was blind to it.** `CountingSet` counts
traversals of the Set; this walked the array the adapter had already
materialized — the blind spot `counting-file-set.ts` documents in its own
header and `baselines.json` records under `_blind_spot`. Under mutation M1,
which drops `index` and reproduces the shipped defect exactly, the sixteen-
language contract test stays GREEN for javascript, because the pass cache is
still reused and `files.scans` reads 2 either way. Two new arms do catch it: a
`suffixResolve` linear-branch counter that runs the legacy adapter first as its
control (135 entries legacy, 0 now), and a mock-free behavioural assertion that
a repo-root module resolves by bare specifier.

Adding an index moves output, exactly as it did for PHP in #2901, so it was
characterized rather than assumed — 211,200 pairs (400 corpora x 3 importers x
176 targets) plus 184 hand cases. **Two classes move and there is no third:**

  A  null -> repo-root file (108)   `require('config')` with root `config.js`.
     The scan tests `endsWith('/' + suffix)`, so a path with no slash has no
     proper suffix and was unreachable through that leg — while `./config`
     from the root already resolved via the exact `Set.has` branch. JS was
     internally inconsistent.
  B  file -> different file (5679)  `import 'app/main'` was resolving to
     `node_modules/dep0/lib/main.js`; the scan skipped the whole-path candidate
     at the 2-segment suffix and fell through to the 1-segment `/main.js`,
     taking the first such file in Set order.
  C  hit -> null                     ZERO, and impossible: proper-suffix keys
     are a subset of the index's keys.

Both moved classes are JS being wrong. **JS-new agrees with TypeScript on all
211,200 pairs and every corpus case, 0 disagreements** — which is the intended
design, since JS delegates to the TS resolver and differed only by this field.

Also swaps the single-slot `let cached: PassCache | null` in JS, TS and Vue for
a module-level `WeakMap`, matching every other language. Two alternating file
sets rebuilt everything on every call: 12.0 -> 1438.2 ms at 4000 files x 400
imports (120x); after, 11.0 -> 15.7 ms. This is LATENT, not live —
`pipeline/run.ts:673` builds one Set per provider pass and the three are
separate providers — but it is why these were the only languages that could not
carry the standard distinct-set guard. They can now: the arm fails on HEAD for
all three (`expected 42 to be 2`) and passes after.

Six mutations caught, including a global `resolveCache` (M5), which needed a
new arm — `expectDistinctFileSetsGetOwnIndex` builds two IDENTICAL corpora, so
a stale answer carried between them is also the right answer.

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

* refactor(ingestion): one per-file-set memo primitive, twenty-one call sites

Every language that indexes its import resolution hand-rolled the same memo:
declare a module-level `WeakMap` keyed on the file-set object, `get`,
`if undefined` build and `set`, return. One concept, written twenty-one times,
and this branch had just added five more.

`import-resolvers/per-file-set.ts` exports it once:

    perFileSet<K extends object, T extends object>(build: (key: K) => T): (key: K) => T

Two decisions, both recorded in the file. `T extends object` rather than
`has`-then-`get`: `WeakMap.get` returning `undefined` cannot distinguish "not
built" from "built as undefined", and the `has` form needs a cast or a non-null
assertion, both banned here — the constraint makes the ambiguous case
unrepresentable instead, and a future caller wanting `string | null` gets a
compile error pointing at the decision. A throwing build stores nothing and
runs again next call, so failures are not memoized and a half-filled index is
never published — inert for these pure builders, and the safer direction.

`K extends object` rather than `ReadonlySet<string>` is what lets C#'s
`readonly string[]`-keyed cache share the helper.

Twenty-one sites migrated across `import-resolvers/` and fifteen languages.
Every existing doc comment was re-homed onto the new call rather than deleted —
several record real invariants (the Set-identity contract, the #1918
pass-through rule, why Rust's memo lives on a different hook).

TypeScript, JavaScript and Vue additionally had byte-identical `PassCache`
interfaces and builders. `import-resolvers/pass-cache.ts` now holds the one
builder, taking a single argument — every difference the three have lives in
the CONSUMER (`tsconfigPaths`, the extension list), not the builder. The
builder is shared, the memo deliberately is not: each adapter keeps its own
`perFileSet`, hence its own index and its own `resolveCache`, because the three
disagree about what a specifier resolves to and one shared cache would hand a
language another language's answers. It buys no runtime reuse and the module
says so — each provider pass builds its own `allFilePaths` Set, so the three
are always different keys.

C and C++'s `augmentedFilePaths` was a two-LEVEL memo, and needed no new
abstraction: the outer memo's value is a function and a function is an object,
so `perFileSet(perFileSet(...))` composes. The two instances stay one per file,
and the reason is now in BOTH doc comments rather than only C++'s — cpp
delegates to `resolveCImportTarget`, whose `suffixIndex` is keyed on the
augmented set, so a shared memo would cross the two languages' indexes.

Two sites are deliberately NOT migrated, each with the reason written at the
declaration so the next sweep does not re-litigate them:
  - `configs/swift.ts` is a two-input memo keyed on one. `targets` is not
    derivable from the key; re-keying on `ctx` would force a banned non-null
    assertion or an unreachable fallback inside a memo builder.
  - `rust/qualified-call.ts` `MODULE_SCOPE_CACHE` is three inputs keyed on one,
    and sits ten lines below a `perFileSet` in the same file — the likeliest
    thing to be "fixed" by mistake.

The other ten remaining `WeakMap`s are different concerns and stay: AST-node
caches, worker-pool runtime state, graph metadata, mutable lazily-filled
accumulators, and the C++ ADL / inline-namespace indexes, which are reassigned
by explicit clear functions and epoch-stamped on read — validity rules beyond
key identity that a closure over a private cache cannot express.

Net −20 lines of code, +22 of the two "why not" notes. The primitive's own doc
is where the cost sits: the Set-identity contract and the two design decisions
are written once instead of being twenty-one implicit facts.

Pure refactor: 1764 unit tests, 42 guard tests, all sixteen contract-test
traversal counts unchanged (c 1, cobol 1, cpp 1, csharp 2, dart 1, go 1,
java 2, javascript 2, kotlin 1, php 1, python 1, ruby 1, rust 0, swift 1,
typescript 2, vue 2), 647 C/C++ tests, and every bench fingerprint unchanged.

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

* test(import-target): gate every registered language, not nine of sixteen

The bench pinned output fingerprints and scaling for 9 of the 16 languages in
`SCOPE_RESOLVERS`. The other seven — c, cpp, javascript, python, rust, swift,
typescript, vue — resolve imports in production with nothing pinning their
output or their cost. JavaScript was the sharpest case: the 25,972 us/import
defect fixed earlier on this branch was gated by unit tests alone.

All 16 are now gated, plus the `csharp_csproj` variant: 17 entries.

**The nine existing languages are byte-identical** — 234 committed values
(9 x 5 arms x 5 fields, plus 9 top-level fingerprints), 0 changed, and no
pre-existing budget touched. Measured both before and after the memo
consolidation in e6f15274e, so it doubles as an independent check that the
refactor preserved behaviour.

Corpora keep both load-bearing rules — most imports MISS, and import count
scales with file count — at resolve rates of 26-36%. C and C++ follow the
`csharp_csproj` precedent: a `LANGS` entry carrying its own context (header
paths through `resolutionConfig`) over an aliased corpus, since cpp delegates
into C's `resolveCImportTarget`. Vue threads `tsconfigPaths` so its alias
branch actually runs; ts/js use bare specifiers only, because relative ones
never reach `suffixResolve`.

Two corrections to my own profiling, both verified rather than assumed:
Swift's `byModule` IS depth-scaled (one bucket entry per interior segment, not
O(files)), and Python's index is depth-free while its RESOLVER is quadratic in
depth — `hasRepoCandidate` and `resolveAbsoluteFromFiles` each rebuild one
ancestor prefix per importer directory component, per import. That is why
python's `depth_budget` is 11 against a 3.5 next-highest; the arm is pinning a
real defect rather than a comfortable number, and it is filed separately.

Rust's collide arm was redesigned rather than budgeted away: it is flat on file
count by construction, so a shared-leaf arm would have asserted nothing. Its
collide corpus varies `::` segment count — the axis its cost actually has — and
the linear 1.8 budget asserts the file-count flatness.

Heap: all 8 measured, 3 gated. javascript (44.07 MiB, retained nothing before
its fix), python (7.27 MiB), c (9.55 MiB). Five skipped with their numbers in
`_arms_note` rather than silently: rust 16 B (no index on this hook), swift
reads 3x SMALLER on a 4x corpus so it is below its own noise floor, typescript
288 B on 46 MB, vue +5.4%, cpp 0.04% from c.

Every gate type was proven able to fail: one run with 10 doctored values fired
10 correctly-worded failures across all 8 new languages, covering per-scale
fingerprint, shape/resolved, shape/distinct_outcomes on a non-small arm, depth,
collide scaling, absolute small ms, absolute collide ms, top-level fingerprint
and heap bytes. That proof found two wrong messages, now fixed: the heap
failure claimed a `buildSuffixIndex` cause that is false for python and c, and
the fingerprint failure pointed at a parity harness covering none of the eight.

Wall clock 26 -> 46 s. The ts/js/vue family is 14.6 s of the 18.8 s added,
because `suffixResolve` probes ~39 extensions per path part on a miss — the
real resolver, not something the bench can tune. Per language the bench got
cheaper (2.7 s vs 3.0 s). If it must shrink, `_arms_note` and the CI comment
record the one cut that removes duplicate work rather than coverage — drop
collide for typescript and vue only, -3.9 s, since all three share
`resolveTsTarget` and javascript keeps the arm covering their common axis.
Explicitly NOT `REPS`: it is 15 because `depth_ratio` flaked 1-in-20 at 5, and
lowering it would re-open that for all 17 languages.

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

* perf(import-resolvers): stop building half of every suffix index

Applies the findings of a four-lane quality review over this branch.

**Half of `buildSuffixIndex` was dead weight for most of its consumers.**
Commit b6ee577e0 on this branch made the THIRD map (`dirMap`) lazy for exactly
this reason and left the two larger ones eager. Tracing every reader: Java and
no-csproj C# call `get` and never `getInsensitive`; PHP calls `getInsensitive`
and never `get`. Measured dead weight at 32k paths: Java 49.98 MiB of a 100.82
MiB index, PHP 34.49 of 69.85.

All three maps are now built on first use, and `lowerMap` is DERIVED from
`exactMap`'s insertion order rather than re-traversed — measured 330 ms against
389 ms today, so it is cheaper even for the two-map consumers. `pass-cache.ts`
hands the builder an already-lowercased list, so for TypeScript, JavaScript and
Vue the derivation is the identity and `getInsensitive` aliases the one map.

  java            80.26 -> 25.61 MiB retained   (-68%)
  csharp no-csproj 57.15 -> 21.52               (-62%)
  javascript       44.07 -> 22.65               (-49%)
  php              60.86 -> 32.09               (-47%)
  build @32k      562.1 -> 119.6 ms  (get-only), 329.5 ms (both)

The derivation is proven, not asserted: keys, values AND insertion order
byte-equal over 968,418 entries across case-colliding, Unicode-adversarial and
pathological corpora, plus 400 seeded-fuzz rounds. Order matters because it is
what makes `getInsensitive` return the first match in file order.

PHP additionally defers `filesByRawDirectory` (statically unreachable unless a
composer.json parses) and `firstProperSuffixMatch` (0 entries and 35.6 ms on
the bench corpus) to the branches that read them.

One suggested micro-optimisation was REJECTED with a counterexample rather than
taken: hoisting `suffixResolve`'s lowercase out of the extension loop assumes
`(s + ext).toLowerCase() === s.toLowerCase() + ext`, which is false for a
segment ending in Greek capital sigma — `("ΑΣ" + ".ts").toLowerCase()` is
`"ασ.ts"`, not `"ας.ts"`, because Final_Sigma is context-sensitive and `.` is
case-ignorable. A file named `ΑΣ.ts` would have stopped resolving. 16
mismatches in 2,171,190 checks, for 8.7%.

**The heap arms had become ceilings over nothing.** `retainedIndexBytes` read
only `index.all.length`, so once the maps went lazy it built none of them and
reported ~0 B — passing every ceiling. All heap arms now route through
`retainedPassBytes`, resolving a real missing import through the real resolver,
so the maps measured are the maps production forces. Two further measurement
defects surfaced while fixing it: PHP reaches the index through a second memo,
so the ephemeron chain needs four GC cycles and was reporting 249,208 B for a
9.3 MB index; and `bytes_large` carried an ~11% rope-flattening bias that made
every ratio read 0.85-0.96 for structures that are linear (now 0.998-1.017).

A `heap_floor_fraction` arm was added — a ceiling can only say "not too big" —
and proven by simulating the exact regression: `16 B at 32000 files < floor
17325000 B — this arm has almost certainly stopped MEASURING`.
`csharp_csproj` is now gated too: its old exclusion as "a duplicate of csharp"
held at +20.8% and is false at 2.47x.

**Three silent-coverage holes in the bench.** `LANGS` was a hand-written
literal claiming to mirror `SCOPE_RESOLVERS` while never importing it — the
seam that let JavaScript ship ungated; it is now derived, with an inventory arm
reconciling both directions. Four per-language budget lookups compared against
a possibly-`undefined` value, so deleting a key deleted the gate. Five
dispatchers ended in bare fallthroughs meaning "ruby" and "csharp", so a
mistyped language would have been benchmarked as Ruby's corpus under C#'s
resolver, forever green.

REPS is now chosen per language (15 below 5 ms, else `clamp(ceil(150/ms),7,15)`)
rather than globally by the noisiest cell: timing phase 39.8 -> 28.7 s, with the
six reduced-N languages showing peak-to-peak 1.008-1.071, no worse than the
eleven that kept 15. Worst headroom across all 85 cells is 0.71 of budget.

`depth_budget` for csharp 3.5 -> 2.2 and java 3.4 -> 2.2: their ratios fell to
1.438/1.402 because the lazy maps stop the deep arm paying for a map it never
reads. The file's own note said 3.5 did not lock that win in; 2.2 does.

Also fixes a raw NUL byte that made `suffix-index-lazy-dir-map.test.ts` BINARY
to git — all 395 lines were invisible to diff, blame and grep. The repo
documents this exact hazard in `route-extractors/dispatch-guard.ts`. That file
now also carries the guard the refactor lacked: eight arms pinning one-map-per
consumer and zero-extra-pass derivation, each proven against four mutations,
including a fused-eager rebuild that moves no total and is caught solely by the
at-construction count.

All 17 bench fingerprints and all 85 per-scale tuples unchanged. 1772 unit
tests, 12 adapter guards, tsc clean.

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

* perf(python): memoize the importer's ancestor chain per directory (#2913)

Python's file index was always depth-free; the resolver was not.
`hasRepoCandidate` and `resolveAbsoluteFromFiles` each rebuilt one ancestor
prefix per directory component of the importer on EVERY import, and the
index's own `dirPrefixes` build inserted one entry per component per file.
So an import from `a/b/c/d/e/f/mod.py` did ~6x the prefix work of one from
`a/mod.py` regardless of corpus size — `depth_ratio` 7.239 where the next
worst language sat at 3.446.

The prefixes are a pure function of the importer's DIRECTORY, so they are
memoized per directory inside `getPythonFileIndex` (`ancestorsByDir`), which
is itself already per-file-set. Three smaller cuts came out of profiling the
same delta: the leading segment is rejected up front against a set of nested
directory names, the module and package buckets are consulted before the
walk instead of inside it, and the `dirPrefixes` build stops at the first
ancestor already stored.

Measured over 6 serial runs: depth_ratio 1.748-1.872 against 7.239, and at a
fixed 400 files the per-import cost at 18 directory components drops 6.761 ->
1.065 us. All five python fingerprints are byte-identical, so this is a
hoist; the budget retightening lands in the following commit, because
`_arms_note` is a single JSON line that also carries the heap-gate rewrite.

Also memoizes `pythonFileExportsName`'s `parsedFiles.find`, which was
O(files) for every import whose package probe resolved — the same shape
#2901 removed, keyed on `parsedFiles` rather than on `allFilePaths`.

The new gate is a count, not a timing: `ancestorsByDir.size` after N imports
from D directories must equal D, paired with a reference-identity assertion
so a memo that rebuilds AND re-stores still fails. `CountingSet` cannot see
this defect — the chain derives from the `fromFile` string and a rebuilt
prefix traverses the file set zero extra times.

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

* fix(import-target): close the eleven findings from the #2911 review

Seven P2s and four P3s. Every one is a gate that could not fail or a
comment that had become false; no shipped behaviour defect was found, and
all 85 per-language fingerprints are unchanged.

GATES THAT COULD NOT FAIL

- The C# namespace-dir memo was keyed on a materialized array, so a
  one-character `[...normalized]` copy at the adapter boundary minted a
  fresh WeakMap key per import while traversing the file set zero extra
  times: 67 tests stayed green and only a timing ratio caught it.
  `resolveCSharpImportInternal` now takes the Set and derives both arrays
  from `getWorkspaceFileIndex`, so there is ONE key shape and ONE
  instrument. Copying the Set now turns three arms red. Established first
  that `configs/csharp.ts` is test-only (`buildImportTargetWorkspace` has
  no production caller) and that both derivations are byte-identical —
  otherwise the rekey would have been a behaviour change, not a hoist.

- The contract test called `resolveImportTarget` with four arguments where
  `pipeline/run.ts:682` passes five, so everything behind `context` was
  ungated for all 16 languages: defeating PHP's `filesByDirectory` memo
  cost 197.0 -> 9,976.2 us/import (50.6x) with 248/248 tests green.
  `CountingSet` provably cannot see it — the builder iterates the
  `parsedFiles` array and touches the Set zero times — so the new gate
  counts own-index reads on `parsedFiles` through a Proxy. Only PHP and
  Python have a context leg; the other fourteen carry the floor anyway.

- Three heap budgets were read with no presence check. `ceiling * undefined`
  is NaN and `bytes < NaN` is false, so deleting `heap_floor_fraction`
  disabled the floor for all eight arms; deleting `heap_ratio_budget` did
  the same; and iterating the baseline's keys dropped a language whose
  ceiling key was deleted out of the gate entirely. All three now fail
  closed with a message naming the broken comparison.

- `HEAP_PROBE_TARGET` decided what each heap arm measured and was compared
  to nothing: repointing csharp_csproj at a non-matching namespace dropped
  it 73.70 -> 59.92 MB with `--check` still exiting 0. The four corpus
  fields are now asserted through the loop the timing scales already use,
  and the floor derives from a recorded reading rather than from a ceiling
  that is itself 1.5x the measurement.

- About 35 of the 86 PHP parity arms were structurally unable to fail:
  both sides called the same production helper, so deleting the `..` guard
  left them green. Every hand case now pins an absolute literal as well as
  the differential. Eight of those literals pin a bug or a documented
  limitation and say so rather than blessing the value.

- The registry inventory arm was weighed and KEPT, against the review's
  suggestion, on a structural number rather than a timing: the benchmarks
  job runs 9m23s against a 12m58s critical path, so its seconds buy no
  merge latency, and moving the arm to vitest would put the registry load
  ON that path while weakening what it reconciles. The "7.3 s" and
  "~46 -> ~42 s" figures it was justified with are corrected, including
  stating that only report mode got faster.

- python's `depth_budget` drops 11 -> 2.6 now that #2913 is in. 1.39x the
  measured maximum rather than the file's usual 1.5x, deliberately: at 2.8
  a revert of the nested-name rejection (2.734) would pass. The two parts
  of that fix this arm cannot gate are named, with the count-based arms
  that do gate them.

COMMENTS THAT HAD BECOME FALSE

- `pass-cache.ts` said it deduplicated "three byte-identical copies".
  JavaScript's had five fields and never called `buildSuffixIndex` — that
  missing field IS this PR's headline defect.
- The per-language census said nine where it is twelve, three of them
  added by this PR. Replaced in seven places with the mechanism that
  enforces it, which cannot go stale.
- `getFilesInDir` handed out the index's live bucket. Now `readonly
  string[]`, so mutation is a compile error; `.slice()` was rejected
  because `configs/python.ts` reads only `.length` and a per-import copy
  would reintroduce the term this PR removes.
- #2910 is the Java in-repo-namespace gap, not the JavaScript index defect.
  13 references corrected, the one correct Java use left in place.

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

* perf(python,bench): flatten the bare-import walk, measure the context leg

Two follow-ups the #2911 review surfaced but left open.

BARE IMPORTS (`import os`) still walked every ancestor of the importer.
#2913 fixed the dotted tier; this tier lives in `import-resolvers/python.ts`
and no bench arm can reach it, because every python arm here spells its
imports with a dot and returns at the `pathLike.includes('/')` guard.

It also ran TWICE per `from x import y`: `resolvePythonImportTarget` probed
the package with `targetIncludesImportedName: true`, and on null — the
expensive case, having already walked to the workspace root — fell through
to a byte-identical call. Established that the two cannot differ before
collapsing them: the flag's only effect is to skip
`pythonImportedSubmoduleTarget`, so the recursion re-runs the outer frame's
entire tail on the same three references, and reaching the fallthrough means
that tail already returned null.

The walk itself is now a memoized chain plus an O(1) proof of absence
against the index's basename buckets. Its chain is NOT the one #2913
memoized and the difference is semantic, not accidental — no
`filter(Boolean)`, self excluded, workspace root included — so under an
absolute-path workspace the unfiltered chain probes `/abs/a/` where a
filtered one would probe `abs/a/`, a prefix of nothing. Two negative arms
pin that in both directions. The shared index moved to
`import-resolvers/python-file-index.ts` rather than being reached across a
cycle, which also collapsed a standalone memo into the one per-file-set.

12 / 24 / 72 Set probes at depth 1 / 4 / 16 become a flat 2. At 18 path
components, 11.615 -> 0.740 us/import (15.7x) and the depth curve is gone:
7.843 -> 0.925. Gated by probe COUNT, not timing.

THE BENCH CALLED `resolveImportTarget` WITH THREE ARGUMENTS where
`pipeline/run.ts:682` passes five, so no timing arm entered the `context`
leg for any language. Arity checked against the registry rather than the
comment: php and python declare five, every other hook three or four.
`parsedFiles` is built first and `allFilePaths` derived from it, matching
`run.ts`; fresh per pass, because the memos behind that leg key on the
array identity and `fastest()` takes a min.

Python's `parsedFiles` was structurally unreadable, not merely unread: the
arm passed a `namespace` spelling, which makes `pythonImportedSubmoduleTarget`
return null before the context is consulted. The import KIND had to change
too.

No fingerprint moved anywhere — on this corpus PHP's leg returns the same
file the cascade already did — which is exactly why the new `context` arm
asserts with-context against without-context instead. Defeating PHP's
`filesByDirectory` memo now costs 1003.7 ms against a 148 ms budget; before
this the bench could not see it at all.

Re-recorded on a quiet box, maxima over 5 serial runs: php small 27.762 ->
34.023 and heap 37.6 -> 49.6 MB (`filesByDirectory` is now retained for the
pass), python small 1.76 -> 4.358. `depth_budget.python` moves 2.6 -> 2.2,
because the added work is depth-FLAT: absolute cost doubled while the ratio
FELL to 1.563, so the old budget had gone slack. Both lock-in figures were
re-measured under the new call shape rather than carried over — reverting
the ancestor memo scores 2.524, reverting the nested-name rejection 2.553,
so each fails at 2.2 with 13% to spare.

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

* refactor(import-target): make the key-shape rule a type, drop three censuses

Cleanup pass over the #2911 review-fix commits. No behaviour change: all 85
per-language fingerprints, every `resolved` and every `distinct_outcomes` are
byte-identical, and the targeted suite is 1851/1851.

MEASURED — `byBasename` was 71% empty array slots

`byBasename` holds roughly one bucket per file, and building each with `[]`
followed by `push` makes V8 grow the backing store to its 16-slot minimum, so
every single-file bucket retained 15 empty pointer slots. Constructing the
one-element bucket directly is byte-identical in contents and 5.50 -> 1.60 MiB
at 32000 `.py` paths. The bench arm reads 10543848 -> 6360936 B (-39.7%);
`heap_reading_bytes.python` and its ceiling are re-recorded. The same edit
shares one `{ raw, norm }` between both maps instead of allocating a second
literal for every `__init__.py`.

THE RULE THAT COST A TIMING RATIO TO FIND IS NOW A COMPILE ERROR

`perFileSet`'s key is narrowed from `object` to
`ReadonlySet<string> | readonly ParsedFile[]`. Reintroducing the #2911 defect
shape — a memo keyed on an array materialized from the file set — now fails
with TS2345 instead of silently minting a fresh `WeakMap` key per import while
traversing the Set zero extra times, which every scan-counting guard reads as
green at its correct value.

That also retires the header's hand-maintained roster of `ParsedFile[]`-keyed
call sites, which listed three — this PR added a fourth in `395c707d4` and did
not update it. A census inside a comment warning that censuses go stale, stale
inside one commit. The header now names shapes; the compiler names sites.

Two more claims that had drifted from their code:

- `per-file-set.ts` asserted "No index derived from the file set is keyed on an
  ARRAY materialized from it". `configs/swift.ts` is, deliberately, with its
  reasons written down. Two files in one directory disagreeing is worse than
  either; the rule now states what the type rejects and names the exception.
- `SuffixIndex.getFilesInDir`'s doc explained that it returns the index's own
  bucket by reference. True of `buildSuffixIndex`; the other implementation of
  that interface, in `languages/php/import-target.ts`, returns a filtered copy.
  The interface now carries only the caller-facing contract (`readonly`, do not
  mutate) and the sharing rationale moved onto the implementation it describes.
- The contract test still described Python as having "NO memo on this key".
  `parsedFileByPath` landed in `395c707d4`; the floor of 1 is now its single
  build rather than a per-import scan.

DEDUP

`importerDirOf` replaces four copies of `replace / lastIndexOf / slice` — two in
production, where one was a memo KEY and the other a memo's query argument, so
the two per-directory memos in one index agreed only by inspection. The tests
keep their own verbatim derivation on purpose: importing production's would
make the key lookup agree by construction and hide a regression.

`buildParsedFiles` maps through `probeFile` instead of repeating its 7-field
literal 900 lines away; `requireNumericBudget` and `expectNoOrphanKeys` replace
three and three copies, with every per-arm `why` kept per-arm. The two Python
memo guards collapse onto shared arms in `test/helpers/counting-file-set.ts` —
1847 tests before and after, and both still go red under mutation.

SKIPPED, with reasons: dropping `normSet` for bucket scans (trades O(1) probes
on the hot path for ~1.6 MB against a 6.4 MB reading); measuring heap for all
17 languages (+9 s and a design decision, not a cleanup); `readonly` on the
five sibling resolvers' array parameters and the `getDirMap` slice/join rewrite
(both correct, both outside this diff).

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

* perf(import-target): rewrite the dirMap build, gate heap for every language

The three items the /simplify pass deferred, plus what measuring them found.

`getDirMap` BUILD — 226.9 ms -> 173.1 ms at 32 000 paths

It built every key with `dirParts.slice(j).join('/')`: one parts array, one
slice array and one joined string per file per directory component, in the map
its own doc calls "by far the most expensive" of the three. Now a
`lastIndexOf` walk slicing substrings out of the original string — the same
rewrite `getExactMap` already records at 357.4 -> 264.5 ms.

The key set is identical, not merely equivalent: 272 956 keys over a 32 000
path corpus carrying absolute paths, leading/interior/trailing doubled
separators, Windows separators, extensionless files, dotfiles, dotted
directories and colons, run both slash-normalized and raw. Zero differences in
keys, in key INSERTION ORDER, in bucket contents, in bucket ORDER, or across
767 732 probes through the real index. Bucket order matters because `php.ts`
reads `[0]`.

READONLY on the per-pass shared arrays

`WorkspaceFileIndex.normalized`/`.all` and the `normalizedFileList`/
`allFileList` parameters of jvm, php, ruby, go and standard are now
`readonly string[]`. This PR already made that argument for one bucket
accessor; these are the two biggest arrays held for a whole pass, and the
blast radius of an in-place sort is larger. Types only — no cast, no copy —
and it let two pre-existing `as string[]` casts in
`languages/typescript/import-target.ts` be deleted rather than added to.

HEAP IS NOW MEASURED FOR ALL SEVENTEEN LANGUAGES, AND THE PROSE WAS WRONG

Nine were excluded on measurements taken once and never re-checked, with the
re-entry condition stated in a comment and watched by nothing. Measuring them:

- go, dart and kotlin had NO stated reason at all — the header said "six of
  seventeen" against a list of eight. kotlin retains 45.85 MiB, the
  second-largest reading in this file, larger than ruby's and java's;
- swift and cobol were recorded as below-noise (0.29 MB, 0 B). They read
  3.29 MB and 2.21 MB and grow the right way. The arm changed under them —
  #2903's real-import probe, then corpus flattening — and nobody re-took it;
- the header quoted javascript at two different values four paragraphs apart.

Only rust's exclusion survived: 16 B at both scales, identical over five runs.

Six of the nine are now FULLY budgeted rather than merely bounded — ceiling,
floor and ratio — because each grows linearly (0.996-1.004 against a 1.25
budget). cobol, swift and rust keep an upper bound and no floor, deliberately:
a floor over a reading at or below its own noise gates the noise. Proven live:
restating kotlin's reading so its floor clears the real measurement fails with
"this arm has almost certainly stopped MEASURING rather than started saving" —
the failure that once left four arms at 0 B under passing ceilings.

Cost: +1.37 s in the heap phase, measured per language rather than asserted.

`normSet` was NOT removed, and the reason is now in the code. It is derivable
from the two buckets, but `byBasename` is keyed on BASENAME: on a 9 000-file
service tree `utils.py` and `models.py` hold 1 000 entries each, so `import
utils` would scan every `utils.py` in the workspace per import — the exact
defect class #2901/#2902/#2908 removed. ~1.6 MB against a 6.4 MB reading buys
both probes staying O(1).

All 85 per-language fingerprints unchanged; 1854 tests pass.

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

* test(php): drop the impossible undefined comparison from the parity copy

CodeQL (js/comparison-between-incompatible-types, alert 945) flags the
`ctx === undefined` arm of the legacy adapter copy: `WorkspaceIndex` is an
object type at that position, so the comparison can never be true.

Optional chaining expresses the same guard without the type-level clash —
an undefined index still fails the `typeof` test and returns null — so the
copy remains behaviourally verbatim against the shipped adapter, which is
the only property this harness relies on.

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

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 17:22:51 +01:00
Carter LaSalle
81100e2c74
fix(python): resolve calls through __init__.py re-exports (#2864)
* fix(python): resolve calls through `__init__.py` re-exports

A call to a name imported from a package never resolved when the package's
`__init__.py` re-exported it rather than defining it:

    pkg/impl.py       def target_fn(x): ...
    pkg/__init__.py   from pkg.impl import target_fn
    caller.py         from pkg import target_fn
                      def calls_it(): return target_fn(21)   # no CALLS edge

`caller.py` gets no CALLS edge. Both IMPORTS hops are recorded, and all four
functions are extracted as nodes — only the call binding is missing. Because
`__init__.py` re-exports are how Python packages declare a public surface, this
misses a large fraction of real call edges, and the failure is silent: the
defining file looks like dead code with zero callers.

The re-export closure that should carry this already exists and is fully general
(`buildReexportClosures` — SCC over the re-export subgraph, bounded fixpoint for
cycles, transitive `via` chains). Python just never fed it: the subgraph admits
only `kind: 'reexport'` and `kind: 'wildcard'`, and Python emits neither for
`from m import x`.

Python has no dedicated re-export form. A module-level `from pkg.impl import X`
binds X locally AND publishes it as `pkg.X`, so it is both a named import and a
re-export. Emitting `kind: 'reexport'` would be wrong — that form drops the local
binding, which Python's does create. Instead add an optional `reexportsName` flag
to the `named`/`alias` variants, alongside the existing provider-specific
`importedSymbolKind` / `targetIncludesImportedName` flags, and admit flagged
imports into the closure subgraph. Languages with an explicit form keep emitting
`kind: 'reexport'` and leave the flag unset, so nothing changes for them — a
negative-control test asserts a plain named import still does not resolve.

Verified on a fixture covering the three shapes (direct, top-level-via-re-export,
function-local-via-re-export): 1 of 3 CALLS edges resolved before, 3 of 3 after.

On a 12.4k-file Python/Go/TypeScript repository: edges 294,416 -> 301,443
(+7,027) and execution flows 300 -> 813. A previously "100% orphaned" module
(`shared/db/event_writer.py`) now correctly reports its caller.

5 new finalize tests (single hop, 3-hop chain, alias keying, cycle termination,
and the negative control) plus 6 updated Python fixture shapes.
`npx tsc --noEmit` clean in both packages; full unit suite shows no regression
against baseline (remaining failures are pre-existing load-sensitive flakes in
analyzer-identity / evidence-provenance-helper / skip-git-cli / hooks, each
verified passing in isolation).

* fix(python): set reexportsName only for module-level imports

`interpretPythonImport` flagged every `from m import x` as republishing the
name, but only a module-level statement does. A `from m import X` inside a
`def` or `class` body binds locally and puts nothing in the module namespace,
so flagging it fabricates a re-export of a name no importer can reach:

    # pkg/__init__.py
    def loader():
        from pkg.impl import InternalHelper
    # caller.py
    from pkg import InternalHelper      # CPython: ImportError

resolved to `def:pkg.impl.InternalHelper`. Worse, with declaration-order
first-wins in the closure, a scope-blind entry could claim a name ahead of the
real module-level import and give a WRONG def for legal, running code.

`interpretImport` receives a `CaptureMatch`, which is `{name, range, text}`
with no syntax node, so the scope is not recoverable there — and it is not
recoverable downstream either: `pass3CollectImports` applies no scope filter
and `ImportEdgeDraft.fromScope` is hardcoded to the module scope. The decision
therefore moves up to `import-decomposer.ts`, which still holds the live
`import_from_statement` node, and rides down as an `@import.publishes` marker.
Computed once per statement, not once per imported name, with the existing
`findAncestorBeforeBoundary` helper.

Only `function_definition` and `class_definition` suppress publication.
`if` / `try` / `for` / `with` do NOT — Python has no block scope — so the
predicate is an ancestor walk for those two node types and nothing else.
Verified against CPython 3.11 in both directions; both are now pinned by
tests, including the counterpart control that a branch-nested import still
republishes.

Also corrects the docblock in `scope-extractor.ts` that sent this change the
wrong way. It claims pass 3 attaches imports "not to any `Scope` — finalize
reconstructs the owning scope via `provider.importOwningScope` during Phase
2". Finalize does no such thing: `importOwningScope` is declared on
`LanguageProvider` and implemented by a dozen providers, and
`grep -rnE "\.importOwningScope\b" gitnexus/src/` returns exactly one hit —
that doc comment. Nothing invokes it.

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

* fix(shared): stop guessing ambiguous and namespace re-exports; bound the via chain

Four changes to the re-export closure, all reachable only now that Python
feeds it.

1. AMBIGUOUS NAMES ARE DROPPED, NOT GUESSED. `populateFileClosure` documented
   "declaration order first-wins for duplicates of the same exported name",
   which is sound only where a duplicate export is illegal — two
   `export { X } from …` is a TypeScript compile error, so the rule never
   fires. Python has no such guarantee:

       from .v1 import Client   # legacy, left behind
       from .v2 import Client   # the actual public Client

   CPython binds v2 (verified on 3.11); first-wins attributed every
   `from pkg import Client` in the repo to the DEAD implementation, and
   `impact("Client")` pointed at the wrong file. Last-wins is not the fix
   either: for the equally common `try:`/`except ImportError:` and
   `if sys.version_info` pairs exactly one branch runs, and which one is not
   decidable here. Both directions are wrong on real code, so the entry is
   dropped — the importer stays unresolved, which is exactly the pre-#2864
   answer, and the file-level IMPORTS edge is untouched.

   `collectAmbiguousReexports` runs as a PRE-PASS over data phase 0 froze,
   so the poisoned set is constant across the fixpoint. That matters: a set
   that grew mid-fixpoint would need retraction to propagate to files that
   already inherited the name, would make `myClosure.size > before` an
   unsound progress signal, and would invalidate the `|SCC| + 1` cap. As a
   pre-pass the closure map stays monotone and every existing termination
   argument survives unchanged. Only two flagged drafts resolving to two
   DIFFERENT in-workspace files count; duplicates of one target are
   harmless, and unresolvable targets never entered the closure.

   Checked in both loops. Named re-exports take precedence over wildcards,
   so suppressing only the named loop would hand the name to a later
   `import *` and reinstate an arbitrary winner through the back door.

2. NAMESPACE-RECLASSIFIED DRAFTS ARE EXCLUDED. The admission guards tested
   `draft.source.kind` while `tryFinalize` tests the post-reclassification
   `draft.base.kind`. Python's `from . import logger` is emitted as `named`,
   reclassified to `namespace` by `isNamespaceImport`, and was still
   admitted — republishing whatever def shared the module's simple name. For
   a `logger.py` holding a module-level `logger = logging.getLogger(...)`,
   importers of `from pkg import logger` bound to that Variable instead of
   the module. Reproduced end to end. Both predicates now take the draft and
   test `base.kind`; this is a no-op for TS/Rust, whose only
   `isNamespaceImport` implementation is Python's.

3. `transitiveVia` IS CAPPED AT 32. Each hop copies the inherited path, so
   an unbounded chain is Theta(depth^2) in time AND retained memory, and
   Theta(|SCC|^2) for a cycle whose chain tracks it. `MAX_REEXPORT_DEPTH =
   100` covered this until fc919ad6 removed it — correct for the shallow
   TypeScript barrels that were then the only input, and invisible until the
   input class changed. Measured at depth 400: 67 ms / 145 MB uncapped vs
   25 ms / 40 MB capped. 32 against a real-world worst case of ~6 for
   `__init__.py` chains. Safe because `ImportEdge.transitiveVia` has no
   production reader — it is diagnostic provenance, emitted and typed but
   dropped by graph emission.

4. `localDefs` ARE INDEXED BY SIMPLE NAME. `findExportByName` linearly
   scanned a target's defs on every call, and the phase-3 fixpoint rescans
   the same target once per iteration. Memoized on the array identity, which
   `FinalizeFile` documents as static input. Worth 12-14% where lookups
   repeat and neutral elsewhere.

The 46-line algorithm docblock was also ORPHANED by the helpers inserted
between it and `buildReexportClosures` — AST-verified, that function had zero
jsdoc blocks, so the cross-reference elsewhere in the file landed on an
undocumented function. Helpers move below it (declarations hoist), and its
step 1, precedence and complexity sections are rewritten: they still claimed
regular imports do not contribute to the export surface, and justified the
via-copy cost by TypeScript barrels being shallow.

The `reexportsName` contract consolidates onto `ParsedImport`, where its
"`kind: 'reexport'` would drop the local binding" rationale is corrected —
`materializeBindings` creates a module-scope binding for every linked edge,
re-export included. The real reasons are that `origin` flips, changing
evidence weight and priority, and that it misreports Python's syntax.

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

* test(shared): add a re-export closure scaling guard to CI

No bench covered `buildReexportClosures` at all. Until #2864 its input was
TypeScript barrel files — a handful of shallow edges — and it admitted only
`reexport` and `wildcard` drafts. It now admits every module-level Python
`from m import x`, measured ~20x more edges on the CPython stdlib and cyclic
SCCs where there were none. The pass went from "rarely runs" to "runs over
the whole named import graph" with nothing watching it.

The regression this guards has already happened once: fc919ad6 removed
`MAX_REEXPORT_DEPTH`, which was correct for shallow barrels and stayed
invisible for as long as the input stayed shallow.

The depth arm is an EXACT structural assertion — build a chain far past the
cap, assert the longest emitted `transitiveVia` is exactly `MAX_VIA_LENGTH`.
It started as a `depth_ratio` timing arm and that was a bad gate: sampled
five times capped it scored 2.71-3.52 and three times uncapped 5.87-7.65, so
the ranges nearly touch and one uncapped run came in UNDER budget. A gate
that passes a third of the time on a broken build is worse than none, because
it gets read as evidence. The structural form fails 3/3 with 401 vs 32.

`width_ms` stays a timing arm with a deliberately loose budget, because a
structural check cannot see a constant factor: restoring a per-lookup linear
scan of `localDefs` leaves every array length untouched while making every
real analyze slower.

Both arms drive `finalize` through INDEXED hooks. Reusing the unit tests'
`defaultHooks` is the trap — its `resolveImportTarget` does `files.some(...)`
per import, which is O(imports x files) in the FIXTURE and swamps the pass so
completely that removing the cap measures as no change at all.

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

* fix(cache): bump SCHEMA_BUMP 53 -> 60 for ParsedImport.reexportsName

`reexportsName` is a new field on `ParsedImport`, and `parsedfile-store.ts`
serializes the whole `ParsedFile` generically — so it is part of the cached
shape even though it is not a capture, which is the easy-to-miss variant of
the rule `parse-cache.ts` states as a MUST. (The `@import.publishes` marker
added alongside it moves the capture output too, so this qualifies twice; the
python captures golden confirms the drift.)

Without the bump, a warm `parsedfile-cache` replays pre-fix `ParsedImport`s
carrying no flag, `isNamedReexport`'s strict `=== true` takes the old path,
and the entire fix is a SILENT NO-OP on incremental analyze while every
cold-run test passes. It lands hardest on `__init__.py` — the rarest-changing,
highest-cache-hit files in a Python repo, i.e. exactly the target. A published
npm release invalidates via `GITNEXUS_PKG_VERSION`; dev trees, main-HEAD
installs and CI with a restored cache dir do not.

60, not 54, because the value has to clear every in-flight claim rather than
just origin/main: main is at 53 while open PR #2899 claims 54 and #2891 claims
59. Five exact clashes are recorded in the ledger, and the pin test cannot
detect a tie — both sides assert the same number and both pass. RE-CHECK
against origin/main immediately before merging.

Also documents the divergence between `pythonFileExportsName` and the
re-export closure. That predicate answers "does this package expose X?" from
`localDefs` alone, so with `pkg/__init__.py: from .impl import log`,
`pkg/impl.py: def log` and a same-named `pkg/log.py`, `from pkg import log`
still targets the submodule and the closure is never consulted — for exactly
the case it was built for.

Deliberately NOT fixed by reusing the flag, which is the obvious three-line
change and is WRONG: `reexportsName` is also set for `from . import log`,
where CPython binds `pkg.log` to the MODULE, not a name (verified on 3.11
against the `from .impl import log` form, which binds the function). Returning
true there would kill the correct namespace edge. Separating the two needs the
re-export's own resolved target — i.e. re-entering `resolvePythonImportTarget`
from a different `fromFile` — and that classification is the subject of open
issue #2882, so it belongs with that fix. Not a regression: both halves behave
exactly as they did before #2864.

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

* test(python): re-baseline the scope-capture fingerprint for @import.publishes

CI's `bench/python-scope/measure.mjs --check` failed on capture fingerprint
drift. Intentional: the module-level marker added for `reexportsName` is a new
synthetic capture, and that guard hashes `tag|text|range` over every
`emitPythonScopeCaptures` output.

Attributed before re-baselining rather than after. Reverting ONLY the
`@import.publishes` emission — nothing else — restores the previous hash
a0da3e7c exactly, so the whole drift is that one marker. `capture_groups_fp`
is 3246 either way and `scaling_ratio` stays ~1.0, so no capture group
appeared or vanished and the pass is still linear.

The other nine bench guards were run rather than assumed: scope-capture,
callable-value-flow, finalize-reexport, cpp-qualified-ns,
kotlin-import-target, receiver-resolution, scope-emission, import-target and
cfg all pass. The benchmarks job runs under `-e`, so this failure masked
whatever followed it — worth checking the rest before pushing a one-line
baseline change.

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

---------

Co-authored-by: Carter LaSalle <carterlasalle@gmail.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 12:21:06 +01:00
DuduPhudu
fa31a7d824
fix: close the nine follow-up review findings from #2856 (routes, receiver typing, truncation honesty) (#2899)
* fix(typescript): a type parameter shadows a declared type of the same name (W2-8)

First item of wave 2, promised to the reviewer on #2856.

`export function unwrap<Result>(value: Result): Result` names the PARAMETER, not
the `interface Result` beside it — tsc resolves both annotations to the
parameter. The type-reference capture that makes a contract answerable ("what
breaks if I remove this field?") had no notion of a parameter binding, so every
annotation mentioning `Result` inside `unwrap` minted a `USES` edge into the
interface, at the same confidence as a real consumer and indistinguishable from
one. Measured on the new fixture: `unwrap` produced TWO false edges while the
genuine consumer produced one.

Blast radius is every generic whose parameter name collides with a declared
type, and the colliding names are ordinary choices for both: `Result`, `Key`,
`Value`, `Item`, `Node`, `Options`, `Config`, `Props`, `State`, `Response`.

TWO HALVES, and the first is why upstream's fix could not reach this. #2833
introduced `bindsTypeParameter` for the CALL-receiver path, where a workspace
`class T` was answering for `<T>`. Reusing it here changed nothing at first, and
the reason is its own documented contract: `@declaration.type-parameters` was
captured for class/interface declarations ONLY, so a generic FUNCTION recorded
no parameter list and the predicate correctly returned false — absence is not
evidence. The data was missing, not the logic. So:

  - TYPESCRIPT_SCOPE_QUERY now captures type parameters on `function_declaration`,
    `generator_function_declaration` and `type_alias_declaration`;
  - the graph bridge consults `bindsTypeParameter` before emitting `USES`.

Both are load-bearing — removing either one fails the fixture.

The fixture carries two controls, because the obvious wrong fix is to stop
emitting: a genuine consumer of the interface must still link, and a generic
whose parameter does NOT collide must still link its real reference. Both are
asserted, and the "genuine consumer" case is asserted FIRST so the absences
below it cannot pass vacuously.

SCHEMA_BUMP 53 -> 54: parse-time capture change. A warm cache replays defs with
no parameter list, so the guard reads nothing and the feature is inert while
looking implemented.

Capture fingerprint re-baselined with justification. NO NEW CAPTURE NAME —
diffing the capture-name sets against the wave-1 branch returns empty; the tag
existed and now fires on more declarations. capture_groups_fp 2338 -> 2371,
fixture_count 151 -> 152, scaling 1.06 < 1.5, and JavaScript's fingerprint does
not move at all, which is the check that this is the TS declaration rules rather
than something broader.

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

* fix(analyze): close the four false-success paths in the graph-write-collapse guard (W2-6)

Second wave-2 item, promised on #2856. All four were reported; all four
reproduced by reading the code they name.

(a) A SAME-COMMIT RE-RUN REPORTED SUCCESS FOREVER. Every other meta-driven
    trigger — schema fingerprint, PDG mode, runner identity, CJK segmentation,
    embedding dims — has a block that forces a rebuild before the
    `alreadyUpToDate` fast path. `graphWriteCollapsed` had none; `grep -rn` found
    writes and no reads. So the one state meaning "most of your edges are gone"
    was the one state that repaired itself only if the user happened to pass
    `--force`. Now forces a full rebuild, and forcing is right rather than merely
    re-running: the persisted graph disagrees with what the pipeline produced, so
    an incremental pass over unchanged files would write nothing and re-stamp the
    same broken index as fresh.

(b) AN INCREMENTAL RE-RUN ERASED THE STAMP. `saveMeta` is a full atomic
    overwrite, and the field was spread in only when the CURRENT run had a
    verdict. `undefined` meant two different things at that site — "full run, no
    collapse" (a positive all-clear) and "incremental write, not comparable" (no
    opinion) — so the second case silently dropped `graph-write-collapsed` from
    meta.json while the edges were still missing. Now three-way: stamp on
    detection, CLEAR on a healthy full run, CARRY FORWARD when there is no
    verdict. That is the shape `branch: branchLabel ?? existingMeta?.branch` two
    lines away had all along.

(c) THE SERVER PATH NEVER CONSUMED IT. `analyze-worker-ipc.ts` projects the field
    "so a server-side caller sees the same degraded outcome the CLI does" — but
    nothing read it, so the comment described an intention and every collapsed
    run reported `complete` to the UI and to every API consumer. Now reports
    `failed` with the counts and the remedy, matching the CLI, which prints
    `Repository indexed INCOMPLETELY` and exits non-zero. A consumer that reads
    "complete" will query the index and get confident wrong answers.

(d) --pdg ROWS MASKED TOTAL STRUCTURAL LOSS. `expected` counts the in-memory
    graph plus the streamed STRUCTURAL manifest; the streamed PDG layers never
    enter `graph.relationshipCount`. But `persisted` was `stats.edges`, a count
    of EVERY `CodeRelation` row, and PDG writes into that same table. With 1,000
    structural edges expected and 4,000 PDG rows persisted, losing every
    structural edge still read `persisted = 4000`, cleared the ratio, and stayed
    silent — on exactly the large repos `--pdg` is used for.

    Worth recording that the OBVIOUS fix does not work. Padding `expected` with
    the PDG rows makes the two universes match but leaves the ratio judging a
    minority population: 4,000 of 5,000 still clears 0.5. I wrote that first, and
    the test I wrote to prove it failed. Only comparing structural against
    structural asks the question the check exists to ask, so `getLbugStats` gains
    a `structuralEdges` count excluding `PDG_EDGE_TYPES`. `TAINT_PATH` is
    deliberately NOT in that set — it is a whole-program Function→Function edge
    persisted by the normal emit, so it is structural and stays counted on both
    sides.

    `index-freshness-graph-collapse.test.ts` had pinned the masking as correct
    (`detectGraphWriteCollapse(1000, 4000)` → undefined, "PDG layers write into
    the same table, so persisted > expected is normal"). True about the table,
    and it licensed the hole. Replaced with the case that matters and a note on
    why the fix is at the caller.

The new `structuralEdges` assertion in `lbug-core-adapter` is there because the
failure mode is silent: the query sits in a try/catch that yields `undefined`,
and `undefined` makes the collapse check decline to compare — so a typo in the
Cypher would throw nothing, fail nothing, and switch the guard off. Verified
against a real LadybugDB and mutation-checked by breaking the query.

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

* fix(processes): make process selection insertion-order invariant (W2-5)

Third wave-2 item. Reproduced before fixing: two equal three-step flows with
`maxProcesses: 1` select `handleAlpha`; inserting the identical nodes and CALLS
edges in reverse select `handleBeta`. Same repository, same commit, a different
persisted graph — so a filesystem that enumerates differently, or an incremental
run that reorders assembly, silently changes what the tool reports.

Four sorts ranked by score or length alone and returned 0 on a tie.
`Array.prototype.sort` is stable, so a 0 preserves INPUT order, which traces
back to `graph.iterNodes()`. Under `maxProcesses` capping that decided which
`Process` and `STEP_IN_PROCESS` nodes were persisted at all. Each now falls
through to a totally-ordered, content-derived key — node id for entry points,
the joined path for traces.

WHAT IS ACTUALLY VERIFIED, stated precisely because "four fixes" would overclaim:

  - the ENTRY-POINT sort is individually mutation-verified;
  - the two DEDUP sorts are collectively mutation-verified;
  - the TRACE-RANK tiebreak is NOT individually observable, and the source says
    so. The dedup sorts already impose a total order on the list that reaches
    it, so removing it alone fails nothing. Kept as defence in depth: it cannot
    misbehave — it only makes an already-deterministic order explicit — and it
    is what stops a change to dedup ordering from silently re-opening this.

Finding that out took two fixtures. The first (three chains, three entry points)
is separated by the entry-point sort before trace ranking is reached, so it never
exercises the trace comparator at all; the second gives ONE entry point two
equal-length branches to different terminals, which is the only shape where the
trace comparator decides. Both are kept — they gate different sites.

The invariance tests assert the INVARIANT rather than any single sort, so they
cover all four sites and any future one without needing to know where they are.
Three assertions: same selection under a cap, identical set uncapped, and
identical ORDER — the last because order is what the cap consumes, so a set-only
assertion would pass while the defect persisted.

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

* fix(impact): UNKNOWN dominates a mixed candidate set, instead of reporting the known floor (W2-4)

Fourth wave-2 item. The all-UNKNOWN branch here was reasoned about carefully and
is correct — its comment even names the two ways a set can be all-UNKNOWN. The
MIXED case fell straight through it.

`RISK_ORDER` is `['LOW','MEDIUM','HIGH','CRITICAL']` and has no `UNKNOWN` entry,
so `indexOf('UNKNOWN')` is -1 and an UNKNOWN candidate can never win the reduce.
An ambiguous name with one caller-less candidate (UNKNOWN, per the round-1 fix)
beside one single-caller candidate (LOW) reported `maxRisk: 'LOW'` — a confident
floor over a set containing an interpretation nobody measured. That is the same
false-safe the all-UNKNOWN branch exists to prevent, one case over, and it
surfaced in the UI as "Max blast radius N (LOW risk)".

`maxRisk` answers "how bad could this be?", and an unresolved candidate could be
CRITICAL — so any UNKNOWN in the set makes the aggregate UNKNOWN. Narrowing it
that way would normally cost information, so the measured part travels alongside
as `knownMaxRisk`, present only when the two differ: absent on a fully-resolved
set, where it would duplicate `maxRisk`, and absent on a fully-unknown one, where
there is no measured part. A reader gets "at least LOW among what resolved, and
one interpretation could not be walked at all", which is strictly more than
either value alone. The human-readable message says the same thing.

The seed gained a mixed pair because the existing one could not reach this: both
its twins are caller-less, so it only ever exercises the all-UNKNOWN branch —
which is precisely why the gap survived a round of review. Three assertions,
both halves mutation-verified.

`eval-server.ts` needs no change: it renders `result.maxRisk ?? 'UNKNOWN'`, so it
now shows UNKNOWN where it previously showed the floor.

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

* fix(routes): track ternary polarity in dispatch guards, so a selected verb cannot be inverted (W2-9)

`if ((req.method === 'GET' ? false : true) && pathname === '/api/i')` emitted
`GET /api/i` — the one method that branch guarantees the request does NOT have.
A ternary SELECTS between its arms, so a verb inside one is not reached merely
because the whole condition is truthy, but `findVerbInSubtree` descended into
both arms and returned the first verb it saw. Same inversion `!` produced before
d4dcba8c, one level up.

Handled by folding the ternary where an arm is a boolean literal, which is what
collapses the selection into a conjunction:

    c ? A : false  ==  c && A     both hold, so search both
    c ? false : B  ==  !c && B    c must NOT hold, so search it at flipped parity
    c ? true : B   ==  c || B     a disjunction guarantees neither operand
    c ? A : true   ==  !c || A    likewise

Two non-literal arms leave the verb chosen by an unknown condition, so the
ternary guarantees nothing. Refusing every ternary would also have fixed the
reported bug, but three of the four shapes measured were ALREADY correct and
would have silently lost their verb; they are pinned now.

A second defect in the same walk, found while reproducing: the `!` rule was
keyed on PRESENCE, returning null at the first negation it saw, while
`isNegatedContext` two functions above states the rule is PARITY and says so
outright — `!!x` is `x`. So `!!(req.method === 'GET')` dropped a verb the source
states plainly. The existing double-negation test covered the PATH position,
where the parity walk already ran, and so never saw it. The verb walk now tracks
parity too, and the two agree.

Verb-less, not route-less: the path comparison is untouched evidence that the
branch serves that path, so an inverted verb becomes a missing verb rather than
a missing route.

SCHEMA_BUMP 54 -> 55. Routes are emitted at parse time and replayed verbatim
from a warm cache, so without the bump an already-indexed repo keeps serving the
inverted verb and the fix looks inert. Free against origin/main (48).

Every rule mutation-checked: removing the ternary dispatch, either literal-arm
rule, the negated-ternary guard, or the parity walk each fails exactly the tests
that claim it. One assertion I wrote survived all five mutations and was removed
rather than kept.

Not a recall win on crypto-trading-bot, which contains neither shape — this is
precision insurance for dispatchers that do.

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

* feat(routes): report every method a dispatch guard serves, not just the first (R3-8 part 1)

`if ((req.method === 'GET' || req.method === 'POST') && bundlesMatch)` is two
routes. The verb walk returned the FIRST verb it found, so `route_map` presented
a two-method route as GET-only and `impact` on the POST path found nothing.
Taken verbatim from the reporting repo's researchRunRoutes.js.

`governingVerb` -> `governingVerbs`, returning a list; `findVerbInSubtree` and
`verbFromTernary` likewise. A guard with several verbs emits one route per verb
via the new `pushPerVerb` — they share a path and a handler but not a method,
and `(method, url)` is the key every downstream consumer dedups and looks up on.

A disjunction yields ALL its verbs or NONE, which also fixes an over-attribution
the first-match rule had:

    req.method === 'GET' || req.method === 'POST'   ->  GET, POST
    req.method === 'GET' || isAdmin                 ->  no verb

The second is reached for ANY method when `isAdmin` holds. Reporting `GET` — as
first-match did — describes a route open to everything as single-method, which
is the direction this module treats as more expensive than saying nothing.
Negated, `!(A || B)` is `!A && !B`, so it excludes verbs rather than offering
them and yields none.

Generic descent deliberately stays FIRST-match rather than unioning across
children: an arbitrary node says nothing about how its children combine, and two
verbs found under one are far more likely unrelated than alternatives. `||` is
the one construct that genuinely means "either of these".

Pinned against regression: the pre-existing rule that distributes ONE verb
across an OR of PATHS must not start multiplying methods, and switch arms
inherit the full method set.

SCHEMA_BUMP 55 -> 56. Routes are parse-time output replayed verbatim from a warm
cache. Free against origin/main (48).

Four mutations, each failing exactly the tests that claim it: removing the
disjunction dispatch, dropping the all-operands rule, allowing a disjunction at
odd parity, and emitting only the first verb.

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

* feat(routes): read `.match()` dispatch, and the capturing wildcard it needs (R3-8 part 2)

`RE.test(pathname)` and `pathname.match(RE)` are the same test with the operands
swapped. Only `.test` was read, which is why 28 of the reporting repo's 75 routes
still named the shared route table as their handler rather than the module that
serves them: those modules dispatch with `.match`.

THE CAPTURING WILDCARD, which is the part that made the rest inert.
`regexToRoutePath` accepted `[^/]+` and refused `([^/]+)` — `(` fell through to
the metacharacter bail. So the non-capturing form translated and the capturing
form produced nothing, and every existing test passed because every existing
test used the non-capturing form. The tests were written against the
implementation rather than against the corpus, and the reporting repo contains
no non-capturing path wildcard at all: a dispatcher captures the segment because
it needs the id. This alone also repairs the already-shipped `.test` rule.
A capture around anything that is NOT one segment still bails — `(.+)` spans
slashes — and the alternation is balanced, so `([^/]+` unclosed is not a match.

`.match` differs from `.test` in one way that matters: its result is USED, so it
is almost always BOUND, and the verb then lives in a later `if`:

    const runMatch = pathname.match(/^\/api\/research-runs\/([^/]+)$/)
    if (req.method === 'GET' && runMatch) { … }

Reading the verb off the CALL would report every one of those verb-less. So a
bound match records `name -> path` and the route is emitted where the binding is
TESTED, once per test site — one binding tested for GET and for PUT is two
routes. A reference counts only in a truthiness position (`&&`/`||` operand, or
a whole `if` condition), which is what separates `if (m && …)` from `m[1]`: a
read of the captured segment says nothing about dispatch and would otherwise
mint a duplicate route per use of the id. A binding never tested still emits one
verb-less route — the code did compute an anchored match against the path.

Regexes named by a same-file const resolve too (`pathname.match(POSITION_REPLAY_RE)`),
with the same ambiguity refusal the string-constant map uses: bound twice to
different patterns means dropped, because a half-right regex is a wrong route.

SCHEMA_BUMP 56 -> 57. Free against origin/main (48).

Nine mutations, each failing exactly the tests that claim it. TWO of my own
tests initially survived their mutation and were rewritten, not kept:
- the non-path-receiver case had no path token anywhere in the fixture, so
  PATH_TOKEN_HINT skipped the file and the assertion was satisfied by a file
  that was never examined;
- the negation case used `!m`, which never reaches the negation check at all —
  a `unary_expression` parent is not a truthiness position to begin with. The
  shape that exercises it is `!(req.method === 'GET' && m)`.
A declaration-site skip written alongside them proved unreachable for the same
reason and was removed rather than left to imply a hazard.

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

* feat(processes): report what the detection ceilings dropped, instead of logging it at debug (W2-3)

`processProcesses` has five ceilings - the entry-point trace quota, the
per-entry trace budget, `maxTraceDepth`, `maxBranching` and `maxProcesses` -
and every one of them fired silently. The result came back looking whole and no
consumer could tell it was a sample. The code's own comment already said so:

    // A silently truncating cap reads as "this is everything", which is the
    // same class of confident-empty answer this work is about.

and then only called `logger.debug`. A log nobody has enabled is not a
disclosure.

`stats.truncation` is additive, so every existing consumer of `totalProcesses` /
`crossCommunityCount` / `avgStepCount` / `entryPointsFound` is unchanged. It
carries one boolean to branch on plus a counter per ceiling, kept SEPARATE
rather than summed because they mean different things: unexplored entry points
mean whole flows are missing, while a depth-capped trace means a flow is present
but shorter than it really is.

`processesDropped` counts against the DEDUPED population, not the raw trace
list - the gap between those two is deduplication doing its job, and counting it
as truncation would report a permanent non-zero on every healthy repo.

`truncated` is DERIVED from the counters rather than set at each site, so a
ceiling added later only has to increment its own counter to be reported.

Surfaced at `warn` and NOT gated on `isDev`: "823 flows" printed without it
reads as the complete set, which is the confident-empty failure wearing its
other face - a confident-COMPLETE one. The debug line stays for the per-entry
detail it carries.

Seven mutations, each failing exactly the tests that claim it, including BOTH
directions of the flag: hardcoding `truncated` false fails the four positive
cases, and hardcoding it true fails the nothing-was-truncated case, which is
asserted first precisely so the positives cannot pass vacuously. The
`walksCutByBudget` fixture gives every node exactly `maxBranching` callees so it
asserts its own counter and not a neighbour's.

Also fixes a defect this work exposed: 10b0c7a1 (W2-5) embedded a RAW NUL BYTE
in `trace.join(...)` instead of the backslash-u escape the rest of the repo
uses. It behaves identically at runtime, but `file` reports the source as
`data`, and grep, git diff and code search treat it as binary - several greps
against this file silently returned nothing while I was reading it. main was
clean here; two other files carry the same raw byte from before this branch and
are left alone.

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

* feat(scope-resolution): resolve members through a MEMBER-CALL producer's return shape (W2-1)

    const svc = new SignalService()
    const r = svc.make()
    return r.secretFlag        // <- no edge

`return-shape-members` types `r` to the producer that made it, but a member call
binds the spelling `svc.make`, and slicing that to its last segment leaves
`make` — a METHOD, never a callable binding in scope. The producer lookup failed
and the pass declined.

The limit shipped documented as needing inter-procedural receiver typing. It does
not. Measured on a fixture, the pipeline had already done the hard part:

  - `readMake -> Method:...SignalService.make#0` already resolves as an ordinary
    CALLS edge, so the receiver is already typed; and
  - `Property:...SignalService.make.secretFlag@N:C` already exists, because R3-4
    anchors a returned literal's keys to the METHOD that returns them, not only
    to free functions.

Both halves were present and unjoined — the same shape as R3-5 itself.

ADDITIVE, not a reroute. The new branch sits inside `if (producerFile ===
undefined)`, so it can only fire where the callable lookup already declined;
every reference that resolved before resolves identically, by construction
rather than by test.

Nothing new is inferred. The receiver is typed by the SAME predicate that typed
`r`, and it must itself resolve to a class — a receiver that cannot be typed
still declines, so `make.<member>` is never matched by name across the graph.
That fabrication is what the existing guards exist to stop and they all carry
over unchanged: the owner must resolve, its file must match the candidate's, and
`ownFilePaths` keeps the polyglot class registry from walking a JS read into a
Java field.

The owner segment is TWO parts for a method (`SignalService.make`) and one for a
free function (`makeSignal`), which is exactly how R3-4 qualifies each. That is
what separates two methods of one class returning the same key name from each
other AND from a free function of that name — the fixture gives `secretFlag`
three owners so a wrong resolution is detectable rather than a coin flip that
happens to look right.

Four mutations, each failing exactly the two tests that claim it: removing the
fallback, using the method alone as the owner segment, taking the producer file
from the reading file instead of the owner class, and dropping the
receiver-type requirement. 3,408 resolver tests pass, including
`polyglot-property-isolation`, which is the one this could plausibly break.

No SCHEMA_BUMP: this is a resolution pass over ParsedFiles, not parse-time
output, so a warm cache replays the same input and produces the new edges.

Measured on crypto-trading-bot: ZERO new edges, byte-identical at 62,158. Its
170 `const x = new Y()` bindings are overwhelmingly built-ins (Map, Set,
Promise, S3Client) rather than workspace classes whose methods return object
literals — it is a module-style JS codebase. Correctness fix for class-shaped
code, not a recall win on this corpus, and it should not be presented as one.

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

* feat(scope-resolution): type a bare parameter from what its callers pass (W2-2)

    function readSpike(spike) { return spike.wickRatio }

had nothing to type `spike` from, so the read fell through to the 0.5 name tier.
That is the standing limit of R3-5 and, measured, by far the largest: 11,012 of
13,672 property edges on the reporting repo (81%) rest on that name guess.

The two facts needed were already extracted, for a different consumer. For JS
and TS among others, `callable-flow-captures` synthesizes:

    formal    owner=readSpike  binding=spike  parameter-index=0
    argument  source=s  parameter-index=0  direct-callee-name=readSpike

Joining them on (callee, parameterIndex) says which cell reaches which
parameter, and the argument's own binding is typed by the same
`findReceiverTypeBinding` a directly-bound receiver already uses. So the
parameter inherits the producer and `spike.wickRatio` resolves as evidence
rather than inference.

No new capture, no parse-time change, NO SCHEMA_BUMP. And deliberately not a
change to the callable-value-flow solver that owns these sites: that pass is
guarded by a fingerprint CORRECTNESS gate plus a timing budget, so this reads
the same facts and computes its own map.

AMBIGUITY DECLINES. A parameter whose callers pass different producers resolves
to nothing. Picking one would fabricate at the 0.9 PRECISE tier, which no
`minConfidence` floor can filter out — the same reason `buildConstantMap` drops
an ambiguous constant instead of taking the first.

Keyed by the formal's (scope, name), not by a definition id. The first attempt
used a def and measured `paramDef=NONE`: a parameter is not reachable through
`findValueBindingInScope` (its predicate is `isOwnableValueLabel`, which lists
Const/Variable/Property/Static because it exists for OWNERSHIP registration, and
a parameter is owned by nothing) and it is not a `local` binding either. The
formal site already states the scope its parameter binds in, which is enough.

Formals carry their DECLARING FILE in the key, so two same-named functions in
different files cannot answer for each other — dropping it makes both go
ambiguous and both readers silently lose their edge.

COVERAGE, counted rather than assumed. The synthesis skips an argument that is
itself a call result (an explicit `continue` in `callable-flow-captures`), so
`f(makeSignal())` emits no argument site and only the bound spelling
`const s = makeSignal(); f(s)` is served. That looked fatal until measured: in
the reporting repo, bare-identifier arguments outnumber call-result arguments
2,563 to 50 — 51:1. Extending the shared, benched capture synthesis for the 2%
case is not worth its risk.

Four mutations, each failing exactly the tests that claim it: keeping the first
producer instead of declining on conflict, dropping the read-site lookup,
matching a formal at index 0 regardless of the argument's index, and dropping
the declaring file from the formal key. Two of those could not be caught by the
first fixture at all — it had a single parameter and a single consumer file — so
the fixture gained a two-parameter callee and a same-named twin in a second file
before they were meaningful. The test helper also had to start filtering by
source FILE, or two different `readSpike` symbols merged into one count.

Measured on crypto-trading-bot: 36 reads left the 0.5 name-guess tier. 26 became
precise 0.9 edges (return-shape reads 1,130 -> 1,156, which is the whole delta),
and 10 became honest absences — the receiver was typed, the producer's shape was
known, and the member is NOT on it, so the site is claimed as disproved rather
than left for the name fallback to invent an answer for.

That is ~0.3% of the 11,012, and it should be reported as such. The 81% figure
is the size of the PROBLEM, not of this fix: the shape requires a bound
argument, a producer that returns an object literal, and a parameter read as a
receiver, and that intersection is narrow. The remaining name-tier reads are
mostly receivers no workspace producer types at all.

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

* fix(processes,ci): anchor trace subsumption, cover the sink wiring, stop one bench guard hiding the rest (#2894, #2896, #2895)

Three follow-ups reported against #2856 after it merged. Each was reproduced
before it was fixed.

#2894 — trace subsumption matched mid-identifier.

`deduplicateTraces` decided whether one trace is a sub-path of another with an
UNANCHORED `String.includes`, so a match could begin in the middle of a node id:

    'X->AA->B'.includes('A->B')   ->   true

and `A -> B` was discarded as redundant against a chain `A` is not a step of at
all. Reproduced directly against the function before fixing.

Padding both keys with the separator makes `includes` match whole steps only.
Reported as measured-inert and that holds — the collision needs one node id to
be a strict suffix of another at a `->` boundary, which real ids
(`Function:<path>:<name>`) do not produce. Fixed anyway because the predicate
did not mean what the surrounding code says it means, in a function whose entire
job is deciding what to delete, and nothing pinned it.

`deduplicateTraces` is exported for the test, matching how `traceFromEntryPoint`
and `buildSinkFunctionSet` are already reached. The tests use bare ids because
the shape cannot be built from realistic ones — which is exactly why nothing
caught it. Alongside the regression case, two tests pin that GENUINE subsumption
still happens, prefix and suffix, so the fix cannot degenerate into "subsume
nothing" and pass the first test trivially. Mutation-checked: reverting the
padding fails the mid-identifier test and only that one.

The encoding assumes `->` never appears IN a node id; a C++ `operator->` would
defeat the join regardless of padding. Out of scope, but the assumption is now
written down where the join happens.

#2896 — the sink wiring was only ever exercised through its fail-open catch.

`processesPhase` reads `allFetchCalls` / `allORMQueries` off the parse output
inside a try/catch that falls open to "no sinks", and every phase-level test
omitted `parse` — so all of them took the CATCH branch and the success path had
no coverage. `getPhaseOutput` is a raw `as T` cast, so a field rename would make
the phase detect zero sinks while every test still passed, because zero sinks is
what they already assert.

The new test asserts the one thing only the success path can produce: a flow
ENDING at the sink while a longer chain continues past it. Its control is the
same graph with no `parse` dep, which must NOT produce that terminal — without
the control the assertion could pass for an unrelated reason. Also asserts
`processesPhase.deps` contains `parse`, so the read and the declaration cannot
diverge, and that a parse output missing those fields still fails open rather
than losing every process.

Mutation-checked, including the exact drift scenario reported: renaming
`allFetchCalls` at the read site, dropping `parse` from `deps`, and passing no
sinks to `processProcesses` each fail exactly the test that claims them.

#2895 — a failing bench guard aborted the job and masked every later guard.

Every step in the benchmarks job was fail-fast, so the first failing `--check`
aborted it and the rest reported `skipped`, which reads identically to "nothing
to do". Audited over 13 runs on #2856: the job succeeded zero times and the last
two guards executed zero times for the life of the PR, while two reviews read
the checks summary and saw nothing wrong. Both guards did in fact pass — that
was luck, not verification.

`if: ${{ !cancelled() }}` on all ten steps after the first, so one stale
baseline reports one red step instead of hiding nine. `!cancelled()` rather than
`always()` so an explicit cancel still stops the job instead of running seven
minutes of benchmarks nobody is waiting for.

The two steps easiest to miss are covered: `Receiver-resolution drop guards`,
whose `run:` sits twenty lines below its `name:` behind a long comment, and the
final `Cross-language pipeline benchmarks` step, which is not a `--check` and so
falls outside any grep for one — and is one of the two that never ran.

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

* fix(parse): capture a fetch call site even when its URL is not a literal (#2897)

The `fetch` rule required the argument to be a string or template literal:

    arguments: (arguments
      [(string (string_fragment) @route.url)
       (template_string) @route.template_url])

so `fetch(url)` with a variable matched nothing at all. Measured across this
repository's own TypeScript sources: **44 of 47 fetch calls pass a variable**, so
94% produced no site.

That is what makes R3-6 look inert. The sink set is built entirely from
`allFetchCalls` / `allORMQueries`, so a function performing an outward call
through a computed URL was never a sink, no flow could terminate there, and the
sink-first ranking rule never changed an ordering. The feature was fine; the
signal underneath it was almost always empty.

The URL alternation is now OPTIONAL, so one match covers both shapes. The R3-6
sink set needs only WHERE the program reaches outward, not where to.

Route linking is untouched, by construction rather than by hope:
`processNextjsFetchRoutes` normalizes the URL first and skips anything that
yields nothing, so a URL-less entry cannot mint a FETCHES edge. Verified on this
repo — FETCHES went 8 -> 9 across the change, i.e. the widening added sink sites
without inventing route edges, which was the one real risk here.

Tested in BOTH JavaScript and TypeScript, since the rule is duplicated in each
query block and fixing one would have left the other blind:

  - a variable argument is captured, with no URL   <- the regression case
  - a computed argument (`fetch(buildUrl(), {...})`) likewise
  - a literal URL is still captured WITH its URL   <- route linking depends on it
  - a template URL likewise
  - exactly ONE site per call — an optional alternation must not make a literal
    match twice, which would double-count the site and could mint two edges
  - `prefetch('/x')` is still not a fetch

Mutation-checked: restoring the mandatory alternation fails six of the twelve,
three in each language.

SCHEMA_BUMP 57 -> 58. Parse-time capture output is replayed verbatim from a warm
cache, so without the bump an already-indexed repo keeps its empty sink set and
the fix looks inert — which is the failure this constant exists to prevent, and
would have reproduced the very symptom being fixed.

Not addressed here, and worth stating: this widens `fetch` only. The reporter's
broader point stands — anything keyed on FETCHES / QUERIES is only as good as
the extraction underneath it, and the ORM side has not been measured. A guard
that fails when a corpus known to contain outward calls yields zero sites is the
right follow-up; this change makes such a guard meaningful rather than
tautological.

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

* test(bench): re-baseline receiver-resolution for the two fixtures this PR adds

`receiver-resolution --check` failed on:

    countArm.totalDropsAllKinds: 140 -> 148
    countArm.bySiteKind.write:    11 ->  19

Investigated before touching the baseline, because a guard that exists to catch
unexplained movement should not be silenced by an unverified story.

WHAT IT IS: the count arm runs the real pipeline over a corpus that includes
`test/fixtures/lang-resolution/`, and this PR adds two fixtures there —
`member-call-producer` (W2-1) and `parameter-producer` (W2-2). Each returns an
object literal with two keys, and a producer writing its own returned key is a
write site the receiver recorder logs. Four each, eight total.

Attributed by dumping the individual drops rather than reading the aggregate:

    member-call-producer/src/producer.js   secretFlag, wickRatio  (2 lines) = 4
    parameter-producer/src/producer.js     source, wickRatio      (2 lines) = 4

The eleven drops already in the baseline are all `javascript-object-properties`
fixtures of exactly the same shape, so the new ones are not a new KIND of drop —
they are more of one the baseline already records. This is the first case the
guard's own failure message names: "a fixture was added".

WHAT IT IS NOT: `callDrops` — THE gate number, and `call`-only by deliberate
design because reads and writes "would inflate it" — is unchanged at 102. `read`
drops unchanged at 27. The SHAPE ARM shows no drift at all: no receiver spelling
moved between RESOLVES / VISIBLE-GAP / INVISIBLE-GAP, so no resolution
regressed.

HOW IT WAS ISOLATED, since the first attempt was misleading and the record is
worth having: reverting `return-shape-members.ts` alone did NOT reproduce it and
pointed away from W2-1/W2-2. Only a commit-level bisect was trustworthy —
`origin/main` OK, W2-8 OK, W2-3 OK, then W2-1 +4 and W2-2 +4, which matches the
fixture count exactly. A file-level revert leaves the fixtures in the tree, and
the fixtures are the cause.

The update is two numbers. Nothing else in the baseline moves.

Worth noting where this failure became visible at all: under the fail-fast
benchmarks job it would have aborted the run and shown the five guards after it
as `skipped`. It is legible here because #2895 — fixed in this same PR — now
lets every later guard run.

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

* fix(analyze): stop `--pdg` runs reporting a healthy index as INCOMPLETE

Every `gitnexus analyze --pdg` reported a graph-write collapse and exited 1 on an
index where every row had persisted. Reported by a user hitting it on a real
repo; introduced by this PR's own W2-6(d).

    Repository indexed INCOMPLETELY
    the pipeline produced 200,501 relationships but only 64,764 are readable

The index was complete: 200,190 rows present, 109,905 PDG and the rest
structural, all queryable.

WHAT WENT WRONG. W2-6(d) made the persisted side count STRUCTURAL rows only —
correct, and the reason is in its own comment: PDG writes into the same table, so
counting everything let PDG surplus mask real structural loss. But the expected
side kept using `graphEmitManifest.totalRows`, and that is a BUFFER-POOL SIZE
HINT which counts every streamed row. PDG streams through that same sink, so the
check compared a structural-plus-PDG expectation against a structural
measurement. On any repo with a PDG layer that is a guaranteed false collapse.

It compounds rather than merely misreporting: the run stamps
`graph-write-collapsed`, and W2-6(a)'s rebuild trigger — added alongside it —
forces a full re-analyze next run, which collapses again. A permanent rebuild
loop, on an index that was never damaged, at ~100s a cycle.

MEASURED RATHER THAN ASSUMED, because the first attempt was wrong. I first
subtracted PDG edges RESIDENT in `graph.relationshipCount`, rebuilt, re-ran the
failing command and got byte-identical numbers. Instrumenting the three terms
showed why:

    relationshipCount=20,825  graphManifestTotalRows=179,676
    pdgEmitManifest=absent    residentPdgInGraph=0

PDG is not resident in the graph AND has no separate manifest — it streams
through the ordinary `GraphEmitSink`. The reverted attempt is not in this diff.

THE FIX. A pair key cannot separate them: it is `From|To` NODE LABELS, and a CFG
edge shares `Function|Function` with CALLS. Only the write path sees
`relationship.type`, so the sink now counts a `structuralRows` subtotal there and
publishes it on the manifest. `totalRows` is unchanged — it still sizes the
buffer pool, which is what it was for.

WHY THIS SHIPPED UNCAUGHT, and what changed about that. The wiring test kept a
LOCAL MIRROR of the expected-count expression "because the production expression
is inline in a 3000-line function". A mirror cannot catch a term the original got
wrong. That expression is now an exported
`computeExpectedStructuralRelationships` which production calls and the test
imports.

It also takes the MANIFEST rather than a pre-selected number, deliberately: the
defect was choosing the wrong FIELD, and a numeric parameter leaves that choice
at a call site no unit test can reach. Verified — with the helper taking a
number, reverting to `totalRows` failed nothing; taking the manifest, the same
revert fails four tests.

Verified end to end on the reported command: `analyze --force --embeddings 0
--pdg` now exits 0 with "indexed successfully", 86,963 nodes / 200,217 edges, and
the run clears the stale collapse stamp.

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

* fix(routes): scope match bindings, and intersect ternary conjunctions

Two ways the dispatch-guard walk minted a route that does not exist — the one
thing this module's header says is worse than missing one.

MATCH BINDINGS WERE KEYED BY BARE NAME, FILE-WIDE. `collectFromMatchBindings`
walked from `tree.rootNode` and resolved `matchBindings.get(node.text)` at every
identifier in a truthiness position, so a same-named binding in ANOTHER function
answered for it. The poison check only fired on a second REGEX match with a
different URL; a non-match binding never entered `collectFromRegexDispatch`, so
nothing refused it. Reproduced:

    function handleReplay(req, res) {
      const m = pathname.match(/^\/api\/live\/positions\/([^/]+)\/replay$/);
      if (req.method === 'GET' && m) { … }
    }
    function handleSettings(req, res) {
      const m = req.headers['x-mode'];        // unrelated value, same name
      if (req.method === 'DELETE' && m) { … }
    }

    GET    /api/live/positions/{param1}/replay  handler=handleReplay    correct
    DELETE /api/live/positions/{param1}/replay  handler=handleSettings  FABRICATED

Wrong in method, handler and line. `m`, `match`, `result` are the ordinary names
here. Two ways the truth was then lost: the fabricated route is VERBED, so
`reconcileDispatchGuardRoutes` kept it and dropped the true verb-less one — the
#2856 `/api/report` shape, through the channel this series added — and `tested`
was name-keyed too, so the tail loop suppressed the real binding's own honest
verb-less emit before reconciliation ever ran.

`matchBindings` and `tested` are now keyed on (enclosing function, name).
`enclosingFunction` is extracted from the walk `enclosingHandlerName` already
did, so there is one function-boundary mechanism, not two. A second declarator
for a key refuses it, and an assignment refuses the name in its own scope and
every enclosing one. `buildRegexConstantMap` refuses a name rebound to anything
that is not a regex literal, closing `let RE = /…/; RE = buildDynamic(req)` and
the `new RegExp(prefix + '/x')` twin.

A use resolves only within its own function. Resolving outward would need a
complete declaration model — params, imports, catch bindings — and a miss there
fabricates exactly the route this fixes. Declining costs the verb, not the path.

THE TERNARY TOOK FIRST-MATCH WHERE THE ALGEBRA IS INTERSECTION. The docblock
proves `c ? A : false ≡ c && A` and says "both hold, so search both", but
`firstNonEmpty` returned one operand's set unintersected:

    (req.method === 'GET' || req.method === 'POST')
      ? (req.method === 'POST' || req.method === 'PUT')
      : false                                    emitted GET and POST
                                                 only POST is reachable
    req.method === 'GET' ? req.method === 'POST' : false
                                                 emitted GET, unsatisfiable

`intersectVerbs` replaces it for both conjunction shapes. An empty side still
yields to the other — "names no method" is not "admits none", which is what the
`isAdmin && POST` fallthrough is for — but two non-empty sides intersect, and an
empty intersection is an unsatisfiable guard that yields no verb.

Both changes strictly REMOVE routes, so SCHEMA_BUMP 58 -> 59: routes are
parse-time output replayed verbatim from a warm cache, and without the bump an
indexed repo keeps serving the fabricated verbed route while the fix looks
implemented.

10 tests added, 9 of which fail without the change. All 86 existing assertions
pass unchanged; none was weakened.

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

* fix(scope-resolution): bind a type parameter only inside the scope it opened

W2-8 captured `@declaration.type-parameters` on EVERY `type_alias_declaration`,
but an alias becomes a SCOPE only when its value is an `object_type`
(`typescript/query.ts:149`). For a union, array, conditional, mapped, tuple or
function alias there is no scope, so the def — now carrying `typeParameters` —
attached to the innermost enclosing scope, which is the MODULE. And
`typeParameterNamesInScope` folds each scope's set from its PARENT'S, so the
name landed in every scope in the file. The `USES` guard then deleted every edge
whose target had that simple name:

    export interface Result { ok: boolean }
    export type Maybe<Result> = Result | null      // one ordinary line
    export function readResult(r: Result) { … }    // its USES edge is DELETED

Silent data loss, in the edge class whose whole purpose is answering "what
breaks if I remove this field?". Measured: adding two scope-less generic aliases
emptied the fixture of USES edges entirely.

The existing fixture could not see it — it wrote `type Box<Result> = { held: Result }`,
the ONE alias form that opens a scope.

`typeParameterNamesInScope` now reads a def's `typeParameters` only when that
declaration OPENED the scope owning it: `scope.kind !== 'Module'` and the def-id
position equals the scope range start, via the canonical `definitionIdPosition`
rather than slicing the id. That is the same alignment test `pickCallerCallableDef`
uses to tell a closure from a nested function, and it is language-neutral — it
also covers `function f() { type W<Result> = Result[] }`, which a module-scope-only
stopgap would miss.

Every language populating the capture was audited (ts, java, csharp, kotlin,
rust, cpp): all anchor it on a declaration that IS a scope node, including C++
where the capture rides `template_declaration` but the anchor is the inner
`class_specifier`. Go uses a separate sidecar. The TypeScript non-object alias
was the only mismatch in the codebase. `query.ts` is untouched.

THE GUARD ALSO SAT AT THE WRONG LAYER, which forced three defects at once. It
keyed on `edgeType === 'USES'` — and `mapReferenceKindToEdgeType` maps THREE
kinds there, `type-reference`, `value-ref` (#2437) and `macro` (#1934) — and,
because `Reference` carries no spelled name, substituted the resolved def's name
via `simpleNameOfDefId`. So `import { Result as ApiResult }` inside
`function unwrap<Result>()` deleted a REAL edge, while a namespace-qualified
target (`Host.Result`) kept a FALSE one, and a positional `@row:col` suffix broke
the last-colon parse outright.

Moved to `lookupForSite`'s `case 'type-reference'` in `resolve-references.ts`,
which has the spelled `site.name` and the reference kind in hand. One line closes
all three, deletes `simpleNameOfDefId` — a byte-identical duplicate of
`simpleNameOfGraphId` — and removes the only `graph-bridge/` -> `scope/` import
in that directory.

Honest scope: all three sub-defects are real in the code but none is observable
end-to-end today (`value-ref` never reaches this path; TypeScript emits no
cross-file USES for a type annotation at all — a separate pre-existing gap). Those
arms are labelled forward guards in the test rather than claimed as repros.

Fixture grows 1 file -> 4; 3 of 9 assertions fail without the change. The
scope-capture TypeScript fingerprint moves for FIXTURE-CORPUS GROWTH ONLY, with
per-file accounting that sums to the delta and JavaScript unchanged as the
control — see the `_rebaselined_` key.

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

* fix(scope-resolution): refuse an ambiguous formal, stop the walk at the nearest binding

Two ways W2-2 typed a parameter from the wrong caller, both at the PRECISE 0.9
tier — above every `minConfidence` floor, so nothing downstream can filter them.

`formals` WAS LAST-WRITE-WINS. The key is (filePath, ownerName, parameterIndex),
`ownerName` is a bare identifier, and `emitFormalFacts` emits one site per
parameter of EVERY function collected, nested functions and class methods
included. A plain `.set` let two same-named callables in one file collide — a
free `parse` and a nested `parse`, a free `apply` and `Runner.apply` — so the
last one visited won, fabricating an edge on the loser and leaving the genuine
consumer untyped. The file's own comment covers only the cross-FILE axis.

The correct shape was thirty lines below, in the `producers` map, which does
`producers.delete(cell); conflicted.add(cell)`. `formals` now refuses the same
way: a key claimed by two DIFFERENT parameters is deleted and recorded, so a
third same-named formal cannot re-claim it. Re-stating the same cell is not a
disagreement, so a benign duplicate capture cannot poison a real key.

THE SCOPE WALK CLIMBED PAST A NEARER BINDING. The docblock claimed it stops at
the first scope carrying the name, but it consulted only `parameterProducers` —
a shadowing `const`, a catch binding or an arrow parameter is not in that map, so
the walk went straight past it to the enclosing formal:

    function readSpike(spike) { … items.map((spike) => spike.wickRatio) … }

typed the ARRAY ELEMENT from the outer parameter. `parameterProducerFor` now
stops at the first scope that binds the name AT ALL — reading the scope's own
tables, the same channels and the same reasoning as the sibling
`isNamespaceNameShadowed` — and then stops at a Function boundary. That boundary
is what covers the anonymous arrow: `collectFunctions` drops a callable it cannot
name, so an anonymous arrow emits no formal site and its scope looks empty while
in fact rebinding the name. The cost — a closure genuinely reading an enclosing
parameter now declines — is documented as the deliberate trade.

No cycle guard, deliberately and with the reason stated: both constructions of
`indexes.scopeTree` validate through `buildScopeTree`, which enforces strict
parent-contains-child ranges, so a cycle needs a scope strictly containing
itself. A per-site Set on every read/write site in the repo would defend against
a state the builder rejects.

5 fixtures, 5 assertions; 4 fail without the change and the control passes both
ways. Still uncovered and not faked: a `for (const x of …)` binder shadow — the
binder lives in the loop header, so JS emits no scope to stop at and no Function
boundary intervenes.

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

* fix(analyze): measure one population in every config, split the stamp on the verdict

`1b41c9df6` fixed the collapse check for the STREAMED configuration by giving
the sink a `structuralRows` subtotal. It does not cover the other one.

`resolveStreamGraphEmit` and `resolveStreamPdgEmit` both open with a
`force === true` gate, so a run without `--force` streams nothing: there is no
manifest, `structuralRows ?? 0` contributes 0, and
`scope-resolution/pipeline/run.ts:1222` (`input.pdgEmitSink ?? graph`) writes PDG
into the ordinary in-memory graph, where `relationshipCount` counts it. And
`isIncremental` requires an existing meta, so a FIRST run is a full write and the
check runs. A first-time `gitnexus analyze --pdg` on a fresh repo therefore
compared structural+PDG against structural and exited non-zero with
"Repository indexed INCOMPLETELY" on a healthy index.

MEASURED, not assumed — `runScopeResolution({ pdg: true })` with no sink:

    pdgEmitSink        = absent (non-force shape)
    relationshipCount  = 1
    residentPdgInGraph = 1
    byType             = [["CFG",1]]

The prior `residentPdgInGraph=0` was taken on a `--force` run, where
`input.graph` IS the sink; it never spoke to this case. `graph-collapse-wiring.test.ts`
had pinned the gap, asserting a PDG-inclusive in-memory count was a valid
structural expectation.

`countStructuralRelationships(graph)` filters `PDG_EDGE_TYPES` over
`forEachRelationshipFields` — the same predicate the sink uses for
`structuralRows` and the adapter for `structuralEdges` — so all three terms
measure one population in every configuration. Declining whenever
`pdg && !streaming` was rejected: that is the DEFAULT PDG shape, so the guard
would be off for every non-force run including the only full write most users
ever do. An unscannable graph (mocked pipelines) yields NaN, the same fact the
old `undefined + rows` produced and one `detectGraphWriteCollapse` already
documents as expected input.

THE THREE-WAY STAMP WAS A TWO-WAY. The comment enumerated collapse -> stamp,
healthy -> clear, no verdict -> carry forward, but the code split on the WRITE
MODE. `graphWriteCollapsed` is undefined for two different reasons, and one of
them is "the structural query threw" — so on a full run where the count could
not be READ, the code took "healthy, clear it" and erased a stamp recording real
edge loss. Run 3 then printed "Already up to date" forever: the exact failure the
comment says it fixed, reachable through the new code's own `catch {}`.

`detectGraphWriteCollapse` now returns `'collapsed' | 'healthy' | 'unmeasurable'`
with a reason, and `selectPersistedCollapseStamp` is a pure exported function
production calls. Two boundaries worth naming: `expected === 0` is unmeasurable
(its own docstring calls it "could not report a total"), but the small-repo
exemption and a cleared ratio are HEALTHY — both counts were taken. Making the
exemption a non-verdict would leave a stamp unclearable on any repo that shrank
below 100 edges, relocating the wedge rather than fixing it.

`getLbugStats` now reports `structuralEdgesError` and warns, and `run-analyze`
falls back to `stats.edges` only when the run had no PDG layer, where the two are
equal by construction. With `--pdg` on there is no substitute, so the absence
becomes an explicit unmeasurable verdict — which preserves the stamp.

13 tests added; 8 fail without the change. The integration suite now seeds a CFG
row and asserts `edges` moves while `structuralEdges` does not — the exclusion
filter was previously unexercised, its own comment conceding "structural == total
here".

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

* fix(server): check the collapse before publishing the index

W2-6 marked a collapsed run's job `failed`, but the check ran INSIDE
`.then(() => backend.init())` — after the publish. `LocalBackend.init()` is the
publish step: it refreshes the registry and atomically swaps the in-memory repo
map every MCP tool and HTTP route resolves through, and its `validate` pass
prunes only entries whose metadata is provably gone, so it can publish but never
quarantine. The known-incomplete database was therefore live and queryable before
the job was ever marked failed — the job status was a label on a published index,
not a gate. The pre-existing comment two lines above says so outright: "the repo
is actually queryable when the client receives the SSE complete event."

`backend-client.ts` routes `failed` to `onError` and never calls `onComplete`, so
the UI showed an error toast while every query against that repo answered from
the incomplete graph — precisely the confident-wrong-answers failure this guard
exists to prevent.

The collapse branch now returns before publishing; the healthy path publishes via
a nested `backend.init()` so the trailing `.catch` still converts init failures
into the same message. `closeDbHandle()` runs on both paths — it is eviction, not
publication, and the worker rewrote the DB files regardless of outcome, so
skipping it would leave a stale pre-rewrite handle.

Honest limit, stated in the error string rather than overclaimed: this keeps a
FIRST-TIME analyze unpublished, which is the UI's main flow. On re-analysis of an
already-published repo the existing map entry survives and points at the same
storagePath. A real quarantine needs an un-register hook on `LocalBackend`, which
does not exist today — follow-up.

`'partial'` was considered and rejected on evidence: it is not a status. It is an
embedding-specific detail object in the `updateJob` allowlist; the status union
excludes it. Adding it would make `isTerminalJobStatus` false, so `sse-progress`
never writes a terminal frame and never calls `res.end()` — the stream hangs
open — while `backend-client` falls through to `onMessage` and `api.ts` spins the
full hold-queue timeout. `failed` at least terminates.

The failure branch also now sets `repoName`, which only the success path did.

First tests this file has ever had: 4, of which 2 fail without the change. They
assert the ORDERING, not just the final status, and build the worker message by
calling the production `projectAnalyzeResultForIpc` so a field rename breaks the
test instead of silently disabling the branch.

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

* fix(processes): count the entry-point cap, and make the disclosure proportionate

W2-3 added a truncation disclosure and then missed the largest ceiling it was
written to report. `findEntryPoints` ends `.slice(0, 200)` and
`entryPointsUnexplored` counted against the POST-slice list, so candidates
201..N were invisible — while the derivation docblock claimed "a new ceiling
added later cannot be forgotten here". An existing one was. On this repo's own
corpus the new counter reads 780 of 980 candidates never ranked in.

`entryPointCandidatesDropped` reports the pre-slice count, folded into
`truncated`, with `ENTRY_POINT_CANDIDATE_LIMIT` extracted and `findEntryPoints`
taking the same optional out-parameter `traceFromEntryPoint` already uses. Its
return contract is unchanged.

THE WARN FIRED ON EVERY RUN. At the shipped defaults — only `maxProcesses` is
overridden — `calleesDropped` fires for any function with 5+ callees and
`tracesDepthCapped` for any chain deeper than 10, so an ungated `logger.warn`
was constant background noise, and a warning that always fires is one nobody
reads. The split is the module's own, from the `ProcessTruncationStats` docblock:
"unexplored entry points mean whole flows are missing, while a depth-capped trace
means a flow is present but shorter than it really is."

So `warn` iff whole flows are absent — candidates dropped, entry points never
traced, or flows dropped at `maxProcesses` — and `debug` for a run truncated only
in depth or breadth. `stats.truncation` still carries all six counters; the
machine-readable channel is unchanged, only the log level moves.
`entryPointCandidatesDropped` stays in the loud set deliberately: it is the only
ceiling that GROWS with repo size, while the other two can only fire while
`maxProcesses` is small enough to bind, so gating on those alone would go silent
on exactly the large repos where 200-of-several-thousand is the thinnest sample.
The message leads with the ratio so the line carries a fact, not an alarm.

THREE COMPARATORS ALLOCATED PER COMPARISON, in the function whose own comment
explains the hoist that removed this shape (`deep_chain` 1233 -> 102 ms).
Measured here: +99 ms once per analyze at 80k functions — small, because `n` is
capped at 200 entry points x a 12-trace budget = 2,400 traces regardless of repo
size. Worth fixing anyway: 23,851 comparisons cost 70,524 joins.

One shared `sortByDepthThenPath` (Schwartzian, key built once per trace) now
serves all three sites, and `rankedByInterest` additionally hoists the `isSink`
test that ran twice per comparison. It also settles a separator inconsistency:
`deduplicateByEndpoints` joined on a SPACE while `traceOrderKey` used NUL, and
node ids embed file paths, so two different traces could produce the same key and
the tiebreak fell back to the insertion order it exists to remove — the same
hazard this series' own `->`-padding fix addresses. Order identity is pinned by a
seeded 200-trace corpus asserting the new sort equals the old one exactly.

11 tests added, 9 failing without the change, including two end-to-end
insertion-order arms. The W2-5 determinism block is unregressed.

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

* fix(docs): restore the agent guidance, and put it in the generator that deleted it

Commit `9e602aef0` — whose message is entirely about the fetch capture — also
regenerated the machine-managed `<!-- gitnexus:start -->` block from a local
non-`--pdg` index, deleting from both AGENTS.md and CLAUDE.md:

  - the whole `MUST treat risk: UNKNOWN as unresolved, not as low` bullet
  - the `pdg_query({mode:"controls"/"flows"})` bullet
  - the `mode: "pdg"` text on the impact bullet
  - `…never read UNKNOWN as an all-clear…` from Never Do

and regressing the stats 248612/565510/918 -> 42853/135955/758. All four document
SHIPPED features: `pdg_query` at `mcp/tools.ts:675`, dispatched at
`local-backend.ts:2233`; `mode: "pdg"` at `tools.ts:448`; `riskNote` at eight
sites.

It matters more than a docs nit because the SAME series makes `UNKNOWN` dominate
a mixed candidate set (`local-backend.ts:6058`) — correct, and it makes UNKNOWN
far more common. The surviving rule only warns on HIGH/CRITICAL, so a set
measuring CRITICAL now reports UNKNOWN and that rule no longer fires, while the
rule that covered the gap was deleted in the same commit range, from all three
files agents actually read.

ROOT CAUSE, which is why restoring the files alone would not have held.
`cli/ai-context.ts` is the template. The `pdg_query` and `mode: "pdg"` text IS in
it, correctly `hasPdg`-gated — a non-PDG analyze SHOULD drop those. The
`risk: UNKNOWN` rules were never in the template at all: they had been hand-added
INSIDE the machine-managed region, so every `gitnexus analyze` on any repo
silently deleted them. This was the second occurrence; #2856's `8f8261021` was
the first. Both lines are now generated unconditionally — they describe impact's
risk semantics, which are not PDG-dependent — so regeneration restores them
instead of removing them.

AGENTS.md and CLAUDE.md are byte-identical to origin/main again, and the fixed
template reproduces that block exactly for `hasPdg: true` plus the real stats.
`.claude/skills/gitnexus-guide/SKILL.md` regains the "Inline staleness signal"
section for a live feature (`local-backend.ts:921`, `:1017-1024`, `:1995`); the
npm mirror's lack of it is pre-existing drift and is left alone, so the new sync
guard is scoped to the canonical and plugin copies.

Guards added, both demonstrated failing against the unrestored files: the managed
block must contain the UNKNOWN policy and its Always-Do/Never-Do bullet counts
must not fall below a floor, and `generateGitNexusContent` must render both lines
for `hasPdg` true AND false while keeping `pdg_query` gated. The existing
fragment lists could never have caught this — they assert presence, and this was
a deletion.

One deliberate loosening, called out rather than buried: the restored text pushes
`ai-context.test.ts`'s block-size ratio past 0.55, so it moves to 0.65. That test
argues against exactly this nudge-the-number pattern. The defence is that the
wording is origin/main's own and the 0.55 budget was calibrated against a block
already missing it; trimming shipped guidance to fit a budget would be the wrong
direction.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-08-09 11:44:52 +01:00
Gergő Magyar
78ecce1b92
perf(import-target): index the workspace once per run for go/csharp/dart/ruby (#2898)
* perf(import-target): index the workspace once per run for go/csharp/dart/ruby

Four import-target resolvers answered their lookups with a full
`allFilePaths` scan per import, making resolution O(imports x files):

- go (#2877): `findRootPackageFiles` / `findAllFilesInPkgDir`, the latter
  once per path segment on the GOPATH fallback. Most Go imports are
  external, so the whole cascade ran to completion before returning null.
- csharp (#2878): the no-csproj leg took the raw Set past the memoized
  index the csproj leg was already using - up to eight passes for a
  four-segment `using`.
- dart (#2879): one scan per candidate path, and for an external package
  both candidates miss, so both always ran to completion.
- ruby (#2880): a complete `buildSuffixIndex` rebuilt and discarded per
  `require` - every require paid to index every file in the repo.

Each now reads an index memoized on the `allFilePaths` Set identity, the
shape `getPythonFileIndex` (#1918) and csharp's own `getWorkspaceFileIndex`
(#1881) already used. Two shared modules back them:

- `workspace-file-index.ts`: normalized list + `SuffixIndex` + a
  normalized->raw map, for csharp and ruby.
- `package-dir-index.ts`: "which files live directly inside a directory
  ending with <path>", for go and csharp. Candidates are bucketed by the
  directory's last segment rather than by indexing every directory suffix,
  which would cost O(files x depth) entries at kernel scale (#2649).

Behaviour is unchanged, including the tie-breaks that are expressed only
through Set-iteration order and `indexOf` positions: the go root leg stays
sorted and its package leg stays unsorted, the first-occurrence rule that
excludes a directory nested inside a same-named directory is preserved,
csharp's whole-path match still beats an earlier suffix match, and dart
still tries `lib/<rel>` fully before bare `<rel>` and matches raw paths.

Verified two ways. `import-target-index-parity.test.ts` keeps verbatim
copies of the pre-change implementations and diffs against them over a
deterministic corpus plus hand-built layouts for each tie-break; six
mutations of the new code were confirmed to fail it. Separately, the bench
corpus produces byte-identical fingerprints against the pre-change
resolvers at both 400 and 1600 files.

`bench/import-target/measure.mjs` gates both arms in CI: per-language
output fingerprints, a scaling budget (measured 0.98-1.12 here, 3.32-4.10
against the pre-change scans), and the corpus shape, so the corpus cannot
be shrunk below the size the scaling arm needs and still print PASS.

Closes #2877
Closes #2878
Closes #2879
Closes #2880

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

* perf(import-target): cover kotlin, and add depth + absolute-cost arms

#2872 landed the same index hoist for Kotlin while this branch was open.
Fold it into the shared measures so all five resolvers are gated on one
corpus, and adopt the two arms that PR's review proved a scaling ratio
alone cannot carry.

- `bench/import-target/measure.mjs` gains a kotlin arm: a Gradle-shaped
  corpus with per-module source roots over one package namespace, `.kt`
  and `.kts` stems, a nested same-name package directory, and a share of
  wildcard `.*` imports so the package fan-out tier — the only tier whose
  output is order-bearing — is inside the fingerprint.

- `depth_ratio`: deep arm at a FIXED file count with ~6x the path
  components. `scaling_ratio` divides the file count out, so it is
  scale-invariant and structurally cannot see a cost that grows with path
  depth instead, and `buildSuffixIndex` (C#, Ruby) and Kotlin's
  `suffixByStem` each emit one entry per component. Measured: go 0.98,
  dart 0.88 (depth-free indexes), ruby 1.48, kotlin 2.20, csharp 3.45 —
  which is why the budget is per language. One global budget would have
  to sit at 5.0 and would let Dart go 0.88 -> 4.9 unnoticed.

- `small_ms_ceiling`: an absolute bound at 4x the measured arm, because a
  constant-factor regression that grows both scale arms equally passes
  every ratio.

- The deep arm must resolve exactly what the small arm resolves. Padding
  was supposed to change depth and nothing else; a deep arm that stopped
  resolving would be timing the null path.

The five fingerprints are unchanged by this commit - verified against the
previous baseline before rewriting it, so adding the kotlin arm and the
deep scale did not perturb the four languages' output.

Kotlin joins the Set-iteration counter in
`import-target-index-parity.test.ts` too. Its own guard
(`kotlin-import-index-reuse.test.ts`) counts index BUILDS, which a scan
added beside a reused index does not move.

That counter is also the only DETERMINISTIC guard against a reintroduced
scan, and this commit documents why rather than pretending otherwise: a
full workspace scan on 1-in-32 imports was measured to pass every timing
arm here (dart, 1.458 scaling against a 1.8 budget, 1.736 ms against a
4 ms ceiling) while the counter reads 14 instead of 1. Tightening the
ceilings toward the noise floor to chase that case would only buy flaky
CI.

`bench/kotlin-import-target/` stays: it fingerprints both file-set
iteration orders and probes the four-tier cascade shape by shape, neither
of which this corpus does.

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

* perf(import-target): merge matching package dirs in one pass

`filesDirectlyInPkgDir` re-spread its accumulator once per matching
directory, costing O(files x dirs^2) copies per import. On a Go monorepo
where many services carry the same package directory (`svcN/internal/pkg`,
which Go's GOPATH cascade queries by two-segment tail) that made the index
SLOWER than the scan it replaced: 13.4x at 1600 matching directories.

Append into one array and sort once. Measured against a verbatim copy of
the pre-change scan, output byte-identical at every k:

  k=200   1400 files   old 0.126 ms   was 0.169 ms   now 0.042 ms
  k=800   5600 files   old 0.457 ms   was 3.002 ms   now 0.185 ms
  k=1600 11200 files   old 0.960 ms   was 12.890 ms  now 0.232 ms

The index now beats the scan by 2.5-4.1x on this shape instead of losing
to it by up to 13x.

Also drop the min-`ord` comparison in `firstFileDirectlyInPkgDir`: the
build loop appends a directory to its last-segment bucket the moment it
accepts that directory's first file, so bucket order already IS ascending
first-file-`ord` order and the first hit is the minimum. Differentially
verified at 0 divergences. The invariant, and the build-loop edits that
would silently break it, are now recorded at the early return.

Type the index containers as deeply readonly so Go's deliberate
`[...rootFiles].sort()` copy is compile-enforced rather than
comment-enforced, and correct the header's claim that a polyglot repo
"never pays" -- only the stored index is per-language.

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

* test(import-target): guard index reuse at the adapter boundary

`workspace-file-index.ts` documented the hazard as "a defensive
`new Set(allFilePaths)` in an ADAPTER" -- the bug #1918 shipped -- and
named the unit parity test as the guard. It is not: that test imports the
resolvers directly, while production reaches them through
`<lang>ScopeResolver.resolveImportTarget`. Inserting the copy at
`go/scope-resolver.ts:31`, `csharp:35`, `dart:189` and `ruby:268` left the
parity test 28/28 green and `measure.mjs --check` PASS in all four cases.
Kotlin and Python already had adapter-level guards; go/csharp/dart/ruby
had none.

Add `test/integration/<lang>-import-index-reuse.test.ts` for the four,
mirroring the Kotlin/Python precedent: resolve through the scope resolver,
assert the file set is traversed once (twice for C#, which builds two
indexes), and pair every count with a result assertion so a count of 1
cannot be the count of an adapter that resolves nothing. Each was proven
to fail under the copy it exists to catch:

  go     expected 600 to be 1     dart   expected 600 to be 1
  ruby   expected 400 to be 1     csharp expected 600 to be 2

`CountingSet` moves to `test/helpers/counting-file-set.ts` and now counts
`forEach`, `values`, `keys` and `entries` as well as `[Symbol.iterator]`.
It missed a rescan spelled `allFilePaths.forEach(...)` entirely; with the
overrides that mutation reads 14 instead of 1.

Four fixtures that pinned the guard next door, each now shown to kill its
mutation:
- the Dart "matched RAW" case used a forward-slash target, so the basename
  bucket missed before the raw comparison was reached and it asserted
  `null === null`. A positive twin carrying the backslash in the TARGET
  catches both half-mutations.
- no C# or Ruby target addressed the corpus's `win\dir\thing` file, so
  deleting the backslash normalization in `workspace-file-index.ts` passed
  both gates. Now 4 failures.
- `normToRaw`'s first-wins rule had no normalization twin in any corpus.
- the Go nested-package fixture was decided by the `endsWith` half and
  never reached the first-occurrence branch its title names; addressing
  the directory as a single segment makes it reach it.

The parity test's own docstring no longer claims the scan count is a
complete census -- it names the three materialized arrays it cannot see.

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

* test(import-target): assert every scale, add collide and retained-heap arms

Three holes in the gate this PR ships as its own proof.

1. `--check` computed three fingerprints per language, stored all three,
   and compared one. `DEEP_PAD = 16 -> 0` deleted the entire depth arm and
   still printed PASS, because depth padding is count-neutral by design so
   no asserted number moved. Assert `fingerprint` per scale, and assert
   `deep.fingerprint !== small.fingerprint` so the padding's EFFECT is
   pinned, not just its output.

2. The corpus minted per-index directory names (`src/pkg${d}`,
   `src/Ns${d}`, `lib/feature${d}`), so max last-segment bucket and max
   matching dirs were both 1 -- and bucket cardinality is the only
   non-constant term the index has. The `dirCount > 1` merge branch had
   never executed in any arm. Add a `collide` arm on shared-leaf layouts
   with identical files/imports/resolved counts; it reaches 9,269
   multi-directory merges per run, up to 34 directories at once. Go and
   C#/Dart legitimately score above the linear budget there and get their
   own; Ruby and Kotlin stay at 1.8 because their keyed maps are
   collision-immune and that immunity is the assertion.

3. No arm measured memory, while the C# no-csproj leg newly retains an
   O(files x depth) suffix index. Add a retained-heap arm on the
   `bench/cfg` pattern, including its loud failure when `--expose-gc` is
   missing rather than a silent skip. Measured at 32k files:
   csharp 73.62 MiB, ruby 55.26 MiB. Ceiling is 1.5x, NOT the 4x the
   timing arms use -- the measurement is byte-stable to 0.00085% across
   processes, so 4x would be throwing away the gate. `_arms_note` records
   why, so nobody harmonises it back.

`depth_ratio`, added by this PR, flaked ~1-in-20: go peaked at 1.748 and
dart at 2.043 against a 1.6 budget, both ratios of two sub-3 ms minima.
Fixed at the estimator, not the threshold -- REPS 5 -> 15, matching
`bench/cfg`, `schema-pairs` and `callable-value-flow` (5 was the lowest in
the repo; the sibling `kotlin-import-target` uses 7, which was not enough
here). 22/22 PASS, every arm now at 70-78% of its budget with a <=1.26x
swing. No budget was widened; the distributions are recorded in
`_arms_note` so the headroom is visibly earned.

Three copies of the same overclaim corrected: the parity test NARROWS the
1-in-32 blind spot, it does not close it -- it watches the Set while the
resolvers hold materialized arrays. `_floor` no longer claims its ratios
"match" the issues' (different corpora, both quadratic).

The step moves to the END of the benchmarks job and runs with
`--expose-gc`. A failing step aborts every step after it (#2895), so the
newest, least-proven gate must not sit ahead of eight established ones.

All five output fingerprints are byte-identical to before this session --
the proof that every change here was behaviour-preserving.

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

* docs(import-target): point the reuse contract at the guard that guards it

`workspace-file-index.ts` told callers the unit parity test guards the
adapter-copy hazard. It does not -- it never crosses the adapter. Name
both layers and say which catches what: the per-language
`test/integration/<lang>-import-index-reuse.test.ts` files at the adapter
boundary, the parity test for a rescan reintroduced inside a resolver.

The C# namespace-dir index comment named `findDirectChild`, which this PR
deleted; it feeds `firstFileDirectlyInPkgDir` now.

Drop `GoResolveContext`, dead since the legacy call-resolution DAG was
removed in #942 -- zero importers, and `gitnexus`'s package.json declares
no `main`, `exports` or `types`, so it is not a published surface.

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

* refactor(import-target): quality pass over the review-round changes

Four cleanup lanes (reuse / simplification / efficiency / altitude) over the
previous four commits. No behaviour change anywhere: all 25 bench cells
(5 languages x 5 arms) are byte-identical on files, imports, resolved,
distinct_outcomes and fingerprint, re-verified after each individual edit.

**Restores a fast path the last commit lost.** Fixing the O(k^2) accumulator
made the SINGLE-directory case — the overwhelmingly common one — copy the
bucket where the original aliased it: measured 1.11x slower at 4 files/dir
rising to 1.72x at 128. Holding the first bucket by reference and promoting to
an accumulator only when a second directory appears is 0.65-0.97x of the
previous code at dirCount=1 and parity at dirCount=64. 176-case differential,
0 divergences.

**`sortedRootFiles` accessor.** `rootFiles` was the only index container read
directly from outside the module. `readonly` is erased at runtime and
`Array.isArray` widens it back, so the copy rule now lives with the code that
owns the invariant instead of at the call site. No `Object.freeze`: V8's
PACKED_FROZEN_ELEMENTS read cost lands on the hot `matchingDirs` path.

**One shared arm for the four reuse guards.** The distinct-file-set test was
copy-pasted four ways, 33-38 identical lines each, and this repo's own helpers
(`mini-repo.ts`, `scope-model.ts`) document extracting at the SECOND verbatim
consumer. `expectDistinctFileSetsGetOwnIndex` takes what actually varies; its
`expected` type excludes `null` so the pairing rule cannot be reinstated as a
hole. The per-language first and third arms stay duplicated on purpose —
corpora and payload shapes genuinely differ. Re-proven: all four still fail
under an adapter-inserted `new Set(allFilePaths)`.

**Bench.** `dirsFor` shared by the two functions that must agree on directory
fan-out (they mint and address the same files). `SCALES` derived from the arm
table, so a future arm cannot be measured, printed and silently never asserted.
Five timing checks with one shape collapsed to a table — the trailing sentence
had already drifted into four wordings. `uniqueTarget`/`collideTarget` as flat
functions, mirroring the `uniqueDir`/`collideDir` split rather than nesting a
second axis four ternaries deep. One `identityPass` replaces two untimed full
resolution passes per cell: -371 ms median.

**CI step moved back where it belongs.** It was parked last "until #2895
lands", but that reasoning was backwards twice over: the flake that motivated
it was fixed at the estimator in the previous commit, and #2895's own audit
measured the last slot as executing zero times in 13 runs. It sits with the
other resolver-index guards; #2899 carries the `if: !cancelled()` that fixes
step masking for every step at once.

Filed rather than fixed here: #2908 (java and cobol still scan the workspace
per import, same shape as #2877-#2880, neither memoized), #2909 (make index
reuse a contract test over SCOPE_RESOLVERS on one instrument).

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

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 09:59:37 +01:00
glier
c6b24162d9
perf(kotlin): index import resolution instead of scanning per import (#2872)
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
Skill copy sync / shipped skills drift guard (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
* perf(kotlin): index import resolution instead of scanning per import

`resolveKotlinImportTarget` walked the entire workspace on every import.
Its four tiers — exact/suffix, directory child, package fan-out and
progressive prefix strip — each ran `for (const raw of allFilePaths)` with a
`replace(/\\/g, '/')` and several string scans per entry, and they are tried
in cascade, so one unresolved import cost two to four full passes.

Across a repository with tens of thousands of Kotlin files that is
O(imports x files): on the order of 10^10 string operations on a single
thread. It does not look like a hot loop from the outside - analyze sits at
exactly 1.00 core with a completely flat heap and emits nothing for hours,
because every allocation is a short-lived string and nothing accumulates to
hint at progress. Small repositories hide it entirely: at a few hundred files
each pass is free.

Three maps, built once per `allFilePaths` Set and memoized on its identity,
make each tier O(1): stem -> path for the exact tier, every component-suffix
of the stem for the suffix tier, and directory -> direct children for both the
fan-out and the first-child fallback. Cost becomes O(files) once plus O(1) per
import. This mirrors the existing Python index (`getPythonFileIndex`), down to
the WeakMap keying and the build counter.

Semantics are unchanged, including the parts the scans expressed only through
iteration order:

  - an exact match anywhere beats a suffix match found earlier, because the
    scan returned on the first exact hit but merely remembered the first
    suffix hit;
  - "first match" stays first in set-iteration order, so both stem maps keep
    the earliest path inserted for a key;
  - a directory-name match still honours the scan's `startsWith`-then-`indexOf`
    rule, which only ever considered the FIRST occurrence of `/dir/`. A path
    like `data/src/main/kotlin/com/example/data/Repo.kt` is therefore still
    NOT a child of `data`. That is arguably wrong, but fixing it here would
    silently move edges in every Kotlin repository; it belongs in its own
    change with its own fixtures.

That claim is gated, not asserted. `bench/kotlin-import-target` fingerprints
every `fromFile | targetRaw -> result` triple over an exhaustive branch matrix
plus a deterministic fuzz, each file set resolved in BOTH iteration orders
because that is the only place the tie-breaks above are expressed. The
committed baseline is the value the PRE-INDEX implementation produces: both
implementations print
5ad605c179081505705ff7698a09dbdbdc4831080af6d9fdec5499cc6bce28ee over the same
20074 cases, 11612 of them non-null, and anyone can re-run it by pointing the
harness's module specifier at the old file.

Its second arm is the scaling ratio, `(t_large/t_small)/(1600/400)` over a
synthetic Kotlin monorepo whose imports are ~40% unresolvable — only a miss
drives all four tiers, which is where the scan was worst. The index measures
0.99 (8.0 ms / 31.7 ms); the implementation it replaces measures 3.737
(2207.8 ms / 33003.5 ms) on that same corpus, so the budget of 1.6 separates
them by a wide margin. Take the absolute times as an order of magnitude only
(~276x, ~1041x): the floor arm was run once cold because best-of-seven against
a quadratic implementation costs minutes, while the index arm is the usual
best-of-seven. The ratios are the comparable pair. Both arms run in the
existing always-on `benchmarks (GITNEXUS_BENCH)` job, next to the C++ guard
from #2788 and the Python one from #1918.

Two unit-level guards sit alongside it: a parity test pinning the curated
cases, and an integration test asserting the index is built once across many
imports — the adapter must pass the Set through, since a defensive copy would
hand a fresh WeakMap key per call and restore the old behaviour (the same trap
Python hit in PR #1918).

Two other providers have the same defect and are left alone here, having no
repository at hand to verify a change against:

  - `go/import-target.ts`: `findRootPackageFiles` and `findAllFilesInPkgDir`
    scan unmemoized, and the GOPATH fallback calls the latter once per path
    segment but the last, so a single import can trigger several full passes;
  - `dart/import-target.ts`: the `package:` branch scans once per candidate
    path — `lib/<rel>` and bare `<rel>` — and `resolveRelative` scans again in
    its suffix fallback, also unmemoized.

`csharp/import-target.ts` is a partial case worth noting: it already builds a
memoized `getWorkspaceFileIndex`, but that is reached only when a `.csproj` is
found; the no-csproj path hands the raw Set to `resolveDirectMatch` and
`resolveByProgressiveStripping`, which scan past it.

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

* test(kotlin): close the blind axes in the import-resolution gate

Review of #2872 found the weak part was the gate, not the resolver: four
plausible follow-up mutations passed `--check` with a byte-identical
fingerprint, `cases` AND `non_null`. Each is now caught, and each was
re-checked against the mutation it exists to stop.

  - The hashed record carried `order | fromFile | targetRaw | result` but not
    the FILE SET, so a corpus edit that swapped the workspace under a case
    while leaving its result string alone was invisible. Leaving the resolver
    untouched and editing only the corpus, two documented load-bearing cases
    could be gutted — the "exact beats an earlier suffix" case losing its
    competing file, the repeated-directory negative case losing its file
    entirely — with the gate green. The file set is now part of the record, and
    that same edit now moves the fingerprint.
  - The corpus capped path depth at 8 components and packages at 16 files,
    which are precisely the two axes the loops this change added run on. It now
    carries 11- and 13-component paths, queries against suffix keys deeper than
    seven segments, a 40-file package, and a fuzz that spans both. Verified:
    capping suffix-key depth at 7, skipping the `dirChildren` suffix loop above
    depth 8, and capping a bucket at 17 entries each now move the fingerprint,
    where all three previously passed.
  - `non_null` was reported but never asserted; it is asserted beside `cases`.
    That closes only the "resolves nothing at all" hole — it stayed 11612 under
    all three code mutations above and under the corpus edit — so it is a
    companion to the two fixes above, not a substitute for either.
  - A ratio cannot see a constant factor, and a file-count ratio cannot see a
    depth cost. `--check` now also asserts a DEPTH ratio (file count fixed,
    paths 24 components against 8) and an absolute ceiling on the small arm: a
    full workspace scan reintroduced on 1-in-32 imports scores 1.490, inside
    the scaling budget, while running 2.8x slower.

The baseline is re-derived, not adjusted: the pre-index implementation and the
index both print
ebf1790bf1d42dad483a51f2cbdeb2351e493b9e8236e4eedeef592dd81e2c5c over the new
20106-case corpus, 13256 of them non-null.

Both test suites were shown to be non-load-bearing and now are:

  - the parity test's repeated-directory case put `data` at the LEADING
    segment, so the `startsWith` guard fired and the `indexOf` rule its own
    comment describes was never reached — a resolver with that check relaxed to
    `>= 0` passed all 18 cases. A mid-path case now pins it, and a backslash
    fan-out case pins `norm.lastIndexOf` against `raw.lastIndexOf`, which was
    also bench-only. Both mutations now fail the unit suite.
  - the index-reuse test discarded all 200 return values, so a build count of 1
    was equally true of an adapter that had stopped resolving anything. It now
    asserts results, and its docstring premise is corrected: every one of its
    imports hit the tier-1 suffix lookup and none reached the fan-out it
    claimed to exercise. Half now genuinely do. The `undefined as never` casts
    and the `?.` are gone — both trailing parameters are optional and the
    member is required.

Resolver changes, all output-identical against the differential above:

  - `dirChildren` buckets are frozen once built. `findKotlinPackageFiles` hands
    a bucket straight out of the index, and the `readonly string[]` return type
    does not survive the caller: the finalize pass normalizes with
    `Array.isArray(t) ? t : [t]`, and `isArray`'s `arg is any[]` predicate
    widens the true branch, so `tsc --strict` accepts a `.sort()` there. A
    downstream sort would permanently reorder the cached bucket and flip the
    first-child tier for every later import in the run.
  - `stripped` is computed only after tier 1 misses, with `lastIndexOf`/`slice`
    instead of `split`/`slice`/`join`. Measured -20% small arm, -21% large arm.
  - `KOTLIN_EXTENSIONS` now comes from the existing `import-resolvers/jvm.ts`
    export instead of a fourth inlined copy.
  - A note on why the shared `buildSuffixIndex` is not reused, with the four
    probes that diverge, and the measured basename-bucket comparison — the one
    place this was less documented than the Python precedent it follows, and
    the question the Go/Dart/C# follow-ups will each face.

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-08-08 09:31:39 +00:00
dependabot[bot]
29929b7488
chore(deps): bump docker/login-action from 4.4.0 to 4.6.0 (#2851)
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.4.0 to 4.6.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](af1e73f918...dbcb813823)

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-06 08:18:01 +01:00
Gergő Magyar
74409a37f6
perf(cpp): index qualified namespace members once per pipeline run (#2788) (#2794)
* perf(cpp): index qualified namespace members once per pipeline run (#2788)

`resolveCppQualifiedNamespaceMember` walked every parsed file — rebuilding a
per-file `scopesById` map each time — once per qualified `ns::member()` call
site, so the scope-resolution emit phase cost O(callsites x scopes). On a
1,473-file C++ repo that was 25.3 min of a 33-min analyze, with 75% of total
self-time in this one function. Its inner `findMemberInNamespaceTransitive`
compounded it: each recursion step filtered `scopesById.values()` by parent,
O(scopes^2) per file on its own.

This is the same bug #1990 fixed in the sibling ADL path (`pickCppAdlCandidates`
-> `AdlCandidateIndex`), so it gets the same fix: a `QualifiedNsMemberIndex`
(receiver simple name -> member simple name -> callable defs) built lazily once
per `parsedFiles` identity and reset by `clearCppInlineNamespaces`, which runs
from `cppScopeResolver.loadResolutionConfig` at the start of every pass. Per
call site the work drops to two Map lookups.

Ordering is preserved exactly — file-major, `parsed.scopes` declaration order,
a namespace's own `ownedDefs` before its inline-namespace children, depth-first
— because the caller takes `allHits[0]` for the single-hit case and
`narrowOverloadCandidates` is first-wins. Non-inline nested namespaces are
still not descended into, and same-name hits across inline children still
report `'ambiguous'` (#1564).

Measured with `PROF_SCOPE_RESOLUTION=1 analyze --force --index-only` on a
synthetic corpus (`namespace ns_i { inline namespace v1 { ... } }` plus 20
`ns_j::fn()` call sites per file):

| files | emit before | emit after |
|-------|-------------|------------|
| 100   | 153ms       | 16ms       |
| 200   | 704ms       | 24ms       |
| 400   | 3,293ms     | 42ms       |
| 800   | 16,898ms    | 78ms       |

Before, doubling the file count quadrupled emit; now it doubles. At 800 files
total scope resolution goes 17.2s -> 394ms.

Output is unchanged, verified rather than assumed: a full graph dump (sorted
nodes + relationships) from a baseline build at the parent commit and from this
one are byte-identical on all 134 `cpp-*` fixtures merged into a single repo
(1573 nodes / 1997 relationships) and on the 400-file synthetic corpus.
`test/integration/resolvers/cpp.test.ts` passes 334/334.

#1990 shipped its ADL fix without a scaling gate, which is how the bug class
came straight back here, so this adds one: `bench/cpp-qualified-ns` measures
`(t_large/t_small)/(1600/400)` — 0.93-1.21 indexed versus 3.45 for the old
per-call-site scan — alongside a fingerprint over every
`receiver::member -> outcome` the corpus resolves, and CI runs it with
`--check`. `test/unit/cpp-qualified-ns-index.test.ts` covers the cache
invalidation the index introduces.

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

* fix(cpp): address tri-review findings on the qualified-namespace index (#2788)

Multi-engine review of this PR (Claude swarm + ce-code-review, Codex
gpt-5.6-sol swarm + ce + adversarial) returned two P1s and five smaller
findings. All are fixed here.

P1 — the index defeated the pipeline's post-language memory release.
`scope-resolution/pipeline/phase.ts` evicts each language's files and then
calls `forceGc()`, on a stated premise that "This language's ParsedFiles are
now unreachable", sizing C/C++ at ~17-20GB on the Linux kernel. The
module-level `let qualifiedNsIndexSource` falsified that: it pinned the whole
`parsedFiles` array, and the index held defs reaching into those files'
scopes, until the *next* C++ pass cleared it — which in a single analyze never
comes. C++ is 7th of 16 in SCOPE_RESOLVERS, so the set survived nine later
language passes plus emit. Replaced with a
`WeakMap<readonly ParsedFile[], QualifiedNsMemberIndex>`, the pattern already
used by `moduleScopeIndexByPass` in `cpp/file-local-linkage.ts`.
`clearCppInlineNamespaces` still swaps in a fresh WeakMap, because the index
has a second input (`inlineNamespaceScopeIds`) the key cannot observe.
Measured with `--expose-gc`: 61.2MB retained after the caller drops the array
before, 0.1MB after.

The ADL twin (`adl.ts`) has the same pattern, so the hazard predates this PR —
but `pickCppAdlCandidates` returns early before `ensureAdlIndex` on
`noAdlSites`/empty `argInfoBySite`, so it rarely arms, whereas a qualified
`ns::member()` index arms on almost every C++ workspace. Moving the ADL twin
to a WeakMap is left as a follow-up.

P1 — the new bench could not see the regression class it exists to gate.
`callSites()` drew every receiver from `ns_${...}`, so the receiver lookup
never missed; production is the opposite, since Case 1.5 in
`receiver-bound-calls.ts` is reached by every plain-identifier receiver call
and misses on most. A rescan reintroduced only on the receiver-bucket-absent
path scored 1.279 and PASSED the old bench. The corpus now mirrors production
(~1 in 5 receivers name a declared namespace) and adds a namespace reopened
across files, a same-name inline nest, a member declared at both namespace and
inline-child level, and call sites carrying a real `Callsite` so
`narrowOverloadCandidates`/`cppConversionRank`/
`isOverloadAmbiguousAfterNormalization` are inside the fingerprinted surface at
all. That same rescan now measures 4.538 and FAILS; defeating the dedup now
fails the fingerprint arm where it previously passed byte-identical. The
fingerprint moved once, deliberately, for the corpus expansion — recorded in
`_rebaseline_2788_review`, explicitly not precedent.

Also fixed:

- Unbounded recursion aborted analyze. `collectNamespaceMembers` recursed per
  inline child with no bound and threw an uncontained `RangeError` at inline
  depth 8000 (`phase.ts`'s try has a `finally`, no `catch`), and a receiver
  *miss* paid full recursion where the deleted walker skipped on a name
  mismatch. An explicit work-stack alone would only have converted that into
  an OOM at depth 6000, because the eager table was quadratic in memory too:
  for a depth-D chain it legitimately holds D(D+1)/2 entries, since `v2::foo()`
  is a valid receiver at every level. Replaced with a lazily-queried node graph
  (per-scope own-member buckets plus direct child links, resolved on demand and
  memoized per receiver+member). Build is now linear; depth 100000 costs 133ms
  where 8000 previously threw.
- "#1990 shipped without a scaling gate" was false. #1990 did ship
  `test/integration/cpp-adl-benchmark.test.ts` (f1b843838). Corrected in the
  bench header and the CI step comment. The accurate point is narrower and
  stronger: that bench asserts `callsResolved === 0`, so it never drives the
  qualified-receiver path, and it is `skipIf(!GITNEXUS_BENCH)` while the only
  step setting that variable lists neither C++ bench — so it has never run in
  CI. Wiring it in is a follow-up.
- "Ordering is load-bearing" was not a live property: `allHits[0]` is only
  reached at length 1, and both tail branches return `'ambiguous'`. Order is
  still preserved for byte-identity with the pre-#2788 walker; the comment now
  says that instead, and the test named for ordering is renamed to the
  parent/inline-child visibility it actually asserts.
- "Same contract as `ensureAdlIndex`" overstated parity — the sibling ships a
  `validateAdlSeqCoverage` guard because it reads
  `seqByNodeId.get(...) ?? 0`, which can silently collapse candidates. This
  index has no analogous defaulting read, so no guard is added; the comment now
  says why.
- Two coverage gaps closed, both mutation-verified: cross-file merge of one
  namespace reopened in two files (no existing test covered it — confirmed by
  making each file clobber the previous and watching only the new test fail),
  and the same-name inline nest whose dedup, when defeated, flips a resolved
  def to `'ambiguous'`.
- `resolveCppQualifiedNamespaceMember`'s JSDoc now names both production call
  sites, including the callsite-less `resolveAdlCandidates` path.
- Memoized candidate buckets are frozen, so a future in-place sort in
  `overload-narrowing.ts` throws instead of silently corrupting later
  resolutions now that the array is shared across call sites.
- `_scaling_note`'s "measured 0.93-1.21" band did not reproduce; it is now the
  honestly observed 1.28-1.45, with the small arm widened to ~14ms (halves the
  spread) and a triage line saying a scaling failure is a timing signal to
  re-run, unlike the deterministic fingerprint arm.

Verification: 840,000 differential probes against the pre-#2788 walker
extracted from base, 0 mismatches, plus 48,000 candidate-order comparisons,
0 mismatches. cpp resolver integration suite 334/334. Unit suite 10/10.
tsc, eslint, prettier clean. Bench --check PASS.

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

* fix(bench): escape the NUL separator so measure.mjs stays a text file

The fingerprint key separator was written as a literal NUL byte instead of the
`\u0000` escape. Git classifies any file containing a NUL as binary, so the
whole bench showed as `Bin` with no diff on GitHub and could not be reviewed —
the same defect this branch already fixed once before the tri-review.

Escaping it is byte-for-byte equivalent at runtime (both produce U+0000), so
the committed fingerprint is unchanged and `--check` still passes.

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

* refactor(cpp): quality cleanups from a four-angle review of the #2788 series

Reuse, simplification, efficiency and altitude passes over the diff. No
resolution behaviour changes: the bench fingerprint is unchanged and the C++
resolver integration suite still passes in full.

Efficiency

- The `Object.freeze` added in the review-fix commit to close an aliasing
  residual costs 4.6x on the narrowing path — V8 moves frozen arrays to
  PACKED_FROZEN_ELEMENTS, off the fast path for the `.filter`/`.map`/`.some`
  runs `narrowOverloadCandidates` does at every multi-candidate call site.
  The hazard it guards is already a compile error (both the memo and the
  parameter are `readonly SymbolDefinition[]`), so it is now a dev-only
  tripwire. Gated on `isSemanticModelValidatorEnabled()` — the repo's opt-IN
  form used by `phase.ts` and `validate-bindings-immutability.ts` — not
  `adl.ts`'s opt-out `NODE_ENV !== 'production'`, which would keep paying the
  cost in CLI runs where `NODE_ENV` is unset. Large bench arm 100.3 -> 70.6ms.
- The index build scanned every scope in every file twice; pass 1 now collects
  `[scope, node]` pairs for pass 2 to iterate. 800k scopes 14.40 -> 8.21ms.
- `simpleNameOf` uses `lastIndexOf('.')` + `slice` instead of
  `split('.').pop()` (83 -> 24 ns/call), semantics verified byte-equivalent
  over 12 edge cases including `undefined`, `''`, `'a.'`, `'.b'` and `'a..b'`.
- The per-call `hookCtx` literal is hoisted to a module const.
- The bench's fingerprint pass resolved 960k call sites to produce 5,800
  distinct outcomes; it now dedups on the key it already builds. Bench wall
  time 2.4 -> 1.9s, fingerprint byte-identical.

Reuse

- `bucketOwnMembers` used an inlined `Function | Method | Constructor` compare;
  it now calls the canonical `isOverloadableCallable`. `graph-bridge/ids.ts`
  already carries a note that inlining this list recreated twin-list drift once.

Altitude

- `adl.ts` had the same retention defect this series just fixed next door: a
  module-level `let adlIndex` + `let adlIndexSource` strongly pinning the whole
  `parsedFiles` array until the next C++ pass, which in a single analyze never
  comes. Converted to the same `WeakMap` shape. Measured with `--expose-gc`:
  89.11MB retained after the caller drops the array before, 0.17MB after. Six
  file-local helpers now take the index as a parameter; no exported signature
  changed.
- The index's freshness depended on `clearCppInlineNamespaces()` being called
  from another file, guarded only by a warning paragraph. An epoch bumped in
  both `populateCppInlineNamespaceScopes` and the clear is now stored with the
  memo, so a missed clear degrades to a rebuild instead of a stale answer —
  confirmed by driving inline state mid-pass without the clear. Roughly line
  neutral, since it replaces most of the paragraph.
- `test/integration/cpp-adl-benchmark.test.ts` is wired into the
  `GITNEXUS_BENCH` step. It is `skipIf`-gated and was absent from that step's
  explicit file list, so #1990's ADL emit-scaling guard had never executed in
  CI. It passes; ~50s added to a 25-minute job. `cpp-pipeline-benchmark.test.ts`
  is deliberately NOT wired: it costs 115s for guards covering generic
  per-language pipeline scaling that nothing here touches.

Simplification

- Deleted a comment referencing a `inlineChildrenByParent` map that only ever
  existed inside this branch's own first commit, so "the legacy map" pointed a
  reader at code that never shipped.
- Dropped three unreachable `undefined` guards (`strict: false`, no
  `noUncheckedIndexedAccess`), keeping the load-bearing `visited` check.
- Compressed the `rootsByReceiver` doc from 17 lines to 7 — it was the longest
  comment in the file and guarded the least consequential property — the
  `validateAdlSeqCoverage` paragraph from 7 lines to 3, and turned three
  restatements of the uncaught-throw and dedup arguments into pointers.
- Test fixtures: dropped the dead `'Module'` union arm, added a one-line `ns()`
  builder for the nine hand-written scope literals, and moved the file to
  `test/unit/scope-resolution/cpp/` where every other C++ scope-resolution unit
  test lives. 403 -> 337 lines, same 10 tests, and the cross-file mutation check
  still fails exactly one test.

Not done, and why: merging this index into `AdlCandidateIndex` (they key on
different names with different inline-transparency depth — a refactor with
correctness risk, not a cleanup); `ScopeTree.getChildren` (trades in-memory
`parsed.scopes` for store hits on a hot path); a shared `cpp/` util for the five
pre-existing `simpleName` copies; sharing fixtures across the bench/test
boundary (no precedent in this repo); and an exact-count arm for the bench,
which is a gate redesign worth its own change.

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

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 15:52:33 +01:00
Gergő Magyar
911151e230
fix(resolution): resolve Go pointer-receiver calls, and report the program boundary instead of hedging (#2766) (#2782)
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-08-01 22:42:18 +01:00
ivangegovdve-sudo
565287528d
fix(ci): stop CI Report dying silently when the tests job fails (#2728)
* fix(ci): stop CI Report dying silently when the tests job fails

The "Build report" step in ci-report.yml runs under
`bash --noprofile --norc -e -o pipefail`. It located its inputs with

    UNIT_SUMMARY=$(find "$DIR/test-reports" -name ... 2>/dev/null | head -1)

`coverage-merge` in ci-tests.yml is `needs: tests` with no `if: always()`,
so any failing shard skips it and the `test-reports` artifact is never
uploaded. `find` then runs against a directory that does not exist and
exits 1; `-o pipefail` carries that status through `| head -1`, the
command substitution hands it to the assignment, and `-e` kills the step.

The death is invisible: `2>/dev/null` discards find's error and the whole
report is built into `$GITHUB_OUTPUT`, so the step logs nothing and just
reports "Process completed with exit code 1". "Comment on PR" is then
skipped, so the CI Report workflow fails and posts nothing on exactly the
PRs whose tests failed — when the report is most useful. The
"Coverage data unavailable" fallback already existed for this case but
was unreachable, because the script died ~160 lines before it.

Route the four lookups through a `find_first` helper that returns empty
when the root is absent. Verified by extracting the step body and running
it against both artifact layouts: with `test-reports` present the output
is byte-identical to the previous script (1335 bytes), and with it absent
the step now exits 0 and emits the coverage-unavailable report instead of
exiting 1 with an empty $GITHUB_OUTPUT.

Observed on 32 of the last 100 failed runs; correlation with the tests
job's conclusion was 6/6 failure and 4/4 success in the sampled runs.

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

* fix(ci): let the prebuild assertion report a missing .node

`Build prebuild` deletes `$pkgdir/prebuilds` before running prebuildify,
so a run that emits nothing without failing leaves `find` searching a path
that no longer exists.  Under the step's `shell: bash` (`-e -o pipefail`)
that `find` exits 1 and kills the step before the `test -n "$out"` guard
below it — the guard written to explain exactly this case never runs, and
the job dies with a bare "Process completed with exit code 1".

Same shape as the `ci-report.yml` fix in this PR: a lookup that exits
non-zero on an absent root pre-empts the fallback beneath it.  `|| true`
hands the empty result to the guard, which still fails the build, now with
`::error::prebuildify produced no .node`.

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-01 18:31:49 +01:00
dependabot[bot]
51095c19f8
chore(deps): bump actions/setup-python from 6.3.0 to 7.0.0 (#2757)
Some checks are pending
Gitleaks / gitleaks (push) Waiting to run
CodeQL / Analyze (javascript-typescript) (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-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6.3.0 to 7.0.0.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](ece7cb06ca...5fda3b95a4)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-31 13:05:18 +01:00
dependabot[bot]
e0dc0c2d5e
chore(deps): bump release-drafter/release-drafter from 7.5.1 to 7.6.0 (#2756)
Bumps [release-drafter/release-drafter](https://github.com/release-drafter/release-drafter) from 7.5.1 to 7.6.0.
- [Release notes](https://github.com/release-drafter/release-drafter/releases)
- [Commits](4d75298e00...eada3c96a6)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-07-31 10:34:51 +01:00
dependabot[bot]
909f2f85b6
chore(deps): bump the codeql-action group across 1 directory with 3 updates (#2755)
Bumps the codeql-action group with 3 updates in the / directory: [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action) and [github/codeql-action/upload-sarif](https://github.com/github/codeql-action).


Updates `github/codeql-action/init` from 4.37.0 to 4.37.3
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](99df26d4f1...e4fba868fa)

Updates `github/codeql-action/analyze` from 4.37.0 to 4.37.3
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](99df26d4f1...e4fba868fa)

Updates `github/codeql-action/upload-sarif` from 4.37.0 to 4.37.3
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](99df26d4f1...e4fba868fa)

---
updated-dependencies:
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.37.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/init
  dependency-version: 4.37.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.37.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-07-31 10:34:35 +01:00
MyShining
de84ad6297
feat(spring): index @Bean factories and @Resource injection (#2740)
* feat(spring): index Bean factories and Resource injection

* fix(spring): address Bean and Resource review findings

* refactor(lbug): keep relation pair parsing in router

* test(lbug): preserve schema exports in WAL mocks

* test(cache): align schema bump pin

---------

Co-authored-by: Shining <xuenning@qiyi.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
2026-07-31 10:33:42 +01:00
Gergő Magyar
27ab37c432
feat(resolution): type receiver chains from AST structure across all 14 languages (#2708) + epistemic lower-bound (#2744) (#2747) 2026-07-31 07:12:57 +01:00
Gergő Magyar
13c77db4d9
fix(ci): stop the placeholder review, verify citations, repair once (#2733) 2026-07-28 18:51:01 +01:00
Gergő Magyar
b0cacd05ee
fix(ci): stop the review agent rejecting its own graph-backed reviews (#2731)
* fix(ci): stop the review agent rejecting its own graph-backed reviews

The context-evidence gate only counted a `context` call when the call
itself passed `file_path` equal to a changed path. The review skill
teaches plain `context({name})`, so 17 of the 26 review-agent run
failures were complete, graph-backed reviews thrown away after full
model spend, with no log line saying which invariant failed.

Prove the evidence from the result instead: `status=found` plus a
`symbol.filePath` inside the repo-scoped changed-path set. Every other
check stays exactly as it was - strict JSON, orchestrator-only turns,
result ordering, duplicate tool-id rejection - and the `repo` argument
still selects the head or the merge-base path set.

Same failure inventory, smaller classes:

- rejection now logs why (in-scope, out-of-scope, sidechain, unresolved
  and off-path counts plus up to three sanitized paths), and the
  envelope error names the message count and first-message shape
- Glob/Grep leave the tool set: they were enabled through `--tools` but
  never allow-listed, so every lane call was denied and burned turns
- both pinned `npm ci` installs retry three times; one registry
  ECONNRESET killed a whole run
- the prompt matches the new contract and asks for the structured body
  even when the analysis is incomplete

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

* fix(skills): mirror the review-skill tool-set change into the shipped copies

The npm package, Claude plugin, and Cursor integration ship byte-identical
copies of .claude/skills/gitnexus-review, and the drift guard compares them.
Dropping Glob/Grep from the lane frontmatter and the SKILL.md sentence only
landed in the canonical tree.

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

* fix(ci): stop one junk context result discarding a proven review

Tri-review of this PR found that the previous commit fixed one spurious
rejection and created another. Widening evidence candidacy from "the call
that named a changed path" to "every orchestrator context call" also
widened the *strict-parse* surface: `contextResultProvesChangedPath`
throws rather than returning false, so a single malformed payload
anywhere in the transcript now discarded a review that an earlier call
had already proven. The MCP makes that reachable without any misbehaving
model - `GITNEXUS_MCP_DEFAULT_MAX_TOKENS=12000` truncates any context
payload over ~48 KB mid-JSON and appends a marker - and it also destroyed
docs-only runs that the `no_indexable_changed_symbols` mode exempts.

Reproduced by running the workflow's own embedded script on both trees:
a proving evidence call followed by one truncated exploratory call gave
`failure_code: null` on the base and `invalid_execution_transcript` on
the head; it is `null` again here.

- payload-shape failures are caught and counted (`malformedResults`)
  instead of thrown; transcript-structural invariants (envelope, tool
  shapes, duplicate ids, empty tool_result) still fail closed
- diagnostics gained the reasons they were blind to: errored results,
  results that arrived out of order or via a sidechain, unanswered
  in-scope calls, and malformed payloads. A rejection can no longer
  print an in-scope call with every reason at zero
- a deletion-only PR no longer registers head-scoped candidates that can
  never be satisfied: an empty eligible set is out of scope, not a result
  "outside the changed paths"
- the mandatory-body prompt clause now pairs with a required `complete`
  boolean. An incomplete analysis publishes its partial body labelled
  `incomplete_analysis` instead of passing as an accepted review
- `Agent(a,b,c)` is split into six separate `Agent(x)` rules: the pinned
  base action parses allowedTools with `.flatMap((v) => v.split(","))`
  (parse-sdk-options.ts at 3553f843), which shattered the grouped rule
  into `Agent(ci-correctness-lens`, four bare names, and
  `ci-critic-lens)` before the SDK saw it. Pre-existing and unproven at
  runtime, but the split form is correct under either reading and lets
  the header's dispatch canary actually prove something

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

* fix(ci): require a line range for context evidence

The tri-review's adversarial lane executed `context({name: 'AGENTS.md'})`
and had the result accepted: the gate checked only that the resolved
filePath was in the changed set, so a bare File node passed for a review
of that file's contents. The trusted prescan already defines an indexable
symbol as one with startLine and endLine, so require the same here.

Pre-existing rather than introduced by this branch, but it is the same
"what counts as proof" surface the rest of this PR tightens.

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

* fix(ci): close the remaining tri-review findings

Addresses every finding the tri-review left open after 0432214d and
1d9f2d75, across both engines.

Reliability and maintainability (Codex ce, ce-reliability, ce-maintainability):
- both pinned `npm ci` installs now call one shared
  `.github/scripts/npm-ci-retry.sh` instead of two near-identical 12-line
  blocks that differed only in a label
- each attempt runs under `timeout` (default 600s, overridable), so a slow
  registry can no longer crowd the model review out of the job's budget
- the helper distinguishes a timeout kill (124) from an npm rejection in
  its log

Test coverage (ce-testing, Codex swarm P3, ce-security, swarm test-ci):
- the retry helper is now exercised behaviourally with a stub npm: first-try
  success runs once, two failures recover on the third, three failures exit 1
- a non-string `symbol.filePath` is a clean reject, not a type error
- an adversarial resolved path (ESC, newline, `::set-output`, RTL override)
  is proven sanitized before it reaches the job log
- the envelope error's shape string is asserted
- an in-scope call whose result never arrives is counted, not silent
- install flags that keep the runtime inert (`--ignore-scripts`, `--prefix`,
  the lock-bound registry) are asserted against the helper they moved into

Correctness and clarity (risk-architect, ce-standards):
- the prompt now tells the model to prefer the uid form or pass file_path
  when a bare name could resolve into an unchanged file, which was the
  narrower off-path failure mode the gate rewrite left behind
- `contextResultProvesChangedPath` -> `contextResultProvesEligiblePath`,
  matching the set-membership contract its sibling was renamed for
- the transcript fixture's default no longer carries a `file_path` the gate
  ignores, which implied the opposite of the contract
- SKILL.md says "file reads" rather than naming a CLI-specific tool, per
  the CLI-neutrality rule in AGENTS.md; mirrored to all three shipped copies
- the interactive-swarm README notes the CI lanes are narrower

Publisher (ce-reliability residual, pre-existing):
- the publish job no longer gates the whole job on authorization, so a
  request rejected at normalization no longer strands the "review in
  progress" marker on the PR forever. Publication stays authorization-gated
  at the step; only the marker cleanup is unconditional.

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

* fix(ci): ship the install helper executable

The extracted helper was committed 100644, so the workflow's direct
invocation would have failed on the runner with permission denied - a
break introduced by the extraction itself, invisible to every existing
assertion. Set the mode and pin it with a test.

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

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 17:13:29 +01:00
Gergő Magyar
4906daf27b
fix(scope-resolution): resolve calls through a closure-valued binding across languages (#2693) (#2695)
* fix(scope-resolution): resolve calls through a closure-valued binding (#2693)

`val f = { }; f()` emitted no CALLS edge in Kotlin or Swift, so `impact` on
such a symbol under-reported to zero — the same false all-clear as #2687.

The cause was not, as first suspected, that these languages fail to feed
`callable-value-flow`. They do: `synthesizeCallableFlowCaptures` is called
from 15 language capture modules, and Kotlin already resolves reassignment
through the pass (`var f = ::a; if (c) f = ::b; f(1)` reaches both targets).
Their captures are already exactly right — the seed names the binding as its
own callable, per the anonymous-callable convention in
callable-flow-captures.ts.

They died one layer later, at the `buildGraphTargetIndex` gate:

    if (!isCallable(def) && providerTarget?.(def) !== true) continue;

`isCallable` is Function/Method/Constructor, but the scope-resolution layer
declares a closure binding with its VALUE label (Kotlin/Swift `Property`),
and `isCallableValueTarget` is implemented by exactly one provider — COBOL.
So the binding never entered `graphTargets`; `lexicalCallableLookup` then
returned `shadowed: true` with no targets, which also suppressed the
workspace-wide fallback, and the seed resolved to nothing.

Only the graph knows a value binding holds a callable — since #2687 it emits
a single `Function` node for one. So value bindings now resolve their graph
id first and are admitted on the label of the node they actually reach.

This is self-limiting: a genuine constant keeps its own Const/Property node,
so `resolveDefGraphId`'s qualified key hits before the label-agnostic
`simpleKey` fallback can reach a same-named callable. Only a binding whose
own value node was replaced by a callable one gets through.

No scope kind changes — Kotlin's `lambda_literal` stays `@scope.block`, so
#1757 smart-cast semantics are untouched by construction. The fix is
language-neutral: it discriminates on the graph node label, never on a
language name.

Dart is fixed separately; its root cause is independent.

* fix(dart): resolve calls through a closure-valued binding (#2693)

Dart needed more than the shared gate fix: neither of its closure-binding
forms could resolve, for two different reasons, and the plan's one-line
diagnosis turned out to be incomplete.

TOP-LEVEL `var f = (x) => x;`
  A graph Function node already existed (#2687), but no `@declaration.*`
  matched the binding, so scope resolution had no SymbolDefinition to attach
  a flow seed to. Adding the declaration exposed a second problem: Dart's
  `initialized_identifier` is FIELDLESS, so the shared field-based assignment
  fallback (`left`/`name`/`value`/…) decomposed nothing and the binding still
  emitted no flow captures at all. Kotlin's fieldless `assignment` node hit
  exactly this and took the same remedy — a provider `extractAssignment`.

FUNCTION-LOCAL `void m() { var f = (x) => x; }`
  Locals parse as `initialized_variable_definition`, which the top-level
  graph-node rules are deliberately anchored under (program) to avoid, so a
  local closure had no graph node at all — nothing for the widened
  `buildGraphTargetIndex` gate to admit.

Both new rules are restricted to a `function_expression` value. Declaring
every Dart variable would mint defs and nodes repo-wide for no resolution
benefit; ordinary locals stay unindexed exactly as before. The top-level
declaration reuses the (program) anchor the graph-node query already relies
on, so class-body fields — which share `initialized_identifier_list` and are
already `@declaration.property` — are never matched twice.

Also drops the now-false note in tree-sitter-queries.ts claiming `f()` does
not resolve for Dart. That node is now the evidence that makes it resolve.

* docs(scope-resolution): document the callable-flow capture contract (#2693)

The module is 1200+ lines behind a nine-line docblock, and the only worked
example was C. Both root causes fixed in this series were "the contract was
discoverable only by reading the emitter":

  - the anonymous-callable convention (a seed whose source is a closure takes
    its DESTINATION's name) is what makes closure bindings resolvable at all,
    and is the reason the widened target gate is correct;
  - a fieldless binding node silently decomposes to nothing under the shared
    assignment fallback, which cost Kotlin one debugging cycle in #2522 and
    Dart another here;
  - captures alone are never enough — the bound name also needs a
    `@declaration.*` or there is no cell to key the seed on.

Records the cell/site model, both traps, and points at the fullest and
smallest worked examples.

Bumps INCREMENTAL_SCHEMA_VERSION 15 → 16 and the parse-cache SCHEMA_BUMP
22 → 23: this series emits NEW CALLS edges and new Dart Function nodes, and
the incremental write set only covers changed files, so an existing index
would keep reporting a zero blast radius for exactly the symbols the fix is
about.

* perf(scope-resolution): pre-filter value bindings in the callable target index (#2693)

Widening the `buildGraphTargetIndex` gate to consider VALUE bindings put the
hot loop on a much larger def population — value bindings outnumber callables
in real source — and the naive version paid full price per binding. Measured
on a synthetic 800-file corpus (8 value bindings per file, 1 of them a closure
binding), the widening cost 2.50-2.82x the pre-#2693 callable-only build.

Two wastes, both provable rather than guessed:

1. `definitionAnchorKey` ran for every def, including value bindings. The
   anchor index is keyed by callable LABEL and the key is built from
   `def.type`, so a value def can never hit it — and the key costs a regex
   per def.

2. Every value binding paid the whole `resolveDefGraphId` key chain only to be
   rejected. It need not: every qualified key that function tries embeds
   `def.type`, so for a VALUE def those can only ever reach a value-labelled
   node. Its one route to a callable is the label-agnostic
   `simpleKey(filePath, simpleName)` fallback, which by construction requires
   a callable node with the SAME file and simple name. So a value binding with
   no such node cannot resolve to a callable, and one Set lookup decides it.

That set is derived in the graph walk the anchor index already performs, so it
costs no extra pass.

  large_ms            7.79-8.37  ->  4.90-5.02   (1.61x faster)
  widening_overhead   2.50-2.82  ->  1.45-1.50

The resolved target-set fingerprint is byte-identical across both, which is
the point: this is a cost change, not a behaviour change.

Adds bench/callable-value-flow/ (fingerprint + scaling + widening-overhead
gates) and wires it into ci-tests.yml beside the other build-free benches. The
overhead budget of 1.9 sits between the measured with-filter and without-filter
bands, so it cannot be met if the pre-filter is removed. Timings use the MIN of
15 warmed reps, not the median: the same build reported 1.65 idle and 2.03
under load, and a median-based gate would have to be loosened past the point of
detecting the regression it exists to catch.

`buildGraphTargetIndex` is exported for the bench; it is pure and not part of
the pass's public contract.

* test(scope-resolution): assert the declaration route does not double-emit (#2693)

Go, Python, C++ and TS/JS already resolved a closure-binding call through
their `@declaration.function` capture. The widened `buildGraphTargetIndex`
gate gives the same call a SECOND possible route, so each must still produce
exactly one edge.

`tryEmitEdge` dedups by key, but a collapsed key and a site-anchored key are
DIFFERENT keys — a real double-emit would show up as two ids for one call
site, not be silently collapsed. Asserting on edge ids rather than target ids
is what makes that visible.

* fix(scope-resolution): join value bindings to their callable node by POSITION (#2693)

Review found the first cut of this series minted FALSE CALLS edges. Admitting a
value binding whose *resolved* graph node is callable let `resolveDefGraphId`
fall through to its label-agnostic, first-write-wins
`simpleKey(filePath, simpleName)` and bind the name to ANY same-named callable
in the file.

The safety argument in the previous commit — "a genuine constant keeps its own
Const/Property node, so the qualified key hits first" — silently assumed
`def.type === node.label`. It does not hold:

  - TypeScript declares `const` as `Variable` but emits a `Const` NODE, so the
    qualified key misses even though the value node exists;
  - Rust `let` bindings get no graph node at all, so the fallback is the only
    route.

Reproduced, all previously emitting a fabricated caller:

  const save = (x: number) => x * 2;   // next to an unrelated Svc.save
      -> Method:svc.ts:Svc.save#1      // Svc never instantiated
  const handler = other;               // shadowing a top-level handler
      -> Function:app.ts:handler       // unreachable from here
  let handler = cb;                    // Rust
      -> Function:main.rs:handler

Worse in Dart, where the same collision INVERTED the feature: the only edge went
to the class method and the closure's own node got none. The result was also
declaration-order dependent — two files differing only in declaration order got
different CALLS sets — and it propagated through argument-to-formal binding into
functions whose source never mentions the name.

A closure binding IS its callable node: same file, same line, same name. An
aliasing local is not. So the join is positional now — a file/line/name index
built in the graph walk `byAnchor` already performs — and value bindings never
run the key chain at all. That is both correct and cheaper:

  large_ms            4.90-5.02  ->  4.37-4.63
  widening_overhead   1.45-1.50  ->  1.43-1.58   (name-match design: 2.50-2.82)

with a byte-identical target-set fingerprint on the bench corpus.

Also from review:

  - `Static` dropped from VALUE_BINDING_DEF_TYPES: `normalizeNodeLabel` has no
    `static` case, so no def can carry that type — it was an entry no fixture
    could ever exercise. The remaining set now documents why it deliberately
    does NOT reuse `isOwnableValueLabel`, which is contracted to a different
    consumer.
  - Dart `final`/`const` top-level closures (static_final_declaration_list) and
    every declarator after the first in a multi-name local now resolve; both
    parse into shapes the earlier rules never reached.
  - The bench source carried a literal NUL byte, so git recorded it as BINARY
    and the only artifact pinning the target set was unreviewable in the PR
    diff. It is written as an escape now. Its corpus also modelled `startLine`
    as 1-based where graph nodes are 0-based, which would have stopped it
    exercising the value-binding path at all.
  - `call-summary-schema-version.test.ts` asserted `passesReuseGate(15)` is
    true; the 15 to 16 bump made that false and the test RED. It now pins 16 as
    current and 15 as rejected, matching the pattern every prior bump followed.
  - The v23 parse-cache comment is at the top of the list, not mid-list.

Tests: the five collision cases above are new regression tests, each confirmed
failing against the previous commit. Also added Kotlin class-body closures (the
only case exercising the Method arm), Dart top-level `final`, Dart multi-name
locals, and a warm-parse-cache replay for Kotlin and Dart — the #2693 captures
are replayed verbatim, so a serialization change would surface only on a SECOND
analyze and every other test here runs cold. The previous negative tests were
vacuous: they paired names that did not collide (`maxSize` vs `size`), so the
pre-filter rejected them before the guard they were named after could run.

* docs(storage): fix the schema-version changelog blocks (#2693)

Two problems, one mine and one not.

MINE: the `INCREMENTAL_SCHEMA_VERSION` block is ASCENDING (v2 … v15), and I
inserted v16 above v15 rather than at the end — I had just moved the parse-cache
entry to the top of ITS block, which is descending, and applied the same habit
to a list ordered the other way. Moved to the end; both blocks are now
internally consistent.

NOT MINE: the parse-cache block carries TWO v21 entries, with v20 wedged between
them. Tracing it: #2632 (Spring DI facts) bumped 20 -> 21 and merged first;
#2653 (Java JLS local-class identities) had branched at 20, also bumped to 21,
and merged second — so it shipped with NO invalidation of its own. An index
already stamped 21 by the first change was treated as current by the second and
kept serving stale local-class identities from the warm cache.

Numbers left alone: both genuinely shipped as 21, and renumbering them now would
misstate what users' indexes actually contain. Instead the entry says so
explicitly, and points at the process fix — re-check the constant against
origin/main immediately before merging, not just when the branch is cut. The
identical collision hit INCREMENTAL_SCHEMA_VERSION in #2653/#2654, so this is a
recurring failure mode of concurrent PRs, not a one-off typo.

Comment-only; no constant changes value.

* feat(scope-resolution): resolve closure bindings in Ruby, Java, C#, PHP and JS/TS var (#2693)

Ruby, Java, C# and PHP already emitted correct callable-flow seeds and invokes.
What they lacked was the #2687 piece — a CALLABLE graph node at the binding,
which is what buildGraphTargetIndex joins to by position. PHP additionally had
no scope declaration for the bound name, so the flow pass had nothing to attach
its seed to.

  ruby    handler = ->(x) { x }        handler.call(1)   -> Function:a.rb:handler
  java    Function<..> handler = x->x  handler.apply(1)  -> Function:A.java:A.handler
  csharp  Func<int,int> handler = ...  handler(1)        -> Function:A.cs:A.handler
  php     $handler = fn($x) => $x      $handler(1)       -> Function:a.php:handler

Ruby and Java invoke through the callable-object protocol; C# and PHP call the
binding directly. Locals work in all four, and a binding whose name collides
with a same-named method resolves to the CLOSURE, not the method.

Two things the sweep caught:

JAVA TWIN. Anchoring the rule on the inner variable_declarator produced BOTH a
Function and a Property node — the exact double-indexing #2687 removed. The
parse-worker dedup keys on (definition node, name), and Java's value rule
anchors on field_declaration, so the keys never matched. Re-anchored on
field_declaration / local_variable_declaration.

JS/TS `var`. `var f = (x) => x` kept a Variable label while const/let got
Function, because `var` is a different grammar node (variable_declaration vs
lexical_declaration) that no closure rule covered. A call through the binding
still resolved via the declaration route, so the CALLS edge pointed at a
NON-callable node. Now consistent across const/let/var.

That last one flipped an existing assertion in const-function-twin.test.ts,
which expected `Variable` for a var-bound function-expression. Its comment
explained why — "var has no matching @definition.function pattern, so nothing
claims the name" — i.e. it documented the gap rather than defending it. The
property it was really protecting (an UNCLAIMED value node survives) now has
its own case with a non-function initializer, and the var-closure case asserts
the collapse to one node, which is also the twin guard for the new rule.

Known limits, both pre-existing and both failing safe:

  - A PHP local closure whose name collides with a top-level function gets no
    edge: both want id Function:<file>:<name>, so the closure never gets its own
    node. This is the file-scoped node-identity convention — TypeScript, Python
    and Dart collapse identically at base.
  - TS/JS class-field arrows stay Property (Kotlin's equivalent emits Method).
    They already resolve; changing the label risks the HAS_PROPERTY ownership
    regression #2687 hit once.

The invalidation constants already bumped in this PR (INCREMENTAL_SCHEMA_VERSION
16, SCHEMA_BUMP 23) cover these additional languages; their notes now say so.

Tests: one case per newly-resolving language plus the PHP anonymous-function
form and the JS var form, in closure-binding-labels.test.ts. The file now spins
a worker pool per test across a dozen languages, so its timeout is raised
file-wide — a case that takes ~7s alone was exceeding the 30s default under
that contention.

* fix(ingestion): class-field closures are callable members in TS/JS (#2693)

A CALLS edge must target a callable node. `class A { handler = (x) => x }` emitted
a Property, so calling it produced `CALLS -> Property:A.ts:A.handler` — an edge
pointing at something the graph says is not callable. Same defect class as the
JS/TS `var` binding fixed in the previous commit, and the last place a closure
binding still carried a value label.

Kotlin already models its class-body closure as Method + HAS_METHOD; TS/JS now
match, so all three agree:

  class-field closure   -> Method   + HAS_METHOD    (CALLS target is callable)
  plain class field     -> Property + HAS_PROPERTY  (unchanged, no CALLS)

Anchored on public_field_definition / field_definition — the same nodes the
property rules use — so the parse-worker dedup collapses the pair rather than
leaving a Method/Property twin, the failure the Java rule hit in the previous
commit.

ON MATCHING THE COMPILERS. This deliberately diverges from tsc and SCIP. The
TypeScript compiler classes `handler = () => {}` as a PropertyDeclaration
("a property declaration independently from what it's assigned to"), and SCIP
gives it a `.` term descriptor, the same suffix as any field — both call it a
property, and Kotlin's compiler likewise treats `val f = { }` as a property with
a function type. The divergence is intentional: GitNexus's Function/Method label
does not mean "tsc SymbolFlags", it means "this node can be the target of a
CALLS edge", which is the convention #2687 set for closure bindings in every
language. Modelling it the compiler's way would mean either dropping call
resolution for these members or emitting a separate node for the lambda and
flowing the property to it — the two-node shape #2687 removed. Recorded here so
the next reader does not "fix" it back.

Tests: TS and JS class-field arrows resolve to their Method node, plus a guard
that a NON-closure class field stays a Property — the closure rule must key on
the initializer, not on the field syntax.

* fix(php): keep the $ sigil on closure-binding nodes so locals stop colliding (#2693)

A PHP local closure whose name matched a file-level function got NO edge at all:

    function save($x) { return $x; }
    function run() {
      $save = fn($x) => $x * 2;
      return $save(1);              // no CALLS edge
    }

Both minted the id Function:<file>:save, so the closure's node was swallowed by
the function's and the positional join found nothing at the binding's line.

The fix is PHP's own semantics rather than a change to node identity across the
graph. PHP holds variables and functions in SEPARATE namespaces — $save and
save() cannot collide in the language — and the sigil is what separates them.
Dropping it was the bug. The node rule now captures the whole variable_name, so
the closure is Function:<file>:$save and the function stays Function:<file>:save.
languages/php/query.ts already keeps the sigil on property declarations for the
same reason, so this makes the two consistent.

The positional join normalises a leading $/@ on both sides, matching what the
scope layer and the callable-flow synthesizer already do, so the binding still
matches its own declaration while its NODE stays distinct.

    local closure + same-named function -> Function:c.php:$save   (the closure)
    calling the real function           -> Function:f.php:save    (unchanged)
    plain $max = 10                     -> no node, no edge       (unchanged)

WHAT THIS DOES NOT FIX. The general problem is wider than PHP: GitNexus node ids
are file-scoped, so a function-local symbol and a file-level one with the same
name collapse in TypeScript, Python and Dart too, and Java/C# only escape by
qualifying on the enclosing CLASS (so two same-named locals in different methods
still collide). SCIP solves it with a separate `local <id>` keyspace that is
document-scoped and never globally addressable. That is issue #2699 — it changes
persisted ids for every function-local symbol and needs its own invalidation, so
it is not bundled here. PHP is fixed on its own merits: the sigil belongs in the
identity regardless of how locals are eventually scoped.

* test(scope-resolution): pin the closure-binding caller-attribution limit (#2693)

Review of this PR found the new callable nodes are call TARGETS but never call
SOURCES: a call made INSIDE a closure binding is attributed to the enclosing
scope, so `impact(handler, direction:"downstream")` reports nothing even though
the closure calls out. Consistent across Kotlin, Dart, Ruby and PHP; TS/JS free
bindings are the exception because their arrow carries a @scope.function whose
range matches.

Not fixed here — pinned, so the boundary is visible instead of surprising, and
so a change in EITHER direction fails a test.

The cause is precise: `pickCallerCallableDef` (graph-bridge/ids.ts) finds the
caller by walking CHILD scopes whose range contains the call site, gated on
`child.kind === 'Function'`. A closure literal is a BLOCK scope in these
languages (Kotlin deliberately, #1757 smart casts), AND the binding's def is
owned by the enclosing scope rather than by the closure's scope — so neither
half of the link exists. Fixing it needs "callable boundary" decoupled from
scope `kind` plus an association between the closure scope and its binding.
That is a change to the caller anchor used by every call in the repo, which is
not something to land at the tail of this PR.

Also adds a unit suite for `buildGraphTargetIndex` itself, covering what the
integration tier cannot isolate: a binding is admitted only on POSITIONAL
evidence, a name-only match is rejected, a non-callable node at that position is
rejected, an ambiguous position claimed by two callables is rejected, and the
PHP dollar sigil normalises across the join while still not matching a
same-named function on another line. That last one closes the review's LOW —
the node/declaration name asymmetry now has an executable contract rather than
resting on a comment.

* docs(test): correct the per-language cause of the attribution limit (#2693)

The comment on the pinned attribution tests claimed "a closure literal is a
BLOCK scope in these languages". That is true for Kotlin (lambda_literal
@scope.block, #1757) and Ruby (do_block/block @scope.block) and FALSE for PHP:
anonymous_function and arrow_function are already @scope.function
(php/query.ts:61-62). Dart is a third case again — it has no scope over a
closure literal at all.

So the four languages fail at three different points, not one:

  Kotlin, Ruby  fail the `child.kind === 'Function'` gate
  PHP           passes that gate; its closure scope owns no callable def,
                because the binding's def belongs to the enclosing scope
  Dart          has no child scope for the walk to consider

Worth correcting carefully rather than tidying: a follow-up plan re-stated this
comment instead of re-deriving it, and inherited the misdiagnosis — it proposed
"relax the kind gate" as required for all four, which is a no-op for PHP and
unreachable for Dart. A review caught it. The comment now states each language's
actual blocker and says why the distinction matters.

Comment-only; the three pinned tests are unchanged and still pass.

* fix(scope-resolution): an ordinary JS/TS `function` binds its own `this` (#2701)

`this.m()` inside a nested `function` resolved to the lexically enclosing
class, so it emitted a CALLS edge that does not exist at runtime — including
the exact `forEach(function () { this.m(); })` shape arrow functions were
introduced to avoid:

    class D {
      m() {}
      build() { const h = function () { this.m(); }; return h; }
    }
    // CALLS: Function:D.ts:D.h -> Method:D.ts:D.m#0      FALSE

ECMA-262 gives an arrow `[[ThisMode]] = lexical`: it has no `this` binding in
its environment record, so the lookup passes through to the enclosing
environment. Every other function form binds `this` at call time. `tsc` draws
the same line by resolving `this` through `getThisContainer` with
`includeArrowFunctions = false`. That one rule is the whole fix.

Languages declare it; shared code never learns a language. The query files —
the one place that already names grammar nodes — tag every non-arrow function
form with `@receiver-owner.this`, which becomes `Scope.ownsReceivers`. A
receiver walk that reaches such a scope without finding the name stops there
instead of borrowing an enclosing scope's binding. Every other language leaves
the field unset and is bit-for-bit unchanged; a Kotlin lambda, which DOES
capture the enclosing `this`, still resolves (pinned as a test).

THREE GATES, ALL LOAD-BEARING. The false edge survived each one alone, which
is why the tests assert on the emitted edge rather than any single walk:

  1. `Scope.ownsReceivers` stops BOTH receiver-type walks — `findReceiver
     TypeBinding` here and its twin `lookupReceiverType` in gitnexus-shared's
     `lookup-core`, which was resolving the receiver independently.
  2. `LanguageTypeConfig.thisBoundaryNodeTypes` stops the type-env AST walk
     that infers a receiver's type during capture.
  3. `isReceiverOwnedButUnbound` makes `receiver-bound-calls` SUPPRESS the
     site. Without it the member still resolved by NAME through `lookupCore`'s
     lexical chain — the class-body scope binds `m` two scopes up — merely at
     lower confidence. An owned-but-unbound receiver is a definitive negative,
     not a miss, so it must not reach a receiver-blind fallback.

Also fixed: `function*(){}` as an expression was not a `@scope.function` at
all, so `this` inside one read as the enclosing method's.

WHAT THIS GIVES UP. The fix REMOVES edges, and some were correct:
`.bind(this)`, `.call(this)` and `forEach(fn, thisArg)` do make `this` the
instance at runtime. Their correctness is fixed at the CALL SITE, which no
scope-level rule can see, so the choice is between losing them and keeping
every detached-callback false positive. All three are pinned as tests
asserting the empty result, so changing the trade later is deliberate.
`this` in a static method also stops resolving to the INSTANCE member — that
edge was wrong in the other direction.

INVALIDATION. Both constants move, and the parse-cache one is not optional:
`ownsReceivers` lives on the cached `Scope`, and a warm cache replays scopes
without it — verified by probe that `--force` alone does NOT re-derive it, so
the fix silently did nothing until SCHEMA_BUMP moved. INCREMENTAL_SCHEMA_
VERSION 16 -> 17 (the incremental write set covers only changed files, so
unchanged TS/JS files would keep their fabricated `this` edges);
SCHEMA_BUMP 23 -> 24.

Verified against a built index, not by reading: all three false edges from the
issue gone, every correct edge kept, same result in JavaScript through its
separate grammar. 64 tests green across the new suite plus the closure-binding
and schema-version suites. The full suite's 36 failures are pre-existing
load-flakes — confirmed by A/B: `skip-git-cli` fails FOUR tests on a clean
HEAD versus three with this change, and `pipeline-pdg-streaming` passes in
isolation either way.

Refs #2701

* fix(ingestion): give function-local callables their own identity (#2699)

Graph node ids were file-scoped, so a local callable and a same-named
file-level one collapsed onto ONE node. That is a wrong answer, not a missing
one — the local call was attributed to the file-level symbol:

    export function save(x) { return x; }
    export function run()   { const save = x => x * 2; return save(1); }
    export function other() { const save = x => x * 3; return save(2); }

    // ONE node Function:a.ts:save, and BOTH run and other pointed at it, so
    // `impact` on the top-level save reported two callers that never call it.

A local's identity is now its enclosing-callable chain plus its own position —
`run.save@2:2`. The chain is for humans reading `impact`; the position is what
makes it correct. Names alone cannot express what ECMAScript actually
specifies, and the gap is the language's, not the grammar's: an environment
record is created per function AND per block, so an anonymous function has no
name to contribute and sibling blocks hold distinct bindings under the same
name. One positional rule settles both, with no conditionals and no
"disambiguate only when it looks ambiguous" heuristic — the ambiguity-flag
class of bug that bit #2514. SCIP reaches the same place with its
document-scoped `local <id>` keyspace.

Top-level functions and class methods are NOT locals and keep their ids
byte-for-byte. That is the bound on the churn: this touches only symbols that
are unreachable from outside their own document anyway.

RESOLUTION JOINS BY POSITION, NOT BY NAME. `resolveDefGraphId` matches a def
to its node on (file, label, line, simple name). A def and its node are the
same construct, so this needs no scope chain at all — which is the point:
re-deriving the chain in the resolver would be a second implementation that
could silently disagree with the first. A genuine tie (two callables on one
line) stores an AMBIGUOUS_POSITION tombstone and falls through to the existing
name keys rather than picking by source order. Without this the node ids were
already correct and calls STILL resolved to the file-level symbol — the fix is
only half a fix without it.

JS/TS GAIN BLOCK SCOPES. They emitted no `@scope.block` at all, so the
resolver could not tell two `const pick` in sibling branches apart. Giving
them distinct ids made that visible as DUPLICATE edges — each call resolving
to BOTH — which is worse than the collapse it replaced. `(statement_block)
@scope.block` supplies the missing environment record. The other half of the
ECMAScript rule was already implemented and waiting: `tsBindingScopeFor`
hoists `var` past blocks to the enclosing Function/Module while `let`/`const`
bind innermost, and its docblock already claimed "the innermost default covers
these" for block scopes that did not exist. All 82 scope-resolution test files
pass with blocks on.

Verified by probe, per case: two locals in different functions, a local inside
an ANONYMOUS function (`outer.fn@1:9.save@2:4`), sibling blocks resolving to
their own binding, `var` still hoisting out of its block, a nested named
`function` vs a file-level one, PHP composing with the `$` sigil from #2693,
and Python. Top-level/method ids unchanged, asserted directly.

Every assertion is on the EDGE, not on node existence. Ids are built twice and
independently — definition phase and caller attribution — and a one-character
disagreement makes the caller attach to a node that does not exist and the
edge vanish, with nothing thrown and no test failing. An edge assertion can
only pass if both phases agree.

INVALIDATION. INCREMENTAL_SCHEMA_VERSION 17 -> 18 and SCHEMA_BUMP 24 -> 25:
persisted node ids change for every function-local callable, and the cached
scope tree lacks block scopes. A top-up would leave unchanged files on the old
ids while changed files emit the new ones, splitting each symbol in two.

Bench fingerprint unchanged and both timing budgets pass. The one full-suite
failure (incremental-orchestration) passes in isolation — its log shows stale
init locks and WAL reclaim, i.e. LadybugDB contention under the parallel run.

Refs #2699

* perf(ingestion): emit block scopes only where they bind something (#2699)

Block scopes make `let`/`const` in sibling blocks distinct bindings, which is
what stopped a call in one branch resolving to both. Emitted naively — one
scope per `statement_block` — they also cost ~10% of analyze wall time, because
every scope-chain walk in every function then steps through levels that bind
nothing.

Two emit-side filters keep the semantics and drop the waste:

  1. A block that IS a function body duplicates the enclosing Function scope.
     Nothing can be declared between a function and its own body, so a binding
     in either resolves identically — the inner scope is pure depth.
  2. A block that declares no `let`/`const`/`class`/`function` binds nothing,
     so it is transparent: a lookup finds nothing in it and walks to the
     parent. `var` is deliberately excluded from that list — it hoists past the
     block to the function, so a block containing only `var` still binds
     nothing.

MEASURED, on a 762-file / 228k-line TypeScript corpus (gitnexus/src), min of 6
warmed reps with the cold first rep discarded:

    block scopes emitted   19,389  ->  5,331     (-72%)
    total scopes           35,942  ->  21,884    (-39%)
    analyze wall time      +9.8%   ->  +1.6-2.5% vs pre-#2699
    peak RSS (whole tree)  2398MB  ->  2434MB    (+1.5%, inside run-to-run noise)

The filters themselves are free: scope emission over the same corpus measured
12.6s naive vs 12.5s filtered.

Wall-clock on a shared runner has a ±10% spread run to run, which is wider than
the effect being optimised, so the durable gate added here counts scopes
instead. `bench/scope-emission/measure.mjs --check` asserts an EXACT scope set
over a synthetic corpus that mixes the shapes the filters discriminate between
— function/method/arrow bodies, non-declaring if/else/for/while/try, blocks
that declare `const`, and a `var`-only block. Baseline is 2 block scopes per
module: only the two `if`/`else` branches that declare `const chosen`. If the
filters regress that number jumps immediately, in a way wall-clock CI could
never resolve from noise. Wired into the existing benchmarks job.

Behaviour is unchanged: 86 scope-resolution and identity test files, 1371
tests, all green — including the sibling-block case this could plausibly have
broken — and the callable-value-flow fingerprint is untouched.

Refs #2699

* test(bench): re-baseline the TS/JS scope-capture fingerprints for #2701

`bench/scope-capture` fingerprints the full capture set per language, and
#2701 added a `@receiver-owner.this` marker to every non-arrow function form
so a scope that BINDS its own `this` can terminate the receiver walk. That is
a capture-set change, so the TypeScript and JavaScript fingerprints moved and
the benchmarks job has been failing since that commit — I pushed it without
checking CI.

A fingerprint is a correctness gate, so this does not simply adopt the new
value. Verified first by diffing the capture-name HISTOGRAM over the same
fixture corpus against 1d308817 (the commit before #2701), which says what a
fingerprint cannot: WHICH names moved.

    typescript   @receiver-owner.this   0 -> 143
    javascript   @receiver-owner.this   0 -> 32

Nothing else. Every other capture count is byte-identical, so no existing
capture shifted and the drift is entirely the intended marker. Both languages'
scaling ratios stay well inside their 1.5 budgets (0.976 / 1.025).

Note `@scope.block` does not appear in the delta: the #2699 filters suppress a
block that is a function body or that declares no binding, and no fixture in
this corpus has a block that binds. Block-scope emission is guarded separately
by `bench/scope-emission`, whose synthetic corpus exercises exactly those
shapes.

Refs #2701

* fix(ingestion): stop the callable-prefix walk at class bodies, not only declarations (#2699)

An anonymous class owns its members, but `CLASS_CONTAINER_TYPES` lists only class
DECLARATION nodes — and a Java anonymous class has none. It is

    object_creation_expression > class_body > method_declaration

so `enclosingCallablePrefix` sailed straight through the anonymous body, reached the
enclosing method, and re-keyed the member as a function-local of that method:

    Method:src/Worker.java:Worker$1.run#0
    -> Method:src/Worker.java:Worker.makeHandler.run@7:12#0

That destroys the javac-compatible JLS identity #2550/#2555/#2562 exist to provide, and
broke four existing Java tests that this PR never touched — anonymous-class instance
identity, local-type identity, and enum-constant-body chaining.

The design was right; the boundary was blind. `CALLABLE_PREFIX_BOUNDARY_TYPES` adds the
body and anonymous-construction forms (`class_body`, `interface_body`,
`annotation_type_body`, `enum_body`, `enum_body_declarations`, `enum_constant`,
`object_creation_expression`, `object_literal`,
`anonymous_object_creation_expression`). Over-inclusion is the SAFE direction here: an
extra boundary only suppresses the nesting prefix, falling back to the pre-#2699 class
qualification.

This also falsifies the claim in the #2699 commit that "top-level functions and class
methods keep their ids byte-for-byte" — an anonymous-class method IS a class method, and
its id did change. The claim was true only for the shapes that were tested.

Also removes the dead `NO_QUALIFIED_NAME` constant, which contained a literal NUL byte.
That byte made `file(1)` report the source as `data` and made plain `grep` return zero
matches for ANY pattern in the whole 2,928-line file — which is why several greps during
development came back mysteriously empty. Two other files carry NULs; they are
pre-existing and out of scope here.

INVALIDATION. INCREMENTAL_SCHEMA_VERSION 18 -> 19 and SCHEMA_BUMP 25 -> 26. This is not
defensive: an index stamped v18 holds the WRONG Java ids, and without the bump it passes
the `=== INCREMENTAL_SCHEMA_VERSION` reuse gate and keeps them on every unchanged file.

Found by the PR #2695 tri-review (review 4782134453) — independently by a Claude
adversarial AST probe, by Codex's swarm, and by CI (`tests / ubuntu / coverage 2/3`).
Verified: `resolvers/java.test.ts` 247/247 (was 243/247), plus this-boundary,
function-local-identity and the schema-version suites.

Refs #2699

* fix(scope-resolution): fail closed when a function-local shadows a same-named callable (#2699)

The #2699 positional join failed OPEN. On a position miss `resolveDefGraphId` fell
through to the label-agnostic, first-write-wins `simpleKey(filePath, simpleName)`, which
aliases a def onto whichever same-named callable was registered first — the exact
fabricated-caller mechanism this PR's own #2693 work already shipped once as a P0.

It misses because the two id phases anchor on different nodes BY DESIGN:
`tree-sitter-queries.ts` anchors the graph node on the outer `lexical_declaration`, while
`languages/typescript/query.ts` anchors the scope def on the inner `arrow_function` so
`anchor.range` lines up with `@scope.function` for auto-hoist. Split the declaration
across lines and those land on different LINES:

    export function run()   { const pick =
        (x) => x * 2; return pick(1); }
    export function other() { const pick =
        (x) => x * 3; return pick(2); }

    before:  run   -> run.pick@1:2      correct
             other -> other.pick@6:2    correct
             other -> run.pick@1:2      FABRICATED — other() never calls run's pick

Every fixture in function-local-identity.test.ts kept the declaration and its initializer
on ONE line, where the anchors coincide. That is why the suite stayed green while the bug
shipped, and the new test deliberately splits them.

WHY NOT A BLANKET FAIL-CLOSED. A position miss is not always a collision: it also happens
where the anchors legitimately differ, e.g. a Vue SFC, whose graph nodes carry
`+ lineOffset` while scope extraction does not. Failing closed on every miss would delete
correct edges there. So the guard is keyed on evidence that the collision is REAL —
`localNameKey` records that a function-local of this simple name exists in the file
(local-identity nodes are recognisable by the `@<row>:<col>` on their last name segment).
Only then is a miss treated as ambiguity. Files with no such local keep their previous
fallback behaviour byte-for-byte.

A missing edge is the correct failure direction here: `impact` can recover from an absent
caller, but a fabricated one silently corrupts the answer.

WHY NOT UNIFY THE ANCHORS. Considered and rejected: the split is deliberate and
load-bearing for auto-hoist across every language (the `rangesEqual(anchor.range,
innermost.range)` rule), so unifying it would fight that discipline far outside this fix.

The regression test was verified to DISCRIMINATE: with the guard disabled it fails on
exactly the fabricated edge (`+ "Function:m.ts:other -> Function:m.ts:run.pick@1:2"`).

impact(resolveDefGraphId, upstream) is CRITICAL — 62 impacted, 23 direct, 6 flows — which
is precisely why the guard is gated rather than broad. detect_changes: HIGH, 8 affected
processes, all in EmitReceiverBoundCalls / EmitRubyMixinEdges. Verified: 85 test files /
1364 tests green, including every scope-resolution unit.

Found by the PR #2695 tri-review (review 4782134453): raised by Codex's adversarial leg,
mechanism source-confirmed during synthesis, then reproduced end-to-end.

Refs #2699

* fix(typescript): stop the enclosing-type walk at nodes that rebind `this` (#2701)

`findEnclosingType` walked `node.parent` to the top of the file with no boundary, so it
happily synthesized a `this` binding from a type that does not own the member:

    class A { outer() { const o = { inner() { return this.x; } }; return o; } }

`this` inside `o.inner` is `o`, never `A` — but the walk reached `A` and bound to it, so
every `this.…` in such a method resolved against the wrong type. Only the module-level
object literal escaped, because there was no enclosing class to reach. Applies to
JavaScript too: `languages/javascript/captures.ts` calls the same function.

Boundary set: object literals and the function forms that rebind `this` at call time.
Arrows are deliberately absent — they inherit `this` lexically, which is what makes a
class-field arrow `m = () => this.x` resolve.

WHY THE MARKER WAS NOT ALSO REMOVED FROM METHOD FORMS.

The review argued `@receiver-owner.this` over-suppresses: `synthesizeTsReceiverBinding`
returns null for static members, object-literal methods and anonymous class expressions,
so those scopes are "owned but unbound" and get suppressed, losing edges the base
resolved. Removing the marker from the method forms was tried and MEASURED, and the
result does not support shipping it:

    marker removed, probe of all five shapes:
      static -> static            RESTORED (true)
      object literal (module)     RESTORED (true)
      anonymous class expression  RESTORED (true)
      static -> INSTANCE          FALSE EDGE returned
      object literal in a class   FALSE EDGE (Nested.outer.inner -> Nested.x)

The last one is the point: this fix stops the false *synthesis*, but removing the marker
re-enables receiver-blind *name* resolution in `lookupCore`'s lexical chain, which
recreates the same wrong edge by another route. The restored edges and the false ones
come from the SAME mechanism — a name walk — so they cannot be separated by toggling the
marker. The real trade is 2 genuinely-new true edges for 2 false ones, not the 3-for-1
the plan assumed.

Corpus evidence (762 real TypeScript files, edge SETS not counts, cold cache both arms):

    baseline vs marker-removed:  net 0, REMOVED 0, ADDED 0

Neither the gains nor the losses occur in production code. Given a 1:1 true/false ratio
on synthetic shapes and zero effect on real ones, the marker stays: for a graph feeding
`impact`, a fabricated caller is worse than an absent one — the same principle applied in
the fail-closed positional join. The three shapes remain UNRESOLVED rather than wrongly
resolved; resolving them properly needs a typed binding for object literals, anonymous
classes and static contexts, which is a feature, not this fix.

Measured with an edge-SET diff harness, after both ce-doc-review passes established that
an edge COUNT cannot decide this (it conflates edges gained with edges lost, so a
near-zero net reads as "no regression"). The harness also had to wipe the index each arm
— a warm parse cache initially reported an unchanged edge set across a real behavioural
change, the same trap documented in the v24 SCHEMA_BUMP note.

detect_changes: low risk, 3 symbols, no affected processes. 85 files / 1365 tests green.

Refs #2701

* docs(test): correct the false "three load-bearing gates" claim (#2701)

The header of `this-boundary.test.ts` asserted that all three gates were
independently load-bearing because "the false edge survived removing any one of
them alone". That was true DURING development, measured incrementally, and was
carried into the shipped comment without being re-tested against the finished
code. It is false: gate 3 (`isReceiverOwnedButUnbound` in `receiver-bound-calls`)
runs FIRST and marks the site in `handledSites`, which `emitReferencesViaLookup`
then skips — so for an explicit `this` receiver it subsumes gate 1. Removing
gate 1's `ownsReceivers` check in `gitnexus-shared/.../lookup-core.ts` leaves all
10 tests in the file passing; verified by experiment.

The gate is RETAINED, and the review's recommendation to delete it as "dead" is
rejected on evidence. `receiver-bound-calls` only suppresses EXPLICIT receivers
(`if (site.explicitReceiver === undefined) continue;`), whereas `lookup-core`'s
gate is also reached for IMPLICIT ones through `IMPLICIT_RECEIVERS` in
`resolveReceiverOwner` — a bare `m()` inside a nested `function` inside a method
goes down that path. The experiment shows the gate is UNTESTED, not unreachable;
those are different claims and only the first is supported. Deleting it on the
strength of a green test run would have removed live code, which is the same
reasoning error the corrected comment is about.

This is a documentation-only change: no behaviour, no test expectations. The
correction is recorded in place rather than silently rewritten, because the way
the claim came to be wrong — measured on an intermediate tree, then asserted
about the final one — is the reusable lesson.

Refs #2701

* test(scope-resolution): pin the block-scope ACCESSES delta as false-edge removal (#2699)

The tri-review flagged that enabling `(statement_block) @scope.block` for
JS/TS drops 114 `ACCESSES -> Const` edges corpus-wide with `added: 0`,
undocumented and untested. That was recorded as a suspected regression.

It is not one. All 274 emitting reference sites behind those 114 edges were
classified by re-reading the source at the site: 269 are member reads, the 5
others are classifier artifacts (the name recurs earlier on the line, as in
`a.b.declLine` for `b`) and are member reads too. No edge was
bare-identifier-only. Every dropped edge was a property read
(`options.baseUrl`) mis-resolving to an unrelated function-local `const` of
the same name in the same file.

The cause is not block-specific: `lookupCore` Step 1 walks the lexical chain
for every lookup, including explicit-receiver property reads. Block scopes do
not fix that, they narrow it, by moving the local off the chain of any
reference outside its block. A local declared directly in the function body
still hijacks the read; that is pre-existing and left alone here.

Two tests. The first discriminates: it fails with the block capture removed
(the false edge reappears) and passes with it. The second is a companion
invariant, identical in both arms, so that "the edge went away" cannot be
satisfied by a change that dropped Block-kind bindings outright.

Fixture notes, both of which defeated earlier attempts at this edge class:
`pruneLocalSymbols` deletes ~94% of function-local value symbols, so the
`const` under test must be kept via `keepLocalValueSymbols`; and the member
read must sit outside the block, since inside it the block is on the
reference's own chain and the false edge appears in both arms.

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

* docs(ingestion): correct the SCIP citation on the function-local id (#2699)

The comment justified the positional, name-bearing local id (`fn@12:9`) as
"same reasoning as SCIP's document-scoped `local <id>` keyspace". SCIP is the
wrong citation for this key shape: its `local <id>` is a per-document counter,
and the spec states that locals do not encode the name.

SCIP remains prior art for the document-scoped keyspace itself, which is the
part the argument actually leans on, so the reference is corrected rather than
dropped. clang's USR for a function-local (`name@offset`) and Kythe's C++
indexer are the accurate citations for a positional, name-bearing key.

Comment only, no behavior change.

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

* fix(typescript,javascript): sync the function node-type lists, and test it (#2701)

Four hand-maintained lists answer "which node types are function-like":

  1. `query.ts` — the `@scope.function` / `@receiver-owner.this` patterns
  2. `captures.ts` — `FUNCTION_NODE_TYPES` (callable-flow synthesis + the
     body-block filter)
  3. `receiver-binding.ts` — `THIS_REBINDING_BOUNDARY_TYPES`
  4. `type-extractors/typescript.ts` — `THIS_BOUNDARY_NODE_TYPES`, whose
     docstring already claimed it was "kept in sync with `@receiver-owner.this`"
     with nothing enforcing it

`generator_function` (the EXPRESSION form, `const g = function* () {}`) was
added to both queries for #2701 and is present in lists 3 and 4, but was
missing from both `FUNCTION_NODE_TYPES`. Added.

That gap changes no graph output today, and the commit does not claim
otherwise. Measured on `const g = function* (x) { yield x; }; g(1)`: node and
edge sets are byte-identical with and without the entry. The `this` boundary
was already correct via the query marker — `this-boundary.test.ts` has a
passing generator case. A generator-expression binding still emits a `Const`
node rather than a `Function` one, so its call resolves to nothing either way;
that label comes from the definition rules, and closing it is a separate change
NOT made here.

So the entry is list consistency and the test is the real deliverable. It
asserts lists 1 and 2 EQUAL, and lists 3 and 4 as subsets of the query markers
with an explicit allowlist — the method forms bind their own `this` but the
class is their `this`-owner, so neither walk may stop there. Verified
discriminating: removing the `generator_function` entry fails both equality
assertions.

The lists are exported for the test; no other production surface changes.

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

* test(bench): gate scope emission per language, not TypeScript-only (#2699)

The scope-emission gate ran the TypeScript emitter only, so a JavaScript-only
regression shipped green. The two filters it guards are implemented twice —
`FUNCTION_BODY_OWNER_TYPES` in `typescript/captures.ts` and
`JS_FUNCTION_BODY_OWNER_TYPES` in `javascript/captures.ts`, each with its own
`blockDeclaresBinding` and `BLOCK_BINDING_CHILD_TYPES` — so covering one said
nothing about the other.

Adds a structurally parallel JavaScript corpus (the same shapes with the
TS-only syntax removed) and splits `baselines.json` per language. `--check`
now also fails when a baselined language is not measured, which is how a gate
goes quietly green.

Verified the new arm bites: disabling the JS body-block filter alone takes
JavaScript from 400 to 600 block scopes and fails `--check`, while TypeScript
stays green — the exact regression the old gate would have passed.

The two languages happen to agree exactly on this corpus (2 blocks per module,
2200 scopes). That is recorded as a measured result, not an invariant: each
language is still gated against its own baseline. TypeScript's numbers are
unchanged.

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

---------

Co-authored-by: Gergo Magyar <abhigyan1.patwari@gmail.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 07:52:18 +01:00
Copilot
d3d4fa31bb
fix(scope-resolution): gate C#/Kotlin free calls by instance ownership (#2563) (#2654)
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
* Initial plan

* fix(scope-resolution): gate C# and Kotlin free calls

* fix(scope-resolution): keep Kotlin ownership gate safe

* Apply remaining changes

* perf(scope-resolution): benchmark and cache ownership gates

* test(scope-resolution): simplify benchmark scaling loop

* refactor(scope-resolution): encapsulate ownership cache

* test(scope-resolution): enforce subquadratic ownership scaling

* fix(scope-resolution): address ownership review findings

* test(csharp): regenerate capture golden for #2563 fixtures

The committed expected-captures.json was missing the new
NamespaceOwnerCollision.cs entry and carried a stale SameFileCases.cs
digest/count (56 → 67), so csharp-captures-golden.test.ts was the sole
red check on the PR. Regenerate with UPDATE_GOLDEN=1 to match the
fixtures the bench fingerprint already reflects.

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 13:31:56 +01:00
Gergő Magyar
f37c126f0c
Merge branch 'main' into dependabot/github_actions/softprops/action-gh-release-3.0.2 2026-07-23 09:23:26 +01:00
dependabot[bot]
e50c49949c
chore(deps): bump softprops/action-gh-release from 3.0.1 to 3.0.2
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 3.0.1 to 3.0.2.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](718ea10b13...3d0d9888cb)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: 3.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-22 20:18:38 +00:00
dependabot[bot]
47f3932c8c
chore(deps): bump actions/setup-node from 6.4.0 to 7.0.0
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6.4.0 to 7.0.0.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](48b55a011b...8207627860)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-22 20:18:29 +00:00
Gergő Magyar
7f7255aef8
fix(analyze): load VECTOR before the incremental writeback touches embedding rows (#2623) (#2624)
* feat(lbug): add ensureEmbeddingRowDmlSafe VECTOR gate for embedding-row DML

LadybugDB refuses every mutation of a table carrying an HNSW index while the
VECTOR extension is not loaded on that connection: DELETE and CREATE raise a
Binder exception, DROP TABLE is refused while the index references it, and SET
segfaults the process. Dropping the index is not an available recovery either —
CALL DROP_VECTOR_INDEX is itself a VECTOR-extension function and is undefined in
exactly that state.

Add a single primitive that loads VECTOR under the analyze install policy and,
only when that fails, reads CALL SHOW_INDEXES (which works without the
extension) to decide whether an index actually exists to trip over. No call
sites yet.

Refs #2623

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

* test(lbug): pin the #2623 VECTOR gate for embedding-row DML

Three cases: no index + VECTOR unavailable stays safe (no needless
escalation); index present + VECTOR unavailable is reported blocked AND the
raw deleteNodesForFiles genuinely throws 'extension is not loaded' (proving the
hazard is real, not theoretical); index present + VECTOR loadable is safe, the
delete works, and the HNSW index survives — the invariant run-analyze relies on
when it keeps the index across a surgical incremental run.

Refs #2623

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

* fix(analyze): load VECTOR before the incremental writeback touches embedding rows

Incremental analyze died on every content change once a repo had built
code_embedding_idx:

  Analysis failed: Binder exception: Trying to delete from an index on table
  CodeEmbedding but its extension is not loaded.

The surgical writeback's first statement is deleteNodesForFiles' CodeEmbedding
join-delete, but nothing on that path loaded VECTOR until Phase 4 — so the
engine refused the delete. This is an ordering defect, not an environment one:
it reproduces on machines where VECTOR loads fine. The dirty-flag recovery then
forced a full rebuild on the next run, which is why it read as 'just slow'.

Call ensureEmbeddingRowDmlSafe() once, before the escalation gate and before any
row is touched — the same 'index lifecycle before row DML' seam dropSearchFTSIndexes
occupies for FTS (#2589). Unconditional, because a DB carrying the index from an
earlier --embeddings run hits the same wall on a plain incremental run. When
VECTOR truly cannot load the table is immutable (the index cannot be dropped
without the extension either), so the run falls through to the existing
wipe-and-COPY escalation with a message naming cause, consequence and remedy.

Fixes #2623

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

* test(analyze): pin the #2623 VECTOR-before-embedding-DML ordering end-to-end

Sibling of the #2589 FTS drop-before-delete suite, same shape: drive the real
runFullAnalysis incremental path over a real git repo and a real LadybugDB,
seed real embedding rows, build the HNSW index, then assert the index state at
the exact moment deleteNodesForFiles is invoked.

Both cases were confirmed to discriminate — with the run-analyze change
reverted they fail with the reported 'Trying to delete from an index on table
CodeEmbedding but its extension is not loaded', and pass with it:
  - surgical path: the run completes, the index is still present AND
    extension_loaded at delete time, exactly one row per nodeId survives, and
    the untouched file's rows are preserved
  - blocked path: with GITNEXUS_LBUG_EXTENSION_INSTALL=never the run escalates
    to a full DB write and says so, instead of crashing

Also applies prettier's reindent to the run-analyze log ternary.

Refs #2623

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

* docs(lbug): cite the pinned LadybugDB version in the #2623 probe note

The probe matrix behind ensureEmbeddingRowDmlSafe was first recorded on
0.18.0, but gitnexus/package-lock.json pins 0.18.2 (#2587). Re-ran every case
on 0.18.2: refused DELETE, refused CREATE, SIGSEGV on SET, DROP_VECTOR_INDEX
undefined, DROP TABLE refused, SHOW_INDEXES readable with extension_loaded
intact. Identical on both, so the design is unchanged — only the citation was
wrong.

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

* fix(analyze): preserve embeddings across the VECTOR-blocked rebuild, and check the catalog before loading

Three follow-ups from reviewing the fix itself.

1. Data loss on the blocked path. Escalating wipes the DB files, and Phase 3.5
   restores embedding rows from cachedEmbeddings — which deriveEmbeddingMode
   only populates when meta.stats.embeddings > 0. A DB holding embedding rows
   that its meta does not account for therefore had every vector destroyed
   silently by a rebuild it never asked for. Probe on a 3-file repo: 3 rows
   before, 0 after, no warning. Read the rows before escalating (a plain MATCH,
   no extension needed) so the existing restore has something to restore, and
   say so in the log. The blocked-path test now asserts the seeded rows survive
   exactly once, and that assertion fails without this rescue.

2. Catalog before extension. ensureEmbeddingRowDmlSafe loaded VECTOR first and
   only read SHOW_INDEXES on failure, so every incremental analyze on a machine
   without VECTOR paid a bounded out-of-process INSTALL attempt plus an
   'extension unavailable' warning — including repos that never built an
   embedding index and can never hit this bug. One local catalog read settles
   that case first; the load is attempted only when an index actually gates DML,
   or when the catalog cannot be read.

3. Dead branch. targetConn is always the module singleton there, so the
   isSharedSingletonConn ternary could never take its second arm. Collapsed to
   withConnLock.

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

* feat(doctor): live-probe the VECTOR extension instead of printing the static platform capability

Review finding on #2624 (MEDIUM), and exactly what #2623's reporter hit:
doctor printed 'VECTOR index: available' — derived from a static platform
check — while every incremental analyze on the same machine was dying on an
unloaded VECTOR extension. The FTS line was switched to a live LOAD probe for
the identical contradiction under #2374; VECTOR now gets the same treatment.

probeVectorExtensionLoad shares the FTS probe's implementation (bounded,
offline-safe, never runs the installer) and doctor's semantic-mode line now
follows the probe, not the platform: without a loadable extension the vector
index can be neither built nor queried, so search really is on exact scan.

The load-error classifier's remedies are label-parameterized so the VECTOR row
stops dispensing FTS-specific advice — 'run analyze --repair-fts' repairs FTS
indexes only and was actively wrong for a missing vector extension. Default
label stays 'FTS'; every existing caller and pinned remedy string is unchanged.

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

* fix(lbug): remove the stale Windows VECTOR gate — the extension ships for win_amd64

The codebase categorically refused VECTOR on Windows (platform !== 'win32' in
isVectorExtensionSupportedByPlatform, plus a hard early-return in
loadVectorExtension) on the strength of an early-era report that in-process
INSTALL VECTOR could SIGSEGV (#1365). That belief is stale, verified directly:

- the extension server hosts win_amd64 VECTOR artifacts for every 0.18.x
  extension version — v0.18.0 and v0.18.1 both serve a real 14 MB PE32+ DLL
  (curl-probed; 'file' confirms PE32+ x86-64)
- the pinned 0.18.2 core resolves its extension directory to 0.18.1
  (strace-verified LOAD open()), so the pinned version's Windows artifact
  exists too
- INSTALL now runs in a spawned child (installDuckDbExtensionOutOfProcess), so
  even a crashing installer kills only the child and degrades to unavailable —
  the original hazard cannot reach the parent process any more

Windows now takes the same runtime path as every other OS: try LOAD, install
out-of-process when policy allows, degrade to exact scan when it truly fails.
The MCP semantic-search lane loses its static platform gate too — it always
attempts the vector index and falls back to the exact scan on runtime failure,
with a once-per-backend diagnostic naming the real error instead of a
platform-policy message. isVectorExtensionSupportedByPlatform is deleted;
getRuntimeCapabilities reports the platform capability as available everywhere
and defers machine truth to the live probe.

Windows CI is the enforcement: the vector suites skip visibly only when the
extension genuinely cannot load, so green Windows lanes now actually exercise
VECTOR instead of silently skipping by policy.

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

* test(lbug): pin the catalog-read-failure fallback in ensureEmbeddingRowDmlSafe

Review finding on #2624 (LOW): the one branch where the gate cannot cheaply
prove safety — SHOW_INDEXES itself erroring — was exercised only by inference.
Force it with a Connection.prototype.query spy over the real DB: the catalog
read fails, and the gate must fall through to actually attempting the
extension load (asserted via the recorded statement stream) rather than
guessing, returning true here because the extension is loadable.

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

* fix(mcp): load VECTOR on the pool's shared Database so the semantic vector lane actually works

Review finding on #2624 (MEDIUM): extension load scope is per-Database
(probe-verified — LOAD on one connection enables QUERY_VECTOR_INDEX on every
connection of the same Database), and the pool pre-warm loaded only FTS. So
LocalBackend's vector lane has ALWAYS raised 'Catalog exception: function
QUERY_VECTOR_INDEX is not defined' through the pool and silently fallen back
to the exact scan — repos above the 10k exact-scan cap got empty semantic
results. The serve path was unaffected (the embedding pipeline loads the
extension itself).

Mirror the FTS line at BOTH load sites — doInitLbug's pre-warm and
initLbugWithDb's external-Database adoption — under the same load-only
contract (the read pool never triggers a network install), tracked by a new
SharedDB.vectorLoaded flag reset where ftsLoaded resets.

The new pool test is discriminating and deliberately closes the writable core
adapter before the pool opens: a shared/injected Database would inherit the
VECTOR load from test seeding and pass either way, so the case forces the pool
onto its OWN fresh read-only Database where only the pre-warm can make the
lane legal. Verified: fails at the pre-fix tree with the exact Catalog
exception, passes with the fix.

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

* ci: run the #2623 ordering suite on Windows/macOS and pre-install VECTOR alongside FTS

Two review findings on #2624, both landing in existing seams:

- scripts/cross-platform-tests.ts gains incremental-vector-extension-ordering
  .test.ts: the win32 VECTOR gate is gone in this PR, so the #2623
  drop-ordering + blocked-path escalation must be proven on the
  windows-latest native addon, not just Ubuntu. (The review's claim that
  lbug-delete-nodes-for-files.test.ts was also missing was wrong — it has
  been on the roster since #2409.)
- scripts/ensure-fts.ts now pre-installs VECTOR under the same best-effort
  auto-policy contract, so every sharded CI process LOADs from ~/.lbdb
  instead of racing its own bounded out-of-process INSTALL; the workflow's
  extension cache already covers it (path is the whole extension dir — key
  kept for cache continuity). The cross-platform job sets
  GITNEXUS_REQUIRE_VECTOR=1 beside GITNEXUS_REQUIRE_FTS so a genuinely
  unavailable VECTOR is a loud failure, never a silent skip.

Windows/macOS cannot be executed locally; the PR's CI lanes are the proof for
this commit. Linux smoke: ensure-fts.ts reports both extensions ready; all 79
roster entries resolve.

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

* test(pool): register loadVectorExtension in the pool unit-suite mocks

The pool adapter's new loadVectorExtension import surfaced in four suites that
mock lbug-adapter.js with explicit factories (vitest fails loudly on a missing
mocked export). Register the export in each — resolving false where the
suite's world assumes no vector, true where it mirrors FTS — and extend
lbug-pool-fts-load.test.ts, the suite that owns pre-warm extension loading,
with the vector pair: successful load cached per shared Database, failed load
retried on the next open, both pinned to policy load-only.

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

* test(analyze): use POSIX literals for graph paths in the #2623 ordering suite

First Windows CI run of this suite (it joined the cross-platform roster this
PR) failed with 'Parser exception: Invalid input <MATCH (n:Function) WHERE
n.filePath = '>' — path.join produces backslashes on Windows, and a backslash
inside the seed helper's single-quoted Cypher literal breaks the parser. The
graph stores repo-relative filePaths with forward slashes on every OS, so
graph-side paths are POSIX literals now (the incremental-orchestration
convention); path.join stays only for real filesystem access.

The same Windows lane also proved the substance this suite exists for:
lbug-vector-extension passed 7/7 on windows-latest — the extension installed,
loaded, and built a real HNSW index there — and the pool vector-lane and DML
gate suites passed too. This commit fixes the harness, not the fix.

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

---------

Co-authored-by: Gergo Magyar <abhigyan1.patwari@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 12:27:00 +01:00
Gergő Magyar
5549403082
fix(eval): self-hosted skill-evolution runner + sandbox Python 3 trust fix (#2600)
* fix(eval): move skill-evolution to a self-hosted runner and fix the sandbox's Python 3 trust gap

GitHub-hosted runners hard-cap job execution at 6 hours, which is too
short once a benchmark session actually invokes Skill/MCP tools for
real (the --bare fix in #2584 means sessions no longer no-op). Move the
job onto a self-hosted runner (5-day cap instead) and document the
activation step in the workflow's own checklist.

Validating the self-hosted run surfaced a real bug: gitnexus-plan
sessions inside the bwrap sandbox failed with "planning must create or
modify exactly one plan artifact; observed 0". Root cause:
evidence-provenance.mjs's atomic plan-writer only trusts a Python 3
binary owned by root or by the current process. Inside this
--unshare-user sandbox only the calling uid is mapped (root isn't), so
the real, root-owned /usr/bin/python3 surfaces as the kernel's overflow
uid and gets correctly refused as untrusted. Fix: provision a small,
self-owned wrapper script (same pattern already used for
shell-prefix) that execs the real interpreter, so the sandbox has a
Python 3 candidate the existing trust check can actually accept --
without touching that security-sensitive validation logic at all.

Also add visibility so this class of failure isn't quiet next time:
report.md now shows why each row failed (error_kinds), not just
resolved 0/1, and the benchmark now exits non-zero when an incumbent
arm -- the currently-shipped skill -- resolves zero across every task,
since that reads as a broken harness rather than a normal candidate
miss.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(eval): close the broken_incumbent_arms zero-valid-runs gap; document runner exposure tradeoff

Addresses the two MEDIUM findings from the gitnexus-review-agent on this PR
(https://github.com/abhigyanpatwari/GitNexus/pull/2600#issuecomment-5033363096).

broken_incumbent_arms required valid_runs > 0 before flagging an incumbent,
so an incumbent that fails every run with an excluded-but-non-systemic
error_kind (e.g. evidence-unverified, which the outage-streak breaker
explicitly resets on rather than accumulates) never accumulated a single
valid run and sailed through silently -- the exact quiet no-promotion
outcome this guard exists to catch, and arguably worse than the
some-runs-resolved-zero case since here nothing completed at all.
aggregate() never marks an excluded/unverifiable row resolved=True, so
dropping the valid_runs requirement and checking resolved == 0 alone
correctly covers both cases. Added a test for exactly this all-excluded
scenario, which none of the existing three did.

Updated the workflow's own activation checklist to reflect what's actually
true now (the gitnexus-evolution environment's branch policy and the
self-hosted runner are both live, codified in infra/gitnexus-evolution/ in
a companion PR) and documented the exposure-window tradeoff the review
flagged: the runner is stopped between runs but not destroyed/recreated per
run, so it isn't fully ephemeral. Stopping already bounds the exposure
window to the job's own runtime on one day out of seven; full per-job
ephemeral provisioning is a deliberate non-goal for a job that runs at
most weekly, revisit if that changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(eval): remove public infra/ pointers from the activation checklist

PR #2603 (the Terraform codification this checklist pointed to) got closed
-- publishing the exact IAM roles, security group rules, and self-hosted
runner topology for a real, live AWS account isn't safe to do in a public
repo, even with no literal secrets or resource IDs in the diff. The
underlying AWS/GitHub setup is unaffected and still documented privately;
this just removes the now-dangling references to a directory that won't
exist in this repo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* ci(actionlint): register the gitnexus-evolution self-hosted runner label

actionlint rejected `runs-on: [self-hosted, linux, x64, gitnexus-evolution]`
in gitnexus-skill-evolution.yml because it can't discover custom runner
labels. Register it in .github/actionlint.yaml so the Workflow Lint check
passes.

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

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 14:40:20 +01:00
Claude
eea9ac92dc ci: move Node pins to the 22.18 floor
With the supported minimum raised to Node 22.18, retarget every lane and
pinned runtime that sat at a lower version so nothing builds or runs the
package on an unsupported (EBADENGINE-warning) Node:

- ci-tests.yml: node-floor-compat 22.14 -> 22.18.0 (name, comment, pin,
  version assertion) so the floor gate guards the new minimum; its #2372
  registerHooks failure mode cannot recur above 22.15. Containment-canary
  pin 22.16.0 -> 22.18.0.
- gitnexus-review-agent.yml + the pinned review/canary runtime: the
  reproducible runtime is version-locked in lockstep across
  .github/{gitnexus-review-runtime,claude-canary-runtime}/package.json and
  their lockfiles (engines), the workflow's node-version, its two
  'node --version = v22.18.0' assertions, the lockfile-engines guard, and
  NODE_VERSION. Moved all of them 22.16.0 -> 22.18.0.
- gitnexus-skill-evolution.yml: pinned runtime 22.16.0 -> 22.18.0.
- CONTRIBUTING.md prerequisite floor updated.
- review-agent-workflow.test.ts, which enforces the runtime lock, updated
  to expect 22.18.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 10:09:34 +00:00
Abhigyan Patwari
2ea00a2b22
fix(ci): install root and shared node_modules for the evolution benchmark (#2575)
* fix(ci): install root and shared node_modules for the evolution benchmark

The first real workflow_dispatch of the skill-evolution loop failed at task
binding: capture_task_dependency_binding aborted with

  SandboxError: sandbox_copy path is unavailable: node_modules: No such
  file or directory

The benchmark tasks sandbox-copy node_modules from three locations
(tasks.scenarios.yaml) — the monorepo root, gitnexus-shared, and gitnexus —
mirroring a full dev checkout. The install step only ran `npm ci` in
gitnexus/, so the root and gitnexus-shared node_modules never existed and
the loop died before any agent ran. Install all three (root, then build
gitnexus-shared, then build gitnexus), matching the per-package install in
ci-tests.yml plus the root deps the tasks require.

A new contract test pins all three installs so this fails in CI rather than
on the next real run — the same guard the workflow's other two P1 fixes got.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ

* fix(ci): only add the missing root install; the subpackage steps already exist

The initial fix redundantly rebuilt gitnexus-shared and gitnexus inside the
gitnexus step — but the workflow already builds both in their own dedicated
steps. Only the monorepo root node_modules was missing. Add a single
"Install monorepo root dependencies" step and leave the two subpackage
build steps untouched, so the benchmark's root sandbox_copy resolves without
double-building.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ

---------

Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 09:56:54 +01:00
Gergő Magyar
fd1e0a999c
feat(ci): review agent runs as a coordinated reviewer swarm (#2572)
* feat(ci): review agent on Sonnet 5 with structured, linked reviews

Bump the pinned review model from claude-sonnet-4-5-20250929 to
claude-sonnet-5 (verified against the pinned Claude Code 2.1.214 with
subscription auth and --json-schema structured output).

Restructure the published review body: verdict-first summary, findings
ordered by severity, fixed section order, and every file or symbol
reference as a GitHub permalink pinned to the analyzed head SHA (or the
merge-base SHA for deleted and rename-old paths) instead of bare
path:line text, so references are clickable and render inline previews.

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

* feat(ci): review agent runs as a coordinated reviewer swarm

Implement the review skill's expert-lens section in CI: the main agent
spawns four trusted lanes in parallel via the Task tool — correctness,
security, blast-radius, and coverage — each a purpose-built persona
restricted to Read/Glob/Grep plus the read-only graph MCP tools.

Personas live in the canonical skill tree (mirrored to all shipped
copies) and are installed into the reviewer's user-scope agents dir from
the exact control SHA, so a hostile PR head can never define a lane.
Lane reports are treated as unverified claims: the main agent re-anchors
findings before publishing, and the publisher's context-evidence gate
still requires the main conversation's own successful context call.
Bash and the newer Agent tool remain disallowed for every context; the
analyze timeout gets swarm headroom (45 -> 60 minutes). The workflow
contract test now pins the swarm posture.

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

* fix(ci): sidechain tool calls can no longer satisfy the evidence gate

The review agent's own review of this PR found that proveGraphReview()
walked the flat transcript without reading parent_tool_use_id, so a
spawned lane's context call could satisfy the publisher's graph-evidence
gate the prompt reserves for the orchestrator. Entries with a non-null
parent_tool_use_id are still strictly validated (malformed linkage fails
the transcript) but are excluded from both candidate context calls and
qualifying results; a new fixture proves sidechain-only evidence is
rejected while mainline evidence beside sidechain turns still passes.

Also gives the orchestrator turn headroom for the four dispatched lanes
(--max-turns 100 -> 150), addressing the review's LOW finding.

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

* refactor(skills): swarm-lane dispatch belongs to the review skill

Move the lane orchestration out of the workflow prompt and into the
gitnexus-review skill itself: a new "Swarm lanes" section names the four
ci-persona lanes, defines when and how to dispatch them (parallel, one
message, per-lane context and file slices), and owns the verification
contract (lane reports are unverified claims; re-anchor, dedup, drop
unanchored findings; lanes structure the work but never gate it). Any
runner of the skill — the CI workflow or a local harness — now triggers
the lanes from one canonical definition.

The workflow prompt keeps only its CI-specific deltas: the lanes'
trusted-control-SHA install provenance, the Task-tool dispatch surface,
and the publisher's orchestrator-only context-evidence gate. Mirrors
synced; 122 contract tests pass.

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

* feat(skills): add adversarial finder lane and critic gate to the swarm

ci-adversarial-lens joins the parallel finder wave: it assumes the change
is broken and constructs reachable failure scenarios — interleavings,
hostile inputs, state corruption, abuse of newly exposed surfaces — each
verified to a concrete entry point before it may be reported.

ci-critic-lens runs last as a gate on the orchestrator's finished draft:
it audits anchoring, concreteness, severity calibration, format
conformance, and honesty, returning PASS or a numbered defect list with
the smallest repair per item. The skill bounds it to two passes and the
critic hardens the review without ever blocking it; the workflow inherits
both lanes automatically through the wholesale ci-personas install.

Mirrors synced across all three shipped trees; 122 contract tests pass.

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

* refactor(ci): workflow defers the whole swarm contract to the skill

Now that the skill's Swarm lanes section owns dispatch, verification, the
critic gate, and the fallbacks, the workflow prompt stops restating any
of it. It contributes only what CI alone knows: the lanes' control-SHA
install provenance, the concrete environment mapping for lane inputs
(diff, manifest, head and merge-base checkouts, exact SHAs), and the one
CI override — the publisher's context-evidence gate remains
orchestrator-only. Analyze timeout gains headroom for the critic's
sequential rounds (60 -> 75 minutes).

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

* fix(ci): dispatch swarm lanes via the Agent tool, not the renamed Task alias

On the pinned Claude Code 2.1.214 the subagent-dispatch tool is `Agent`
(`Task` was renamed to `Agent` in 2.1.63 and is now a legacy alias), and
permission rules evaluate deny before allow. The workflow allowed `Task`
and denied `Agent`, so the orchestrator could never dispatch a lane and
every review silently fell back to the inline single-agent path while the
text-only tests certified the broken config.

Use `Agent` consistently: add it to --tools, allow it scoped to the six
ci-personas (`Agent(ci-correctness-lens,...,ci-critic-lens)`), remove it
from --disallowedTools, and update the prompt. Tests now match the scoped
allowlist on the raw string (commas inside Agent(...) break a split) and
assert Agent is no longer bare-denied.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ

* fix(ci): harden swarm permissions — allow Glob/Grep + merge-base reads, quarantine PR-head agents

Three permission-hygiene gaps around the swarm dispatch:
- Glob/Grep were in --tools but had no allow rule, so the lanes' declared
  tools could manufacture denied-tool errors; allow them (read-only,
  sandboxed by cwd + add-dir).
- The prompt hands lanes the merge-base source checkout for deleted /
  rename-old symbols, but no Read rule covered it; add a scoped Read()
  allow (which grants access without triggering --add-dir agent discovery).
- The --add-dir PR-head copy is scanned for spawnable agent definitions and
  the pinned runtime has no suppression env, so a PR could ship its own
  .claude/agents/*.md. Drop that subtree from the materialized copy after
  checkout-index (skills left intact), so only the trusted control-SHA
  personas can ever be dispatched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ

* test(ci): pin the Agent allowlist to the ci-personas; require a dispatch canary

A text-only assertion cannot prove the pinned CLI actually dispatches the
lanes (print mode silently ignores invalid settings and does not validate
Agent(type) content at parse time) — that is what let the original
Task/Agent inversion pass CI. Two mitigations for the class:

- A cross-consistency test asserts the six names in the Agent(...) allowlist
  equal the six ci-personas filenames and each persona's frontmatter name,
  so a rename or typo in any of the three fails without auth.
- The activation checklist now requires the post-merge canary to prove a
  positive dispatch AND an unlisted-type refusal before enabling the trigger.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ

* fix(ci): bound swarm transcript volume with per-persona maxTurns

The six lanes stream into the single execution transcript the publisher
validates, but the personas carried no turn budget, so a large-PR swarm
run could overflow the (hard-throw) transcript caps and brick a valid
review. Bound each lane deterministically — finders maxTurns 12, the
critic maxTurns 6 — which keeps the worst case (~2×(150+5×12+2×6) ≈ 444
messages) under the unchanged 1_000 cap, so no cap needs raising. A new
test encodes that invariant: it fails if a persona's maxTurns is bumped
without revisiting the cap. Applied byte-identically across all four
shipped skill trees.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ

* test(ci): independently pin both sidechain evidence-gate guards

The sidechain-exclusion guards at candidate registration and result
acceptance were mutually redundant on realistic transcripts (a real
sidechain turn carries parent_tool_use_id on both its call and result),
so deleting either guard alone still passed the whole suite. Add two
asymmetric cross-wired fixtures — a mainline call with a sidechain result
(pins the acceptance guard) and a sidechain call with a mainline result
(pins the registration guard), both expecting missing_graph_evidence.
Mutation-verified: deleting either guard alone now reddens the suite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ

* docs(skills): require own evidence before dispatch; document critic fail-open and swarm naming

Strengthen the gitnexus-review "Swarm lanes" contract (all four mirrors):
- The orchestrator must make its own graph context call on a changed
  symbol before dispatching any lane, so a fully-delegated run cannot
  leave the publisher's evidence gate unsatisfied (mirrored into the
  workflow prompt, with a test pinning the ordering phrase).
- Document that the critic's fail-open is deliberate (bounded to two
  passes, cannot deadlock, review still gated by evidence + schema),
  and distinguish it from the hard lane-7 gate in the separate
  gitnexus-pr-swarm-review skill.
- Give a concrete local-harness registration pointer for ci-personas.
- Add a reciprocal cross-reference in gitnexus-pr-swarm-review (single
  path — that skill is not part of the mirrored family).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ

* docs: record the review-agent swarm capability (AGENTS.md, CLAUDE.md, reviewer-swarm README)

Reflect the shipped swarm in the standing docs: bump AGENTS.md to 1.14.0
and CLAUDE.md to 1.8.0 with changelog rows, extend the gitnexus-review
description to mention the ci-personas swarm lanes, and refresh the
reviewer-swarm README so its differentiator names the real distinction
(interactive on-demand swarm vs the CI review agent's in-workflow lanes)
now that both run swarms. No CHANGELOG.md edit (feature-PR rule).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ

* fix(ci): close pre-push review findings on the swarm permission change

Adversarial review of the fix diff caught two issues introduced by the
permission-hygiene commit:
- Bare Glob/Grep in --allowedTools are separate tools that the Read()-scoped
  path denies (/proc, github.workspace, ...) do not cover, opening an
  undenied read path to the raw checkouts and host paths via a prompt-
  injected lane. Drop the bare allow — under dontAsk they stay denied by
  omission; lanes read via the scoped Read() rules and the graph MCP.
- The agents quarantine removed only the add-dir root's .claude/agents; make
  it recursive so a nested (e.g. monorepo subpackage) .claude/agents cannot
  survive and be discovered.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ

* feat(ci): post an "in progress" marker while the review swarm runs

Swarm reviews can take up to 75 minutes, and until now the PR showed no
sign a review was running. Add a dedicated write-scoped `acknowledge` job
that, under the same authorization gate as analyze, upserts a per-PR
"🔄 GitNexus review in progress" sticky comment linking to the live run
(and reacts 👀 to the trigger comment); the publisher removes that marker
when the review — or a clean failure — posts.

The marker lives in its own job so the model-facing analyze job stays
secretless and read-only: it cannot post to the PR, so per-lane live
progress isn't exposed there — the marker is a binary "running" state with
a link to the Actions run where lane-by-lane progress is visible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 07:31:40 +01:00
Gergő Magyar
becac9a5d3
feat(eval): run the skill-evolution loop online (#2571)
* feat(eval): run the skill-evolution loop online

Add a scheduled + dispatch-gated workflow that runs the offline
propose -> benchmark -> gate loop (workflow_bench.evolve) in CI with the
pinned Claude canary runtime and bubblewrap containment, uploads the
benchmark evidence as an artifact, and on a gate-passed promotion opens
a human-reviewed PR via the release App token. The applied overlay is
bounded to the canonical skill tree and its shipped mirrors; any escape
fails the run instead of reaching a PR.

The scheduled lane ships disabled behind GITNEXUS_EVOLUTION_ENABLED and
requires the new GITNEXUS_BENCH_AUTH_TOKEN secret (benchmark sessions
bill real API usage), mirroring the review agent's staged rollout.

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

* fix(ci): restructure promotion-PR script so no lint suppression is needed

Replace the inline single-quoted credential helper with a GIT_ASKPASS
file written via a quoted heredoc (the App token still reaches git only
through step env at push time), and assemble the PR body from quoted
heredocs plus double-quoted printf instead of a backtick-laden
single-quoted template. Every run script in the workflow now passes
shellcheck with zero findings and zero disables; the body and askpass
rendering are smoke-tested.

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

* fix(ci): apply gate-passing overlays in the evolution loop

The loop invoked workflow_bench.evolve without --apply, so
apply_promoted_overlay (its only working-tree writer, gated by
`if args.apply:`) never ran. git status stayed clean, promoted=false was
emitted every run, and the App-token/PR-open steps were unreachable dead
code — a gate-passing run went green as "No promotion this run".

validate_promotion_for_apply already runs before the apply gate, so
adding --apply lets a passing candidate reach the tree without weakening
the deterministic gate; the boundary check then confirms it stayed in the
skill trees.

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

* fix(ci): provision ~/GitNexus so the benchmark repo resolves on CI

Every scenario in tasks.scenarios.yaml addresses the target repo as
~/GitNexus; runner_tasks.py resolves it with expanduser().resolve() then
`git -C <repo> rev-parse`, which raises when the path is missing. On a
hosted runner the checkout lands in $GITHUB_WORKSPACE and nothing created
~/GitNexus, so the first real run failed at task-binding.

Symlink ~/GitNexus -> $GITHUB_WORKSPACE before the loop. The checkout uses
fetch-depth: 0 (full history for the parentless clone), and the benchmark
only clones the repo copy-on-write and mounts deps read-only, so the
checkout is never mutated. GITNEXUS_BENCH_ORACLE_ROOT stays unset — it
defaults to the in-repo oracles dir and is staged by the harness.

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

* fix(ci): harden promotion summary output and PR branch recovery

Three fixes to the promotion-detection and PR-open steps:

- GITHUB_OUTPUT summary used a fixed `PROMOTION_EOF` heredoc delimiter; a
  value containing that marker on its own line could close the block early
  and inject output keys. Use a per-run random delimiter, matching the
  pattern already in tree-sitter-upgrade-readiness.yml.
- The summary concatenated every generation's promotion.json (including
  rejected ones), so the PR body could show a losing generation's
  decisions. The loop returns on the first promotion, so emit only the
  highest-numbered gen-N/bench/promotion.json — the decision that fired.
- The promotion branch name omitted the run attempt. GITHUB_RUN_ID is
  stable across re-runs, so a re-run after push-succeeds/PR-create-fails
  could never push. Include ${GITHUB_RUN_ATTEMPT} (the artifact name
  already does).

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

* fix(ci): least-privilege the promotion App token and gate on an Environment

The Mint-App-Token step passed only app-id + private-key, so the minted
token inherited every permission the Release App installation holds
(including Workflows: write) — far more than "push a branch, open a PR".
Switch to `client-id` (as publish.yml does) and request only
permission-contents: write + permission-pull-requests: write.

Bind the job to a protected Environment (gitnexus-evolution) so promotion
runs can be gated server-side. workflow_dispatch runs the workflow and
in-tree evolve.py from the *dispatched ref*, so a code-side ref guard is
removable by the dispatched branch itself; an Environment deployment-branch
rule (main only) is the boundary that holds. The admin steps to create it
and scope the secrets are documented in the activation checklist.

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

* fix(ci): correct upload-artifact pin comment and add shell strict-mode

- The upload-artifact SHA 043fb46d… is v7.0.1 (labeled so in the sibling
  workflows that pin it); the comment mislabeled it # v6.0.0. Correct the
  comment; the pin is unchanged.
- Add `set -euo pipefail` to the two build steps that lacked it, matching
  every other run block in the file (GitHub's default shell already sets
  -eo pipefail; this adds -u and consistency).

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

* docs(ci): complete the skill-evolution activation checklist

- Add RELEASE_APP_ID / RELEASE_APP_PRIVATE_KEY to the required-secrets
  checklist (the Mint step hard-fails without them on a promotion) and the
  App-install-scope verification.
- Document the protected Environment admin step and why it is the real
  boundary for the workflow_dispatch ref-secret exposure.
- Note that workflow_dispatch runs the billing loop regardless of
  GITNEXUS_EVOLUTION_ENABLED.
- Justify the weekly cron against the README's ~90-day guidance and note the
  355-minute timeout ceiling.

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

* fix(eval): redact API tokens from diagnostic fields before artifact upload

results.jsonl (runner.py) and proposer-session.json (evolve.py) serialize
session records whose error_detail can carry a stderr_tail that echoed the
API key. Transcripts are redacted before persistence, but these two sinks
were not, and both land in the 14-day evolution artifact.

Run each record's serialized JSON through the existing redact_text with the
run's auth token before writing. Scoped to these diagnostic sinks only: the
promoted overlay and proposal.md are left untouched (the overlay is the
applied artifact and must stay byte-identical for apply and the
shipped-skills-sync guard).

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

* test(ci): add a contract test for the skill-evolution workflow

No test exercised this workflow's path, which is why both P1 blockers
(missing --apply, unresolvable ~/GitNexus task repo) reached production.
Parse the workflow YAML and assert the structural contract: --apply is
passed, the task repo is provisioned, the promotion branch carries the run
attempt, the App token is permission-scoped and the job is Environment-
gated, the output summary uses a random delimiter and a single generation,
the artifact pin is labelled correctly, and every multi-line shell step
sets strict mode. Follows the review-agent-workflow.test.ts precedent.

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

* feat(ci): run the proposer on its own (stronger) model

One `model` input drove both the benchmark arms and the proposer/diagnosis
session. Split them: `model` stays the benchmark arms (match the model your
skill users run, so a promotion is valid for them and the tasks aren't
ceiling-saturated), and a new `proposer_model` input runs the proposer —
the harder meta-reasoning task that writes the candidate skill, and only one
session per generation, so a stronger model is cheap here. evolve.py already
supports --proposer-model; the workflow just didn't expose it.

Defaults: arms = claude-sonnet-5, proposer = claude-opus-4-8 (both
overridable via workflow_dispatch). The weekly cadence bounds the added
spend. Contract test asserts the split stays wired.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 05:37:07 +01:00
Gergő Magyar
94a528f577
feat(ci): review agent on Sonnet 5 with structured, linked reviews (#2570)
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
Skill copy sync / shipped skills drift guard (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
Bump the pinned review model from claude-sonnet-4-5-20250929 to
claude-sonnet-5 (verified against the pinned Claude Code 2.1.214 with
subscription auth and --json-schema structured output).

Restructure the published review body: verdict-first summary, findings
ordered by severity, fixed section order, and every file or symbol
reference as a GitHub permalink pinned to the analyzed head SHA (or the
merge-base SHA for deleted and rename-old paths) instead of bare
path:line text, so references are clickable and render inline previews.


Claude-Session: https://claude.ai/code/session_01Va5uu9Ar3e45QZ5xFsG4AZ

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 17:45:35 +01:00
Gergő Magyar
b3826d6b0e
fix(ci): unblock the review agent dispatch and publisher lanes (#2567)
* fix(ci): unblock the review agent dispatch and publisher lanes

The first workflow_dispatch validation run surfaced two defects:

- setup-node rejects `cache: false` (the YAML boolean arrives as the
  string 'false' and v6 fails with "Caching for 'false' is not
  supported"), killing the analyze job before the isolation preflight.
  Omitting the input is the supported way to disable caching.
- The publisher held only `issues: write`, but GITHUB_TOKEN needs
  `pull-requests: write` to create issue comments on a pull request,
  so even the safe-failure comment died with "Resource not accessible
  by integration".

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

* test(ci): align the publisher permission contract with PR commenting

The workflow contract test pinned the publisher to pull-requests: read,
which is exactly the permission set that made comment publication fail.
Encode the corrected scope and assert the publisher still cannot write
repository contents.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 16:36:22 +01:00
Gergő Magyar
8b5057f325
feat(skills): GitNexus Engineering Tool Kits (#2566)
* feat(skills): add ce-plan — GitNexus+PDG implementation-planning skill

Adds .claude/skills/ce-plan: a planning-only skill that builds
implementation-ready plans from GitNexus graph navigation (query/context/
impact/trace), bounded statement-level PDG slices (pdg_query, impact
mode:pdg, explain), and targeted source verification, with a context
ledger to prevent repeated reads and a machine-readable implementation
context pack (stable contract for a future ce-implement). Whitelisted in
.gitignore and registered in AGENTS.md and CLAUDE.md outside the
auto-managed gitnexus block.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(skills): apply ce-plan validation findings (tool contract, consistency, conventions)

Tool contract: impact mode:'pdg' shape now includes the schema-required
direction param; CDG branch sense documented as the result 'label' field
(reason is cypher/raw-edge only); explain caveats corrected to its real
false-negative classes (cross-function TAINT_PATH is modeled).

Consistency: PDG slice homed in working memory (ledger keeps one-liners);
depth knob defined and category-overrides-baseline ordering stated;
call_depth (consumed by nothing) and content-hash bookkeeping dropped;
Never section folded into Hard rules; Phase 3 deduplicated to a pointer;
allowed-repeat escalations defined; budget/discard accounting clarified;
verification-commands gathering added to Phase 4; open_questions added to
the context pack.

From scenario runs: plans now pin the verified-at HEAD commit and index
freshness in a header, tag claims [verified]/[graph]/[inferred]/[assumed],
quote load-bearing tool output, prefer pre-hook-carrying npm scripts, and
support an out:<path> destination override; output path defined as the
Phase 1 target repo root.

Conventions: AGENTS.md 1.9.0 / CLAUDE.md 1.4.0 changelog rows + metadata
bumps; future ce-implement qualified as future.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): rename ce-plan → gitnexus-plan; add cross-CLI (Codex) entrypoints

Renames the skill dir, frontmatter, output filename convention, plan H1
(GitNexus Engineering Plan), the future executor handle
(gitnexus-implement), the .gitignore whitelist entry, and all
AGENTS.md/CLAUDE.md references. Follows the pr-swarm-review cross-CLI
pattern: SKILL.md is the canonical CLI-neutral spec, AGENTS.md § Engineering
planning is the Codex/any-agent entrypoint, and the README documents the
optional user-level ~/.codex/prompts/gitnexus-plan.md slash command plus an
invocation matrix. Skill prose de-branded from Claude Code (agent-neutral
verification layer).

Also fixes two post-review README contradictions: the anti-reread claim now
names the ledger's allowed escalations, and 'read-only by contract' is now
'planning-only' (the skill writes exactly one repo file — the plan); the
scope-creep rule and template §12 now agree on where deferred follow-ups
land. Drops the stale plugin-collision limitation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(skills): document Codex user-level install path for gitnexus-plan

Codex discovers SKILL.md skills from ~/.agents/skills (same path the other
gitnexus-* skills install to); README now documents the cp install plus the
optional ~/.codex/prompts slash-command file, with the prompt body preferring
the repo copy and falling back to the user-level install.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): gitnexus-plan freshness gate + active PDG-layer refresh

Freshness is now a Phase 1 gate, not advisory: under the default
freshness:strict, a stale index is refreshed once per planning session via
node .gitnexus/run.cjs analyze --index-only (appending --pdg when the task
will reach the PDG phase), then the context resource is re-read. A missing
PDG layer likewise triggers the one permitted --index-only --pdg refresh
and re-probe instead of a passive recommendation. freshness:accept (or a
failed/impractical refresh) preserves the old behavior: plan on the stale
graph, source-weighted, labelled in the plan header. --index-only is the
load-bearing flag choice — it suppresses all file generation, so the
planning-only contract holds (only the .gitnexus store changes). Ledger
gains an index_refresh record; plan header states fresh / refreshed /
refresh-skipped-with-reason.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): gitnexus-plan runner build check before freshness refresh

When the target repo builds the analyzer from its own source (bin → dist/
mapping, as gitnexus/ does), the Phase 1 freshness gate now verifies dist/
is current before running the analyze refresh — rebuilding via the
package's build script when any analyzer source file is newer than the
built entrypoint — and prefers that freshly built CLI. Otherwise a stale
dist re-indexes with outdated extraction logic and the 'fresh' index lies.
Rebuilds are recorded in the ledger's index_refresh; the PDG-phase refresh
inherits the same check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): add gitnexus-work executor and gitnexus-lfg pipeline

gitnexus-work executes a gitnexus-plan as verified atomic commits: consumes
the §11 implementation_context pack, drift-checks the plan's evidence pin
against HEAD, re-verifies assumptions before relying on them, runs impact
before every symbol edit and detect_changes before every commit (repo
mandates), builds tests from the plan's scenarios, and routes structural
drift back to gitnexus-plan Deepen mode instead of coding around it.

gitnexus-lfg is a thin orchestrator: gitnexus-plan → blocking user gate
(deepen / proceed / stop, deepen loops allowed) → gitnexus-work → review
via the existing gitnexus-pr-review skill (open PR, else branch diff vs
default). One bounded fix cycle for review findings; never pushes or opens
a PR on its own.

gitnexus-plan gains a Deepen mode (re-run freshness gate, escalate to
depth:deep, re-verify graph/inferred/assumed claims toward verified,
rewrite the same file); its 'future gitnexus-implement' placeholder is
retired in favor of gitnexus-work. Registered via .gitignore whitelists,
AGENTS.md 1.10.0 (section renamed to Engineering planning & execution),
CLAUDE.md 1.5.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(skills): apply cross-skill review findings to the gitnexus skill family

Two P1s: gitnexus-plan Deepen mode now re-anchors before re-pinning
(diffs the old evidence pin over every [verified]-claim file and re-reads
or downgrades before the header moves — moving the pin without this
laundered stale claims as verified); the index-refresh budget is stated
once in Phase 1 (one --index-only refresh plus at most one Phase 3 --pdg
upgrade per session, Deepen = its own session) with ledger and pdg-slice
deferring to it.

Contract fixes: gitnexus-work's drift check now covers every file the
pack cites (not just files_to_modify) and parses the full pack incl.
primary/related symbols and acceptance_criteria (walked in Phase 4
alongside §13); a pre-completed check skips §7 steps already landed and
Deepen gains a reconcile-execution-state step, closing the mid-execution
route-back loop; pack assumptions must name what to check and how.

lfg: Lane 4 passes the merge-base to detect_changes compare (two-dot
diff misattributes upstream commits when default advanced), branch-diff
is the stated normal case, oversized review findings route to the plan
gate instead of overflowing direct mode, the one-fix-cycle cap is
explicit on re-run, and headless runs end at the plan gate with the plan
as deliverable. work: blank mode narrowed to *gitnexus-plan*.md with a
re-execution guard, direct-mode discipline spelled out, branch
meaningfulness defined against the plan slug, and the plan document is
committed as the branch's docs commit (review diff includes it).
Planning-only contract now names the dist/ rebuild as the second
permitted state change; Phase 5.1 names the four claim tags; stale
AGENTS.md anchors fixed.

Known latent issue left untouched: gitnexus/gitnexus-pr-review pairs a
three-dot example with a two-dot detect_changes compare — that skill is
also shipped by the plugin, so fixing it here would drift the copies;
lfg compensates by passing the merge-base.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): ship the engineering skill family with the gitnexus package

npm i -g gitnexus users now get gitnexus-plan / gitnexus-work / gitnexus-lfg:
the three skills are added to gitnexus/skills/ in directory form (SKILL.md +
references/), which installSkillsTo already enumerates dynamically and copies
recursively to every editor target (~/.agents/skills for Codex, Cursor,
OpenCode, Qoder, ...) on gitnexus setup — uninstall enumerates the same root,
so removal stays clean. The Claude Code plugin channel
(gitnexus-claude-plugin/skills/) carries the same copies plus the standard
per-skill mcp.json.

Global-install support in the skill text: gitnexus-plan Phase 1 now resolves
the analyzer runner explicitly — node .gitnexus/run.cjs analyze when the
project has a runner, else gitnexus analyze (installed CLI), else
npx gitnexus analyze — and all analyze mentions route through it, satisfying
the skills-steering policy (#1939/#1945) which sweeps the plugin copies.

New drift guard test/unit/shipped-skills-sync.test.ts asserts the npm and
plugin copies stay byte-identical to the canonical .claude/skills/ family
(plugin = canonical + mcp.json), same discipline as run.cjs ↔
resolve-invocation.ts. skills-steering + shipped-skills-sync: 11/11 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(eval): workflow_bench — measure the skill workflow's token savings

Benchmarks gitnexus-plan → gitnexus-work against a baseline agent
(--disallowedTools Skill) on identical tasks, in fresh detached worktrees,
using real headless Claude Code sessions; every number comes from the CLI's
--output-format json usage report (field names validated against a live
2.1.207 session). Reports per-arm medians (input/cache/output tokens, cost,
wall time, turns), a savings row, and resolve status from a per-task verify
command — savings on failed tasks are flagged, not celebrated. Per-task
setup hook prepares fresh worktrees (deps); --permission-mode
bypassPermissions (default) lets sessions run unattended in the throwaway
trees.

Free-model support: --base-url/--auth-token/--model route headless sessions
through any Anthropic-compatible endpoint; free-model.litellm.yaml is a
ready litellm-proxy template for OpenRouter :free variants or local Ollama,
so benchmarking burns no paid tokens (README documents rate limits and the
small-model skill-following caveat).

Harness validated end-to-end with a stub CLI (worktree lifecycle, both
arms, plan→work chaining, verify, aggregation, report) and 4 pytest units
for the pure aggregation/savings/report helpers. AGENTS.md 1.11.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(eval): record first workflow_bench calibration run

Trivial-task calibration (add -V alias): both arms resolved; workflow arm
~4.3x baseline cost — the documented overhead-dominated regime, recorded so
the regime boundary is empirical rather than asserted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(eval): workflow_bench scenario matrix — arm variants, task classes, churn

Ground-base measurement across scenarios: tasks.scenarios.yaml spans four
labeled classes (trivial → investigation-bug → investigation-feature →
cross-module) with deterministic verifies (prescribed test files). New arms:
workflow_direct (gitnexus-work direct mode — the middle option that locates
the routing boundary lfg's gate and work's triage encode) and baseline_nomcp
(no skills AND no graph tools — separates workflow-discipline value from
GitNexus-tool value; off by default). Records now carry task class and diff
churn (files/+ins/−del vs the starting commit) as an over-engineering proxy;
the report renders a class column and per-arm savings rows vs baseline.
5 pytest units + stub-CLI e2e of the full three-arm matrix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(eval): record workflow_bench ground base; fix churn measurement bias

Ground base (3 classes x 3 arms, n=1/cell): every arm resolved every task —
pass/fail quality saturates at this difficulty, making the comparison pure
cost. Full plan→work never amortized its ~$9-11 fixed cost on tasks a
baseline finishes in ≤35 turns (−211% to −333% cost); workflow_direct sits
near baseline (−15% to −55%, once faster wall) with more test coverage.
Routing implication recorded: direct mode/plain agent below this scale,
full workflow for cross-module / multi-session / plan-as-deliverable work.
The cross-module cell and multi-run variance are the next measurements.

Churn fix: git add --intent-to-add -A before diffing (arms that never
commit no longer undercount new files) and :(exclude)docs/plans (the
committed plan doc no longer inflates workflow churn); this run's churn
numbers predate the fix and are omitted from the recorded table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf(skills): cost-optimize the workflow from measured ground base

Every optimization targets a measured fixed-cost component
(eval/workflow_bench ground base: workflow arm −211% to −333% vs baseline,
all tasks resolved):

- Plan form is category-priced: compact form (core sections w/ § anchors
  preserved, ≤80 lines excl. pack, mini-pack subset of the context pack)
  for narrow/default categories; the full 13 sections only for deep work
  (refactor/security/performance/concurrency/architecture). A compact plan
  outgrowing its cap reclassifies to full rather than overflowing.
- Freshness gate is category-priced: compact categories default to accept
  (source-weighted, refresh only when a graph claim becomes load-bearing);
  strict stays the default for full-plan categories — the rebuild+re-index
  was the largest single fixed cost.
- Turn economy: per-category tool-call budgets (~10 to ~45; architecture
  uncapped); budget exhaustion routes open questions to §12 instead of
  more digging.
- gitnexus-work fast path: HEAD == evidence pin → skip all citation
  re-reading (the pin's entire point); mini-pack fields tolerated.
- lfg Lane 1 boundary triage: tasks below the measured ~35-turn boundary
  get offered gitnexus-work direct mode before the plan lane is spent.

Copies re-synced (npm skills/, plugin, ~/.agents); steering + sync guards
green. Re-measurement of the workflow arm follows to verify the numbers
actually improve.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(eval): record optimization re-measurement — inv-bug workflow cell −20% cost

Same task, same conditions, post-830a0459 skills: $14.56→$11.70 (−20%),
83→72 turns, cache_read −24%; verified in-transcript that the compact form,
turn budget, and skipped rebuild/re-index all fired. Wall +15% from a work-
session test-debugging tail (n=1 variance). Regime unchanged (~3.5x baseline
on this class) — routing rule stands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(eval): per-arm clone isolation — worktree ref-namespace leak contaminated an arm

The cross-module workflow_direct cell reported an impossible 28-turn solve
with churn byte-identical to the workflow arm: git worktree add shares the
repo's ref namespace, so the workflow arm's slug branch (created by
gitnexus-work Phase 2) survived worktree removal and the direct arm found
and adopted the completed work. Arms now get isolated git clone --shared
copies (object store via alternates, refs clone-local — agent branches and
stashes die with the clone; origin/<ref> fallback for non-default refs).
Leaked branch deleted; baseline arm verified clean (0 branch references in
its transcript); cell marked invalidated pending re-run.

Records the valid cross-module cells: workflow $18.32 vs baseline $18.03
(premium −1.6%, vs −211%..−333% on smaller classes) — fixed costs amortize
at this scale, with a less destructive diff and a plan artifact as bonus;
resolve rate still tied. Churn fingerprinting is what caught the
contamination — noted in the README as an integrity check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(eval): complete cross-module cell — direct mode wins 47% cost / 56% wall

Clean clone-isolated re-run: workflow_direct resolved the hardest class at
$9.53/52 turns/15m vs $18.03/98/34m baseline and $18.32/107/37m full
workflow. The measured story across all four classes: the execution
discipline (gitnexus-work) is the consistent sweet spot and delivers real
token savings on hard tasks; the planning pass buys its artifact, not
same-session savings. Resolve rate tied everywhere (n=1/cell caveat).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(eval): add trajectory-gated skill evolution (#2431)

- Pair prompt candidates with incumbent workflow arms
- Gate promotions on pinned-model quality and efficiency
- Expire router evidence and document its lifecycle

* fix(eval): allow pr-review skill candidates

* feat(skills): rename and generalize GitNexus review

* feat(eval): external-comparator and review arms for workflow_bench

- ce_workflow / ce_workflow_direct: compound-engineering ce-plan/ce-work
  arms prompted with the same structure as the gitnexus arms
- review / ce_review: gitnexus-review vs ce-code-review on an identical
  diff applied by the task's setup
- plan handoff is snapshot-based: committed example plans in docs/plans/
  tie on clone mtimes and broke the name-glob pick (executed a stale plan)
- verify output tail is recorded per run and the final working-tree patch
  is kept, so failed rows are diagnosable after the clone is destroyed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(skills,eval): address #2431 review — data-safe rename migration, fail-closed bench evidence

- setup: never delete a legacy renamed skill dir — the installer cannot
  prove ownership (users customize or hand-write skills under these
  names); warn with the path instead, and the test now asserts survival
- workflow_bench: fail closed when a session's --output-format json
  report is empty, malformed, or missing usage fields — an exit-0 shell
  with no parseable usage no longer counts as measured evidence
  (5 parametrized regression tests)
- workflow_bench: document the trust model prominently (task setup/verify
  are shell-executed, sessions run bypassPermissions with the parent env,
  candidate overlays are prompt injection surface) in README + docstring
- free-model.litellm.yaml: master_key from LITELLM_MASTER_KEY env instead
  of a static token; loopback-binding warning
- ci: run the eval workflow_bench pytest suite on ubuntu (pytest+pyyaml
  only — no full eval stack)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(eval): demand observed foreground verification in headless work-arm prompts

In a headless -p session there is no later turn: a work arm backgrounded
its slow test run, scheduled wakeups that can never fire, and reported
done while two of its tests failed. All four work-arm prompts (both
skill families, symmetric) now require verification output to be
observed inside the session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): ask plan depth up front instead of offering deepen afterwards

gitnexus-plan Phase 0 now asks one blocking question in interactive
sessions — quick / standard / deep, mapped onto the existing depth/form/
freshness knobs — when the invocation carries no explicit depth signal.
Explicit knobs and headless runs skip the question (category posture
unchanged, so benchmarks and automation behave as before).

gitnexus-lfg's plan gate slims to proceed/stop: depth was already the
user's up-front choice, so deepening is no longer offered by default —
an explicit deepen request at the gate and executor route-backs still
run Deepen mode, which remains the mechanism for strengthening an
existing plan document.

All shipped copies resynced (npm skills/, Claude plugin); AGENTS.md
1.13.0 and CLAUDE.md 1.7.0 pointers updated, including the analyzer's
regenerated index-stats block at this branch's head.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): taint pass, expert lenses, and post-work index refresh

gitnexus-review gains a PDG-backed taint-and-dependence pass (explain +
pdg_query, --pdg folded into the stale refresh on trust-boundary diffs) and
an Expert lenses section: domain reviewers derived from the graph's
clusters plus four cross-cutting lenses (architectural fit, language
conformance per the repo's own contract, Definition of Done, simplicity),
dispatched once after the evidence-gathering steps and scaled to the diff.
gitnexus-work Phase 4 now refreshes the knowledge graph after the DoD walk
via the resolved-runner ladder with analyze --index-only, so the lfg review
lane and later sessions query the finished work without dirtying the tree.
lfg's threshold-governance paragraph moves to its README; eval citations
are tagged as measured in the GitNexus repo. All shipped copies re-synced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cli): remove legacy gitnexus-pr-review on uninstall; cover the rename migration

uninstall's removal set now includes LEGACY_SKILL_DIR_NAMES derived from
RENAMED_SKILL_DIRS, so a pre-rename install is cleaned up instead of
orphaned. The rename warning gains behavioral coverage (fires with a legacy
dir present, silent without), and shipped-skills-sync asserts legacy names
stay absent from every shipped tree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(eval): metric provenance, error-kind rows, skill-invocation verification, gate noise floor

The promotion gate defaults to cost_usd (the only metric that includes
subagent spend); token metrics carry an explicit main-loop-only warning in
the report and promotion.json. Rows are classified by error_kind
(session-error / verify-failed / infra-error), excluded from efficiency
medians, and the gate requires equal valid-run counts. Each session's
transcript is scanned for the expected Skill invocation and fails closed on
a verified miss; a one-run resolution edge no longer promotes (noise
floor). Per-run timeouts and setup failures record an infra-error row
instead of aborting the sweep. Overlays touching skills no candidate arm
exercises are rejected up front.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: fix skill routing paths, version headers, and skill rosters

Routing tables point at the tracked direct skill paths (matching the
post-#2434 generator output), AGENTS.md/CLAUDE.md headers match their
latest changelog rows, the 1.12.0 row describes what the migration actually
does, package/cursor READMEs list the full shipped skill roster, and the
swarm READMEs describe /gitnexus-review's expert lenses instead of calling
it single-agent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: drift-guard workflow for skill copies; pin eval pip deps; track docs/plans

ci.yml ignores '**.md', so an md-only skill edit would merge without the
shipped-skills-sync test running — skill-sync.yml triggers exactly on the
guarded trees. The eval job's pip install is version-pinned, and
docs/plans/ is unignored so gitnexus-plan output can be committed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ci): keep the runner-invocation literal in gitnexus-review; add concurrency block to skill-sync

skills-steering requires skills with a stale-index hint to carry the exact
'node .gitnexus/run.cjs analyze' form — restore it with the fallback ladder
as a parenthetical instead of replacing it. skill-sync.yml gains the
top-level concurrency block the workflow-convention check enforces.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): token-economy guidance for expert lenses

Merge lenses that ground in the same material into one reviewer, and use
cheaper model/effort tiers for mechanical lenses where the harness offers
them, reserving the strongest engine for adversarial judgment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(eval): isolate transcript home on Windows

Ensure workflow_bench transcript tests set USERPROFILE alongside HOME so Path.home() resolves to the temporary test home on Windows.

* docs(skills): fold PR #2522 execution learnings into review/work/plan

Eight incident-backed hardenings from running the full skill cycle
(review -> plan -> work, 28-finding fix series) on PR #2522:

gitnexus-review:
- Expert lenses execute the code under review on candidate failing shapes
  (empirical probe outranks source reading — every HIGH the language
  lenses found came from a probe, not a read).
- Step 7 re-runs the exact CI check for refreshed baselines/fingerprints
  (a stale committed artifact is invisible in the diff; caught a red
  benchmarks arm).
- Step 8 treats version/invalidation constants as review surface
  (INCREMENTAL_SCHEMA_VERSION class recurred verbatim from #2494).

gitnexus-work:
- Step 4 proves regression tests discriminate against the pre-fix tree.
- Step 5 rebuilds executed build output before every verification run
  (parse workers load dist/; a correct fix 'failed' until rebuilt).
- Step 6 makes stage -> detect_changes -> commit one unbroken sequence.

gitnexus-plan:
- Phase 0 seeded-evidence mode: plan FROM a completed review's verified
  findings instead of re-running the graph ladder.
- Template §7: fingerprint/golden-guarded output rebaselines once, at the
  series tip.

All distribution copies resynced; shipped-skills-sync + skills-steering
24/24 locally.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(eval): close the skill-evolution loop with an automated proposer driver

workflow_bench.evolve adds the three arrows the README described as manual:
a proposer session that turns loser trajectories (results.jsonl rows,
transcripts, patches, the learning queue) into ONE bounded candidate
overlay, a driver that iterates propose -> paired benchmark -> deterministic
gate up to --generations, and an --apply step that copies a promoted
overlay onto the canonical skills and shipped mirrors as a working-tree
diff. The trust boundary is unchanged: overlays re-validate through
candidate_overlay_files before any benchmark or apply consumes them, and
committing, CI, and the PR merge stay human.

learnings.jsonl is gitignored: it is machine-local evidence, like the
session transcripts it complements.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(skills): route live-task friction into the evolution learning queue

Each family skill gains a short 'Skill feedback' section: on friction with
the skill's own instructions, append one JSON line to
eval/workflow_bench/learnings.jsonl (GitNexus repo only) — never self-edit
the skill from a live task. The proposer in workflow_bench.evolve consumes
the queue as hints; a learning reaches a shipped skill only by beating the
incumbent on the paired benchmark. All shipped mirrors re-copied byte-
identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci(tests): run the evolve helper tests in the eval pytest job

test_evolve.py needs only pytest+pyyaml, same as the harness tests the job
already runs — without this line the new module had no CI coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(ci): comment-triggered GitNexus review agent for PRs

'@gitnexus review' from a maintainer (OWNER/MEMBER/COLLABORATOR; the action
re-validates write access) runs the repo's gitnexus-review skill headlessly
against the PR and posts the review as a sticky comment — remote triggering
with no local setup. Read-only by construction: contents: read token,
Write/Edit and web tools disallowed, Bash allowlisted to git reads and the
gitnexus CLI; analyze parses PR code with tree-sitter, never executes it.
Requires the ANTHROPIC_API_KEY repository secret; activates once the file
is on the default branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(ci): dispatch lane + existing OAuth secret for the review agent

Align with claude.yml: same action pin and the CLAUDE_CODE_OAUTH_TOKEN
secret the repo already carries — no new secret to configure. Add a
workflow_dispatch lane (PR number input) so the agent can be triggered from
the Actions UI and tested before the issue_comment trigger reaches the
default branch. Allowlist gh pr view/diff and gh api, which the review
skill uses to pin PR SHAs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ci): close a fork-PR RCE vector in the review agent's tool allowlist

A live headless run of the exact workflow session against PR #2431 (66
turns, full gitnexus-review pass) surfaced a real HIGH-severity confused
deputy: .gitnexus/ is gitignored, not blocked — a fork PR can commit its
own .gitnexus/run.cjs, issue_comment checks out PR-head content, and the
skill's runner ladder tries 'node .gitnexus/run.cjs analyze' first. That
would execute fork-controlled JS inside a job holding
CLAUDE_CODE_OAUTH_TOKEN and a write-scoped GITHUB_TOKEN — the opposite of
the 'PR code is read, never executed' claim in the workflow's own header.

Fix: drop the run.cjs allowlist entry so analyze always resolves through
npx gitnexus (npm registry, not the checked-out tree); the skill's
documented fallback mode covers the resulting graceful degradation. Also
drop 'gh api' (not read-only — accepts -X POST/PATCH/DELETE) and downgrade
pull-requests: write to read (comment posting only needs issues: write;
the prompt already forbids formal review submission).

Same session flagged a latent evolve.py bug: select_evidence's cost sort
used dict.get's missing-key default, which doesn't cover an explicit JSON
null in a foreign --seed-results row and crashes proposer setup with
TypeError. Guarded with 'or 0.0' and added a regression test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: harden PR review and evolution trust boundaries

* ci: follow workflow concurrency convention

* fix(eval): make terminating error paths explicit

* fix: unblock hardened review runtime checks

* test: make containment canaries deterministic

* test: expose Claude canary tool failures

* fix: adapt clean shell environment for Claude

* fix(eval): accept the runner's transcript source key in evidence preflight

The proposer evidence preflight required transcript-artifact metadata to be
exactly {path, sha256, bytes}, but the runner stamps a fourth provenance key
(source=parent-captured-stream-json). Any --seed-results or generation>=2 run
therefore aborted with SandboxError before proposing or promoting. Pin the
producer literal as PARENT_EVENT_STREAM_SOURCE and validate it in the metadata
check, and round-trip real producer output through sum_sessions into the
preflight so the schema can't drift again.

* fix(eval): treat an unmeasured session cost as unavailable, not $0

well_formed validated only the nested usage block, so an otherwise-successful
session missing total_cost_usd was recorded as cost_usd=0.0 — and cost_usd is
the default promotion metric (lower wins), so a cost-less session scored as
free and could win promotion it never earned. Extract cost via measured_cost()
(None on absent/garbage, a measured 0.0 preserved), propagate None through
sum_sessions/aggregate/savings/report, and have the gate refuse to rank on a
metric that was not measured on every run in both arms.

* fix(eval): warn when ranking on the main-loop-only num_turns metric

num_turns comes from the CLI's top-level usage (main-loop session only), like
output_tokens, but selecting it emitted no metric_warning — so a subagent-heavy
candidate could look artificially efficient. Add num_turns to
MAIN_LOOP_ONLY_METRICS and broaden the warning to cover turns.

* fix(eval): fail closed when an overlay adds a file with no committed base

An overlay adding a new .md under gitnexus-{plan,work} passes the structural
overlay checks but has no committed base for committed_destination_base_digests
to bind against, so it raised an uncaught ValueError that crashed the evolve
driver (and runner --candidate-overlay) mid-run. Catch it at both call sites:
evolve reports NOT PROMOTED and exits, runner routes it through parser.error.

* feat(eval): circuit-break the runner sweep on a systemic outage

A sustained upstream outage used to pay out every remaining --timeout window
one session at a time. Track consecutive session/infra/cleanup failures via a
pure systemic_outage_streak helper; after --outage-streak (default 5) in a row,
stop the sweep, still write report.md/promotion.json from partial evidence, and
exit non-zero so evolve.py halts instead of proposing from truncated evidence.
A task's own resolved=False never trips the breaker.

* fix(cli): report a dirty working tree as stale in gitnexus status

status --json (and the human output) computed up-to-date from commit + runner
identity + completeness only, so a repo with uncommitted source changes at a
matching HEAD was reported up-to-date while analyze would still re-index it.
A graph-backed agent gating on that JSON could skip re-analysis on a stale
graph. Extract analyze's dirty-tree check into a shared isWorkingTreeDirty()
in storage/git and fold it into the status freshness decision.

* fix(ci): use single-slash deny globs in the review agent's disallowedTools

github.workspace already expands to an absolute path, so Read(/${{ github.workspace }}/**)
and Read(//proc/**),(//sys/**),(//dev/**) produced double-slash patterns that a
normalizing matcher may not match — silently no-opping the deny layer. Not
exploitable (the allowlist is the primary control and never grants those
paths), but the globs should be well-formed. Update the pinned test strings.

* ci: install gitnexus-shared with npm ci from the committed lockfile

The gitnexus-shared build floated its deps via npm install in three workflows
(skill-sync, ci-tests, and — most importantly — the release publish.yml) while
every other install step uses npm ci. The lockfile is committed and in sync, so
switch all three to npm ci for reproducible, locked installs.

* test(cli): make the shipped-skills drift guard reject symlinks

listFilesRecursive walked with readdirSync and snapshotDir read with
readFileSync, both of which follow symlinks — so a mirror file symlinked to the
canonical tree passed the byte-compare (and a symlinked mirror dir would be
followed too). Reject a symlinked root via lstat and any symlinked entry via
Dirent.isSymbolicLink, with negative tests (skipped on Windows).

* test(eval): guard the candidate-skill vs mirror-root coverage invariant

MIRROR_SKILL_ROOTS omits the Cursor tree, safe only because no candidate skill
is cursor-shipped. Pin that invariant: every CANDIDATE_SKILLS entry must exist
under canonical + every mirror root and must not ship to Cursor, so adding a
cursor-shipped skill to the candidate set (the PR #2488 asymmetric-sync class)
fails loudly instead of syncing three of four trees.

* docs(ci): describe the review agent's staged post-merge rollout

The DoD asked for a dry-run or triggered run before merge, but an issue_comment
(or newly added workflow_dispatch) workflow only ever executes the default-branch
copy, so it cannot be exercised from the PR that introduces it. Reword the DoD
and the activation checklist to a staged rollout: merge registered-but-disabled,
validate same-repo and fork execution post-merge, then enable the variable.

* fix: pin plugin skill mcp.json to the release version via #2445 tooling

The ten plugin skill mcp.json launched `npx -y gitnexus@latest mcp` on every
skill connect — non-reproducible and a supply-chain surface, and (unlike the
persisted setup config) never pinned. Extend sync-plugin-manifests.mjs with an
mcp surface kind that stamps the gitnexus@<version> launch arg, pin all ten to
1.6.9 now, and keep them byte-identical so the drift guard stays green. The
release lifecycle + publish.yml --check now re-stamp them like the four manifest
surfaces; only READMEs stay on @latest as docs.

* test(eval): prove the proposer's built-in file tools are confined

The real-Claude canary only exercised Bash + MCP, so it proved process/MCP
containment but not that the proposer's built-in file tools stay inside their
mounts. Add a canary over the exact PROPOSER_ALLOWED_TOOLS surface and the same
read-only /evidence mount as run_proposer (allowlist extracted to a shared
constant so it can't drift): Read reaches /evidence, a Write into the read-only
evidence mount is denied, and a Write lands in the output tree.

* fix(eval): apply the candidate overlay after task setup for fair arms

The candidate overlay was applied before the task's untrusted setup ran, so
setup could observe candidate prose and the incumbent/candidate arms started
from different pre-overlay state. Reorder within the sandbox: capture the base
(pre-overlay) skill digest, run setup against the base skills, verify setup did
not tamper them, then apply the overlay and capture the post-overlay digest the
model must preserve. apply_candidate_overlay stages path-specific overlay files,
so setup's uncommitted changes stay out of the baseline and churn is unchanged.

Graph freshness for the review arm is handled by the status dirty-tree fix plus
the review skill's stale-triggered re-index, not by reordering the cached
per-task-sha graph materialization (which is mechanically blocked).

* test(eval): end-to-end containment proof of the autonomous proposer

Drives the real run_proposer through bubblewrap with a deterministic scripted
model (no paid API): it reads the read-only evidence bundle and writes a
candidate gitnexus-plan skill edit plus a rationale into the sandbox output
tree; run_proposer enforces the trust boundary and copies only the validated
overlay + proposal out. This exercises the autonomous-proposal stage of the
self-evolution loop end-to-end in the eval/containment CI job (the gate and
apply stages are covered by test_workflow_bench_evolution and
test_promotion_apply). Env-gated on GITNEXUS_REQUIRE_CLAUDE_CANARY, so it runs
only where the pinned Claude binary and user namespaces are available.

* fix(eval): let the proposer author its overlay via Bash

Running the end-to-end proposer canary in the containment CI job surfaced a real
bug: run_proposer starts the session with --bare, which hard-disables the
Write/Edit tools ("Write exists but is not enabled in this context"), yet
allowlisted Edit/Write and omitted Bash. The proposer therefore had no working
way to write its candidate overlay — the self-evolution loop could never produce
a candidate. The sandbox settings already pre-authorize Bash
(autoAllowBashIfSandboxed) and confine writes to workspace/tmp/home, so switch
PROPOSER_ALLOWED_TOOLS to Read/Grep/Glob/Bash and tell the proposer to author
files with Bash. The end-to-end test now drives the real run_proposer through
bubblewrap and asserts a validated overlay + proposal are produced (this also
replaces the earlier file-tool canary, whose Write/Edit premise was moot).

* test(eval): author the proposer overlay with newline-free Bash content

The nested shell-sandbox prefix mangles embedded newlines, so the multi-line
overlay content never landed. Use single-line content for the deterministic
proposer canary.

* test(eval): drop the unverifiable end-to-end proposer canary

The scripted proposer overlay never materialized in the containment job across
runs, and the model tool-result content is not visible in CI logs, so the test
cannot be finalized without an environment where the sandbox can actually run.
Keep the verified production fix (Bash-authoring in run_proposer); the proposer
sandbox/containment stays covered by the existing Bash+MCP and process-tree
canaries.

* test(cli): drop run-analyze.ts from the windowsHide spawn-family list

U7 moved run-analyze.ts's only child_process call (the git status --porcelain
dirty check) into storage/git.ts (already covered by this test, with
windowsHide). run-analyze.ts no longer imports a spawn-family function, so the
windowsHide-regression test's 'must have >=1 spawn call' invariant failed for
it. Remove it from SRC_FILES.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Zander Raycraft <zanderjraycraft@gmail.com>
Co-authored-by: Azizur Rahman <azizur100389@gmail.com>
2026-07-19 15:07:24 +01:00
dependabot[bot]
e2e9254938
chore(deps): bump github/codeql-action/upload-sarif (#2535)
Bumps the codeql-action group with 1 update: [github/codeql-action/upload-sarif](https://github.com/github/codeql-action).


Updates `github/codeql-action/upload-sarif` from 4.36.2 to 4.37.0
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](8aad20d150...99df26d4f1)

---
updated-dependencies:
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: codeql-action
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-17 13:21:25 +01:00
dependabot[bot]
0656099332
chore(deps): bump docker/metadata-action from 6.1.0 to 6.2.0 (#2536)
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 6.1.0 to 6.2.0.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](80c7e94dd9...dc80280410)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-17 13:21:05 +01:00
dependabot[bot]
731ab6f512
chore(deps): bump marocchino/sticky-pull-request-comment (#2537)
Bumps [marocchino/sticky-pull-request-comment](https://github.com/marocchino/sticky-pull-request-comment) from 3.0.4 to 3.0.5.
- [Release notes](https://github.com/marocchino/sticky-pull-request-comment/releases)
- [Commits](0ea0beb66e...5770ad5eb8)

---
updated-dependencies:
- dependency-name: marocchino/sticky-pull-request-comment
  dependency-version: 3.0.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-17 13:20:48 +01:00
dependabot[bot]
dc993a6d43
chore(deps): bump github/codeql-action/analyze from 4.36.2 to 4.37.0 (#2506)
* chore(deps): bump github/codeql-action/analyze from 4.36.2 to 4.37.0

Bumps [github/codeql-action/analyze](https://github.com/github/codeql-action) from 4.36.2 to 4.37.0.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](8aad20d150...99df26d4f1)

---
updated-dependencies:
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* ci(codeql): keep action steps in lockstep

Co-authored-by: azizur100389 <azizur100389@gmail.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: azizur100389 <azizur100389@gmail.com>
2026-07-17 11:40:51 +01:00
Gergo Magyar
573a777ef5 ci(tests): widen the Windows shard watchdog and keep exit diagnostics (#2449)
The busiest Windows platform shard reached 14m57s against the 15 minute
watchdog on the rc.19 green run and has timed out once since. CI now
sets GITNEXUS_CROSS_PLATFORM_TIMEOUT_MINUTES=20 (the job timeout stays
25), the stale comfortably-under comment reflects reality, and the
runner always logs status, signal, spawn code and elapsed time so the
next status-null death is diagnosable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 12:29:01 +01:00
Gergo Magyar
42de243e9a ci(release): sync plugin manifests on every version bump (#2445)
The RC path bumped only gitnexus/package.json, so every v1.6.10-rc tag
through rc.28 shipped the four plugin manifest surfaces frozen at 1.6.9
and failed its own unit suite. The npm version lifecycle script now
runs a fail-closed sync whenever npm version executes, in CI or on a
maintainer's laptop; publish.yml verifies the result and stages the
surfaces into the detached release commit, and the stable path refuses
to publish a tag whose manifests drifted. The sync is textual so a
release commit carries a one-line change per surface instead of
reformatting churn.

Design follows the proposal by @100yenadmin in #2445, moved onto the
standard npm version hook.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 12:29:01 +01:00
dependabot[bot]
40f7502370
chore(deps): bump docker/setup-buildx-action from 4.1.0 to 4.2.0 (#2500)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 4.1.0 to 4.2.0.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](d7f5e7f509...bb05f3f551)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-16 07:03:41 +01:00
dependabot[bot]
7edf6236b5
chore(deps): bump docker/login-action from 4.2.0 to 4.4.0 (#2507)
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.2.0 to 4.4.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](650006c6eb...af1e73f918)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-16 07:03:18 +01:00
dependabot[bot]
76c61bed49
chore(deps): bump dorny/paths-filter from 4.0.1 to 4.0.2 (#2505)
Bumps [dorny/paths-filter](https://github.com/dorny/paths-filter) from 4.0.1 to 4.0.2.
- [Release notes](https://github.com/dorny/paths-filter/releases)
- [Changelog](https://github.com/dorny/paths-filter/blob/master/CHANGELOG.md)
- [Commits](fbd0ab8f3e...7b450fff21)

---
updated-dependencies:
- dependency-name: dorny/paths-filter
  dependency-version: 4.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-16 07:02:56 +01:00
Eva
e3136f593f
ci: update setup composites to setup-node v6 (#2451)
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-07-14 17:11:26 +01:00
dependabot[bot]
3e38cd0eb5
chore(deps): bump docker/setup-qemu-action from 4.1.0 to 4.2.0 (#2408)
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 4.1.0 to 4.2.0.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](06116385d9...96fe6ef7f3)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: 4.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-09 06:53:19 +01:00