import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; import fsp from 'node:fs/promises'; import * as path from 'node:path'; import * as os from 'node:os'; import { _captureLogger } from '../../../src/core/logger.js'; import type { BridgeHandle, GroupConfig, RepoHandle } from '../../../src/core/group/types.js'; import { BRIDGE_SCHEMA_VERSION } from '../../../src/core/group/bridge-schema.js'; import { makeGroupToolPort, writeGroupYaml } from './fixtures.js'; /** * A repo that is registered but whose index cannot be opened must not be * reported as a MISSING repo, and must not silently replace a good * contracts.json with an empty one. * * The failure this pins: `syncGroup` wrapped `initLbug` + extraction 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 registry was overwritten with an empty one. * * Three of these cases exist because mutation testing showed the original four * could not see the change they were named after: * - a two-repo case, because with exactly one configured repo * `unreadableRepos.length === configuredRepoCount` holds whenever anything * fails, so deleting the `=== configuredRepoCount` conjunct — turning "every * repo failed" into "any repo failed" — passed everything; * - an all-missing case, because deleting the `unreadableRepos.length > 0` * conjunct was caught only by a 9.7 s integration test in another directory; * - a log assertion, because deleting both `logger.warn` calls — the entire * stated purpose of the change — passed everything too. */ const LBUG_VERSION_ERROR = 'LadybugDB unavailable for backend-repo. Another process may be rebuilding the index. ' + 'Retry later. (Runtime exception: Trying to read a database file with a different version. ' + 'Database file version: 43, Current build storage version: 40)'; const initLbugMock = vi.fn(); const readRegistryStrictMock = vi.fn(); /** * A SEPARATE mock from the strict one, and that separation is the whole point. * * Both exports used to resolve to one mock, so the refuses-to-sync case below — * which drives the read by rejecting — got the same rejection whichever export * `syncGroup` called. It would have passed identically against the lenient read * it exists to rule out, which is to say it measured nothing about which read is * used. * * The implementation here is the lenient export's real contract: `readRegistry` * swallows EACCES and a corrupt file alike and answers `[]`. Pointing * `syncGroup` at it therefore turns an unreadable registry back into "no repo is * registered" — every configured repo MISSING, the total-failure guard off, a * good contracts.json replaced by an empty one at exit 0 — and the case goes * red. On this path production reaches the lenient export only under * `detect.workspace_deps`, which `makeConfig` leaves off, so no other case in * this file can see the split. */ const readRegistryLenientMock = vi.fn(async (..._args: unknown[]): Promise => []); 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', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, readRegistry: (...args: unknown[]) => readRegistryLenientMock(...args), readRegistryStrict: (...args: unknown[]) => readRegistryStrictMock(...args), }; }); /** * Armed by the bridge-write-failure suite at the bottom of this file, `null` * everywhere else. There is no filesystem shape that makes the real writer fail * while `writeContractRegistry` — same directory, one line earlier in * `syncGroup` — still succeeds, and that ordering is the whole subject of the * warning under test. */ let writeBridgeFailure: Error | null = null; /** * Only the read-only OPEN legs are stubbed, so `runGroupImpact` can read the * metadata a preserve sync just wrote without a native LadybugDB open of a * placeholder file. The bridge write, `writeBridgeMeta`, `readBridgeMeta` and * `bridgeMetaMatchesFile` all travel their real implementations — they are the * code under test here, and `syncGroup` reaches the bridge write through this * module too. The wrapper below is a pass-through in every test that does not * arm `writeBridgeFailure`. * * It intercepts `writeBridgeUnlocked`, NOT the exported `writeBridge`: the swap * comes in two halves, and `syncGroup` calls the lock-free one because it is * already inside `withGroupSyncLock` (a second acquisition of a non-reentrant * lock would hang every sync). Arming the acquiring wrapper instead would inject * a fault into a function this path never calls, and the failure branch below * would go quietly untested. */ vi.mock('../../../src/core/group/bridge-db.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, writeBridgeUnlocked: vi.fn(async (...args: Parameters) => { if (writeBridgeFailure) throw writeBridgeFailure; return actual.writeBridgeUnlocked(...args); }), getCachedBridgeReadOnly: vi.fn( async (groupDir: string) => ({ _db: {}, _conn: {}, groupDir, _readOnly: true }) as BridgeHandle, ), queryBridge: vi.fn(async () => [] as Array>), closeBridgeDb: vi.fn(async () => undefined), }; }); /** * Armed by the concurrent-sync suite at the bottom of this file, `null` * everywhere else. It runs INSIDE the real group sync lock — after this sync * acquired it, before its persist section starts — which is the one window in * which another sync's write can land: extraction runs OUTSIDE the lock, so a * sync that queued behind a winner is holding stats it took before the winner * ever wrote. Nothing in-process can reach that window otherwise, and a rare * interleave is not a test. */ let whileWaitingForTheGroupLock: (() => Promise) | null = null; /** * A pass-through in every test that does not arm the hook: the REAL lock is * acquired, on the real `/sync-lock`, exactly as production does. */ vi.mock('../../../src/core/group/group-lock.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, withGroupSyncLock: (groupDir: string, operation: () => Promise): Promise => actual.withGroupSyncLock(groupDir, async () => { const hook = whileWaitingForTheGroupLock; whileWaitingForTheGroupLock = null; if (hook) await hook(); return operation(); }), }; }); const { syncGroup } = await import('../../../src/core/group/sync.js'); const { runGroupImpact } = await import('../../../src/core/group/cross-impact.js'); const { bridgeMetaMatchesFile, closeAllCachedBridges, readBridgeMeta, writeBridgeMeta } = await import('../../../src/core/group/bridge-db.js'); const registryEntry = (name: string, dir: string) => ({ name, path: `/repos/${dir}`, storagePath: `/repos/${dir}/.gitnexus`, indexedAt: '2026-01-01T00:00:00.000Z', lastCommit: 'abc123', }); const REGISTRY = [registryEntry('backend-repo', 'backend'), registryEntry('web-repo', 'web')]; const makeConfig = (repos: Record): GroupConfig => ({ version: 1, name: 'test', description: '', repos, links: [], packages: {}, detect: { http: true, grpc: false, thrift: false, topics: false, includes: false, workspace_deps: false, }, matching: {}, }); /** * Resolve handles from a table keyed on the registry name, so a multi-repo case * needs no branching inside the test body. An unknown name resolves to `null`, * which is the production "not in the registry" answer. */ const handleTable = (names: readonly string[]) => { const byName = new Map( names.map((name) => [ name, { id: `pool-${name}`, path: `/repos/${name}`, repoPath: `/repos/${name}`, storagePath: `/repos/${name}/.gitnexus`, }, ]), ); return async (registryName: string): Promise => byName.get(registryName) ?? null; }; /** `initLbug` is called with the pool id, so failures can be keyed on the repo. */ const failInitFor = (failingPoolIds: ReadonlySet) => async (poolId: unknown) => { if (failingPoolIds.has(String(poolId))) throw new Error(LBUG_VERSION_ERROR); }; const PRIOR_REGISTRY = { version: 1, generatedAt: '2026-01-01T00:00:00.000Z', repoSnapshots: {}, missingRepos: [], contracts: [{ contractId: 'http::GET::/api/users' }], crossLinks: [{ contractId: 'http::GET::/api/users' }], }; /** * The one warning that describes the RUN rather than a single repo: it is the * only record carrying the whole-run repo lists. The per-repo load failures * logged beside it carry `repo` / `groupPath` instead, so selecting on the list * field cannot pick one of those up by accident. */ const totalFailureWarning = (cap: ReturnType) => cap.records().find((r) => r.level === 40 && Array.isArray(r.unreadableRepos)); /** * Read a file's bytes and its stat through ONE open handle. * * `stat(path)` followed by `readFile(path)` is two independent path * resolutions with a window between them — a real check-then-use race, and one * CodeQL flags as `js/file-system-race`. It also makes the assertion weaker * than it reads: the two calls can land on different inodes, so "the bytes and * the mtime are both unchanged" would not actually be a statement about one * file. Since these tests exist to prove a specific file was left alone, that * distinction is the whole point rather than a technicality. * * One handle, both answers, no second lookup. */ const snapshotFile = async ( filePath: string, ): Promise<{ text: string; size: number; mtimeMs: number }> => { const handle = await fsp.open(filePath, 'r'); try { const [bytes, stat] = await Promise.all([handle.readFile(), handle.stat()]); return { text: bytes.toString('utf8'), size: stat.size, mtimeMs: stat.mtimeMs }; } finally { await handle.close(); } }; describe('syncGroup with an unreadable index', () => { let groupDir: string; beforeEach(() => { initLbugMock.mockReset(); readRegistryStrictMock.mockReset(); readRegistryStrictMock.mockResolvedValue(REGISTRY); // `mockClear`, not `mockReset`: the lenient answer IS its implementation // (see its declaration), so resetting would erase the very behaviour that // makes calling it distinguishable from calling the strict one. readRegistryLenientMock.mockClear(); groupDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-unreadable-')); }); afterEach(() => { fs.rmSync(groupDir, { recursive: true, force: true }); }); it('reports an unopenable index as unreadable, not missing', async () => { initLbugMock.mockRejectedValue(new Error(LBUG_VERSION_ERROR)); const result = await syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { skipWrite: true, }); expect(result.unreadableRepos).toEqual(['app/backend']); expect(result.failedRepos).toEqual([{ repo: 'app/backend', reason: LBUG_VERSION_ERROR }]); // The repo IS registered — calling it "missing" sends the operator to // `gitnexus analyze` for a problem that indexing will not fix. expect(result.missingRepos).toEqual([]); }); it('still reports a genuinely unregistered repo as missing', async () => { const result = await syncGroup(makeConfig({ 'app/ghost': 'not-in-registry' }), { skipWrite: true, }); expect(result.missingRepos).toEqual(['app/ghost']); expect(result.unreadableRepos).toEqual([]); }); it('logs the underlying load error, with the repo it belongs to', async () => { initLbugMock.mockRejectedValue(new Error(LBUG_VERSION_ERROR)); const cap = _captureLogger(); try { await syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { skipWrite: true }); } finally { cap.restore(); } // The whole point of the change is that this error reaches the operator. // Asserting only on `unreadableRepos` left both `logger.warn` calls // deletable with every test still green. const warnings = cap.records().filter((r) => r.level === 40); const loadFailure = warnings.find((r) => String(r.repo ?? '') === 'backend-repo'); expect(loadFailure).toBeDefined(); expect(String(loadFailure?.groupPath)).toBe('app/backend'); expect(JSON.stringify(loadFailure?.err)).toContain('Current build storage version'); }); it('preserves the previous contracts and refreshes the diagnostics when nothing could be read', async () => { initLbugMock.mockRejectedValue(new Error(LBUG_VERSION_ERROR)); const contractsPath = path.join(groupDir, 'contracts.json'); fs.writeFileSync(contractsPath, JSON.stringify(PRIOR_REGISTRY)); const result = await syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { groupDir }); expect(result.unreadableRepos).toEqual(['app/backend']); expect(result.registryOutcome).toBe('preserved'); const onDisk = JSON.parse(fs.readFileSync(contractsPath, 'utf8')) as Record; // The contracts are the previous run's and are kept verbatim — an // extraction that read nothing is not evidence that the group has none. expect(onDisk.contracts).toEqual(PRIOR_REGISTRY.contracts); expect(onDisk.crossLinks).toEqual(PRIOR_REGISTRY.crossLinks); // `generatedAt` dates the contracts, which did not change, so it does not // move either — otherwise `group status` would claim this run produced them. expect(onDisk.generatedAt).toBe(PRIOR_REGISTRY.generatedAt); // ...but the diagnostic describing THIS run is refreshed, which is what // makes `gitnexus group status` able to explain the failure afterwards. expect(onDisk.unreadableRepos).toEqual(['app/backend']); }); it('keeps the prior suppressedMatchStages instead of stamping this run request', async () => { // The preserved registry describes an EARLIER sync. If this run's request // were stamped onto it, a graph narrowed by `--exact-only` would be // relabelled complete the moment a later plain sync failed to read // anything — and `group_impact` reads exactly that field to decide whether // its answer is a floor. initLbugMock.mockRejectedValue(new Error(LBUG_VERSION_ERROR)); const contractsPath = path.join(groupDir, 'contracts.json'); fs.writeFileSync( contractsPath, JSON.stringify({ ...PRIOR_REGISTRY, suppressedMatchStages: ['wildcard'] }), ); // This run asks for NO suppression, and fails to read anything. const result = await syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { groupDir }); expect(result.registryOutcome).toBe('preserved'); // The result describes THIS run — it really did suppress nothing. expect(result.suppressedMatchStages).toEqual([]); const onDisk = JSON.parse(fs.readFileSync(contractsPath, 'utf8')) as Record; // The file still describes the sync that produced its contracts. expect(onDisk.suppressedMatchStages).toEqual(['wildcard']); }); it('does not stamp this run request onto the preserved bridge metadata', async () => { // Same property one artifact over. `bridge.lbug` is untouched on this path, // so its meta.json must keep describing the sync that built it; otherwise // contracts.json, meta.json and the database describe three different runs. initLbugMock.mockRejectedValue(new Error(LBUG_VERSION_ERROR)); fs.writeFileSync(path.join(groupDir, 'contracts.json'), JSON.stringify(PRIOR_REGISTRY)); await writeBridgeMeta(groupDir, { version: 1, generatedAt: '2026-01-01T00:00:00.000Z', missingRepos: [], unreadableRepos: [], suppressedMatchStages: ['wildcard'], }); await syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { groupDir }); const meta = await readBridgeMeta(groupDir); expect(meta.suppressedMatchStages).toEqual(['wildcard']); // ...while the diagnostics describing THIS run are refreshed, as before. expect(meta.unreadableRepos).toEqual(['app/backend']); }); it('writes nothing at all when there is no previous registry to preserve', async () => { initLbugMock.mockRejectedValue(new Error(LBUG_VERSION_ERROR)); const result = await syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { groupDir }); // NOT `preserved`. Nothing exists to preserve, and the CLI turns that word // into "the contracts from the previous sync are preserved" — which sends // an operator whose group has never synced looking for a file that has // never existed. Same class of confident-wrong-answer as the rest of this. expect(result.registryOutcome).toBe('no-prior-registry'); expect(fs.existsSync(path.join(groupDir, 'contracts.json'))).toBe(false); }); it('reports `preserved` only when a prior registry was actually refreshed', async () => { initLbugMock.mockRejectedValue(new Error(LBUG_VERSION_ERROR)); fs.writeFileSync(path.join(groupDir, 'contracts.json'), JSON.stringify(PRIOR_REGISTRY)); const result = await syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { groupDir }); expect(result.registryOutcome).toBe('preserved'); }); it('does not report `preserved` when the prior registry will not parse', async () => { // An unparseable prior is not a thing that got carried forward either. initLbugMock.mockRejectedValue(new Error(LBUG_VERSION_ERROR)); fs.writeFileSync(path.join(groupDir, 'contracts.json'), '{"truncated": '); const result = await syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { groupDir }); expect(result.registryOutcome).toBe('no-prior-registry'); // ...and the unparseable file is left exactly as it was, not replaced. expect(fs.readFileSync(path.join(groupDir, 'contracts.json'), 'utf8')).toBe('{"truncated": '); }); it('names the previous sync in the total-failure warning when a prior registry was kept', async () => { // This warning used to be emitted BEFORE the prior registry was resolved, // so it promised "the contracts from the previous sync" without knowing // whether there were any. This is the branch on which that promise is true. initLbugMock.mockRejectedValue(new Error(LBUG_VERSION_ERROR)); fs.writeFileSync(path.join(groupDir, 'contracts.json'), JSON.stringify(PRIOR_REGISTRY)); const cap = _captureLogger(); let result; try { result = await syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { groupDir }); } finally { cap.restore(); } const warning = totalFailureWarning(cap); expect(warning).toBeDefined(); // The log and the console say the same thing about which of the two // happened: the CLI picks its sentence from `registryOutcome`, and this is // the outcome whose sentence keeps the previous contracts. expect(result.registryOutcome).toBe('preserved'); expect(String(warning?.msg)).toContain('previous sync'); // Still a warning, still carrying the lists that name the cause. expect(warning?.level).toBe(40); expect(warning?.unreadableRepos).toEqual(['app/backend']); expect(warning?.missingRepos).toEqual([]); }); it('does not claim anything was preserved when there is no prior registry', async () => { // The same total failure with nothing on disk to preserve. The warning said // the contracts from the previous sync were being kept — to an operator // whose group has never synced, about a file that has never existed, while // the console line for this same run says the opposite. What the message // says about disk has to be what happened on it. initLbugMock.mockRejectedValue(new Error(LBUG_VERSION_ERROR)); const cap = _captureLogger(); let result; try { result = await syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { groupDir }); } finally { cap.restore(); } const warning = totalFailureWarning(cap); expect(warning).toBeDefined(); expect(result.registryOutcome).toBe('no-prior-registry'); expect(String(warning?.msg)).not.toMatch(/previous sync|preserv|keeping|kept/i); expect(String(warning?.msg)).toContain('no previous contracts.json'); // ...and it is still a warning carrying the same lists as the branch above. expect(warning?.level).toBe(40); expect(warning?.unreadableRepos).toEqual(['app/backend']); expect(warning?.missingRepos).toEqual([]); }); it('still writes when only SOME configured repos are unreadable', async () => { // The case that pins the word "every" in `everyRepoFailed`. With a single // configured repo, "every repo failed" and "any repo failed" are the same // predicate, so the guard could be widened to abort on a single skewed repo // in a five-repo group — silently freezing contracts.json forever — with // nothing going red. initLbugMock.mockImplementation(failInitFor(new Set(['pool-backend-repo']))); const result = await syncGroup( makeConfig({ 'app/backend': 'backend-repo', 'app/web': 'web-repo' }), { groupDir, resolveRepoHandle: handleTable(['backend-repo', 'web-repo']) }, ); expect(result.unreadableRepos).toEqual(['app/backend']); expect(result.missingRepos).toEqual([]); expect(result.registryOutcome).toBe('written'); const onDisk = JSON.parse( fs.readFileSync(path.join(groupDir, 'contracts.json'), 'utf8'), ) as Record; // The partial result records which repo is unaccounted for, so a reader of // contracts.json can tell a small registry from a complete one. expect(onDisk.unreadableRepos).toEqual(['app/backend']); }); it('records an empty unreadable list on a clean sync, not an absent one', async () => { // `[]` is a measurement — "this sync accounted for every repo" — and it is // a different claim from a registry that never recorded the field. Omitting // the empty case made that state unreachable: every clean sync wrote a // registry whose `unreadableRepos` was absent, so `gitnexus group status` // reported it as not recorded and told the operator to re-run the sync that // had just succeeded. const result = await syncGroup( makeConfig({ 'app/backend': 'backend-repo', 'app/web': 'web-repo' }), { groupDir, resolveRepoHandle: handleTable(['backend-repo', 'web-repo']) }, ); expect(result.unreadableRepos).toEqual([]); expect(result.registryOutcome).toBe('written'); const onDisk = JSON.parse( fs.readFileSync(path.join(groupDir, 'contracts.json'), 'utf8'), ) as Record; expect(onDisk).toHaveProperty('unreadableRepos'); expect(onDisk.unreadableRepos).toEqual([]); }); it('still writes when every repo is merely MISSING and none failed to load', async () => { // A group whose repos were all deregistered legitimately syncs to empty. // The guard must stay off here: it is gated on a load ERROR, not on an // empty result. Dropping the `unreadableRepos.length > 0` conjunct would // turn a deliberate deregistration into a registry frozen forever. const result = await syncGroup( makeConfig({ 'app/ghost': 'not-in-registry', 'app/phantom': 'also-absent' }), { groupDir }, ); expect(result.unreadableRepos).toEqual([]); expect(result.missingRepos).toEqual(['app/ghost', 'app/phantom']); expect(result.registryOutcome).toBe('written'); expect(fs.existsSync(path.join(groupDir, 'contracts.json'))).toBe(true); }); it('does not claim to preserve a file on a dry run', async () => { initLbugMock.mockRejectedValue(new Error(LBUG_VERSION_ERROR)); const cap = _captureLogger(); let result; try { result = await syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { skipWrite: true }); } finally { cap.restore(); } expect(result.registryOutcome).toBe('not-attempted'); // The total-failure warning talks about an existing contracts.json. A // caller that asked not to write may not even have a group directory, so // telling it the file was left untouched describes a file that need not // exist. // Selected on the sentence both total-failure messages share, so this stays // decisive whichever of the two the persisting path would have emitted. const totalFailureWarnings = cap .records() .filter((r) => String(r.msg ?? '').includes('No repo in this group could be read')); expect(totalFailureWarnings).toEqual([]); }); it('refuses to sync when the global registry cannot be read', async () => { // `readRegistry` swallows every failure and returns `[]`, so an EACCES or a // truncated registry.json presented as "no repo is registered": every // configured repo 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 change fixes. // // Only the STRICT export is armed to reject. The lenient one is a separate // mock answering `[]` — production's own lenient behaviour — so a `syncGroup` // reading through it never sees this failure at all: it would sync a group // whose every repo is "unregistered" and overwrite the prior registry, which // is what makes each of the three assertions below a statement about which // read was used rather than about EACCES. const eacces = Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }); readRegistryStrictMock.mockRejectedValue(eacces); const contractsPath = path.join(groupDir, 'contracts.json'); fs.writeFileSync(contractsPath, JSON.stringify(PRIOR_REGISTRY)); await expect( syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { groupDir }), ).rejects.toThrow('EACCES'); expect(JSON.parse(fs.readFileSync(contractsPath, 'utf8'))).toEqual(PRIOR_REGISTRY); // The direct form of the same claim, so a regression names itself instead of // arriving as "expected a rejection, got a resolved sync". expect(readRegistryLenientMock).not.toHaveBeenCalled(); expect(readRegistryStrictMock).toHaveBeenCalled(); }); }); /** * The preserve path rewrites `contracts.json` and deliberately does NOT rebuild * `bridge.lbug` — the contracts that bridge holds are the ones being preserved, * so rebuilding it from an extraction that read nothing is the one write that * could lose them. * * But `meta.json`, not `contracts.json`, is where `runGroupImpact` reads * completeness from. Leaving it alone therefore left the two files telling * different stories: `contracts.json` said "this sync could not read app/backend" * while a cross-repo query, reading the previous sync's metadata, answered * `{ cross: [], truncated: false }` — fully accounted for. That is a confident * wrong answer about the exact thing the completeness channel exists to make * legible, and it is what R6 forbids. * * Refreshing the metadata is not free, though, and the naive version of it is * worse than the bug. This file rewrites `meta.json` ATOMICALLY, so its mtime * becomes now while `bridge.lbug`'s stays old — which is precisely the shape * the unstamped write-order rule ACCEPTS. A refresh that just carried the old * fields forward would therefore LAUNDER a pair that was already broken into * one that passes `bridgeMetaMatchesFile`. Hence the explicit marker, and hence * the cases below that pin a broken pair as still broken afterwards. */ describe('the preserve path and the bridge metadata beside it', () => { let home: string; let groupDir: string; let dbPath: string; let metaPath: 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'); const PRIOR_META_GENERATED_AT = '2026-01-01T00:00:00.000Z'; beforeEach(async () => { initLbugMock.mockReset(); readRegistryStrictMock.mockReset(); readRegistryStrictMock.mockResolvedValue(REGISTRY); home = await fsp.mkdtemp(path.join(os.tmpdir(), 'gitnexus-preserve-bridge-')); groupDir = path.join(home, 'groups', 'waveful'); dbPath = path.join(groupDir, 'bridge.lbug'); metaPath = path.join(groupDir, 'meta.json'); await writeGroupYaml(groupDir, ['app/backend']); }); afterEach(async () => { await closeAllCachedBridges(); await fsp.rm(home, { recursive: true, force: true }); }); const seedPriorRegistry = (): Promise => fsp.writeFile(path.join(groupDir, 'contracts.json'), JSON.stringify(PRIOR_REGISTRY)); /** A stamped pair that matches: what a successful `writeBridge` leaves behind. */ const seedMatchingPair = async (): Promise => { await fsp.writeFile(dbPath, 'the previous sync database'); const stat = await fsp.stat(dbPath); await writeBridgeMeta(groupDir, { version: BRIDGE_SCHEMA_VERSION, generatedAt: PRIOR_META_GENERATED_AT, bridgeSize: stat.size, bridgeMtimeMs: stat.mtimeMs, missingRepos: [], unreadableRepos: [], }); }; /** * The legacy shape, broken: metadata with no stamp sitting beside a database * that was replaced after it. `unstampedMetaPairsByWriteOrder` is the ONLY * thing that can see this, and it sees it purely through the two file times — * which is why an atomic rewrite of `meta.json` erases the evidence. */ const seedUnstampedPairWithNewerDatabase = async (): Promise => { await fsp.writeFile(dbPath, 'a database swapped in after the metadata'); await writeBridgeMeta(groupDir, { version: BRIDGE_SCHEMA_VERSION, generatedAt: PRIOR_META_GENERATED_AT, missingRepos: [], unreadableRepos: [], }); await fsp.utimes(metaPath, WRITTEN_AT, WRITTEN_AT); await fsp.utimes(dbPath, TEN_SECONDS_LATER, TEN_SECONDS_LATER); }; const runTotalFailureSync = () => { initLbugMock.mockRejectedValue(new Error(LBUG_VERSION_ERROR)); return syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { groupDir }); }; const runSuccessfulSync = () => { initLbugMock.mockReset(); return syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { groupDir, resolveRepoHandle: handleTable(['backend-repo']), }); }; const runImpact = () => runGroupImpact( { port: makeGroupToolPort(home), gitnexusDir: home }, { name: 'waveful', repo: 'app/backend', target: 'publish', direction: 'upstream' }, ); const pairsAfterwards = async (): Promise => bridgeMetaMatchesFile(groupDir, await readBridgeMeta(groupDir)); it('reports the repos this sync could not read as a lower bound on the next cross-repo query', async () => { // The headline case, and the one that makes `contracts.json` and `group // impact` describe the same set of unaccounted repos. Every other signal // says "complete": the local walk finished, the bridge returned no // crossings, no cap and no clock fired. await seedPriorRegistry(); await seedMatchingPair(); const result = await runTotalFailureSync(); expect(result.registryOutcome).toBe('preserved'); const impact = await runImpact(); expect(impact).toMatchObject({ cross: [], truncated: true, truncationReason: 'incomplete-sync', riskEpistemic: 'lower-bound', truncatedRepos: ['app/backend'], }); }); it('leaves the bridge database itself byte-for-byte untouched', async () => { // The contracts this bridge holds are the ones being preserved. A refresh // that rebuilt it would be the single write capable of losing them. await seedPriorRegistry(); await seedMatchingPair(); const before = await snapshotFile(dbPath); await runTotalFailureSync(); const after = await snapshotFile(dbPath); expect(after.text).toBe(before.text); expect(after.size).toBe(before.size); expect(after.mtimeMs).toBe(before.mtimeMs); }); it('keeps a stamped pair that already failed the check failing, with its stamp untouched', async () => { // Re-stamping here would MANUFACTURE provenance: it would declare that this // metadata describes the database beside it, which is the one thing the // failed check just said is not known. The stamp fields are carried through // verbatim instead — dropping them would leave an unstamped file whose // freshly-moved mtime the write-order rule then accepts. await seedPriorRegistry(); await fsp.writeFile(dbPath, 'a database this metadata was never written for'); await writeBridgeMeta(groupDir, { version: BRIDGE_SCHEMA_VERSION, generatedAt: PRIOR_META_GENERATED_AT, bridgeSize: 999_999, bridgeMtimeMs: 1_700_000_000_000, missingRepos: [], unreadableRepos: [], }); expect(await pairsAfterwards()).toBe(false); await runTotalFailureSync(); const after = await readBridgeMeta(groupDir); expect(after.bridgeSize).toBe(999_999); expect(after.bridgeMtimeMs).toBe(1_700_000_000_000); expect(after.provenanceUnknown).toBe(true); expect(after.unreadableRepos).toEqual(['app/backend']); expect(await pairsAfterwards()).toBe(false); }); it('keeps an unstamped pair that already failed the check failing, though the rewrite moves meta.json to now', async () => { // The laundering case, and the only shape that exercises the write-order // rule. Before the sync the database is NEWER than the metadata beside it, // which is the inverted write order that rule rejects. The atomic rewrite // then makes `meta.json` the newer file — the exact shape it ACCEPTS — so // without an explicit marker a preserve sync would hand back "verified" for // a pair it just found broken. await seedPriorRegistry(); await seedUnstampedPairWithNewerDatabase(); expect(await pairsAfterwards()).toBe(false); await runTotalFailureSync(); const dbStat = await fsp.stat(dbPath); const metaStat = await fsp.stat(metaPath); // The evidence the write-order rule reads has genuinely been inverted... expect(metaStat.mtimeMs).toBeGreaterThanOrEqual(dbStat.mtimeMs); const after = await readBridgeMeta(groupDir); // ...no stamp was invented to replace it... expect(after.bridgeSize).toBeUndefined(); expect(after.bridgeMtimeMs).toBeUndefined(); // ...and the verdict survives in the metadata, which is the only place it // can, because the refresh cannot avoid moving the mtime. expect(after.provenanceUnknown).toBe(true); expect(await pairsAfterwards()).toBe(false); const impact = await runImpact(); expect(impact).toMatchObject({ truncated: true, truncationReason: 'incomplete-sync', riskEpistemic: 'lower-bound', }); }); it('never writes either reader-side field back into meta.json', async () => { // `repoListsUnreadable` and `pairedWithDatabase` are things a READER // computes ABOUT a file; both are documented NEVER PERSISTED. This path is // the first code to read metadata and write it back, so a naive // `writeBridgeMeta(await readBridgeMeta(dir))` persists whichever of them // the read produced. A stale `pairedWithDatabase: true` on disk is actively // poisonous: it tells every future reader the pair was verified. await seedPriorRegistry(); await fsp.writeFile(dbPath, 'db'); await fsp.writeFile( metaPath, JSON.stringify({ version: BRIDGE_SCHEMA_VERSION, generatedAt: PRIOR_META_GENERATED_AT, // Not a list of repo paths, so `readBridgeMeta` answers with // `repoListsUnreadable: true` on the object this path then rewrites. missingRepos: { 'app/backend': true }, // A foreign writer, a hand-edit, or an earlier naive round-trip. pairedWithDatabase: true, }), ); await runTotalFailureSync(); const raw = JSON.parse(await fsp.readFile(metaPath, 'utf8')) as Record; expect(raw).not.toHaveProperty('pairedWithDatabase'); expect(raw).not.toHaveProperty('repoListsUnreadable'); // ...and the unusable list was replaced by this run's real measurement, // rather than being carried forward as garbage. expect(raw.missingRepos).toEqual([]); expect(raw.unreadableRepos).toEqual(['app/backend']); }); it('completes when there is no bridge.lbug for the metadata to describe', async () => { // `writeBridge` can leave this behind: the old database is renamed aside // and the new one never arrives. The refresh must not stat a file that is // not there, and must not vouch for one either. await seedPriorRegistry(); await writeBridgeMeta(groupDir, { version: BRIDGE_SCHEMA_VERSION, generatedAt: PRIOR_META_GENERATED_AT, bridgeSize: 4096, bridgeMtimeMs: 1_700_000_000_000, missingRepos: [], unreadableRepos: [], }); const result = await runTotalFailureSync(); expect(result.registryOutcome).toBe('preserved'); expect(fs.existsSync(dbPath)).toBe(false); const after = await readBridgeMeta(groupDir); expect(after.provenanceUnknown).toBe(true); // Carried through verbatim: the stamp still records which database this // metadata was written for, which is information, not a claim about what // is on disk now. expect(after.bridgeSize).toBe(4096); expect(after.bridgeMtimeMs).toBe(1_700_000_000_000); expect(after.unreadableRepos).toEqual(['app/backend']); // The query that follows answers rather than throwing. With no database // there is nothing to answer FROM, so it names the missing file and sends // the operator to `group sync` — never a confident "nothing depends on // this". (The lower-bound answer is the shape above, where a database IS // present and the marker is what stops it being trusted.) const impact = await runImpact(); expect(impact).toMatchObject({ error: expect.stringContaining('No bridge.lbug') }); }); it('does not manufacture metadata for a bridge that has never existed', async () => { // Neither file is on disk, so there is no pair that could disagree with // anything and nothing to keep honest. `readBridgeMeta` already answers // `version: 0` — provenance unknown — for an absent file, and writing a // `version: 0` file that says the same thing only invents state. await seedPriorRegistry(); await runTotalFailureSync(); expect(fs.existsSync(metaPath)).toBe(false); expect(fs.existsSync(dbPath)).toBe(false); }); it('records this run against a database whose metadata is missing entirely', async () => { // The other half of the pair being absent. The database is real and the // metadata is gone — provenance is already unknown, and staying silent // costs the operator the NAMES of the repos this run could not read. await seedPriorRegistry(); await fsp.writeFile(dbPath, 'a database with no metadata beside it'); await runTotalFailureSync(); const after = await readBridgeMeta(groupDir); expect(after.provenanceUnknown).toBe(true); expect(after.unreadableRepos).toEqual(['app/backend']); expect(await pairsAfterwards()).toBe(false); }); it('does not launder the pair it already marked on a second preserve run', async () => { // The invariant in its strongest form: no preserve run ever increases the // number of pairs that pass the check. The second run reads metadata that // now carries the marker, and the marker has to survive its own rewrite. await seedPriorRegistry(); await seedUnstampedPairWithNewerDatabase(); await runTotalFailureSync(); expect(await pairsAfterwards()).toBe(false); await runTotalFailureSync(); const after = await readBridgeMeta(groupDir); expect(after.provenanceUnknown).toBe(true); expect(await pairsAfterwards()).toBe(false); }); it('clears the marker on the next successful sync', async () => { // Nothing clears the marker deliberately: a successful `writeBridge` // builds fresh metadata from a literal and simply never sets the field. // That is what keeps a marked bridge from being marked forever. await seedPriorRegistry(); await seedUnstampedPairWithNewerDatabase(); await runTotalFailureSync(); expect((await readBridgeMeta(groupDir)).provenanceUnknown).toBe(true); const ok = await runSuccessfulSync(); expect(ok.registryOutcome).toBe('written'); const after = await readBridgeMeta(groupDir); expect(after.provenanceUnknown).toBeUndefined(); expect(await pairsAfterwards()).toBe(true); }); it('control: a successful sync writes a pair that passes the check, unmarked', async () => { // Without this, "the marker is absent after a good sync" could be true // because the marker is absent from everything. const ok = await runSuccessfulSync(); expect(ok.registryOutcome).toBe('written'); const after = await readBridgeMeta(groupDir); expect(after.provenanceUnknown).toBeUndefined(); expect(after.unreadableRepos).toEqual([]); expect(await pairsAfterwards()).toBe(true); }); }); /** * The branch taken when `writeBridge` throws. `contracts.json` has already been * written and is canonical by then, so the failure is a recoverable degradation * — and the warning is the ONLY thing this branch produces. The return value, * `registryOutcome` and every file on disk are identical whether the sentence * is true or not, which is why the text needs an assertion of its own instead of * borrowing a state assertion from elsewhere in this file. */ describe('the warning after a failed bridge write', () => { let groupDir: string; beforeEach(() => { initLbugMock.mockReset(); readRegistryStrictMock.mockReset(); readRegistryStrictMock.mockResolvedValue(REGISTRY); writeBridgeFailure = null; groupDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-bridge-write-')); }); afterEach(async () => { writeBridgeFailure = null; await closeAllCachedBridges(); fs.rmSync(groupDir, { recursive: true, force: true }); }); const runSync = () => syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { groupDir, resolveRepoHandle: handleTable(['backend-repo']), }); /** The bridge-failure warning is the only record carrying `groupDir`. */ const bridgeWarning = (cap: ReturnType) => cap.records().find((r) => r.level === 40 && typeof r.groupDir === 'string'); it('withdraws the old bridge provenance when the registry advanced but the bridge write failed', async () => { // The split-brain this guards: contracts.json commits, the bridge write // then fails, and the previous database stays in place describing an // EARLIER sync. Left vouching for itself, `group_impact` traverses that // older graph and calls its answer complete while `group_contracts` // reports the new registry — two public surfaces, contradictory claims, // out of one sync. Marking provenance unknown withdraws the completeness // claim without deleting a graph still useful as a floor. // `unreadableRepos` is deliberately UNREADABLE here, not merely empty: that // is what makes `readBridgeMeta` set the reader-only `repoListsUnreadable` // on what it returns, so the assertion below can actually catch a // read-modify-write writer round-tripping it back to disk. Seeded with a // valid list the check passes whether or not the strip exists. fs.writeFileSync( path.join(groupDir, 'meta.json'), JSON.stringify({ version: 1, generatedAt: '2026-01-01T00:00:00.000Z', missingRepos: [], unreadableRepos: 'not-a-list', }), ); expect((await readBridgeMeta(groupDir)).repoListsUnreadable).toBe(true); expect((await readBridgeMeta(groupDir)).provenanceUnknown).toBeUndefined(); writeBridgeFailure = new Error('ENOSPC: no space left on device'); await runSync(); expect((await readBridgeMeta(groupDir)).provenanceUnknown).toBe(true); // ...and the withdrawal must not persist the reader-only fields. // `readBridgeMeta` sets both on what it returns, so a read-modify-write // writer round-trips them unless the write boundary strips them. // `pairedWithDatabase` is the poisonous one: persisted, it would tell every // later reader the pair was verified when nothing verified it. const raw = JSON.parse(fs.readFileSync(path.join(groupDir, 'meta.json'), 'utf8')) as Record< string, unknown >; expect(raw).not.toHaveProperty('pairedWithDatabase'); expect(raw).not.toHaveProperty('repoListsUnreadable'); }); // control: a sync whose bridge write SUCCEEDS must not withdraw provenance — // otherwise every healthy sync would report its own answers as a floor. it('control: a successful bridge write leaves provenance intact', async () => { await runSync(); expect((await readBridgeMeta(groupDir)).provenanceUnknown).toBeFalsy(); }); it('names the registry as intact and does not promise a truncation this branch never reports', async () => { writeBridgeFailure = new Error('ENOSPC: no space left on device'); const cap = _captureLogger(); let result; try { result = await runSync(); } finally { cap.restore(); } // The registry write happens before the bridge write and is not rolled back // — the reason this failure is survivable at all. expect(result.registryOutcome).toBe('written'); const warning = bridgeWarning(cap); expect(warning).toBeDefined(); expect(String(warning?.msg)).toContain('contracts.json is intact'); // The claim the code does not keep. A failed `writeBridge` leaves the // PREVIOUS sync's database and the metadata stamped for it untouched, so the // next cross-repo query reads a pair that checks out, finds no unreadable // repos recorded in it, and answers `truncated: false` — from contracts this // sync has already superseded. Nothing on this branch marks the bridge at // all, so telling the operator to wait for `truncated` is telling them to // wait for a signal that is never coming. expect(String(warning?.msg)).not.toMatch(/truncat/i); // What the code does guarantee instead: the registry is the good copy, the // bridge may still answer from the previous sync, and only another sync // replaces it. expect(String(warning?.msg)).toMatch(/previous sync/i); expect(String(warning?.msg)).toContain('group sync'); // ...and the underlying failure still reaches the operator. expect(String(warning?.err)).toContain('ENOSPC'); expect(result.warnings).toEqual([expect.stringContaining('writeBridge failed')]); expect(result.warnings[0]).toMatch(/previous sync/i); }); it('control: a sync whose bridge write succeeds emits no such warning', async () => { // Without this, "the warning does not promise a truncation" could be true // because no warning is emitted on any run at all. const cap = _captureLogger(); let result; try { result = await runSync(); } finally { cap.restore(); } expect(result.registryOutcome).toBe('written'); expect(bridgeWarning(cap)).toBeUndefined(); }); }); /** * R9, the half a lock does not fix: serializing is not ordering. * * Both syncs run EXTRACTION outside the lock and only the persist section * inside it, so a total-failure sync that queues behind a healthy one arrives at * the critical section holding a snapshot of a group it read minutes ago. The * preserve path then re-reads `contracts.json` as `prior` — and the file it * finds is the winner's, written while this run waited — and stamps its own * all-unreadable lists over it. That is not a rare interleave: it is what * happens every time the total-failure sync loses the race, and it downgrades a * registry that describes repos that were readable seconds earlier. * * The guard is a compare-and-swap on the prior file's own identity: stat it * BEFORE acquiring, re-stat AFTER, and skip the diagnostic refresh when the two * differ. Keyed on the file, not on `generatedAt`: that field is stamped when * the registry object is built (before the lock), so a winner that waited writes * one OLDER than the loser's start, and the preserve path carries it forward * verbatim by design — after any preserve sync it does not date the write at * all. File identity also needs no cross-process clock agreement and has no * undefined case for an absent or unparseable timestamp. */ describe('a total-failure sync that reaches the group lock second', () => { let groupDir: string; let contractsPath: string; let dbPath: string; let metaPath: string; /** What the sync that won the lock wrote while this one was still waiting. */ const WINNER_REGISTRY = { version: 1, generatedAt: '2026-02-02T00:00:00.000Z', repoSnapshots: {}, missingRepos: [], unreadableRepos: [], contracts: [{ contractId: 'http::GET::/api/users' }, { contractId: 'http::POST::/api/users' }], crossLinks: [{ contractId: 'http::GET::/api/users' }], }; beforeEach(() => { initLbugMock.mockReset(); readRegistryStrictMock.mockReset(); readRegistryStrictMock.mockResolvedValue(REGISTRY); whileWaitingForTheGroupLock = null; groupDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-second-')); contractsPath = path.join(groupDir, 'contracts.json'); dbPath = path.join(groupDir, 'bridge.lbug'); metaPath = path.join(groupDir, 'meta.json'); }); afterEach(async () => { whileWaitingForTheGroupLock = null; await closeAllCachedBridges(); fs.rmSync(groupDir, { recursive: true, force: true }); }); const runTotalFailureSync = () => { initLbugMock.mockRejectedValue(new Error(LBUG_VERSION_ERROR)); return syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { groupDir }); }; const seedPriorRegistry = (): void => fs.writeFileSync(contractsPath, JSON.stringify(PRIOR_REGISTRY)); /** The winner's write, landing while this sync waits on the lock. */ const winnerWritesTheRegistry = async (): Promise => { fs.writeFileSync(contractsPath, JSON.stringify(WINNER_REGISTRY)); }; const readOnDisk = (): Record => JSON.parse(fs.readFileSync(contractsPath, 'utf8')) as Record; it('leaves the registry the winning sync wrote exactly as it found it, and reports superseded', async () => { seedPriorRegistry(); whileWaitingForTheGroupLock = winnerWritesTheRegistry; const result = await runTotalFailureSync(); // Byte-identical to what the winner wrote. NOT the winner's contracts with // this run's all-unreadable list stamped over them, which is what re-reading // `prior` inside the lock produces — a registry that says every repo in the // group is unreadable, written on top of a sync that had just read them. expect(fs.readFileSync(contractsPath, 'utf8')).toBe(JSON.stringify(WINNER_REGISTRY)); expect(readOnDisk().unreadableRepos).toEqual([]); // The existing outcome, not a new one: nothing was written and a prior // registry was kept, which is exactly what `preserved` already means. A new // value would fall through `cli/group.ts`'s outcome chain, which has no // fallback branch, and falsify the guard asserting the sync tool's // description names every reachable outcome. expect(result.registryOutcome).toBe('superseded'); // ...and the caller still learns what THIS run could not read. expect(result.unreadableRepos).toEqual(['app/backend']); }); it('treats a registry that was absent before the lock and present after as changed', async () => { // No prior file at all when this sync stat'd: on its own reading it was // heading for `no-prior-registry` (write nothing), and then found a registry // to "refresh" — one belonging to a sync it never overlapped in extraction. whileWaitingForTheGroupLock = winnerWritesTheRegistry; const result = await runTotalFailureSync(); expect(fs.readFileSync(contractsPath, 'utf8')).toBe(JSON.stringify(WINNER_REGISTRY)); expect(readOnDisk().unreadableRepos).toEqual([]); expect(result.registryOutcome).toBe('superseded'); }); it('does not stamp this run into the bridge metadata beside the registry it skipped', async () => { // `meta.json` is where `runGroupImpact` reads completeness from, so writing // this run's lists there is the same downgrade one file over: it would // report repos as unaccounted for that the winning sync had just accounted // for. The refresh describes THIS run, and on this path this run is the // stale one — and `refreshPreservedBridgeMeta` moves meta.json's mtime and // can mark a pair `provenanceUnknown`, so it can only degrade a pair the // winner left consistent. Nothing is written, which is what makes // `preserved` an honest answer here. fs.writeFileSync(dbPath, 'the winning sync database'); const dbStat = fs.statSync(dbPath); await writeBridgeMeta(groupDir, { version: BRIDGE_SCHEMA_VERSION, generatedAt: '2026-02-02T00:00:00.000Z', bridgeSize: dbStat.size, bridgeMtimeMs: dbStat.mtimeMs, missingRepos: [], unreadableRepos: [], }); const metaBefore = await snapshotFile(metaPath); seedPriorRegistry(); whileWaitingForTheGroupLock = winnerWritesTheRegistry; await runTotalFailureSync(); const metaAfter = await snapshotFile(metaPath); expect(metaAfter.text).toBe(metaBefore.text); expect(metaAfter.mtimeMs).toBe(metaBefore.mtimeMs); const meta = await readBridgeMeta(groupDir); expect(meta.unreadableRepos).toEqual([]); expect(meta.provenanceUnknown).toBeUndefined(); }); it('refreshes as usual when it is the sync that got to the lock first', async () => { // The same interleaving in the other order: the other sync is still // extracting and has written nothing, so this run's stats match across the // acquisition and the diagnostic refresh — the entire point of the preserve // path — must still happen. A guard that fired on "a second sync exists" // rather than on "the file changed" would freeze the diagnostics of every // contended group. seedPriorRegistry(); let otherSyncStillExtracting = false; whileWaitingForTheGroupLock = async () => { otherSyncStillExtracting = true; }; const result = await runTotalFailureSync(); expect(otherSyncStillExtracting).toBe(true); expect(result.registryOutcome).toBe('preserved'); const onDisk = readOnDisk(); expect(onDisk.contracts).toEqual(PRIOR_REGISTRY.contracts); expect(onDisk.unreadableRepos).toEqual(['app/backend']); }); it('control: an uncontended sync sees identical stats and refreshes as usual', async () => { // Nothing armed at all, so the compare-and-swap runs over a file no one // else touched. Without this, every assertion above could be satisfied by a // guard that skipped the refresh on every run. seedPriorRegistry(); const result = await runTotalFailureSync(); expect(result.registryOutcome).toBe('preserved'); const onDisk = readOnDisk(); expect(onDisk.contracts).toEqual(PRIOR_REGISTRY.contracts); expect(onDisk.unreadableRepos).toEqual(['app/backend']); }); });