fix(group): stop reporting what could not be measured as a measurement of zero (#3012)

* fix: surface unreadable group indexes and escape raw NUL bytes in source

Two independent diagnostics failures, both of which turn a real error into a
confident, benign-looking answer.

**Unreadable member repos (#3011).** `syncGroup` wrapped `initLbug` plus all
contract extraction for each member in a bare `catch {}` that pushed the repo
onto `missingRepos` and discarded the error. A LadybugDB storage-version
mismatch therefore surfaced as "repo not found", `group sync` printed
`0 contracts, 0 cross-links` and exited 0, and the existing contracts.json was
overwritten with an empty registry. The two states need different answers from
the operator — a missing repo must be indexed, an unreadable one is usually
version skew or a lock — so they are now separate:

- the caught error is logged with the repo, group path and lbug path
- `unreadableRepos` is tracked alongside `missingRepos` on `SyncResult`,
  persisted (optionally, so older registries still parse) on `ContractRegistry`,
  and threaded through `GroupService` sync/status
- `group sync` reports both before the cascade counts, since an unread repo is
  the likely explanation for a small or empty count
- `group status` reports unreadable repos separately; calling them "missing"
  actively misdescribed them
- when EVERY configured repo fails to open, the write is skipped: an extraction
  that read nothing is not evidence the group has no contracts, and replacing a
  good registry with an empty one loses data while reporting success

**Raw NUL bytes (#3010).** `sync.ts` and `free-call-fallback.ts` each used a NUL
as a join delimiter, written as a literal 0x00 instead of `\0`. Identical at
runtime, but it makes the file test as binary: `file(1)` reports `data`, ugrep
returns empty with exit 1 — indistinguishable from "no match", with no message —
and BSD grep replaces matching lines with "Binary file ... matches". A search
that should hit comes back as a confident "not present". Both now use the escape,
and a unit test fails on any raw control byte in src/ so it cannot silently
return.

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

* test(hygiene): guard every tracked source file against a raw NUL, not just src/

The guard added with the NUL escapes only scanned gitnexus/src for .ts/.tsx.
Neither prior recurrence of this defect in this repo was in that scope:
b620773b1 was gitnexus/bench/cpp-qualified-ns/measure.mjs and 38d737bb5 was a
fixture under gitnexus/test. A guard that cannot see where the bug has actually
landed twice is not a guard.

Drive the file list from `git ls-files` at the repository root over
.ts/.tsx/.js/.jsx/.mjs/.cjs/.mts/.cts — 2483 files instead of 828 — and split
the byte class, which is the part that matters:

  - 0x00 is a hard failure repo-wide. It is the byte git's binary heuristic
    keys on, so it is the one that costs a file its diff (and, on the base side
    of a PR, its inline-comment anchors and its three-way merge).
  - The wider C0 class stays scoped to gitnexus/src. A repo-wide scan finds
    exactly one hit, test/unit/logger.test.ts:146, and that 0x1b is a
    legitimate ANSI-escape fixture that is the subject of the test. Widening
    this half would go red on day one.

Read Buffers and scan bytes instead of decoding each file to latin1, through a
bounded read pool: 1.5 s for 2483 files, against 8-21 s previously for 828.

Add a negative fixture — a planted 0x00 and 0x1b run through the same scanning
helper — so a future refactor of the collector cannot leave a permanently green
guard, plus an assertion that the collected set still reaches bench/, test/ and
.mjs, which goes red if the scope is ever narrowed back.

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

* fix(group): report a cross-repo impact built from an incomplete bridge as truncated

When a sync cannot read a member repo, that repo's contracts and every
cross-link touching them are simply absent from bridge.lbug. Nothing in the
impact walk could notice: the only incompleteness channel on a
GroupImpactResult is truncationFields(), which is driven by fan-out state
(truncatedRepos / localPartial / fanoutTimedOut), and a repo missing from the
bridge sets none of them.

So `group impact` on a symbol whose one downstream consumer lives in an
unreadable repo returned `{ cross: [], truncated: false }` — "complete: nothing
in another repo depends on this". That is a wrong answer, not an empty one, for
a tool an agent uses to license a delete or a rename.

BridgeMeta now records unreadableRepos alongside missingRepos, writeBridge
persists it when non-empty, and runGroupImpact folds a non-empty
unreadableRepos ∪ missingRepos into truncated / riskEpistemic: 'lower-bound',
naming the repos in truncatedRepos.

The reason is a new 'incomplete-sync' rather than the existing 'partial'
because the remedy differs: 'timeout' and 'partial' are runtime limits the same
query can clear on a retry, while this one clears only when `gitnexus group
sync` succeeds. Runtime limits still take precedence when both apply, since
those are what the caller can act on immediately.

The risk VALUE is never clamped down — mergeRisk is monotone in the traversed
crossing count, so an incomplete bridge can only under-report. Marking the floor
is what makes that legible.

Both shape changes are additive and optional, so a bridge written before this
still reads.

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

* fix(group): say truthfully what a sync did to contracts.json

Review follow-ups to the unreadable-repo diagnostics. Every item below is a
place where the code still answered a question it could not answer.

1. The CLI announced a write it did not perform. `group sync` printed "Wrote
   contracts.json (0 contracts, 0 cross-links)" unconditionally, including on
   the path that deliberately left the file alone. SyncResult now carries
   registryOutcome ('written' | 'preserved' | 'not-attempted'), the CLI prints
   from it, and group_sync returns it so an agent that calls group_sync then
   group_contracts can tell why the counts disagree.

2. Refusing to write anything on total failure threw away the diagnostic
   describing the run that just happened. `group status` reads contracts.json
   from disk, so the operator who saw the sync fail and ran status to find out
   why read the PREVIOUS sync's file: no unreadable list, an old lastSync, a
   healthy-looking group — or worse, the previous run's unreadable list
   presented as this one's. The skip is now targeted: contracts, crossLinks,
   repoSnapshots and generatedAt carry forward verbatim, only missingRepos and
   unreadableRepos are refreshed. generatedAt stays put because it dates the
   contracts, which are still the previous run's. With no prior file, or an
   unparseable one, nothing is written at all.

3. Per-repo extraction is now all-or-nothing. Extractors run in sequence and
   any one can throw; appending each one's results straight to autoContracts
   meant a repo whose HTTP extractor succeeded and whose gRPC extractor then
   failed contributed a partial set to the registry, while the same run told
   the operator that repo's "contracts are omitted from this sync".

4. readRegistry gains an opt-in strict mode, and syncGroup uses it. The lenient
   `catch { return []; }` converted "I could not read the registry" into "no
   repo is registered": every configured repo then resolved to MISSING, the
   total-failure guard stayed off (it needs a load error), and a good
   contracts.json was replaced by an empty one at exit 0. That is an unreadable
   condition reported as missing, one frame above the code this branch fixes.
   The default stays lenient for the other nine callers; ENOENT stays lenient
   in both modes.

5. Absence of unreadableRepos keeps meaning "not recorded". The loader spreads
   the key in only when present instead of defaulting to [], and getStatus
   passes undefined through, so a legacy registry no longer reads as "the last
   sync found none unreadable". getStatus also gates both list fields on
   Array.isArray: it reads through readContractRegistry, which is a bare
   JSON.parse cast, so a corrupt string in either slot used to reach
   cli/group.ts and die in .join(', ') — the command whose job is explaining an
   unreadable thing, crashing on one.

6. Smaller, same theme: the per-repo warning passes the Error itself rather
   than err.message, so pino keeps the stack; the total-failure warning no
   longer fires on a dry run, where it described a file the call was never
   going to touch and which need not exist; the status table's MISSING legend
   stops re-conflating the two states; the sync warning drops its
   GITNEXUS_LOG_LEVEL=warn hint, which would only have suppressed output (pino
   emits warn at the default info level, so the reason was already printed);
   and the group_sync tool description and its idempotency comment now describe
   what the tool actually does.

Testing. The original four cases could not see the change they were named
after. Mutation testing showed two survivors: dropping the ===
configuredRepoCount conjunct, which turns "every repo failed" into "any repo
failed" and would silently freeze contracts.json for a group where one of five
repos is skewed; and deleting both logger.warn calls, the stated purpose of the
change. Both survived because every case configured exactly one repo and
nothing read the log. There is now a two-repo case running the real per-repo
loop, an all-missing case, a _captureLogger assertion on the level 40 record,
partial-extraction cases, and strict-read cases. All five mutants are killed,
each by exactly one test.

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

* fix(group): tighten the registry list gates and stop naming a truncation reason on complete results

Three follow-ups from the check bot's pass over the previous commits.

1. `detect.includes` was missing from both group-sync test fixtures, so they did
   not satisfy the `GroupConfig` they claim to construct. It went unnoticed
   because `tsconfig.json` is src-only; `tsconfig.test.json` reports it. The
   older of the two fixtures carried the gap in from the original commit.

2. `runGroupImpact` named its truncation reason in a variable computed before
   the truncated check, so on a fully complete result the variable read
   'incomplete-sync'. `truncationFields` discards the reason when `truncated` is
   false, so nothing surfaced — but a value that is wrong whenever it is unused
   is a trap for the next reader. Computed inline at the one call site that can
   consult it, which is also how the neighbouring call sites are written.

3. `Array.isArray` alone let a corrupt registry through. `['app/backend']` and
   `[{repo:'x'}]` are both arrays, and only the second reaches `cli/group.ts`'s
   `.join(', ')` — as `[object Object]`, a measurement the operator can read but
   cannot act on. Both readers now go through one `recordedRepoList` helper that
   requires an array of strings; anything else is "not recorded", the same as
   absent. Two more rows in the corrupt-value table cover it.

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

* fix(group): keep readRegistry's signature, and stop describing unreadableRepos as index-only

Two items from the check bot's blocking pass.

1. `readRegistry` gained an optional `opts` parameter last commit. That is
   source-compatible — every zero-argument call still compiles and behaves
   identically — but the contract check treats any parameter-list change on a
   symbol with outside callers as a break, and it is right that the safest
   version of this change touches that signature not at all. The strict read is
   now its own export, `readRegistryStrict()`, over a shared private body.
   `readRegistry()` is byte-identical to what it was; `syncGroup` is the only
   caller of the strict one, and the mode is legible at the call site instead of
   hiding in an options bag.

2. `unreadableRepos` is described everywhere as "the index could not be opened".
   That was accurate before this branch and is not now: making per-repo
   extraction all-or-nothing means a repo also lands there when an extractor
   throws partway with the index open fine. The two belong in one bucket
   because the consequence is one thing — none of that repo's contracts are in
   this sync — but the docs have to say so, or an operator reads `unreadableRepos`
   as a storage diagnosis and goes looking at LadybugDB for an extractor bug.
   Corrected on `ContractRegistry`, `BridgeMeta`, `SyncResult`, the `group_sync`
   tool description, and the `group sync` console output, which now says
   "Could not extract contracts from" rather than "Could not read the index for".

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

* fix(cli): stop calling an unreadable registry an old one in group status

`getStatus` reports `unreadableRepos` as `undefined` for two different reasons:
the field is genuinely absent, or it held something that was not a list of repo
paths and the shape gate declined to guess. The status line named only the
first — "registry predates this field" — so a corrupt value read as a merely
old registry.

That is the same shape of wrong answer this command exists to stop giving: a
condition we could not read, presented as a benign one we understand. The line
now names both, and asks for a sync either way, which is the fix in both cases.

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

* fix(group): close the three fail-open paths left on the safety boundaries

Follow-ups from the re-review of 31c2b6e81. All three of its blocking findings
reproduce; each is a place where unknown state still resolved to a confident
benign answer, which is the one thing this branch exists to stop.

1. Strict registry reading accepted malformed rows. `[{}]` is a JSON array, so
   it passed the shape check: every configured repo then failed to resolve into
   `missingRepos`, none produced a load ERROR, the total-failure guard stayed
   off, and a good contracts.json was replaced with an empty one at exit 0 —
   the same fail-open the strict mode was added to close, one level down from
   the file to the rows inside it. Strict mode now requires `name`, `path` and
   `storagePath` on every row and rejects the WHOLE registry if any row fails.
   Rejecting rather than filtering is the point: dropping bad rows would report
   the repos they name as unregistered, which is the same wrong answer again.
   `indexedAt` / `lastCommit` are deliberately not required — callers already
   default them, so demanding them would trade a fail-open for a fail-shut on a
   legitimate legacy registry.

2. A failed bridge publication could make impact look complete. `writeBridge`
   swaps `bridge.lbug` and writes `meta.json` as two operations, and this branch
   made that meta load-bearing: `runGroupImpact` derives its truncation fields
   from it. A sync interrupted between the two steps therefore left a NEW bridge
   beside the PREVIOUS sync's metadata, and an impact query read that as
   "complete". Fixed from both ends. The write path removes the old meta before
   the swap, so the window leaves metadata ABSENT rather than stale. The read
   path treats absent-or-unparseable meta (`version: 0`) as unknown provenance
   and reports a floor, which also covers the caught `writeBridge` failure in
   `syncGroup`. Over-reporting truncation on a bridge that is actually fine is
   the safe direction, and the next successful sync clears it.

3. `preserved` was returned when there was nothing to preserve. On a group's
   first all-unreadable sync the outcome was set before the prior registry was
   read, so the CLI told an operator "the contracts from the previous sync are
   preserved" about a file that had never existed. Split out as
   `no-prior-registry`, with its own console message.

Also widened the NUL guard to the source languages it claimed to cover. The
commit that added it said "every tracked source file" while the collector
stopped at the JS/TS family, so a raw NUL in tracked Python, Java, Go, Rust,
C/C++, Ruby, PHP, Kotlin, Swift, C# or shell would still have turned those files
binary unnoticed. Measured before widening: 2315 non-JS tracked source files,
zero hits, so this was an unforced gap rather than a tradeoff. A planted `.py`
fixture and a collector-coverage assertion keep it honest.

Every fix is mutation-verified: reverting each one individually turns its own
tests red (3, 2, 2, 1 and 1 failures respectively), and all pass together.

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

* fix(group): record the empty unreadable measurement instead of dropping it

Both writers omitted `unreadableRepos` when it was empty, which made the
tri-state this branch introduced unreachable in its most common case.

`ContractRegistry.unreadableRepos` is optional on the TYPE so a registry written
before the field existed still parses, and absence there means "not recorded".
But a sync that read every repo successfully HAS measured it, and `[]` is that
measurement. Dropping it collapsed "measured, none" into "never recorded", so
after every clean sync `gitnexus group status` printed

    Last sync unreadable repos: not recorded
    (the registry predates this field, or its value could not be read)
    Re-run `gitnexus group sync` to record it.

about the sync that had just succeeded. The distinction is only worth having if
the writer commits to it, so both `contracts.json` and the bridge's `meta.json`
now record the field whenever the sync supplied it, `[]` included.

The check bot found this on the bridge writer and attributed the consequence to
`group status`. The consequence is real but it is not the bridge's: `getStatus`
reads `contracts.json` and never touches `BridgeMeta`, whose only consumer is
`runGroupImpact` — where absent and empty are already equivalent. So the
user-visible half was in the registry writer, one file over from where it was
reported, and both are fixed.

Also fills in `DetectConfig.includes` (and `workspace_deps`) across the group
test fixtures that predate those fields. These are pre-existing on main and are
a no-op at runtime — `undefined` and `false` are both falsy at the gate — but
they are the same defect the bot flagged as an error in the new fixtures, and
`tsconfig.test.json` reported eleven of them. That file is not in CI, which is
why they survived; the group tree is now clean of them.

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

* test(group): stop two bridge-metadata tests claiming coverage they do not have

Both were named for the swap window and neither injects a swap failure.

"drops the previous meta.json before swapping the database file" runs two
successful writeBridge calls. Its assertions hold with the removal in either
position, because writeBridge overwrites meta.json at the end regardless — so
it cannot pin the ordering it is named for. Renamed to what it does cover, the
successful-rebuild replacement, with the limit stated in the body rather than
left for the next reader to discover.

"leaves NO meta.json when the swap fails partway" removes the file by hand
after a successful write, so it exercises readBridgeMeta's missing-file
contract, not writeBridge. That contract is worth pinning on its own — version 0
is the signal runGroupImpact fails closed on — so the test stays, under a name
that says so.

The ordering itself is pinned in bridge-meta-swap-window.test.ts, which mocks
retryRename to throw on the bridge.lbug swap and asserts the previous sync's
metadata cannot survive it. Both renamed tests now point there, so the coverage
is findable from the place someone would look for it.

No production code changes.

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

* fix(group): pair bridge metadata to its database instead of deleting it

The previous commit closed the swap/metadata window by removing meta.json before
the database swap, so the window would fail to "absent" rather than "stale". That
was the wrong trade, and it destroyed recoverable state.

The old database's move to `.bak` sits inside a catch that swallows failures, not
just "no existing db". When that rename fails — a held read-only handle does this
on Windows, and a long-lived MCP server holds one — the failure is swallowed, the
following `tmp -> bridge.lbug` throws, and writeBridge exits with the OLD database
still in place and perfectly valid. Its metadata was already deleted. Cross-repo
impact then answers "we cannot say" for that group until some future sync
succeeds, and if the cause is a held handle or permissions there is no such sync.
A working feature, destroyed permanently to close a narrow window.

Deleting also only chose which way the window failed; it never closed it.

So destroy nothing, and make the pair self-describing instead: writeBridge stamps
the database's size and mtime into the metadata it writes, and
`bridgeMetaMatchesFile` lets a reader ask whether the two still belong together.
`runGroupImpact` treats a mismatch the same as absent metadata — provenance
unknown, report a floor. A metadata file left over from an earlier sync cannot
match a freshly renamed database, and a sync that fails before the swap leaves a
matching pair untouched. Metadata written before the stamp existed is
unverifiable rather than stale, and is accepted: failing those closed would mark
every pre-existing bridge incomplete, trading a narrow window for a repo-wide
regression.

The swap-window test now distinguishes the two failure shapes, because they want
different answers. When every rename fails the old database never moves, so the
surviving metadata still matches it and impact keeps answering from it. When only
the final rename fails the old database has already reached `.bak` and no
database is in place, so the metadata correctly matches nothing — and
`ensureBridgeReady` fails loudly on the absent file, which beats a silent floor.

Mutation-verified: reinstating the delete, neutering the pairing check, and
dropping the stamp each turn 2, 3 and 3 tests red respectively.

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

* chore: keep TypeScript diffs readable after a NUL leaves the tree

Git decides a pair is binary when EITHER blob carries a NUL, and it only
sniffs the first 8000 bytes. `gitnexus/src/core/group/sync.ts` carried one
at byte 5132 on main. This branch removes it, but the base side still has
it, so the file renders as "Binary files differ" in the pull request: no
hunks, no inline comments, and no three-way merge — however clean the head
side is. A head-side byte guard cannot detect that, by construction, since
it only ever sees the working tree.

Setting the `diff` attribute stops the heuristic from hiding the change.
It does not mark the files binary, does not imply `text`, and does not
change how blobs are stored, normalized, or checked out — the root
`* text=auto eol=lf` still governs all of that. It affects diff generation
and rendering only.

Locally this turns the branch's own sync.ts diff from `Bin 17612 -> 25346
bytes` into 154 insertions and 16 deletions.

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

* fix(group): answer "provenance unknown" for malformed bridge metadata

`readBridgeMeta` guarded the read and the parse but not the SHAPE of what it
parsed, then cast the result. `runGroupImpact` spread both repo lists straight
into a Set, so a `meta.json` whose `missingRepos` held an object threw a
TypeError out of the entire cross-repo query — and threw it from a point after
`ensureBridgeReady` had taken the bridge lease and before the `try` whose
`finally` releases it, so every such query also leaked a refcount the cached
handle could never get back. A malformed file is a reason to answer "we cannot
say", never a reason to crash the question.

The shape gate now lives where the metadata is read, mirroring the one
`service.ts` already applies to the registry's copies of these same two lists.
Each list is judged independently: a garbage `unreadableRepos` no longer
discards a `missingRepos` that was genuinely measured. A list that was present
but unusable is dropped rather than normalized to `[]`, because an unreadable
value is not a measurement of zero — the new reader-side `repoListsUnreadable`
carries that distinction, and `runGroupImpact` folds it into the same
provenance-unknown verdict it already reaches for `version: 0` and for
metadata that does not pair with the database beside it.

A root that is not an object is closed too. `JSON.parse` succeeds on `null`,
`7` and `[]`; the first threw on `.version`, and the other two read `undefined`
and sailed through the version gate as if the bridge had been vouched for.

Both provenance values moved inside the protected region and are initialized
fail-closed, so a future throw between the lease and the walk releases rather
than wedges.

`repoListsUnreadable` is reader-side only: the sole `writeBridgeMeta` call site
builds a fresh literal, so nothing persists it and no schema version moves.

Mutation-verified: reverting the shape gate alone turns 4 tests red — the three
malformed-list scenarios plus the handle-release regression.

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

* fix(storage): reject registry rows that cannot identify a repo

The strict read's row gate gave `typeof v === 'string'`, and `typeof '' ===
'string'`. A row whose `name` was blank therefore passed as resolvable, then
matched nothing in `defaultResolveHandle` — putting every configured repo in
`missingRepos` and presenting an unusable registry as a clean answer about an
empty one. That is the same unreadable-as-missing fail-open the strict mode
exists to close, one level further in. A blank `storagePath` is worse than
useless: it joins to a relative `lbug` under the current directory, so the sync
opens an index that is not the repo's.

Both now have to be non-blank after trimming. `path` stays at the bare string
check, on the same reasoning that already exempts `indexedAt`/`lastCommit`:
require only what resolution depends on to IDENTIFY the repo. This gate rejects
the whole registry and the registry is machine-wide, so a field tightened past
what identification needs would let one blank value in one row break every
group sync on the machine — including groups whose repos all resolve. A blank
`path` still yields a working handle; `defaultResolveHandle` does read it, but
only for the pool id and `repoPath`, neither of which decides whether the row
names a repo.

The error now says what is actually wrong instead of naming three fields that
are all present.

Mutation-verified in both directions: dropping the trim turns the three
rejection tests red, and applying the wider fix that was considered and
declined — tightening `path` too — turns exactly the counter-case red, so that
test genuinely pins the narrow reading rather than passing either way.

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

* fix(group): bound the per-repo contract staging append

`autoContracts.push(...repoContracts)` passes every staged contract as a
separate argument, and the engine caps how many arguments one call may take.
That cap is a function of the host's available stack, so it is a different
number on every machine — this one accepts a 125k-element spread and dies at
150k.

The spread itself is not new; what it carries is. Before staging, this line
appended a single extractor's output as it came back. Staging made it carry the
whole repo's, which is enough for a large repo to raise `RangeError: Maximum
call stack size exceeded` on the one line whose job is to commit work that just
succeeded. The throw lands in the catch below, so the sync reports a repo whose
extractors all ran cleanly as one whose index could not be read — a crash
wearing the costume of a diagnostic.

A bounded loop replaces it: the count a repo can stage is now bounded by memory
rather than by how much stack the process happened to get.

The guard is structural, not size-based, and deliberately so. A "make the
fixture big enough to crash" test passes against unfixed code on any host with
a larger stack, which is exactly the guarantee a regression gate cannot give
up. It walks the AST and locates the region by role — the `const` staging
buffer typed `StoredContract[]`, then the extractor `try` that is a direct
statement of the block declaring it — so renaming either identifier keeps it
pointed at the same code. `.apply()` is rejected alongside spread, being the
same hazard in different syntax.

Direct statements only, because `syncGroup` wraps this whole section in its own
try/finally for the lease sweep, and that ancestor reads the buffer too.
Matching any enclosing `try` pulls in the entire function body — including the
two windowed-manifest spreads, which are bounded by the window size and are not
what this fixes.

Mutation-verified in both directions: restoring the spread turns the gate red
naming that line alone; deleting a manifest-window spread, and separately
adding a third one, both leave it green.

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

* fix(group): keep unreadable repos out of manifest contracts too

Per-repo staging closed one door: a repo whose extractor threw contributes
nothing through the direct path. Deferred manifest resolution was a second
door, still open. It derives its known-repo set from the resolved-handle map,
which kept an entry for a repo the same run had already declared unreadable —
so the sync re-opened that index and resolved symbols against a database it had
just told the operator it could not read.

Deleting the handle in the catch stops the re-open, but it does not satisfy
R2 on its own: `ManifestExtractor` resolves both endpoints of a link and emits
a contract for each, and for an endpoint with no executor that contract is
still emitted with a synthetic UID. The registry ended up naming a repo the
same run reported unreadable.

So the emitted output is filtered by ENDPOINT, not by link. Dropping the whole
link would delete the healthy partner's contract as well — a repo losing its
own output because a neighbour's index would not open, which is wider than the
requirement and destroys good data to suppress bad. A cross-link is different:
it asserts something about a pair, so if either end is unreadable there is
nothing left to anchor it to, and a half-anchored link is exactly the
confident-about-what-it-could-not-read answer the registry must not give.

Deleting the handle also changed what the operator gets told, so the warning is
split. An unreadable repo IS configured; letting it fall into the "references
repos not in config.repos" branch states something false and sends the reader
to edit group.yaml for a problem only re-indexing fixes. It now gets its own
message naming what was actually omitted.

Mutation-verified four ways: reverting the endpoint filter turns three
scenarios red; the over-broad whole-link variant turns the healthy-partner
scenario red and nothing else; removing the handle delete turns the
no-re-open scenario red; and reverting the warning split turns the operator-
message scenario red. Every assertion reads the written contracts.json rather
than the in-memory result.

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

* refactor(group): keep readBridgeMeta's signature stable across the shape gate

The shape gate landed by widening the return type to a reader-only
`ReadBridgeMeta extends BridgeMeta`. That is source-compatible — a covariant
return, one added optional field, every existing caller unaffected, typecheck
and suite clean — but the contract check reads it as a changed signature with a
caller left behind, and blocks the merge on it. This branch already hit the
same wall on `readRegistry` and settled it the same way: leave the signature
alone and make the difference legible some other way.

So the flag moves onto `BridgeMeta` itself as an optional, documented,
never-persisted field, and `readBridgeMeta` goes back to the exact signature
its callers already compile against.

That is the better shape here anyway. The reader-only subtype would have split
the validation two ways: `openBridgeDbReadOnly` and `bridgeExists` both gate on
`meta.version`, and the normalization that comes with the gate is what stops a
`version: null` in a hand-edited meta.json from reading as `undefined` and
sailing through `version > 0` as though the bridge had been vouched for. One
type keeps all three callers behind the same guard.

Nothing persists the flag: `writeBridgeMeta`'s only caller builds a fresh
literal, so it cannot round-trip to disk.

No behavior change — pure type restructuring. 927 tests pass, typecheck clean.

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

* fix(group): stop treating a half-written bridge stamp as a verified match

`bridgeMetaMatchesFile` joined its two `undefined` checks with `||`, so
metadata carrying a size and no mtime — or the reverse — returned `true`, the
same answer it gives a fully verified pair.

A stamp is a PAIR. Both halves absent is the legacy shape: metadata written
before stamping existed, which cannot be verified either way and is accepted
deliberately, because failing it closed would mark every pre-existing bridge
incomplete until re-synced. Exactly one half present is not that. Something
wrote a stamp and did not finish, which is precisely the condition stamping was
added to detect — so the check handed back "verified" for the one shape that
most deserves suspicion, and a cross-repo impact query built on it would report
a confident answer about a database its metadata cannot vouch for.

The two states are now separated: neither half present accepts, exactly one
rejects as provenance-unknown, both compare against the file as before.

Found by the repository's own contract check, not by the plan.

Mutation-verified: restoring the `||` form turns both half-stamp cases red
while the legacy and fully-stamped controls stay green, so the pair genuinely
pins the distinction rather than passing either way.

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

* fix(group): pair unstamped bridge metadata by write order, before any open

Unstamped metadata was waved through: `bridgeMetaMatchesFile` returned "matches"
for any pair with no stamp to check, so the stale-meta-beside-a-new-database
window stayed open for every bridge written before stamping existed, and
`runGroupImpact` spent that metadata's completeness as fact.

`writeBridge` renames the database into place and writes the metadata after, so
`meta.mtime >= db.mtime` holds for any pair written together — including by
builds that predate the stamp. A database strictly newer than the metadata
beside it can only come from a swap whose metadata write did not land. That is
the fallback now. It is a heuristic on write order, not proof of provenance, and
it is wrong in two directions: a stale metadata file touched after the swap
still reads as paired, and a pair whose clock stepped backwards between the two
writes reads as unpaired. Both are recorded at the code; the second is the safe
direction. Equality counts as paired, or a coarse-granularity filesystem would
reject every legacy bridge for a reason that is about the filesystem.

The verdict is now taken in `ensureBridgeReady` BEFORE the database is opened,
and carried on the metadata rather than recomputed afterwards.

That ordering is load-bearing, not tidiness. Impact and trace both open the
bridge and only then ask about provenance, so on any platform or LadybugDB
build where a read-only open advances the file's mtime, every pre-stamp bridge
would report provenance-unknown from its first query onward — the exact
repo-wide regression this rule was chosen to avoid, arriving as a silent
downgrade rather than an error. It does not happen on Linux, which was measured.
It cannot be measured on Windows: pinning it by really opening the database
needs an in-process write→read reopen of the same bridge.lbug, which is a
documented limitation there. Rather than ship a Windows-skipped test and leave
the assumption unverified on the platform whose file semantics are most likely
to differ, the check moved ahead of the open so no platform has to be trusted.

The new guard forces the hostile case on every platform: the open is stubbed to
advance the database's mtime, and the verdict must still be "paired". It is
registered in the cross-platform list so the Windows and macOS shards run it,
and it has a control so it cannot pass vacuously.

Two existing fixtures mocked `readBridgeMeta` to return a stamped-era version
while never writing a meta.json — a state production cannot reach, since a
non-zero version can only come from a file that exists. They now write the
metadata their own mock claims to have read, rather than the helper being
loosened to accept metadata it cannot stat.

Mutation-verified twice: reverting the write-order branch turns both rejection
cases red while all four legacy-accept cases stay green, and moving the pairing
call back after the open turns the ordering guard red on its own.

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

* refactor(group): compute cross-repo completeness in one place

Three surfaces can return a partial cross-repo answer — impact, trace, and the
contract listing — and each decided for itself whether it was complete. Impact
carried the structured triple; trace said it in prose, if at all. An agent
reading a not-found trace had no machine-readable way to tell "there is no
path" from "there may be a path in a repo this sync could not read", which is
the difference between an answer and a floor.

`crossRepoCompleteness` is now the one computation, and its input deliberately
does not name where any of it came from. `BridgeMeta` is not in the signature
and must not be: `groupContracts` answers the same question from contracts.json
and never opens a bridge, so `version`, `repoListsUnreadable` and
`pairedWithDatabase` do not exist on that path. Each caller derives its own
`provenanceUnknown` — the bridge callers through `bridgeProvenanceUnknown`,
which stays separate for exactly that reason — and passes the boolean in.

Scope arrives as a predicate rather than a repo list or a subgroup, so
narrowing a query's scope stays a change to one argument at the call site.

The trace results now carry `truncated` / `truncationReason` / `riskEpistemic`
like impact does. `notes` is untouched; it remains an addition to the machine
channel, never the channel.

One correction to the approach as written: it said to pass the trace's two
endpoint repos as the predicate, but a destination trace declares no `to`. It
asks where a call lands, so any member may hold the answer — and an unreadable
provider repo is precisely how "no outgoing ContractLink leaves this repo"
becomes a wrong answer rather than an empty one. Filtering that path to the
`from` repo would have reintroduced the bug this unit exists to close, so it
passes every repo and a test pins it.

Two pre-existing paths become consistent with the vocabulary as a result: a
crossing-capped result now reports `truncationReason: 'partial'` alongside the
`truncated` flag it already set, and the destination path's `ambiguous` returns
now report the cap its `ok` and `not_found` siblings already reported. Both are
additive — no field is removed, and no `truncated` flips from true to false.

`truncationFields` returns a discriminated union now, so `truncationReason`
reads without a fallback on the branch where it cannot be absent.

Mutation-verified: reverting the provenance fold alone — one line in the shared
helper — turns 8 tests red across both surfaces, 2 new trace scenarios and 6
existing impact ones, which is the point of there being one helper.

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

* fix(group): narrow the incomplete-repo set to the query's declared scope

A subgroup-scoped impact query was marked a lower bound by repos it had
explicitly excluded. The fan-out already drops every neighbour outside the
subgroup, so those repos could not have contributed a crossing to the answer —
and a completeness marker that fires on results it does not describe is how a
caller learns to ignore the marker.

The scope is the query's DECLARED one, not the one the walk reached. An
incomplete repo's contracts are absent from the bridge by definition, so it is
never in the traversed set; filtering on what was traversed would empty the
intersection on every query and silently restore the fail-open this channel
exists to close.

Declared scope here is the subgroup PLUS the query's own repo, which the
approach did not account for. The walk starts from that repo's contracts in the
bridge, so when it is the repo the sync could not read there are no crossings
to find under any scope — and a subgroup excluding it would have turned that
vacuum into a confident "nothing depends on this", for a tool an agent uses to
license a delete. That case reported a floor before this change, so narrowing to
the subgroup alone would have been a regression. The union only ever widens the
in-scope set, so it cannot re-mark a repo the query excluded.

Membership goes through the existing `repoInSubgroup` in both clauses, `exact`
for the origin equality, rather than growing a second notion of what it means
for a repo path to be in scope.

Sound only while `MAX_SUPPORTED_CROSS_DEPTH` is 1 — at depth 2 an out-of-scope
repo can sit between two in-scope ones — and that constraint is recorded at the
intersection.

Unscoped queries are byte-for-byte unchanged: `repoInSubgroup` answers true for
an absent subgroup, so the intersection is the whole set.

Mutation-verified: restoring the unfiltered predicate turns exactly the two
scoped cases red while the unscoped control and both in-scope guards stay green.

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

* fix(group): keep the preserved registry and the bridge from disagreeing

A total-failure sync refreshed contracts.json's diagnostic lists and left
meta.json alone. But meta.json, not contracts.json, is where runGroupImpact
reads completeness from — so the registry said "this sync could not read
app/backend" while a cross-repo query answered `{ cross: [], truncated: false }`.
Two surfaces describing the same run, one of them wrong, and the wrong one is
the machine-readable one an agent uses to license a delete.

The preserve path now refreshes the same two fields in the metadata. The
database stays untouched: it still holds the contracts being preserved, and
rebuilding it here would be the one write that could lose them.

Refreshing metadata is not free, though, and the obvious version of it is a
fail-open. The rewrite moves meta.json's mtime to now while bridge.lbug's stays
old, so an unstamped pair whose database is NEWER than its metadata — the shape
the write-order rule exists to reject — would come out of a preserve sync
passing the check. Writing "no stamp" does not help; the write-order comparison
is exactly what the moved mtime defeats. The verdict has to be recorded in the
metadata, because the refresh cannot avoid moving the mtime.

So `provenanceUnknown` is persisted whenever the existing pair does not already
check out, the existing stamp fields are carried through verbatim rather than
dropped, and `bridgeMetaMatchesFile` rejects the marker ahead of both the stamp
and the write-order heuristic. A pair that already matched is re-stamped
instead, which also upgrades a legacy unstamped-but-paired bridge to an exact
stamp. No preserve run can increase the number of pairs that pass the check.
The marker self-clears: `writeBridge` builds fresh metadata and never sets it.

`BridgeMeta` carries two reader-side fields documented as never persisted, and
this is the first code in the repo that reads metadata and writes it back. Both
are stripped explicitly before every write. `pairedWithDatabase` is the
dangerous one — persisted, it would tell every future reader the pair had been
verified — and a test seeds both on disk to pin that neither survives.

The write is not wrapped in a catch, unlike writeBridge on the success path.
There contracts.json is canonical and already written, so a stale bridge is a
recoverable degradation; here the write IS the guard against a confident wrong
answer, and swallowing its failure would reinstate the fail-open it closes.
`writeContractRegistry` above is unguarded into the same directory for the same
reason.

A group with neither file writes nothing: `readBridgeMeta` already answers
`version: 0` for an absent file, so a written one would say what the absence
already says while inventing state for a bridge that has never existed.

Mutation-verified three ways: dropping the marker write turns 6 red including
both laundering scenarios; moving the marker check below the stamp branches
turns the unstamped-laundering case red; removing the field stripping turns the
never-persisted test red. Each restored byte-exactly and re-verified.

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

* fix(group): report group_contracts' completeness in the shared vocabulary

`group_contracts` returned contracts and cross-links and said nothing about
whether that listing was the whole story. An agent reading it after a sync that
could not open half the group got a confident-looking list with no way to tell
it was a floor — the same fail-open the impact path already closed, on a surface
that had no channel for the answer at all.

It now returns the registry's two diagnostic lists and the structured triple,
folded through the same helper the impact and trace surfaces use, so the three
cannot drift. The helper takes no `BridgeMeta` precisely so this path — which
reads contracts.json and never opens a bridge — can share it.

The three registry states stay distinguishable, which is the point:
  - key absent: the registry predates the field and has no opinion about which
    indexes opened, so the key is omitted rather than invented as `[]`, and the
    listing reports a floor. It cannot say which repos the sync failed to read,
    so it cannot claim to be complete.
  - key present and empty: measured, clean, not truncated.
  - key present and populated: the repos, and a floor.

`incompleteRepos` is dropped on this surface alone: both lists it derives from
are returned verbatim beside it, and a third name for the same repos is drift
waiting to happen.

The import is lazy, matching `groupImpact` and `groupTrace` in this same class.
`cross-impact.js` statically pulls the native LadybugDB binding through
`bridge-db.js`, and `service.ts` is loaded by every `gitnexus group` subcommand
including ones that touch no database.

One fix inside the same file that this unit forced: the registry loader gated
`missingRepos` with a bare `Array.isArray`, which admits `[{repo:'x'}]`. That
was inert while nothing read the list, but this change both returns it and folds
it into the completeness answer — so an unreadable value would have been printed
as a repo name and would have flipped `truncated` on garbage. It now uses the
same `recordedRepoList` gate `group status` already applies to the same field.
`missingRepos` has always been required, so unlike `unreadableRepos` it has no
"not recorded" state to preserve and an unreadable value degrades to empty.

Mutation-verified: reverting the fold alone turns 14 tests red and leaves the
control — the contract and cross-link payload this tool has always returned —
green.

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

* fix(cli): stop dropping group contracts' completeness fields on the way out

`group contracts --json` destructured `{ contracts, crossLinks }` from the
service payload and rebuilt an object from just those two. Everything else the
service returned was discarded on the way to stdout — so the completeness
fields the MCP tool now carries were invisible at the CLI, and the two surfaces
disagreed about the same registry.

It prints the payload whole now. A field added to the service reaches `--json`
without a matching edit here, which is the point: the re-serialized subset was
a second place that had to be remembered, and it was not.

The human-readable path gains the same signal in words. A listing built from a
sync that could not read part of the group shows counts that are a floor, not a
census, and it named neither fact. It now says so and names the repos when the
registry recorded them — and says the sync did not record which repos it could
read when it did not, because a listing that cannot say what it is missing is
still incomplete.

Mutation-verified: restoring the re-serialized subset turns the `--json` case
and the control red.

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

* fix(group): tell a missing registry entry apart from an unreadable one

`group status` printed MISSING for both "this repo has no row in the registry"
and "the registry itself could not be read", so an operator whose registry.json
was corrupt was told every repo was unregistered — and sent to re-register them
instead of to the one file that was actually broken.

The two are now separate. `missing` keeps its old meaning and still flags every
unusable repo, so an older consumer is unaffected; `unresolvable` is additive,
always present, and carries the reason that produced it.

This is the one caller that has to make that distinction, so it takes the
strict global-registry read. `readRegistry`'s `catch { return [] }` collapses a
malformed registry into an empty one, which is indistinguishable from a genuine
absence and is exactly what produced the wrong label. The cost is accepted
knowingly and recorded at the call site: the strict read rejects the whole
registry when any row fails to identify a repo, so one malformed row renders
every member unresolvable — including members whose own rows are fine. That is
the honest verdict, and it is reported as an unresolved state rather than a
clean one.

Choosing between the two labels needs to know whether a row exists at all,
which `registryIdentifies` answers by mirroring the two tiers the resolver
matches a bare group-config value on — registry name, case-insensitively, and
repo path. It deliberately stops short of the hashed-id and partial-name tiers:
those exist to be generous about what an operator typed, while this only picks
a label, and a looser match would relabel a genuine registry miss as an
unresolvable row — the same conflation this change removes, pointed the other
way.

The plan's third failure mode — a row that resolves but whose storage path
cannot be opened — turns out to be unreachable: `loadMeta` returns null on
every error and `checkStaleness` catches everything, so nothing after
`resolveRepo` inside the try can throw. The reachable per-repo case is
`resolveRepo` itself throwing, as it does for two registered clones sharing a
name, and that is what the tests drive end to end through the real CLI. The
code still handles the plan's case correctly if those helpers ever start
throwing.

Mutation-verified: reverting the split turns 6 unit and 2 CLI cases red while
both controls — a genuine miss, and a healthy group — stay green.

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

* fix(cli): say what the preserve path actually does to contracts.json

The sync summary announced "Did NOT write contracts.json" on the branch that
writes it. The preserve path rewrites the file — keeping the previous sync's
contracts and cross-links, replacing only the two diagnostic lists — so an
operator who checked the mtime and found it moved was told the opposite of what
had happened, on the command this PR exists to make legible.

It now says the previous contracts were kept and names what changed.

The no-prior-registry branch is narrowed for the same reason. It claimed
nothing at all was written, and that is no longer true either: this path still
records the run against an existing bridge's metadata. The claim is now scoped
to contracts.json, which is the file it can actually speak for.

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

* fix(group): stop the total-failure log promising a preservation that did not happen

The warning fired before the prior registry was read, so it could only ever
promise one of the two things that might be true — and it promised the wrong
one to every group that has never synced: "keeping the contracts from the
previous sync" about a file that does not exist. The console line for that same
run, driven by `registryOutcome`, said the opposite.

It now lives inside the branch, after the read, with one message per outcome
chosen at the point the outcome is decided. The log and the console cannot
disagree, because the same fact selects both.

Both messages keep the warn level and the two repo lists.

Mutation-verified: reverting the split turns the no-prior-registry case red
while the preserved case — whose claim was already true — stays green. The
dry-run test's log filter was also widened to the sentence both messages share,
or the new wording would have made that assertion match nothing and pass
regardless.

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

* fix(group): make the bridge-failure warning describe what the code guarantees

The warning after a failed `writeBridge` promised that cross-repo impact would
report `truncated` until a sync succeeded. Nothing on that path produces that
signal.

The swap is the last step: `writeBridge` builds the new database in a staging
directory and only then moves the old one aside. A failure during the build
therefore leaves the previous sync's `bridge.lbug` exactly where it was, beside
the `meta.json` stamped for it — a pair that passes `bridgeMetaMatchesFile`
with the previous run's `unreadableRepos`. The next cross-repo query answers
`truncated: false` from superseded contracts, which is the opposite of what the
operator was told to expect, and worse than being told nothing.

The warning now says what is actually true: contracts.json is intact and
canonical, the bridge was not replaced, cross-repo queries may still answer from
the previous sync's contracts, and nothing marks them as superseded.

The metadata is deliberately NOT re-stamped to make the original promise true.
That would recreate exactly the metadata/database mis-pairing the stamping on
the preserve path exists to prevent, and the comment at the warning records it.

The claim is asserted against captured log output rather than left to the state
tests. Those check which pairs match and what the preserve path writes; every
one of them stays green while this sentence reverts to promising a truncation.
An unasserted user-facing branch is the defect class this change is closing, so
it does not get to close it while remaining one.

No filesystem shape makes the real `writeBridge` fail while
`writeContractRegistry` succeeds — they write into the same directory one line
apart — so the failure is armed through a pass-through wrapper on the file's
existing mock. It delegates byte-for-byte unless a test arms it, and is reset
around the new suite.

Mutation-verified: restoring the original wording turns its own assertion red
and nothing else.

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

* docs(mcp): name every registry outcome group_sync can actually return

The tool's description told agents `registryOutcome` is 'written' or
'preserved'. It has a third reachable value: 'no-prior-registry', returned when
nothing could be read AND there was no previous contracts.json to carry
forward. An agent calling this tool against a group that has never synced got a
value its own tool description said did not exist, and no way to tell it apart
from the case where the previous contracts survive.

The distinction is the whole point of the value. After 'preserved' there is a
registry to read — stale, but real. After 'no-prior-registry' there is nothing
on disk at all, so a following group_contracts or group_impact has no registry
rather than an old one. Those need different responses from the caller.

'not-attempted' stays undocumented because it is unreachable through this tool,
and a guard asserts it stays that way.

The code comment above the annotations claimed the preserve path does NOT write
contracts.json. It does — it rewrites the file, keeping the previous contracts
and cross-links and refreshing only the two diagnostic lists, which the CLI's
own summary was corrected to say a few commits ago. Left alone it would have
re-seeded the same wrong claim next to the text that now states it correctly.

Mutation-verified: deleting the 'no-prior-registry' sentence turns the guard red.

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

* docs(mcp): explain structural incompleteness on the impact tool and status resource

The impact tool's GROUP MODE paragraph described one cause of truncation — the
fan-out running out of room — and left an agent to assume that was the only one.
So a `truncated: true` carrying `truncationReason: 'incomplete-sync'` read as
"retry with a smaller scope", when retrying returns the identical floor forever:
the repos are absent from the bridge itself, and only a re-sync puts them back.
The old text also said the response carries the truncation fields "when it stops
early", which is wrong for that case — `truncatedRepos` names repos even when
ZERO crossings to them were attempted, because their contracts were never in the
bridge to cross to.

The paragraph now branches on the reason and gives each its remedy: 'timeout'
and 'partial' are runtime limits where a retry or a larger budget can help;
'incomplete-sync' is structural and the remedy is `group_sync`.

The reason union is now derived from an exported `as const` array rather than
written as a bare type. A type-only union gives a guard nothing to enumerate, so
the guard has to hand-list the members — and then it passes forever the moment a
fourth is added, which is the exact regression it exists to catch. The guard
iterates the runtime array instead. Verified by appending a probe member and
watching it go red, then removing it. The resolved type is unchanged; every
importer uses `import type` and none needed an edit.

The status resource said "Group index / contract staleness" and nothing about
the distinctions its payload now carries. It explains all of them: a repo absent
from the registry versus one whose entry could not be resolved, and the
`unreadableRepos` tri-state where an ABSENT key is not an empty one — absent
means the last sync never recorded what it could read, so cross-repo answers for
that group are a floor.

The description an MCP client actually receives lives in `getResourceTemplates`,
not in the context resource's inventory line the plan pointed at. Both now carry
the vocabulary, so the two surfaces cannot disagree about the same payload.

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

* feat(group): serialize group syncs behind a fail-closed per-group lock

Two concurrent syncs of one group could lose one another's writes. Both read
the prior registry, both built contracts, both wrote — last writer won, and the
loser's work was gone with nothing reporting it. A group sync is long and
expensive and is exactly the operation whose lost update destroys contracts.

`syncGroup` now takes a lock for the whole persist section, acquired exactly
once. `acquireIndexLock` is not reentrant, so a second acquisition anywhere
below would deadlock the happy path rather than an edge case; `withGroupSyncLock`
has one call site and nothing inside it re-acquires.

The lock lives on a dedicated `sync-lock` directory inside the group directory,
mirroring the registry lock's dedicated directory rather than reusing the
resource's own — a lock directory that could collide with a per-repo index slot
repeats a bug the registry lock's comment already warns about.

It fails CLOSED, which is the opposite of `withRegistryLock` and deliberately
so. That one degrades to unlocked because it guards a sub-second JSON merge on
a latency-critical path; here running unprotected is the outcome the lock
exists to prevent. Three exits are covered: a timeout, an unwritable lock
directory, and the lock-free degradation the primitive performs silently.

That third exit needed a change in `index-lock.ts`, and it is the one declared
exception to keeping this work inside core/group/. `acquireIndexLock` answers a
read-only or permission-denied filesystem with a no-op handle that is
byte-identical in shape to a real one, so a caller for whom lock-free is not an
acceptable outcome could not tell the difference. It now carries an optional
`lockFree` marker. The change is additive by construction: no signature moves,
no control flow changes, nothing about when or how a lock is taken changes, and
every caller that ignores the field behaves exactly as before.

A filesystem probe inside the group module was considered and rejected on
evidence: `selectBackend` returns `socket` on Linux and Windows, where
`acquireViaSocket` never touches the filesystem and this branch cannot occur —
so a probe would refuse syncs on the two platforms that never degrade while
missing the one that does.

The timeout ceiling is a named 600s constant passed explicitly. The magnitude
matches the primitive's own analyze-sized default because a group sync is
analyze-shaped and a legitimately queued second sync must be able to wait out a
full first one. Passing it explicitly is about the override, not the magnitude:
`resolveTimeoutMs` resolves `GITNEXUS_INDEX_LOCK_TIMEOUT_MS <= 0` to Infinity,
which would turn fail-closed into a hang.

Cross-process exclusion is proved with a real spawned holder, not an in-process
mock, which cannot demonstrate the property this exists for. The lock-free
scenario pins `GITNEXUS_INDEX_LOCK_BACKEND=file` — unpinned it would pass on
two of three platforms while measuring nothing — and produces the failure by
injecting EACCES on one syscall rather than by chmod, so it runs identically on
Windows instead of being skipped there.

The CLI reports the failure through pino rather than a bare stderr write, which
this package lints as an error to keep that migration moving, and the test reads
the `msg` field rather than a raw substring — matching on the raw text would
have passed only by accident of quoting and would go green again if the line
were downgraded.

Nothing is skipped on any platform, and the test is registered for the
cross-platform shards.

Mutation-verified: removing the lock acquisition turns 6 scenarios red;
removing the lock-free rejection turns the degradation scenario red on its own.

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

* fix(group): make the sync-lock timeout name a cause it can establish

The fail-closed lock surfaced the primitive's own timeout message to users for
the first time, and that message says the wait was on "another gitnexus
analyze" — a cause its detection path cannot establish. It is the same
confident-about-what-it-could-not-determine claim this PR exists to remove,
inherited rather than written.

The wrapper now throws its own. It names the group, the lock directory, the
operation, and the elapsed wait, and it says plainly that nothing was written.

The holder clause branches on `holderKnown`. The socket backend exposes no owner
metadata and reports a placeholder pid of -1, so on that backend — and on the
file backend's malformed or vanished-lock timeouts — the message says the lock
stayed held but the backend cannot identify who held it, rather than printing a
pid that means nothing.

The elapsed wait is measured by the wrapper. `IndexLockTimeoutError` carries
only `holder` and `holderKnown`; the figure exists solely inside the string
being replaced, so it had to be taken rather than read.

One pre-existing assertion changed with it: the timeout case asserted
`'Timed out after 600000ms'` from the inherited text, which is precisely the
message this replaces.

Mutation-verified: restoring the inherited message turns the three assertion
cases red and leaves the control — a real acquisition that succeeds — green.

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

* fix(group): stop a losing sync from downgrading the one that beat it to the lock

Serializing is not ordering. Both syncs run extraction outside the critical
section, so a total-failure sync that acquires second reads the winner's fresh
registry as `prior` and rewrites it with all-unreadable lists. The lock alone
does not prevent that — it only decides who goes second, and the loser then
overwrites a healthy registry with a description of its own failure.
Deterministically, not as a rare interleave.

The guard is a compare-and-swap on the registry file's own identity: stat
before acquiring, re-stat after, and write nothing when they differ. Identity is
presence plus size, mtime and inode — `writeContractRegistry` publishes through
write-then-rename, so a real replacement always changes the inode even if size
and mtime happen to collide.

Deliberately NOT keyed on `generatedAt`, for two independent reasons. It is
stamped when the registry object is built, before the lock is acquired, so a
winner that waited would write a value older than the loser's start. And the
preserve path carries it forward verbatim by design — it dates the contracts,
not the write — so after any preserve sync it does not date the write at all,
leaving the comparison blind on exactly the pairing this guards. A file-identity
compare also needs no cross-process clock agreement.

The skip reports the existing `preserved` outcome. Nothing was written and a
prior registry was kept, which is what that value already means; a new one would
falsify the guard asserting the sync tool's description names every reachable
outcome, and would fall through the CLI's outcome chain, which has no fallback.

The bridge metadata refresh is skipped too, which the plan did not specify.
`refreshPreservedBridgeMeta` stamps THIS run's repo lists into meta.json, and
meta.json is where cross-repo impact reads completeness — so writing it would
report as unaccounted-for exactly the repos the winning sync had just accounted
for. That is the same downgrade being refused, one file over. Skipping both is
what makes `preserved` an honest answer here.

Mutation-verified: removing the after-stat and the skip turns the three decisive
cases red while both non-misfire controls stay green.

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

* refactor(group): run the bridge swap inside the caller's critical section

The bridge swap needed the group lock, and could not take it: `syncGroup`
already holds it when it calls `writeBridge`, and `acquireIndexLock` is not
reentrant. Acquiring inside the swap would deadlock every sync on the happy
path rather than on an edge case.

So the body splits the way this repo already splits this shape — a lock-free
`writeBridgeUnlocked` whose precondition is that the caller holds the lock, and
a thin `writeBridge` wrapper that acquires it for direct callers, mirroring
`registerRepoUnlocked` / `withRegistryLock`. `syncGroup` calls the inner one;
everything else keeps calling `writeBridge` and is now serialized by it.

`writeBridge`'s exported signature is byte-identical to before, so no caller
changed and nothing about the exported surface moved.

The precondition is enforced by a comment naming the single production call
site, which is what the existing precedent does. A type could carry it, but the
repo's own answer to this question is a comment, and diverging here would make
this the odd one out for no additional guarantee.

`refreshPreservedBridgeMeta` is deliberately left unsplit. Its one caller is
already inside the critical section and it has no test callers, so an acquiring
wrapper would be dead code standing in for a guarantee the caller already
provides — and moving the lock inside it would be the second acquisition this
change exists to avoid.

Scope: this delivers writer-writer exclusion only. The reader-side promotion of
a leftover `.bak` into place runs on ordinary reads, outside any lock, and is
not claimed here — the pairing check remains the reader's defense. Confirmed as
live behavior while writing the crash-recovery test, which asserts on file
state rather than through `bridgeExists` for exactly that reason.

One test file beyond the two the unit named had to change: a suite mocks
`bridge-db` to inject a `writeBridge` failure and exercise the bridge-write
warning. Once the sync calls `writeBridgeUnlocked`, that fault was being
injected into a function the path no longer calls, and the test went red. The
mock is repointed.

Mutation-verified three ways. Pointing the sync back at the acquiring wrapper
deadlocks a single UNCONTENDED sync — the evidence that the nesting defect is
real and that this split is what prevents it. Removing the wrapper's
acquisition turns the direct-write exclusion case red. Making the lock-free half
acquire for itself turns the held-lock case red.

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

* test(hygiene): reach every tracked text file with the raw-byte guard

The guard claimed to protect tracked source from a raw NUL — the byte that
makes git classify a file binary and costs it its diff, its inline comments and
its three-way merge on GitHub. It matched on an end-anchored extension regex
covering the JavaScript family, so most of what this repo tracks was never
looked at: JSON, YAML, TOML, Markdown, snapshots, SQL, protobuf, the .NET
project files, the shell and batch scripts.

Worse, an extension regex cannot reach a file that has none. `Dockerfile`,
`CODEOWNERS`, `LICENSE`, the husky hook and every bare dotfile were unreachable
by construction — no amount of widening the pattern would have covered them —
so a second basename filter had to exist for the claim to be true.

It stays an allowlist rather than becoming "everything git tracks", because the
repo legitimately tracks binaries whose extensions must stay out.

The two filters together now collect every one of the 5000 tracked files except
31 — the 30 native prebuilds and one PNG — and those 31 are exactly the files
that carry a NUL. The allowlist no longer has a gap that is not a genuine
binary.

The planted-fixture cases route through the collector's own predicate rather
than straight into the scanner. The pre-existing fixture test bypassed the
filter entirely, so it could only ever prove the byte locator worked, never
that the collector would hand it the file — which is precisely how the gap
survived.

Mutation-verified both ways: removing the basename filter drops `.gitignore`
and `Dockerfile` from the planted results, and reverting the extension regex
drops `.json` and `.md`.

One added case is a preservation pin rather than proof — that tracked binary
formats stay out passes either way, and guards the allowlist from becoming a
denylist later.

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

* test(hygiene): stop the byte guard reading the vendored grammar tree

Widening the guard to every tracked text format also pulled in the vendored
tree-sitter grammars, and those are where the bytes are: four generated
`parser.c` files come to 62 MB between them, Kotlin's alone 33.7 MB. Excluding
that root drops 76 files but 66% of the bytes the scan reads — 97 MB down to
33 MB.

The exclusion is a single anchored prefix, matched case-sensitively with
`startsWith`, and both halves of that matter. A `vendor` path-SEGMENT match
would also drop first-party fixtures this repo tracks under directories named
`vendor` and `Vendor` — a Kotlin one, a PHP one, and three files under
gitnexus-web — silently narrowing coverage while the assertion pinned the loss
in place. Case-insensitivity would do the same to a `Vendor` directory at the
excluded root's own level.

The root is named in the guard itself, so the claim that it covers every
tracked text file stays honest about the one place it deliberately does not
look.

The cost comment was wrong and is now measured rather than estimated. It said
"the scan is ~10 ms" — ambiguous between locating the byte and reading the
files, and stale in its byte basis. Locating is ~14 ms; the reads dominate it
by two orders of magnitude, which is the actual reason for the concurrency pool
and the actual reason this exclusion is worth having. Every figure was
re-derived from the finished file rather than carried over from a draft.

The header's claim that `git ls-files` "never descends into vendor" was already
false — vendored code is tracked, so all 106 of its files were being reported
and read. Corrected here, where the distinction becomes load-bearing.

Registered in the cross-platform list first and given a shard weight second.
The weight table is only consulted for files already in that list, so a weight
entry alone is inert and the shard test filters unregistered keys without
complaining. The three-way split stays within 1.01x of ideal.

Mutation-verified three ways: a case-insensitive segment match, a
case-sensitive segment match, and a case-insensitive anchored prefix each turn
an assertion red.

The casing half was initially unfalsifiable — nothing tracked is named
`gitnexus/Vendor/`, so a tracked-set assertion could not distinguish it. Rather
than leave the claim unpinned or invent a fixture, it is pinned on the
predicate with a synthetic path; the tracked-set assertions pin the anchoring.

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

* test(group): make the strict-read test able to see which read ran

The file bound both registry exports to one mock:

    readRegistry:       (...args) => readRegistryMock(...args),
    readRegistryStrict: (...args) => readRegistryMock(...args),

so the case named for the strict read asserted a behavior it could not
attribute. Point the production call at the lenient export and every assertion
still holds, because the mock answers the same way whichever one is called.

That is not a hypothetical. With this file as it was, and `syncGroup` mutated to
call `readRegistry` instead of `readRegistryStrict`, all 32 tests passed — the
suite was blind to the exact substitution it exists to prevent, and the fix it
guards could have been reverted without a single red.

The exports now have separate mocks: the lenient one always resolves an empty
list, which is its real contract, and only the strict one is armed by the cases
that need a failure. The named case also asserts directly that the strict read
was called and the lenient one was not, so the attribution is explicit rather
than implied by an outcome.

No tests added — the unit is about what the existing ones can see.

Mutation-verified: the same substitution now turns 24 cases red, including the
named one, and everything stays green unmutated.

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

* test(group): pin the CLI output branches this PR introduced

The three sync outcomes and the status table's new labels had no assertions.
Every one of them is a sentence about what happened on disk, and this PR
corrected several that were false — a preserve branch that announced it had not
written the file it rewrites, a status table that called an unreadable registry
a missing entry. Text that describes state, with nothing pinning it, is how
those got wrong in the first place.

Six cases drive the real CLI end to end, through the two shapes that need no
indexed repo: members absent from the registry, and members registered at a
storage path with no index file, which makes every repo unreadable. The file
header claimed no LadybugDB-backed command was driven end to end; that is no
longer true and it now says so.

Each branch was suppressed in turn and its assertion goes red — all five that
the plan named.

One of those mutations first reported PASS, and the cause is worth recording: the
string being suppressed also appears inside a neighbouring branch's comment, so
the harness silenced the wrong line. That is a bad mutation, not a weak test.
The harness now asserts the marker it suppresses is unique before trusting the
result, and the redone check goes red.

The plan's sixth scenario is already covered by an existing case that asserts
both labels in one table, so it is not duplicated. A seventh case was added
beyond the plan: without a populated-list case, "prints neither line" would pass
just as well against a CLI that never printed that line at all.

Adds about 15s of measured spawn time locally; CI runs these against the built
dist, which is materially faster per spawn.

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

* test(group): assert the MCP payloads by exact shape, not by partial match

Nothing asserted what the group tools actually return. The sync response's
unreadable list and registry outcome, and the contract listing's incompleteness
fields, are documented in the tool descriptions an agent reads — and could have
been dropped in a refactor without a single test noticing.

The assertions are exact-shape rather than partial. A `toMatchObject` would let
a dropped key pass, which is precisely the regression these exist to catch: the
failure mode is an absent field, and a partial match is defined not to see one.
Absences are additionally asserted explicitly.

The tri-state has to survive the response boundary, and it is the reason exact
shape matters here more than usual. An absent `unreadableRepos` means the sync
never recorded what it could read, so the listing is a floor; an empty list
means it measured none; a populated list names them. Collapsing absent into
empty turns "we do not know" into "we checked, it is fine" — so a mutation that
replaces the conditional spread with `?? []` is covered specifically, not just
the outright deletion.

Mutation-verified per field: removing either sync forwarding line, deleting the
conditional spread, replacing it with the invent-empty form, dropping the
truncation triple, or hardcoding the provenance flag each turns an assertion
red.

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

* docs(group): stop the bridge input narrowing what unreadableRepos means

The same field had three definitions. The registry and the bridge metadata both
say it covers a repo this sync could not extract from — an index that would not
open, or an extractor that threw partway through, one bucket because the
consequence is one thing. The bridge input said only "whose index could not be
opened", which describes one cause and silently excludes the other.

It now points at the registry's definition instead of restating it a third
time. A definition written once and referenced cannot drift; three copies of it
already had.

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

* docs(group): record what the mtime pairing does and does not prove

The write-order fallback is a heuristic standing in for provenance, and a
future reader deciding whether to lean on it needs to know where it breaks
before they do. Both directions are now stated where the function is read
rather than only in the plan that introduced it.

The false-accept direction is a non-monotonic wall clock — mtime is realtime,
so an NTP step back, a snapshot restore, or container skew between the two
writes can leave a mis-paired set reading as ordered. Coarse filesystem
granularity is explicitly called out as NOT being that hazard, because it looks
like it: it collapses a pair written together to equal times, and equal is
accepted, which is the right answer for that pair.

The false-reject direction is any copy or restore that rewrites the database's
mtime after the metadata's. An intact legacy pair is demoted to a lower bound
and stays there until a sync re-stamps it, because nothing on the read path can
tell it apart from the swap window it imitates.

That second direction corrects a claim made while planning this work: that the
rule could only ever demote pairs already broken. It cannot. `cp -r` and
`rsync` without timestamp preservation both produce it on a healthy group, and
saying otherwise where the code is read would leave a future reader to discover
it the hard way.

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

* fix(storage): stop a corrupt registry quoting its own bytes into errors

`JSON.parse`'s SyntaxError embeds a window of the source around the failure —
V8 gives exactly ten characters either side — and the strict read rethrew it
untouched. The registry persists HTTPS remote URLs with their userinfo, so a
file that breaks next to one puts the credential into the error:

    Unexpected token 'L', ..."end.git"},LEAKCAN4RY"... is not valid JSON

The parse now has its own guarded region and reports the path and the failure
class, matching the two corrupt-registry errors already in this function.

The original error is discarded — not logged, not attached as `cause`. This
codebase's convention elsewhere is to hand the logger the Error so it captures
stack and cause, and following that convention here is precisely what would put
the byte window into the log. Under MCP stdio that log is written to the
client's log file on disk, so the thrown-error channel was never the only one
that mattered. The `catch` takes no binding, so the error cannot be reused by
accident later.

That was not theoretical: a sibling commit routes this message into
`unresolvableReason`, which `group status` returns to MCP clients and prints in
the CLI table. Every channel was traced — throw, cause, inspect with the full
chain, the logger, and both downstream consumers.

The leaking shape is narrower than it first appears, and worth recording. The
windowed message only fires when the parser fails at a value-start or trailing
position; a break inside a quoted string yields an unterminated-string error
carrying no window. So a plain mid-URL truncation does not leak — a short write
landing over a longer one does, leaving a URL fragment where a value was
expected. That is a reachable shape for the one machine-wide file every
gitnexus process writes.

The test asserts the message still names the path and the corruption class, not
only that the secret is absent. Asserting absence alone would stay green if the
message became empty.

Mutation-verified: restoring the raw rethrow brings the token back verbatim.

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

* docs(storage): drop the stale lenient call-site count

The docstring said keeping `readRegistry`'s signature untouched leaves "its
nine other call sites" unaffected. There were thirteen when the discrepancy was
noticed and fourteen by the time it was fixed. The same figure appeared in the
test file's header.

Replaced rather than corrected. A count in prose next to code that moves is a
claim that goes stale without anything failing — which is the defect class this
change set exists to remove, so re-seeding a fresh number would be repeating it
with a longer fuse. The argument was never about the quantity: leaving the
signature alone keeps every lenient caller provably unaffected whether there is
one or fifty.

Also withdrawn while here: the claim that the bridge schema-version guards
diverge between call sites. They do not — the two forms are complements for
every value a writer can produce, there are three sites rather than the two
claimed, and all three agree. Recording a divergence that does not exist would
leave a future reader chasing it.

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

* docs(group): add an auditable finding-to-commit map

The Definition of Done claims every review finding has exactly one commit and
that reverting it reintroduces that finding and no other. Without a map that
claim is only checkable by whoever holds the review report, which is one person
for a short time.

The map lists all 28 primary findings against their commits, the three findings
whose suggested fix was deliberately not implemented and what shipped instead,
and the four defects found while executing that no reviewer raised.

It also records the revert contract honestly. Revertability is
dependency-aware, not absolute: the shared completeness helper has three
consumers, so reverting it alone does not build. That coupled set is named
rather than left for someone to discover mid-revert.

Two sections exist because the work produced them, not because the plan asked.
Six claims in the plan turned out to be contradicted by the code — among them a
scope predicate that would have reintroduced the bug its unit was closing, and
an assertion about the mtime rule that was simply wrong. Recording only the
findings would leave the impression the plan was followed as written. Five
residual risks are listed for the same reason, including that R14 is not met on
this PR: the diff attribute works locally but GitHub reads it from the base
side, so this PR's own sync.ts stays binary in the web view and every PR after
it renders as text.

Not under docs/ — that path is gitignored, so a map written there would never
reach the PR and the audit it exists for could not be performed by anyone else.

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

* fix(group): read a version that is not a version as no provenance

Raised by the check bot on this PR, and real — the bot found one symptom of it;
the field splits four gates apart, not one.

`readBridgeMeta` accepted any numeric `version`, and `0` is this file's word for
"no provenance". A parseable but impossible value — negative, fractional — is
not a schema version, and each gate that reads the field disagreed about it:

  ensureBridgeReady      `> 0 && !== CURRENT`  → opens the bridge
  openBridgeDbReadOnly   `> 0 && !== CURRENT`  → opens the bridge
  bridgeExists           `=== 0 || === CURRENT` → says it is not there
  bridgeProvenanceUnknown `=== 0`               → reports the answer complete

Four verdicts about one file, and the last one is a fail-open of exactly the
class this PR exists to close: a bridge nothing can vouch for, reported as
fully accounted for.

The suggested fix was to widen the provenance check to `<= 0`. That closes the
reported symptom and leaves `bridgeExists` still disagreeing with both openers,
so it is fixed at the reader instead: a version that is not a positive integer
normalizes to the sentinel the gates were all written against. One change, four
gates agreeing by construction, rather than teaching each of them the same new
case and hoping the fifth reader remembers.

Infinity is covered too, though by the pre-existing type check rather than the
range one — JSON cannot carry it, so it arrives as `null`. Recorded at the test
so the case is not mistaken for proof of the range check.

Mutation-verified: restoring the loose numeric check turns the negative and
fractional cases red.

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

* fix(group): stop a malformed contracts.json reading as an unresolvable registry entry

Raised by the check bot on this PR. Its stated mechanism was wrong — `loadMeta`
returns null on every error and `checkStaleness` catches everything, so neither
can throw — but its conclusion was right, and there is a concrete path it did
not name.

`readContractRegistry` is a bare `JSON.parse(content) as ContractRegistry` with
no shape check, and the snapshot lookup guarded only the registry object:

    registry?.repoSnapshots[repoPath]

The `?.` covers `registry` being null, not `repoSnapshots` being absent. A
contracts.json without that field — a legacy file, a hand-edit, a truncated
write — throws `TypeError: Cannot read properties of undefined`, which lands in
the catch that labels failures as unresolvable GLOBAL-registry entries. So a
group whose own contracts file is malformed reported every repo as a broken
registry row, sending the operator to repair a file that was fine.

An error from one cause presented as another, which is the defect this PR has
been removing everywhere else.

The optional chain closes the crash. The try is also narrowed to the call that
earns the label: only `resolveRepo` sits inside it now, so "did not resolve"
describes something that actually failed to resolve rather than whatever else
happened to throw nearby. The comment records why the other two calls in that
block cannot throw, so the next reader does not have to re-derive it.

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

* refactor(group): give the completeness fold a module no native binding reaches

The shared fold ended up in `cross-impact.ts`, which statically imports
`bridge-db.ts` and through it the native LadybugDB binding. `groupContracts`
therefore reached it through `await import('./cross-impact.js')` — loading that
whole module graph to run a Set union and a ternary. Measured: 44-51ms and
8.4MB of RSS on first call, paid once per MCP server and once per
`gitnexus group contracts` invocation.

`completeness.ts` holds the vocabulary and the fold and imports nothing but
types. `service.ts` imports it statically; the lazy import and the comment
justifying it both go. `cross-impact.ts` re-exports so the three surfaces still
have one import site for the vocabulary.

Three other duplications collapse into the same move.

`traceCompleteness` was hand-writing `{truncated, truncationReason,
riskEpistemic}` — a third writer of the pair `truncationFields` exists to keep
mechanically linked (#2787), in the file the consolidation had just touched. It
calls the helper now.

`recordedRepoList` existed twice, byte-identical, one copy's docblock saying it
mirrored the other. That gate is the predicate the whole
absent-vs-empty-vs-populated distinction rests on, applied to the same two
lists on both the registry and the bridge — tightening one copy would have
fixed one surface silently. One definition now.

The trace's scope predicate compared repo paths with `===` while its sibling in
`cross-impact.ts`, added in the same change, went through `repoInSubgroup` with
a comment about not growing a second notion of membership. It had grown one:
the helper normalizes separators and strips trailing slashes, so the same
group.yaml spelling could be in scope for impact and out of scope for trace.

Also here: `registryIdentifies` was a third, weaker copy of the registry's path
rule — it skipped `realpath`, so a symlinked row would not match where the real
resolver would. It uses `canonicalizePath`/`registryPathEquals` now.
`contracts.json` is no longer respelled as a literal in `sync.ts`; `storage.ts`
owns the name it reads and writes. And the runtime-truncation predicate is
bound once instead of written out at both the flag and the reason, where
forgetting the second would label a retry-able answer `incomplete-sync`.

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

* fix(group): give the lost-the-race sync its own outcome instead of overloading preserved

A sync that finds contracts.json replaced while it waited for the lock reported
`registryOutcome: 'preserved'`. That value already meant something else, and the
two differ in exactly the thing the value is for: `preserved` rewrites the file
with this run's diagnostics; this path does not touch it and deliberately does
not record them.

So both surfaces stated something false about disk. The tool description told
agents `preserved` means "contracts.json was rewritten ... refreshing only
missingRepos/unreadableRepos to describe THIS run (the file changed)". The CLI
said "only the unreadable/missing repo lists were refreshed to describe THIS
run". On the lost-race branch nothing was written and the log line beside it
says so outright.

That is the defect class this whole change set removes, reintroduced by the
change set itself — and the reasoning recorded at the time makes it worse, not
better: a new value was rejected because it "would fall through cli/group.ts's
outcome chain, which has no fallback branch". A renderer limitation decided a
domain value, and the description then had to cover two states with one
sentence that fits one of them.

`superseded` is its own outcome now, described in its own words to agents and
rendered in its own words at the CLI. The registry on disk is FRESHER than this
response's diagnostics, which is the opposite of every other non-written
outcome and is why an agent needs to tell them apart.

The CLI renders from a `Record` keyed on the union, so the next outcome fails
the build here rather than printing nothing — the gap that made folding the
state in look like the cheap option.

The description guard is scoped per clause rather than over the whole string.
It forbade "untouched" anywhere, which was right when one clause could only lie
in that direction and wrong now that another clause is accurately untouched. It
also asserts the superseded clause says so, or the two collapse back into one
word for two states.

Found by the quality pass over this branch, not by review.

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

* test(group): read bytes and stat through one handle, not two path lookups

CodeQL flagged both sites as `js/file-system-race`, high severity, and it is
right about the shape. `stat(path)` followed by `readFile(path)` is two
independent path resolutions with a window between them — the classic
check-then-use race.

It also made the assertions weaker than they read. These two tests exist to
prove a specific file was left untouched, and two lookups can land on different
inodes, so "the bytes and the mtime are both unchanged" was not actually a
statement about one file. The distinction is the whole point here rather than a
technicality.

`snapshotFile` opens the path once and takes both answers from that handle. The
race is gone because there is no second lookup, and the assertion now genuinely
concerns one inode.

I had previously triaged these as below the ruleset's threshold and left them
for the repository owner. That was wrong: they carry
`security_severity_level: high`, and the branch ruleset gates on
`high_or_higher`, so they were blocking the merge rather than sitting under it.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
This commit is contained in:
DuduPhudu 2026-08-26 11:37:16 +03:00 committed by GitHub
parent 031e123731
commit 2c0fb7753c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 8129 additions and 91 deletions

12
.gitattributes vendored
View file

@ -15,3 +15,15 @@
*.so binary
*.dll binary
*.dylib binary
# TypeScript sources are always text for diff purposes. Git's binary
# heuristic fires when EITHER blob in a pair carries a NUL, so a source
# file that carried one on a base commit still renders as "Binary files
# differ" — with no hunks and no inline comments — long after the byte
# itself is gone from the working tree. A head-side guard cannot see
# that, by construction. This does not mark the files binary or change
# how they are stored; it only stops the heuristic from hiding a diff.
*.ts diff
*.tsx diff
*.mts diff
*.cts diff

View file

@ -65,6 +65,16 @@ export const WINDOWS_WEIGHTS_SEC: Readonly<Record<string, number>> = {
'test/integration/antigravity-hook-e2e.test.ts': 7,
'test/unit/index-lock.test.ts': 5,
'test/unit/setup.test.ts': 5,
// ESTIMATE, not a measurement. This file asserts almost nothing; it READS —
// one 4893-file pass over every tracked text file, plus an 830-file pass over
// `src/`. Measured at 2.3 s and 0.3 s per pass on a virtualised and a local
// Linux filesystem respectively, so the cost is entirely per-file open
// latency, which is the term Windows inflates most (NTFS plus Defender on
// every read). Scaled from the slower Linux figure to keep the split
// conservative rather than let the 8 s PER_FILE_OVERHEAD floor under-charge
// a file that touches more paths than anything else here. Replace with a real
// figure after the first green Windows matrix run.
'test/unit/source-control-bytes.test.ts': 15,
};
/**

View file

@ -208,6 +208,18 @@ const SPAWN_CLI = [
// exposed a file-backend double-admit race here (#2658 review); the reclaim is
// now judgment-verified so a live holder is never displaced.
'test/integration/analyze-index-lock-concurrency.test.ts',
// The per-group sync lock (R9), same class of guarantee one level up: real
// child processes contend for one group's lock while this process runs a real
// `syncGroup`, and the CLI case spawns the real command. Everything that
// varies here is platform-owned — which backend `selectBackend()` picks
// (Windows named pipe / Linux abstract socket / macOS file lock), kernel
// auto-release on SIGKILL vs. the file backend's pid-liveness reclaim, and
// `mkdir` over an occupied path. The fail-closed cases pin
// GITNEXUS_INDEX_LOCK_BACKEND=file so the filesystem branch is exercised on
// every OS rather than only where it is the default; no case is skipped on
// any platform, because a skipped case turns "a sync that cannot be protected
// does not run" into a claim that holds on Ubuntu only.
'test/integration/group/group-sync-lock-concurrency.test.ts',
// The three `dist/` module-load closure guards, all built on the shared
// child-process probe in `test/helpers/module-load-probe.ts`. That probe IS
// the platform-varying part: it spawns `process.execPath` in array form,
@ -261,6 +273,28 @@ const FILESYSTEM = [
'test/integration/filesystem-walker.test.ts',
'test/integration/markdown-processor-crlf.test.ts',
'test/integration/ignore-and-skip-e2e.test.ts',
// Pins that the bridge pairing verdict is measured before the database is
// opened. The property it protects is about mtime behavior across OS and
// filesystem, and the alternative — really opening the bridge — cannot run on
// Windows at all (in-process write→read reopen of the same bridge.lbug is a
// documented limitation). Running it on every platform is the whole point:
// Windows is where an unverified assumption about mtime would hurt most.
'test/unit/group/bridge-pairing-precedes-open.test.ts',
// The raw-control-byte guard reads every tracked text file `git ls-files`
// reports — 4893 of them — and decides membership from the git path, which is
// always `/`-separated no matter what the host separator is. Both halves of
// that are platform-varying: the collector basename-matches with
// `path.posix.basename` against `git ls-files -z` output while the reads go
// through `path.join`, so on Windows the same string is consumed under two
// separator conventions in one pass, and only a real windows-latest run
// proves they agree. It is also the file-count-heaviest read loop in the
// suite, so it is where a per-file filesystem cost (NTFS + Defender, or
// macOS's slower stat path) would show up first. No case is skipped on any
// platform: a guard that only holds on Ubuntu is not a guard on the file
// whose NUL it exists to catch. Budget: the heaviest single case is one
// 4893-file pass — 2.3 s on a slow virtualised filesystem, 0.34 s on a local
// disk — against a 30 s testTimeout.
'test/unit/source-control-bytes.test.ts',
];
const ALL_CROSS_PLATFORM = [

View file

@ -1,6 +1,7 @@
// gitnexus/src/cli/group.ts
import { createRequire } from 'node:module';
import type { Command } from 'commander';
import type { RegistryWriteOutcome } from '../core/group/sync.js';
import { logger } from '../core/logger.js';
const _require = createRequire(import.meta.url);
@ -120,16 +121,42 @@ export function registerGroupCommands(program: Command): void {
indexStale: boolean;
contractsStale: boolean;
missing: boolean;
/**
* Optional here on purpose: a payload produced before the split
* carries no such key, and an absent one must degrade to the
* label this command has always printed rather than to the new
* one an unrecorded cause is not evidence of a cause.
*/
unresolvable?: boolean;
unresolvableReason?: string;
commitsBehind?: number;
}
>;
missingRepos?: string[];
unreadableRepos?: string[];
};
console.log(' Repo index / contracts staleness:');
for (const [repoPath, row] of Object.entries(st.repos || {})) {
if (row.missing) {
console.log(` ${repoPath.padEnd(25)} MISSING (not in registry or unreadable)`);
// Two different facts with two different remedies: a repo the
// registry never heard of is fixed by indexing it, while an entry
// the resolver choked on is fixed by repairing the registry.
// Printing "no entry in the registry" for the second one states a
// cause that was never measured, and points at the wrong repair.
if (row.unresolvable) {
// The reason can be multi-line — an ambiguous registry names
// every colliding clone. Fold it onto this row's line rather
// than truncating it: those paths are what the operator acts on,
// and a table row that swallows half its own explanation is the
// failure this label exists to stop.
const why = (row.unresolvableReason ?? 'the registry entry could not be resolved')
.replace(/\s+/g, ' ')
.trim();
console.log(` ${repoPath.padEnd(25)} UNRESOLVABLE (${why})`);
continue;
}
console.log(` ${repoPath.padEnd(25)} MISSING (no entry in the registry)`);
continue;
}
const idx = row.indexStale
@ -138,6 +165,26 @@ export function registerGroupCommands(program: Command): void {
const ctr = row.contractsStale ? ' CONTRACTS_STALE' : '';
console.log(` ${repoPath.padEnd(25)} ${idx}${ctr}`);
}
// `undefined` and `[]` are different answers here: a registry written
// before this was tracked has no opinion, while an empty array is a
// measurement. Printing nothing for both would let an unmeasured sync
// read as evidence that every index opened cleanly.
//
// `undefined` covers two ways of not knowing — the field is absent, or
// it held something that was not a list of repo paths and `getStatus`
// declined to guess. Naming only the first would make a corrupt
// registry read as a merely old one, which is the same shape of wrong
// answer this command exists to stop giving.
const unreadable = st.unreadableRepos;
if (unreadable === undefined) {
console.log(
`\n Last sync unreadable repos: not recorded` +
`\n (the registry predates this field, or its value could not be read)` +
`\n Re-run \`gitnexus group sync\` to record it.`,
);
} else if (unreadable.length > 0) {
console.log(`\n Last sync unreadable repos: ${unreadable.join(', ')}`);
}
if ((st.missingRepos || []).length > 0) {
console.log(`\n Last sync missing repos: ${st.missingRepos!.join(', ')}`);
}
@ -158,30 +205,93 @@ export function registerGroupCommands(program: Command): void {
const { getGroupDir, getDefaultGitnexusDir } = await import('../core/group/storage.js');
const { loadGroupConfig } = await import('../core/group/config-parser.js');
const { syncGroup } = await import('../core/group/sync.js');
const { GroupSyncLockError } = await import('../core/group/group-lock.js');
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
const config = await loadGroupConfig(groupDir);
console.log(`Syncing group "${name}" (${Object.keys(config.repos).length} repos)...\n`);
const result = await syncGroup(config, {
groupDir,
allowStale: Boolean(opts.allowStale),
verbose: Boolean(opts.verbose),
skipEmbeddings: Boolean(opts.skipEmbeddings),
exactOnly: Boolean(opts.exactOnly),
});
let result: Awaited<ReturnType<typeof syncGroup>>;
try {
result = await syncGroup(config, {
groupDir,
allowStale: Boolean(opts.allowStale),
verbose: Boolean(opts.verbose),
skipEmbeddings: Boolean(opts.skipEmbeddings),
exactOnly: Boolean(opts.exactOnly),
});
} catch (err) {
// A sync that could not take the group's lock did NOT run and wrote
// nothing (R9 fails closed). That is an operator-actionable outcome, not
// a crash, so report it as a failed command rather than letting it
// surface as an unhandled rejection with a stack trace — commander's
// async actions have no error handler, so an uncaught throw here would
// print exactly that.
if (!(err instanceof GroupSyncLockError)) throw err;
logger.error(`⚠️ Did not sync group "${name}": ${err.message}`);
process.exitCode = 1;
return;
}
if (opts.json) {
console.log(JSON.stringify(result, null, 2));
} else {
// Repos we could not read are the most likely explanation for a small
// or empty contract count, so they are reported before the counts —
// otherwise a run that read nothing looks exactly like a clean run.
if (result.unreadableRepos.length > 0) {
// No "re-run with GITNEXUS_LOG_LEVEL=warn" hint: the default level is
// `info`, and pino emits `warn` (40) at `info` (30), so the reason was
// already printed by this same run — raising the level to `warn` would
// only suppress the surrounding `info` output.
console.log(
`\n ⚠️ Could not extract contracts from: ${result.unreadableRepos.join(', ')}` +
`\n None of their contracts are included in this sync (the warning above says why),` +
`\n or check \`gitnexus doctor\` in the affected repo.`,
);
}
if (result.missingRepos.length > 0) {
console.log(
`\n ⚠️ Not found in the registry: ${result.missingRepos.join(', ')}` +
`\n Index them with \`gitnexus analyze\`, or remove them from group.yaml.`,
);
}
console.log(`\nMatching cascade:`);
const exactLinks = result.crossLinks.filter((l) => l.matchType === 'exact');
console.log(` exact: ${exactLinks.length} cross-links (confidence 1.0)`);
console.log(` unmatched: ${result.unmatched.length} contracts`);
console.log(
`\nWrote contracts.json (${result.contracts.length} contracts, ${result.crossLinks.length} cross-links)`,
);
// Driven by what actually happened to the file. This line used to be
// unconditional, so a run that deliberately preserved the previous
// registry still announced `Wrote contracts.json (0 contracts, 0
// cross-links)` — a confident false statement about persisted state, on
// the exact path this command exists to make legible.
// Exhaustive by construction: a `Record` keyed on the union means a
// new outcome fails the build here instead of printing nothing, which
// is what previously pushed a distinct state into `preserved` and made
// this summary false on one of the two branches it then covered.
const OUTCOME_LINE: Record<RegistryWriteOutcome, string | null> = {
written:
`\nWrote contracts.json (${result.contracts.length} contracts, ` +
`${result.crossLinks.length} cross-links)`,
preserved:
`\nKept the previous contracts.json — no repo in this group could be read.` +
`\n Its contracts and cross-links are unchanged; only the unreadable/missing` +
`\n repo lists were refreshed to describe THIS run. Fix the repos above and re-run.`,
superseded:
`\nDid NOT touch contracts.json — no repo in this group could be read, and another` +
`\n sync replaced the file while this one waited for the group lock. That sync's` +
`\n result stands and this run's repo lists were NOT recorded: they describe a` +
`\n group state older than what is on disk. Fix the repos above and re-run.`,
'no-prior-registry':
`\nDid NOT write contracts.json — no repo in this group could be read,` +
`\n and there is no previous contracts.json to fall back on. Fix the repos` +
`\n above and re-run.`,
// Nothing to say: the caller asked for no write.
'not-attempted': null,
};
const line = OUTCOME_LINE[result.registryOutcome];
if (line) console.log(line);
}
});
@ -370,7 +480,7 @@ export function registerGroupCommands(program: Command): void {
return;
}
const { contracts, crossLinks } = raw as {
const { contracts, crossLinks, truncated, unreadableRepos, missingRepos } = raw as {
contracts: Array<{
role: string;
contractId: string;
@ -384,10 +494,19 @@ export function registerGroupCommands(program: Command): void {
confidence: number;
contractId: string;
}>;
truncated?: boolean;
unreadableRepos?: string[];
missingRepos?: string[];
};
if (opts.json) {
console.log(JSON.stringify({ contracts, crossLinks }, null, 2));
// The whole payload, not a re-serialized subset. Destructuring the two
// fields this command happens to print and rebuilding an object from
// them dropped everything else the service returned — which is how the
// completeness fields were invisible here while the MCP tool carried
// them. Printing `raw` means a field added to the service reaches
// `--json` without a matching edit in this file.
console.log(JSON.stringify(raw, null, 2));
} else {
console.log(`Contracts (${contracts.length}):`);
for (const c of contracts) {
@ -399,6 +518,19 @@ export function registerGroupCommands(program: Command): void {
` ${l.from.repo} -> ${l.to.repo} [${l.matchType}, conf=${l.confidence}] ${l.contractId}`,
);
}
if (truncated) {
// Counts above are a floor, not a census. Name the repos when the
// registry recorded them, and say so plainly when it did not — a
// listing that cannot say what it is missing is still incomplete.
const absent = [...(unreadableRepos ?? []), ...(missingRepos ?? [])];
console.log(
absent.length > 0
? `\n⚠ This listing is incomplete: the last sync could not account for ${absent.join(', ')}.` +
`\n Contracts from those repos are absent, so the counts above are a lower bound.`
: `\n⚠ This listing is incomplete: the last sync did not record which repos it could` +
`\n read, so the counts above are a lower bound. Re-run group sync.`,
);
}
}
} finally {
await backend.dispose().catch(() => {});

View file

@ -0,0 +1,107 @@
# Review findings → commits (PR #3012)
Every finding raised in review of this PR, and the commit that closes it. The
Definition of Done claims each finding has exactly one commit and that reverting
that commit reintroduces that finding and no other; this is what makes the claim
checkable without the reviewer's report in hand.
**Not under `docs/`** — that path is gitignored, so a map written there would
never reach the PR and nobody but its author could perform the audit. It lives
beside the code it describes, as `PIPELINE.md` does.
## Revert contract
Revertability is **dependency-aware**. Where one commit extracts a helper that
later commits consume, reverting the helper alone does not build. The contract
is: reverting a commit reintroduces its own finding and no other _finding_, with
its prerequisite commits retained.
One coupled set exists:
| Set | Commits | Why coupled |
| -------------------------- | --------------------------------------------------- | -------------------------------------------------------------------------------------- |
| Shared completeness helper | `4c203ac7b``79f6f5bcb`, `0fe6fc9d4`, `dbc3953b0` | The three consumers call `crossRepoCompleteness`; reverting it alone breaks the build. |
## Primary findings
| # | Finding | Commit |
| --- | -------------------------------------------------------------------------------- | ----------- |
| 1 | Malformed `meta.json` crashes cross-repo impact and leaks the bridge handle | `27b0069f2` |
| 2 | Unreadable repos still contribute contracts through deferred manifest resolution | `7037e8441` |
| 3 | Strict read accepts a registry row that cannot identify a repo | `5245b22d7` |
| 4 | Unstamped bridge metadata is trusted without any check | `94f2a8757` |
| 5 | A subgroup-scoped query is marked incomplete by repos it excluded | `79f6f5bcb` |
| 6 | The preserved registry and the bridge disagree about the same sync | `4676abf03` |
| 7 | Three surfaces compute completeness three different ways | `4c203ac7b` |
| 8 | `group_contracts` has no channel for its own completeness | `0fe6fc9d4` |
| 9 | `group status` cannot tell a missing entry from an unreadable registry | `a12b846c9` |
| 10 | The sync summary describes a write that did not happen that way | `5a668455c` |
| 11 | The total-failure log promises preservation where there is nothing to preserve | `c4b356b29` |
| 12 | The bridge-failure warning promises a truncation the code never reports | `1df79bb9a` |
| 13 | Two concurrent syncs of one group lose each other's writes | `4f07359bf` |
| 14 | The bridge swap needs the lock its caller already holds | `3b6215862` |
| 15 | The byte guard misses most tracked text files, and all extensionless ones | `07bf8be75` |
| 16 | The byte guard reads the vendored grammar tree it does not need to judge | `3ef831a0a` |
| 17 | The strict-read test cannot see which registry read ran | `eccc3c682` |
| 18 | The CLI branches this PR introduced have no assertions | `535d2ad29` |
| 19 | The MCP payloads have no assertions | `2c253b4a8` |
| 20 | Corrupt-registry errors quote the file's bytes, credentials included | `24ba2a537` |
| 21 | The mtime pairing's limits are recorded nowhere a reader will look | `ca0aca106` |
| 22 | The bridge-input docstring narrows what `unreadableRepos` means | `8c930f470` |
| 23 | The strict-read docstring's call-site count is wrong | `a95838954` |
| 24 | Contract staging crashes on the engine's argument limit | `57eac7558` |
| 25 | The sync tool's description names two of three reachable outcomes | `8bfd1a6ab` |
| 26 | The impact tool and status resource do not explain incompleteness | `dbc3953b0` |
| 27 | A lock timeout blames an `analyze` it cannot establish | `2d2a0119e` |
| 28 | A losing sync downgrades the one that beat it to the lock | `e407f05cf` |
## Findings raised in review and deliberately not implemented as suggested
| Finding | Suggested fix | What shipped, and why |
| ---------------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Unstamped metadata is trusted | Treat every absent stamp as incomplete | Rejected. It would mark every pre-existing bridge a lower bound until re-synced — a repo-wide regression traded for a narrow window. The write-order pairing in `94f2a8757` is the narrower fix. |
| Stale bridge signal after a failed write | Re-stamp the metadata so the warning's promise becomes true | Rejected. Re-stamping recreates the metadata/database mis-pairing that stamping exists to prevent. `1df79bb9a` corrects the warning instead. |
| Strict row gate | Require all three fields non-blank | Narrowed to `name` and `storagePath`. This gate rejects the whole registry, which is machine-wide, so a field tightened past what identification needs lets one blank value break every group sync on the machine. |
## Found during execution, not in the review
| What | Commit |
| --------------------------------------------------------------------------------------------- | ----------- |
| A half-written bridge stamp read as a verified match (found by the repo's own contract check) | `066f2d802` |
| `readBridgeMeta`'s widened return type blocked the merge on contract drift | `a9d281dd4` |
| `group contracts --json` discarded every field it did not re-serialize | `b7753575d` |
| `sync.ts` renders as a binary diff because the base blob carries a NUL | `1667c24b4` |
## Corrections to the plan, found while executing it
Recorded because each was a claim in the plan that the code contradicted.
| Claim | Reality |
| ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| The strict gate should require the fields "the resolution path consumes" | `defaultResolveHandle` **does** consume `path`. The distinction is what _identifies_ the repo. |
| Pass the trace's two endpoint repos as the scope predicate | A destination trace declares no `to`. Narrowing to `from` would report an unreadable provider as "no outgoing link". |
| Filter the incomplete set by the subgroup prefix | The query's own repo must stay in scope, or an unreadable origin becomes a confident "nothing depends on this". |
| `group status`'s third failure mode is a row that resolves but cannot be opened | Unreachable — `loadMeta` returns `null` on every error and `checkStaleness` catches everything. The reachable case is `resolveRepo` throwing. |
| The mtime rule can only demote pairs already broken | False. `cp -r` and `rsync` without `-t` demote an intact pair. Recorded at the code in `ca0aca106`. |
| `.scm` files are "edited constantly" here | Every tracked `.scm` is vendored. This repo writes tree-sitter queries inline in TypeScript. |
## Residual risks, recorded rather than closed
- **Credentials in the registry.** HTTPS remote URLs are persisted with their
userinfo intact. `24ba2a537` stops one channel echoing them; it does not stop
them being written. Pre-existing, tracked separately.
- **`readRegistryFile`'s read error.** The ENOENT-guarded outer catch still
rethrows the raw `fs.readFile` error into `unresolvableReason`. Node embeds
the path, not file contents, so no registry bytes leak — but it is the one
remaining foreign error object on that path.
- **Abstract-socket lock scope.** Linux abstract sockets are
network-namespace-scoped, so two containers sharing a bind-mounted group
directory do not contend unless the file backend is forced. Recorded at
`group-lock.ts`.
- **Scope filter at depth > 1.** The declared-scope intersection is sound only
while `MAX_SUPPORTED_CROSS_DEPTH` is 1. At depth 2 an out-of-scope repo can
sit between two in-scope ones. Recorded at the intersection site.
- **R14 is unmet on this PR.** `.gitattributes` makes TypeScript diffs render as
text, and it works locally — but GitHub resolves the attribute from the base
side, which does not carry it. `sync.ts` renders as binary in this PR's web
view and will render as text for every PR after this one merges.

View file

@ -5,12 +5,14 @@ import lbug from '@ladybugdb/core';
import type { LbugValue } from '@ladybugdb/core';
import type { BridgeHandle, BridgeMeta, StoredContract, CrossLink, RepoSnapshot } from './types.js';
import { BRIDGE_SCHEMA_QUERIES, BRIDGE_SCHEMA_VERSION } from './bridge-schema.js';
import { recordedRepoList } from './completeness.js';
import {
closeLbugConnection,
openLbugConnection,
type LbugConnectionHandle,
} from '../lbug/lbug-config.js';
import { dedupeContracts, dedupeCrossLinks } from './normalization.js';
import { withGroupSyncLock } from './group-lock.js';
import { createLogger } from '../logger.js';
import { retryRename, writeFileAtomic } from '../../storage/fs-atomic.js';
@ -650,13 +652,296 @@ export async function writeBridgeMeta(groupDir: string, meta: BridgeMeta): Promi
await writeFileAtomic(path.join(groupDir, 'meta.json'), JSON.stringify(meta, null, 2));
}
/**
* Does `meta` still describe the `bridge.lbug` sitting next to it?
*
* `writeBridge` stamps the database's size and mtime into the metadata it
* writes, so a metadata file left over from an earlier sync cannot match a
* database that was replaced after it. Callers whose answer depends on the
* metadata being true of THIS database (cross-repo impact reads completeness
* from it) must not treat a mismatch as fact.
*
* When BOTH halves of the stamp are absent the metadata predates stamping, and
* it is judged on the write order of the two files instead see
* {@link unstampedMetaPairsByWriteOrder}. Failing every unstamped metadata
* closed would mark all pre-existing bridges as incomplete until re-synced,
* trading a narrow window for a repo-wide regression; accepting them all hands
* back "verified" for the very window this pairing exists to catch.
*
* A stamp is a PAIR, so exactly one half present is rejected rather than waved
* through. That is not the legacy shape: something wrote a stamp and did not
* finish, which is the very condition stamping was added to detect. Joining the
* two `undefined` checks with `||` returned "verified" for precisely the shape
* that most deserves suspicion.
*
* Returns `false` when the database itself cannot be stat'd, on either path,
* since metadata describing a file that is not there describes nothing.
*
* The checks are ORDERED by how strong their evidence is, strongest first, and
* each later one is reached only because every earlier one had nothing to say.
* `provenanceUnknown` therefore comes first: a metadata file whose own writer
* says it cannot vouch for the database beside it has settled the question, and
* neither the stamp nor the write-order heuristic may overturn that.
*
* The marker is not decoration. `refreshPreservedBridgeMeta` rewrites this file
* atomically without touching the database, which leaves `meta.mtime` newer
* the write order a paired write produces, and the one the unstamped branch
* ACCEPTS. Reading the marker after that branch (or not at all) hands back
* "verified" for a pair the same code path had just found broken.
*/
export async function bridgeMetaMatchesFile(groupDir: string, meta: BridgeMeta): Promise<boolean> {
if (meta.provenanceUnknown) return false;
const stampedSize = meta.bridgeSize !== undefined;
const stampedMtime = meta.bridgeMtimeMs !== undefined;
if (!stampedSize && !stampedMtime) return unstampedMetaPairsByWriteOrder(groupDir);
if (!stampedSize || !stampedMtime) return false;
try {
const stat = await fsp.stat(path.join(groupDir, 'bridge.lbug'));
return stat.size === meta.bridgeSize && stat.mtimeMs === meta.bridgeMtimeMs;
} catch {
return false;
}
}
/**
* Could the unstamped `meta.json` plausibly have been written by the sync that
* put this `bridge.lbug` beside it?
*
* `writeBridge` renames the database into place and writes the metadata AFTER,
* so `meta.mtime >= db.mtime` holds for any pair written together including
* pairs written by builds from before the stamp existed, which is what makes
* this usable as back-compat rather than a repo-wide "re-sync everything".
* The only way to reach a database strictly NEWER than the metadata beside it
* is a swap whose metadata write did not land: the stale-meta-beside-a-new-
* database window, whose completeness `runGroupImpact` would otherwise spend as
* fact.
*
* This is a HEURISTIC ON WRITE ORDER, not proof of provenance. It answers "were
* these two written in the order a successful sync writes them?", and treats
* that as a proxy for "do these two belong together". It is wrong in two
* directions, and neither is theoretical:
* - FALSE ACCEPT, from a non-monotonic wall clock. `mtimeMs` is realtime, not
* monotonic, so an NTP step backwards, a VM snapshot restore or container
* clock skew between the database write and the metadata write can leave a
* genuinely mis-paired set reading as ordered. Anything that touches the
* stale metadata after a swap does the same a restore from backup, an
* editor save, a copy that preserves only the database's times. The STAMP
* is what actually closes this; a pair that has one never reaches here.
*
* Coarse filesystem mtime granularity is NOT this hazard, despite looking
* like it: it collapses a pair written together to equal times, and equal
* is accepted, which is the correct verdict for that pair.
*
* - FALSE REJECT, from anything that rewrites the database's mtime after the
* metadata's `cp -r`, `rsync` without `-t`, a machine move, a restore
* that replays files in directory order. An intact legacy pair is then
* demoted to a lower bound and stays there until the next successful sync
* re-stamps it; there is no other recovery, because nothing on the read
* path can distinguish it from the swap window it is imitating.
*
* This direction is the safe one it degrades an answer to a floor rather
* than vouching for one but it is a real, reachable cost, not a
* theoretical one, and it is NOT true that the rule can only ever demote
* pairs that were already broken.
*
* Equality counts as paired. On a filesystem with coarse mtime granularity both
* writes land in the same tick, and demanding a strictly newer metadata file
* would reject every legacy bridge there for a reason that is about the
* filesystem rather than about the bridge.
*
* A timestamp that cannot be measured is no match, the same convention the
* read-only handle cache applies to a bridge it could not stat: a comparison
* that could not be made is not a comparison that succeeded.
*/
async function unstampedMetaPairsByWriteOrder(groupDir: string): Promise<boolean> {
try {
const [dbStat, metaStat] = await Promise.all([
fsp.stat(path.join(groupDir, 'bridge.lbug')),
fsp.stat(path.join(groupDir, 'meta.json')),
]);
return metaStat.mtimeMs >= dbStat.mtimeMs;
} catch {
return false;
}
}
/**
* Read `meta.json`, validating the SHAPE of what it holds.
*
* The read and the parse have always been guarded an absent or unparseable
* file answers `version: 0`, which every caller already treats as "no
* provenance". What was not guarded is a file that parses into something that
* is not this shape: `runGroupImpact` spread both repo lists directly into a
* `Set`, so a non-iterable there threw a TypeError out of the whole cross-repo
* query, from a point where the bridge lease had been taken and not yet
* released. A malformed file is a reason to answer "provenance unknown", never
* a reason to crash the question.
*/
export async function readBridgeMeta(groupDir: string): Promise<BridgeMeta> {
const unreadable: BridgeMeta = { version: 0, generatedAt: '', missingRepos: [] };
let parsed: unknown;
try {
const content = await fsp.readFile(path.join(groupDir, 'meta.json'), 'utf-8');
return JSON.parse(content) as BridgeMeta;
parsed = JSON.parse(content);
} catch {
return { version: 0, generatedAt: '', missingRepos: [] };
return unreadable;
}
// `JSON.parse` succeeds on `null`, `7` and `[]` too, and none of them are
// metadata. Reading `.version` off the first of those is a thrown TypeError;
// reading it off the others silently yields `undefined`, which passes the
// version gate as if the bridge had been vouched for.
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return unreadable;
const raw = parsed as Partial<BridgeMeta>;
const missingRepos = recordedRepoList(raw.missingRepos);
const unreadableRepos = recordedRepoList(raw.unreadableRepos);
// Each list is judged on its own: a file whose `unreadableRepos` is garbage
// can still carry a `missingRepos` that was genuinely measured, and throwing
// that away would turn one unknown into two.
const repoListsUnreadable =
(raw.missingRepos !== undefined && missingRepos === undefined) ||
(raw.unreadableRepos !== undefined && unreadableRepos === undefined);
const meta: BridgeMeta = {
...raw,
// A version that is not a number cannot be compared against
// BRIDGE_SCHEMA_VERSION; `0` is this file's existing word for "provenance
// unknown", which is exactly what such a file gives us.
// `0` is this file's word for "no provenance". A version that is not a
// positive integer is not a schema version, and letting one through splits
// the four gates that read this field: `ensureBridgeReady` and
// `openBridgeDbReadOnly` both compare `> 0 && !== CURRENT` and would open
// the bridge, `bridgeExists` compares `=== 0 || === CURRENT` and would say
// it is not there, and `bridgeProvenanceUnknown` compares `=== 0` and would
// call the answer complete. Normalizing here keeps all four agreeing
// instead of teaching each one the same new case.
version:
Number.isInteger(raw.version) && (raw.version as number) > 0 ? (raw.version as number) : 0,
generatedAt: typeof raw.generatedAt === 'string' ? raw.generatedAt : '',
missingRepos: missingRepos ?? [],
};
// Absent, not empty. `unreadableRepos` is optional and "not recorded" is a
// distinct state from "measured none", so an unusable value is dropped rather
// than carried through — `repoListsUnreadable` is what records that something
// was there and could not be read.
if (unreadableRepos) meta.unreadableRepos = unreadableRepos;
else delete meta.unreadableRepos;
if (repoListsUnreadable) meta.repoListsUnreadable = true;
return meta;
}
/* ------------------------------------------------------------------ */
/* refreshPreservedBridgeMeta */
/* ------------------------------------------------------------------ */
/**
* What a refresh did to `meta.json`.
*
* - `restamped` the pair still matched, so the lists were refreshed
* and the stamp re-taken from the database on disk.
* - `provenance-unknown` the pair did NOT match (or there is no database to
* match), so the lists were refreshed and the metadata
* marked as unable to vouch for the file beside it.
* - `no-bridge` neither `meta.json` nor `bridge.lbug` exists, so
* there is no pair to keep honest and nothing written.
*/
export type PreservedBridgeMetaOutcome = 'restamped' | 'provenance-unknown' | 'no-bridge';
async function fileExists(filePath: string): Promise<boolean> {
try {
await fsp.access(filePath);
return true;
} catch {
return false;
}
}
/**
* Bring `meta.json`'s diagnostic lists up to date with a sync that PRESERVED
* the bridge instead of rebuilding it, without ever making the metadata claim
* more about the database than it did before.
*
* `syncGroup`'s total-failure path keeps the previous run's contracts and
* deliberately leaves `bridge.lbug` alone the contracts that bridge holds are
* the ones being preserved. But `runGroupImpact` reads completeness from
* `meta.json`, not from `contracts.json`, so leaving the metadata alone too left
* the two files telling different stories: the registry said "this sync could
* not read svc/users" while a cross-repo query answered "complete, nothing
* depends on this" (R4/R6).
*
* The refresh is the whole difficulty. It rewrites `meta.json` atomically, so
* the file's mtime becomes now while the database's stays old which is the
* write order a paired write produces, and precisely what
* `unstampedMetaPairsByWriteOrder` accepts. Three rules follow, and each of them
* is load-bearing:
*
* 1. Ask `bridgeMetaMatchesFile` FIRST, on the file as it stands. After the
* write the question is unanswerable, because the write is what destroys
* the evidence.
* 2. Re-stamp only when that answer was yes. Re-stamping a pair that already
* failed would MANUFACTURE the provenance the failure just denied the
* same metadata/database mis-pairing stamping exists to prevent (KTD6).
* 3. When it was no, record `provenanceUnknown` explicitly and carry the
* existing stamp fields through verbatim. Writing "no stamp" instead is
* worse, not better: an unstamped file is judged on the two file times,
* and this write has just put them in the accepting order.
*
* Nothing here opens, reads, or writes the database. The only `stat` of it
* happens on the branch where the pair was just verified.
*
* NOT SPLIT into locked/unlocked halves the way {@link writeBridge} is, and
* deliberately. Its one caller is `syncGroup`'s preserve branch, which is
* already inside `withGroupSyncLock` so this write is ALREADY serialized
* against every other sync of the group, and taking the lock here would be the
* second acquisition of a non-reentrant primitive that the split exists to
* avoid. An acquiring wrapper would therefore have zero production callers,
* and no test calls this function at all: it would be dead code standing in for
* a guarantee the caller already provides. If a caller outside the critical
* section ever appears, it needs the same treatment `writeBridge` got a
* wrapper, not a lock moved down here.
*/
export async function refreshPreservedBridgeMeta(
groupDir: string,
diagnostics: { missingRepos: string[]; unreadableRepos: string[] },
): Promise<PreservedBridgeMetaOutcome> {
const dbPath = path.join(groupDir, 'bridge.lbug');
const [metaOnDisk, dbOnDisk] = await Promise.all([
fileExists(path.join(groupDir, 'meta.json')),
fileExists(dbPath),
]);
// Nothing on either side of the pair. `readBridgeMeta` already answers
// `version: 0` — provenance unknown — for an absent file, so a file written
// here would say what the absence already says while inventing state for a
// bridge that has never existed.
if (!metaOnDisk && !dbOnDisk) return 'no-bridge';
const existing = await readBridgeMeta(groupDir);
const paired = await bridgeMetaMatchesFile(groupDir, existing);
const refreshed: BridgeMeta = { ...existing, ...diagnostics };
// NEVER PERSISTED (see `BridgeMeta`): both are things a READER computes ABOUT
// a file, and this is the first code in the repo that reads metadata and
// writes it back. `pairedWithDatabase` is the poisonous one — persisted, it
// would tell every future reader that the pair had been verified.
delete refreshed.repoListsUnreadable;
delete refreshed.pairedWithDatabase;
if (paired) {
const stat = await fsp.stat(dbPath).catch(() => null);
if (stat) {
refreshed.bridgeSize = stat.size;
refreshed.bridgeMtimeMs = stat.mtimeMs;
await writeBridgeMeta(groupDir, refreshed);
return 'restamped';
}
// The database disappeared between the pairing check and this stat. There
// is nothing left to stamp, so fall through and say so rather than write a
// stamp describing a file that is gone.
}
refreshed.provenanceUnknown = true;
await writeBridgeMeta(groupDir, refreshed);
return 'provenance-unknown';
}
/* ------------------------------------------------------------------ */
@ -668,6 +953,22 @@ export interface WriteBridgeInput {
crossLinks: CrossLink[];
repoSnapshots: Record<string, RepoSnapshot>;
missingRepos: string[];
/**
* Repos this sync could not extract from see
* `ContractRegistry.unreadableRepos` for the full definition, which this
* field carries unchanged.
*
* Deliberately not restated here. The narrower wording this once had ("whose
* index could not be opened") described one of the two causes and silently
* excluded the other, an extractor that threw partway through so the same
* field meant one thing on the registry, another on the bridge input, and a
* third on the result. One definition, referenced twice, cannot drift.
*
* Recorded in meta.json so cross-repo impact can tell "nothing depends on
* this" from "we could not look": the bridge built here is missing every
* contract those repos own.
*/
unreadableRepos?: string[];
}
/**
@ -702,7 +1003,33 @@ function errMessage(err: unknown): string {
}
}
export async function writeBridge(
/**
* Rebuild `bridge.lbug` and its `meta.json`, ASSUMING THE CALLER ALREADY HOLDS
* THE GROUP SYNC LOCK for `groupDir` (R9).
*
* PRECONDITION the group lock is held. There is exactly one production call
* site, `syncGroup` in sync.ts, and it is already inside
* `withGroupSyncLock(groupDir, …)` when it gets here. Enforced by this comment
* rather than by a type, matching `registerRepoUnlocked` / `withRegistryLock`
* in repo-manager.ts, which splits the same shape for the same reason.
*
* WHY THE SPLIT EXISTS AT ALL. The swap this function performs old database
* aside, temp database into place, then `meta.json` written as a SECOND
* operation is the write two concurrent syncs can interleave into a pairing
* that never existed: one sync's metadata beside the other's database. That
* needs mutual exclusion. But taking the lock HERE would be a second
* acquisition of a non-reentrant primitive inside a region that already holds
* it, and it would hang every single sync on the happy path, not some rare
* interleave. So the exclusion is the caller's, and this function only states
* the precondition. {@link writeBridge} is the acquiring wrapper for callers
* who are not already inside that region.
*
* SCOPE writer-writer only. The reader-side promotion of a leftover
* `bridge.lbug.bak` runs on ordinary reads, outside anybody's critical section;
* `bridgeMetaMatchesFile` remains the reader's defense there and is not
* replaced by this lock.
*/
export async function writeBridgeUnlocked(
groupDir: string,
input: WriteBridgeInput,
): Promise<WriteBridgeReport> {
@ -962,11 +1289,39 @@ export async function writeBridge(
}
await removeLbugFile(bakPath);
// 4. Write meta.json
// 4. Write the new meta.json, STAMPED WITH THE FILE IT DESCRIBES.
//
// meta.json carries the bridge's completeness, and since #3011 that is
// load-bearing: `runGroupImpact` folds `unreadableRepos missingRepos`
// into its truncation fields. The swap above and this write are two
// operations, so a sync that stops between them leaves the previous sync's
// meta beside a new database — and reading that as fact is a confidently
// wrong answer about the one thing this channel exists to make legible.
//
// Deleting the old meta before the swap would decide which way that window
// fails, but at an unacceptable price: the rename of the old database is
// wrapped in a catch that also swallows a FAILED rename (a held read-only
// handle does this on Windows), so `writeBridge` can throw with the old,
// perfectly good database still in place — and its metadata already gone,
// unrecoverably, for as long as the swap keeps failing.
//
// So destroy nothing and pair the two instead: record the size and mtime of
// the database this metadata describes, and let readers check that the pair
// still belongs together (`bridgeMetaMatchesFile`). A stale meta cannot match
// a freshly renamed database, and a sync that fails before the swap leaves a
// matching pair untouched.
const finalStat = await fsp.stat(finalPath);
await writeBridgeMeta(groupDir, {
version: BRIDGE_SCHEMA_VERSION,
generatedAt: new Date().toISOString(),
bridgeSize: finalStat.size,
bridgeMtimeMs: finalStat.mtimeMs,
missingRepos: input.missingRepos,
// Persisted whenever the caller supplied it, `[]` included: an empty list
// is the measurement "this sync accounted for every repo", and it is a
// different claim from a bridge that never recorded the field. Omitted
// only when the caller passed nothing to record.
...(input.unreadableRepos ? { unreadableRepos: input.unreadableRepos } : {}),
});
return report;
@ -982,6 +1337,33 @@ export async function writeBridge(
}
}
/**
* Rebuild `bridge.lbug` and its `meta.json` as the only writer of `groupDir`.
*
* The acquiring half of the split described on {@link writeBridgeUnlocked}: for
* callers that are NOT already inside the group's critical section, this takes
* the group sync lock around the whole swap and releases it afterwards. Two
* concurrent calls therefore run one after the other, so the `meta.json` left
* on disk is stamped for the `bridge.lbug` left on disk instead of for the
* loser's, which is the pairing the swap-plus-metadata sequence would otherwise
* let them interleave into.
*
* NOT used by `syncGroup`, and it must not be: that path already holds this
* lock, and `acquireIndexLock` is not reentrant, so routing it here would make
* every ordinary sync wait out the full `GROUP_SYNC_LOCK_TIMEOUT_MS` ceiling
* against itself. It calls {@link writeBridgeUnlocked} directly.
*
* Fails closed exactly as `withGroupSyncLock` does: if the lock cannot be
* acquired, a `GroupSyncLockError` is thrown and NOTHING is written
* `bridge.lbug` and `meta.json` are left as they were.
*/
export async function writeBridge(
groupDir: string,
input: WriteBridgeInput,
): Promise<WriteBridgeReport> {
return withGroupSyncLock(groupDir, () => writeBridgeUnlocked(groupDir, input));
}
/* ------------------------------------------------------------------ */
/* openBridgeDbReadOnly */
/* ------------------------------------------------------------------ */

View file

@ -0,0 +1,137 @@
/**
* The one computation of "is this cross-repo answer complete?" (KTD10), and the
* truncation vocabulary it speaks.
*
* A LEAF MODULE, deliberately, and that is the whole reason it exists apart from
* `cross-impact.ts`. Three surfaces need this fold impact, trace, and the
* contract listing but `cross-impact.ts` statically imports `bridge-db.ts`,
* and through it the native LadybugDB binding. `service.ts` therefore had to
* reach the fold through `await import('./cross-impact.js')`, which loaded that
* entire module graph on the first `group_contracts` of every process 44-51ms
* and 8.4MB of RSS to run a `Set` union and a ternary, once per CLI invocation.
*
* Nothing here imports anything but types. Keep it that way: the moment this
* file gains a runtime import, every consumer pays for it again.
*/
import type { GroupImpactTruncationReason } from './types.js';
/**
* A union rather than `Pick<GroupImpactResult, ...>` so the two states are
* distinguishable by their `truncated` discriminant: a caller that folds these
* fields into its own result (see `crossRepoCompleteness`) can then read
* `truncationReason` on the truncated branch without a fallback for a value
* that cannot be absent there.
*/
export type TruncationFields =
| { truncated: false }
| {
truncated: true;
truncationReason: GroupImpactTruncationReason;
riskEpistemic: 'lower-bound';
};
/**
* Build the truncation fields every `runGroupImpact` return path shares.
*
* `riskEpistemic` must follow `truncated` mechanically: it is the marker that
* tells a caller the `risk` value is a floor rather than a verdict, and
* `mergeRisk` can only under-report once a crossing is dropped. Attaching it at
* each return let two of the four paths set `truncated` without it, so a
* truncated result read as complete deriving it in one place is what keeps
* the invariant from drifting again (#2787).
*/
export function truncationFields(
truncated: boolean,
// Only read on the truncated branch, so the not-truncated call sites omit it
// rather than passing a reason that is thrown away.
reasonIfTruncated: GroupImpactTruncationReason = 'partial',
): TruncationFields {
if (!truncated) return { truncated: false };
return { truncated: true, truncationReason: reasonIfTruncated, riskEpistemic: 'lower-bound' };
}
/**
* Everything a caller needs in order to say whether a cross-repo answer is
* complete deliberately WITHOUT naming where any of it came from.
*
* `BridgeMeta` is not in this signature, and must not be: `groupContracts`
* answers the same question from `contracts.json` (via
* `loadContractRegistryResilient`) and never opens a bridge at all, so
* `version` / `repoListsUnreadable` / `pairedWithDatabase` do not exist on that
* path. Each caller computes its own `provenanceUnknown` from whatever
* provenance IT has and passes the boolean in.
*/
export interface CrossRepoCompletenessInput {
/**
* Repos the sync could not extract from, and repos it found no entry for.
* Two independent diagnostics with one consequence none of those repos'
* contracts are in the artifact so they are folded into one set.
*/
unreadableRepos?: readonly string[];
missingRepos?: readonly string[];
/** Computed by the caller; see `bridgeProvenanceUnknown` for the bridge one. */
provenanceUnknown: boolean;
/**
* The query's DECLARED scope, not the set of repos the walk happened to
* reach: the subgroup filter for an impact query, the two endpoint repos for
* a trace, every member for a query that names none. An incomplete repo the
* caller never asked about cannot make the caller's answer a floor, and
* marking it anyway is how the marker stops meaning anything. Passing the
* predicate in rather than a repo list, or a subgroup is what keeps
* narrowing a scope a call-site change.
*/
inScope: (repoPath: string) => boolean;
}
/** The structured triple, plus the in-scope repos that produced it. */
export type CrossRepoCompleteness = TruncationFields & {
/**
* In-scope repos absent from the artifact, deduped, in first-seen order.
* Empty on a provenance-unknown answer: nothing was measured there, and
* inventing names out of an unreadable value is not a measurement.
*/
incompleteRepos: string[];
};
/**
* The ONE computation of "is this cross-repo answer complete?" (KTD10).
*
* Three surfaces can return a partial cross-repo answer impact, trace, and
* the contract listing and each used to decide for itself, in its own
* vocabulary, which is how two of them ended up saying it in prose only. The
* answer is the same structured triple `GroupImpactResult` already carries, so
* an agent reading any of them learns "complete" vs "floor" the same way.
*
* `truncationFields` derives `riskEpistemic` from `truncated` mechanically, and
* is reused here rather than re-implemented for the same reason it exists: the
* marker that says "this is a floor, not a verdict" may never drift away from
* the flag that says the answer was cut short (#2787).
*/
export function crossRepoCompleteness(input: CrossRepoCompletenessInput): CrossRepoCompleteness {
const incompleteRepos = [
...new Set([...(input.unreadableRepos ?? []), ...(input.missingRepos ?? [])]),
].filter((repoPath) => input.inScope(repoPath));
return {
...truncationFields(input.provenanceUnknown || incompleteRepos.length > 0, 'incomplete-sync'),
incompleteRepos,
};
}
/**
* A recorded repo list is an array of strings. Anything else a bare string, an
* object, an array of objects is a value we could not read, which is "not
* recorded", not "none".
*
* ONE definition, deliberately. This gate is the predicate the whole
* absent-vs-empty-vs-populated distinction rests on, and it applies to the same
* two lists on both the registry and the bridge metadata. It lived in two files
* verbatim, which meant tightening it say, to reject blank strings would
* have fixed one surface and silently left the other.
*
* `Array.isArray` alone is not enough: only an array of strings survives
* `cli/group.ts`'s `.join(', ')` as repo paths rather than as `[object Object]`.
*/
export function recordedRepoList(value: unknown): string[] | undefined {
if (!Array.isArray(value)) return undefined;
return value.every((entry) => typeof entry === 'string') ? (value as string[]) : undefined;
}

View file

@ -7,11 +7,11 @@ import fsp from 'node:fs/promises';
import path from 'node:path';
import type {
BridgeHandle,
BridgeMeta,
ContractType,
CrossRepoImpact,
GroupConfig,
GroupImpactResult,
GroupImpactTruncationReason,
MatchType,
OutOfScopeLink,
} from './types.js';
@ -24,12 +24,23 @@ import {
} from './group-path-utils.js';
import { getGroupDir } from './storage.js';
import {
bridgeMetaMatchesFile,
closeBridgeDb,
getCachedBridgeReadOnly,
queryBridge,
readBridgeMeta,
} from './bridge-db.js';
import { BRIDGE_SCHEMA_VERSION } from './bridge-schema.js';
// Re-exported so the three surfaces keep one import site for the vocabulary,
// while the fold itself stays in a leaf module no native binding reaches.
export {
truncationFields,
crossRepoCompleteness,
type TruncationFields,
type CrossRepoCompleteness,
type CrossRepoCompletenessInput,
} from './completeness.js';
import { truncationFields, crossRepoCompleteness } from './completeness.js';
import { compareCodeUnits } from '../../lib/utils.js';
// High limit for the local phase of group impact so collectImpactSymbolUids
@ -381,23 +392,30 @@ export function mergeRisk(localRisk: string, cross: CrossRepoImpact[]): string {
}
/**
* Build the truncation fields every `runGroupImpact` return path shares.
* Is this bridge's metadata unable to say where its contents came from?
*
* `riskEpistemic` must follow `truncated` mechanically: it is the marker that
* tells a caller the `risk` value is a floor rather than a verdict, and
* `mergeRisk` can only under-report once a crossing is dropped. Attaching it at
* each return let two of the four paths set `truncated` without it, so a
* truncated result read as complete deriving it in one place is what keeps
* the invariant from drifting again (#2787).
* The three reads are all about a `BridgeMeta` and stay OUT of
* `crossRepoCompleteness` on purpose (see its doc): they are how a caller that
* opened a bridge computes `provenanceUnknown`, not how every caller does.
*
* - `version === 0` no readable meta.json at all (`readBridgeMeta` answers
* that for both "absent" and "unparseable");
* - `repoListsUnreadable` a meta.json that parsed but whose repo lists are
* not repo lists. A value we could not read is not a measurement of zero,
* so it may not be spent as one;
* - `pairedWithDatabase === false` a meta.json that does not describe the
* database sitting beside it, which is what a sync interrupted between the
* swap and the metadata write leaves behind. Measured by
* `ensureBridgeReady` BEFORE the database is opened and carried on the
* meta; this only reads the answer (#3012).
*
* Treating any of them as complete is the fail-open the completeness channel
* exists to close.
*/
function truncationFields(
truncated: boolean,
// Only read on the truncated branch, so the not-truncated call sites omit it
// rather than passing a reason that is thrown away.
reasonIfTruncated: GroupImpactTruncationReason = 'partial',
): Pick<GroupImpactResult, 'truncated' | 'truncationReason' | 'riskEpistemic'> {
if (!truncated) return { truncated: false };
return { truncated: true, truncationReason: reasonIfTruncated, riskEpistemic: 'lower-bound' };
export function bridgeProvenanceUnknown(meta: BridgeMeta): boolean {
return (
meta.version === 0 || meta.repoListsUnreadable === true || meta.pairedWithDatabase === false
);
}
function addCrossImpact(cross: CrossRepoImpact[], candidate: CrossRepoImpact): void {
@ -418,7 +436,7 @@ function addCrossImpact(cross: CrossRepoImpact[], candidate: CrossRepoImpact): v
export async function ensureBridgeReady(
groupDir: string,
): Promise<{ handle: BridgeHandle } | { error: string }> {
): Promise<{ handle: BridgeHandle; meta: BridgeMeta } | { error: string }> {
const meta = await readBridgeMeta(groupDir);
if (meta.version > 0 && meta.version !== BRIDGE_SCHEMA_VERSION) {
return {
@ -433,6 +451,13 @@ export async function ensureBridgeReady(
error: `No bridge.lbug in this group directory. Run gitnexus group sync (schema ${BRIDGE_SCHEMA_VERSION}).`,
};
}
// Pair the metadata to the database BEFORE opening it, and carry the answer.
// An unstamped pair is judged on the two files' write order, so any open that
// touched `bridge.lbug`'s mtime would silently convert "legacy but intact"
// into "provenance unknown" for every pre-stamp bridge on that platform. This
// ordering removes the question rather than betting on the answer.
meta.pairedWithDatabase = await bridgeMetaMatchesFile(groupDir, meta);
// Use the cached read-only handle if available — avoids reopening the same
// bridge.lbug in a long-lived MCP server, which fails on Windows because
// the OS handle isn't fully released before the next open races in.
@ -442,7 +467,7 @@ export async function ensureBridgeReady(
error: `Could not open bridge.lbug read-only (schema ${BRIDGE_SCHEMA_VERSION}). Run gitnexus group sync.`,
};
}
return { handle };
return { handle, meta };
}
function rowToNeighbor(r: Record<string, unknown>): BridgeNeighborRow | null {
@ -641,6 +666,25 @@ export async function runGroupImpact(
if ('error' in bridgePrep) return { error: bridgePrep.error };
const handle = bridgePrep.handle;
// Repos the sync that built this bridge could not account for. Their
// contracts — and every cross-link touching them — are simply absent from
// bridge.lbug, and nothing else in this walk can notice that: the only
// incompleteness channel on the result is `truncationFields`, driven by
// fan-out state. Without folding these in, a query about a symbol whose one
// downstream consumer lives in an unreadable repo returns
// `{ cross: [], truncated: false }` — "complete: nothing depends on this" —
// which is a wrong answer, not an empty one, for a tool an agent uses to
// license a delete or a rename.
//
// The metadata read that answers it (`bridgeProvenanceUnknown`) happens
// INSIDE the `try` below, and the flag is initialized fail-closed here only
// because it outlives that block. The lease taken by `ensureBridgeReady` is
// released by the `finally` and nowhere else, so work done between the lease
// and the `try` is work whose every throw leaks a refcount the cached handle
// can never get back — which is how a malformed meta.json used to wedge the
// handle as well as crash the query. (The repo lists are folded in after the
// `finally`, where a throw can no longer strand the lease.)
let provenanceUnknown = true;
const cross: CrossRepoImpact[] = [];
const outOfScope: OutOfScopeLink[] = [];
const truncatedRepos: string[] = [];
@ -650,6 +694,8 @@ export async function runGroupImpact(
let fanoutTimedOut = false;
try {
provenanceUnknown = bridgeProvenanceUnknown(bridgePrep.meta);
const neighbors = await resolveBridgeNeighbors(handle, {
localRepo: repoPath,
uids,
@ -782,7 +828,45 @@ export async function runGroupImpact(
const localSum = (local as { summary?: Record<string, number> })?.summary || {};
const localRisk = String((local as { risk?: string }).risk ?? 'LOW');
const localPartial = Boolean((local as { partial?: boolean }).partial);
const truncated = truncatedRepos.length > 0 || localPartial;
// The bridge's own incompleteness, in the shared vocabulary, read through
// what this query DECLARED. The fan-out above already drops every neighbour
// outside `subgroup`, so an incomplete repo the query excluded could not have
// contributed a crossing to this answer — marking the answer a floor because
// of it makes the marker fire on results it does not describe, which is how a
// caller learns to ignore it. An unscoped query passes `subgroup: undefined`,
// which `repoInSubgroup` answers true for, so the intersection is the whole
// set and that path is byte-for-byte the old behaviour.
//
// The declared scope is the subgroup PLUS the query's own repo (`exact`
// reuses the one membership helper for the equality, rather than growing a
// second notion of it): the walk starts from `repoPath`'s contracts in the
// bridge, so if THAT is the repo the sync could not read there are no
// crossings to find for any scope, and a subgroup excluding it must not turn
// that vacuum into a confident "complete".
//
// Declared scope, not traversed scope: an incomplete repo's contracts are
// absent from the bridge by definition, so it is never in the set the walk
// reached — filtering on what was traversed would empty the intersection on
// every query and silently restore the fail-open.
//
// Sound only while `MAX_SUPPORTED_CROSS_DEPTH` is 1. At depth 2+ an
// out-of-scope repo can sit BETWEEN two in-scope ones, so dropping it would
// convert a genuine lower bound into a confident complete answer; widen this
// predicate in the same change that raises the depth.
const bridge = crossRepoCompleteness({
unreadableRepos: bridgePrep.meta.unreadableRepos,
missingRepos: bridgePrep.meta.missingRepos,
provenanceUnknown,
inScope: (candidate) =>
repoInSubgroup(candidate, subgroup) || repoInSubgroup(candidate, repoPath, true),
});
// One predicate, read twice below. Written out at both sites, a third runtime
// cause added to the flag and forgotten at the reason would label a
// retry-able answer `incomplete-sync` — telling the operator to re-sync for
// something a retry fixes. That reason-vs-flag drift is what `truncationFields`
// exists to prevent.
const runtimeTruncated = truncatedRepos.length > 0 || localPartial;
const truncated = runtimeTruncated || bridge.truncated;
const result: GroupImpactResult = {
local,
@ -794,8 +878,17 @@ export async function runGroupImpact(
// and under-reporting a blast radius is the unsafe direction (an agent told
// LOW proceeds; told CRITICAL it stops). Marking the floor keeps the
// warning intact while making the incompleteness legible.
...truncationFields(truncated, fanoutTimedOut ? 'timeout' : 'partial'),
truncatedRepos: [...new Set(truncatedRepos)],
// Runtime limits first — they are what the caller can retry. 'incomplete-sync'
// is the remaining cause once nothing was merely cut short, and its remedy is
// a different one: re-run `gitnexus group sync`, not the query. Computed
// inline because `truncationFields` reads the reason ONLY on the truncated
// branch — naming it in a variable invited reading it on the complete path,
// where it would say 'incomplete-sync' about a complete result.
...truncationFields(
truncated,
fanoutTimedOut ? 'timeout' : runtimeTruncated ? 'partial' : 'incomplete-sync',
),
truncatedRepos: [...new Set([...truncatedRepos, ...bridge.incompleteRepos])],
summary: {
direct: localSum.direct ?? 0,
processes_affected: localSum.processes_affected ?? 0,

View file

@ -25,16 +25,29 @@
import { GroupNotFoundError, loadGroupConfig } from './config-parser.js';
import { getGroupDir } from './storage.js';
import { ensureBridgeReady, MAX_SUPPORTED_CROSS_DEPTH } from './cross-impact.js';
import {
bridgeProvenanceUnknown,
crossRepoCompleteness,
ensureBridgeReady,
MAX_SUPPORTED_CROSS_DEPTH,
} from './cross-impact.js';
import type { CrossRepoCompleteness } from './completeness.js';
import { truncationFields } from './completeness.js';
import { compareCodeUnits } from '../../lib/utils.js';
import { closeBridgeDb, queryBridge } from './bridge-db.js';
import { repoInSubgroup } from './group-path-utils.js';
import type {
GroupPdgFlowHop,
GroupRepoHandle,
GroupSymbolResolution,
GroupToolPort,
} from './service.js';
import type { BridgeHandle, GroupConfig } from './types.js';
import type {
BridgeHandle,
BridgeMeta,
GroupConfig,
GroupImpactTruncationReason,
} from './types.js';
// ── Result types (discriminated on `status`) ─────────────────────────────
@ -77,7 +90,29 @@ export interface GroupTraceEndpoint {
repo: string;
}
export interface GroupTraceOkResult {
/**
* The incompleteness vocabulary, verbatim from `GroupImpactResult` (KTD10).
*
* A cross-repo trace and a cross-repo impact can both be cut short by the same
* two kinds of cause a runtime limit inside this walk, or a bridge that never
* held part of the group and an agent must not have to learn a second
* vocabulary (or parse a `notes` string) to tell "no path exists" from "we
* could not have seen the path". Every field here means exactly what it means
* on `GroupImpactResult`; `notes` stays a human-readable ADDITION to them,
* never the machine-readable channel.
*/
export interface GroupTraceCompleteness {
/** True when this answer is a floor rather than a verdict. */
truncated?: boolean;
/** Why, when `truncated` — runtime limit ('partial'/'timeout') before structure. */
truncationReason?: GroupImpactTruncationReason;
/** Set with `truncated`: the answer under-reports, it never over-reports. */
riskEpistemic?: 'lower-bound';
/** In-scope repos absent from the bridge; omitted when none were measured. */
truncatedRepos?: string[];
}
export interface GroupTraceOkResult extends GroupTraceCompleteness {
status: 'ok';
group: string;
from: GroupTraceEndpoint;
@ -89,7 +124,6 @@ export interface GroupTraceOkResult {
edges: TraceEdge[];
/** Present only when PDG enrichment ran for at least one segment. */
dataFlow?: SegmentDataFlow[];
truncated?: boolean;
notes: string[];
}
@ -101,23 +135,23 @@ export interface GroupTraceCandidate {
startLine: number;
}
export interface GroupTraceNotFoundResult {
/**
* `truncated: true` here means the answer is NOT authoritative either the
* crossing cap (`MAX_CROSSINGS_TO_TRY`) was hit so a connecting ContractLink
* ranked beyond it may have been skipped, or the bridge itself never held part
* of the group. Both read as "unknown", not as "no path exists";
* `truncationReason` says which.
*/
export interface GroupTraceNotFoundResult extends GroupTraceCompleteness {
status: 'not_found';
group: string;
role?: 'from' | 'to';
query?: string;
/**
* True when the answer is NOT authoritative: the crossing cap
* (`MAX_CROSSINGS_TO_TRY`) was hit, so a connecting ContractLink ranked beyond
* the cap may have been skipped. A consumer should treat this as "unknown",
* not "no path exists".
*/
truncated?: boolean;
notes: string[];
suggestion?: string;
}
export interface GroupTraceAmbiguousResult {
export interface GroupTraceAmbiguousResult extends GroupTraceCompleteness {
status: 'ambiguous';
group: string;
role: 'from' | 'to';
@ -187,6 +221,54 @@ export const TRACE_NOTES = {
'The candidates are listed; trace from the exact calling function or pass `to_uid`.',
} as const;
/**
* Fold this bridge's completeness into the runtime-truncation flag a trace call
* site already computed, and answer in the shared vocabulary.
*
* Precedence mirrors `runGroupImpact`: a runtime limit wins the reason, because
* it is the cause the caller can act on (narrow the query, raise maxDepth),
* while `'incomplete-sync'` needs a different remedy `gitnexus group sync`
* and would otherwise mask it.
*
* Returns `{}` not `{ truncated: false }` when the answer is complete, so a
* clean trace result keeps the exact shape it has always had.
*/
function traceCompleteness(
bridge: CrossRepoCompleteness,
runtimeTruncated: boolean,
): GroupTraceCompleteness {
const repos = bridge.incompleteRepos.length > 0 ? { truncatedRepos: bridge.incompleteRepos } : {};
// Through `truncationFields`, not hand-written: `riskEpistemic` must follow
// `truncated` mechanically, and a third writer of that pair is how the
// invariant drifts (#2787). The bridge branch re-spreads the helper's own
// output rather than naming its fields.
if (runtimeTruncated) return { ...truncationFields(true, 'partial'), ...repos };
if (!bridge.truncated) return {};
const { incompleteRepos: _incompleteRepos, ...fields } = bridge;
return { ...fields, ...repos };
}
/**
* The trace's declared scope for `crossRepoCompleteness`.
*
* A symbol-to-symbol trace asks about exactly two repos, so an unreadable third
* member cannot make its answer a floor. A DESTINATION trace declares no `to`
* at all the call may land in any member so every repo is in scope there,
* which is why the predicate is built per call site rather than derived from
* the endpoints inside the helper.
*/
function bridgeCompletenessFor(
meta: BridgeMeta,
inScope: (repoPath: string) => boolean,
): CrossRepoCompleteness {
return crossRepoCompleteness({
unreadableRepos: meta.unreadableRepos,
missingRepos: meta.missingRepos,
provenanceUnknown: bridgeProvenanceUnknown(meta),
inScope,
});
}
/** Repo-relative path equality, tolerant of a leading "./" / "/" or a repo prefix. */
function sameFile(a: string, b: string): boolean {
if (!a || !b) return false;
@ -873,6 +955,23 @@ async function stitchCrossRepo(
if (p.pdg) notes.push(TRACE_NOTES.pdgRequested);
try {
// Inside the `try`, like `runGroupImpact`'s equivalent: the lease taken by
// `ensureBridgeReady` is released by this block's `finally` and nowhere
// else, so anything computed between the lease and the `try` is work whose
// every throw would strand a refcount the cached handle never gets back.
//
// Declared scope = the two endpoint repos. Whether either of them is a repo
// this bridge could not read decides whether "no ContractLink connects
// them" is a verdict or a floor.
const bridge = bridgeCompletenessFor(
bridgePrep.meta,
// `repoInSubgroup(..., exact)` rather than `===`: it normalizes separators
// and strips trailing slashes, which bare equality does not, so the same
// group.yaml spelling cannot be in scope for impact and out of scope here.
(repoPath) =>
repoInSubgroup(repoPath, fromEp.member.repoPath, true) ||
repoInSubgroup(repoPath, toEp.member.repoPath, true),
);
const { crossings, truncated: crossingsTruncated } = await listCrossingsBetween(
handle,
fromEp.member.repoPath,
@ -883,6 +982,10 @@ async function stitchCrossRepo(
return {
status: 'not_found',
group: p.name,
// No crossings at all is exactly the answer a bridge that never held an
// endpoint's repo produces, so it is the one that most needs the floor
// marker. (Nothing was capped: there were zero rows to cap.)
...traceCompleteness(bridge, false),
notes,
suggestion:
'The endpoints live in different repos with no ContractLink between them. ' +
@ -1016,6 +1119,13 @@ async function stitchCrossRepo(
hopCount: edges.length,
hops: [...hopsA, ...hopsB],
edges,
// A found path is still an answer from this bridge: if its provenance is
// unknown, or an endpoint's repo never made it in, the path may be stale
// and it is certainly not the only one. An incompleteness channel that
// fires only on the empty answer teaches an agent that a non-empty one
// is always complete. The crossing cap is NOT folded in here — a path
// that connected is not a capped search — so this site passes `false`.
...traceCompleteness(bridge, false),
notes,
...(dataFlow.length > 0 ? { dataFlow } : {}),
};
@ -1028,7 +1138,7 @@ async function stitchCrossRepo(
return {
status: 'not_found',
group: p.name,
...(crossingsTruncated ? { truncated: true } : {}),
...traceCompleteness(bridge, crossingsTruncated),
notes,
suggestion: crossingsTruncated
? `No connecting crossing among the ${MAX_CROSSINGS_TO_TRY} highest-confidence ` +
@ -1099,6 +1209,12 @@ async function stitchToDestination(
if (p.crossDepthClamped) notes.push(TRACE_NOTES.crossDepthClamped);
try {
// Inside the `try` for the lease reason above `stitchCrossRepo`'s copy. A
// destination trace declares NO `to`: the call may land in any member, so
// every repo is in the query's scope and no incomplete one can be filtered
// out. An unreadable provider repo is precisely how "no outgoing
// ContractLink leaves this repo" becomes a wrong answer, not an empty one.
const bridge = bridgeCompletenessFor(bridgePrep.meta, () => true);
const { crossings, truncated } = await listCrossingsFrom(handle, fromEp.member.repoPath);
if (crossings.length === 0) {
notes.push(TRACE_NOTES.destinationNoLink);
@ -1107,6 +1223,8 @@ async function stitchToDestination(
group: p.name,
role: 'to',
query: p.from_uid ?? p.from,
// Zero rows to cap, so only the bridge's own completeness can speak.
...traceCompleteness(bridge, false),
notes,
suggestion: 'Pass a `to` symbol for a symbol-to-symbol trace, or run group_sync.',
};
@ -1224,7 +1342,9 @@ async function stitchToDestination(
hopCount: edgesA.length + 1,
hops: [...hopsA, providerHop],
edges: [...edgesA, boundaryEdge],
...(truncated ? { truncated: true } : {}),
// The cap already marked this result; the bridge's completeness folds
// into the same fields rather than beside them.
...traceCompleteness(bridge, truncated),
notes: resultNotes,
};
};
@ -1240,6 +1360,8 @@ async function stitchToDestination(
group: p.name,
role: 'to',
candidates: candidatesFrom(precise),
// The candidate LIST is what an incomplete bridge shortens here.
...traceCompleteness(bridge, truncated),
notes: [...notes, TRACE_NOTES.destinationMultiple],
};
}
@ -1255,6 +1377,7 @@ async function stitchToDestination(
group: p.name,
role: 'to',
candidates: candidatesFrom(fileLevel),
...traceCompleteness(bridge, truncated),
notes: [...notes, TRACE_NOTES.destinationAmbiguousFile],
};
}
@ -1265,7 +1388,7 @@ async function stitchToDestination(
group: p.name,
role: 'to',
query: p.from_uid ?? p.from,
...(truncated ? { truncated: true } : {}),
...traceCompleteness(bridge, truncated),
notes,
suggestion: 'Trace from the function that issues the HTTP request, or pass a `to` symbol.',
};

View file

@ -0,0 +1,201 @@
/**
* Cross-process single-writer lock for one group's persisted state (R9).
*
* A group sync ends by REPLACING `contracts.json` and rebuilding `bridge.lbug`
* from a snapshot it computed minutes earlier. Two syncs of the same group that
* overlap therefore do not merge the second one's write simply overwrites the
* first one's, and whichever finishes last wins with a registry assembled from
* repo state the other run never saw. Nothing detects it afterwards: both runs
* report success, and the group's contracts silently describe a mixture that was
* never true at any instant. This module serializes that section so one sync at
* a time can be inside it.
*
* WHERE THE LOCK LIVES. On a dedicated `sync-lock` directory INSIDE the group
* directory mirroring `withRegistryLock`, which locks a `registry-lock`
* directory beside the registry rather than the registry's own directory
* (repo-manager.ts). {@link acquireIndexLock} is NOT reentrant and its file
* backend writes `analyze.lock` into the directory it is handed, so pointing it
* at a directory that some other code path might also lock or that already
* holds a per-repo index slot reintroduces exactly the collision the registry
* lock's own comment warns about. `<groupDir>/sync-lock` is a namespace nothing
* else claims: group directories live under `~/.gitnexus/groups/<name>` (or
* `$GITNEXUS_HOME`), never under a repo's `.gitnexus[/branches/<slug>]`.
*
* WHY IT FAILS CLOSED, unlike the registry lock. `withRegistryLock` degrades to
* running UNLOCKED on timeout, and that is right for it: it guards a sub-second
* JSON read/merge/write on a latency-critical path (`augment` runs on every
* editor tool call), and running unlocked is merely the pre-lock status quo. A
* group sync is the opposite on every axis it is long, expensive, operator-
* initiated, and its lost update destroys contracts rather than a registry field.
* A sync that cannot be protected must not run at all, and there are three
* distinct ways it can fail to be protected; all three throw
* {@link GroupSyncLockError}:
*
* 1. TIMEOUT the holder is still alive when the ceiling elapses.
* 2. LOCK-FREE DEGRADATION `acquireIndexLock` answers a read-only or
* permission-denied filesystem with a no-op handle that is byte-identical
* to a real one at the API boundary. That is a deliberate tolerance for
* `analyze` (an unwritable index dir rejects every write anyway, so the
* lock is moot), but here it would hand back a handle that protects
* nothing while the sync went on to attempt its writes. The handle now
* carries {@link IndexLockHandle.lockFree}, so we can see it and refuse.
* 3. ANY OTHER ACQUIRE FAILURE e.g. `sync-lock` cannot be created because a
* regular file already occupies the path. Silently proceeding on an error
* we did not anticipate is the same unprotected run under another name.
*
* WHY THE CEILING IS PASSED EXPLICITLY. The magnitude is not the point 10
* minutes deliberately matches `acquireIndexLock`'s own default, because a group
* sync is analyze-shaped and a legitimately queued second sync must be able to
* wait out a full first one (the registry lock's 5s is sized for a sub-second
* merge and is the wrong model here). The reason to pass it is
* `resolveTimeoutMs`: it prefers an explicit argument over
* `GITNEXUS_INDEX_LOCK_TIMEOUT_MS`, and that variable's `<= 0` case resolves to
* `Number.POSITIVE_INFINITY`. Inheriting it would let an environment turn this
* lock's fail-closed timeout into an unbounded hang.
*
* ACQUIRED EXACTLY ONCE, by `syncGroup`, around its whole persist section.
* Nothing it calls beneath that point `writeContractRegistry`,
* `refreshPreservedBridgeMeta`, `writeBridgeUnlocked` takes this lock; a
* second acquisition would deadlock a non-reentrant primitive on the HAPPY
* path, not on some edge case. `bridge-db.ts` exports the swap in both forms
* for exactly that reason: `writeBridgeUnlocked` for the held-lock caller
* (`syncGroup`), and the `writeBridge` wrapper, which acquires here, for direct
* callers that are outside the region. The same split `repo-manager.ts` uses
* for `registerRepoUnlocked` / `registerRepo`.
*
* SCOPE CAVEAT (recorded, not solved): the default socket backend uses Linux
* abstract sockets, which are network-namespace-scoped. Two containers that
* share a bind-mounted group directory but sit in separate netns will NOT
* contend, exactly as documented for the index lock itself; forcing
* `GITNEXUS_INDEX_LOCK_BACKEND=file` is what covers that deployment.
*/
import path from 'node:path';
import {
acquireIndexLock,
IndexLockTimeoutError,
type IndexLockHandle,
} from '../../storage/index-lock.js';
import { logger } from '../logger.js';
/** Lock-directory name inside the group directory. Never the group dir itself. */
export const GROUP_SYNC_LOCK_DIRNAME = 'sync-lock';
/** The dedicated lock namespace for one group: `<groupDir>/sync-lock`. */
export const getGroupSyncLockDir = (groupDir: string): string =>
path.join(groupDir, GROUP_SYNC_LOCK_DIRNAME);
/**
* Wait ceiling for the group sync lock (10 min). See the module header: the
* magnitude matches `acquireIndexLock`'s analyze-sized default on purpose; the
* reason it is passed EXPLICITLY is to keep `GITNEXUS_INDEX_LOCK_TIMEOUT_MS`
* (whose `<= 0` case means unbounded) from turning fail-closed into a hang.
*/
export const GROUP_SYNC_LOCK_TIMEOUT_MS = 600_000;
/** Which of the three fail-closed exits produced a {@link GroupSyncLockError}. */
export type GroupSyncLockFailure = 'timeout' | 'lock-free' | 'unavailable';
/**
* A group sync could not be protected, so it did not run. One class for all
* three exits so both callers the CLI command and the MCP service have a
* single thing to catch and report.
*/
export class GroupSyncLockError extends Error {
readonly reason: GroupSyncLockFailure;
readonly groupDir: string;
constructor(reason: GroupSyncLockFailure, groupDir: string, message: string, cause?: unknown) {
super(message, cause === undefined ? undefined : { cause });
this.name = 'GroupSyncLockError';
this.reason = reason;
this.groupDir = groupDir;
}
}
/**
* Run `operation` as the only group sync touching `groupDir`, or throw
* {@link GroupSyncLockError} without running it at all.
*
* The lock is released in a `finally`, so it is dropped whether the operation
* succeeds or throws.
*/
export const withGroupSyncLock = async <T>(
groupDir: string,
operation: () => Promise<T>,
): Promise<T> => {
let handle: IndexLockHandle;
// The wrapper times the acquisition itself. `IndexLockTimeoutError` carries
// `holder` and `holderKnown` and nothing else — the elapsed wait exists only
// inside its inherited message string, so the figure has to be measured here
// to be reported without that message. `Date.now()` matches how the primitive
// measures its own wait.
const acquireStartedAt = Date.now();
try {
handle = await acquireIndexLock(getGroupSyncLockDir(groupDir), {
timeoutMs: GROUP_SYNC_LOCK_TIMEOUT_MS,
// `acquireIndexLock`'s own `log` texts name an "analyze" holder, which
// misattributes a group-sync wait — the same reason `withRegistryLock`
// supplies its own line instead of passing `log` through.
onWaitStart: () =>
logger.info(
{ groupDir },
'Waiting for another GitNexus process to finish syncing this group…',
),
});
} catch (err) {
// The inherited message names "another gitnexus analyze" as the holder —
// a cause this detection path cannot establish. Nothing but a group sync
// ever locks `<groupDir>/sync-lock` (see the module header), and on the
// socket backend the holder is not identifiable at all. Re-word it around
// what IS known: which group, which operation, and how long we waited.
if (err instanceof IndexLockTimeoutError) {
throw new GroupSyncLockError(
'timeout',
groupDir,
`Timed out after ${Date.now() - acquireStartedAt}ms waiting for the sync lock on ` +
`group "${path.basename(groupDir)}" (${getGroupSyncLockDir(groupDir)}). ` +
// `holderKnown` is false on the socket backend and on the file
// backend's malformed/vanished-lock timeouts, where `holder` is a
// placeholder (`pid -1`). Presenting that as a real owner would be the
// same unestablished claim in a new form.
(err.holderKnown
? `Held by pid ${err.holder.pid} on ${err.holder.hostname} ` +
`(invocation ${err.holder.invocationId}). `
: `The lock stayed held for the whole wait, but this lock backend ` +
`cannot identify the holder. `) +
`Nothing was written and this group was not synced. ` +
`Re-run once the other sync of this group has finished.`,
err,
);
}
throw new GroupSyncLockError(
'unavailable',
groupDir,
`Could not acquire the sync lock for this group (${getGroupSyncLockDir(groupDir)}): ` +
`${err instanceof Error ? err.message : String(err)}. Nothing was written.`,
err,
);
}
if (handle.lockFree) {
// A handle that owns nothing. Release it anyway (it is a no-op, but the
// contract is that every handle is released) and refuse to run: this sync
// would otherwise write `contracts.json` and `bridge.lbug` with no
// protection at all against a concurrent sync doing the same.
handle.release();
throw new GroupSyncLockError(
'lock-free',
groupDir,
`The sync lock for this group could not be created at ` +
`${getGroupSyncLockDir(groupDir)} (read-only or permission-denied filesystem), ` +
`so this sync cannot be protected against a concurrent one. Nothing was written. ` +
`Make the group directory writable and re-run.`,
undefined,
);
}
try {
return await operation();
} finally {
handle.release();
}
};

View file

@ -6,7 +6,16 @@
import fsp from 'node:fs/promises';
import path from 'node:path';
import { checkStaleness } from '../git-staleness.js';
import { loadMeta, type RepoMeta } from '../../storage/repo-manager.js';
import {
canonicalizePath,
loadMeta,
readRegistryStrict,
registryPathEquals,
type RegistryEntry,
type RepoMeta,
} from '../../storage/repo-manager.js';
import { crossRepoCompleteness } from './completeness.js';
import { recordedRepoList } from './completeness.js';
import { GroupNotFoundError, loadGroupConfig } from './config-parser.js';
import {
fileMatchesServicePrefix,
@ -222,6 +231,34 @@ function isCrossLink(raw: unknown): raw is CrossLink {
return typeof o.contractId === 'string' && typeof o.type === 'string';
}
/**
* Does the global registry hold a row for this configured group member?
*
* Consulted only once resolution has ALREADY failed, to choose which of the
* two failures `group status` reports. It mirrors the two tiers
* `LocalBackend.resolveRepo` matches a bare group-config value on the
* registry `name`, case-insensitively, and the repo `path` and deliberately
* stops short of its hashed-id and partial-name tiers: those exist to be
* generous about what an operator typed, while this predicate only decides
* between two labels, and a looser match here would relabel a genuine registry
* miss as an unresolvable row. That is the same conflation this reporting
* exists to remove, pointed the other way.
*/
function registryIdentifies(entries: RegistryEntry[], registryName: string): boolean {
const wantedName = registryName.toLowerCase();
// Path equality goes through the registry's own rule rather than a local
// `resolve` + platform-case compare. `canonicalizePath` also follows symlinks,
// so a row registered through one and looked up through the other still
// matches — and there is one definition of registry path identity instead of
// a third, weaker copy of it living in a group module nobody would grep.
const wantedPath = canonicalizePath(registryName);
return entries.some((entry) => {
if (typeof entry.name === 'string' && entry.name.toLowerCase() === wantedName) return true;
if (typeof entry.path !== 'string') return false;
return registryPathEquals(canonicalizePath(entry.path), wantedPath);
});
}
async function loadContractRegistryResilient(
groupDir: string,
): Promise<
@ -288,6 +325,8 @@ async function loadContractRegistryResilient(
}
}
// Bound once: the gate is a full array scan and the ternary below used it twice.
const recordedUnreadable = recordedRepoList(base.unreadableRepos);
const registry: ContractRegistry = {
version: typeof base.version === 'number' ? base.version : 0,
generatedAt: typeof base.generatedAt === 'string' ? base.generatedAt : '',
@ -295,7 +334,20 @@ async function loadContractRegistryResilient(
base.repoSnapshots && typeof base.repoSnapshots === 'object' && base.repoSnapshots !== null
? (base.repoSnapshots as Record<string, { indexedAt: string; lastCommit: string }>)
: {},
missingRepos: Array.isArray(base.missingRepos) ? (base.missingRepos as string[]) : [],
// Same gate as `groupStatus` uses on the same field, for the same reason:
// `Array.isArray` alone waves through `[{repo:'x'}]`, and `groupContracts`
// now returns this list AND folds it into its completeness answer, so a
// value we could not read would be reported as a repo name. `missingRepos`
// has always been required, so — unlike `unreadableRepos` below — there is
// no "not recorded" state to preserve: an unreadable value degrades to empty.
missingRepos: recordedRepoList(base.missingRepos) ?? [],
// Spread, not `?? []`. `ContractRegistry.unreadableRepos` documents absence
// as "not recorded", and a registry written before the field existed has no
// opinion about which indexes were readable. Normalizing that to `[]` hands
// the caller "the last sync found none unreadable" — an unmeasured state
// rendered as a clean result, which is the same conflation this whole
// change removes.
...(recordedUnreadable ? { unreadableRepos: recordedUnreadable } : {}),
contracts,
crossLinks,
};
@ -347,18 +399,34 @@ export class GroupService {
// MCP server startup entirely and off every non-sync group call. The CLI
// already does exactly this at `cli/group.ts`'s sync command.
const { syncGroup } = await import('./sync.js');
const result = await syncGroup(config, {
groupDir,
exactOnly: Boolean(params.exactOnly),
skipEmbeddings: Boolean(params.skipEmbeddings),
allowStale: Boolean(params.allowStale),
verbose: Boolean(params.verbose),
});
const { GroupSyncLockError } = await import('./group-lock.js');
let result: Awaited<ReturnType<typeof syncGroup>>;
try {
result = await syncGroup(config, {
groupDir,
exactOnly: Boolean(params.exactOnly),
skipEmbeddings: Boolean(params.skipEmbeddings),
allowStale: Boolean(params.allowStale),
verbose: Boolean(params.verbose),
});
} catch (err) {
// Fails closed (R9): this sync could not be protected against a concurrent
// one, so it did not run and wrote nothing. Return it through the same
// error channel a missing group uses — NEVER as a success payload of zeroes,
// which an agent would read as "the group genuinely has no contracts".
if (!(err instanceof GroupSyncLockError)) throw err;
return { error: err.message };
}
return {
contracts: result.contracts.length,
crossLinks: result.crossLinks.length,
unmatched: result.unmatched.length,
missingRepos: result.missingRepos,
unreadableRepos: result.unreadableRepos,
// An agent that calls group_sync and then group_contracts a moment later
// can otherwise see contract counts that disagree with this payload, with
// nothing here explaining why the write was skipped.
registryOutcome: result.registryOutcome,
};
}
@ -386,7 +454,38 @@ export class GroupService {
);
contracts = contracts.filter((c) => !matchedIds.has(`${c.repo}::${c.contractId}`));
}
const out: Record<string, unknown> = { contracts, crossLinks: registry.crossLinks };
// `loadContractRegistryResilient` already applied `recordedRepoList` to
// both: `undefined` here is "the last sync recorded no opinion" (a registry
// written before the field existed, or a value we could not read), which is
// NOT the same answer as the measured empty list.
const { unreadableRepos, missingRepos } = registry;
// `incompleteRepos` is dropped on this surface only because the two lists it
// is derived from are returned verbatim right below; the truncation triple is
// the part that has no other channel here.
const { incompleteRepos: _incompleteRepos, ...truncation } = crossRepoCompleteness({
unreadableRepos,
missingRepos,
// An unrecorded `unreadableRepos` means this listing cannot say which
// repos the sync failed to read — so it cannot claim to be complete.
provenanceUnknown: unreadableRepos === undefined,
// A contract LISTING declares no scope to intersect with: it is the whole
// registry, so every configured repo is in scope by construction. The
// `type`/`repo`/`unmatchedOnly` filters above narrow which rows are shown,
// not which repos the sync had to read to produce them.
inScope: () => true,
});
const out: Record<string, unknown> = {
contracts,
crossLinks: registry.crossLinks,
missingRepos,
// Omitted rather than `[]` when the registry never recorded it — the same
// convention `skippedCorrupt` follows below, and the difference between
// "the sync measured zero unreadable repos" and "the sync never said".
...(unreadableRepos ? { unreadableRepos } : {}),
// The structured triple, verbatim from the impact surface (KTD10):
// `truncated` always, `truncationReason` + `riskEpistemic` with it.
...truncation,
};
if (skippedCorrupt > 0) out.skippedCorrupt = skippedCorrupt;
return out;
}
@ -573,17 +672,80 @@ export class GroupService {
}
const registry = await readContractRegistry(groupDir);
/**
* The STRICT global-registry read, deliberately this is the one caller
* that has to tell "the registry says nothing about this repo" apart from
* "the registry could not be read at all", and only the strict mode can.
* `readRegistry`'s `catch { return [] }` collapses a malformed registry
* into an empty one, which is indistinguishable from a genuine absence and
* would report every configured repo as having no entry the exact
* conflation the two labels below exist to remove.
*
* The consequence is accepted knowingly: the strict read rejects the WHOLE
* registry when any single row fails to identify a repo, so one malformed
* row renders every member of the group unresolvable, including members
* whose own rows are fine. That is the honest verdict a registry the
* resolver cannot trust row-wise cannot be trusted about any row and it
* is reported as an unresolved state, never as a clean one.
*
* ENOENT is not a failure in either mode: no registry file genuinely means
* nothing has been registered yet, so every repo is legitimately missing.
*/
let registryEntries: RegistryEntry[] | null = null;
let registryReadError: string | null = null;
try {
registryEntries = await readRegistryStrict();
} catch (err) {
registryReadError = err instanceof Error ? err.message : String(err);
}
const repoStatuses: Record<
string,
{
indexStale: boolean;
contractsStale: boolean;
/**
* Unchanged meaning: this repo has no usable status. It stays `true`
* for BOTH failures below, so a consumer written before the split
* still sees every unusable repo flagged. Reporting an unresolvable
* repo as `missing: false` would hand that consumer `indexStale:
* false` for a repo nothing was ever read from — a false all-clear.
*/
missing: boolean;
/**
* Which failure `missing` means: `false` is a genuine registry miss,
* `true` is an entry the resolver could not turn into a repo. Additive
* always present on every row, so an agent can branch on it without
* having to treat an absent key as either answer.
*/
unresolvable: boolean;
/** Set only when `unresolvable`; says what could not be resolved. */
unresolvableReason?: string;
commitsBehind?: number;
}
> = {};
for (const [repoPath, registryName] of Object.entries(config.repos)) {
if (registryEntries === null) {
repoStatuses[repoPath] = {
indexStale: false,
contractsStale: false,
missing: true,
unresolvable: true,
unresolvableReason: `the global registry could not be read: ${registryReadError}`,
};
continue;
}
// Only `resolveRepo` is inside the try that produces the
// "did not resolve" label, so the label is earned rather than assumed.
// `loadMeta` and `checkStaleness` cannot throw — the first returns null on
// every error, the second catches everything — but the reading below them
// can, and did: `registry.repoSnapshots` is read off a bare
// `JSON.parse(...) as ContractRegistry` with no shape check, so a
// contracts.json missing that field threw a TypeError into this catch and
// reported every repo as an unresolvable GLOBAL-registry entry. That sent
// the operator to repair the wrong file. The optional chain below closes
// the crash; this split stops the next one being mislabelled the same way.
try {
const repoObj = await this.port.resolveRepo(registryName);
const meta: Partial<Pick<RepoMeta, 'lastCommit' | 'indexedAt'>> =
@ -593,7 +755,7 @@ export class GroupService {
? checkStaleness(repoObj.repoPath, meta.lastCommit)
: { isStale: true, commitsBehind: -1 };
const snapshot = registry?.repoSnapshots[repoPath];
const snapshot = registry?.repoSnapshots?.[repoPath];
const contractsStale =
snapshot && meta.indexedAt ? snapshot.indexedAt !== meta.indexedAt : !snapshot;
@ -601,17 +763,45 @@ export class GroupService {
indexStale: staleness.isStale,
contractsStale: Boolean(contractsStale),
missing: false,
unresolvable: false,
commitsBehind: staleness.commitsBehind,
};
} catch {
repoStatuses[repoPath] = { indexStale: false, contractsStale: false, missing: true };
} catch (err) {
// The registry read succeeded, so its answer about this row is
// trustworthy: a row that is there and still would not resolve is a
// different fact from a row that was never there, and the operator's
// next move differs (repair the entry vs. index the repo).
const known = registryIdentifies(registryEntries, registryName);
const reason = err instanceof Error ? err.message : String(err);
repoStatuses[repoPath] = {
indexStale: false,
contractsStale: false,
missing: true,
unresolvable: known,
...(known
? { unresolvableReason: `registry entry "${registryName}" did not resolve: ${reason}` }
: {}),
};
}
}
return {
group: name,
lastSync: registry?.generatedAt || null,
missingRepos: registry?.missingRepos || [],
// `readContractRegistry` is a bare `JSON.parse(...) as ContractRegistry`,
// so both of these are whatever the file happened to hold — the
// validation in `loadContractRegistryResilient` never runs on this path.
// A `contracts.json` carrying a string here reached `cli/group.ts` and
// died in `.join(', ')`, i.e. an unreadable registry crashing the command
// whose job is to explain unreadable things.
//
// `missingRepos` has always been required, so there is no "not recorded"
// state to preserve for it — an unreadable value degrades to empty.
missingRepos: recordedRepoList(registry?.missingRepos) ?? [],
// `unreadableRepos` does have one: absent means "not recorded", not
// "none" (see ContractRegistry), and a value we could not read is equally
// unrecorded. Reporting either as an empty list is the same conflation.
unreadableRepos: recordedRepoList(registry?.unreadableRepos),
repos: repoStatuses,
};
}

View file

@ -5,7 +5,7 @@ import * as os from 'node:os';
import type { ContractRegistry } from './types.js';
import { writeFileAtomic } from '../../storage/fs-atomic.js';
const CONTRACTS_FILE = 'contracts.json';
export const CONTRACTS_FILE = 'contracts.json';
export function getDefaultGitnexusDir(): string {
return process.env.GITNEXUS_HOME || path.join(os.homedir(), '.gitnexus');
@ -30,6 +30,11 @@ export function getGroupDir(gitnexusDir: string, groupName: string): string {
return path.join(gitnexusDir, 'groups', groupName);
}
/** The registry path, so callers that stat or watch the file do not respell its name. */
export function getContractRegistryPath(groupDir: string): string {
return path.join(groupDir, CONTRACTS_FILE);
}
export async function writeContractRegistry(
groupDir: string,
registry: ContractRegistry,

Binary file not shown.

View file

@ -100,7 +100,20 @@ export interface ContractRegistry {
version: number;
generatedAt: string;
repoSnapshots: Record<string, RepoSnapshot>;
/** Configured repos with no entry in the registry. */
missingRepos: string[];
/**
* Configured repos that ARE registered but that this sync could not extract
* from the index would not open (version skew, lock, corruption), or an
* extractor threw partway through. The two are one bucket because the
* consequence is one thing: NONE of that repo's contracts are in this
* registry. Distinct from `missingRepos`, which is "no entry in the
* registry at all" and needs a different answer from the operator.
*
* Optional so a registry written before this field existed still parses
* absent means "not recorded", not "none".
*/
unreadableRepos?: string[];
contracts: StoredContract[];
crossLinks: CrossLink[];
}
@ -117,8 +130,24 @@ export interface RepoHandle {
storagePath: string;
}
/** Why local impact or fan-out stopped early (e.g. wall-clock budget exhausted). */
export type GroupImpactTruncationReason = 'timeout' | 'partial';
/**
* Why local impact or fan-out stopped early (e.g. wall-clock budget exhausted).
*
* `'timeout'` and `'partial'` are runtime limits the same query can succeed on
* a retry. `'incomplete-sync'` is structural: the bridge itself was built from a
* sync that could not read every configured repo, so those repos' contracts are
* absent from every query against it until `gitnexus group sync` succeeds.
*
* A runtime array rather than a bare type union: every value here has to be
* explained on the agent-facing surface that returns it, and only an enumerable
* list lets a guard test assert that. A test that hand-lists the members passes
* forever once a fourth is added which is the exact drift the guard exists to
* catch, so the list an agent is promised and the list the code can emit have
* to come from the same place.
*/
export const GROUP_IMPACT_TRUNCATION_REASONS = ['timeout', 'partial', 'incomplete-sync'] as const;
export type GroupImpactTruncationReason = (typeof GROUP_IMPACT_TRUNCATION_REASONS)[number];
export interface GroupImpactResult {
local: unknown;
@ -222,5 +251,110 @@ export interface BridgeHandle {
export interface BridgeMeta {
version: number;
generatedAt: string;
/**
* Size and mtime of the `bridge.lbug` this metadata was written for, so a
* reader can tell whether the two still belong together.
*
* `writeBridge` replaces the database and writes this file as two operations;
* a sync that stops between them leaves the PREVIOUS sync's metadata beside a
* new database, and `runGroupImpact` reads completeness from that metadata.
* Stamping the pair is what lets `bridgeMetaMatchesFile` reject the mismatch
* without anything having to be deleted deleting the old metadata up front
* would lose it permanently on a swap that fails with the old database still
* in place, which is a normal Windows outcome when a read-only handle is held.
*
* Optional: metadata written before this existed carries no stamp. Such a
* file is not waved through `bridgeMetaMatchesFile` falls back to comparing
* the two files' modification times, since a successful write orders the
* database rename before the metadata write and a database NEWER than the
* metadata beside it therefore cannot be the one it describes.
*
* That fallback proves WRITE ORDER, not provenance, and is wrong in both
* directions a non-monotonic clock can make a mis-paired set read as
* ordered, and any copy or restore that rewrites the database's times after
* the metadata's demotes an intact legacy pair to a lower bound until the
* next sync re-stamps it. A stamped pair never reaches that fallback, which
* is the reason to prefer stamping over widening the heuristic. Both
* directions are spelled out at `bridgeMetaMatchesFile`.
*/
bridgeSize?: number;
bridgeMtimeMs?: number;
/**
* Reader-side only: true when `meta.json` parsed but one of its repo lists
* held a value that was not a list of repo paths.
*
* NEVER PERSISTED. `readBridgeMeta` sets it to describe what it found in the
* file; `writeBridgeMeta`'s only caller builds a fresh literal, so it cannot
* round-trip back to disk. It lives on this interface rather than on a
* reader-only subtype so that `readBridgeMeta` keeps the exact signature
* every caller already compiles against.
*
* The unusable value is dropped rather than normalized, so `missingRepos: []`
* on such a result is inert filler this flag, not the empty list, is what
* says the bridge's provenance is unknown.
*/
repoListsUnreadable?: boolean;
/**
* Reader-side only: did this metadata pair with the `bridge.lbug` beside it,
* measured BEFORE anything opened that database?
*
* NEVER PERSISTED, for the same reason as `repoListsUnreadable`.
*
* The measurement has to happen before the open, and the answer has to be
* carried rather than recomputed. `runGroupImpact` and `runGroupTrace` open
* the bridge and only then ask about provenance, so a platform where a
* read-only open advances the database's mtime would fail every unstamped
* pair the moment it was read turning back-compat for pre-stamp bridges
* into a repo-wide "everything is a lower bound". Whether any given
* LadybugDB build and OS does that is not something a reader should have to
* know, and it cannot be observed on Windows, where the in-process
* writeread reopen this would need is a documented limitation. Ordering the
* check ahead of the open makes the question moot on every platform instead
* of true on the ones that happen to be testable.
*/
pairedWithDatabase?: boolean;
/**
* PERSISTED, unlike the two fields above: the writer of this metadata could
* not establish that it describes the `bridge.lbug` beside it, and no reader
* may conclude otherwise from the files alone.
*
* Written by `refreshPreservedBridgeMeta` the preserve path in `syncGroup`,
* which refreshes the diagnostic lists of a bridge it deliberately does NOT
* rebuild. That refresh rewrites `meta.json` ATOMICALLY, so this file's mtime
* becomes now while the database's stays old; and "metadata newer than the
* database beside it" is exactly the write order that
* `unstampedMetaPairsByWriteOrder` accepts. A refresh that simply carried the
* old fields forward would therefore convert a pair that check had been
* REJECTING into one it waves through laundering unknown provenance into
* verified provenance, which is the fail-open this whole channel exists to
* close.
*
* "Just don't write a stamp" is not a substitute, and is worse: an unstamped
* metadata file is judged on the two file times, and the refresh has already
* moved them into the accepting order. The verdict has to be recorded IN the
* file, because the write that records it is itself what destroys the
* evidence a reader would otherwise use.
*
* `bridgeMetaMatchesFile` rejects on this ahead of both the stamp and the
* write-order heuristic, so `ensureBridgeReady` answers
* `pairedWithDatabase: false` and `bridgeProvenanceUnknown` reports the
* cross-repo answer as a lower bound. That is the ONE enforcement point; do
* not add a second reader for this field.
*
* Self-clearing: a successful `writeBridge` builds fresh metadata from a
* literal and never sets it, so the next good sync retires the marker without
* anything having to delete it.
*/
provenanceUnknown?: boolean;
missingRepos: string[];
/**
* Configured repos the sync that produced this bridge could not extract from
* (see `ContractRegistry.unreadableRepos`). Their contracts and every
* cross-link touching them are absent from `bridge.lbug`, so a cross-repo
* impact query against this bridge is a lower bound, not a verdict
* `runGroupImpact` folds a non-empty value into its truncation fields for
* exactly that reason.
* Optional: a bridge written before this field existed does not record it.
*/
unreadableRepos?: string[];
}

View file

@ -787,7 +787,7 @@ export function pickUniqueGlobalCallable(
// because the list would then depend on the caller's scope, not just its file.
const cacheKey =
scopeDefsCache !== undefined && isCallerVisible === undefined
? `${name}${callerFilePath}`
? `${name}\0${callerFilePath}`
: undefined;
let scopeDefs: readonly SymbolDefinition[] | undefined =
cacheKey !== undefined ? scopeDefsCache!.get(cacheKey) : undefined;

View file

@ -97,7 +97,22 @@ export function getResourceTemplates(): ResourceTemplate[] {
{
uriTemplate: 'gitnexus://group/{name}/status',
name: 'Group Index Status',
description: 'Per-repo index and contract-registry staleness for a repository group',
// The payload is a bare serialization, so nothing in it says which of
// three states a reader is looking at. Both distinctions below are
// additive fields whose meaning is invisible without this: `missing`
// alone cannot separate "not registered" from "registry unreadable", and
// an omitted `unreadableRepos` key looks exactly like a measured zero.
description:
'Per-repo index and contract-registry staleness for a repository group. ' +
'Every configured repo carries both `missing` and `unresolvable`: a repo genuinely absent ' +
'from the global registry is missing:true with unresolvable:false; a repo whose registry ' +
'entry could not be read or resolved is unresolvable:true with an unresolvableReason ' +
'(missing stays true there too, so a consumer written before the split still sees every ' +
'unusable repo); a healthy repo is neither. The group-level unreadableRepos list is ' +
'three-state, and an ABSENT key is not an empty one: absent means the last sync never ' +
'recorded which repos it could read (provenance unknown — treat cross-repo answers for ' +
'this group as a floor), an empty list means the sync measured none, and a populated list ' +
'names the repos whose contracts are missing from the registry.',
mimeType: 'text/yaml',
},
];
@ -389,7 +404,12 @@ async function getContextResource(backend: LocalBackend, repoName?: string): Pro
lines.push(
' - gitnexus://group/{name}/contracts: Group contract registry (optional ?type=&repo=&unmatchedOnly=)',
);
lines.push(' - gitnexus://group/{name}/status: Group index / contract staleness');
lines.push(
' - gitnexus://group/{name}/status: Group index / contract staleness — separates a repo absent ' +
'from the registry (missing, not unresolvable) from one whose entry could not be read ' +
'(unresolvable + unresolvableReason), and carries unreadableRepos as absent=never recorded / ' +
'empty=measured none / populated=named',
);
return lines.join('\n');
}

View file

@ -504,7 +504,7 @@ Handles disambiguation: when multiple symbols share the target name, returns ran
EdgeType: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, METHOD_OVERRIDES, METHOD_IMPLEMENTS, ACCESSES
Confidence: 1.0 = certain, <0.8 = fuzzy match
GROUP MODE: set "repo" to "@<groupName>" for cross-repo impact anchored at the default member (lexicographically first key in group.yaml "repos"), or "@<groupName>/<groupRepoPath>" to choose the member (same path keys as in group.yaml). Phase-1 walk runs in that member; cross-boundary fan-out uses the group bridge. A cross entry with fanout_status:"not_attempted" proves the declared repository boundary, but its far endpoint has no graph symbol; do not interpret empty by_depth or affected_processes on that entry as a completed zero-impact walk. The fan-out attempts at most 50 neighbour crossings, strongest-confidence first; when it stops early the response carries truncated:true, truncatedRepos, and riskEpistemic:"lower-bound" dropping a crossing can only move risk DOWN, so treat that risk as a floor, never as a verdict.
GROUP MODE: set "repo" to "@<groupName>" for cross-repo impact anchored at the default member (lexicographically first key in group.yaml "repos"), or "@<groupName>/<groupRepoPath>" to choose the member (same path keys as in group.yaml). Phase-1 walk runs in that member; cross-boundary fan-out uses the group bridge. A cross entry with fanout_status:"not_attempted" proves the declared repository boundary, but its far endpoint has no graph symbol; do not interpret empty by_depth or affected_processes on that entry as a completed zero-impact walk. The fan-out attempts at most 50 neighbour crossings, strongest-confidence first. Any short answer carries truncated:true, truncatedRepos, riskEpistemic:"lower-bound" AND a truncationReason dropping a crossing can only move risk DOWN, so treat that risk as a floor, never as a verdict. truncated:true does NOT always mean the fan-out ran out of room, so branch on truncationReason: the remedy differs. 'timeout' (the fan-out's wall-clock budget expired) and 'partial' (a neighbour crossing, or the local walk, was cut short) are runtime limits — the same query can return more on a retry or with a larger timeoutMs. 'incomplete-sync' is structural: the group bridge was built by a sync that could not say which repos it read, or that could not read an in-scope repo, so those repos' contracts are absent from EVERY query against this bridge, and truncatedRepos names them even when ZERO crossings to them were attempted. Retrying returns the same floor run group_sync (\`gitnexus group sync\`) and query again.
SERVICE: optional monorepo path prefix (case-sensitive path segments). When "repo" starts with "@", scopes the local impact walk and cross-repo symbol paths to files under that prefix; ignored for a normal indexed repo name.`,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
@ -851,9 +851,15 @@ WHEN TO USE: Discover groups before group_sync. Optional "name" returns a single
name: 'group_sync',
description: `Rebuild the Contract Registry (contracts.json) for a group: extract HTTP contracts, apply manifest links, exact-match cross-links.
WHEN TO USE: After changing group.yaml or re-indexing member repos.`,
// Writes contracts.json on every call; conservatively non-idempotent
// even though output is deterministic for identical input.
WHEN TO USE: After changing group.yaml or re-indexing member repos.
READ THE RESULT: \`missingRepos\` are configured repos with no entry in the registry (index them, or drop them from group.yaml); \`unreadableRepos\` ARE registered but this sync could not extract from them — the index would not open (version skew, lock, corruption), or an extractor failed partway — so NONE of their contracts are in this sync and a following group_impact / group_contracts is a lower bound, not a verdict. \`registryOutcome\` says what happened to the file, and the three values a call here can return each need a different response: 'written' — this run's contracts replaced contracts.json; 'preserved' — nothing could be read, so contracts.json was rewritten keeping the previous sync's contracts and cross-links verbatim and refreshing only \`missingRepos\`/\`unreadableRepos\` to describe THIS run (the file changed, the contracts in it did not, and they are as old as the last sync that succeeded); 'superseded' — nothing could be read, and another sync replaced contracts.json while this one waited for the group lock; that file was left untouched and this run's lists were NOT recorded, because they describe an older group state than what is on disk (so the registry is fresher than this response's diagnostics, not staler); 'no-prior-registry' — nothing could be read AND there was no previous contracts.json to carry forward, so none was written and this group has no contract registry on disk. Only 'no-prior-registry' means there is nothing to read: after it, group_contracts / group_impact have no registry at all rather than a stale one, so fix the repos above and re-run before trusting either.`,
// Usually writes contracts.json, so conservatively non-idempotent even
// though output is deterministic for identical input. When no configured
// repo could be read it still rewrites the file, keeping the previous
// registry's contracts and refreshing only its diagnostic fields
// (`registryOutcome: 'preserved'`); it writes nothing when there was no
// previous registry to carry forward (`'no-prior-registry'`).
annotations: DESTRUCTIVE_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',

View file

@ -105,6 +105,25 @@ export interface LockRecord {
export interface IndexLockHandle {
/** Our own record — `invocationId` is shown to waiters as the holder id. */
readonly record: LockRecord;
/**
* `true` ONLY on the no-op handle returned when the filesystem refused to
* create the lock file (see {@link LOCK_UNWRITABLE_CODES}); absent on every
* handle that owns a real lock. Purely descriptive it surfaces a fact this
* module already had, and changes nothing about when or how a lock is taken.
*
* It exists because that degradation is otherwise INVISIBLE at the API
* boundary: the no-op handle is byte-identical in shape to a real one, so a
* caller for whom "lock-free" is not an acceptable outcome (a long, expensive
* critical section whose lost update destroys data e.g. a group sync) has no
* way to tell it apart and fail closed. A filesystem probe is not a substitute:
* {@link selectBackend} returns `socket` on Linux and Windows, where this
* branch cannot occur at all, so a probe would refuse on the two platforms
* that never degrade.
*
* Additive by construction: every caller that ignores this field behaves
* exactly as it did before it existed.
*/
readonly lockFree?: true;
/** Idempotent; only removes the lock file if it still carries our token. */
release(): void;
}
@ -317,8 +336,14 @@ export const isLockUnwritableCode = (code: string | undefined): boolean =>
code !== undefined && LOCK_UNWRITABLE_CODES.has(code);
/** A lock handle that owns nothing returned when the filesystem refuses to
* create the lock file (see {@link LOCK_UNWRITABLE_CODES}). Release is a no-op. */
const noopHandle = (record: LockRecord): IndexLockHandle => ({ record, release: () => {} });
* create the lock file (see {@link LOCK_UNWRITABLE_CODES}). Release is a no-op.
* Carries {@link IndexLockHandle.lockFree} so a caller that must not run
* unprotected can tell this apart from a handle that owns a real lock. */
const noopHandle = (record: LockRecord): IndexLockHandle => ({
record,
lockFree: true,
release: () => {},
});
/**
* Delete orphaned build/staging artifacts left in the lock directory by a

View file

@ -616,18 +616,138 @@ const sanitizeEntries = (entries: RegistryEntry[]): RegistryEntry[] =>
});
/**
* Read the global registry. Returns empty array if not found.
* A registry row we can actually resolve a repo from.
*
* `Array.isArray` is not enough on the strict path: `[{}]` is a JSON array, so
* a malformed registry passed the shape check, every configured repo failed to
* resolve, and because none of them produced a load ERROR the total-failure
* guard stayed off and a good contracts.json was replaced by an empty one. That
* is the same fail-open the strict mode exists to close, one level down from
* the file to the rows inside it.
*
* Only the three fields the resolution path actually depends on are required.
* `indexedAt` / `lastCommit` are deliberately NOT: callers already default them
* (`e?.indexedAt || ''`), so demanding them would reject a legacy row that
* resolves perfectly well trading a fail-open for a fail-shut on real data.
*
* Two of the three must also be non-blank, because `typeof '' === 'string'`
* passes a row that cannot identify anything. `name` is what
* `defaultResolveHandle` matches a configured repo against, so a blank one
* matches nothing and puts every repo in `missingRepos` the same fail-open,
* dressed as a clean answer. `storagePath` is what the resolved handle carries
* to `path.join(storagePath, 'lbug')`; blank, that joins to a relative `lbug`
* under the CWD, so the sync opens an index that is not the repo's.
*
* `path` stays at the bare string check, on the same reasoning that exempts
* `indexedAt` / `lastCommit`: require only what the resolution path depends on
* to IDENTIFY the repo. This check rejects the WHOLE registry, which is
* machine-wide, so a field tightened past what resolution needs would let one
* blank value in one row break every group sync on the machine including
* groups whose repos all resolve.
*/
export const readRegistry = async (): Promise<RegistryEntry[]> => {
const isResolvableEntry = (value: unknown): value is RegistryEntry => {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const e = value as Record<string, unknown>;
const identifies = (v: unknown): boolean => typeof v === 'string' && v.trim() !== '';
return identifies(e.name) && identifies(e.storagePath) && typeof e.path === 'string';
};
/**
* Shared body for the two read modes below.
*
* `strict` distinguishes "the registry says nothing is registered" from "the
* registry could not be read". Lenient collapses both into `[]`.
*
* ENOENT is lenient in BOTH modes: no file genuinely means nothing has been
* registered yet, and every first-run path depends on that.
*/
const readRegistryFile = async (strict: boolean): Promise<RegistryEntry[]> => {
let raw: string;
try {
const raw = await fs.readFile(getGlobalRegistryPath(), 'utf-8');
const data = JSON.parse(raw);
return Array.isArray(data) ? sanitizeEntries(data) : [];
} catch {
raw = await fs.readFile(getGlobalRegistryPath(), 'utf-8');
} catch (err) {
if (strict && (err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
return [];
}
try {
// The parse gets its OWN guarded region, narrower than the checks below,
// and the parser's error is DISCARDED rather than rethrown.
//
// `JSON.parse`'s SyntaxError quotes a ten-character window of the source
// either side of the break — `Unexpected token 'L', ..."end.git"},<here>"...`.
// Registry rows carry remote URLs with their HTTPS userinfo verbatim, so a
// registry that breaks on one of those URLs puts the credential into that
// window, and the thrown message is not the only place it goes from there:
// `groupStatus` interpolates it into `unresolvableReason` for an MCP
// client, and `gitnexus group sync` prints it.
//
// Not logged and not attached as `cause` either, deliberately against this
// file's own convention of handing the `Error` object to the logger so it
// captures stack and cause: under MCP stdio the client writes those records
// to a log file on disk, so following the convention here would move the
// byte window from one channel to a more durable one. The parser's position
// offset is not worth a credential — the path and the failure class are
// what an operator acts on, and they are what the two errors below say too.
let data: unknown;
try {
data = JSON.parse(raw);
} catch {
throw new Error(`${getGlobalRegistryPath()} is not valid JSON (registry is corrupt)`);
}
if (!Array.isArray(data)) {
if (strict) {
throw new Error(`${getGlobalRegistryPath()} is not a JSON array (registry is corrupt)`);
}
return [];
}
if (strict) {
// Reject the WHOLE registry, never filter the bad rows out. Dropping them
// would report the repos they name as unregistered, which is precisely
// the unreadable-as-missing answer this mode refuses to give.
const bad = data.findIndex((entry) => !isResolvableEntry(entry));
if (bad !== -1) {
throw new Error(
`${getGlobalRegistryPath()} entry ${bad} does not identify a repo — name and storagePath must be non-empty strings and path must be a string (registry is corrupt)`,
);
}
}
return sanitizeEntries(data as RegistryEntry[]);
} catch (err) {
if (strict) throw err;
return [];
}
};
/**
* Read the global registry. Returns empty array if not found and, note, also
* when the file exists but cannot be read or parsed. That is fine for a
* read-only listing, where an unreadable registry and an empty one print the
* same nothing. It is not fine for a caller that ACTS on emptiness; see
* `readRegistryStrict`.
*/
export const readRegistry = async (): Promise<RegistryEntry[]> => readRegistryFile(false);
/**
* Read the global registry, refusing to report an unreadable one as empty.
*
* An EACCES after a `sudo gitnexus analyze`, a truncated registry.json, or an
* $HOME-on-NFS blip otherwise presents as "no repo is registered" an
* unreadable condition reported as missing, which is exactly the conflation
* #3011 removes one frame further down. `syncGroup` is the caller that acts on
* that answer, by replacing a good contracts.json with an empty one.
*
* Deliberately a separate export rather than an option on `readRegistry`:
* leaving that signature untouched keeps every existing lenient call site
* provably unaffected, and the mode is legible at the call site.
*
* No count here on purpose. This comment carried one, it said nine, and the
* real figure was thirteen by the time anyone checked and fourteen shortly
* after a number in prose beside code that moves is a claim that rots
* silently, which is the defect class this whole change set is about. The
* argument does not need the figure: it holds for one call site or fifty.
*/
export const readRegistryStrict = async (): Promise<RegistryEntry[]> => readRegistryFile(true);
/**
* Write the global registry to disk.
*

View file

@ -0,0 +1,62 @@
/**
* Child process for the cross-process group sync-lock tests (R9). Holds the
* REAL `withGroupSyncLock` on GROUP_DIR so the parent's `syncGroup` contends
* with a genuinely separate process the only way to observe the property this
* lock exists for. An in-process mock cannot: the socket backend's exclusion is
* a kernel binding, and the file backend's is an O_EXCL create.
*
* Env:
* GROUP_LOCK_MODULE file:// URL or path of the group-lock module to import.
* GROUP_DIR the group directory to lock.
* MARKER written (with our pid) once the lock is HELD.
* HOLD_MS how long to hold before releasing. 0/unset = hold until
* killed (used by the holder-death and no-contention cases).
* CONTRACTS optional: path to write just before releasing, standing in
* for the first sync's persist. Written LAST on purpose if
* the lock were absent the waiting sync would have written
* first and this would overwrite it, which is exactly the
* lost update the test hunts.
* RELEASED optional: path stamped with Date.now() just before release.
*/
import { writeFileSync } from 'node:fs';
import { pathToFileURL } from 'node:url';
// On Windows `import('C:\\…')` throws ERR_UNSUPPORTED_ESM_URL_SCHEME (a bare
// drive path reads as a URL scheme), so address the module as a file:// URL.
const spec = process.env.GROUP_LOCK_MODULE.startsWith('file:')
? process.env.GROUP_LOCK_MODULE
: pathToFileURL(process.env.GROUP_LOCK_MODULE).href;
const { withGroupSyncLock } = await import(spec);
const holdMs = Number(process.env.HOLD_MS ?? 0);
// Nothing else keeps this process alive: the socket backend's server is unref'd
// and the file backend holds no open handle.
const keepalive = setInterval(() => {}, 1000);
await withGroupSyncLock(process.env.GROUP_DIR, async () => {
writeFileSync(process.env.MARKER, String(process.pid));
if (holdMs <= 0) {
await new Promise(() => {}); // hold until the parent kills us
return;
}
await new Promise((r) => setTimeout(r, holdMs));
if (process.env.CONTRACTS) {
writeFileSync(
process.env.CONTRACTS,
JSON.stringify({
version: 1,
generatedAt: new Date().toISOString(),
writtenBy: 'child',
contracts: [],
crossLinks: [],
repoSnapshots: {},
missingRepos: [],
unreadableRepos: [],
}),
);
}
if (process.env.RELEASED) writeFileSync(process.env.RELEASED, String(Date.now()));
});
clearInterval(keepalive);
process.exit(0);

View file

@ -1,15 +1,20 @@
/**
* Smoke-test `gitnexus group` CLI (same spawn pattern as cli-e2e.test.ts, via
* CLI_SPAWN_PREFIX: built dist in CI, tsx-on-source locally).
* Does not exercise LadybugDB-backed commands end-to-end (needs indexed fixtures).
* Does not exercise LadybugDB-backed QUERY commands end-to-end (needs indexed
* fixtures). `group sync` IS driven end-to-end below, but only through the two
* shapes that need no indexed repo: a group whose members are absent from the
* registry, and a group whose members are registered at a storage path holding
* no `lbug` file at all which is what makes them unreadable.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
import { CLI_SPAWN_PREFIX } from '../../helpers/cli-entry.js';
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import os from 'node:os';
import { INDEX_METADATA_FILE } from '../../../src/storage/repo-meta.js';
const testDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(testDir, '../../..');
@ -25,16 +30,20 @@ afterAll(() => {
}
});
function runGroup(args: string[]) {
function runGroupIn(home: string, args: string[]) {
return spawnSync(process.execPath, [...CLI_SPAWN_PREFIX, 'group', ...args], {
cwd: repoRoot,
encoding: 'utf8',
timeout: 20000,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, GITNEXUS_HOME: tmpHome },
env: { ...process.env, GITNEXUS_HOME: home },
});
}
function runGroup(args: string[]) {
return runGroupIn(tmpHome, args);
}
describe('group CLI', () => {
it('create + list', () => {
const c = runGroup(['create', 'acme']);
@ -107,3 +116,464 @@ describe('group CLI', () => {
}
});
});
describe('group contracts reports its completeness', () => {
/**
* `groupContracts` returns the structured triple alongside the contracts, so
* an agent can tell a complete listing from a floor. The `--json` path used
* to destructure `{ contracts, crossLinks }` and re-serialize just those two,
* which silently dropped every other field the service returned including
* the ones that say the listing is incomplete. Printing the payload whole is
* what keeps a new field from needing a matching CLI edit to become visible.
*/
const seedRegistry = (group: string, registry: Record<string, unknown>): void => {
const groupDir = path.join(tmpHome, 'groups', group);
fs.mkdirSync(groupDir, { recursive: true });
fs.writeFileSync(path.join(groupDir, 'contracts.json'), JSON.stringify(registry, null, 2));
};
const baseRegistry = {
version: 1,
generatedAt: '2026-01-01T00:00:00.000Z',
contracts: [],
crossLinks: [],
repoSnapshots: {},
missingRepos: [],
};
it('carries the incompleteness fields through --json', () => {
expect(runGroup(['create', 'jsonfloor']).status).toBe(0);
seedRegistry('jsonfloor', { ...baseRegistry, unreadableRepos: ['app/backend'] });
const r = runGroup(['contracts', 'jsonfloor', '--json']);
expect(r.status).toBe(0);
const payload = JSON.parse(r.stdout) as Record<string, unknown>;
expect(payload.unreadableRepos).toEqual(['app/backend']);
expect(payload.truncated).toBe(true);
expect(payload.truncationReason).toBe('incomplete-sync');
expect(payload.riskEpistemic).toBe('lower-bound');
// Still everything it always returned.
expect(payload.contracts).toEqual([]);
expect(payload.crossLinks).toEqual([]);
});
it('tells a human reader the listing is a floor, and which repos are missing from it', () => {
expect(runGroup(['create', 'humanfloor']).status).toBe(0);
seedRegistry('humanfloor', { ...baseRegistry, unreadableRepos: ['app/backend'] });
const r = runGroup(['contracts', 'humanfloor']);
expect(r.status).toBe(0);
expect(r.stdout).toContain('app/backend');
expect(r.stdout.toLowerCase()).toContain('incomplete');
});
it('control: a complete registry says nothing about truncation on either surface', () => {
expect(runGroup(['create', 'complete']).status).toBe(0);
seedRegistry('complete', { ...baseRegistry, unreadableRepos: [] });
const j = JSON.parse(runGroup(['contracts', 'complete', '--json']).stdout) as Record<
string,
unknown
>;
expect(j.truncated).toBe(false);
expect(j.truncationReason).toBeUndefined();
expect(j.riskEpistemic).toBeUndefined();
const h = runGroup(['contracts', 'complete']);
expect(h.stdout.toLowerCase()).not.toContain('incomplete');
});
});
/**
* The per-repo status table had ONE failure label `MISSING (no entry in the
* registry)` — and every reason a repo failed to resolve was printed with it,
* including a global registry that could not be read at all. For that case the
* line states something nobody measured (the command never got to read any
* entry) and points at the wrong repair: index the repo, when the fix is to
* repair the registry.
*
* These cases go through the real CLI because the label is the deliverable
* the service payload can carry the distinction perfectly while the table
* still prints one word for both.
*/
describe('group status names which failure a repo hit', () => {
let home: string;
/** Two members: one the registry will know about, one it never will. */
const GROUP_YAML = `version: 1
name: labels
description: ""
repos:
backend: backend-registry
svc/users: svc-users-registry
links: []
packages: {}
detect:
http: false
grpc: false
thrift: false
topics: false
shared_libs: false
embedding_fallback: false
matching:
bm25_threshold: 0.7
embedding_threshold: 0.65
max_candidates_per_step: 3
`;
beforeEach(() => {
home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-group-status-labels-'));
const groupDir = path.join(home, 'groups', 'labels');
fs.mkdirSync(groupDir, { recursive: true });
fs.writeFileSync(path.join(groupDir, 'group.yaml'), GROUP_YAML, 'utf8');
});
afterEach(() => {
fs.rmSync(home, { recursive: true, force: true });
});
/**
* A registry row that survives `LocalBackend.init()`'s validation pass
* which prunes (and rewrites) any entry whose storage path has no metadata
* file, so a row backed by nothing would silently become a genuine absence
* before `group status` ever read the registry.
*/
const registeredRow = (name: string, dirName: string): Record<string, string> => {
const repoPath = path.join(home, dirName);
const storagePath = path.join(repoPath, '.gitnexus');
fs.mkdirSync(storagePath, { recursive: true });
fs.writeFileSync(path.join(storagePath, INDEX_METADATA_FILE), '{}', 'utf8');
return {
name,
path: repoPath,
storagePath,
indexedAt: '2026-01-01T00:00:00.000Z',
lastCommit: 'abc123',
};
};
const writeRegistry = (body: string): void =>
fs.writeFileSync(path.join(home, 'registry.json'), body, 'utf8');
it('says MISSING for a repo a readable registry simply does not hold', () => {
// The label this command has always printed, kept honest: the registry
// reads fine and genuinely has no row for either member.
writeRegistry('[]');
const r = runGroupIn(home, ['status', 'labels']);
expect(r.status).toBe(0);
expect(r.stdout).toMatch(/^ +backend +MISSING {3}\(no entry in the registry\)$/m);
expect(r.stdout).toMatch(/^ +svc\/users +MISSING {3}\(no entry in the registry\)$/m);
expect(r.stdout).not.toContain('UNRESOLVABLE');
});
it('says UNRESOLVABLE for a repo the registry holds but cannot resolve', () => {
// Two registered clones under one name: the row is right there, and
// resolution still cannot pick one. Printing "no entry in the registry"
// here would be a false statement about the file just read — and the two
// members must come out with DIFFERENT labels in the same table.
writeRegistry(
JSON.stringify([
registeredRow('backend-registry', 'clone-a'),
registeredRow('backend-registry', 'clone-b'),
]),
);
const r = runGroupIn(home, ['status', 'labels']);
expect(r.status).toBe(0);
// One line, not four: the ambiguity error is multi-line and gets folded.
expect(r.stdout).toMatch(/^ +backend +UNRESOLVABLE \(.*backend-registry.*\)$/m);
expect(r.stdout).toMatch(/^ +svc\/users +MISSING {3}\(no entry in the registry\)$/m);
});
it('says UNRESOLVABLE for every member when the registry itself cannot be read', () => {
// Nothing was measured about any repo, so "no entry in the registry" is a
// claim about a file that could not be parsed. Every configured member is
// unresolved — including one whose row might have been perfectly fine.
writeRegistry('{"repos": []}');
const r = runGroupIn(home, ['status', 'labels']);
expect(r.status).toBe(0);
expect(r.stdout).toMatch(/^ +backend +UNRESOLVABLE \(.*registry\.json.*\)$/m);
expect(r.stdout).toMatch(/^ +svc\/users +UNRESOLVABLE \(.*registry\.json.*\)$/m);
expect(r.stdout).not.toContain('MISSING');
});
});
/**
* A group.yaml with every detector off, so nothing in a sync opens a repo graph
* and the only thing that can vary is what the registry says about its members.
*
* `links` is spliced in verbatim because the two shapes below need different
* ones: a manifest link is the single input that makes a sync produce contracts
* with no indexed repo (synthetic UIDs see
* `group-service-sync-lazy-import.test.ts`), which is what gives the wrote-line
* counts to assert something other than zeroes.
*/
function writeGroupYaml(
home: string,
group: string,
repos: Record<string, string>,
links = '[]',
): string {
const groupDir = path.join(home, 'groups', group);
fs.mkdirSync(groupDir, { recursive: true });
const repoLines = Object.entries(repos)
.map(([groupPath, registryName]) => ` ${groupPath}: ${registryName}`)
.join('\n');
fs.writeFileSync(
path.join(groupDir, 'group.yaml'),
`version: 1
name: ${group}
description: ""
repos:
${repoLines}
links: ${links}
packages: {}
detect:
http: false
grpc: false
thrift: false
topics: false
shared_libs: false
embedding_fallback: false
includes: false
workspace_deps: false
matching:
bm25_threshold: 0.7
embedding_threshold: 0.65
max_candidates_per_step: 3
`,
'utf8',
);
return groupDir;
}
/**
* `group sync` has three mutually exclusive things it can say about
* contracts.json, and the sentence is the ONLY channel that distinguishes them:
* all three exit 0, and two of them leave the file's contract counts identical.
*
* The line used to be the unconditional `Wrote contracts.json (0 contracts, 0
* cross-links)`, printed even on a run that deliberately kept the previous
* registry a confident false statement about persisted state on the exact
* path this command exists to make legible. These go through the real CLI
* because the sentence IS the deliverable: the service payload can carry
* `registryOutcome` perfectly while the console still says one thing for all
* three.
*/
describe('group sync says what it did to contracts.json', () => {
let home: string;
/** Contracts a preserve run must carry forward untouched. */
const PRIOR_REGISTRY = {
version: 1,
generatedAt: '2026-01-01T00:00:00.000Z',
repoSnapshots: {},
missingRepos: [],
unreadableRepos: [],
contracts: [],
crossLinks: [],
};
beforeEach(() => {
home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-group-sync-outcome-'));
});
afterEach(() => {
fs.rmSync(home, { recursive: true, force: true });
});
/**
* Registry rows whose storage directory exists but holds no `lbug` file, so
* `initLbug` throws `LadybugDB not found at …` for every one of them. That is
* a load ERROR, not an absence: the repos resolve, and every one of them
* lands on `unreadableRepos` the only state that reaches the two
* total-failure branches. A row missing from registry.json instead reports as
* MISSING and syncs to a written registry, which is the other case below.
*/
const registerReposWithNoIndex = (registryNames: Record<string, string>): void => {
const rows = Object.entries(registryNames).map(([registryName, dirName]) => {
const repoPath = path.join(home, dirName);
const storagePath = path.join(repoPath, '.gitnexus');
fs.mkdirSync(storagePath, { recursive: true });
return {
name: registryName,
path: repoPath,
storagePath,
indexedAt: '2026-01-01T00:00:00.000Z',
lastCommit: 'abc123',
};
});
fs.writeFileSync(path.join(home, 'registry.json'), JSON.stringify(rows), 'utf8');
};
it('prints what it wrote, and the counts, on a sync that produced a registry', () => {
// Every member is genuinely absent from the registry, which is a clean
// (if empty-handed) sync: the total-failure guard is gated on a load error,
// never on an empty result. The declared manifest link still yields two
// synthetic contracts and one cross-link, so the counts in the line are
// non-zero and therefore say something.
const groupDir = writeGroupYaml(
home,
'wrote',
{ 'app/backend': 'wrote-backend', 'app/frontend': 'wrote-frontend' },
`
- from: app/frontend
to: app/backend
type: custom
contract: rotateSigningKey
role: consumer`,
);
fs.writeFileSync(path.join(home, 'registry.json'), '[]', 'utf8');
const r = runGroupIn(home, ['sync', 'wrote']);
expect(r.status).toBe(0);
expect(r.stdout).toContain('Wrote contracts.json (2 contracts, 1 cross-links)');
// The other two sentences are about the same file and contradict this one.
expect(r.stdout).not.toContain('Kept the previous contracts.json');
expect(r.stdout).not.toContain('Did NOT write contracts.json');
expect(fs.existsSync(path.join(groupDir, 'contracts.json'))).toBe(true);
});
it('says the previous contracts.json was KEPT when no repo could be read', () => {
// "Did NOT write contracts.json" was false here: this path REWRITES the
// file, keeping the previous sync's contracts and replacing only the two
// diagnostic lists. Saying otherwise sent an operator looking at an
// unchanged mtime to conclude the sync had not run.
const groupDir = writeGroupYaml(home, 'kept', {
'app/backend': 'kept-backend',
'app/frontend': 'kept-frontend',
});
registerReposWithNoIndex({ 'kept-backend': 'backend', 'kept-frontend': 'frontend' });
const contractsPath = path.join(groupDir, 'contracts.json');
fs.writeFileSync(contractsPath, JSON.stringify(PRIOR_REGISTRY), 'utf8');
const r = runGroupIn(home, ['sync', 'kept']);
expect(r.status).toBe(0);
expect(r.stdout).toContain(
'Kept the previous contracts.json — no repo in this group could be read.',
);
expect(r.stdout).toContain('Its contracts and cross-links are unchanged');
expect(r.stdout).not.toContain('Wrote contracts.json');
expect(r.stdout).not.toContain('Did NOT write contracts.json');
// What makes the sentence true rather than merely present: the file is
// still there, its contracts are the previous run's, and only the
// diagnostic list describes THIS run.
const onDisk = JSON.parse(fs.readFileSync(contractsPath, 'utf8')) as Record<string, unknown>;
expect(onDisk.contracts).toEqual(PRIOR_REGISTRY.contracts);
expect(onDisk.generatedAt).toBe(PRIOR_REGISTRY.generatedAt);
expect(onDisk.unreadableRepos).toEqual(['app/backend', 'app/frontend']);
});
it('says nothing was written when no repo could be read and there is no prior registry', () => {
// Distinct from the branch above on purpose: there is nothing on disk to
// keep, so promising the previous sync's contracts are safe would send an
// operator whose group has never synced looking for a file that has never
// existed.
const groupDir = writeGroupYaml(home, 'nothing', {
'app/backend': 'nothing-backend',
'app/frontend': 'nothing-frontend',
});
registerReposWithNoIndex({ 'nothing-backend': 'backend', 'nothing-frontend': 'frontend' });
const r = runGroupIn(home, ['sync', 'nothing']);
expect(r.status).toBe(0);
expect(r.stdout).toContain(
'Did NOT write contracts.json — no repo in this group could be read,',
);
expect(r.stdout).toContain('there is no previous contracts.json to fall back on');
expect(r.stdout).not.toContain('Wrote contracts.json');
expect(r.stdout).not.toContain('Kept the previous contracts.json');
// And the claim is true of disk: no file was invented to go with it.
expect(fs.existsSync(path.join(groupDir, 'contracts.json'))).toBe(false);
});
});
/**
* `undefined` and `[]` are different answers about the last sync's unreadable
* repos "never recorded" versus the measurement "none" and `group status`
* is where an operator reads them. Printing nothing for both would let an
* unmeasured sync read as evidence that every index opened cleanly, which is
* the fail-open the tri-state exists to close.
*/
describe('group status reports what the last sync recorded as unreadable', () => {
let home: string;
const BASE_REGISTRY = {
version: 1,
generatedAt: '2026-01-01T00:00:00.000Z',
repoSnapshots: {},
missingRepos: [],
contracts: [],
crossLinks: [],
};
const NOT_RECORDED_LINE = 'Last sync unreadable repos: not recorded';
beforeEach(() => {
home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-group-status-unreadable-'));
// An empty registry, so every member reports MISSING and nothing in the
// per-repo table can vary between these three cases.
fs.writeFileSync(path.join(home, 'registry.json'), '[]', 'utf8');
});
afterEach(() => {
fs.rmSync(home, { recursive: true, force: true });
});
const seed = (group: string, registry: Record<string, unknown>): void => {
const groupDir = writeGroupYaml(home, group, {
'app/backend': `${group}-backend`,
'app/frontend': `${group}-frontend`,
});
fs.writeFileSync(path.join(groupDir, 'contracts.json'), JSON.stringify(registry), 'utf8');
};
it('says the field was not recorded when the registry never carried it', () => {
// A contracts.json written before the field existed has no opinion about
// which indexes were readable, and the remedy is to re-run the sync — not
// to conclude that none of them failed.
seed('unrecorded', BASE_REGISTRY);
const r = runGroupIn(home, ['status', 'unrecorded']);
expect(r.status).toBe(0);
expect(r.stdout).toContain(NOT_RECORDED_LINE);
expect(r.stdout).toContain('the registry predates this field, or its value could not be read');
expect(r.stdout).toContain('Re-run `gitnexus group sync` to record it.');
});
it('says nothing at all when the registry recorded an empty list', () => {
// `[]` is a measurement — this sync accounted for every repo — so there is
// no caveat to print and no repo to name. Reporting the "not recorded"
// caveat here would tell an operator to re-run the sync that just
// succeeded.
seed('measured', { ...BASE_REGISTRY, unreadableRepos: [] });
const r = runGroupIn(home, ['status', 'measured']);
expect(r.status).toBe(0);
expect(r.stdout).not.toContain('Last sync unreadable repos');
});
it('names the repos when the registry recorded some', () => {
// Without this, "says nothing at all" above would also be satisfied by a
// command that never printed this line on any registry.
seed('named', { ...BASE_REGISTRY, unreadableRepos: ['app/backend'] });
const r = runGroupIn(home, ['status', 'named']);
expect(r.status).toBe(0);
expect(r.stdout).toContain('Last sync unreadable repos: app/backend');
expect(r.stdout).not.toContain(NOT_RECORDED_LINE);
});
});

View file

@ -0,0 +1,609 @@
/**
* The per-group sync lock (R9): two concurrent syncs of one group cannot lose
* one another's writes, and a sync that cannot be protected does not run.
*
* The exclusion cases contend with a REAL second process (`group-sync-lock-child.mjs`)
* rather than an in-process mock: the default backend's exclusion is a kernel
* socket binding and the file backend's is an O_EXCL create, so nothing observed
* inside one process can prove either.
*
* Nothing here is platform-skipped. The cases that need the FILE backend pin it
* explicitly (`GITNEXUS_INDEX_LOCK_BACKEND=file`) the pin is load-bearing, not
* incidental: on Linux and Windows `selectBackend()` answers `socket`, and the
* socket backend never touches the filesystem, so an unpinned filesystem-failure
* case would measure nothing on two of the three platforms.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { syncGroup } from '../../../src/core/group/sync.js';
import {
GROUP_SYNC_LOCK_TIMEOUT_MS,
GroupSyncLockError,
getGroupSyncLockDir,
withGroupSyncLock,
} from '../../../src/core/group/group-lock.js';
import { GroupService } from '../../../src/core/group/service.js';
import { makeGroupToolPort } from '../../unit/group/fixtures.js';
import type { LockRecord } from '../../../src/storage/index-lock.js';
import { CLI_SPAWN_PREFIX, tsxLoaderUrl } from '../../helpers/cli-entry.js';
import type { GroupConfig, StoredContract } from '../../../src/core/group/types.js';
const testDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(testDir, '../../..');
const childScript = path.resolve(repoRoot, 'test', 'fixtures', 'group-sync-lock-child.mjs');
const groupLockSource = path.resolve(repoRoot, 'src', 'core', 'group', 'group-lock.ts');
const indexLockSpecifier = '../../../src/storage/index-lock.js';
const groupLockSpecifier = '../../../src/core/group/group-lock.js';
const makeConfig = (name: string): GroupConfig => ({
version: 1,
name,
description: '',
repos: {},
links: [],
packages: {},
detect: {
http: true,
grpc: false,
thrift: false,
topics: false,
shared_libs: false,
includes: false,
workspace_deps: false,
embedding_fallback: false,
},
matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 },
});
const parentContract: StoredContract = {
contractId: 'http::GET::/api/parent',
type: 'http',
role: 'provider',
symbolUid: 'uid-parent',
symbolRef: { filePath: 'src/parent.ts', name: 'Parent.get' },
symbolName: 'Parent.get',
confidence: 0.9,
meta: { method: 'GET', path: '/api/parent' },
repo: 'app/parent',
};
/** A persisting sync driven entirely off an extractor override (no repo index). */
const runSync = (groupDir: string) =>
syncGroup(makeConfig(path.basename(groupDir)), {
groupDir,
extractorOverride: async () => [parentContract],
});
const contractsPath = (groupDir: string): string => path.join(groupDir, 'contracts.json');
const readContracts = (groupDir: string): Record<string, unknown> =>
JSON.parse(readFileSync(contractsPath(groupDir), 'utf8')) as Record<string, unknown>;
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
const waitFor = async (predicate: () => boolean, timeoutMs: number): Promise<void> => {
const start = Date.now();
for (;;) {
if (predicate()) return;
if (Date.now() - start > timeoutMs) throw new Error('condition not met within timeout');
await sleep(25);
}
};
const waitForExit = (proc: ChildProcess, timeoutMs: number): Promise<void> =>
new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('child did not exit')), timeoutMs);
proc.once('exit', () => {
clearTimeout(timer);
resolve();
});
});
let home: string;
const children: ChildProcess[] = [];
/** Create `<home>/groups/<name>` with a group.yaml, the way `group create` does. */
const makeGroup = (name: string): string => {
const dir = path.join(home, 'groups', name);
mkdirSync(dir, { recursive: true });
writeFileSync(
path.join(dir, 'group.yaml'),
`version: 1\nname: ${name}\ndescription: ''\nrepos: {}\nlinks: []\n`,
);
return dir;
};
/**
* Spawn the holder. tsx-on-source (not `dist/`) so the child runs the same
* module this process imported the lock's directory and endpoint derivation
* must agree across the two, and a stale build would silently prove nothing.
*/
const spawnHolder = (opts: {
groupDir: string;
marker: string;
holdMs?: number;
contracts?: string;
released?: string;
backend?: string;
}): ChildProcess => {
const child = spawn(process.execPath, ['--import', tsxLoaderUrl(), childScript], {
env: {
...process.env,
GROUP_LOCK_MODULE: pathToFileURL(groupLockSource).href,
GROUP_DIR: opts.groupDir,
MARKER: opts.marker,
HOLD_MS: String(opts.holdMs ?? 0),
...(opts.contracts ? { CONTRACTS: opts.contracts } : {}),
...(opts.released ? { RELEASED: opts.released } : {}),
...(opts.backend ? { GITNEXUS_INDEX_LOCK_BACKEND: opts.backend } : {}),
},
stdio: ['ignore', 'pipe', 'pipe'],
});
children.push(child);
return child;
};
beforeEach(() => {
home = mkdtempSync(path.join(os.tmpdir(), 'gnx-group-lock-'));
});
afterEach(async () => {
for (const c of children) {
if (c.exitCode === null && c.signalCode === null) c.kill('SIGKILL');
}
children.length = 0;
vi.doUnmock(indexLockSpecifier);
vi.resetModules();
delete process.env.GITNEXUS_INDEX_LOCK_BACKEND;
delete process.env.GITNEXUS_INDEX_LOCK_TIMEOUT_MS;
rmSync(home, { recursive: true, force: true });
});
describe('group sync lock — uncontended (regression gate)', () => {
it('a single sync completes and writes contracts.json exactly as before', async () => {
const groupDir = makeGroup('solo');
const result = await runSync(groupDir);
expect(result.registryOutcome).toBe('written');
expect(result.contracts).toHaveLength(1);
expect(existsSync(contractsPath(groupDir))).toBe(true);
expect((readContracts(groupDir).contracts as unknown[]).length).toBe(1);
}, 60_000);
it('holds the lock on <groupDir>/sync-lock, never on the group directory itself', async () => {
// KTD3. `acquireIndexLock`'s file backend writes `analyze.lock` into the
// directory it is handed, so handing it the group directory would drop a
// lock file beside contracts.json and share a namespace with anything else
// that ever locks a group. Pin the file backend: on the socket backend the
// lock leaves no filesystem trace at all, so this would assert nothing.
process.env.GITNEXUS_INDEX_LOCK_BACKEND = 'file';
const groupDir = makeGroup('located');
expect(getGroupSyncLockDir(groupDir)).toBe(path.join(groupDir, 'sync-lock'));
await runSync(groupDir);
expect(existsSync(getGroupSyncLockDir(groupDir))).toBe(true);
expect(existsSync(path.join(groupDir, 'analyze.lock'))).toBe(false);
}, 60_000);
});
describe('group sync lock — cross-process exclusion', () => {
it('makes the second sync wait, so the final registry is the LATER sync, not the first', async () => {
// The child writes its own contracts.json LAST, immediately before releasing.
// Without the lock the parent's sync would finish first and the child's write
// would land on top of it — the lost update this exists to prevent. With the
// lock the parent cannot start persisting until the child is done, so the
// final file is the parent's.
const groupDir = makeGroup('contended');
const marker = path.join(home, 'held.marker');
const released = path.join(home, 'released.marker');
const HOLD_MS = 1500;
spawnHolder({
groupDir,
marker,
holdMs: HOLD_MS,
contracts: contractsPath(groupDir),
released,
});
await waitFor(() => existsSync(marker), 60_000);
const startedAt = Date.now();
const result = await runSync(groupDir);
const finishedAt = Date.now();
expect(result.registryOutcome).toBe('written');
// The holder released before we finished persisting.
const releasedAt = Number(readFileSync(released, 'utf8'));
expect(finishedAt).toBeGreaterThanOrEqual(releasedAt);
// And we genuinely waited rather than racing through: the hold began before
// our clock started, so a lock-free run would have finished near-instantly.
expect(finishedAt - startedAt).toBeGreaterThan(HOLD_MS / 2);
// The surviving registry is ours, not the holder's.
const written = readContracts(groupDir);
expect(written.writtenBy).toBeUndefined();
expect((written.contracts as StoredContract[])[0].contractId).toBe(parentContract.contractId);
}, 120_000);
it('lets a waiting sync proceed once the holder dies', async () => {
const groupDir = makeGroup('bereaved');
const marker = path.join(home, 'held.marker');
const child = spawnHolder({ groupDir, marker }); // holds until killed
await waitFor(() => existsSync(marker), 60_000);
let settled = false;
const pending = runSync(groupDir).finally(() => {
settled = true;
});
await sleep(600);
expect(settled).toBe(false); // blocked on the live holder
child.kill('SIGKILL');
await waitForExit(child, 30_000);
const result = await pending;
expect(result.registryOutcome).toBe('written');
expect(existsSync(contractsPath(groupDir))).toBe(true);
}, 120_000);
it('does not make syncs of two different groups contend', async () => {
// The holder never releases, so if the lock were group-agnostic this sync
// would block until the wait ceiling and the case would fail by timeout.
const held = makeGroup('group-a');
const other = makeGroup('group-b');
const marker = path.join(home, 'held.marker');
const child = spawnHolder({ groupDir: held, marker });
await waitFor(() => existsSync(marker), 60_000);
const result = await runSync(other);
expect(result.registryOutcome).toBe('written');
expect(existsSync(contractsPath(other))).toBe(true);
expect(existsSync(contractsPath(held))).toBe(false);
expect(child.exitCode).toBeNull(); // still holding group-a
}, 120_000);
});
describe('group sync lock — fails closed', () => {
/** Occupy `<groupDir>/sync-lock` with a regular file: the lock directory then
* cannot be created (EEXIST), on every platform, with no permission games. */
const blockLockDir = (groupDir: string): void => {
writeFileSync(getGroupSyncLockDir(groupDir), 'not a directory');
};
it('refuses to sync when the sync-lock directory cannot be created', async () => {
process.env.GITNEXUS_INDEX_LOCK_BACKEND = 'file';
const groupDir = makeGroup('blocked');
blockLockDir(groupDir);
await expect(runSync(groupDir)).rejects.toBeInstanceOf(GroupSyncLockError);
expect(existsSync(contractsPath(groupDir))).toBe(false);
}, 60_000);
it('rejects the lock-free handle a read-only filesystem produces', async () => {
// KTD4.2. `acquireIndexLock` answers EROFS/EACCES/EPERM with a no-op handle
// that is byte-identical in shape to a real one — right for `analyze`, fatal
// here, because the sync would go on to write with nothing protecting it.
// The failure is injected at the one syscall that produces it, so the REAL
// acquire path runs and the REAL no-op handle comes back; a permissions
// fixture would have to be skipped on Windows, where mode bits do not deny
// directory creation, and skipping is what makes this guarantee a fiction.
process.env.GITNEXUS_INDEX_LOCK_BACKEND = 'file';
const groupDir = makeGroup('readonly');
const lockDir = getGroupSyncLockDir(groupDir);
vi.resetModules();
vi.doMock('node:fs', async () => {
const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
const mkdirSync: typeof actual.mkdirSync = ((
p: Parameters<typeof actual.mkdirSync>[0],
o,
) => {
if (String(p) === lockDir) {
const err: NodeJS.ErrnoException = new Error(`EACCES: permission denied, mkdir '${p}'`);
err.code = 'EACCES';
throw err;
}
return actual.mkdirSync(p, o);
}) as typeof actual.mkdirSync;
return { ...actual, mkdirSync, default: { ...actual, mkdirSync } };
});
const fresh = await import(groupLockSpecifier);
let ran = false;
await expect(
fresh.withGroupSyncLock(groupDir, async () => {
ran = true;
}),
).rejects.toMatchObject({ name: 'GroupSyncLockError', reason: 'lock-free' });
expect(ran).toBe(false);
vi.doUnmock('node:fs');
vi.resetModules();
}, 60_000);
it('propagates an acquire timeout instead of running the sync unprotected', async () => {
const groupDir = makeGroup('timed-out');
const holder: LockRecord = {
v: 1,
pid: 4242,
hostname: os.hostname(),
startTime: null,
token: 't',
invocationId: 'other-sync',
acquiredAt: new Date().toISOString(),
};
vi.resetModules();
vi.doMock(indexLockSpecifier, async () => {
const actual =
await vi.importActual<typeof import('../../../src/storage/index-lock.js')>(
indexLockSpecifier,
);
return {
...actual,
acquireIndexLock: async () => {
throw new actual.IndexLockTimeoutError(holder, 600_000);
},
};
});
const fresh = await import(groupLockSpecifier);
let ran = false;
const err = await fresh
.withGroupSyncLock(groupDir, async () => {
ran = true;
})
.then(
() => null,
(e: Error) => e,
);
expect(ran).toBe(false);
expect(err).toMatchObject({ name: 'GroupSyncLockError', reason: 'timeout' });
// The wording is the subject of the suite below; here it only has to be the
// group lock's own message rather than the primitive's raw failure text.
expect((err as Error).message).toContain('sync lock on group "timed-out"');
// The original error is preserved as `cause`, holder metadata intact. Asserted
// structurally, not with `instanceof`: this case drives a freshly re-evaluated
// module graph, whose `IndexLockTimeoutError` is a different class object from
// the statically imported one.
expect((err as { cause?: unknown }).cause).toMatchObject({
name: 'IndexLockTimeoutError',
holder: { invocationId: 'other-sync' },
holderKnown: true,
});
}, 60_000);
it('passes its own wait ceiling, so GITNEXUS_INDEX_LOCK_TIMEOUT_MS cannot make it unbounded', async () => {
// `resolveTimeoutMs` prefers an explicit argument over the env var, whose
// `<= 0` case resolves to POSITIVE_INFINITY — inheriting it would turn this
// lock's fail-closed timeout into a hang.
process.env.GITNEXUS_INDEX_LOCK_TIMEOUT_MS = '0';
const groupDir = makeGroup('ceiling');
const seen: Array<Record<string, unknown>> = [];
vi.resetModules();
vi.doMock(indexLockSpecifier, async () => {
const actual =
await vi.importActual<typeof import('../../../src/storage/index-lock.js')>(
indexLockSpecifier,
);
return {
...actual,
acquireIndexLock: async (_dir: string, o: Record<string, unknown>) => {
seen.push(o);
return { record: {} as LockRecord, release: () => {} };
},
};
});
const fresh = await import(groupLockSpecifier);
await fresh.withGroupSyncLock(groupDir, async () => undefined);
expect(seen).toHaveLength(1);
expect(seen[0].timeoutMs).toBe(GROUP_SYNC_LOCK_TIMEOUT_MS);
expect(Number.isFinite(seen[0].timeoutMs as number)).toBe(true);
}, 60_000);
});
describe('group sync lock — what the timeout says happened', () => {
/**
* Drive `withGroupSyncLock` against an `acquireIndexLock` that waits `waitMs`
* and then times out exactly as the primitive does, and hand back the error
* the wrapper produced.
*
* `reportedMs` is the figure baked into the INHERITED message and is
* deliberately nowhere near the real wait: a wrapper that re-uses the
* primitive's text reports that number, one that times the acquisition itself
* reports `waitMs`. `IndexLockTimeoutError` carries no elapsed field, so the
* two are the only places the figure can come from.
*/
const timedOutAcquire = async (opts: {
groupDir: string;
waitMs: number;
reportedMs: number;
holderKnown: boolean;
}): Promise<Error | null> => {
// `holderKnown: false` mirrors `unknownHolder()` in index-lock.ts: the
// socket backend exposes no owner metadata, so the record is a placeholder
// (`pid -1`) that no message may present as a real holder.
const holder: LockRecord = {
v: 1,
pid: opts.holderKnown ? 4242 : -1,
hostname: os.hostname(),
startTime: null,
token: opts.holderKnown ? 't' : '',
invocationId: opts.holderKnown ? 'other-sync' : '<unreadable>',
acquiredAt: opts.holderKnown ? new Date().toISOString() : '',
};
vi.resetModules();
vi.doMock(indexLockSpecifier, async () => {
const actual =
await vi.importActual<typeof import('../../../src/storage/index-lock.js')>(
indexLockSpecifier,
);
return {
...actual,
acquireIndexLock: async () => {
await sleep(opts.waitMs);
throw new actual.IndexLockTimeoutError(holder, opts.reportedMs, opts.holderKnown);
},
};
});
const fresh = await import(groupLockSpecifier);
return fresh
.withGroupSyncLock(opts.groupDir, async () => undefined)
.then(
() => null,
(e: Error) => e,
);
};
const waitedMsIn = (message: string): number =>
Number(/Timed out after (\d+)ms/.exec(message)?.[1] ?? NaN);
it('names the group, the operation, and the wait it measured itself', async () => {
const groupDir = makeGroup('slow-group');
const err = await timedOutAcquire({
groupDir,
waitMs: 120,
reportedMs: 600_000,
holderKnown: true,
});
expect(err).toMatchObject({ name: 'GroupSyncLockError', reason: 'timeout' });
const msg = String(err?.message);
expect(msg).toContain('sync lock on group "slow-group"');
expect(msg).toContain(getGroupSyncLockDir(groupDir));
expect(msg).toContain('was not synced');
// The elapsed wait is this wrapper's own measurement. The primitive
// announced 600000ms; the acquisition actually took ~120ms, and only a
// wrapper that timed it can say so. Half the sleep is the floor, the way
// the exclusion case above bounds its own wait — a timer cannot fire at
// half its delay on any host.
const waited = waitedMsIn(msg);
expect(Number.isFinite(waited)).toBe(true);
expect(waited).toBeGreaterThanOrEqual(60);
expect(msg).not.toContain('600000');
// The primitive's error is still the cause, so nothing is lost by rewording.
expect((err as { cause?: unknown }).cause).toMatchObject({
name: 'IndexLockTimeoutError',
holder: { invocationId: 'other-sync' },
});
}, 60_000);
it('does not blame an analyze, and does not name a holder the backend cannot identify', async () => {
// The inherited message says "another gitnexus analyze" holds the lock —
// a cause this path cannot establish (nothing but a group sync ever locks
// `<groupDir>/sync-lock`), and on the socket backend it cannot name the
// holder at all: `holderKnown` is false and `holder.pid` is the placeholder
// -1. Fail-closed made both claims user-visible for the first time.
const groupDir = makeGroup('anonymous-holder');
const err = await timedOutAcquire({
groupDir,
waitMs: 0,
reportedMs: 600_000,
holderKnown: false,
});
const msg = String(err?.message);
expect(msg).toContain('sync lock on group "anonymous-holder"');
expect(msg).not.toMatch(/analyze/i);
// No pid is quoted at all — not the placeholder, not any other. Matched on
// the shape the message would use to name one, so a random temp-directory
// segment cannot satisfy it by accident.
expect(msg).not.toMatch(/pid\s+-?\d+/i);
expect(msg).toContain('cannot identify the holder');
}, 60_000);
it('names the holder when the backend does identify one', async () => {
// The other half of the branch: on the file backend the record is real, and
// suppressing it would throw away the one thing that lets an operator find
// the process to wait for.
const groupDir = makeGroup('identified-holder');
const err = await timedOutAcquire({
groupDir,
waitMs: 0,
reportedMs: 600_000,
holderKnown: true,
});
const msg = String(err?.message);
expect(msg).toMatch(/pid 4242/);
expect(msg).toContain(os.hostname());
expect(msg).toContain('other-sync');
expect(msg).not.toMatch(/analyze/i);
expect(msg).not.toContain('cannot identify the holder');
}, 60_000);
it('control: an acquisition that succeeds raises nothing', async () => {
// No mock: the real lock, uncontended. Without this, every assertion above
// could be satisfied by a wrapper that failed on every acquisition.
const groupDir = makeGroup('uncontended-message');
await expect(withGroupSyncLock(groupDir, async () => 'ran')).resolves.toBe('ran');
}, 60_000);
});
describe('group sync lock — how a lock failure surfaces', () => {
it('fails the `group sync` command with the lock message, not a stack trace', () => {
process.env.GITNEXUS_INDEX_LOCK_BACKEND = 'file';
const groupDir = makeGroup('cli-blocked');
writeFileSync(getGroupSyncLockDir(groupDir), 'not a directory');
const run = spawnSync(process.execPath, [...CLI_SPAWN_PREFIX, 'group', 'sync', 'cli-blocked'], {
cwd: repoRoot,
encoding: 'utf8',
timeout: 120_000,
env: { ...process.env, GITNEXUS_HOME: home, GITNEXUS_INDEX_LOCK_BACKEND: 'file' },
});
expect(run.status).not.toBe(0);
// The message goes through pino (`console.error` is an eslint error in this
// package — a forcing function for that migration), so it arrives as a JSON
// envelope rather than raw text. Read the `msg` field: asserting the raw
// substring would pass only by accident of quoting, and would go green
// again if the line were ever downgraded to a bare stderr write.
const logged = run.stderr
.split('\n')
.filter((line) => line.trim().startsWith('{'))
.map((line) => JSON.parse(line) as { level: number; msg: string });
const failure = logged.find((entry) => entry.msg.includes('Did not sync group'));
expect(failure, `no failure log in stderr: ${run.stderr}`).toBeDefined();
expect(failure?.level).toBe(50); // pino error
expect(failure?.msg).toContain('Did not sync group "cli-blocked"');
expect(failure?.msg).toContain('sync lock');
expect(run.stderr).not.toContain('GroupSyncLockError: ');
expect(run.stderr).not.toMatch(/^\s+at /m); // no stack frames
expect(existsSync(contractsPath(groupDir))).toBe(false);
}, 180_000);
it('returns a lock failure through group_sync as an error payload, never an empty success', async () => {
process.env.GITNEXUS_INDEX_LOCK_BACKEND = 'file';
const groupDir = makeGroup('mcp-blocked');
writeFileSync(getGroupSyncLockDir(groupDir), 'not a directory');
process.env.GITNEXUS_HOME = home;
try {
const service = new GroupService(makeGroupToolPort(home));
const payload = (await service.groupSync({ name: 'mcp-blocked' })) as Record<string, unknown>;
expect(typeof payload.error).toBe('string');
expect(String(payload.error)).toContain('sync lock');
// The failure must not masquerade as a clean sync of an empty group.
expect(payload.contracts).toBeUndefined();
expect(payload.registryOutcome).toBeUndefined();
expect(existsSync(contractsPath(groupDir))).toBe(false);
} finally {
delete process.env.GITNEXUS_HOME;
}
}, 120_000);
});

View file

@ -10,13 +10,18 @@ import {
closeBridgeDb,
contractNodeId,
writeBridge,
writeBridgeUnlocked,
bridgeMetaMatchesFile,
openBridgeDbReadOnly,
readBridgeMeta,
bridgeExists,
createContractLookupIndex,
indexContract,
findContractNode,
type WriteBridgeInput,
} from '../../../src/core/group/bridge-db.js';
import { getGroupSyncLockDir, withGroupSyncLock } from '../../../src/core/group/group-lock.js';
import { BRIDGE_SCHEMA_VERSION } from '../../../src/core/group/bridge-schema.js';
import { retryRename } from '../../../src/storage/fs-atomic.js';
import type { BridgeHandle, CrossLink } from '../../../src/core/group/types.js';
import { makeContract } from './fixtures.js';
@ -153,6 +158,75 @@ describe('writeBridge + read', () => {
expect(exists).toBe(true);
});
it("replaces the previous sync's metadata on a successful rebuild", async () => {
// meta.json describes the bridge's completeness, and since #3011 that is
// load-bearing: runGroupImpact folds `unreadableRepos missingRepos` into
// its truncation fields, so a value from an earlier sync is a wrong answer
// about this one.
//
// Scope, stated because the obvious stronger reading is wrong: this covers
// the SUCCESSFUL path only. It cannot pin the removal-before-swap ordering,
// because writeBridge overwrites meta.json at the end either way — the
// assertions below hold with the removal in either position. The ordering
// is pinned in `bridge-meta-swap-window.test.ts`, which fails the swap
// itself and checks the previous sync's metadata cannot survive it.
await writeBridge(tmpDir, {
contracts: [makeContract()],
crossLinks: [],
repoSnapshots: {},
missingRepos: [],
});
const before = await readBridgeMeta(tmpDir);
await writeBridge(tmpDir, {
contracts: [makeContract()],
crossLinks: [],
repoSnapshots: {},
missingRepos: [],
unreadableRepos: ['svc/users'],
});
const after = await readBridgeMeta(tmpDir);
expect(before.unreadableRepos).toBeUndefined();
expect(after.unreadableRepos).toEqual(['svc/users']);
});
it('reports version 0 for a bridge whose meta.json is gone', async () => {
// `version: 0` is the "no provenance" signal `runGroupImpact` fails closed
// on, so it is worth asserting directly rather than only through the
// callers that consume it. No fault is injected into writeBridge here —
// the file is removed afterwards — so this pins readBridgeMeta's contract,
// not the write ordering (see `bridge-meta-swap-window.test.ts` for that).
await writeBridge(tmpDir, {
contracts: [makeContract()],
crossLinks: [],
repoSnapshots: {},
missingRepos: [],
unreadableRepos: ['svc/users'],
});
await fsp.rm(path.join(tmpDir, 'meta.json'), { force: true });
const meta = await readBridgeMeta(tmpDir);
expect(meta.version).toBe(0);
expect(meta.unreadableRepos).toBeUndefined();
});
it('persists an explicitly empty unreadableRepos measurement', async () => {
// Same distinction as the registry: `[]` means the sync accounted for every
// repo, and dropping it collapses that into "never recorded".
await writeBridge(tmpDir, {
contracts: [makeContract()],
crossLinks: [],
repoSnapshots: {},
missingRepos: [],
unreadableRepos: [],
});
const meta = await readBridgeMeta(tmpDir);
expect(meta.unreadableRepos).toEqual([]);
});
it('test_writeBridge_returns_report_with_insert_counts', async () => {
const report = await writeBridge(tmpDir, {
contracts: [makeContract(), makeContract({ repo: 'frontend', role: 'consumer' })],
@ -448,6 +522,226 @@ describe('writeBridge + read', () => {
expect(meta.missingRepos).toEqual([]);
});
});
/* ------------------------------------------------------------------ */
/* The bridge swap runs inside the caller's critical section (R9) */
/* ------------------------------------------------------------------ */
/**
* R9, writer-writer half. The swap and the metadata write are two operations:
* `bridge.lbug` is renamed into place first, and only then is `meta.json`
* written with the size and mtime of the file it describes. Two writers that
* overlap can therefore leave one writer's metadata beside the other's
* database. What prevents it is the group sync lock, held across the whole
* swap.
*
* That is why the swap comes in two halves. `writeBridgeUnlocked` assumes the
* lock is already held and is what `syncGroup` calls from inside its
* `withGroupSyncLock` region; `writeBridge` is the thin acquiring wrapper for
* callers that are not already in that region every caller in this file, and
* every other direct caller in the suite. Routing the held-lock caller through
* the wrapper instead would be a SECOND acquisition of a non-reentrant
* primitive, which does not fail fast: it waits out the ten-minute ceiling
* against a lock its own call stack holds. The first case below is the
* regression gate for exactly that, and it goes red by timeout.
*
* SCOPE, so the block is not read as more than it is: this is writer-writer
* exclusion only. The reader-side promotion of a leftover backup file runs on
* ordinary reads, outside anyone's critical section, and `bridgeMetaMatchesFile`
* remains the reader's defense there.
*
* Nothing here opens `bridge.lbug`. The in-process write-then-read reopen is
* the documented LadybugDB Windows limitation this file skips elsewhere
* (`itLbugReopen`), so every assertion below is made on file state and on
* `bridgeMetaMatchesFile`, which reads `meta.json` and the database's `stat`
* and never opens it. No case in this block is platform-skipped, and the
* surrounding `writeBridge + read` describe is the unchanged control for the
* single-direct-call path.
*/
describe("writeBridge — the swap runs inside the caller's critical section (R9)", () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'bridge-lock-'));
});
afterEach(async () => {
await cleanupTempDir(tmpDir);
});
/** One contract, plus a `missingRepos` marker naming the writer that built it. */
const payload = (writer: string): WriteBridgeInput => ({
contracts: [makeContract({ repo: writer })],
crossLinks: [],
repoSnapshots: {},
missingRepos: [writer],
});
/**
* How long a contended writer is given to finish while the lock is held
* elsewhere. An uncontended write of this payload takes ~50-110ms in this
* suite, so the window is more than fifteen times the work "still not
* finished" is a statement about the lock rather than about how fast the host
* is, and a wrapper that does not acquire finishes inside it on any host.
*/
const CONTENDED_WINDOW_MS = 2000;
/**
* A plain existence check. Deliberately NOT `bridgeExists`, which is a READER
* and promotes a leftover `bridge.lbug.bak` back into place on its way to an
* answer the reader-side path this block makes no claim about, and one that
* would repair the crashed state the last case is trying to hand to the next
* writer.
*/
const onDisk = (name: string): Promise<boolean> =>
fsp.access(path.join(tmpDir, name)).then(
() => true,
() => false,
);
it('a caller that already holds the group lock completes the swap without acquiring a second one', async () => {
// The production shape: `syncGroup` holds the lock across its whole persist
// section and calls the LOCK-FREE half from inside it. `acquireIndexLock` is
// not reentrant, so a swap that acquired for itself would not fail fast — it
// would wait out GROUP_SYNC_LOCK_TIMEOUT_MS (ten minutes) against a lock this
// very call stack is holding. The short per-case timeout is the assertion:
// this case goes red by TIMEOUT the moment the inner half starts acquiring.
const report = await withGroupSyncLock(tmpDir, () =>
writeBridgeUnlocked(tmpDir, payload('held-lock-caller')),
);
expect(report.contractsInserted).toBe(1);
expect(await bridgeExists(tmpDir)).toBe(true);
const meta = await readBridgeMeta(tmpDir);
expect(meta.missingRepos).toEqual(['held-lock-caller']);
expect(await bridgeMetaMatchesFile(tmpDir, meta)).toBe(true);
}, 15_000);
it('a direct write cannot enter the swap while another holder has the group lock', async () => {
// The exclusion itself, observed as ordering rather than as a race: a holder
// takes the group lock, a direct `writeBridge` starts underneath it, and the
// write may not complete until the holder lets go. Nothing is mocked — the
// holder takes the same real lock the wrapper does.
const order: string[] = [];
let markHeld!: () => void;
const lockIsHeld = new Promise<void>((resolve) => {
markHeld = resolve;
});
let releaseHolder!: () => void;
const holderMayRelease = new Promise<void>((resolve) => {
releaseHolder = resolve;
});
const holder = withGroupSyncLock(tmpDir, async () => {
markHeld();
await holderMayRelease;
order.push('holder-released');
});
await lockIsHeld;
let settled = false;
const contender = writeBridge(tmpDir, payload('contender')).then(() => {
order.push('write-finished');
settled = true;
});
await new Promise((resolve) => setTimeout(resolve, CONTENDED_WINDOW_MS));
// The write is still outside the swap. Without the wrapper's acquisition it
// has long since finished, and the ordering assertion below inverts.
expect(settled).toBe(false);
// And it has not written anything either: the swap is what produces both files.
expect(await onDisk('bridge.lbug')).toBe(false);
expect(await onDisk('meta.json')).toBe(false);
releaseHolder();
await holder;
await contender;
expect(order).toEqual(['holder-released', 'write-finished']);
expect((await readBridgeMeta(tmpDir)).missingRepos).toEqual(['contender']);
}, 30_000);
it('after two contended direct writes the metadata on disk vouches for the database on disk', async () => {
// Two writers into one group at once. Serialized, the loser's swap completes
// in full before the winner's begins, so what is left is one writer's
// database under one writer's metadata — never a mixture, and never a stamp
// taken from the other writer's file.
const [first, second] = await Promise.all([
writeBridge(tmpDir, payload('writer-a')),
writeBridge(tmpDir, payload('writer-b')),
]);
expect(first.contractsInserted).toBe(1);
expect(second.contractsInserted).toBe(1);
const meta = await readBridgeMeta(tmpDir);
// Exactly one writer's measurement — not both, not neither.
expect(meta.missingRepos).toHaveLength(1);
expect(['writer-a', 'writer-b']).toContain(meta.missingRepos[0]);
// ...and the stamp it carries describes the database that is actually there.
expect(await bridgeMetaMatchesFile(tmpDir, meta)).toBe(true);
expect(meta.provenanceUnknown).toBeUndefined();
expect(meta.version).toBe(BRIDGE_SCHEMA_VERSION);
// Nothing is left half-swapped: the backup was consumed and neither staging
// directory survives its writer.
const left = await fsp.readdir(tmpDir);
expect(left.filter((f) => f.startsWith('bridge.lbug.bak'))).toEqual([]);
expect(left.filter((f) => f.startsWith('bridge-tmp-'))).toEqual([]);
}, 60_000);
it('the wrapper releases the group lock, so the next writer is not blocked by the last one', async () => {
await writeBridge(tmpDir, payload('first'));
// Sequential, not concurrent. A wrapper that acquired and never released
// would not fail here — it would hang until the ten-minute ceiling, which is
// what the short per-case timeout turns into a red.
await expect(withGroupSyncLock(tmpDir, async () => 'free')).resolves.toBe('free');
const again = await writeBridge(tmpDir, payload('second'));
expect(again.contractsInserted).toBe(1);
const meta = await readBridgeMeta(tmpDir);
expect(meta.missingRepos).toEqual(['second']);
expect(await bridgeMetaMatchesFile(tmpDir, meta)).toBe(true);
}, 30_000);
it('a writer that died mid-swap leaves state the next write recovers from', async () => {
await writeBridge(tmpDir, payload('before-the-crash'));
// Reproduce what a process killed between the two renames leaves behind: the
// live database moved aside to `bridge.lbug.bak`, no `bridge.lbug` at all,
// the staging directory it never cleaned up, and its lock directory still on
// disk. That last one matters on the file backend, where the directory
// outlives the holder; on the socket backend the kernel drops the binding
// when the holder dies and there is nothing on disk to leave. Pre-creating it
// is harmless there and load-bearing here, which is why it is not skipped.
await fsp.rename(path.join(tmpDir, 'bridge.lbug'), path.join(tmpDir, 'bridge.lbug.bak'));
for (const suffix of ['.wal', '.shadow']) {
await fsp
.rename(
path.join(tmpDir, `bridge.lbug${suffix}`),
path.join(tmpDir, `bridge.lbug.bak${suffix}`),
)
.catch(() => {
/* sidecar absent — nothing to move */
});
}
const orphanStaging = path.join(tmpDir, 'bridge-tmp-deadwriter');
await fsp.mkdir(orphanStaging, { recursive: true });
await fsp.writeFile(path.join(orphanStaging, 'bridge.lbug'), 'half-written');
await fsp.mkdir(getGroupSyncLockDir(tmpDir), { recursive: true });
expect(await onDisk('bridge.lbug')).toBe(false);
expect(await onDisk('bridge.lbug.bak')).toBe(true);
const report = await writeBridge(tmpDir, payload('after-the-crash'));
expect(report.contractsInserted).toBe(1);
expect(await onDisk('bridge.lbug')).toBe(true);
const meta = await readBridgeMeta(tmpDir);
expect(meta.missingRepos).toEqual(['after-the-crash']);
expect(await bridgeMetaMatchesFile(tmpDir, meta)).toBe(true);
// The dead writer's backup is consumed by the recovery, not inherited by it.
expect(await onDisk('bridge.lbug.bak')).toBe(false);
}, 60_000);
});
/* ------------------------------------------------------------------ */
/* getCachedBridgeReadOnly cache tests */

View file

@ -0,0 +1,506 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import fsp from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { makeContract } from './fixtures.js';
import { BRIDGE_SCHEMA_VERSION } from '../../../src/core/group/bridge-schema.js';
/**
* `writeBridge` replaces `bridge.lbug` and writes `meta.json` as two separate
* operations, so there is a window between them that an interrupted or failing
* sync stops inside. Which way that window fails is a correctness decision, not
* a detail.
*
* `meta.json` records which repos the sync could not account for, and since
* #3011 `runGroupImpact` folds that into its truncation fields. So a STALE meta
* left beside a NEWLY swapped bridge asserts that the new bridge is as complete
* as the previous sync was a confident wrong answer about the exact thing this
* channel exists to make legible.
*
* Deleting the old meta before the swap would close that, and is wrong. The
* rename of the old database is wrapped in a catch that also swallows a FAILED
* rename a held read-only handle does this on Windows so `writeBridge` can
* throw with the old, perfectly good database still in place. Its metadata would
* then be gone unrecoverably, and cross-repo impact would answer "we cannot say"
* for as long as the swap kept failing. That is a working feature destroyed to
* close a narrow window.
*
* So nothing is deleted. `writeBridge` stamps the database's size and mtime into
* the metadata, and `bridgeMetaMatchesFile` checks the pair still belongs
* together. This file pins both halves: a stale meta is rejected, and a sync
* that fails leaves the previous, matching pair intact.
*/
/**
* `mode` selects which rename fails, and the distinction matters:
*
* - `'all'` models the Windows shape the fix is really about. The old
* database's move to `.bak` is itself wrapped in a catch that swallows
* failures, so a held read-only handle makes that move fail SILENTLY and the
* subsequent `tmp -> bridge.lbug` throw leaving the old database exactly
* where it was, still valid.
* - `'final'` fails only the `tmp -> bridge.lbug` step, so the old database has
* already been moved aside to `.bak` and no database is in place at all.
*/
const renameMock = vi.hoisted(() => ({ mode: 'none' as 'none' | 'all' | 'final' }));
vi.mock('../../../src/storage/fs-atomic.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../src/storage/fs-atomic.js')>();
return {
...actual,
retryRename: async (src: string, dst: string) => {
const fails =
renameMock.mode === 'all' || (renameMock.mode === 'final' && dst.endsWith('bridge.lbug'));
if (fails) throw new Error(`simulated rename failure for ${dst}`);
return actual.retryRename(src, dst);
},
};
});
const { writeBridge, readBridgeMeta, bridgeMetaMatchesFile, closeAllCachedBridges } =
await import('../../../src/core/group/bridge-db.js');
const input = (unreadableRepos?: string[]) => ({
contracts: [makeContract()],
crossLinks: [],
repoSnapshots: {},
missingRepos: [],
...(unreadableRepos ? { unreadableRepos } : {}),
});
describe('writeBridge meta.json swap window', () => {
let groupDir: string;
beforeEach(async () => {
renameMock.mode = 'none';
groupDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'gitnexus-bridge-window-'));
});
afterEach(async () => {
renameMock.mode = 'none';
await fsp.rm(groupDir, { recursive: true, force: true });
});
it('keeps the previous metadata when the database swap fails, and it still matches', async () => {
// The regression this file exists for. An earlier version of the fix deleted
// meta.json before the swap; because the old-database rename is inside a
// catch that swallows failures, `writeBridge` can throw with that database
// still in place — and the metadata describing it already destroyed.
await writeBridge(groupDir, input([]));
const seeded = await readBridgeMeta(groupDir);
// Every rename fails, so the old database never moves: this is the shape a
// held handle produces on Windows.
renameMock.mode = 'all';
await expect(writeBridge(groupDir, input(['svc/users']))).rejects.toThrow('simulated rename');
const after = await readBridgeMeta(groupDir);
// Nothing was lost: the previous sync's measurement survives...
expect(after.version).toBe(seeded.version);
expect(after.generatedAt).toBe(seeded.generatedAt);
expect(after.unreadableRepos).toEqual([]);
// ...and it still describes the database that is actually on disk, so
// cross-repo impact keeps answering from it instead of degrading to a floor
// until some future sync happens to succeed.
await expect(bridgeMetaMatchesFile(groupDir, after)).resolves.toBe(true);
});
it('reports no match when the swap moved the database aside and then failed', async () => {
// The other failure shape: the old database reached `.bak` and the new one
// never arrived, so there is no `bridge.lbug` for the surviving metadata to
// describe. Rejecting is correct here — `ensureBridgeReady` fails loudly on
// the absent database anyway, which is a better answer than a silent floor.
await writeBridge(groupDir, input([]));
const seeded = await readBridgeMeta(groupDir);
renameMock.mode = 'final';
await expect(writeBridge(groupDir, input(['svc/users']))).rejects.toThrow('simulated rename');
const after = await readBridgeMeta(groupDir);
expect(after.generatedAt).toBe(seeded.generatedAt);
await expect(bridgeMetaMatchesFile(groupDir, after)).resolves.toBe(false);
});
it('rejects metadata that describes a different database', async () => {
// The other half: the stale-meta-beside-a-new-bridge window. Simulated by
// replacing the database underneath a metadata file that was written for
// the previous one — which is the state a sync interrupted between the swap
// and the metadata write leaves behind.
await writeBridge(groupDir, input([]));
const stale = await readBridgeMeta(groupDir);
const dbPath = path.join(groupDir, 'bridge.lbug');
const bytes = await fsp.readFile(dbPath);
await fsp.writeFile(dbPath, Buffer.concat([bytes, Buffer.from([0])]));
await expect(bridgeMetaMatchesFile(groupDir, stale)).resolves.toBe(false);
});
it('accepts metadata that carries no stamp but was written after its database', async () => {
// Back-compat: a bridge written before the stamp existed carries no stamp
// to check, and failing those closed would mark every pre-existing bridge
// incomplete — a repo-wide regression traded for a narrow window. It is
// still paired to a database, though: `writeBridge` renames the database in
// and writes the metadata after, so this pair's write order is intact and
// that is what it is judged on.
await writeBridge(groupDir, input([]));
const meta = await readBridgeMeta(groupDir);
const legacy = { ...meta };
delete legacy.bridgeSize;
delete legacy.bridgeMtimeMs;
await expect(bridgeMetaMatchesFile(groupDir, legacy)).resolves.toBe(true);
});
it('rejects a stamp when the database is gone entirely', async () => {
await writeBridge(groupDir, input([]));
const meta = await readBridgeMeta(groupDir);
await fsp.rm(path.join(groupDir, 'bridge.lbug'), { force: true });
await expect(bridgeMetaMatchesFile(groupDir, meta)).resolves.toBe(false);
});
it('records the new metadata when the swap succeeds', async () => {
// The control: removing the old meta early must not cost the happy path
// its metadata, which a fix that only deleted would.
await writeBridge(groupDir, input());
await writeBridge(groupDir, input(['svc/users']));
const after = await readBridgeMeta(groupDir);
expect(after.version).toBeGreaterThan(0);
expect(after.unreadableRepos).toEqual(['svc/users']);
});
});
describe('bridgeMetaMatchesFile with a half-written stamp', () => {
let groupDir: string;
beforeEach(async () => {
renameMock.mode = 'none';
groupDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'gitnexus-bridge-partial-'));
});
afterEach(async () => {
renameMock.mode = 'none';
await fsp.rm(groupDir, { recursive: true, force: true });
});
/**
* A stamp is a PAIR. Either both halves describe the database beside them or
* the metadata cannot vouch for it at all.
*
* The absent-stamp branch exists for metadata written before stamping, which
* is a benign, known state. A metadata file carrying exactly one half is not
* that: something wrote a stamp and did not finish, which is the very
* condition the stamp was added to detect. Accepting it as an `undefined`
* check joined by `||` did hands back "verified" for the one shape that
* most deserves suspicion.
*/
const seedStamped = async (): Promise<void> => {
await writeBridge(groupDir, input([]));
};
const rewriteMeta = async (mutate: (m: Record<string, unknown>) => void): Promise<void> => {
const metaPath = path.join(groupDir, 'meta.json');
const raw = JSON.parse(await fsp.readFile(metaPath, 'utf-8')) as Record<string, unknown>;
mutate(raw);
await fsp.writeFile(metaPath, JSON.stringify(raw, null, 2));
};
it('rejects metadata carrying a size but no mtime', async () => {
await seedStamped();
await rewriteMeta((m) => {
delete m.bridgeMtimeMs;
});
const meta = await readBridgeMeta(groupDir);
expect(meta.bridgeSize).toBeTypeOf('number');
expect(meta.bridgeMtimeMs).toBeUndefined();
await expect(bridgeMetaMatchesFile(groupDir, meta)).resolves.toBe(false);
});
it('rejects metadata carrying an mtime but no size', async () => {
await seedStamped();
await rewriteMeta((m) => {
delete m.bridgeSize;
});
const meta = await readBridgeMeta(groupDir);
expect(meta.bridgeMtimeMs).toBeTypeOf('number');
expect(meta.bridgeSize).toBeUndefined();
await expect(bridgeMetaMatchesFile(groupDir, meta)).resolves.toBe(false);
});
it('still accepts metadata carrying neither half, which is the legacy shape', async () => {
await seedStamped();
await rewriteMeta((m) => {
delete m.bridgeSize;
delete m.bridgeMtimeMs;
});
const meta = await readBridgeMeta(groupDir);
await expect(bridgeMetaMatchesFile(groupDir, meta)).resolves.toBe(true);
});
it('control: a fully stamped pair written together still matches', async () => {
await seedStamped();
const meta = await readBridgeMeta(groupDir);
await expect(bridgeMetaMatchesFile(groupDir, meta)).resolves.toBe(true);
});
});
describe('bridgeMetaMatchesFile pairs an unstamped meta by write order', () => {
/**
* A metadata file with no stamp cannot answer "is this the database I was
* written for?" from its own contents. It is not silent, though: a successful
* `writeBridge` renames the database into place and THEN writes the metadata,
* so `meta.mtime >= db.mtime` holds for every pair written together
* including pairs written by builds that predate stamping, which is the whole
* reason those are not simply failed closed.
*
* A database strictly NEWER than the metadata beside it inverts that order,
* and the only way to reach it is a swap whose metadata write did not land.
*
* This is a heuristic on write order, not proof of provenance, so these cases
* set both timestamps explicitly with `fsp.utimes`. Nothing here sleeps and
* nothing waits for a filesystem to tick: the separation is written, not
* hoped for, so the same verdict comes back on a 1-second-granularity
* filesystem as on a nanosecond one.
*/
let groupDir: string;
/** Fixed, whole-second instants — exactly representable on any filesystem. */
const WRITTEN_AT = new Date('2026-01-01T00:00:00.000Z');
const TEN_SECONDS_LATER = new Date('2026-01-01T00:00:10.000Z');
beforeEach(async () => {
renameMock.mode = 'none';
groupDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'gitnexus-bridge-unstamped-'));
});
afterEach(async () => {
renameMock.mode = 'none';
await closeAllCachedBridges();
await fsp.rm(groupDir, { recursive: true, force: true });
});
/**
* Produce the legacy shape from a real bridge: a database written by
* `writeBridge` with metadata beside it that carries no stamp, exactly as a
* build from before stamping left it.
*/
const seedUnstamped = async (): Promise<void> => {
await writeBridge(groupDir, input([]));
const metaPath = path.join(groupDir, 'meta.json');
const raw = JSON.parse(await fsp.readFile(metaPath, 'utf-8')) as Record<string, unknown>;
delete raw.bridgeSize;
delete raw.bridgeMtimeMs;
await fsp.writeFile(metaPath, JSON.stringify(raw, null, 2));
};
const setMtimes = async (db: Date | null, meta: Date | null): Promise<void> => {
if (db) await fsp.utimes(path.join(groupDir, 'bridge.lbug'), db, db);
if (meta) await fsp.utimes(path.join(groupDir, 'meta.json'), meta, meta);
};
it('accepts an unstamped pair whose two files share a timestamp', async () => {
// The coarse-filesystem case: both writes land in the same tick, so the
// order they happened in is no longer visible. Equality is the pair being
// written together as far as anything can tell, and rejecting it would fail
// every legacy bridge on a 1-second-granularity filesystem.
await seedUnstamped();
await setMtimes(WRITTEN_AT, WRITTEN_AT);
const meta = await readBridgeMeta(groupDir);
expect(meta.bridgeSize).toBeUndefined();
expect(meta.bridgeMtimeMs).toBeUndefined();
await expect(bridgeMetaMatchesFile(groupDir, meta)).resolves.toBe(true);
});
it('accepts an unstamped meta written after the database it sits beside', async () => {
await seedUnstamped();
await setMtimes(WRITTEN_AT, TEN_SECONDS_LATER);
const meta = await readBridgeMeta(groupDir);
await expect(bridgeMetaMatchesFile(groupDir, meta)).resolves.toBe(true);
});
it('rejects an unstamped meta when the database was replaced underneath it', async () => {
// The window this branch exists for. A sync that swapped the database and
// stopped before writing metadata leaves the PREVIOUS sync's completeness
// beside a database it never measured — and `runGroupImpact` spends that as
// fact. With no stamp to check, the inverted write order is the only thing
// that says so, and it says so unambiguously.
await seedUnstamped();
await setMtimes(TEN_SECONDS_LATER, WRITTEN_AT);
const meta = await readBridgeMeta(groupDir);
await expect(bridgeMetaMatchesFile(groupDir, meta)).resolves.toBe(false);
});
it('rejects an unstamped meta when there is no database beside it at all', async () => {
// Metadata describing a file that is not there describes nothing. The
// stamped path already answers `false` here; the unstamped path must not
// answer `true` just because it had no stamp to compare.
await seedUnstamped();
await fsp.rm(path.join(groupDir, 'bridge.lbug'), { force: true });
const meta = await readBridgeMeta(groupDir);
await expect(bridgeMetaMatchesFile(groupDir, meta)).resolves.toBe(false);
});
it('keeps following the stamp when a stamped pair has its file times skewed against it', async () => {
// The ordering guard, acceptance direction. A stamped meta whose FILE is
// older than the database still matches, because the stamp inside it says
// so and the stamp is the stronger evidence. Only the metadata file's time
// is moved — touching the database would invalidate the stamp itself and
// make this measure the wrong thing.
await writeBridge(groupDir, input([]));
const dbStat = await fsp.stat(path.join(groupDir, 'bridge.lbug'));
await setMtimes(null, new Date(dbStat.mtimeMs - 10_000));
const meta = await readBridgeMeta(groupDir);
expect(meta.bridgeSize).toBeTypeOf('number');
await expect(bridgeMetaMatchesFile(groupDir, meta)).resolves.toBe(true);
});
it('keeps following the stamp when a stale stamped meta has the newer file time', async () => {
// The ordering guard, rejection direction. The mtime heuristic must not be
// reachable as a second chance for a stamp that already failed: this pair
// has the write order a paired write produces and a stamp that says the
// database is not the one it describes.
await writeBridge(groupDir, input([]));
const dbPath = path.join(groupDir, 'bridge.lbug');
const bytes = await fsp.readFile(dbPath);
await fsp.writeFile(dbPath, Buffer.concat([bytes, Buffer.from([0])]));
const dbStat = await fsp.stat(dbPath);
await setMtimes(null, new Date(dbStat.mtimeMs + 10_000));
const meta = await readBridgeMeta(groupDir);
await expect(bridgeMetaMatchesFile(groupDir, meta)).resolves.toBe(false);
});
});
describe('bridgeMetaMatchesFile reads an explicit provenance marker first', () => {
/**
* The strongest evidence a metadata file can carry about which database it
* describes is a statement from the writer that it does NOT describe the one
* beside it. `bridgeMetaMatchesFile` orders its checks by evidence strength,
* and this one outranks both of the others its doc has said so since the
* stamp landed; these cases make it true.
*
* The marker exists because the preserve path in `syncGroup` refreshes
* `meta.json` without touching `bridge.lbug`. That rewrite is atomic, so the
* metadata's mtime becomes now while the database's stays old the write
* order a paired write produces, and the shape the unstamped rule ACCEPTS.
* Writing "no stamp" instead of a marker would therefore let a preserve sync
* convert a pair the rule had been rejecting into one it waves through.
*/
let groupDir: string;
beforeEach(async () => {
renameMock.mode = 'none';
groupDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'gitnexus-bridge-marker-'));
});
afterEach(async () => {
renameMock.mode = 'none';
await closeAllCachedBridges();
await fsp.rm(groupDir, { recursive: true, force: true });
});
it('rejects a marked pair whose stamp matches the database beside it', async () => {
await writeBridge(groupDir, input([]));
const meta = await readBridgeMeta(groupDir);
// The control: this exact pair is otherwise verified by the stamp.
await expect(bridgeMetaMatchesFile(groupDir, meta)).resolves.toBe(true);
await expect(
bridgeMetaMatchesFile(groupDir, { ...meta, provenanceUnknown: true }),
).resolves.toBe(false);
});
it('rejects a marked pair whose write order says it is paired', async () => {
// The unstamped branch, and the one the preserve path actually reaches: an
// atomic metadata rewrite always leaves `meta.mtime >= db.mtime`, so the
// heuristic has nothing left to object to and the marker is the only
// surviving record of the verdict.
await writeBridge(groupDir, input([]));
const meta = await readBridgeMeta(groupDir);
const legacy = { ...meta };
delete legacy.bridgeSize;
delete legacy.bridgeMtimeMs;
await expect(bridgeMetaMatchesFile(groupDir, legacy)).resolves.toBe(true);
await expect(
bridgeMetaMatchesFile(groupDir, { ...legacy, provenanceUnknown: true }),
).resolves.toBe(false);
});
it('rejects a marked metadata file even when the database is gone', async () => {
// Nothing about the files can overturn the marker, including the absence of
// the file the stamp branch would have stat'd.
await writeBridge(groupDir, input([]));
const meta = await readBridgeMeta(groupDir);
await fsp.rm(path.join(groupDir, 'bridge.lbug'), { force: true });
await expect(
bridgeMetaMatchesFile(groupDir, { ...meta, provenanceUnknown: true }),
).resolves.toBe(false);
});
});
describe('readBridgeMeta normalizes a version that is not a version', () => {
let groupDir: string;
beforeEach(async () => {
renameMock.mode = 'none';
groupDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'gitnexus-bridge-version-'));
});
afterEach(async () => {
renameMock.mode = 'none';
await fsp.rm(groupDir, { recursive: true, force: true });
});
/**
* `0` is this file's word for "no provenance", and every gate is written
* against it. A parseable but impossible version negative, fractional,
* NaN-adjacent is not a schema version, and if it survives the read it
* splits the gates apart: the two openers compare `> 0 && !== CURRENT` and
* let it through, `bridgeExists` compares `=== 0 || === CURRENT` and says the
* bridge is not there, and the provenance check compares `=== 0` and calls
* the answer complete. Four gates, four verdicts, one file.
*
* Normalizing at the reader is what keeps them agreeing, rather than teaching
* each gate the same new case.
*/
const seedVersion = async (version: unknown): Promise<void> => {
await fsp.writeFile(path.join(groupDir, 'bridge.lbug'), 'db');
await fsp.writeFile(
path.join(groupDir, 'meta.json'),
JSON.stringify({ version, generatedAt: '', missingRepos: [] }),
);
};
it.each([
['negative', -1],
['fractional', 1.5],
// JSON cannot carry Infinity — it serializes to `null`, so this one is
// caught by the pre-existing type check rather than by the range check.
// Kept because it is a shape a hand-edited file can still present.
['infinite', Number.POSITIVE_INFINITY],
])('reads a %s version as no provenance rather than as a schema version', async (_label, v) => {
await seedVersion(v);
const meta = await readBridgeMeta(groupDir);
expect(meta.version).toBe(0);
});
it('control: the current schema version is preserved exactly', async () => {
await seedVersion(BRIDGE_SCHEMA_VERSION);
const meta = await readBridgeMeta(groupDir);
expect(meta.version).toBe(BRIDGE_SCHEMA_VERSION);
});
});

View file

@ -0,0 +1,107 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import fsp from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
/**
* The pairing verdict must be measured BEFORE anything opens `bridge.lbug`.
*
* An unstamped metadata file is paired to its database by write order, so the
* answer depends on `bridge.lbug`'s mtime. Cross-repo impact and trace both open
* that database and only afterwards ask about provenance so on any platform
* or LadybugDB build where a read-only open advances the file's mtime, every
* pre-stamp bridge would report provenance-unknown from its first query onward.
* That is the repo-wide regression the write-order rule was chosen to avoid, and
* it would arrive as a silent downgrade rather than an error.
*
* Whether a given OS does that is not observable everywhere: pinning it by
* really opening the database needs an in-process writeread reopen of the same
* `bridge.lbug`, which is a documented Windows limitation. A test skipped on
* Windows would leave the property unverified on exactly the platform whose
* file semantics are most likely to differ.
*
* So this asserts the ordering instead of the platform's behavior. The open is
* stubbed to advance the database's mtime the hostile case, forced, on every
* platform. If the verdict is taken before the open it is unaffected; if anyone
* moves it after, this goes red on Linux, macOS and Windows alike.
*/
const openSpy = vi.fn();
vi.mock('../../../src/core/group/bridge-db.js', async () => {
const actual = await vi.importActual<typeof import('../../../src/core/group/bridge-db.js')>(
'../../../src/core/group/bridge-db.js',
);
return {
...actual,
getCachedBridgeReadOnly: async (groupDir: string) => {
openSpy();
// Simulate an open that touches the database. Ten seconds ahead of the
// metadata beside it, which under the write-order rule reads as "this
// database is newer than the metadata describing it" — unpaired.
const dbPath = path.join(groupDir, 'bridge.lbug');
const future = new Date(Date.now() + 10_000);
await fsp.utimes(dbPath, future, future);
return { conn: {}, db: {} } as unknown as Awaited<
ReturnType<typeof actual.getCachedBridgeReadOnly>
>;
},
};
});
const { ensureBridgeReady } = await import('../../../src/core/group/cross-impact.js');
const { BRIDGE_SCHEMA_VERSION } = await import('../../../src/core/group/bridge-schema.js');
describe('the bridge pairing verdict is taken before the database is opened', () => {
let groupDir: string;
beforeEach(async () => {
openSpy.mockClear();
groupDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'gitnexus-pair-order-'));
});
afterEach(async () => {
await fsp.rm(groupDir, { recursive: true, force: true });
});
/** A legacy pair: unstamped metadata written after its database, as a real sync leaves it. */
const seedUnstampedPair = async (): Promise<void> => {
const base = new Date(1_700_000_000_000);
await fsp.writeFile(path.join(groupDir, 'bridge.lbug'), 'db');
await fsp.writeFile(
path.join(groupDir, 'meta.json'),
JSON.stringify({
version: BRIDGE_SCHEMA_VERSION,
generatedAt: '',
missingRepos: [],
}),
);
await fsp.utimes(path.join(groupDir, 'bridge.lbug'), base, base);
await fsp.utimes(path.join(groupDir, 'meta.json'), base, base);
};
it('reports an unstamped pair as paired even when the open advances the database mtime', async () => {
await seedUnstampedPair();
const prep = await ensureBridgeReady(groupDir);
expect('error' in prep).toBe(false);
expect(openSpy).toHaveBeenCalledTimes(1);
if ('error' in prep) throw new Error(prep.error);
// Measured before the open, so the open's mtime bump cannot reach it.
expect(prep.meta.pairedWithDatabase).toBe(true);
});
it('still reports a genuinely unpaired legacy bridge as unpaired', async () => {
// The control. If the verdict were hardcoded or dropped, this would pass
// vacuously alongside the case above.
await seedUnstampedPair();
const newer = new Date(1_700_000_060_000);
await fsp.utimes(path.join(groupDir, 'bridge.lbug'), newer, newer);
const prep = await ensureBridgeReady(groupDir);
expect('error' in prep).toBe(false);
if ('error' in prep) throw new Error(prep.error);
expect(prep.meta.pairedWithDatabase).toBe(false);
});
});

View file

@ -114,6 +114,15 @@ describe('group impact fan-out is bounded by a count, not by the clock (#2787)',
...Array.from({ length: REPO_COUNT }, (_, i) => repoKey(i)),
]);
await fsp.writeFile(path.join(groupDir, 'bridge.lbug'), '');
// The metadata this suite's `readBridgeMeta` mock hands back has to exist on
// disk as well as in the mock. It carries no size/mtime stamp, so
// `bridgeMetaMatchesFile` pairs it to the database by write order — and a
// metadata file that is not there cannot be paired to anything. Written
// AFTER `bridge.lbug`, which is the order a real sync produces.
await fsp.writeFile(
path.join(groupDir, 'meta.json'),
JSON.stringify({ version: 1, generatedAt: '', missingRepos: [] }),
);
});
afterEach(async () => {

View file

@ -0,0 +1,449 @@
/**
* A bridge built by a sync that could not account for every configured repo is
* MISSING crossings, not free of them. Those repos' contracts and every
* cross-link touching them never made it into `bridge.lbug`, and nothing in
* the impact walk can notice: the only incompleteness channel on a
* `GroupImpactResult` is `truncationFields(...)`, and that is driven purely by
* fan-out state.
*
* The failure this file pins: `group impact` on a symbol whose one downstream
* consumer lives in an unreadable repo returned `{ cross: [], truncated: false }`
* "complete: nothing depends on this". That is a wrong answer, not an empty
* one, for the tool an agent uses to license a delete or a rename.
*
* `readBridgeMeta` is deliberately NOT stubbed here: the `meta.json` each case
* writes is the input under test, so it has to travel the real read.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fsp from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import type { BridgeHandle, BridgeMeta } from '../../../src/core/group/types.js';
import type { GroupToolPort } from '../../../src/core/group/service.js';
import { BRIDGE_SCHEMA_VERSION } from '../../../src/core/group/bridge-schema.js';
import { makeGroupToolPort, writeGroupYaml } from './fixtures.js';
const bridgeHandle = {
_db: {},
_conn: {},
groupDir: '',
_readOnly: true,
} as BridgeHandle;
const bridgeRows = vi.hoisted(() => ({
value: [] as Array<Record<string, unknown>>,
}));
vi.mock('../../../src/core/group/bridge-db.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../src/core/group/bridge-db.js')>();
return {
...actual,
getCachedBridgeReadOnly: vi.fn(async () => bridgeHandle),
queryBridge: vi.fn(async () => bridgeRows.value),
closeBridgeDb: vi.fn(async () => undefined),
};
});
const { runGroupImpact } = await import('../../../src/core/group/cross-impact.js');
const { writeBridgeMeta, closeBridgeDb } = await import('../../../src/core/group/bridge-db.js');
const UNREADABLE_REPO = 'svc/users';
const MISSING_REPO = 'svc/billing';
/** A crossing the fan-out will try to traverse. */
const crossingRow = {
neighborRepo: 'svc/orders',
neighborUid: 'Function:src/handler.ts:handle',
neighborFilePath: 'src/handler.ts',
matchType: 'exact',
confidence: 1,
contractId: 'custom::c000',
contractType: 'custom',
};
type ImpactShape = {
truncated: boolean;
truncationReason?: string;
riskEpistemic?: string;
truncatedRepos: string[];
cross: unknown[];
};
/** No `?? []` fallback on purpose: an `{ error }` result must blow up here. */
const shapeOf = (result: unknown): ImpactShape => result as ImpactShape;
const sortedRepos = (result: unknown): string[] => [...shapeOf(result).truncatedRepos].sort();
/** A port whose only defect is that one neighbour repo fails to resolve. */
const portWithUnresolvableNeighbour = (home: string, neighbourRepo: string): GroupToolPort =>
makeGroupToolPort(home, {
resolveRepo: vi.fn(async (name: string) => {
if (name === `${neighbourRepo}-registry`) throw new Error('repo not registered');
return { id: name, name, repoPath: name, storagePath: path.join(home, name) };
}) as GroupToolPort['resolveRepo'],
});
describe('group impact over a bridge built from an incomplete sync', () => {
let home: string;
let groupDir: string;
beforeEach(async () => {
home = await fsp.mkdtemp(path.join(os.tmpdir(), 'gitnexus-incomplete-bridge-'));
groupDir = path.join(home, 'groups', 'waveful');
await writeGroupYaml(groupDir, ['backend', 'svc/orders', UNREADABLE_REPO, MISSING_REPO]);
await fsp.writeFile(path.join(groupDir, 'bridge.lbug'), '');
bridgeRows.value = [];
});
afterEach(async () => {
await fsp.rm(home, { recursive: true, force: true });
vi.restoreAllMocks();
});
const writeMeta = (meta: Omit<BridgeMeta, 'version' | 'generatedAt'>): Promise<void> =>
writeBridgeMeta(groupDir, {
version: BRIDGE_SCHEMA_VERSION,
generatedAt: '2026-01-01T00:00:00.000Z',
...meta,
});
/**
* meta.json exactly as given. `writeBridgeMeta` is typed, and the values
* these cases are about are ones `BridgeMeta` forbids which is precisely
* why nothing on the read path was checking for them: a truncated write, a
* hand-edit, or a foreign writer can still leave them on disk.
*/
const writeRawMeta = (fields: Record<string, unknown>): Promise<void> =>
fsp.writeFile(
path.join(groupDir, 'meta.json'),
JSON.stringify({
version: BRIDGE_SCHEMA_VERSION,
generatedAt: '2026-01-01T00:00:00.000Z',
...fields,
}),
);
const run = (port: GroupToolPort, extraParams: Record<string, unknown> = {}) =>
runGroupImpact(
{ port, gitnexusDir: home },
{
name: 'waveful',
repo: 'backend',
target: 'publish',
direction: 'upstream',
...extraParams,
},
);
it('reports a repo the sync could not read as truncation, not as a clean empty result', async () => {
// The headline case. Every other signal here says "complete": the local
// walk finished, the bridge returned no crossings, no cap and no clock
// fired. `unreadableRepos` in meta.json is the ONLY evidence that the
// empty `cross` is a lower bound rather than a verdict.
await writeMeta({ missingRepos: [], unreadableRepos: [UNREADABLE_REPO] });
const result = await run(makeGroupToolPort(home));
expect(result).toMatchObject({
cross: [],
truncated: true,
truncationReason: 'incomplete-sync',
riskEpistemic: 'lower-bound',
});
expect(sortedRepos(result)).toEqual([UNREADABLE_REPO]);
});
it('treats a repo with no registry entry the same way', async () => {
// A MISSING repo is equally absent from the bridge — the sync had nothing
// to extract from it, so its contracts are gone from every query against
// this bridge for exactly the same reason.
await writeMeta({ missingRepos: [MISSING_REPO] });
const result = await run(makeGroupToolPort(home));
expect(result).toMatchObject({
truncated: true,
truncationReason: 'incomplete-sync',
riskEpistemic: 'lower-bound',
});
expect(sortedRepos(result)).toEqual([MISSING_REPO]);
});
it('names each incomplete repo once when a repo is both unreadable and missing', async () => {
// The two lists are independent diagnostics and can overlap. A caller
// reading `truncatedRepos` as "the repos I could not see" must not be
// handed the same one twice.
await writeMeta({ missingRepos: [MISSING_REPO], unreadableRepos: [MISSING_REPO] });
const result = await run(makeGroupToolPort(home));
expect(sortedRepos(result)).toEqual([MISSING_REPO]);
});
it('claims no floor when the bridge is complete and the walk finished', async () => {
// The control that gives the cases above their meaning: a clean bridge and
// a clean walk must still produce a result with NO truncation shape at all,
// or `incomplete-sync` would just be the new name for every answer.
await writeMeta({ missingRepos: [], unreadableRepos: [] });
const result = await run(makeGroupToolPort(home));
expect(result).toMatchObject({ truncated: false, truncatedRepos: [] });
expect(result).not.toHaveProperty('truncationReason');
expect(result).not.toHaveProperty('riskEpistemic');
});
it('reports a bridge with no meta.json at all as a floor, not as complete', async () => {
// `writeBridge` swaps the database file and writes meta.json as two steps,
// so a sync interrupted between them leaves a NEW bridge with NO metadata.
// `readBridgeMeta` answers `version: 0` for that (and for an unparseable
// one), which carries no repo lists — so reading it as "complete" would
// hand back a confident `{ cross: [], truncated: false }` about a bridge
// whose provenance is unknown. That is the fail-open this channel exists
// to close, arriving through the door the write path leaves open.
await fsp.rm(path.join(groupDir, 'meta.json'), { force: true });
const result = await run(makeGroupToolPort(home));
expect(result).toMatchObject({
truncated: true,
truncationReason: 'incomplete-sync',
riskEpistemic: 'lower-bound',
});
});
it('reports an unparseable meta.json as a floor too', async () => {
await fsp.writeFile(path.join(groupDir, 'meta.json'), '{"version": ');
const result = await run(makeGroupToolPort(home));
expect(result).toMatchObject({ truncated: true, truncationReason: 'incomplete-sync' });
});
it('answers a lower bound when the missing-repo list is an object, instead of throwing', async () => {
// `runGroupImpact` spread both repo lists straight into a `new Set([...])`.
// A non-iterable value there is a TypeError thrown out of the whole query —
// an operator asking about their blast radius gets a stack trace instead of
// the honest "this bridge's provenance is unreadable, treat the answer as a
// floor" that the very same metadata already licenses.
await writeRawMeta({ missingRepos: { 'svc/users': true } });
const result = await run(makeGroupToolPort(home));
expect(result).toMatchObject({
truncated: true,
truncationReason: 'incomplete-sync',
riskEpistemic: 'lower-bound',
});
// Nothing was measured, so nothing is named. The reason field carries the
// signal; inventing repo names out of an unreadable value would not.
expect(shapeOf(result).truncatedRepos).toEqual([]);
});
it('answers a lower bound when the unreadable-repo list is a number', async () => {
// The other list, and a non-iterable of a different kind — a scalar reaches
// the same spread. `missingRepos` here IS well formed and measured empty,
// which is what makes this case about the second list alone.
await writeRawMeta({ missingRepos: [], unreadableRepos: 3 });
const result = await run(makeGroupToolPort(home));
expect(result).toMatchObject({
truncated: true,
truncationReason: 'incomplete-sync',
riskEpistemic: 'lower-bound',
});
expect(shapeOf(result).truncatedRepos).toEqual([]);
});
it('does not report the entries of a list that is not a list of repo paths', async () => {
// `Array.isArray` alone would pass this: it is an array, and it is even
// partly right. But `truncatedRepos` is printed by `cli/group.ts` with
// `.join(', ')`, so the object entry surfaces to an operator as
// `[object Object]` — a repo name that does not exist, presented as a
// measurement. A value we cannot read is not a value we half-report.
await writeRawMeta({ missingRepos: [MISSING_REPO, { repo: UNREADABLE_REPO }] });
const result = await run(makeGroupToolPort(home));
expect(result).toMatchObject({
truncated: true,
truncationReason: 'incomplete-sync',
riskEpistemic: 'lower-bound',
});
expect(shapeOf(result).truncatedRepos).toEqual([]);
});
it('releases the bridge handle on a malformed meta.json, and answers the next query normally', async () => {
// The throw happened AFTER the read-only bridge lease was taken and BEFORE
// the `try` whose `finally` releases it, so every malformed-metadata query
// burned a refcount that is never given back — the cached handle can then
// never be closed or invalidated, and `group sync` cannot swap the database
// underneath it on Windows. Releasing is not a detail of the fix: it is why
// a second query on the same group still gets an answer.
vi.mocked(closeBridgeDb).mockClear();
await writeRawMeta({ missingRepos: {} });
await run(makeGroupToolPort(home));
expect(vi.mocked(closeBridgeDb).mock.calls.length).toBe(1);
await writeMeta({ missingRepos: [], unreadableRepos: [] });
const second = await run(makeGroupToolPort(home));
expect(second).toMatchObject({ truncated: false, truncatedRepos: [] });
expect(vi.mocked(closeBridgeDb).mock.calls.length).toBe(2);
});
it('reports a meta.json that is not an object at all as a floor, not as a crash', async () => {
// `JSON.parse('null')` succeeds, so the parse guard never fires and the
// cast hands `null` to a `.version` read. Same class as the two lists: a
// successfully-parsed file whose SHAPE is not metadata.
await fsp.writeFile(path.join(groupDir, 'meta.json'), 'null');
const result = await run(makeGroupToolPort(home));
expect(result).toMatchObject({ truncated: true, truncationReason: 'incomplete-sync' });
});
it('does not read a meta.json written before the field existed as incomplete', async () => {
// Back-compat: `unreadableRepos` is optional, and a bridge written by an
// older build simply does not record it. Absence must not be read as "some
// repo was unreadable" — that would mark every pre-existing bridge as a
// lower bound and make the marker meaningless.
await writeMeta({ missingRepos: [] });
const onDisk: unknown = JSON.parse(
await fsp.readFile(path.join(groupDir, 'meta.json'), 'utf-8'),
);
const result = await run(makeGroupToolPort(home));
expect(onDisk).not.toHaveProperty('unreadableRepos');
expect(result).toMatchObject({ truncated: false, truncatedRepos: [] });
expect(result).not.toHaveProperty('truncationReason');
});
it('keeps reporting timeout when the fan-out clock fired and the bridge is also incomplete', async () => {
// Both causes at once. `timeout` is the retryable one — the same query can
// succeed on the next run — while `incomplete-sync` needs a different
// remedy (`gitnexus group sync`). The caller is told the cause it can act
// on first, and the unreadable repo still shows up in `truncatedRepos`.
// A never-resolving `impactByUid` makes the budget timer the only thing
// that can settle the race, so this branch is taken on every host; nothing
// here measures elapsed time.
await writeMeta({ missingRepos: [], unreadableRepos: [UNREADABLE_REPO] });
bridgeRows.value = [crossingRow];
const port = makeGroupToolPort(home, {
impactByUid: vi.fn(() => new Promise<unknown>(() => {})) as GroupToolPort['impactByUid'],
});
const result = await run(port, { timeoutMs: 200 });
expect(result).toMatchObject({
truncated: true,
truncationReason: 'timeout',
riskEpistemic: 'lower-bound',
});
expect(sortedRepos(result)).toEqual([crossingRow.neighborRepo, UNREADABLE_REPO].sort());
});
it('keeps reporting partial when the fan-out cut a crossing and the bridge is also incomplete', async () => {
// Same precedence rule for the other runtime limit: a crossing that could
// not be traversed (its repo does not resolve) is `partial`, and the
// structural cause does not get to overwrite it.
await writeMeta({ missingRepos: [], unreadableRepos: [UNREADABLE_REPO] });
bridgeRows.value = [crossingRow];
const port = portWithUnresolvableNeighbour(home, crossingRow.neighborRepo);
const result = await run(port);
expect(result).toMatchObject({ truncated: true, truncationReason: 'partial' });
expect(sortedRepos(result)).toEqual([crossingRow.neighborRepo, UNREADABLE_REPO].sort());
});
/**
* The declared scope of a group-impact query is its subgroup prefix (plus
* the repo the walk starts from), and the incomplete-repo set has to be read
* through it. A subgroup-scoped query already drops every neighbour outside
* the prefix, so an unreadable repo it excluded could not have contributed a
* crossing to THIS answer reporting it as a floor anyway marks a complete
* result incomplete, and a marker that fires on answers it does not describe
* is a marker an agent learns to ignore.
*/
describe("narrowed to the query's declared scope", () => {
it('answers complete when the declared subgroup excludes the unreadable repo', async () => {
// The scoped twin of the headline case: same bridge, same metadata, but
// the query asks only about `svc/orders`, and `svc/users` is not in it.
await writeMeta({ missingRepos: [], unreadableRepos: [UNREADABLE_REPO] });
const result = await run(makeGroupToolPort(home), { subgroup: 'svc/orders' });
expect(result).toMatchObject({ truncated: false, truncatedRepos: [] });
expect(result).not.toHaveProperty('truncationReason');
expect(result).not.toHaveProperty('riskEpistemic');
});
it('still answers a lower bound for the same query with no subgroup', async () => {
// The control that keeps the case above honest: drop the scope and the
// very same bridge must go back to reporting the floor. An unscoped query
// declares the whole group, so the intersection is the whole set.
await writeMeta({ missingRepos: [], unreadableRepos: [UNREADABLE_REPO] });
const result = await run(makeGroupToolPort(home));
expect(result).toMatchObject({
truncated: true,
truncationReason: 'incomplete-sync',
riskEpistemic: 'lower-bound',
});
expect(sortedRepos(result)).toEqual([UNREADABLE_REPO]);
});
it('keeps the lower bound when the declared subgroup contains the unreadable repo', async () => {
// `svc` is a prefix of `svc/users`, so the repo IS declared here and the
// answer is still a floor. The filter narrows by membership, not by
// exact equality — a subgroup that spans the unreadable repo gains
// nothing from the scope.
await writeMeta({ missingRepos: [], unreadableRepos: [UNREADABLE_REPO] });
const result = await run(makeGroupToolPort(home), { subgroup: 'svc' });
expect(result).toMatchObject({
truncated: true,
truncationReason: 'incomplete-sync',
riskEpistemic: 'lower-bound',
});
expect(sortedRepos(result)).toEqual([UNREADABLE_REPO]);
});
it('names only the declared repos when some incomplete repos are out of scope', async () => {
// Two incomplete repos, one inside the declared scope and one outside.
// `truncatedRepos` is what an operator reads as "the repos I could not
// see for this question", so naming a repo the question excluded is a
// wrong answer in the same way marking the result incomplete is.
await writeMeta({ missingRepos: [MISSING_REPO], unreadableRepos: [UNREADABLE_REPO] });
const result = await run(makeGroupToolPort(home), { subgroup: UNREADABLE_REPO });
expect(result).toMatchObject({ truncated: true, truncationReason: 'incomplete-sync' });
expect(sortedRepos(result)).toEqual([UNREADABLE_REPO]);
});
it("keeps the lower bound when the unreadable repo is the query's own repo", async () => {
// The walk starts from `backend`'s contracts in the bridge, so when
// `backend` is the repo the sync could not read there are no crossings to
// find at all — for any scope. A subgroup that excludes the origin repo
// must not turn that vacuum into a confident "nothing depends on this".
await writeMeta({ missingRepos: [], unreadableRepos: ['backend'] });
const result = await run(makeGroupToolPort(home), { subgroup: 'svc/orders' });
expect(result).toMatchObject({
truncated: true,
truncationReason: 'incomplete-sync',
riskEpistemic: 'lower-bound',
});
expect(sortedRepos(result)).toEqual(['backend']);
});
});
});

View file

@ -0,0 +1,305 @@
/**
* A cross-repo TRACE reads the same bridge `group impact` reads, and inherits
* the same failure: a bridge built by a sync that could not account for every
* configured repo is MISSING crossings, not free of them. `stitchCrossRepo`
* answered `status: 'not_found'` "no ContractLink connects these endpoints"
* for a bridge that never held the endpoint repo's contracts at all, and the
* only difference between that answer and an authoritative one was prose in
* `notes`.
*
* What this file pins is the MACHINE-readable difference: the same structured
* triple `truncated` / `truncationReason` / `riskEpistemic` that
* `GroupImpactResult` carries, computed for the trace by the SAME helper
* (`crossRepoCompleteness`), so an agent reading either surface learns
* "complete" vs "floor" from one vocabulary instead of from two note strings.
*
* The other half is scope: the incomplete-repo set is filtered by what the
* QUERY declared, not by what the walk happened to touch. A trace between two
* healthy repos is not a lower bound because some third repo in the group was
* unreadable but a DESTINATION trace, which declares no `to` at all, has
* every repo in scope by construction.
*
* `readBridgeMeta` is deliberately NOT stubbed: the `meta.json` each case
* writes is the input under test, so it has to travel the real read. Only the
* bridge DATABASE is mocked, which is what keeps every case here running
* identically on every platform nothing reopens an lbug file.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fsp from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import type { BridgeHandle, BridgeMeta } from '../../../src/core/group/types.js';
import type { GroupSymbolResolution, GroupToolPort } from '../../../src/core/group/service.js';
import { BRIDGE_SCHEMA_VERSION } from '../../../src/core/group/bridge-schema.js';
import { makeGroupToolPort, writeGroupYaml } from './fixtures.js';
const bridgeHandle = {
_db: {},
_conn: {},
groupDir: '',
_readOnly: true,
} as BridgeHandle;
const bridgeRows = vi.hoisted(() => ({
value: [] as Array<Record<string, unknown>>,
}));
vi.mock('../../../src/core/group/bridge-db.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../src/core/group/bridge-db.js')>();
return {
...actual,
getCachedBridgeReadOnly: vi.fn(async () => bridgeHandle),
queryBridge: vi.fn(async () => bridgeRows.value),
closeBridgeDb: vi.fn(async () => undefined),
};
});
const { runGroupTrace } = await import('../../../src/core/group/cross-trace.js');
const { writeBridgeMeta } = await import('../../../src/core/group/bridge-db.js');
const FROM_REPO = 'app/frontend';
const TO_REPO = 'app/backend';
/** A third member, on no traced path — the scope filter's whole subject. */
const OFF_PATH_REPO = 'svc/users';
const FROM_UID = 'fe::callUsers';
const TO_UID = 'be::getUsers';
const okSym = (id: string, name: string, filePath: string): GroupSymbolResolution => ({
kind: 'ok',
symbol: { id, name, type: 'Function', filePath, startLine: 10, endLine: 14 },
});
/** Keyed on `<registryName>:<queried name>` — if-free dispatch, no branching. */
const SYMBOLS: Record<string, GroupSymbolResolution> = {
[`${FROM_REPO}-registry:callUsers`]: okSym(FROM_UID, 'callUsers', 'src/api.ts'),
[`${TO_REPO}-registry:getUsers`]: okSym(TO_UID, 'getUsers', 'src/routes.ts'),
};
const okTrace = (name: string, filePath: string): unknown => ({
status: 'ok',
from: { name, filePath, startLine: 10 },
to: { name, filePath, startLine: 10 },
hopCount: 1,
hops: [{ name, filePath, startLine: 10 }],
edges: [{ relType: 'CALLS', confidence: 1 }],
});
/** Both segments of the one crossing connect — the successful-trace cases. */
const CONNECTING_SEGMENTS: Record<string, unknown> = {
[`${FROM_REPO}-registry:${FROM_UID}->consumer-uid`]: okTrace('callUsers', 'src/api.ts'),
[`${TO_REPO}-registry:provider-uid->${TO_UID}`]: okTrace('getUsers', 'src/routes.ts'),
};
const crossingRow = (contractId: string): Record<string, unknown> => ({
consumerUid: 'consumer-uid',
providerUid: 'provider-uid',
consumerFile: 'src/api.ts',
providerFile: 'src/routes.ts',
providerRepo: TO_REPO,
providerName: 'getUsers',
matchType: 'exact',
confidence: 0.9,
contractId,
contractType: 'http',
});
type TraceShape = {
status: string;
notes: string[];
truncated?: boolean;
truncationReason?: string;
riskEpistemic?: string;
truncatedRepos?: string[];
};
/** No `?? {}` fallback on purpose: an unexpected result must blow up here. */
const shapeOf = (result: unknown): TraceShape => result as TraceShape;
describe('cross-repo trace over a bridge built from an incomplete sync', () => {
let home: string;
let groupDir: string;
beforeEach(async () => {
home = await fsp.mkdtemp(path.join(os.tmpdir(), 'gitnexus-trace-incomplete-'));
groupDir = path.join(home, 'groups', 'waveful');
await writeGroupYaml(groupDir, [FROM_REPO, TO_REPO, OFF_PATH_REPO]);
// Written BEFORE meta.json so the unstamped pair reads as paired by write
// order — otherwise every case here would be "provenance unknown" and the
// scope cases could not be told apart from the control.
await fsp.writeFile(path.join(groupDir, 'bridge.lbug'), '');
bridgeRows.value = [];
});
afterEach(async () => {
await fsp.rm(home, { recursive: true, force: true });
vi.restoreAllMocks();
});
const writeMeta = (meta: Omit<BridgeMeta, 'version' | 'generatedAt'>): Promise<void> =>
writeBridgeMeta(groupDir, {
version: BRIDGE_SCHEMA_VERSION,
generatedAt: '2026-01-01T00:00:00.000Z',
...meta,
});
const soundMeta = (): Promise<void> => writeMeta({ missingRepos: [], unreadableRepos: [] });
const port = (segments: Record<string, unknown> = {}): GroupToolPort =>
makeGroupToolPort(home, {
resolveSymbol: vi.fn(
async (repo, q) =>
SYMBOLS[`${repo.name}:${q.name ?? q.uid ?? ''}`] ?? { kind: 'not_found' },
) as GroupToolPort['resolveSymbol'],
trace: vi.fn(
async (repo, params) =>
segments[`${repo.name}:${params.from_uid}->${params.to_uid}`] ?? { status: 'no_path' },
) as GroupToolPort['trace'],
});
const run = (p: GroupToolPort, extraParams: Record<string, unknown> = {}): Promise<unknown> =>
runGroupTrace(
{ port: p, gitnexusDir: home },
{ name: 'waveful', from: 'callUsers', to: 'getUsers', ...extraParams },
);
it('reports a not_found trace as a lower bound when an endpoint repo was never read', async () => {
// The headline case. Every other signal says "complete": both endpoints
// resolved, the bridge answered, no cap fired. `unreadableRepos` naming the
// `to` repo is the ONLY evidence that "no ContractLink connects these
// endpoints" is a floor — that repo's contracts are absent from this
// bridge, so the link could not have been found even if it exists.
await writeMeta({ missingRepos: [], unreadableRepos: [TO_REPO] });
const result = shapeOf(await run(port()));
expect(result).toMatchObject({
status: 'not_found',
truncated: true,
truncationReason: 'incomplete-sync',
riskEpistemic: 'lower-bound',
truncatedRepos: [TO_REPO],
});
});
it('reports a trace over a bridge with no provenance as a lower bound too', async () => {
// `writeBridge` swaps the database and writes meta.json as two steps, so an
// interrupted sync leaves a NEW bridge with NO metadata. `readBridgeMeta`
// answers `version: 0`, which carries no repo lists at all — so nothing can
// be named, and the reason field is the entire signal.
await fsp.rm(path.join(groupDir, 'meta.json'), { force: true });
const result = shapeOf(await run(port()));
expect(result).toMatchObject({
status: 'not_found',
truncated: true,
truncationReason: 'incomplete-sync',
riskEpistemic: 'lower-bound',
});
// Nothing was measured, so nothing is named — inventing repo names out of
// an unreadable value would not be a measurement.
expect(result).not.toHaveProperty('truncatedRepos');
});
it('marks a SUCCESSFUL trace over a bridge with no provenance', async () => {
// A found path is still an answer from a bridge that may not describe the
// database beside it: other crossings may be missing and this one may be
// stale. The fields ride on `status: 'ok'` for exactly that reason — an
// incompleteness channel that only fires on the empty answer teaches an
// agent that a non-empty answer is always complete.
await fsp.rm(path.join(groupDir, 'meta.json'), { force: true });
bridgeRows.value = [crossingRow('http::GET::/api/users')];
const result = shapeOf(await run(port(CONNECTING_SEGMENTS)));
expect(result).toMatchObject({
status: 'ok',
truncated: true,
truncationReason: 'incomplete-sync',
riskEpistemic: 'lower-bound',
});
});
it('does not mark a trace whose endpoints exclude the unreadable repo', async () => {
// R7: the incomplete set is filtered by the query's DECLARED scope. A
// third member being unreadable says nothing about whether frontend
// reaches backend — marking it would make every answer in a group with one
// sick repo a lower bound, which is how a floor marker stops meaning
// anything.
await writeMeta({ missingRepos: [], unreadableRepos: [OFF_PATH_REPO] });
const result = shapeOf(await run(port()));
expect(result.status).toBe('not_found');
expect(result).not.toHaveProperty('truncated');
expect(result).not.toHaveProperty('truncationReason');
expect(result).not.toHaveProperty('riskEpistemic');
expect(result).not.toHaveProperty('truncatedRepos');
});
it('claims no floor when the bridge is sound', async () => {
// The control that gives the cases above their meaning.
await soundMeta();
const result = shapeOf(await run(port()));
expect(result.status).toBe('not_found');
expect(result).not.toHaveProperty('truncated');
expect(result).not.toHaveProperty('truncationReason');
expect(result).not.toHaveProperty('riskEpistemic');
});
it('distinguishes the two not_found answers without string-matching a note', async () => {
// The verification this unit exists for. Both runs produce the SAME prose;
// the structured field is the only thing that separates "no path exists"
// from "we could not have seen the path".
await writeMeta({ missingRepos: [], unreadableRepos: [TO_REPO] });
const overIncomplete = shapeOf(await run(port()));
await soundMeta();
const overSound = shapeOf(await run(port()));
expect(overIncomplete.notes).toEqual(overSound.notes);
expect(overIncomplete.truncationReason).toBe('incomplete-sync');
expect(overSound.truncationReason).toBeUndefined();
});
it('has every repo in scope for a destination trace, which declares no `to`', async () => {
// A destination trace asks "where does this call land?" — the answer may be
// in ANY member, so no repo can be filtered out of the incomplete set. An
// unreadable provider repo is precisely how "no outgoing ContractLink
// leaves this repo" becomes a wrong answer rather than an empty one.
await writeMeta({ missingRepos: [], unreadableRepos: [OFF_PATH_REPO] });
const result = shapeOf(await run(port(), { to: undefined }));
expect(result).toMatchObject({
status: 'not_found',
truncated: true,
truncationReason: 'incomplete-sync',
riskEpistemic: 'lower-bound',
truncatedRepos: [OFF_PATH_REPO],
});
});
it('keeps reporting the crossing cap when the bridge is also incomplete', async () => {
// Precedence mirrors `runGroupImpact`: the runtime limit is the one the
// caller can act on (narrow the query), while 'incomplete-sync' needs a
// different remedy (`gitnexus group sync`). The unreadable repo is still
// named.
await writeMeta({ missingRepos: [], unreadableRepos: [TO_REPO] });
bridgeRows.value = Array.from({ length: 51 }, (_, i) =>
crossingRow(`http::GET::/api/users/${i}`),
);
const result = shapeOf(await run(port()));
expect(result).toMatchObject({
status: 'not_found',
truncated: true,
truncationReason: 'partial',
riskEpistemic: 'lower-bound',
truncatedRepos: [TO_REPO],
});
});
});

View file

@ -49,6 +49,15 @@ describe('group impact through manifest-only endpoints', () => {
const groupDir = path.join(home, 'groups', 'waveful');
await writeGroupYaml(groupDir, ['backend', 'app']);
await fsp.writeFile(path.join(groupDir, 'bridge.lbug'), '');
// The metadata this suite's `readBridgeMeta` mock hands back has to exist on
// disk as well as in the mock. It carries no size/mtime stamp, so
// `bridgeMetaMatchesFile` pairs it to the database by write order — and a
// metadata file that is not there cannot be paired to anything. Written
// AFTER `bridge.lbug`, which is the order a real sync produces.
await fsp.writeFile(
path.join(groupDir, 'meta.json'),
JSON.stringify({ version: 1, generatedAt: '', missingRepos: [] }),
);
});
afterEach(async () => {

View file

@ -0,0 +1,519 @@
/**
* `ContractRegistry.unreadableRepos` is optional, and its absence means "the
* last sync did not record this", not "the last sync found none unreadable".
* Every registry written before the field existed is in that state.
*
* The failure this file pins: both the registry loader and `groupStatus`
* normalized a missing field to `[]`, so a group whose contracts.json predates
* the diagnostic reported a clean, measured zero an unmeasured state rendered
* as a good result. `[]` and `undefined` are different answers here, and the
* CLI's `group status` prints them differently for exactly that reason.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fsp from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { GroupService, type GroupToolPort } from '../../../src/core/group/service.js';
import { makeGroupToolPort, writeGroupYaml } from './fixtures.js';
/** The fields every case shares; only `unreadableRepos` is under test. */
const REGISTRY_BASE = {
version: 1,
generatedAt: '2026-01-01T00:00:00.000Z',
repoSnapshots: {},
missingRepos: [],
contracts: [],
crossLinks: [],
};
/** One valid row, so "the registry still loads" is observable in the payload. */
const GOOD_CONTRACT = {
contractId: 'http::GET::/api/users',
type: 'http',
repo: 'backend',
role: 'provider',
symbolUid: 'u',
symbolRef: { filePath: 'src/routes.ts', name: 'getUsers' },
symbolName: 'getUsers',
confidence: 1,
meta: {},
};
type StatusPayload = { group: string; unreadableRepos?: unknown; missingRepos?: unknown };
/**
* One row of the per-repo status table. Every field is `unknown` so a wrong
* TYPE fails the assertion rather than being coerced past it `undefined` and
* `false` are different answers about which failure a row means.
*/
type RepoStatusRow = { missing?: unknown; unresolvable?: unknown; unresolvableReason?: unknown };
type RepoStatusPayload = { repos: Record<string, RepoStatusRow> };
/** One valid cross-link, so the control can assert that half of the payload too. */
const GOOD_CROSS_LINK = {
from: { repo: 'frontend', symbolUid: 'f' },
to: { repo: 'backend', symbolUid: 'u' },
contractId: 'http::GET::/api/users',
type: 'http',
matchType: 'exact',
confidence: 1,
};
type ContractsPayload = {
contracts?: unknown[];
crossLinks?: unknown[];
skippedCorrupt?: number;
error?: string;
/** The registry's own diagnostics, echoed onto the listing. */
missingRepos?: unknown;
unreadableRepos?: unknown;
/** The shared incompleteness triple (KTD10) — `unknown` so a wrong TYPE fails. */
truncated?: unknown;
truncationReason?: unknown;
riskEpistemic?: unknown;
};
describe('unreadableRepos survives a round trip through contracts.json', () => {
let home: string;
let groupDir: string;
beforeEach(async () => {
home = await fsp.mkdtemp(path.join(os.tmpdir(), 'gitnexus-registry-unreadable-'));
groupDir = path.join(home, 'groups', 'waveful');
await writeGroupYaml(groupDir, ['backend', 'svc/users']);
vi.stubEnv('GITNEXUS_HOME', home);
});
afterEach(async () => {
vi.unstubAllEnvs();
await fsp.rm(home, { recursive: true, force: true });
vi.restoreAllMocks();
});
/**
* Written as raw JSON, not through `writeContractRegistry`: the point of
* several cases is a file shape the current `ContractRegistry` type cannot
* express a legacy file with the key missing, or a corrupted one.
*/
const writeRegistryJson = (extra: Record<string, unknown>): Promise<void> =>
fsp.writeFile(
path.join(groupDir, 'contracts.json'),
JSON.stringify({ ...REGISTRY_BASE, ...extra }, null, 2),
'utf8',
);
const status = async (): Promise<StatusPayload> => {
const svc = new GroupService(makeGroupToolPort(home));
return (await svc.groupStatus({ name: 'waveful' })) as StatusPayload;
};
const contracts = async (): Promise<ContractsPayload> => {
const svc = new GroupService(makeGroupToolPort(home));
return (await svc.groupContracts({ name: 'waveful' })) as ContractsPayload;
};
it('reports a registry that never recorded the field as not recorded', async () => {
// The whole point: a contracts.json written before this diagnostic existed
// has no opinion about which indexes opened. Reporting `[]` here tells the
// caller the last sync measured zero unreadable repos, which never happened.
await writeRegistryJson({});
const result = await status();
expect(result.unreadableRepos).toBeUndefined();
expect(result.unreadableRepos).not.toEqual([]);
});
it('reports a measured zero as a measured zero', async () => {
// The companion that gives the case above its meaning. A sync that read
// every index DID record an answer, and that answer is an empty list.
await writeRegistryJson({ unreadableRepos: [] });
const result = await status();
expect(result.unreadableRepos).toEqual([]);
});
it('passes a recorded list through intact', async () => {
await writeRegistryJson({ unreadableRepos: ['app/backend'] });
const result = await status();
expect(result.unreadableRepos).toEqual(['app/backend']);
});
const corruptValues: Array<{ label: string; value: unknown }> = [
{ label: 'null', value: null },
{ label: 'a bare string', value: 'app/backend' },
{ label: 'an object', value: { 'app/backend': true } },
// An array of the wrong element type is the shape `Array.isArray` alone
// waves through, and it is the one that reaches `.join(', ')` and renders
// as `[object Object]` — a measurement the operator can read but not act on.
{ label: 'an array of objects', value: [{ repo: 'app/backend' }] },
{ label: 'an array of numbers', value: [1, 2] },
];
it.each(corruptValues)(
'does not launder $label in the unreadableRepos slot into a clean empty list',
async ({ value }) => {
// A hand-edited or half-written registry must not be able to produce the
// one value that means "measured, and everything was fine".
//
// `groupStatus` reads the file through `readContractRegistry`, which is a
// bare `JSON.parse(...) as ContractRegistry` — the validation in
// `loadContractRegistryResilient` never runs on this path — so the shape
// gate lives in `getStatus` itself. It has to: a non-array here used to
// reach `cli/group.ts` and die in `.join(', ')`, which is the command
// whose entire job is explaining an unreadable thing crashing on one.
//
// A value we cannot read is "not recorded", the same as absent.
await writeRegistryJson({ unreadableRepos: value });
const result = await status();
expect(result.group).toBe('waveful');
expect(result.unreadableRepos).toBeUndefined();
expect(result.unreadableRepos).not.toEqual([]);
},
);
it.each(corruptValues)(
'does not hand $label in the missingRepos slot to the CLI either',
async ({ value }) => {
// Same gate, same reason: `cli/group.ts` calls `.join(', ')` on this one
// too. `[]` is the right answer here rather than `undefined` — unlike
// `unreadableRepos`, `missingRepos` has always been required, so there is
// no "not recorded" state to preserve.
await writeRegistryJson({ missingRepos: value });
const result = await status();
expect(result.group).toBe('waveful');
expect(result.missingRepos).toEqual([]);
},
);
it('loads a registry that predates the field without inventing a value for it', async () => {
// `loadContractRegistryResilient` had zero test references when this
// back-compat promise was made, so the legacy shape was resting on a type
// annotation alone. This is the read path an agent hits right after a sync.
await writeRegistryJson({ contracts: [GOOD_CONTRACT] });
const result = await contracts();
expect(result.error).toBeUndefined();
expect(result.contracts).toHaveLength(1);
expect(result.skippedCorrupt).toBeUndefined();
});
it('still salvages good contract rows when the unreadableRepos slot is corrupt', async () => {
// The resilient loader's job is to hand back everything it can parse. A
// junk value in one diagnostic field must not cost the caller the rows
// next to it, and must not throw out of a read-only tool call.
await writeRegistryJson({
unreadableRepos: 'app/backend',
contracts: [{ not: 'a-contract' }, GOOD_CONTRACT],
});
const result = await contracts();
expect(result.error).toBeUndefined();
expect(result.contracts).toHaveLength(1);
expect(result.skippedCorrupt).toBe(1);
});
/**
* `group_contracts` is the third surface that can hand back a partial
* cross-repo answer (KTD10). `group_status` already reports the registry's
* two repo lists; the listing itself reported nothing at all, so an agent
* reading a contract set assembled from a sync that could not open half the
* group could not tell it apart from a complete one.
*
* The answer here is the SAME structured triple `GroupImpactResult` carries
* `truncated` / `truncationReason` / `riskEpistemic` computed by the SAME
* helper (`crossRepoCompleteness`), so the three surfaces cannot drift into
* three vocabularies.
*/
describe('group_contracts reports its completeness in the shared vocabulary', () => {
it('names the unreadable repos and marks the listing a floor', async () => {
await writeRegistryJson({ unreadableRepos: ['app/backend'], contracts: [GOOD_CONTRACT] });
const result = await contracts();
expect(result.unreadableRepos).toEqual(['app/backend']);
expect(result.truncated).toBe(true);
// Not 'partial'/'timeout': nothing was cut short by a runtime limit here.
// The remedy is `gitnexus group sync`, not a narrower query.
expect(result.truncationReason).toBe('incomplete-sync');
expect(result.riskEpistemic).toBe('lower-bound');
// The rows the sync DID read are still returned — a floor, not an error.
expect(result.contracts).toHaveLength(1);
});
it('reports a measured-clean registry as complete', async () => {
// The companion that gives the case above its meaning: a sync that read
// every index recorded an answer, and that answer is an empty list.
await writeRegistryJson({ unreadableRepos: [], contracts: [GOOD_CONTRACT] });
const result = await contracts();
expect(result.unreadableRepos).toEqual([]);
expect(result.truncated).toBe(false);
// The two companions are set WITH `truncated`, never without it.
expect(result.truncationReason).toBeUndefined();
expect(result.riskEpistemic).toBeUndefined();
});
it('omits the key for a registry that predates the field, and reports a floor', async () => {
// Absence is "not recorded", not "none". Inventing `[]` here would tell
// the agent the last sync measured zero unreadable repos — it never ran
// the measurement — and the same conflation would then say "complete".
await writeRegistryJson({ contracts: [GOOD_CONTRACT] });
const result = await contracts();
expect(Object.keys(result)).not.toContain('unreadableRepos');
expect(result.unreadableRepos).toBeUndefined();
expect(result.truncated).toBe(true);
expect(result.truncationReason).toBe('incomplete-sync');
expect(result.riskEpistemic).toBe('lower-bound');
});
it('counts a missing repo as incompleteness even when every index opened', async () => {
// The two lists are independent diagnostics with one consequence: none of
// those repos' contracts are in the artifact. A recorded-clean
// `unreadableRepos` must not launder a missing member into a complete set.
await writeRegistryJson({
unreadableRepos: [],
missingRepos: ['svc/users'],
contracts: [GOOD_CONTRACT],
});
const result = await contracts();
expect(result.missingRepos).toEqual(['svc/users']);
expect(result.unreadableRepos).toEqual([]);
expect(result.truncated).toBe(true);
expect(result.truncationReason).toBe('incomplete-sync');
expect(result.riskEpistemic).toBe('lower-bound');
});
it.each(corruptValues)(
'does not read $label in the unreadableRepos slot as a measured zero',
async ({ value }) => {
// Same gate as `group_status`, on the same registry field: a value we
// could not read is unrecorded, so the listing omits the key and says
// it is a floor rather than reporting a clean measured empty list.
await writeRegistryJson({ unreadableRepos: value, contracts: [GOOD_CONTRACT] });
const result = await contracts();
expect(Object.keys(result)).not.toContain('unreadableRepos');
expect(result.truncated).toBe(true);
expect(result.truncationReason).toBe('incomplete-sync');
},
);
it.each(corruptValues)(
'degrades $label in the missingRepos slot to an empty list',
async ({ value }) => {
// `missingRepos` has always been required, so there is no "not
// recorded" state to preserve — but an unreadable value must not reach
// the caller (or the completeness fold) as if it were a repo list. An
// array of objects is the shape `Array.isArray` alone waves through.
await writeRegistryJson({
missingRepos: value,
unreadableRepos: [],
contracts: [GOOD_CONTRACT],
});
const result = await contracts();
expect(result.missingRepos).toEqual([]);
expect(result.truncated).toBe(false);
},
);
it('keeps the contract and cross-link payload it has always returned', async () => {
// Control. The completeness fields are an ADDITION to this payload; if
// this case moves, the fold broke the surface it was meant to annotate.
await writeRegistryJson({
unreadableRepos: [],
contracts: [GOOD_CONTRACT],
crossLinks: [GOOD_CROSS_LINK],
});
const result = await contracts();
expect(result.error).toBeUndefined();
expect(result.contracts).toEqual([GOOD_CONTRACT]);
expect(result.crossLinks).toEqual([GOOD_CROSS_LINK]);
expect(result.skippedCorrupt).toBeUndefined();
});
});
/**
* The per-repo table had ONE failure label `missing`, printed as "no entry
* in the registry" and every cause collapsed into it, including a global
* registry that could not be read at all. For that cause "no entry" is a
* statement about a file nothing could be read from, and it points at the
* wrong repair: index the repo, when the fix is to repair the registry.
*
* `getStatus` therefore reads the global registry through the STRICT mode.
* The lenient read's `catch { return [] }` turns an unreadable registry into
* an empty one, which is indistinguishable from a genuine absence it can
* only ever produce the `missing` answer, so it cannot express these cases.
*/
describe('group status tells a missing repo apart from an unresolvable one', () => {
/** A registry row carrying every field the strict read demands of one. */
const registryRow = (name: string): Record<string, unknown> => ({
name,
path: path.join(home, name),
storagePath: path.join(home, name, '.gitnexus'),
indexedAt: '2026-01-01T00:00:00.000Z',
lastCommit: 'abc123',
});
/**
* Written verbatim rather than through the registry writer: half these
* cases need a file shape `RegistryEntry[]` cannot express a JSON
* object, a truncated write, a row that names nothing.
*/
const writeGlobalRegistry = (body: string): Promise<void> =>
fsp.writeFile(path.join(home, 'registry.json'), body, 'utf8');
const notFound = (name: string): never => {
throw new Error(`Repository "${name}" not found. Available: `);
};
/**
* Stands in for `LocalBackend.resolveRepo`: a handle for the names given,
* and its own not-found error for the rest. The fixture only means
* anything while it agrees with the registry file the case wrote
* `getStatus` reads that file itself, and the two answers are what these
* cases are about.
*/
const portResolving = (resolvable: string[]): GroupToolPort => {
const handles = new Map(
resolvable.map((name) => [
name,
{
id: name,
name,
repoPath: path.join(home, name),
storagePath: path.join(home, name, '.gitnexus'),
},
]),
);
return makeGroupToolPort(home, {
resolveRepo: vi.fn(async (registryName?: string) => {
const wanted = String(registryName);
return handles.get(wanted) ?? notFound(wanted);
}),
});
};
const statusWith = async (port: GroupToolPort): Promise<RepoStatusPayload> =>
(await new GroupService(port).groupStatus({ name: 'waveful' })) as RepoStatusPayload;
it('renders a repo the readable registry simply lacks as missing', async () => {
// The guard on the other side of the split: the new label must not
// swallow the old one. This repo has no row, that is exactly why
// resolution failed, and "no entry in the registry" is a true statement.
await writeGlobalRegistry(JSON.stringify([registryRow('backend-registry')]));
const result = await statusWith(portResolving(['backend-registry']));
expect(result.repos['svc/users'].missing).toBe(true);
expect(result.repos['svc/users'].unresolvable).toBeFalsy();
expect(result.repos['svc/users'].unresolvableReason).toBeUndefined();
});
it('renders a repo the registry does hold but cannot resolve as unresolvable', async () => {
// The same port failure as the case above, in the same group, with one
// difference: the registry HAS the row. "No entry in the registry" would
// be a false statement about the file the command just read.
await writeGlobalRegistry(
JSON.stringify([registryRow('backend-registry'), registryRow('svc/users-registry')]),
);
const result = await statusWith(portResolving(['backend-registry']));
expect(result.repos['svc/users'].unresolvable).toBe(true);
expect(result.repos['svc/users'].unresolvableReason).toContain('svc/users-registry');
// The pre-split flag keeps its meaning, so a consumer written before the
// split still sees an unusable repo flagged rather than a clean row.
expect(result.repos['svc/users'].missing).toBe(true);
});
it('carries both states in one payload, distinguishably', async () => {
// What an agent reads. Nothing resolves; the registry knows one of the
// two repos and not the other. Two failures, two different answers.
await writeGlobalRegistry(JSON.stringify([registryRow('backend-registry')]));
const result = await statusWith(portResolving([]));
expect(result.repos['backend'].unresolvable).toBe(true);
expect(result.repos['svc/users'].unresolvable).toBe(false);
expect(result.repos['backend'].missing).toBe(true);
expect(result.repos['svc/users'].missing).toBe(true);
});
const unreadableRegistries: Array<{ label: string; body: string }> = [
{ label: 'a JSON object', body: '{"repos": []}' },
{ label: 'a truncated write', body: '[{"name":"backend-registry",' },
{ label: 'not JSON at all', body: 'nope' },
];
it.each(unreadableRegistries)(
'renders every configured repo as unresolvable when the registry is $label',
async ({ body }) => {
// The answer the lenient read cannot give: it collapses this file into
// `[]`, and every repo then reports "no entry in the registry" — a
// measurement of a file nothing could be measured from.
await writeGlobalRegistry(body);
const result = await statusWith(portResolving([]));
expect(result.repos['backend'].unresolvable).toBe(true);
expect(result.repos['svc/users'].unresolvable).toBe(true);
expect(result.repos['backend'].unresolvableReason).toContain('registry');
},
);
it('reports every repo as unresolvable when one row cannot identify a repo', async () => {
// The accepted consequence of the strict read: it rejects the WHOLE
// registry on one unidentifiable row, so `backend` is reported
// unresolvable even though its own row is intact and it still resolves.
// Deliberate — a registry the resolver cannot trust row-wise cannot be
// trusted about any row — and the answer is an unresolved state, never
// the clean `missing: false` row this used to print.
await writeGlobalRegistry(
JSON.stringify([
registryRow('backend-registry'),
{ ...registryRow('svc/users-registry'), name: ' ' },
]),
);
const result = await statusWith(portResolving(['backend-registry', 'svc/users-registry']));
expect(result.repos['backend'].unresolvable).toBe(true);
expect(result.repos['backend'].missing).toBe(true);
expect(result.repos['svc/users'].unresolvable).toBe(true);
});
it('renders neither state for a group whose repos all resolve', async () => {
// Control. Both labels are for failures; a healthy group must show
// neither, or the split is just a new way to raise a false alarm.
await writeGlobalRegistry(
JSON.stringify([registryRow('backend-registry'), registryRow('svc/users-registry')]),
);
const result = await statusWith(portResolving(['backend-registry', 'svc/users-registry']));
expect(result.repos['backend'].missing).toBe(false);
expect(result.repos['backend'].unresolvable).toBeFalsy();
expect(result.repos['svc/users'].missing).toBe(false);
expect(result.repos['svc/users'].unresolvable).toBeFalsy();
});
});
});

View file

@ -0,0 +1,304 @@
/**
* What `group_sync` and `group_contracts` PUT ON THE WIRE.
*
* Both tools document fields an agent is expected to branch on, and both build
* their payload by hand a literal per field, each one a line that can be
* deleted without breaking a type or a build. Nothing asserted either payload,
* so dropping `unreadableRepos` or `registryOutcome` from the sync response, or
* the truncation triple from the contract listing, was a silent change: the
* caller simply stopped being told, and every existing test stayed green.
*
* Hence exact-shape assertions throughout. `toMatchObject` which is what the
* one existing `groupSync` assertion uses, in
* `test/integration/group/group-service-sync-lazy-import.test.ts` passes
* happily on a payload that has lost a key, which is precisely the regression
* this file exists to catch.
*
* The tri-state these cases pin, established by the sibling commits in this PR:
*
* - an ABSENT `unreadableRepos` means the sync never recorded which repos it
* could read, so any answer derived from the artifact is a floor;
* - an EMPTY list is a measurement this sync accounted for every repo;
* - a POPULATED list names the repos whose contracts are not in there.
*
* `groupContracts` therefore OMITS the key in the absent case rather than
* inventing `[]`, and pairs it with `truncated: true` +
* `truncationReason: 'incomplete-sync'` + `riskEpistemic: 'lower-bound'`. An
* exact-shape assertion is the only kind that can see the difference between
* omitting a key and normalizing it to empty.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { SyncResult } from '../../../src/core/group/sync.js';
import type { GroupToolPort, GroupRepoHandle } from '../../../src/core/group/service.js';
import type { CrossLink } from '../../../src/core/group/types.js';
import { makeContract } from './fixtures.js';
/**
* `GroupService.groupSync` reaches `syncGroup` through a dynamic
* `await import('./sync.js')`; vitest resolves that to the same module id as
* the specifier below, so this factory serves it. Mocked because the two
* forwarded fields are what is under test and a real sync cannot be steered to
* an arbitrary `registryOutcome` without an indexed repo the real import is
* pinned separately, and deliberately unmocked, in
* `test/integration/group/group-service-sync-lazy-import.test.ts`.
*/
const syncGroupMock = vi.fn<() => Promise<SyncResult>>();
vi.mock('../../../src/core/group/sync.js', () => ({
syncGroup: (...args: unknown[]) => syncGroupMock(...(args as [])),
}));
const { GroupService } = await import('../../../src/core/group/service.js');
const port: GroupToolPort = {
resolveRepo: vi.fn(
async (name?: string): Promise<GroupRepoHandle> => ({
id: name ?? 'repo',
name: name ?? 'repo',
repoPath: '/tmp/repo',
storagePath: '/tmp/repo/.gitnexus',
}),
),
impact: vi.fn(async () => ({ symbols: [] })),
query: vi.fn(async () => ({ processes: [] })),
impactByUid: vi.fn(async () => null),
context: vi.fn(async () => ({
status: 'found' as const,
symbol: { filePath: 'src/routes.ts', uid: 'uid-1', name: 'getUsers' },
})),
};
const GROUP = 'payload';
/** Every field of a `SyncResult`, overridable one at a time. */
const syncResult = (overrides: Partial<SyncResult> = {}): SyncResult => ({
contracts: [],
crossLinks: [],
unmatched: [],
missingRepos: [],
unreadableRepos: [],
repoSnapshots: {},
registryOutcome: 'written',
...overrides,
});
const CONTRACT = makeContract({ repo: 'app/backend' });
const CROSS_LINK: CrossLink = {
contractId: CONTRACT.contractId,
type: 'http',
matchType: 'exact',
confidence: 1,
from: {
repo: 'app/frontend',
symbolUid: 'uid-2',
symbolRef: { filePath: 'src/client.ts', name: 'callUsers' },
},
to: {
repo: 'app/backend',
symbolUid: 'uid-1',
symbolRef: { filePath: 'src/routes.ts', name: 'getUsers' },
},
};
let home: string;
let groupDir: string;
beforeEach(() => {
syncGroupMock.mockReset();
home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-group-payload-'));
groupDir = path.join(home, 'groups', GROUP);
fs.mkdirSync(groupDir, { recursive: true });
fs.writeFileSync(
path.join(groupDir, 'group.yaml'),
`version: 1
name: ${GROUP}
description: ""
repos:
app/backend: payload-backend
app/frontend: payload-frontend
`,
'utf8',
);
vi.stubEnv('GITNEXUS_HOME', home);
});
afterEach(() => {
vi.unstubAllEnvs();
fs.rmSync(home, { recursive: true, force: true });
});
const seedRegistry = (registry: Record<string, unknown>): void =>
fs.writeFileSync(path.join(groupDir, 'contracts.json'), JSON.stringify(registry), 'utf8');
const BASE_REGISTRY = {
version: 1,
generatedAt: '2026-01-01T00:00:00.000Z',
repoSnapshots: {},
missingRepos: [],
contracts: [CONTRACT],
crossLinks: [CROSS_LINK],
};
describe('group_sync forwards what the sync learned about the repos and the file', () => {
it('carries the unreadable list and the registry outcome, by exact shape', async () => {
// The headline case: a sync that could read nothing and therefore kept the
// previous registry. An agent that calls `group_sync` and then
// `group_contracts` a moment later otherwise sees contract counts that
// disagree with this payload, with nothing here explaining why the write
// was skipped — and no way to tell "the group has no contracts" from "this
// run could not read the repos that hold them".
syncGroupMock.mockResolvedValue(
syncResult({
contracts: [CONTRACT],
crossLinks: [CROSS_LINK],
unmatched: [CONTRACT],
missingRepos: ['app/frontend'],
unreadableRepos: ['app/backend'],
registryOutcome: 'preserved',
}),
);
const payload = await new GroupService(port).groupSync({ name: GROUP });
// `toEqual`, not `toMatchObject`: deleting either forwarded line from the
// return literal leaves a payload that a partial match still accepts.
expect(payload).toEqual({
contracts: 1,
crossLinks: 1,
unmatched: 1,
missingRepos: ['app/frontend'],
unreadableRepos: ['app/backend'],
registryOutcome: 'preserved',
});
});
it('reports an empty unreadable list as the measurement it is', async () => {
// `[]` here is "this sync accounted for every repo", and it has to arrive
// as `[]` rather than as an absent key: on the response boundary the two
// are the difference between a clean result and an unmeasured one.
syncGroupMock.mockResolvedValue(syncResult({ registryOutcome: 'written' }));
const payload = await new GroupService(port).groupSync({ name: GROUP });
expect(payload).toEqual({
contracts: 0,
crossLinks: 0,
unmatched: 0,
missingRepos: [],
unreadableRepos: [],
registryOutcome: 'written',
});
});
it('names each write outcome the sync can reach', async () => {
// `registryOutcome` is a union of four, and the CLI's outcome chain has no
// fallback branch — a value that never reached the wire would fall through
// it silently. Forwarding is verbatim, so this pins that too.
const outcomes: SyncResult['registryOutcome'][] = [
'written',
'preserved',
'no-prior-registry',
'not-attempted',
];
const seen: unknown[] = [];
for (const registryOutcome of outcomes) {
syncGroupMock.mockResolvedValue(syncResult({ registryOutcome }));
const payload = (await new GroupService(port).groupSync({ name: GROUP })) as Record<
string,
unknown
>;
seen.push(payload.registryOutcome);
}
expect(seen).toEqual(outcomes);
});
});
describe('group_contracts forwards its structured incompleteness', () => {
it('omits the unreadable list, and calls the listing a floor, when the sync never recorded one', async () => {
// Provenance unknown. The registry predates the field (or held something
// that was not a list of repo paths), so this listing cannot say which
// repos the sync failed to read — and therefore cannot claim to be
// complete. Inventing `[]` here would report an unmeasured state as a clean
// one, which is the conflation the whole tri-state removes.
seedRegistry(BASE_REGISTRY);
const payload = await new GroupService(port).groupContracts({ name: GROUP });
expect(payload).toEqual({
contracts: [CONTRACT],
crossLinks: [CROSS_LINK],
missingRepos: [],
truncated: true,
truncationReason: 'incomplete-sync',
riskEpistemic: 'lower-bound',
});
// The same claim stated directly, because it is an ABSENCE and absence is
// the one thing a reader of the assertion above has to infer.
expect(payload).not.toHaveProperty('unreadableRepos');
});
it('returns the measured empty list, and calls the listing complete', async () => {
// The middle state, and the only one that may answer `truncated: false`.
seedRegistry({ ...BASE_REGISTRY, unreadableRepos: [] });
const payload = await new GroupService(port).groupContracts({ name: GROUP });
expect(payload).toEqual({
contracts: [CONTRACT],
crossLinks: [CROSS_LINK],
missingRepos: [],
unreadableRepos: [],
truncated: false,
});
// `truncationReason` and `riskEpistemic` ride `truncated: true` and must not
// appear beside a complete answer — an agent that branches on either one
// being present would read this listing as a floor.
expect(payload).not.toHaveProperty('truncationReason');
expect(payload).not.toHaveProperty('riskEpistemic');
});
it('names the repos, and marks the listing a floor, when the sync recorded some', async () => {
// The populated state. `truncated` alone says the answer was cut short;
// `unreadableRepos` is what says WHERE, and it is the field that turns "this
// listing is incomplete" into something an operator can act on.
seedRegistry({ ...BASE_REGISTRY, unreadableRepos: ['app/backend'] });
const payload = await new GroupService(port).groupContracts({ name: GROUP });
expect(payload).toEqual({
contracts: [CONTRACT],
crossLinks: [CROSS_LINK],
missingRepos: [],
unreadableRepos: ['app/backend'],
truncated: true,
truncationReason: 'incomplete-sync',
riskEpistemic: 'lower-bound',
});
});
it('marks the listing a floor for a repo the registry recorded as missing too', async () => {
// The two lists are independent diagnostics with one consequence — none of
// those repos' contracts are in the artifact — so the completeness fold
// reads both. A `truncated` derived from `unreadableRepos` alone would call
// this listing complete while a whole member is unaccounted for.
seedRegistry({ ...BASE_REGISTRY, missingRepos: ['app/frontend'], unreadableRepos: [] });
const payload = await new GroupService(port).groupContracts({ name: GROUP });
expect(payload).toEqual({
contracts: [CONTRACT],
crossLinks: [CROSS_LINK],
missingRepos: ['app/frontend'],
unreadableRepos: [],
truncated: true,
truncationReason: 'incomplete-sync',
riskEpistemic: 'lower-bound',
});
});
});

View file

@ -0,0 +1,587 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { _captureLogger } from '../../../src/core/logger.js';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { fileURLToPath } from 'node:url';
import ts from 'typescript';
import type {
ContractRegistry,
ExtractedContract,
GroupConfig,
GroupManifestLink,
RepoHandle,
} from '../../../src/core/group/types.js';
/**
* Per-repo extraction is all-or-nothing.
*
* `syncGroup` runs each enabled extractor for a repo in sequence and any one of
* them can throw. Appending results to the shared `autoContracts` as they were
* produced meant a repo whose HTTP extractor succeeded and whose gRPC extractor
* then failed contributed a partial set to contracts.json while the catch that
* caught the failure told the operator that repo's "contracts are omitted from
* this sync", and `group sync` printed the same. The persisted registry held an
* undocumented partial view of a repo that the diagnostics described as absent.
*
* Nothing about the earlier extractor's output is wrong in isolation. What makes
* it unusable is that no reader can tell which repos are complete: a contract
* that is silently absent reads exactly like a contract that does not exist.
*/
const PARTIAL_CONTRACT: ExtractedContract = {
contractId: 'http::GET::/api/users',
type: 'http',
role: 'provider',
symbolUid: 'Function:src/users.ts:listUsers',
symbolRef: { filePath: 'src/users.ts', name: 'listUsers' },
symbolName: 'listUsers',
confidence: 1,
meta: {},
};
const httpExtract = vi.fn();
const grpcExtract = vi.fn();
// Bound through an arrow so the test body can read its calls: which repos the
// deferred manifest phase re-opens is the observable side of dropping a failed
// repo's handle, and a `vi.fn()` created inside the factory is unreachable here.
const initLbugMock = vi.fn(async () => {});
vi.mock('../../../src/core/lbug/pool-adapter.js', () => ({
initLbug: (...args: unknown[]) => initLbugMock(...args),
executeParameterized: vi.fn(async () => []),
pinRepo: vi.fn(() => () => {}),
getMaxResidentRepos: vi.fn(() => 5),
}));
vi.mock('../../../src/storage/repo-manager.js', () => ({
readRegistry: vi.fn(async () => []),
readRegistryStrict: vi.fn(async () => []),
}));
vi.mock('../../../src/core/group/extractors/http-route-extractor.js', () => ({
HttpRouteExtractor: class {
extract = (...args: unknown[]) => httpExtract(...args);
},
}));
vi.mock('../../../src/core/group/extractors/grpc-extractor.js', () => ({
GrpcExtractor: class {
extract = (...args: unknown[]) => grpcExtract(...args);
},
}));
const { syncGroup } = await import('../../../src/core/group/sync.js');
const handle: RepoHandle = {
id: 'pool-backend',
path: '/repos/backend',
repoPath: '/repos/backend',
storagePath: '/repos/backend/.gitnexus',
};
const config = (): GroupConfig => ({
version: 1,
name: 'test',
description: '',
repos: { 'app/backend': 'backend-repo' },
links: [],
packages: {},
detect: {
http: true,
grpc: true,
thrift: false,
topics: false,
shared_libs: false,
embedding_fallback: false,
includes: false,
workspace_deps: false,
},
matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 },
});
describe('syncGroup when one extractor fails partway through a repo', () => {
let groupDir: string;
beforeEach(() => {
httpExtract.mockReset();
grpcExtract.mockReset();
groupDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-partial-'));
});
afterEach(() => {
fs.rmSync(groupDir, { recursive: true, force: true });
});
it('keeps none of that repos contracts, matching what the diagnostics say', async () => {
httpExtract.mockResolvedValue([PARTIAL_CONTRACT]);
grpcExtract.mockRejectedValue(new Error('gRPC extraction failed'));
const result = await syncGroup(config(), {
groupDir,
resolveRepoHandle: async () => handle,
});
expect(httpExtract).toHaveBeenCalledTimes(1);
expect(result.unreadableRepos).toEqual(['app/backend']);
// The contract the HTTP extractor produced is discarded with the rest of
// the repo. Anything else contradicts the warning the same run emits.
expect(result.contracts).toEqual([]);
});
it('keeps every contract when all enabled extractors succeed', async () => {
// The control: the all-or-nothing rule must not cost the happy path its
// output, which a guard that simply dropped `repoContracts` would.
httpExtract.mockResolvedValue([PARTIAL_CONTRACT]);
grpcExtract.mockResolvedValue([]);
const result = await syncGroup(config(), {
groupDir,
resolveRepoHandle: async () => handle,
});
expect(result.unreadableRepos).toEqual([]);
expect(result.contracts).toHaveLength(1);
expect(result.contracts[0].contractId).toBe('http::GET::/api/users');
expect(result.contracts[0].repo).toBe('app/backend');
});
});
/**
* The staged contracts must be appended by a BOUNDED construct.
*
* Staging (above) is what made the append dangerous. Before it, each extractor's
* output was appended as it came back, so `autoContracts.push(...)` only ever
* spread one extractor's contracts; staging makes it spread the whole repo's.
* A spread call passes every element as a separate ARGUMENT, and the engine caps
* how many arguments a call can take so a repo that stages enough contracts
* kills the sync with `RangeError: Maximum call stack size exceeded` on the one
* line whose job is to commit the work that just succeeded.
*
* This gate is structural rather than size-based ON PURPOSE. The argument limit
* is a function of the host's available stack: this machine accepts a 125k-element
* spread and dies at 150k, and a larger-stack host sails past both. A "make the
* fixture big enough to crash" test therefore passes against unfixed code on some
* hosts which is precisely the guarantee a regression gate cannot give up. The
* size test below is a completeness/ordering check, not the guard.
*
* Scope: the per-repo extractor `try` block ONLY. `sync.ts` also spreads in the
* windowed manifest loop (`autoContracts.push(...windowResult.contracts)` and its
* cross-link twin). Those predate this change, are bounded by the window size,
* and are not what this gate is about a text scan keyed on `autoContracts.push(...`
* would match them too and fail on code this change never touches. So the region
* is located by AST and by ROLE, not by name: the `const … : StoredContract[] = []`
* staging buffer declared per repo (the function-scoped `let autoContracts` is
* excluded by the `const`), then the one `try` whose block references it. Renaming
* either identifier keeps the gate pointed at the same code.
*
* `.apply(` is rejected alongside the spread: `push.apply(dest, staged)` is the
* same argument-limit hazard wearing different syntax.
*/
const SYNC_SOURCE_PATH = fileURLToPath(new URL('../../../src/core/group/sync.ts', import.meta.url));
/** Every node under `node`, in source order. No branching, so nothing is skippable. */
function descendants(node: ts.Node): ts.Node[] {
const out: ts.Node[] = [];
const visit = (n: ts.Node): void => {
out.push(n);
n.forEachChild(visit);
};
node.forEachChild(visit);
return out;
}
/** `const <name>: StoredContract[] = []` — the per-repo staging buffer. */
function isStagingBufferDeclaration(node: ts.Node): node is ts.VariableDeclaration {
return (
ts.isVariableDeclaration(node) &&
node.type !== undefined &&
ts.isArrayTypeNode(node.type) &&
ts.isTypeReferenceNode(node.type.elementType) &&
ts.isIdentifier(node.type.elementType.typeName) &&
node.type.elementType.typeName.text === 'StoredContract' &&
node.initializer !== undefined &&
ts.isArrayLiteralExpression(node.initializer) &&
node.initializer.elements.length === 0 &&
ts.isVariableDeclarationList(node.parent) &&
(node.parent.flags & ts.NodeFlags.Const) !== 0
);
}
/** `x.apply(dest, args)` — an argument-limited append in non-spread clothing. */
function isApplyCall(call: ts.CallExpression): boolean {
return ts.isPropertyAccessExpression(call.expression) && call.expression.name.text === 'apply';
}
function describeCall(sourceFile: ts.SourceFile, call: ts.CallExpression): string {
const { line } = sourceFile.getLineAndCharacterOfPosition(call.getStart(sourceFile));
return `${line + 1}: ${call.getText(sourceFile).replace(/\s+/g, ' ')}`;
}
describe('the per-repo staging append in sync.ts', () => {
it('appends the staged contracts without spreading them into a call', () => {
const source = fs.readFileSync(SYNC_SOURCE_PATH, 'utf-8');
const sourceFile = ts.createSourceFile(
SYNC_SOURCE_PATH,
source,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS,
);
const allNodes = descendants(sourceFile);
const stagingBuffers = allNodes.filter(isStagingBufferDeclaration);
// One staging buffer, or this gate no longer knows which code it guards.
expect(stagingBuffers.map((d) => d.name.getText(sourceFile))).toHaveLength(1);
const stagingNames = stagingBuffers.map((d) => d.name.getText(sourceFile));
// The block the buffer is declared in — the per-repo loop body.
const declaringBlocks = stagingBuffers
.map((d) => d.parent.parent.parent) // declaration → list → statement → block
.filter(ts.isBlock);
expect(declaringBlocks).toHaveLength(1);
// The extractor try-block: a DIRECT statement of that block whose `try` reads
// the staging buffer. Direct statements only, deliberately — `syncGroup` wraps
// this whole section in its own try/finally (the lease sweep), and that
// ancestor reads the buffer too. Widening to "any try that mentions it" pulls
// in the entire function body, manifest-window spreads and all.
const extractorTryBlocks = declaringBlocks.flatMap((block) =>
block.statements
.filter(ts.isTryStatement)
.filter((statement) =>
descendants(statement.tryBlock).some(
(n) => ts.isIdentifier(n) && stagingNames.includes(n.text),
),
)
.map((statement) => statement.tryBlock),
);
expect(extractorTryBlocks).toHaveLength(1);
const unboundedAppends = extractorTryBlocks.flatMap((block) =>
descendants(block)
.filter(ts.isCallExpression)
.filter((call) => call.arguments.some(ts.isSpreadElement) || isApplyCall(call))
.map((call) => describeCall(sourceFile, call)),
);
// Every staged contract must reach `autoContracts` through a bounded loop:
// the count a repo can stage is then bounded by memory, not by how much
// stack the host happened to give this process.
expect(unboundedAppends).toEqual([]);
});
});
/**
* A repo can stage more contracts than a call is allowed to take as arguments.
* 200_000 is over this host's measured spread ceiling (~125k) and under nothing
* in particular the point is that the count is bounded by memory now, so the
* assertion is that all of them arrive, in the order the extractors produced them.
*/
const LARGE_CONTRACT_COUNT = 200_000;
describe('syncGroup appending a repo that staged a large contract count', () => {
let groupDir: string;
beforeEach(() => {
httpExtract.mockReset();
grpcExtract.mockReset();
groupDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-bulk-'));
});
afterEach(() => {
fs.rmSync(groupDir, { recursive: true, force: true });
});
it('keeps every staged contract, in order', async () => {
const staged: ExtractedContract[] = Array.from({ length: LARGE_CONTRACT_COUNT }, (_, i) => ({
...PARTIAL_CONTRACT,
contractId: `http::GET::/api/item/${i}`,
symbolUid: `Function:src/items.ts:item${i}`,
}));
httpExtract.mockResolvedValue(staged);
grpcExtract.mockResolvedValue([]);
const result = await syncGroup(config(), {
groupDir,
// Nothing here is about persistence; writing a 200k-contract registry and
// bridge would only make the test slow.
skipWrite: true,
resolveRepoHandle: async () => handle,
});
// An argument-limit RangeError lands in the per-repo catch, so an unbounded
// append shows up here as an "unreadable" repo with zero contracts — the
// extraction that actually succeeded, reported as an unreadable index.
expect(result.unreadableRepos).toEqual([]);
expect(result.contracts).toHaveLength(LARGE_CONTRACT_COUNT);
const firstOutOfOrder = result.contracts.findIndex(
(c, i) => c.contractId !== `http::GET::/api/item/${i}`,
);
expect(firstOutOfOrder).toBe(-1);
}, 30_000);
it('appends an ordinary repos contracts in the order the extractors produced them', async () => {
// The control. Ordering across extractors is observable in contracts.json
// and in every consumer of it, so the bounded append has to reproduce the
// sequence the spread produced: HTTP contracts first, then gRPC, each in
// the extractor's own order.
const httpContracts: ExtractedContract[] = ['a', 'b', 'c'].map((suffix) => ({
...PARTIAL_CONTRACT,
contractId: `http::GET::/api/${suffix}`,
}));
const grpcContracts: ExtractedContract[] = ['x', 'y'].map((suffix) => ({
...PARTIAL_CONTRACT,
type: 'grpc',
contractId: `grpc::svc.Service/${suffix}`,
}));
httpExtract.mockResolvedValue(httpContracts);
grpcExtract.mockResolvedValue(grpcContracts);
const result = await syncGroup(config(), {
groupDir,
resolveRepoHandle: async () => handle,
});
expect(result.unreadableRepos).toEqual([]);
expect(result.contracts.map((c) => c.contractId)).toEqual([
'http::GET::/api/a',
'http::GET::/api/b',
'http::GET::/api/c',
'grpc::svc.Service/x',
'grpc::svc.Service/y',
]);
});
});
/**
* A repo the sync reported unreadable contributes NO contracts to the persisted
* registry including through deferred manifest resolution.
*
* Per-repo staging (above) closes the extractor door only. It leaves the
* manifest one open: `repoHandles` kept the failed repo's pool identity, so the
* windowed manifest phase still counted it among the known repos, re-opened it,
* and `ManifestExtractor` emitted a contract for BOTH endpoints of every link
* naming it. contracts.json therefore listed a repo that the very same run's
* `unreadableRepos` said it could not read the contradiction the staging
* change exists to remove, reproduced one phase later.
*
* The narrow part is what must NOT be dropped. `ManifestExtractor` resolves both
* endpoints of a link and emits one contract per endpoint, so dropping the whole
* link would also delete the HEALTHY partner's contract. A link is not the unit
* of ownership; the endpoint is. Hence the filter is by endpoint repo, and the
* all-healthy control below is what pins the healthy partner's output so an
* over-broad "drop the link" fix cannot pass.
*
* Every assertion here reads the WRITTEN contracts.json, not the in-memory
* `SyncResult`: the file is what `group status`, the bridge builder and the next
* sync consume, so an in-memory-only assertion would not describe the artifact
* the requirement is about.
*/
const GRPC_LINK: GroupManifestLink = {
from: 'app/gateway',
to: 'app/backend',
type: 'grpc',
// `role` describes `from`: the gateway CONSUMES what the backend provides, so
// the provider endpoint is the repo whose extractor fails below.
role: 'consumer',
contract: 'orders.Orders/List',
};
const LINK_CONTRACT_ID = 'grpc::orders.Orders/List';
const linkedConfig = (): GroupConfig => ({
...config(),
repos: { 'app/gateway': 'gateway-repo', 'app/backend': 'backend-repo' },
links: [GRPC_LINK],
});
/**
* Resolve handles from a table keyed on the GROUP path, so a two-repo case needs
* no branching in the test body. Distinct `repoPath`s are what let the extractor
* outcome below be keyed per repo.
*/
const LINKED_HANDLES = new Map<string, RepoHandle>([
[
'app/gateway',
{
id: 'pool-gateway',
path: '/repos/gateway',
repoPath: '/repos/gateway',
storagePath: '/repos/gateway/.gitnexus',
},
],
[
'app/backend',
{
id: 'pool-backend',
path: '/repos/backend',
repoPath: '/repos/backend',
storagePath: '/repos/backend/.gitnexus',
},
],
]);
const resolveLinkedHandle = async (
_registryName: string,
groupPath: string,
): Promise<RepoHandle | null> => LINKED_HANDLES.get(groupPath) ?? null;
/**
* `extract(executor, repoPath, handle)` key the outcome on the repo path so
* which repo fails is data, not a branch in a test body. A repo outside the
* failing set extracts cleanly.
*/
const grpcFailingIn =
(failing: ReadonlySet<string>) =>
async (_executor: unknown, repoPath: unknown): Promise<ExtractedContract[]> => {
if (failing.has(String(repoPath))) throw new Error('gRPC extraction failed');
return [];
};
const readPersistedRegistry = (dir: string): ContractRegistry =>
JSON.parse(fs.readFileSync(path.join(dir, 'contracts.json'), 'utf8')) as ContractRegistry;
/** `<repo>|<contractId>|<role>` — the identity a registry reader cares about. */
const contractIdentities = (registry: ContractRegistry): string[] =>
registry.contracts.map((c) => `${c.repo}|${c.contractId}|${c.role}`);
describe('syncGroup persisting a manifest link with an unreadable endpoint', () => {
let groupDir: string;
beforeEach(() => {
httpExtract.mockReset();
grpcExtract.mockReset();
// `mockClear`, not `mockReset` — the resolving implementation is what makes
// `await initLbug(...)` a no-op for every other case in this file.
initLbugMock.mockClear();
// The manifest link is the only contract source in these cases, so the
// per-repo extractors contribute nothing and the registry contains exactly
// what deferred manifest resolution emitted.
httpExtract.mockResolvedValue([]);
groupDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-manifest-'));
});
afterEach(() => {
fs.rmSync(groupDir, { recursive: true, force: true });
});
it('names no contract for the repo the same run reported unreadable', async () => {
grpcExtract.mockImplementation(grpcFailingIn(new Set(['/repos/backend'])));
const result = await syncGroup(linkedConfig(), {
groupDir,
resolveRepoHandle: resolveLinkedHandle,
});
expect(result.unreadableRepos).toEqual(['app/backend']);
expect(result.registryOutcome).toBe('written');
const onDisk = readPersistedRegistry(groupDir);
expect(onDisk.unreadableRepos).toEqual(['app/backend']);
expect(onDisk.contracts.filter((c) => c.repo === 'app/backend')).toEqual([]);
// Not just the `repo` tag: the manifest fallback uid is `manifest::<repo>::…`,
// so a contract can still carry the unreadable repo's name after a filter
// that only looked at one field.
expect(onDisk.contracts.filter((c) => JSON.stringify(c).includes('app/backend'))).toEqual([]);
});
it('keeps the healthy endpoints own contract from that same link', async () => {
grpcExtract.mockImplementation(grpcFailingIn(new Set(['/repos/backend'])));
await syncGroup(linkedConfig(), { groupDir, resolveRepoHandle: resolveLinkedHandle });
// Byte-identical to the healthy endpoint's line in the all-healthy control
// below — that equality IS the requirement: one endpoint failing costs the
// other nothing. A fix that drops the whole link empties this array.
expect(contractIdentities(readPersistedRegistry(groupDir))).toEqual([
`app/gateway|${LINK_CONTRACT_ID}|consumer`,
]);
});
it('emits no cross-link for a pair whose other endpoint failed', async () => {
grpcExtract.mockImplementation(grpcFailingIn(new Set(['/repos/backend'])));
await syncGroup(linkedConfig(), { groupDir, resolveRepoHandle: resolveLinkedHandle });
// A cross-link asserts a relationship between two repos. With one of them
// absent from this sync there is nothing to assert it against, and a
// half-anchored link is exactly the "confident about something it could not
// read" answer the registry must not give.
expect(readPersistedRegistry(groupDir).crossLinks).toEqual([]);
});
it('emits both contracts and the cross-link when both endpoints are healthy', async () => {
// The control. Without it, "drop everything the link touches" passes every
// case above while deleting a healthy repo's contracts.
grpcExtract.mockImplementation(grpcFailingIn(new Set()));
const result = await syncGroup(linkedConfig(), {
groupDir,
resolveRepoHandle: resolveLinkedHandle,
});
expect(result.unreadableRepos).toEqual([]);
expect(result.registryOutcome).toBe('written');
const onDisk = readPersistedRegistry(groupDir);
expect(contractIdentities(onDisk)).toEqual([
`app/backend|${LINK_CONTRACT_ID}|provider`,
`app/gateway|${LINK_CONTRACT_ID}|consumer`,
]);
expect(onDisk.crossLinks).toHaveLength(1);
expect(onDisk.crossLinks[0]).toMatchObject({
from: { repo: 'app/gateway' },
to: { repo: 'app/backend' },
type: 'grpc',
contractId: LINK_CONTRACT_ID,
matchType: 'manifest',
});
});
it('does not re-open the index it just reported unreadable', async () => {
// The other half of the fix, and the one a contract-level assertion cannot
// see: the manifest phase derives its known-repo set from `repoHandles`, so
// a failed repo left in that map is re-initialized and queried a second
// time. Filtering the OUTPUT would still hide the contracts while the sync
// went on reading an index it had already told the operator it could not
// read — and, for a window at its residency cap, spending a slot on it.
grpcExtract.mockImplementation(grpcFailingIn(new Set(['/repos/backend'])));
await syncGroup(linkedConfig(), { groupDir, resolveRepoHandle: resolveLinkedHandle });
const openedPools = initLbugMock.mock.calls.map((call) => String(call[0]));
// The gateway is opened twice: once to extract, once for its manifest
// window. The backend is opened once — the extraction attempt that failed —
// and never again.
expect(openedPools).toEqual(['pool-gateway', 'pool-backend', 'pool-gateway']);
});
it('tells the operator the endpoint was unreadable, not that it is unconfigured', async () => {
// The two diagnoses need different actions: an unconfigured repo means edit
// group.yaml, an unreadable one means re-index. Reusing the "not in
// config.repos" line for a repo that IS configured sends the operator to
// change a file that is already correct — and its "cross-links will use
// synthetic UIDs" tail describes an outcome that no longer happens, since
// this link's cross-link is dropped outright.
grpcExtract.mockImplementation(grpcFailingIn(new Set(['/repos/backend'])));
const cap = _captureLogger();
try {
await syncGroup(linkedConfig(), { groupDir, resolveRepoHandle: resolveLinkedHandle });
} finally {
cap.restore();
}
const linkWarnings = cap
.records()
.filter((r) => r.level === 40)
.map((r) => String(r.msg ?? ''))
.filter((msg) => msg.includes('[group/sync] manifest link'));
expect(linkWarnings).toHaveLength(1);
expect(linkWarnings[0]).toContain('could not read: app/backend');
expect(linkWarnings[0]).not.toContain('not in config.repos');
});
});

File diff suppressed because it is too large Load diff

View file

@ -154,10 +154,11 @@ vi.mock('../../../src/core/lbug/sidecar-recovery.js', () => ({
statIfExists: vi.fn().mockResolvedValue(null),
}));
// readRegistry is called in syncGroup's else branch; resolveRepoHandle is
// The registry read happens in syncGroup's else branch; resolveRepoHandle is
// supplied, so an empty registry is fine (only the meta.json fallback reads it).
vi.mock('../../../src/storage/repo-manager.js', () => ({
readRegistry: vi.fn().mockResolvedValue([]),
readRegistryStrict: vi.fn().mockResolvedValue([]),
}));
const { syncGroup } = await import('../../../src/core/group/sync.js');
@ -214,6 +215,7 @@ describe('syncGroup windowed resolution bounds pool residency (real pool, #2189)
topics: false,
shared_libs: false,
embedding_fallback: false,
includes: false,
workspace_deps: false,
},
matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 },

View file

@ -28,6 +28,7 @@ describe('syncGroup', () => {
topics: false,
shared_libs: false,
embedding_fallback: false,
includes: false,
workspace_deps: false,
},
matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 },
@ -224,6 +225,7 @@ describe('syncGroup', () => {
topics: false,
shared_libs: false,
embedding_fallback: false,
includes: false,
workspace_deps: false,
},
matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 },
@ -678,6 +680,7 @@ service OrderService {
topics: false,
shared_libs: false,
embedding_fallback: false,
includes: false,
workspace_deps: false,
},
matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 },
@ -748,6 +751,7 @@ service OrderService {
topics: false,
shared_libs: false,
embedding_fallback: false,
includes: false,
workspace_deps: workspaceDeps,
},
matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 },
@ -905,6 +909,7 @@ service OrderService {
topics: false,
shared_libs: false,
embedding_fallback: false,
includes: false,
workspace_deps: true,
},
matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 },
@ -996,6 +1001,7 @@ service OrderService {
topics: false,
shared_libs: false,
embedding_fallback: false,
includes: false,
workspace_deps: false,
},
matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 },
@ -1080,6 +1086,7 @@ service OrderService {
topics: false,
shared_libs: false,
embedding_fallback: false,
includes: false,
workspace_deps: false,
},
matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 },
@ -1124,6 +1131,7 @@ describe('syncGroup windowed manifest resolution (issue #2189 / PR #2191 review)
topics: false,
shared_libs: false,
embedding_fallback: false,
includes: false,
workspace_deps: false,
},
matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 },

View file

@ -25,6 +25,8 @@ describe('Group types', () => {
topics: true,
shared_libs: true,
embedding_fallback: true,
includes: true,
workspace_deps: true,
},
matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 },
};
@ -93,6 +95,8 @@ describe('Group types', () => {
topics: true,
shared_libs: true,
embedding_fallback: true,
includes: true,
workspace_deps: true,
},
matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 },
};

View file

@ -0,0 +1,315 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import fs from 'node:fs/promises';
import path from 'node:path';
import { inspect } from 'node:util';
import { readRegistry, readRegistryStrict } from '../../src/storage/repo-manager.js';
import { _captureLogger, type LoggerCapture } from '../../src/core/logger.js';
import { createTempDir } from '../helpers/test-db.js';
import { syncGroup } from '../../src/core/group/sync.js';
import type { GroupConfig } from '../../src/core/group/types.js';
// `syncGroup` is driven here through the REAL registry file and the REAL
// `defaultResolveHandle`, which is the pairing under test; only the pool is
// stubbed, so no LadybugDB index has to exist for a resolved repo to sync.
vi.mock('../../src/core/lbug/pool-adapter.js', () => ({
initLbug: vi.fn(async () => {}),
executeParameterized: vi.fn(async () => []),
pinRepo: vi.fn(() => () => {}),
getMaxResidentRepos: vi.fn(() => 5),
}));
/**
* `readRegistry` used to answer every failure with `[]`.
*
* For a listing that is harmless an unreadable registry and an empty one look
* the same in `gitnexus list`, and both print nothing. For a caller that *acts*
* on emptiness it is not: `syncGroup` derives `missingRepos` from this list, and
* an all-missing sync is allowed to write, so an EACCES after a
* `sudo gitnexus analyze`, a truncated registry.json, or an $HOME-on-NFS blip
* turned "I could not read the registry" into the factual claim "no repo is
* registered" and replaced a good contracts.json with an empty one at exit 0.
*
* That is an unreadable condition reported as missing: the same conflation
* #3011 removes one stack frame further down, which is why `readRegistryStrict`
* exists and why syncGroup is the only caller that uses it. It is a separate
* export rather than an option on `readRegistry` so that every lenient call
* site keeps a provably untouched signature.
*
* ENOENT stays lenient in both modes. No file genuinely means nothing has been
* registered yet, and every first-run path depends on that.
*/
describe('readRegistryStrict', () => {
let tmpHome: Awaited<ReturnType<typeof createTempDir>>;
let savedGitnexusHome: string | undefined;
let registryPath: string;
/** A row every field of which the resolution path can use. */
const resolvableRow = () => ({
name: 'backend-repo',
// Deliberately inside the temp home: `syncGroup` joins `storagePath` with
// `meta.json`, and a stray real file there would make the snapshot
// assertion below depend on the host.
path: path.join(tmpHome.dbPath, 'repos', 'backend'),
storagePath: path.join(tmpHome.dbPath, 'repos', 'backend', '.gitnexus'),
indexedAt: '2026-01-01T00:00:00.000Z',
lastCommit: 'abc123',
});
const makeConfig = (repos: Record<string, string>): GroupConfig => ({
version: 1,
name: 'test',
description: '',
repos,
links: [],
packages: {},
detect: {
http: false,
grpc: false,
thrift: false,
topics: false,
shared_libs: false,
embedding_fallback: false,
includes: false,
workspace_deps: false,
},
matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 },
});
beforeEach(async () => {
tmpHome = await createTempDir('gitnexus-registry-strict-');
savedGitnexusHome = process.env.GITNEXUS_HOME;
process.env.GITNEXUS_HOME = tmpHome.dbPath;
registryPath = path.join(tmpHome.dbPath, 'registry.json');
});
afterEach(async () => {
if (savedGitnexusHome === undefined) delete process.env.GITNEXUS_HOME;
else process.env.GITNEXUS_HOME = savedGitnexusHome;
await tmpHome.cleanup();
});
it('returns [] for a registry that does not exist, strict or not', async () => {
await expect(readRegistry()).resolves.toEqual([]);
await expect(readRegistryStrict()).resolves.toEqual([]);
});
it('reads a valid registry identically in both modes', async () => {
const entries = [
{
name: 'backend-repo',
path: '/repos/backend',
storagePath: '/repos/backend/.gitnexus',
indexedAt: '2026-01-01T00:00:00.000Z',
lastCommit: 'abc123',
},
];
await fs.writeFile(registryPath, JSON.stringify(entries));
await expect(readRegistry()).resolves.toEqual(entries);
await expect(readRegistryStrict()).resolves.toEqual(entries);
});
it('throws on a corrupt registry instead of reporting an empty one', async () => {
await fs.writeFile(registryPath, '{"truncated": ');
// Lenient stays lenient — existing callers keep the contract they have.
await expect(readRegistry()).resolves.toEqual([]);
await expect(readRegistryStrict()).rejects.toThrow();
});
it('throws when a row is missing the fields the resolver needs', async () => {
// `[{}]` is a JSON array, so an array-shape check alone waved it through.
// Every configured repo then failed to resolve and landed in missingRepos;
// because none produced a load ERROR the total-failure guard stayed off,
// and a good contracts.json was replaced with an empty one at exit 0. Same
// fail-open as an unreadable file, one level down.
await fs.writeFile(registryPath, JSON.stringify([{}]));
await expect(readRegistry()).resolves.toEqual([{}]);
await expect(readRegistryStrict()).rejects.toThrow('registry is corrupt');
});
it('throws on a row whose required fields are the wrong type', async () => {
await fs.writeFile(
registryPath,
JSON.stringify([{ name: 'backend-repo', path: 42, storagePath: '/s' }]),
);
await expect(readRegistryStrict()).rejects.toThrow('registry is corrupt');
});
it('rejects the whole registry rather than dropping the bad row', async () => {
// Filtering would report the repos the surviving rows do not name as
// unregistered — the unreadable-as-missing answer this mode exists to
// refuse, reintroduced as a silent partial read.
await fs.writeFile(
registryPath,
JSON.stringify([
{
name: 'good-repo',
path: '/repos/good',
storagePath: '/repos/good/.gitnexus',
indexedAt: '2026-01-01T00:00:00.000Z',
lastCommit: 'abc123',
},
{},
]),
);
await expect(readRegistryStrict()).rejects.toThrow('entry 1');
});
it('accepts a legacy row that omits indexedAt and lastCommit', async () => {
// Those two are defaulted by every caller (`e?.indexedAt || ''`), so
// demanding them would turn a fail-open into a fail-shut on real data.
const legacy = [
{ name: 'backend-repo', path: '/repos/backend', storagePath: '/repos/backend/.gitnexus' },
];
await fs.writeFile(registryPath, JSON.stringify(legacy));
await expect(readRegistryStrict()).resolves.toEqual(legacy);
});
it('rejects a row whose `name` is blank, and names the offending index', async () => {
// `typeof e.name === 'string'` is true of `''`, so a blank name walked
// straight past the shape check and then failed to match ANY configured
// repo in `defaultResolveHandle` — every repo landed in missingRepos, no
// load ERROR was produced, the total-failure guard stayed off, and a good
// contracts.json was replaced by an empty one. A field the resolution path
// matches on cannot be blank and still identify a repo.
const rows = [resolvableRow(), { ...resolvableRow(), name: '' }];
await fs.writeFile(registryPath, JSON.stringify(rows));
// Lenient keeps the contract it has: it hands the row back untouched.
await expect(readRegistry()).resolves.toEqual(rows);
await expect(readRegistryStrict()).rejects.toThrow('entry 1');
});
it('rejects a row whose `name` is whitespace only', async () => {
await fs.writeFile(registryPath, JSON.stringify([{ ...resolvableRow(), name: ' ' }]));
await expect(readRegistryStrict()).rejects.toThrow('registry is corrupt');
});
it('rejects a row whose `storagePath` is whitespace only', async () => {
// `storagePath` is what the handle carries to `path.join(storagePath,
// 'lbug')`. Blank, that joins to a relative `lbug` under the CWD — an
// index that is not this repo's, opened without anyone saying so.
await fs.writeFile(registryPath, JSON.stringify([{ ...resolvableRow(), storagePath: ' ' }]));
await expect(readRegistry()).resolves.toHaveLength(1);
await expect(readRegistryStrict()).rejects.toThrow('registry is corrupt');
});
it('accepts a row whose unused `path` is blank, and syncs the repo it names', async () => {
// The counter-case that fixes the width of the rule. `path` is not what
// identifies a repo, so tightening it too would trade this fail-open for a
// fail-shut: one blank `path` anywhere in the MACHINE-WIDE registry would
// reject the whole file and break every group sync on the machine,
// including groups whose repos all resolve. Same principle as indexedAt /
// lastCommit — require only what the resolution path depends on.
const row = { ...resolvableRow(), path: ' ' };
await fs.writeFile(registryPath, JSON.stringify([row]));
await expect(readRegistryStrict()).resolves.toEqual([row]);
const result = await syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), {
skipWrite: true,
});
// Resolved: not reported missing, and the snapshot carries THIS row's
// registry metadata, which only a successful name match could supply.
expect(result.missingRepos).toEqual([]);
expect(result.unreadableRepos).toEqual([]);
expect(result.repoSnapshots['app/backend']).toEqual({
indexedAt: '2026-01-01T00:00:00.000Z',
lastCommit: 'abc123',
});
});
/**
* A credential-shaped secret, distinctive enough that the whole failure
* surface can be grepped for it. Synthetic not a real token.
*/
const REGISTRY_SECRET = 'LEAKCAN4RY';
/**
* A corrupt registry whose break sits directly on a credential.
*
* The shape is a short write landing over a longer one: the head is the new
* content, and the tail is what was left of the old file which resumes in
* the middle of a remote URL's HTTPS userinfo. `registry.json` is the one
* file every gitnexus process on the machine writes and `withRegistryLock`
* degrades to unlocked on timeout, so two writers really can produce this.
*
* What matters is where the parser stops: the first byte it rejects is the
* first byte of the credential, and V8 quotes a ten-character window either
* side of that position into `SyntaxError.message`. Registry rows carry
* remote URLs with userinfo verbatim (a pre-existing capture-side issue), so
* the bytes in that window are a live secret.
*/
const corruptRegistryOnACredential = (): string =>
`[{"name":"backend-repo","path":"/repos/backend","storagePath":"/repos/backend/.gitnexus",` +
`"remoteUrl":"https://gnx-bot:${REGISTRY_SECRET}@github.com/acme/backend.git"},` +
`${REGISTRY_SECRET}@github.com/acme/backend.git"}]`;
it('names a corrupt registry without quoting its bytes, on the throw or the log', async () => {
// One test, every channel. A rejection an operator never sees the message
// of is still rendered somewhere: `groupStatus` interpolates it verbatim
// into `unresolvableReason` for an MCP client, the CLI prints it, and any
// `logger.error({ err }, …)` on the way would serialise message, stack and
// `cause` into the MCP client's log file on disk. So assert on all of them.
await fs.writeFile(registryPath, corruptRegistryOnACredential());
let cap: LoggerCapture | undefined;
let thrown: unknown;
try {
cap = _captureLogger('trace');
await readRegistryStrict();
} catch (err) {
thrown = err;
} finally {
cap?.restore();
}
const logged = cap?.text() ?? '';
expect(thrown).toBeInstanceOf(Error);
const error = thrown as Error;
// Channel 1: the message every renderer above reads.
expect(error.message).not.toContain(REGISTRY_SECRET);
// Channel 2: `cause`, which pino's error serialiser and `util.inspect`
// both walk. Discarding the parser error means there is nothing to walk.
expect(error.cause).toBeUndefined();
// Channel 3: whatever a generic stringifier reaches — own properties,
// stack, and the cause chain in one shot.
expect(inspect(error, { depth: null })).not.toContain(REGISTRY_SECRET);
// Channel 4: the log. Nothing is logged here at all, and the assertion
// holds the line against "log the Error object" being added later.
expect(logged).not.toContain(REGISTRY_SECRET);
// And it still says what failed. Host-independent: the raw parser error
// names neither the path nor the failure class, on any V8.
expect(error.message).toContain(registryPath);
expect(error.message).toContain('registry is corrupt');
});
it('still reports that same credential-bearing registry as empty on the lenient path', async () => {
// The guarded parse must not change what lenient callers see: `gitnexus
// list` and the eight other lenient sites still get `[]`, not a throw.
await fs.writeFile(registryPath, corruptRegistryOnACredential());
await expect(readRegistry()).resolves.toEqual([]);
});
it('throws when the registry parses but is not an array', async () => {
// A JSON object here is corruption too, and it is the shape most likely to
// survive a partial write: `[]` is what the lenient path would return, which
// is indistinguishable from a registry that really has no entries.
await fs.writeFile(registryPath, '{"repos": []}');
await expect(readRegistry()).resolves.toEqual([]);
await expect(readRegistryStrict()).rejects.toThrow('not a JSON array');
});
});

View file

@ -0,0 +1,505 @@
import { afterEach, describe, expect, it } from 'vitest';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
/**
* Guard: no tracked source file may carry a raw control byte.
*
* A NUL written as a literal 0x00 rather than the `\0` escape is invisible in
* an editor and identical at runtime, but it makes the file test as BINARY:
* git shows `Bin` instead of a diff, so the change cannot be read on the PR,
* cannot take an inline comment, and cannot be three-way merged; `file(1)`
* reports `data`; `ugrep` returns empty with exit 1 (indistinguishable from
* "no match", with no message); and BSD grep replaces the matching lines with
* `Binary file … matches`. A search that should hit comes back as a confident
* "not present", which is the worst way for a file to be unreadable.
*
* The byte class is deliberately split, because the two halves are not the
* same rule:
*
* - 0x00 is checked across EVERY tracked source file. git's binary heuristic
* keys on NUL alone, so NUL is the byte that actually costs a file its
* text status. Both recurrences in this repo landed outside `src/`
* b620773b1 in `gitnexus/bench/cpp-qualified-ns/measure.mjs`, and
* 38d737bb5 in a `gitnexus/test/integration/` fixture so a guard scoped
* to `src/` would have caught neither, and one of the two was not even a
* `.ts` file.
* - The wider C0 class (everything except tab, LF and CR) stays scoped to
* `gitnexus/src`. Those bytes only *look* binary to some tools; they do not
* flip git's own classification, and outside `src/` they have a legitimate
* user: `test/unit/logger.test.ts` feeds a real 0x1b ANSI escape through the
* NDJSON encoder, which is the entire point of that test. Widening this half
* repo-wide would go red on that fixture the day it landed.
*
* The file list comes from `git ls-files` at the repository root rather than a
* directory walk: it is exactly the set git applies its binary heuristic to, it
* never descends into `node_modules` or `dist`, and it honours `.gitignore` for
* free. The tradeoff is that a brand-new file is only covered once git knows
* about it `git add -N` is enough. What it does NOT skip is vendored code,
* which is tracked here; that is the one deliberate exclusion, and it is named
* in {@link UNSCANNED_ROOT} below.
*
* Files are read as Buffers and scanned byte-wise. Decoding each one to a
* string first bought nothing: LOCATING the byte is ~14 ms for the whole repo
* (1.6 ms of `Buffer.indexOf` across the 33 MB NUL set, 12 ms of the
* byte-at-a-time C0 loop across the 11 MB `src/` subset), and the READS dominate
* it by two orders of magnitude 4893 files, ~0.3 s warm on a local disk and
* several seconds on a virtualised or network one. That ratio is why the reads
* go through a small concurrency pool, and why {@link UNSCANNED_ROOT} is worth
* having: without it the same scan pulls in 4969 files and 97 MB, because four
* generated `parser.c` files under the vendored grammar tree are 62 MB between
* them.
*/
const HERE = path.dirname(fileURLToPath(import.meta.url));
/**
* Asking git rather than resolving `../../..` keeps this correct inside a
* linked worktree, and fails loudly (instead of silently scanning nothing) if
* this test is ever run outside a checkout.
*/
const REPO_ROOT = execFileSync('git', ['rev-parse', '--show-toplevel'], {
cwd: HERE,
encoding: 'utf8',
}).trim();
/**
* Every tracked text format a raw NUL would silently turn binary.
*
* Not just the JS/TS family, and not just code: git's heuristic does not care
* what a file is for. This repo tracks Python, Java, Go, Rust, C/C++, Ruby,
* PHP, Kotlin, Swift, C#, COBOL, shell and Dart sources as resolver fixtures,
* and it hand-edits far more configuration than source `package.json`, the
* workflow YAML, `go.mod` and `*.csproj` fixtures, the docs, and the vitest
* `.snap` files that are regenerated on demand and reviewed as diffs. A NUL
* costs any of them its diff on exactly the same terms.
*
* `.scm` (tree-sitter queries) and `.gyp` are listed for the same reason, even
* though every tracked instance of both today sits inside the vendored
* grammar tree: the day a first-party query file lands outside it, it is
* covered without a second round of this.
*
* The list stays an ALLOWLIST rather than "everything git tracks" because the
* index also names the tree-sitter `.node` prebuilds and a `.png`, and those 31
* files are genuinely binary they are the whole reason a NUL scan cannot just
* read the index.
*/
const SOURCE_EXTENSIONS =
/\.(?:ts|tsx|js|jsx|mjs|cjs|mts|cts|py|pyi|java|kt|kts|go|rs|c|h|cc|cpp|hpp|cs|rb|php|swift|scala|dart|lua|pl|sh|bash|zsh|cbl|cpy|jcl|sql|vue|svelte|html|htm|css|scss|less|jinja|toml|cfg|ini|properties|env|example|json|jsonc|jsonl|yml|yaml|xml|csv|txt|md|mdc|mdm|mdx|snap|scm|proto|lock|mod|sum|csproj|props|targets|sln|gradle|gyp|gypi|ps1|bat|cmd)$/;
/**
* Tracked text files whose whole NAME is the format the half no extension
* regex can reach.
*
* {@link SOURCE_EXTENSIONS} is end-anchored on a dot, so `Dockerfile`,
* `CODEOWNERS`, `LICENSE`, `SHA256SUMS` and the husky hook never match it at
* any width, and neither does a bare dotfile like `.gitignore` or
* `.prettierrc`, whose entire name reads as an extension. Every one of them is
* hand-edited here, and a NUL would cost each of them its diff.
*
* Matched against the BASENAME, so one entry covers every directory the name
* appears in, and matched case-sensitively, which is how git stores the path.
*/
const SOURCE_BASENAMES =
/^(?:Dockerfile(?:\..+)?|CODEOWNERS|LICENSE|SHA256SUMS|pre-commit|\.(?:cursorrules|dockerignore|git-blame-ignore-revs|gitattributes|gitignore|gitkeep|gitleaksignore|npmignore|prettierignore|prettierrc|windsurfrules))$/;
/** Scope of the wider control-byte rule. git paths are always `/`-separated. */
const STRICT_SOURCE_ROOT = 'gitnexus/src/';
/**
* The one tracked root this guard deliberately does not scan.
*
* `gitnexus/vendor/` is upstream tree-sitter grammars, vendored wholesale. It
* is never hand-edited, so the mistake this guard exists to catch cannot happen
* there and it is where the whole cost is: four generated `parser.c` files
* are 62 MB of the 97 MB the allowlist would otherwise read, two thirds of the
* scan for 76 of its 4969 files.
*
* An ANCHORED PREFIX, deliberately, and deliberately case-SENSITIVE. Matching a
* `vendor` path SEGMENT, or matching case-insensitively, would also drop three
* tracked paths that live outside this root and are reviewed as diffs like any
* other source here: `gitnexus-web/src/vendor/leiden/`, the Kotlin
* `vendor/Assert.kt` resolver fixture, and the PHP `src/Vendor/Utils/Format.php`
* one. That loss would be silent the guard would simply stop covering them
* which is why the exclusion case below pins both halves.
*/
const UNSCANNED_ROOT = 'gitnexus/vendor/';
/** Enough to hide per-file I/O latency without risking EMFILE. */
const READ_CONCURRENCY = 16;
interface ScanTarget {
/** Absolute path to read. */
readonly abs: string;
/** Path as reported in failures — repo-root-relative for tracked files. */
readonly rel: string;
}
interface Offender {
readonly rel: string;
readonly line: number;
readonly byte: number;
}
/** The one byte git's binary heuristic keys on. */
function findNulByte(buf: Buffer): number {
return buf.indexOf(0);
}
/** C0 controls minus the three that are legitimate in source: tab, LF, CR. */
function findControlByte(buf: Buffer): number {
for (let i = 0; i < buf.length; i += 1) {
const byte = buf[i];
if (byte > 0x1f) continue;
if (byte === 0x09 || byte === 0x0a || byte === 0x0d) continue;
return i;
}
return -1;
}
/** Only ever called for an actual offender, so the O(offset) count is free. */
function lineOfOffset(buf: Buffer, offset: number): number {
let line = 1;
for (let i = 0; i < offset; i += 1) {
if (buf[i] === 0x0a) line += 1;
}
return line;
}
/**
* `git ls-files` reports the index, which can name a path that is not on disk
* (a staged deletion, a sparse checkout). Those are not offenders. Any other
* read failure propagates rather than quietly shrinking the scanned set.
*/
async function readTrackedFile(abs: string): Promise<Buffer | null> {
try {
return await fsp.readFile(abs);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
throw error;
}
}
async function scanTarget(
target: ScanTarget,
locate: (buf: Buffer) => number,
): Promise<Offender | null> {
const buf = await readTrackedFile(target.abs);
if (buf === null) return null;
const offset = locate(buf);
if (offset === -1) return null;
return { rel: target.rel, line: lineOfOffset(buf, offset), byte: buf[offset] };
}
/**
* Reads run concurrently, so the completion order is not the input order the
* result is sorted before it is returned so the assertion never depends on it.
*/
async function scanTargets(
targets: readonly ScanTarget[],
locate: (buf: Buffer) => number,
): Promise<Offender[]> {
const offenders: Offender[] = [];
let cursor = 0;
const worker = async (): Promise<void> => {
for (;;) {
const index = cursor;
cursor += 1;
if (index >= targets.length) return;
const offender = await scanTarget(targets[index], locate);
if (offender !== null) offenders.push(offender);
}
};
const workers = Math.min(READ_CONCURRENCY, targets.length);
await Promise.all(Array.from({ length: workers }, () => worker()));
return offenders.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : a.line - b.line));
}
/**
* The collector's whole filter, in one predicate.
*
* Exposed as a function so the planted-fixture cases below can put a fixture
* name through the SAME decision the repo-wide scan makes. Asserting on
* `scanTargets` alone proves only that the byte locator works; it says nothing
* about whether the collector would ever hand that file to the locator, and
* that second half is the one that has been too narrow.
*/
function isScannedTextFile(rel: string): boolean {
if (rel.startsWith(UNSCANNED_ROOT)) return false;
return SOURCE_EXTENSIONS.test(rel) || SOURCE_BASENAMES.test(path.posix.basename(rel));
}
function listTrackedSourceFiles(): ScanTarget[] {
const stdout = execFileSync('git', ['ls-files', '-z'], {
cwd: REPO_ROOT,
encoding: 'utf8',
maxBuffer: 64 * 1024 * 1024,
});
// `-z` emits raw, NUL-terminated paths, so nothing is quoted or escaped and
// the trailing empty segment is dropped by the filter (it has neither a
// matching extension nor a matching basename).
return stdout
.split('\u0000')
.filter((rel) => isScannedTextFile(rel))
.map((rel) => ({ abs: path.join(REPO_ROOT, rel), rel }));
}
const TRACKED_SOURCE_FILES = listTrackedSourceFiles();
const STRICT_SOURCE_FILES = TRACKED_SOURCE_FILES.filter((target) =>
target.rel.startsWith(STRICT_SOURCE_ROOT),
);
function describeOffender(offender: Offender): string {
const byte = `0x${offender.byte.toString(16).padStart(2, '0')}`;
return `${offender.rel}:${offender.line} contains ${byte}`;
}
function failureMessage(lead: readonly string[], offenders: readonly Offender[]): string {
return [...lead, ...offenders.map((offender) => ` - ${describeOffender(offender)}`)].join('\n');
}
/** Line 3 carries the raw NUL; the two lines above it prove the line count. */
const PLANTED_NUL_SOURCE = ['const a = 1;', 'const b = 2;', "const sep = '\u0000';", ''].join('\n');
/** Line 2 carries a raw ESC — the byte the repo-wide half deliberately allows. */
const PLANTED_ESCAPE_SOURCE = ['const a = 1;', "const red = '\u001b[31m';", ''].join('\n');
/**
* The same defect in a non-JS source file. git classifies this as binary for
* exactly the same reason, and an allowlist that stops at `.cts` would collect
* neither the file nor the byte.
*/
const PLANTED_PY_NUL_SOURCE = ['a = 1', 'b = 2', "sep = '\u0000'", ''].join('\n');
/**
* The same defect again, in the four shapes the JS/TS extension list could not
* reach. The last two are why a second, basename filter has to exist at all:
* `Dockerfile` has no extension, and `.gitignore` is a name that IS its
* extension, so an end-anchored `\.(…)$` regex can never match either,
* however far its alternation is widened.
*/
const PLANTED_JSON_NUL_SOURCE = ['{', ' "a": 1,', ' "sep": "\u0000"', '}', ''].join('\n');
const PLANTED_MD_NUL_SOURCE = ['# Heading', 'separator: \u0000', ''].join('\n');
const PLANTED_DOCKERFILE_NUL_SOURCE = ['FROM node:22-bookworm', 'RUN echo \u0000', ''].join('\n');
const PLANTED_DOTFILE_NUL_SOURCE = ['dist/', 'sep-\u0000/', ''].join('\n');
function writeFixture(dir: string, name: string, source: string): ScanTarget {
const abs = path.join(dir, name);
// Written as a Buffer so the escapes above land as single raw bytes on disk,
// which is the shape the guard has to catch.
fs.writeFileSync(abs, Buffer.from(source, 'utf8'));
return { abs, rel: name };
}
function removeDir(dir: string | null): void {
if (dir === null) return;
fs.rmSync(dir, { recursive: true, force: true });
}
describe('source hygiene', () => {
let fixtureDir: string | null = null;
afterEach(() => {
removeDir(fixtureDir);
fixtureDir = null;
});
it('has no raw NUL byte in any tracked source file', async () => {
const offenders = await scanTargets(TRACKED_SOURCE_FILES, findNulByte);
expect(
offenders.map(describeOffender),
failureMessage(
[
'A raw NUL makes git classify the whole file as binary: it shows as `Bin`',
'with no diff, takes no inline review comment, and will not three-way',
'merge. Write the character as an escape instead (e.g. `\\0` or',
'`\\u0000`), which is identical at runtime and keeps the file text:',
],
offenders,
),
).toEqual([]);
});
it('has no other raw control byte under gitnexus/src', async () => {
const offenders = await scanTargets(STRICT_SOURCE_FILES, findControlByte);
expect(
offenders.map(describeOffender),
failureMessage(
[
'Raw control bytes make a source file test as binary to `file(1)`, `less`',
'and several greps, so those tools skip it silently. Write the character',
'as an escape instead, which is identical at runtime and keeps the file',
'text. If the raw byte is the subject of the code (an ANSI-escape',
'fixture, say), it belongs in the test tree, not in src/:',
],
offenders,
),
).toEqual([]);
});
it('scans past gitnexus/src and past .ts, where both recurrences landed', () => {
const outsideSrc = TRACKED_SOURCE_FILES.map((target) => target.rel).filter(
(rel) => !rel.startsWith(STRICT_SOURCE_ROOT),
);
// Narrowing the collector back to src/, or back to .ts only, is what let
// this defect land twice. Each of these would go red on that narrowing.
expect(outsideSrc.length).toBeGreaterThan(0);
expect(outsideSrc.filter((rel) => rel.startsWith('gitnexus/bench/')).length).toBeGreaterThan(0);
expect(outsideSrc.filter((rel) => rel.startsWith('gitnexus/test/')).length).toBeGreaterThan(0);
expect(outsideSrc.filter((rel) => rel.endsWith('.mjs')).length).toBeGreaterThan(0);
});
it('reports the path, line and byte value of a planted control byte', async () => {
fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), 'source-control-bytes-'));
const planted = [
writeFixture(fixtureDir, 'planted-escape.ts', PLANTED_ESCAPE_SOURCE),
writeFixture(fixtureDir, 'planted-nul.py', PLANTED_PY_NUL_SOURCE),
writeFixture(fixtureDir, 'planted-nul.ts', PLANTED_NUL_SOURCE),
];
const nulOffenders = await scanTargets(planted, findNulByte);
const controlOffenders = await scanTargets(planted, findControlByte);
// Without this the guard above is unfalsifiable: a collector that returns
// an empty list, or a locator that never matches, passes it forever.
expect(nulOffenders.map(describeOffender)).toEqual([
'planted-nul.py:3 contains 0x00',
'planted-nul.ts:3 contains 0x00',
]);
expect(controlOffenders.map(describeOffender)).toEqual([
'planted-escape.ts:2 contains 0x1b',
'planted-nul.py:3 contains 0x00',
'planted-nul.ts:3 contains 0x00',
]);
});
it('collects tracked sources outside the JS/TS family', () => {
// The allowlist is the collector's only filter, so a language missing from
// it is a language the NUL rule silently does not cover. This goes red if
// the list is ever narrowed back to JS/TS.
const collected = TRACKED_SOURCE_FILES.map((target) => target.rel);
const byExtension = (ext: string): number =>
collected.filter((rel) => rel.endsWith(ext)).length;
expect(byExtension('.py')).toBeGreaterThan(0);
expect(byExtension('.java')).toBeGreaterThan(0);
expect(byExtension('.go')).toBeGreaterThan(0);
expect(byExtension('.rs')).toBeGreaterThan(0);
});
it('reports a planted NUL in the shapes the JS/TS extension list never reached', async () => {
fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), 'source-control-bytes-'));
const planted = [
writeFixture(fixtureDir, '.gitignore', PLANTED_DOTFILE_NUL_SOURCE),
writeFixture(fixtureDir, 'Dockerfile', PLANTED_DOCKERFILE_NUL_SOURCE),
writeFixture(fixtureDir, 'planted-nul.json', PLANTED_JSON_NUL_SOURCE),
writeFixture(fixtureDir, 'planted-nul.md', PLANTED_MD_NUL_SOURCE),
];
// Through the collector's own predicate, not straight into the locator: a
// file the collector never yields is a file the guard never reads, and that
// is the failure mode both halves of the filter exist to close. Dropping
// either half deletes entries from this list.
const scanned = planted.filter((target) => isScannedTextFile(target.rel));
const offenders = await scanTargets(scanned, findNulByte);
expect(offenders.map(describeOffender)).toEqual([
'.gitignore:2 contains 0x00',
'Dockerfile:2 contains 0x00',
'planted-nul.json:3 contains 0x00',
'planted-nul.md:2 contains 0x00',
]);
});
it('collects every tracked text format, files with no extension included', () => {
const collected = TRACKED_SOURCE_FILES.map((target) => target.rel);
const byExtension = (ext: string): number =>
collected.filter((rel) => rel.endsWith(ext)).length;
// Data and configuration formats. git's heuristic does not care that these
// are not code: a NUL costs `package.json` its diff exactly as it costs a
// `.ts` file, and every format below is hand-edited in this repo.
expect(byExtension('.json')).toBeGreaterThan(0);
expect(byExtension('.yml')).toBeGreaterThan(0);
expect(byExtension('.yaml')).toBeGreaterThan(0);
expect(byExtension('.md')).toBeGreaterThan(0);
expect(byExtension('.snap')).toBeGreaterThan(0);
expect(byExtension('.txt')).toBeGreaterThan(0);
expect(byExtension('.csproj')).toBeGreaterThan(0);
expect(byExtension('go.mod')).toBeGreaterThan(0);
expect(byExtension('.properties')).toBeGreaterThan(0);
expect(byExtension('.cbl')).toBeGreaterThan(0);
// The basename half. An end-anchored EXTENSION regex cannot reach any of
// these however far its alternation is widened, so widening alone would
// have left all of them outside the guard.
expect(collected).toContain('.devcontainer/Dockerfile');
expect(collected).toContain('.github/CODEOWNERS');
expect(collected).toContain('.husky/pre-commit');
expect(collected).toContain('LICENSE');
expect(byExtension('.gitignore')).toBeGreaterThan(0);
expect(byExtension('.prettierrc')).toBeGreaterThan(0);
});
it('still leaves tracked binary formats out of the scan', () => {
const collected = TRACKED_SOURCE_FILES.map((target) => target.rel);
// Why this stays an allowlist rather than "everything git tracks". The
// index also names the tree-sitter prebuilds and one docs screenshot, and
// those 31 files are the only tracked files here that really do carry a
// NUL — scanning them would report all 31 forever.
expect(collected.filter((rel) => rel.endsWith('.node'))).toEqual([]);
expect(collected.filter((rel) => rel.endsWith('.png'))).toEqual([]);
expect(isScannedTextFile('gitnexus/prebuilds/linux-x64/tree-sitter-kotlin.node')).toBe(false);
expect(isScannedTextFile('Documentation/docs-asset/kilo-code-mcp.png')).toBe(false);
});
it('skips the vendored grammar tree without dropping first-party `vendor` paths', () => {
const collected = TRACKED_SOURCE_FILES.map((target) => target.rel);
expect(collected.filter((rel) => rel.startsWith(UNSCANNED_ROOT))).toEqual([]);
// Both halves in one assertion, because the cheap way to write the
// exclusion — a `vendor` path SEGMENT, or a case-insensitive match — passes
// the line above and silently drops these three. Nothing else would notice:
// a file that leaves the collected set just stops being guarded.
expect(collected).toContain('gitnexus-web/src/vendor/leiden/index.js');
expect(collected).toContain(
'gitnexus/test/fixtures/lang-resolution/kotlin-import-package-evidence/vendor/Assert.kt',
);
expect(collected).toContain(
'gitnexus/test/fixtures/lang-resolution/php-namespace-fallback-isolation/src/Vendor/Utils/Format.php',
);
// Case-sensitivity, pinned on the predicate rather than on the tracked set,
// because nothing tracked today is named `gitnexus/Vendor/` — so a
// case-insensitive prefix would cost this repo nothing YET, and only the
// predicate can say the rule out loud. The repo already proves the casing
// distinction is live: `src/Vendor/Utils/Format.php` above is first-party.
expect(isScannedTextFile('gitnexus/Vendor/tree-sitter-c/src/parser.c')).toBe(true);
// ...and the anchor itself, for the same reason: only the leading path is
// vendored, not every directory that happens to be called `vendor`.
expect(isScannedTextFile('gitnexus/src/core/vendor/adapter.ts')).toBe(true);
expect(isScannedTextFile(`${UNSCANNED_ROOT}tree-sitter-c/src/parser.c`)).toBe(false);
// And the first-party half of the formats the vendored tree also uses is
// still collected, so the exclusion cost coverage of nothing.
const outsideRoot = (ext: string): number =>
collected.filter((rel) => rel.endsWith(ext) && !rel.startsWith(UNSCANNED_ROOT)).length;
expect(outsideRoot('.c')).toBeGreaterThan(0);
expect(outsideRoot('.h')).toBeGreaterThan(0);
expect(outsideRoot('.js')).toBeGreaterThan(0);
expect(outsideRoot('.json')).toBeGreaterThan(0);
expect(outsideRoot('.md')).toBeGreaterThan(0);
});
});

View file

@ -13,6 +13,8 @@ import {
LIST_REPOS_DEFAULT_LIMIT,
LIST_REPOS_MAX_LIMIT,
} from '../../src/mcp/tools.js';
import { getResourceTemplates, type ResourceTemplate } from '../../src/mcp/resources.js';
import { GROUP_IMPACT_TRUNCATION_REASONS } from '../../src/core/group/types.js';
const GROUP_TOOLS = new Set(['group_list', 'group_sync']);
const MUTATING_TOOLS = new Set(['rename', 'group_sync']);
@ -290,6 +292,40 @@ describe('GITNEXUS_TOOLS', () => {
}
});
// U27: `RegistryWriteOutcome` has four members, three of which a group_sync
// MCP call can actually return (`not-attempted` needs `skipWrite`/no
// `groupDir`, neither reachable through this tool). An agent that only knows
// 'written' and 'preserved' reads the third — nothing readable AND no prior
// registry — as "your previous contracts survived", which is a claim about a
// file that does not exist (R4).
it('group_sync description names every registry outcome reachable through the tool', () => {
const syncTool = GITNEXUS_TOOLS.find((t) => t.name === 'group_sync')!;
const d = syncTool.description;
expect(d).toContain('registryOutcome');
expect(d).toContain("'written'");
expect(d).toContain("'preserved'");
expect(d).toContain("'superseded'");
expect(d).toContain("'no-prior-registry'");
// Naming the value is not describing it: the outcome an agent has to act on
// differently is "there is no contracts.json on disk at all".
expect(d).toMatch(/no previous contracts\.json|no contracts\.json (exists|was written)/i);
// `not-attempted` is unreachable through this tool; documenting it would
// advertise an outcome no caller can observe.
expect(d).not.toContain('not-attempted');
// 'preserved' rewrites contracts.json (keeping the previous contracts and
// cross-links, refreshing the diagnostic lists). ITS clause may not say the
// file was left alone — that sent an operator reading an unchanged mtime to
// conclude the sync never ran. Scoped to that clause rather than the whole
// description, because 'superseded' genuinely does leave the file untouched
// and describing it accurately must not trip this.
const preservedClause = d.slice(d.indexOf("'preserved'"), d.indexOf("'superseded'"));
expect(preservedClause).not.toMatch(/did NOT write|untouched|left alone|unwritten/i);
// ...and the superseded clause must say exactly that, or the two collapse
// back into one word for two different things on disk.
const supersededClause = d.slice(d.indexOf("'superseded'"), d.indexOf("'no-prior-registry'"));
expect(supersededClause).toMatch(/untouched|not recorded/i);
});
it('impact, query, and context expose optional service with minLength', () => {
for (const n of ['impact', 'query', 'context'] as const) {
const tool = GITNEXUS_TOOLS.find((t) => t.name === n)!;
@ -394,3 +430,58 @@ describe('GITNEXUS_TOOLS', () => {
expect(shapeCheckTool.description).toContain('pre-change analysis');
});
});
// U28: a cross-repo answer that is a floor says so with `truncated` +
// `truncationReason`, and the agent-facing surfaces have to teach that
// vocabulary — an agent that cannot tell a retryable runtime limit from a
// structural one retries a query that will return the same floor forever (R8).
describe('cross-repo incompleteness vocabulary', () => {
const impactDescription = (): string =>
GITNEXUS_TOOLS.find((t) => t.name === 'impact')!.description;
const groupStatusTemplate = (): ResourceTemplate =>
getResourceTemplates().find((t) => t.uriTemplate === 'gitnexus://group/{name}/status')!;
it('impact description names every truncation reason the group surfaces can return', () => {
const d = impactDescription();
expect(d).toContain('truncationReason');
// Iterates the RUNTIME array on purpose: a hand-listed expectation here
// would keep passing after a fourth reason is added and left undescribed,
// which is the only failure this guard exists to catch.
for (const reason of GROUP_IMPACT_TRUNCATION_REASONS) {
expect(
d,
`truncationReason '${reason}' is not explained in the impact description`,
).toContain(`'${reason}'`);
}
});
it("impact description gives 'incomplete-sync' a re-sync remedy, not a retry", () => {
const d = impactDescription();
// The structural cause and its remedy: the bridge is missing those repos'
// contracts, so the same query returns the same floor until a sync fixes it.
expect(d).toContain('group_sync');
expect(d).toMatch(/'incomplete-sync'[\s\S]{0,600}group_sync/);
// ...and truncated:true must no longer read as "the fan-out ran out of
// room": on 'incomplete-sync' zero crossings may have been attempted.
expect(d).toMatch(/truncated:true does NOT always mean/i);
});
it('group status resource description explains the absent / empty / populated tri-state', () => {
const d = groupStatusTemplate().description;
expect(d).toContain('unreadableRepos');
// Three states, one vocabulary (R8): absent = the sync never recorded which
// repos it could read, empty = it measured none, populated = it named them.
// Describing it as a two-state turns "unknown" into "none".
expect(d).toMatch(/absent/i);
expect(d).toMatch(/empty/i);
expect(d).toMatch(/populated/i);
});
it('group status resource description tells an absent repo from an unresolvable one', () => {
const d = groupStatusTemplate().description;
expect(d).toContain('missing');
expect(d).toContain('unresolvable');
expect(d).toContain('unresolvableReason');
});
});