PINNED_GITNEXUS_VERSION did not select a runtime — the sandbox already
mounts gitnexus/dist built from the checkout by the workflow's own
`npm run build`, so the benchmark has always run main, not a release. The
constant only asserted that the checkout's package.json said "1.6.9" and
raised SandboxError otherwise.
That makes it a tripwire aimed at the wrong thing. main is 1.6.9 today,
so it passes; the next release bumps it and every skill-evolution run
hard-fails until someone edits this line — discovered on a Saturday, on a
lane that runs unattended once a week, after the box has already been
started and the proposer session paid for.
What the constant was guarding is still guarded, and better: the sandbox
canary now checks the version reported from inside the sandbox against
the checkout's own package.json, so it still proves the mounted runtime
is the one this checkout built, without a literal that has to be
maintained in lockstep with releases. The digest bindings in
promotion.json (sandbox_dependency_content_digest, graph and oracle
digests) remain what actually pins the substrate a candidate was measured
against.
The remaining check keeps package.json readable and versioned, so a
malformed runtime still fails closed.
* fix(schema): declare the full scope-resolution relation cross product (#2792)
`RELATION_SCHEMA` was hand-listed, and every prior fix added only the
FROM/TO pair named in a crash report — `Const→Method` in #2769, the
Swift/Rust member pairs before it. So `analyze` kept aborting at
`assertDeclaredPair` on the next codebase whose edges happened to land on
a different pair; #2792 reports `Class→Variable` on Java.
Audit the surface instead of the symptom. `buildGraphNodeLookup` skips
any node whose label is not in `isLinkableLabel`, so the lookup holds
only linkable-labelled nodes — and both endpoints of every graph-bridge
edge resolve through that lookup. The emittable surface is therefore
exactly:
FROM LINKABLE_LABELS + File (the module-level caller fallback)
TO LINKABLE_LABELS + CALL_TARGET_TYPES
`isCallerAnchorLabel` is a strict subset of linkable and contributes
nothing on top. `CALL_TARGET_TYPES` contributes `Delegate`, which
`tryEmitEdgeWithExplicitTargetId` can emit without going through the
lookup at all.
Generate that 14x14 block into the DDL rather than listing it: 223 -> 322
declared pairs, and no future pair from these sets can be missing by
construction. The containment/inheritance/DI/route/cluster/PDG pairs stay
hand-declared — no single predicate describes them.
Both label sets live in the ingestion layer, which `core/lbug` must not
import, so schema.ts carries twin lists. test/unit/schema-pair-coverage.ts
derives the requirement from the originals and fails CI when either set
grows without the pairs landing here — the piecemeal loop this fix ends.
Measured before widening: at 322 pairs the cost is inside noise
(1.09s vs 1.12s per 300 anchored queries on a 32-table DB), but the full
32x32 cross product is ~1.8x on untyped-endpoint anchored queries. The
audited subset is the right scope, not "declare everything".
INCREMENTAL_SCHEMA_VERSION 34 -> 35: LadybugDB fixes endpoint pairs when
the rel table is created, so a pre-v35 database physically cannot store
these edges.
Closes#2792
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(schema): declare the non-bridge structural pairs COBOL and Vue emit
The generated scope-resolution block closed the half of RELATION_SCHEMA a
label predicate can describe. The hand-declared half was still stale: with
#2791's Function->Variable fix applied, `analyze` continued to abort on this
repo's own test/fixtures/lang-resolution with
Relationship label pair Module→Property is not declared
A full sweep (assertDeclaredPair patched to log-and-skip, run over the whole
fixture corpus) found 13 undeclared pairs over 106 edges. This branch already
covered 3 of them via the cross product; the remaining 10 come from emitters
outside the graph bridge:
- cobol-processor.ts mints Module / Namespace / Record / Property /
CodeElement and wires them with CONTAINS, CALLS and ACCESSES (9 pairs)
- vue-sfc-extractor.ts emits BINDS_EVENT_HANDLER from a handler Function to
the child component's File, the only edge whose target is a File (1 pair)
CodeElement, Namespace, Record and File are in neither scope-bridge label set,
so neither the generated block nor schema-pair-coverage.test.ts can reach them.
Adds test/integration/structural-pair-coverage.test.ts, which derives the
requirement from a corpus instead of a predicate: it runs the real pipeline
over the non-bridge fixtures and requires every FROM/TO pair they produce to
be declared. Mutation-checked — dropping `FROM Function TO File` fails it with
exactly Function|File.
Verified: cobol-app, vue-basic and php-transitive-traits now index instead of
aborting; the full lang-resolution corpus completes at 10,876 nodes / 18,517
edges; scrypster/muninndb at 0b7a4272 (the #2789 repro) completes at 20,069
nodes / 71,580 edges, matching #2791 exactly, so this supersedes that PR.
* refactor(test): simplify the structural pair coverage guard
Cleanup pass over the previous commit. No behaviour change to the schema.
- reuse `FIXTURES` and `runPipelineFromRepo` from resolvers/helpers.ts instead
of re-deriving the fixture root and importing pipeline.js directly
- gate on `distWorkerExists()` like every other integration test that passes
`workerUrlForTest`, so a missing dist skips rather than fails
- run the three fixtures with `it.concurrent.each`; they share nothing and the
cost is almost all worker spawn plus grammar load, which overlaps well
(tests phase 21-24s -> 5.6s measured)
- replace the sentinel-in-a-Set filter with a plain `.filter()` chain, matching
the sibling unit test, and move the declared/table lookups off the per-edge
path onto the deduped set
- move the pure string pin out of the integration tier into
schema-pair-coverage.test.ts, where the identical construct already lives, so
it needs no build and survives fixture deletion
- trim the schema and test prose that restated the code, and correct the
BINDS_EVENT_HANDLER attribution: it is emitted by
languages/vue/scope-resolver.ts, not vue-sfc-extractor.ts
- amend the v35 comment to mention the 10 structural pairs it now also stamps
Still mutation-checked: dropping `FROM Function TO File` now fails both the
integration sweep and the unit pin with exactly Function|File. 89 tests green.
* fix(schema): generate the attachment pair surface and close four analyze aborts
Review of the generated scope-bridge cross product found four `analyze`
hard-aborts still live at head, each reproduced end-to-end on the default
user path (`analyze --index-only --skip-git`):
Method→Annotation Spring `@Bean` + `@ConditionalOnMissingBean` (Java + Kotlin)
Method→File Vue Options-API `methods:` handler bound to a child event
Namespace→Record COBOL `DECLARATIVES` / `USE AFTER STANDARD ERROR ON <file>`
Class→Tool `@mcp.tool()` applied to a class
All four are pre-existing on main, and both existing guards were structurally
blind to them: the unit guard derives from LINKABLE_LABELS ∪ CALL_TARGET_TYPES
(none of Annotation/Tool/Record/File-as-target is a member) and the corpus
guard ran three fixtures that exercise none of these emitters. All 16 tests
passed while all four crashes were live.
The PR's model — "bridge endpoint × structural endpoint" — does not fit:
Namespace→Record is structural on both sides. The property that does hold is
that the ANCHOR is a lookup result, not a literal at the emit site, so the
emitter cannot constrain its label. That gives a second closed-form rule:
DEFINITION_ANCHOR_LABELS × ATTACHMENT_TARGET_LABELS
DEFINITION_ANCHOR_LABELS is derived from NODE_TABLES by subtraction, so a new
node table joins automatically. 332 → 450 declared pairs.
Sized against a committed harness (gitnexus/bench/schema-pairs), real
@ladybugdb/core, identical data: 450 costs 0.93–1.05× of 332 on untyped-endpoint
anchored queries — inside noise — versus 1.22–1.43× at 641 and 2.03–2.34× at
1024. The harness reproduces the known #2792 cliff, which is what makes the 450
figure trustworthy.
Also in this change:
- Delete the 161 hand-declared pairs the rules already generate (233 → 72).
The declared set is byte-identical at 450; those lines were load-bearing
shadow, because the generator suppresses anything already declared
structurally, so narrowing a rule later would silently keep pairs alive.
A new guard fails CI if a hand-declared pair is ever re-added inside a rule.
- Import LINKABLE_LABELS / CALL_TARGET_TYPES instead of hand-copying them.
The twins' stated justification ("the ingestion layer must not be imported
here") is false: csv-generator.ts and lbug-adapter.ts, siblings in the same
directory, already do, and no rule in AGENTS.md / ARCHITECTURE.md /
CONTRIBUTING.md / GUARDRAILS.md states otherwise.
- Resolve `resolveStreamGraphEmit` after the guards that rebind `options.force`,
not at function entry. It gates on `force`, and every freshness guard runs
~360 lines later, so the v34→v35 bump would have pushed every existing index
down the non-streamed emit path — losing the #2680 memory streaming added for
the #2649 kernel-scale OOM, for exactly the population most likely to be
memory-constrained.
- `UndeclaredRelationPairError` now carries the relationship type, both node ids
and the source file, with a matching CLI branch. The old message named only
the abstract label pair, which a user could not act on. Found through the
cause chain, since pipeline-phases/runner.ts rewraps every phase failure.
- Share one classifier (`relPairKeyFor`) across the router, both emit sinks and
the corpus guard, which previously hand-mirrored the router's skip rule; one
cause-chain walker in lib/utils.ts; one exported pair-matching regex.
- Corpus guard: four new fixtures reproducing the aborts, per-fixture sentinel
pairs so a fixture that stops emitting fails loudly instead of passing
vacuously on an empty graph.
The per-edge path stays allocation-free: the failure context is passed
positionally and the message is built only inside the throw.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182jkjQqzACkJKYw4MLDnhX
* test(bench): re-baseline the COBOL capture fingerprint for the new fixture
`bench/scope-capture` globs `lang-resolution/cobol-*`, so the
`cobol-declaratives` fixture added in 81daf370e (to reproduce the
`Namespace→Record` analyze abort) joined that corpus and shifted the
fingerprint — 14 → 15 files.
Verified corpus-only, not a capture change: with that one fixture moved
aside the fingerprint is byte-identical to the prior baseline
(d45bb091…), and 81daf370e touches no COBOL capture code. The new value
reproduces CI's reported hash exactly. Scaling 0.677 < 1.5 budget.
`bench/scope-capture/measure.mjs --check` → PASS (15 languages).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182jkjQqzACkJKYw4MLDnhX
---------
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* 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>
The live proof run showed the driver's own lines streaming correctly and
every one of the sweep's 24 cell lines sharing one timestamp
(12:05:29.96) four hours after the sweep began — the exact symptom
echo_stdout was added to remove, still present for the phase that
actually takes the fifteen hours.
`_drain` read with `pipe.read(8192)`. On a BufferedReader that blocks
until it has all 8192 bytes or the pipe closes; it does not return short
reads. A sweep emits a couple of short lines per ~45-minute cell and
never fills 8 KB, so everything sat in the buffer until the process
exited. The tail and the capture were unaffected — they only need the
bytes eventually — which is why nothing caught it before.
The existing echo test could not have: its child wrote one line and
exited immediately, so EOF made `read` return. The new test makes the
child refuse to exit until the echoed line has been observed, so an
implementation that only flushes at EOF deadlocks and fails on the
timeout instead of passing on a technicality. Verified it fails with
`read` and passes with `read1`.
A run spans ~15h and apt-daily-upgrade.timer fires daily around 06:34, so
every scheduled run crosses it. On 2026-08-02 unattended-upgrades upgraded
openssl at 07:54:02 and needrestart restarted the Actions runner five
seconds later. The job went to Canceled 14s after that, and a cancelled
job skips even `if: always()` — so the evidence artifact died with it,
which is the one outcome the rest of this workflow's budget nesting exists
to prevent.
This is a plausible contributor to the unexplained mid-run failures in the
July dispatch cluster, none of which left an artifact behind either.
The box config itself is applied out-of-band like the rest of the instance
setup; the checklist now carries the requirement so a rebuilt box does not
silently reintroduce it.
Every task in tasks.scenarios.yaml names `ref: main`, and resolving it is
the first thing task binding does. actions/checkout only creates a local
ref for the ref it checked out, so `main^{commit}` resolves on a main run
and dies with "unknown revision" on any other — which is what a
workflow_dispatch from a branch hits, before a single session starts.
The step that points the benchmark at the checkout now also makes that
checkout able to answer for the refs the tasks name. On a main run the
fetch is a no-op.
`run_managed` reaps by process group, and cells only ever ran one at a
time before `--workers`. Nothing exercised what happens when a `killpg`
fires while other owned trees are alive — a leaked or shared pgid would
take the siblings down with it, and the sweep would read that as two
more excluded runs, which is exactly what the promotion gate refuses to
decide on.
Three real cells run concurrently: one times out and is force-killed
while the other two are mid-flight with descendants of their own. The
test asserts the victim's descendant never escapes and both siblings
still finish with their output intact.
This is the part of the concurrency change reachable without a real
sandbox — bubblewrap needs unprivileged user namespaces, which the
container this was written in denies, so the bwrap canaries stay skipped
here and run in the named Ubuntu CI job.
`ManagedProcessError.__str__` embeds up to 1000 raw bytes of stderr_tail
(process_control.py:72-73), and run_cell printed it verbatim. That line
was inert until this branch: nothing ever printed the sweep subprocess's
stdout, on success or failure. `echo_stdout` streams it live into the job
log, so the print became a sink — and the only one of its kind here that
skipped `redact_text`, which results.jsonl and every transcript already
apply to exactly this field, for exactly this reason.
GitHub masks the registered secret, but masking only catches that literal
value; it is not the guarantee the other sinks have.
The test drives a ManagedProcessError carrying the token in stderr_tail
and asserts it never reaches stdout — verified to fail without the fix.
Also from the same pass: bind `result_indexes[0]` once in run_claude, and
give the workflow contract test a `findStep` helper instead of five
copies of the same `steps.find` predicate.
* fix(swift): preprocess indented conditional directives so class bodies survive parsing
* fix(swift): make conditional-directive blanking comment-, string- and brace-aware (#2771)
Addresses the review findings on PR #2771. The transform fired
unconditionally, which turned valid Swift into parse errors while missing the
most common shape it was written for.
- The blank/keep decision now consults `blockCommentDepth`, so ` #endif */` —
the result of commenting out a conditional block — keeps its comment
terminator. Previously `hasError` went raw=false -> preprocessed=true and the
rest of the file was swallowed.
- The decision keys on the scanner's brace depth instead of indentation. A
column-0 `#if` inside a class body is blanked (6 of 7 body shapes previously
still lost the enclosing declaration) and an indented file-scope directive is
not — matching what the doc comment already claimed. Bare-CR line endings,
NBSP/ideographic indentation and a leading BOM are recognized too.
- A group is blanked only when every branch is brace-balanced. An `#if`/`#else`
that splits a declaration header leaves one unmatched `{` once both branches
survive, which collapsed five top-level nodes into one and gave unrelated
types fabricated `NetworkClient.` qualified names. Such a group now degrades
to the pre-fix behavior.
- Multiline strings honour `\"""` escapes, and a plain `"""` closes even when a
`#` follows it, so the scanner no longer wedges in string state and silently
stops blanking for the rest of the file.
- The pound run is counted once per position and skipped. It was quadratic:
10.6s for one 64k-`#` line, well inside the 512 KB walker limit.
- Extended regex literals (`#/.../#`) no longer open a phantom block comment.
- Directive-free files return early, matching `stripUeMacros`.
Worker parity: `emitSwiftScopeCaptures` and `emitCppScopeCaptures` re-apply
their provider's `preprocessSource` on the parse-cache-miss path — Dart already
did this — and the embedding parse in `ensureAndParse` applies the hook as
well. Before this the worker and the scope-capture/embedding halves analyzed
different programs, turning a consistent degradation into cold-run/warm-run
non-determinism. A new parity test pins the equivalence for every provider that
defines the hook.
SCHEMA_BUMP 37 -> 38: this changes parse semantics, the chunk key hashes raw
on-disk bytes, and `preprocessSource` runs after the key is computed — so a
same-package-version warm cache would replay pre-fix Swift results verbatim,
including across `--force`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(ingestion): apply preprocessSource once in the scope bridge (#2771)
Follow-up cleanup on the review fixes. The previous commit re-applied each
provider's `preprocessSource` inside `emitSwiftScopeCaptures` and
`emitCppScopeCaptures`, mirroring what Dart already did — three copies of the
same rule, and a contract that asked every future emitter to remember it.
`extractParsedFile` is the single funnel every `emitScopeCaptures` caller
passes through (parse worker, scope-resolution run, Vue script extraction), and
it already receives the provider. Applying the hook there on the cache-miss
path covers all three languages and every future one, names no language in
shared code, and drops Dart's unconditional transform on the cache-hit path.
Verified the three emitters use `sourceText` for nothing but the parse, so the
substitution is output-identical — which the parity test asserts directly.
Also from the cleanup pass:
- the parity test derives its language list from the provider registry, so a
new provider adopting the hook fails until it adds a fixture
- `ensureAndParse` resolves the provider from the language it already computed,
instead of a second extension table (`getProviderForFile`)
- the preprocessor returns `sourceText` unchanged when no group was blanked,
which is the common case for files whose only directives are top-level
- `split(/(\r\n|\n|\r)/)` replaces the hand-rolled line splitter, and the
per-group brace bookkeeping is two scalars instead of an array
- the hint regex is derived from the line regex so the two cannot drift
- unit assertions compare the WHOLE preprocessed file against the expected
blanking, replacing per-line spot checks; the pipeline tests share one
`runFixture` helper and `getNodesForFile` in the resolver test helpers
- `LanguageProvider.preprocessSource` documents the real call sites and says
plainly that the set is not closed — `populateRangeBindings` still hands
language helpers raw text
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(autofix): apply prettier + eslint fixes via /autofix command
---------
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
The upload step needs a path that does not depend on the sweep step
surviving. It did not need shared state to get one: `runner.temp` is
available in a step, only not in a job-level `env:`, so each of the three
consumers can name `${RUNNER_TEMP}/wfevolve` itself. That deletes the
env var and the step that published it — the previous fix swapped one
threading channel for a sturdier one where no channel was required.
Also from the same review pass:
- `announce`/`keep` drop their default-argument capture of `task["id"]`
and `per_arm`. Late binding only bites a closure invoked after the loop
moves on; these are called synchronously inside `sweep_task_cells`,
which blocks until every wave completes. The trick was guarding against
a race that cannot happen here, while implying to the next reader that
it can.
- `_stub_cell_dependencies` returns the list its teardown appends to
rather than taking it as an out-parameter, dropping the boilerplate
from every call site.
- The workflow's `WORKERS` comment points at the `--workers` help text
instead of restating it, so the rationale has one home.
* fix(group): preserve manifest-only impact crossings
Keep proven manifest cross-repo hits when the far endpoint has no concrete graph symbol, avoiding a guaranteed failed UID fan-out.
* fix(group): verify manifest-only neighbor repos
Keep manifest-only crossings from bypassing neighbor repository resolution so unavailable repos still surface as truncated fan-out.
* fix(group): distinguish boundary-only impact crossings
Keep manifest-only boundaries visible without treating unattempted fan-out as completed impact or escalating risk, and cover service scope, deduplication, and real bridge persistence.
---------
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
`${{ runner.temp }}` does not exist in a job-level `env:` block — the
runner context is only available to steps — so OUT_ROOT would have
resolved to a bare `/wfevolve` at the filesystem root. The sweep would
have failed writing there, and the upload would have pointed at nothing.
actionlint caught it; this repo lints workflows for exactly this reason.
The property that mattered is kept: the path is fixed before anything can
fail, rather than read from the sweep step's outputs — that being the
step whose death is the reason the upload matters. The first step now
publishes it to GITHUB_ENV, which every later step sees, including the
`if: always()` upload after a killed sweep.
The contract test pins the step's position and its exact line, so the
context cannot creep back into the job block.
`--workers` reaches the sweep from evolve.py and from a workflow_dispatch
input. Both default to 1, so nothing about the scheduled lane changes:
the runner is sized for one cell at a time, and a cell starved of CPU
drifts toward its session timeout, which the gate counts as an excluded
run and refuses to decide on.
generation_timeout_seconds is left alone deliberately — it is a
worst-case sum-of-every-timeout bound (843h at current settings), already
far looser than any real run, and concurrency only makes it looser.
Raising the input is gated on the runner resize; the contract test pins
the default so the lane cannot start running 3-way on a 2-vCPU box by
accident.
18 cells at ~48 min each, strictly serial, is 97.4% of a generation's
14.7h. The cells are independent — the wall clock was a scheduling
choice, not a measurement requirement.
`--workers` (default 1) runs the cells of one task concurrently; tasks
stay sequential, so the sanitized graph snapshot each task already builds
before its cells stays a single-writer affair. Threads, not processes:
cells are subprocess-bound and `run_managed` keeps every piece of
ownership state local to its own call, so nothing is shared to race on.
Waves, not one fan-out. The outage breaker counts CONSECUTIVE systemic
failures, and "consecutive" means nothing in completion order — folding
as futures landed would make the trip point flaky between identical runs.
Each wave is folded in submission order once complete, and the next wave
starts only if the breaker held, so the breaker overruns by at most
`workers - 1` cells (the ones already in flight) rather than by a whole
task.
`--workers 1` calls the cell directly rather than using a pool of one.
That is not an optimisation: an async KeyboardInterrupt is delivered only
to the main thread, so a cell on a worker thread is outside the reach of
the ownership cleanup that kills its sandboxed process tree. The default
therefore stays exactly what it is today, Ctrl-C included, and above 1
the flag's help says what is given up.
All bookkeeping stays on the main thread — the results.jsonl append, the
per-arm accumulation, the progress prints, the streak fold. No lock is
needed anywhere, the progress counter cannot race, prints do not
interleave, and results.jsonl keeps its canonical order.
Every future is read. An exception a cell did not expect stays parked
inside its Future until something asks for it; unread, a harness bug
would become a silently missing run instead of a crash.
The sweep's innermost body — clone, sandbox, sessions, verify, oracle,
teardown — was ~260 lines of `main()`'s scope, reachable only by running
the whole sweep. Nothing tested it, and it could not run anywhere except
that loop.
`run_cell(ctx, run_idx, arm)` now owns one (run, arm) cell and returns
its row; `TaskCellContext` is a frozen dataclass holding the per-task
inputs a cell reads, so a cell depends on named fields rather than on
whatever `main()` happens to have in scope. The try/except/finally moves
verbatim, exception whitelist unchanged: a harness bug still escapes
rather than being recorded as an ordinary infra-error and averaged into
the evidence.
The task asset snapshot is now prepared once per task, next to the graph
snapshot, instead of lazily inside whichever cell reached it first.
`TaskAssetCache` is a plain dict (task_assets.py:222-226,340), so the
lazy build was a read-then-write race waiting for a caller that is not
strictly serial. One behavior delta: when that preparation fails, every
cell of the task now reports the same wrapped RuntimeError, where before
the first cell reported the original OSError/SandboxError/ValueError.
The loop keeps all the bookkeeping — progress counter, prints, the
results.jsonl append, per-arm accumulation, the outage streak. Behavior
is otherwise unchanged; this is the seam, not the concurrency.
Six new tests cover it, the first coverage this body has ever had: the
row's task/arm/run/digest bindings, each of the five expected failure
kinds still removing the clone, an unexpected KeyError escaping while
cleanup still runs, cleanup failure overriding the primary outcome, and a
failed per-task snapshot failing every cell closed.
An end-to-end pass over the lane — instance start, job, artifacts,
promotion — found three ways it loses work that has already been paid for.
**Evidence died with the job.** Three budgets have to nest: EventBridge
keeps the box up 24h from ~02:45, the job timeout was also 1440min, and
the sweep had no budget of its own. A job-level timeout CANCELS the job,
so the upload step never runs; and since the box stops 24h after it
starts while a scheduled run can begin well after the cron (the
2026-08-01 run was queued 65min late), the box always won that race —
the runner would simply vanish mid-step. The job now gets 21h, the sweep
step 19h, so a wedged generation fails the step, keeps the job alive, and
still uploads. The nesting is asserted in the contract test.
**The upload could be skipped.** Its path came from an output the sweep
step wrote — the same step whose death is the reason the upload matters.
OUT_ROOT is now a job-level env constant known before anything runs, and
the upload is unconditional: results.jsonl and transcripts are appended
as the sweep goes, so a killed generation still holds the evidence that
explains why it died.
**The lane was memoryless.** `--seed-results` is how a run sees what
already lost (summarize_gate feeds the prior promotion.json to the
proposer), and with the default --generations 1 there is no earlier
generation in-process to supply it — the workflow never passed it, so
every Saturday proposed from a blank slate and could re-propose the same
rejected candidate forever. The lane now seeds from the last successful
run's artifact, best-effort: a first run, an expired artifact, a missing
gh, or a failed download proceeds without it rather than costing a
generation.
Also guards the silent-promotion path: `.claude/skills/*` is gitignored
with a hand-maintained per-skill allowlist, and `git status --porcelain`
— how the workflow detects an applied promotion — is blind to ignored
paths. A candidate skill missing from that allowlist would report "No
promotion this run" after the gate said promote. A test now asserts every
CANDIDATE_SKILLS entry is visible in all three shipped trees.
* 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>
- `_na` moves next to `measured_cost` in runner_sessions, the function
whose None it renders, so the proposer-progress line stops
reimplementing it inline.
- `evaluate_candidate` derives `ungated_tasks` from the `gated` flag
already on each row instead of accumulating a parallel list, and
reports the carve-out as one aggregate line rather than one per task:
`reasons` is truncated to three entries when it is fed back to the
proposer (summarize_gate), and a growing set of unsolvable tasks must
not crowd out why the candidate actually won or lost.
- run_claude cuts the event window at the result event, so "nothing after
the result is evidence" is a property of what the readers below can see
rather than an assumption that a `system` event never carries a
tool_use block.
- The teardown test builds its stream with the existing `event_stream`
fixture instead of re-joining the prefix by hand.
The gate demanded that the candidate resolve every valid run of every
selected task, regardless of how the incumbent scored. inv-feature-list-
repos-filter fails its hidden oracle on 100% of runs in both arms, so the
quality floor could never be met while it stayed in the set — and its
cost comparison (which ranks who spent more while failing the same
oracle) also fed the per-task regression cap and the median. One task
outside both arms' capability was silently vetoing every future
promotion.
A task both arms measured cleanly — at least min_runs valid runs, zero
exclusions — and that neither ever resolved carries no signal about the
candidate. It is now reported in the decision (`gated: false`, plus an
`ungated_tasks` list and a named reason) and left out of the floor, the
regression cap, and the median. It still runs, and its failures still
feed the proposer as evidence: an unsolved task is the loop's target,
not its veto.
The floor is unchanged everywhere it has signal. A candidate that goes
2/3 where the incumbent goes 1/3 is still rejected as unreliable, and a
generation where NO task resolved anywhere is now `insufficient_evidence`
rather than an efficiency verdict over runs that all failed.
promotion.json goes to schema_version 4 — task rows changed meaning, and
a stale v3 binding must not be applied under the new rule.
Run 29907431284 printed its whole 14h45m of output at one timestamp
(00:02:59.32) as the process exited: stdout is a pipe, so CPython
block-buffered it, and there was no way to tell a live run from a wedged
one. Three changes make the lane observable in the Actions log:
- PYTHONUNBUFFERED for the driver (workflow step) and for the benchmark
subprocess (its env is an explicit minimal dict and inherits nothing),
so lines reach the log when they are written.
- run_managed grows `echo_stdout`, a passthrough that streams a child's
stdout to stderr as it arrives while leaving the bounded tail intact.
evolve.py enables it for the benchmark sweep — the multi-hour phase,
whose per-run lines previously surfaced only as a tail, and only on
failure. It stays off everywhere else: a Claude session's stdout is the
evidence stream and is written out only after redaction.
- The sweep now announces each cell as it starts (`3/18, 47m elapsed`)
and reports `took=` and `error_kind=` when it finishes, so an excluded
run — the thing that actually blocks promotion — is visible live
instead of only in results.jsonl. evolve.py also reports the proposer's
duration, turns, and cost once the proposal lands.
The evolution loop has not been able to promote anything since it went
online. Run 29907431284 (the last green run) reached the gate and threw
away 5 of its 18 runs, and the gate requires zero excluded runs in both
paired arms — so the generation could never produce a verdict on merit.
Two causes, both in the session layer:
1. Claude Code drains background-task bookkeeping after the final result
event (`background_tasks_changed`, `task_updated`, `task_notification`,
all `type: "system"`). The parent-stream check required the result to
be the literal last event, so three sessions that had exited 0 with a
complete result and usage payload were recorded as session errors.
Trailing `system` events carry no tool_use/tool_result/usage payload
and cannot forge skill or cost evidence; anything else after the
result still fails closed.
2. The 3600s per-session ceiling killed two `workflow` incumbent runs on
inv-bug-pdg-note mid-verification. Successful `workflow` rows in the
same run finished in ~1600-2600s across both sessions, so the ceiling
moves to 5400s and now lives in one shared constant instead of two
argparse defaults that could drift apart.
Also marks the activation checklist against reality: the secrets, the
Environment, the runner, and the validation dispatch are all in place;
the repository variable GITNEXUS_EVOLUTION_ENABLED is the one remaining
gap, and until it is set the Saturday cron skips the job in seconds while
the EventBridge schedule still starts the runner for the day.
* feat(scope-resolution): make MAX_CALLABLE_VALUE_TARGETS configurable via env
The branch's original commit was a whole-file snapshot taken at a stale base
and never touched the constant, so the env read was missing and the branch's
own test failed. Implemented here, matching the sibling
GITNEXUS_MAX_PROPERTY_DISPATCH_FANOUT knob.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(callable-value-flow): add env override tests
* docs(callable-value-flow): document GITNEXUS_MAX_CALLABLE_VALUE_TARGETS env
Adds a Troubleshooting subsection to README.md and a commented entry to
gitnexus/.env.example for the new per-callable-site dispatch-target cap
(default 32), following the maintainer's review request to document the
knob alongside its implementation.
---------
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Ubuntu <ubuntu@localhost.localdomain>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
* fix(schema): declare Swift member-containment pairs in CONTAINS DDL
* fix(schema): declare remaining Rust impl/trait and JS/TS object-literal HAS_METHOD pairs; guard streamed emit sinks against undeclared pairs (PR #2769 review)
* refactor(schema): share one declared-pairs constant across router and sinks
DECLARED_REL_PAIRS was being computed independently in three places
(csv-generator.ts, graph-emit-sink.ts, pdg-emit-sink.ts) from the same
static RELATION_SCHEMA parse. Export the existing constant from
csv-generator.ts (already imported by both sinks) instead.
assertDeclaredPair now takes the pre-built pairKey rather than the two
labels, since every caller (RelPairRouter.route, both sinks' addRelationship)
needs that same key immediately after for its own Map/stream lookup on the
per-streamed-edge hot path — avoids rebuilding the template string twice
per edge.
Also drops two schema.test.ts assertions that duplicated coverage already
in the more narrowly-named regression tests below them, and trims the v32
ladder comment to point at assertDeclaredPair's docstring instead of
re-explaining the same failure mechanism.
* fix(schema): use replaceAll for the pair-arrow error message (CodeQL)
.replace(str, ...) only touches the first match; CodeQL flags that as
incomplete string escaping regardless of the caller's invariant that
pairKey contains exactly one '|'. replaceAll is equivalent here and
silences the alert.
---------
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
* feat(scope-resolution): make MAX_PROPERTY_DISPATCH_FANOUT configurable via env
* test(property-dispatch): add env override tests
* docs(scope-resolution): document GITNEXUS_MAX_PROPERTY_DISPATCH_FANOUT in README and .env.example
Add a dedicated troubleshooting subsection and .env.example entry for the
GITNEXUS_MAX_PROPERTY_DISPATCH_FANOUT environment variable, matching the
format used by the sibling GITNEXUS_MAX_CALLABLE_VALUE_TARGETS knob.
Closes maintainer request: "Could you please document this in the readme
plus the .env.example?"
---------
Co-authored-by: Ubuntu <ubuntu@localhost.localdomain>
* fix(jvm): bound same-package sibling injection
* docs(jvm): document sibling injection cap
* fix(jvm): mark truncated sibling sets incomplete and bound the merge
Review follow-ups on the sibling injection cap (#2732):
- The cap silently produced a third visibility state. Before it, a file was
either fully visible (package under 500 files) or fully incomplete; once
`injectedIds.size` hit the cap, real siblings were dropped while
`isVisibilityIncomplete` still returned `false`. That flag gates wildcard
attribution in seven Spring passes (`bean-candidates.ts:199` and the java/
kotlin bean-metadata, conditionals, config-bindings and DI resolvers), so
201-500-file packages — exactly this cap's population — resolved wildcard
annotations against a truncated sibling set with no log signal. Truncation
now marks the file incomplete and analyze warns once with the affected file
count.
- The cap only bounded `bindingAugmentations`; the two `typeBindings` merges
below it still absorbed every sibling, so a class excluded from the binding
set could still steer receiver/variable type inference through
`scope.typeBindings`. Both halves now use the same bounded sibling set, and
the merge iterates that set directly rather than filtering a full rescan, so
the cap bounds the work as well as the result.
- Path segments are split once per bucket instead of on every pairwise
proximity comparison — that comparison runs O(files²) per package.
- `JvmPackageFact` was re-declared locally instead of imported from
`package-facts.js`, where the canonical declaration still serves both
languages' facades and capture side-channels. Nothing kept the copies in
sync. Restored the import.
- README/.env.example: `GITNEXUS_MAX_INJECTED_SIBLINGS` does not lift the
fixed 500-file package skip (including at `0`), and truncation disables
wildcard attribution for the affected files. Both are now stated.
* test(jvm): restore the language-facade coverage and pin the cap's behaviour
The cap rewrite replaced the per-language harness with generic fixtures,
dropping the Java/Kotlin capture-side-channel and facade coverage (package fact
extraction, the 500-file skip, fail-closed on a file that produced no
ParsedFile) and leaving a proximity fixture whose candidates were already in
order — so it could not tell a working sort from plain truncation of the input.
Restores that harness and adds cap-specific cases on top, driven through the
shared JVM factory. The fixture interleaves near and distant siblings, so the
retained set is only reachable by a working proximity sort. Covers: the exact
capped set, truncation marking the file visibility-incomplete, type bindings
bounded by the same sibling set, the unbounded `0` override staying complete,
and the documented default of 200 applying when the variable is unset.
Each new case fails against the pre-fix implementation.
---------
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
* fix: serialize global registry mutations
* fix: serialize global registry mutations
* fix: keep registry reads lock-free
* test(registry): document cross-platform lock coverage
* fix(registry): isolate the registry lock's namespace, timeout and diagnostics
Review follow-ups on the global registry lock (#2716):
- The lock took `getGlobalDir()` itself, which is byte-identical to a repo's
index slot when that repo is rooted at the user's home directory (a real
dotfiles layout). `runFullAnalysis` holds the per-repo lock across its whole
pipeline and `acquireIndexLock` is not reentrant, so `registerRepo` /
`adoptFlatBranchLabel` self-deadlocked until the wait ceiling and then failed
the analyze. The registry now locks a private `<globalDir>/registry-lock`
namespace no index slot can ever resolve to.
- The lock inherited the index lock's 10-minute default timeout, sized for
multi-minute analyze runs. `gitnexus augment` — documented to cold-start in
under 500ms and shelled out from editor tool-use hooks — reaches it through
`listRegisteredRepos({ validate: true })`. Registry transactions are
sub-second, so they now get their own 5s ceiling.
- Contention was silent: no `log`/`onWaitStart` was wired, and the primitive's
own texts attribute a wait to "another gitnexus analyze", which misnames a
registry holder. A registry-specific line is emitted on wait start instead.
- On timeout the transaction now proceeds unlocked with a warning rather than
throwing. The lost-update race it guards was unguarded before this branch, so
degrading to the old best-effort behaviour beats failing `analyze`/`list`/
`index` outright — none of which wrap these calls in a handler — on a wedged
lock.
- `adoptFlatBranchLabel`'s recursive `fs.rm` no longer runs inside the lock;
only the closing re-read/mutate/write does, mirroring `clean.ts`, which
deletes the branch directory before calling the locked `removeBranchIndex`.
A slow delete no longer blocks every registry operation on the machine.
* test(registry): cover the remaining locked mutators and the colliding layout
Three of the five functions the registry lock wraps had no overlap coverage, so
a future narrowing of the lock would go unnoticed. Adds:
- overlapping `removeBranchIndex` calls on two branches of one entry,
- an overlapping `unregisterRepo` / `registerRepo` pair on distinct repos,
- a registration issued while an index lock is held on the global directory,
which reproduces the home-rooted self-contention the lock namespace fix
addresses.
Each fails without the corresponding fix: the two overlap tests lose an update
when `withRegistryLock` is bypassed, and the collision test sees the wait
announcement and the degraded-write warning once the lock namespace is reverted
to `getGlobalDir()`. The collision test asserts on those log records rather
than on elapsed time, so it stays deterministic on a slow runner.
* perf(registry): keep the validation walk out of the registry lock
`listRegisteredRepos({ validate: true })` held the global lock across its
read-only validation walk — an `fs.access` per entry, slow on a network mount
or a large registry — even though the common case prunes nothing and writes
nothing. That is the same lock `gitnexus augment` takes on every editor tool
call, so unrelated registry work serialized behind a walk that never touched
the file.
The walk now runs unlocked; the lock is taken only when an entry is provably
gone, and the prune is applied to a snapshot re-read inside it, so a
registration that lands during the walk is no longer clobbered by a stale
write. Same shape as the `adoptFlatBranchLabel` split.
---------
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
* fix(mcp): surface resolved repo/branch/indexed-at in the FTS-degraded warning
Turns the generic "FTS indexes missing" message into a diagnostic
that reveals what this MCP session actually resolved, so a CLI/MCP
mismatch or stale-connection theory is visible in the warning text
itself instead of requiring a separate debugging round-trip (#2767).
* fix(mcp): stop swallowing real FTS query errors behind the missing-index message
queryFTSViaExecutor previously collapsed 'index genuinely missing' and
'a real query/connection error occurred' into the same silent null,
so a real failure could masquerade as the generic FTS-degraded
message with no diagnostic trail — even when it happened on only
some of the per-table queries while others succeeded. Classifies the
failure (mirroring queryFTS's own check for this exact cypher call),
always logs a non-benign error server-side regardless of overall
outcome, and surfaces it (redacted) in the client warning only when
every table failed (#2767).
* fix(mcp): give --repair-fts a dedicated freshness signal for warm readers
--repair-fts intentionally never restamps indexedAt (it doesn't
regenerate the graph), so a long-lived MCP session's pool staleness
check had no explicit signal that a repair happened, only the
incidental file-identity delta. Reuses the existing (forensic-only)
capabilities.fts.status field: repair-fts now stamps just that
sub-field (everything else byte-identical), and ensureInitialized
compares it as a third, independent reinit trigger alongside the
existing stamp/identity checks, seeded at cold init too so a fresh
process's first warm check doesn't false-trigger (#2767).
* test(mcp): warm session picks up an out-of-band --repair-fts rebuild (#2767)
New end-to-end integration test: a real writable LadybugDB session
builds an index WITHOUT FTS, a real LocalBackend observes 'FTS
indexes missing' through the real pool, a separate writable session
performs the exact repair-fts writes (real createSearchFTSIndexes +
the #2767 capability-only meta stamp), and the SAME still-warm
backend re-queries successfully without a restart — closing the one
end-to-end gap no existing test covered.
Running this against the real engine surfaced a second real message
shape for a missing FTS index ("doesn't have an index with name X",
not just "does not exist") that the U2 classifier didn't recognize —
fixed classifyFtsQueryError to match both, with a regression test
pinning the exact observed string.
* fix(review): address code-review findings on the #2767 FTS fix
- Anchor classifyFtsQueryError to the exception class (mirroring
isBenignDropFtsIndexError) instead of a bare substring search, so a
real, differently-classed error that happens to echo the benign
phrase in its body (e.g. an echoed user query) can't be
misclassified as a benign missing-index (adversarial review).
- Re-read the on-disk meta immediately before the --repair-fts
capability stamp write instead of reusing the pre-rebuild snapshot,
so a concurrent writer (e.g. the HTTP server's background embedding
checkpoint job) landing mid-repair isn't silently reverted.
- Surface a client-facing partial-result warning (mirroring the
existing enrichmentDegraded convention) when some FTS tables
succeed but at least one hits a real error, instead of only logging
it server-side.
- Update RepoMeta.capabilities' stale 'no programmatic readers'
docstring now that ensureInitialized reads capabilities.fts.status.
- Widen the warm-session integration test's polling deadline for more
margin over the production 5s staleness-check throttle.
* fix(ci): drop the cold-init loadMeta call ensureInitialized never needed
It stole the mocked loadMeta call an unrelated upstream PDG test
depends on (test/integration/impact-pdg-statement-precise.test.ts
queues a single mockResolvedValueOnce for its own PDG-config read;
the extra call consumed that slot before the PDG code ran, so it
fell through to the mock's null default and epistemic came back
undefined instead of 'pdg-intra-procedural'). Cold init now leaves
lastObservedFtsStatus unseeded — the cost is at most one redundant
initLbug call on the first warm check, which no-ops via a single
fs.stat when nothing actually changed, not a real reopen.
* fix(review): address tri-review findings on the #2767 FTS fix
Fixes two P1s (misleading repair-fts advice on real query errors;
embedding-checkpoint job silently reverting the capabilities.fts stamp
for up to its 30-minute lifetime), five P2/P3s (stale indexedAt in
warnings, extension-unavailable noise, mismatched log severity, a
table-missing vs index-missing conflation confirmed against a live
LadybugDB, and a reinit-watermark latching bug), and the four residual
items already self-disclosed in this PR's description (shared FTS
error classifier, consolidated per-pool observed-state map, a
redactPaths whitespace gap, and an isolated ftsCapsChanged test).
A /simplify pass afterward caught one more real bug: the
extension-unavailable short-circuit only guarded the MCP pool path,
so the CLI-path fix above it started surfacing spurious non-benign
errors for the same expected degraded state the pool path stays
silent on — now both paths agree.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---------
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* fix(ingestion): join multi-line closure bindings on initializer startLine
Graph-node captures sit on the outer binding wrapper while scope-resolution
anchors on the inner callable; the line-only position join missed when those
split across lines and fail-closed dropped the real CALLS edge (#2735).
* fix(ingestion): unwrap Ruby call+block for multi-line lambda joins
Cover Kotlin/Ruby/Dart multi-line closure CALLS in integration tests, and
dig Ruby's call/block field so do-end bindings join on the block start line.
* style(ingestion): format closure join changes
* fix(ingestion): make closure position join language agnostic