GitNexus/gitnexus/test/unit/group/bridge-db.test.ts
ivkond 1ff324ca16
feat(group): bridge.lbug storage + contract matching expansion (1/4 of #606 split) (#795)
* feat(group): bridge.lbug storage + contract matching expansion

Part 1 of 4 in the split of #606 (ticket: #791, closes #790 with a
revised plan per @magyargergo's request).

## What changed

Adds the LadybugDB-backed bridge storage infrastructure and extends
the contract matching algorithm with wildcard support. All changes are
additive: storage.ts, sync.ts, service.ts, cli/group.ts, mcp/tools.ts
are left on their upstream main versions and will migrate to the new
bridge in follow-up PRs (#792, #793, #794).

### Files

**New (844 LOC prod):**
- `gitnexus/src/core/group/bridge-db.ts` — atomic write-to-temp with
  `retryRename` for Windows EBUSY/EPERM, per-item write tolerance via
  `WriteBridgeReport`, `findContractNode` with three-tier symbol
  lookup (uid → filePath+name → filePath)
- `gitnexus/src/core/group/bridge-schema.ts` — schema DDL
- `gitnexus/src/core/group/normalization.ts` — contract ID
  canonicalization + `dedupeContracts` / `dedupeCrossLinks` helpers
  used by both matching and bridge write

**Modified (+137 LOC prod):**
- `gitnexus/src/core/group/matching.ts` — adds `runWildcardMatch` for
  `grpc::Service/*` wildcard consumers, `buildProviderIndex` helper,
  and canonical gRPC ID handling in `normalizeContractId`
- `gitnexus/src/core/group/types.ts` — `MatchType` gains `'wildcard'`;
  new `BridgeHandle` and `BridgeMeta` interfaces

**New tests (658 LOC):**
- `gitnexus/test/unit/group/bridge-db.test.ts` — core write/read round
  trip, `WriteBridgeReport` shape, dropped-links counter, retryRename
  behavior on EBUSY/ENOENT/EPERM/EACCES
- `gitnexus/test/unit/group/bridge-db-edge.test.ts` — edge cases
  (malformed meta, missing contract nodes, concurrent access)

**Modified tests (+225 LOC):**
- `gitnexus/test/unit/group/matching.test.ts` — wildcard consumer
  matching, gRPC canonical ID handling, same-service guard

### Self-review fixes folded in

Carried forward from the original #606 self-review:
- `writeBridge` try/finally handle lifecycle + `handleClosed` sentinel
- `openBridgeDbReadOnly` partial-handle cleanup
- `writeBridgeMeta` uses `retryRename` for Windows consistency
- `retryRename` unit tests (was zero coverage)
- Per-item try/catch around every CREATE loop so one malformed contract
  doesn't abort the whole write
- Dropped cross-link counter (`linksDroppedMissingNode`)

### Why now

magyargergo asked for the #606 PR to be split so we can iterate with
confidence (https://github.com/abhigyanpatwari/GitNexus/pull/606#issuecomment-4229612271).
This is the foundational layer — pure infra, no user-facing surface,
no callers of the new APIs in this PR. Later PRs wire it in.

### How to verify

- `cd gitnexus && npx tsc --noEmit`
- `cd gitnexus && npx vitest run test/unit/group/bridge-db.test.ts --pool=forks`
- `cd gitnexus && npx vitest run test/unit/group/bridge-db-edge.test.ts --pool=forks`
- `cd gitnexus && npx vitest run test/unit/group/matching.test.ts --pool=forks`
- Pre-commit hook runs clean

### Risk / rollback

**Low.** All new code sits under `src/core/group/` in new files plus a
minimal `+16/-1` diff to `types.ts` and a `+136/-0` diff to `matching.ts`
(both purely additive). No existing callers reference the new APIs
(bridge-db, openBridgeOrFallback, runWildcardMatch) — the PRs that wire
them in come later in the split chain. Rollback = `git revert` of the
merge commit; no state introduced, no schema migration triggered.

### Scope discipline (per GUARDRAILS.md)

- Only the 8 files listed above are touched; no drive-by refactors
- No CI/release/security config changes
- No secrets, tokens, or machine-specific paths
- Content is lifted from the #606 branch which already passed CI 11/11
  green on `d15b8cb` (before the split)

### Dependencies

- **Base:** `main` (no dependencies on other split PRs)
- **Blocks:** extractor expansion (#792), sync pipeline (#793),
  cross-impact feature (#794)
- **Related ticket:** #791

Co-authored-by: Claude <noreply@anthropic.com>

* fix(group): address @claude review on #795

Addresses the findings from the automated review on PR #795
(https://github.com/abhigyanpatwari/GitNexus/pull/795#issuecomment-4229770000
— posted by @magyargergo / claude-code Action run).

### Medium severity (reviewer flagged as blockers)

- **bridge-db.ts `openBridgeDbReadOnly` bak recovery** — the `.bak`
  recovery path used bare `fsp.rename(bakPath, dbPath)`, which is
  exactly the scenario most likely to hit Windows EBUSY/EPERM (an
  interrupted writer still holding the handle for a few ms). Switched
  to `retryRename` for consistency with the rest of the file's
  Windows-safe rename path.
- **bridge-db.ts `ensureBridgeSchema` error detection** — the inline
  `msg.includes('already exists')` substring match has been lifted
  into a named constant `LBUG_ALREADY_EXISTS_MSG` with a comment
  documenting the coupling to LadybugDB's error message wording and
  why we can't use `IF NOT EXISTS` (LadybugDB DDL doesn't support it)
  or typed errors (LadybugDB's JS driver doesn't expose error codes).
  Also tightened the `catch (err: any)` to `catch (err: unknown)`.
- **bridge-db.ts `findContractNode` — extracted out of writeBridge**
  — the 35-line async closure living inside `writeBridge` has been
  lifted to three module-level functions: `createContractLookupIndex`,
  `indexContract`, and `findContractNode`. `findContractNode` is now
  a pure synchronous function taking a prebuilt index instead of
  doing its own DB queries. The `writeBridge` cross-link loop is now
  ~25 lines instead of ~100.
- **bridge-db.ts `findContractNode` — N+1 query elimination** — the
  old inner-closure version issued up to 6 DB round-trips per
  cross-link (2 endpoints × up to 3 tiers of fallback queries). For a
  group with 1000 cross-links, that's up to 6000 DB queries just to
  resolve endpoints. The new version consults an in-memory
  `ContractLookupIndex` built incrementally as contracts are inserted
  (`indexContract` called AFTER each successful insert so failed
  inserts don't poison the index). Cross-link resolution is now
  O(1) per link instead of O(3) DB queries per link, with zero DB
  round-trips during the cross-link loop.

### Minor severity

- **bridge-db.ts `queryBridge` empty-array guard** — if LadybugDB
  ever returns an empty `QueryResult[]` at the top level (shouldn't
  happen with single-statement calls, but driver contract isn't
  explicit), the old code would call `.getAll()` on `undefined` and
  crash with a confusing stack. Added an `unwrapQueryResult` helper
  that throws an explicit `'empty QueryResult array'` error instead,
  making a potential driver regression visible immediately.
- **normalization.ts `contractRichness` weights** — added a
  block-level comment documenting the weight ordering (+3 for
  symbolUid, +2 for each symbol-identifying field, +1 for service
  tag or non-manifest origin) and explicitly noting that the
  absolute numbers don't matter, only the relative ordering. Matches
  the "comment for contributors" suggestion in the review.
- **bridge-schema.ts `BRIDGE_SCHEMA_VERSION` migration comment** —
  added a 4-point contract explaining what bumping the constant
  means ("discard and re-sync" strategy for V1, no in-place
  migration yet, new migration logic should live in a separate
  `bridge-migrations.ts` module when it becomes necessary).
- **test/unit/group/fixtures.ts** — extracted the `makeContract`
  helper previously copy-pasted between `bridge-db.test.ts` and
  `bridge-db-edge.test.ts` into a shared fixtures module. Both test
  files now import from `./fixtures.js`. Kept the scope minimal:
  fixtures is NOT a general-purpose factory module, just the shared
  baseline contract builder.

### New tests

Added 9 pure-function unit tests for the now-extracted
`findContractNode` in `bridge-db.test.ts`:
  - returns null on empty index
  - tier 1 (symbolUid) match, including repo-scope and role-scope
    isolation
  - tier 2 (filePath + symbolName) fallback when symbolUid is empty
    or mismatches
  - tier 3 (filePath only) when exactly one contract lives in the
    file, and refusal when multiple do
  - priority ordering when multiple tiers could resolve

These are fully isolated — no DB, no temp directories, no native
LadybugDB binding — so they run in &lt;10ms total and are
immediately trustworthy as a regression safety net.

### Deliberately deferred (reviewer marked as "fine for now")

- `BridgeHandle._db` / `._conn` typing to `unknown` with casts in
  `bridge-db.ts` — reviewer's note: "The typing is fine for now."
- Batch inserts via `UNWIND` — needs LadybugDB support confirmation,
  tracked as a follow-up; the per-item pattern remains.
- `queryBridge` prepared-statement lifecycle — the current pattern
  (prepare → execute → GC) relies on LadybugDB's internals, worth
  verifying against their docs in a separate audit.

### Scope discipline (per `GUARDRAILS.md`)

- Only files touched by this PR (`bridge-db.ts`, `bridge-schema.ts`,
  `normalization.ts`, both bridge test files, new `fixtures.ts`) —
  no drive-by refactors
- No CI/release/security config changes
- No secrets

### Test + typecheck status

- `npx tsc --noEmit` clean
- `bridge-db.test.ts`: added 9 `findContractNode` tests, all pass in
  isolation. The full-file run still hits the pre-existing native
  LadybugDB cleanup segfault that flakes the reported count — same
  as every prior commit on this branch, not a regression.
- `bridge-db-edge.test.ts`: 4/4 pass
- `matching.test.ts`: 28/28 pass
- `types.test.ts`: 5/5 pass
- `retryRename` tests (4/4) and `findContractNode` tests (9/9)
  verified in isolation via `-t` filter

Co-authored-by: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-11 19:46:12 +01:00

575 lines
19 KiB
TypeScript

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 {
openBridgeDb,
ensureBridgeSchema,
queryBridge,
closeBridgeDb,
contractNodeId,
retryRename,
writeBridge,
openBridgeDbReadOnly,
readBridgeMeta,
bridgeExists,
createContractLookupIndex,
indexContract,
findContractNode,
} from '../../../src/core/group/bridge-db.js';
import type { CrossLink } from '../../../src/core/group/types.js';
import { makeContract } from './fixtures.js';
describe('bridge-db core', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'bridge-test-'));
});
afterEach(async () => {
await fsp.rm(tmpDir, { recursive: true, force: true });
});
it('test_openBridgeDb_returns_handle_and_closes', async () => {
const dbPath = path.join(tmpDir, 'test.lbug');
const handle = await openBridgeDb(dbPath);
expect(handle).toBeDefined();
expect(handle._db).toBeDefined();
expect(handle._conn).toBeDefined();
expect(handle.groupDir).toBe(tmpDir);
// Close should not throw
await closeBridgeDb(handle);
});
it('test_ensureBridgeSchema_creates_tables_idempotent', async () => {
const dbPath = path.join(tmpDir, 'test.lbug');
const handle = await openBridgeDb(dbPath);
await ensureBridgeSchema(handle);
// Run again — should not throw
await ensureBridgeSchema(handle);
const rows = await queryBridge<{ cnt: number }>(
handle,
'MATCH (c:Contract) RETURN count(c) AS cnt',
);
expect(rows[0].cnt).toBe(0);
await closeBridgeDb(handle);
});
it('test_queryBridge_returns_inserted_data', async () => {
const dbPath = path.join(tmpDir, 'test.lbug');
const handle = await openBridgeDb(dbPath);
await ensureBridgeSchema(handle);
await queryBridge(
handle,
`CREATE (c:Contract {
id: 'abc123', contractId: 'http::GET::/api', type: 'http', role: 'provider',
repo: 'backend', confidence: 0.9
})`,
);
const rows = await queryBridge<{ repo: string; confidence: number }>(
handle,
'MATCH (c:Contract) RETURN c.repo AS repo, c.confidence AS confidence',
);
expect(rows).toHaveLength(1);
expect(rows[0].repo).toBe('backend');
expect(rows[0].confidence).toBe(0.9);
await closeBridgeDb(handle);
});
it('test_queryBridge_parameterized', async () => {
const dbPath = path.join(tmpDir, 'test.lbug');
const handle = await openBridgeDb(dbPath);
await ensureBridgeSchema(handle);
await queryBridge(
handle,
`CREATE (c:Contract {
id: 'p1', contractId: 'http::GET::/api', type: 'http', role: 'provider',
repo: 'backend', confidence: 0.9
})`,
);
const rows = await queryBridge<{ repo: string }>(
handle,
'MATCH (c:Contract) WHERE c.repo = $r RETURN c.repo AS repo',
{ r: 'backend' },
);
expect(rows).toHaveLength(1);
expect(rows[0].repo).toBe('backend');
await closeBridgeDb(handle);
});
it('test_contractNodeId_full_sha256', () => {
const id = contractNodeId('backend', 'http::GET::/api', 'provider', 'src/routes.ts');
expect(id).toHaveLength(64); // full SHA-256 hex
// Same inputs → same hash
const id2 = contractNodeId('backend', 'http::GET::/api', 'provider', 'src/routes.ts');
expect(id).toBe(id2);
// Different filePath → different hash
const id3 = contractNodeId('backend', 'http::GET::/api', 'provider', 'src/other.ts');
expect(id).not.toBe(id3);
});
});
describe('writeBridge + read', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'bridge-write-'));
});
afterEach(async () => {
await fsp.rm(tmpDir, { recursive: true, force: true });
});
it('test_writeBridge_creates_bridge_lbug_file', async () => {
await writeBridge(tmpDir, {
contracts: [makeContract()],
crossLinks: [],
repoSnapshots: { backend: { indexedAt: '2026-01-01', lastCommit: 'abc' } },
missingRepos: ['missing-repo'],
});
const exists = await bridgeExists(tmpDir);
expect(exists).toBe(true);
});
it('test_writeBridge_returns_report_with_insert_counts', async () => {
const report = await writeBridge(tmpDir, {
contracts: [makeContract(), makeContract({ repo: 'frontend', role: 'consumer' })],
crossLinks: [],
repoSnapshots: { backend: { indexedAt: '2026-01-01', lastCommit: 'abc' } },
missingRepos: [],
});
expect(report.contractsInserted).toBe(2);
expect(report.contractsFailed).toBe(0);
expect(report.snapshotsInserted).toBe(1);
expect(report.snapshotsFailed).toBe(0);
expect(report.linksInserted).toBe(0);
expect(report.linksFailed).toBe(0);
expect(report.linksDroppedMissingNode).toBe(0);
expect(report.sampleErrors).toHaveLength(0);
});
it('test_writeBridge_counts_dropped_links_with_missing_nodes', async () => {
// Provider + cross-link that references a non-existent consumer node →
// findContractNode returns null for `from`, link gets dropped.
const provider = makeContract({ role: 'provider' });
const report = await writeBridge(tmpDir, {
contracts: [provider],
crossLinks: [
{
from: {
repo: 'ghost',
symbolUid: '',
symbolRef: { filePath: 'nowhere.ts', name: 'ghostFn' },
},
to: {
repo: provider.repo,
symbolUid: provider.symbolUid,
symbolRef: provider.symbolRef,
},
type: 'http',
contractId: provider.contractId,
matchType: 'exact',
confidence: 1.0,
},
],
repoSnapshots: {},
missingRepos: [],
});
expect(report.linksInserted).toBe(0);
expect(report.linksDroppedMissingNode).toBe(1);
expect(report.linksFailed).toBe(0);
expect(report.contractsInserted).toBe(1);
});
it('test_writeBridge_contracts_queryable', async () => {
await writeBridge(tmpDir, {
contracts: [makeContract(), makeContract({ repo: 'frontend', role: 'consumer' })],
crossLinks: [],
repoSnapshots: {},
missingRepos: [],
});
const handle = await openBridgeDbReadOnly(tmpDir);
expect(handle).not.toBeNull();
const rows = await queryBridge<{ repo: string }>(
handle!,
'MATCH (c:Contract) RETURN c.repo AS repo',
);
expect(rows).toHaveLength(2);
await closeBridgeDb(handle!);
});
it('test_writeBridge_meta_json_persists_missingRepos', async () => {
await writeBridge(tmpDir, {
contracts: [],
crossLinks: [],
repoSnapshots: {},
missingRepos: ['repo-a', 'repo-b'],
});
const meta = await readBridgeMeta(tmpDir);
expect(meta.missingRepos).toEqual(['repo-a', 'repo-b']);
expect(meta.version).toBeGreaterThan(0);
expect(meta.generatedAt).toBeTruthy();
});
it('test_writeBridge_repoSnapshots_queryable', async () => {
await writeBridge(tmpDir, {
contracts: [],
crossLinks: [],
repoSnapshots: { 'hr/backend': { indexedAt: '2026-01-01', lastCommit: 'abc' } },
missingRepos: [],
});
const handle = await openBridgeDbReadOnly(tmpDir);
const rows = await queryBridge<{ id: string; indexedAt: string }>(
handle!,
'MATCH (s:RepoSnapshot) RETURN s.id AS id, s.indexedAt AS indexedAt',
);
expect(rows).toHaveLength(1);
expect(rows[0].id).toBe('hr/backend');
expect(rows[0].indexedAt).toBe('2026-01-01');
await closeBridgeDb(handle!);
});
it('test_writeBridge_crossLinks_queryable', async () => {
const provider = makeContract({ repo: 'backend', role: 'provider' });
const consumer = makeContract({
repo: 'frontend',
role: 'consumer',
symbolRef: { filePath: 'src/api.ts', name: 'fetchUsers' },
symbolName: 'fetchUsers',
});
const link: CrossLink = {
from: {
repo: 'frontend',
symbolUid: '',
symbolRef: { filePath: 'src/api.ts', name: 'fetchUsers' },
},
to: {
repo: 'backend',
symbolUid: 'uid-1',
symbolRef: { filePath: 'src/routes.ts', name: 'getUsers' },
},
type: 'http',
contractId: 'http::GET::/api/users',
matchType: 'exact',
confidence: 1.0,
};
await writeBridge(tmpDir, {
contracts: [provider, consumer],
crossLinks: [link],
repoSnapshots: {},
missingRepos: [],
});
const handle = await openBridgeDbReadOnly(tmpDir);
const rows = await queryBridge<{ fromRepo: string; toRepo: string; matchType: string }>(
handle!,
'MATCH (a:Contract)-[l:ContractLink]->(b:Contract) RETURN l.fromRepo AS fromRepo, l.toRepo AS toRepo, l.matchType AS matchType',
);
expect(rows).toHaveLength(1);
expect(rows[0].fromRepo).toBe('frontend');
expect(rows[0].toRepo).toBe('backend');
expect(rows[0].matchType).toBe('exact');
await closeBridgeDb(handle!);
});
it('test_writeBridge_duplicate_contracts_and_links_are_deduped', async () => {
const provider = makeContract({
repo: 'backend',
role: 'provider',
symbolUid: '',
symbolName: 'auth.AuthService/Login',
symbolRef: { filePath: 'src/auth.proto', name: 'Login' },
contractId: 'grpc::auth.AuthService/Login',
type: 'grpc',
meta: { source: 'manifest' },
});
const concreteProvider = makeContract({
...provider,
symbolUid: 'uid-auth-login',
symbolName: 'Login',
confidence: 0.85,
meta: { source: 'analyze' },
});
const consumer = makeContract({
repo: 'frontend',
role: 'consumer',
symbolUid: '',
symbolName: 'auth.AuthService/Login',
symbolRef: { filePath: 'src/client.ts', name: 'AuthServiceClient' },
contractId: 'grpc::auth.AuthService/Login',
type: 'grpc',
meta: { source: 'manifest' },
});
const link: CrossLink = {
from: {
repo: 'frontend',
symbolUid: '',
symbolRef: { filePath: 'src/client.ts', name: 'AuthServiceClient' },
},
to: {
repo: 'backend',
symbolUid: '',
symbolRef: { filePath: 'src/auth.proto', name: 'Login' },
},
type: 'grpc',
contractId: 'grpc::auth.AuthService/Login',
matchType: 'manifest',
confidence: 1,
};
await writeBridge(tmpDir, {
contracts: [provider, concreteProvider, consumer],
crossLinks: [link, { ...link }],
repoSnapshots: {},
missingRepos: [],
});
const handle = await openBridgeDbReadOnly(tmpDir);
const contracts = await queryBridge<{ repo: string; symbolUid: string; symbolName: string }>(
handle!,
'MATCH (c:Contract) RETURN c.repo AS repo, c.symbolUid AS symbolUid, c.symbolName AS symbolName ORDER BY c.repo',
);
const links = await queryBridge<{ fromRepo: string; toRepo: string }>(
handle!,
'MATCH (a:Contract)-[l:ContractLink]->(b:Contract) RETURN l.fromRepo AS fromRepo, l.toRepo AS toRepo',
);
expect(contracts).toHaveLength(2);
expect(contracts[0]).toEqual({
repo: 'backend',
symbolUid: 'uid-auth-login',
symbolName: 'Login',
});
expect(links).toHaveLength(1);
await closeBridgeDb(handle!);
});
it('test_openBridgeDbReadOnly_returns_null_for_missing', async () => {
const handle = await openBridgeDbReadOnly(path.join(tmpDir, 'nonexistent'));
expect(handle).toBeNull();
});
it('test_bridgeExists_false_for_missing', async () => {
expect(await bridgeExists(path.join(tmpDir, 'nonexistent'))).toBe(false);
});
it('test_writeBridge_overwrites_previous', async () => {
await writeBridge(tmpDir, {
contracts: [makeContract()],
crossLinks: [],
repoSnapshots: {},
missingRepos: [],
});
await writeBridge(tmpDir, {
contracts: [makeContract({ repo: 'new-repo' })],
crossLinks: [],
repoSnapshots: {},
missingRepos: [],
});
const handle = await openBridgeDbReadOnly(tmpDir);
const rows = await queryBridge<{ repo: string }>(
handle!,
'MATCH (c:Contract) RETURN c.repo AS repo',
);
expect(rows).toHaveLength(1);
expect(rows[0].repo).toBe('new-repo');
await closeBridgeDb(handle!);
});
it('test_readBridgeMeta_returns_defaults_for_missing', async () => {
const meta = await readBridgeMeta(path.join(tmpDir, 'nonexistent'));
expect(meta.version).toBe(0);
expect(meta.generatedAt).toBe('');
expect(meta.missingRepos).toEqual([]);
});
});
describe('retryRename', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('retries on EBUSY and eventually succeeds', async () => {
// Spy on fs.promises.rename and make the first two attempts fail with
// EBUSY, then succeed on the third. Verifies that Windows-style
// transient rename failures don't immediately bubble up.
const attempts: Array<[string, string]> = [];
let calls = 0;
const spy = vi.spyOn(fsp, 'rename').mockImplementation(async (src, dst) => {
attempts.push([String(src), String(dst)]);
calls++;
if (calls < 3) {
const err = new Error('resource busy or locked') as NodeJS.ErrnoException;
err.code = 'EBUSY';
throw err;
}
// Third attempt: pretend the rename worked.
return undefined;
});
await retryRename('/src/a', '/dst/b', 3);
expect(spy).toHaveBeenCalledTimes(3);
expect(attempts.every(([s, d]) => s === '/src/a' && d === '/dst/b')).toBe(true);
});
it('rethrows non-retryable errors immediately', async () => {
// A non-retryable code (e.g. ENOENT) should NOT be swallowed into a
// retry loop — that would mask real bugs and waste time.
let calls = 0;
vi.spyOn(fsp, 'rename').mockImplementation(async () => {
calls++;
const err = new Error('no such file') as NodeJS.ErrnoException;
err.code = 'ENOENT';
throw err;
});
await expect(retryRename('/src/a', '/dst/b', 5)).rejects.toMatchObject({ code: 'ENOENT' });
expect(calls).toBe(1);
});
it('gives up after the configured number of attempts', async () => {
let calls = 0;
vi.spyOn(fsp, 'rename').mockImplementation(async () => {
calls++;
const err = new Error('locked') as NodeJS.ErrnoException;
err.code = 'EPERM';
throw err;
});
await expect(retryRename('/src/a', '/dst/b', 3)).rejects.toMatchObject({ code: 'EPERM' });
expect(calls).toBe(3);
});
it('retries on EACCES as well', async () => {
let calls = 0;
vi.spyOn(fsp, 'rename').mockImplementation(async () => {
calls++;
if (calls < 2) {
const err = new Error('permission denied') as NodeJS.ErrnoException;
err.code = 'EACCES';
throw err;
}
return undefined;
});
await retryRename('/src/a', '/dst/b', 3);
expect(calls).toBe(2);
});
});
describe('findContractNode', () => {
// Pure-function tests for the lookup index + three-tier resolver that
// were previously an inner closure of `writeBridge` and therefore
// untestable in isolation. Every test here builds its own index and
// never touches the DB.
it('returns null on empty index', () => {
const index = createContractLookupIndex();
expect(findContractNode(index, 'backend', 'provider', 'uid-1', 'src/a.ts', 'foo')).toBeNull();
});
it('tier 1: returns contract matched by symbolUid', () => {
const index = createContractLookupIndex();
const c = makeContract({ symbolUid: 'uid-42', repo: 'backend', role: 'provider' });
indexContract(index, c, 'node-A');
expect(findContractNode(index, 'backend', 'provider', 'uid-42', 'anywhere.ts', 'anyName')).toBe(
'node-A',
);
});
it('tier 1 is repo-scoped: same uid in a different repo does not match', () => {
const index = createContractLookupIndex();
const c = makeContract({ symbolUid: 'uid-42', repo: 'backend' });
indexContract(index, c, 'node-A');
expect(
findContractNode(index, 'frontend', 'provider', 'uid-42', 'src/routes.ts', 'getUsers'),
).toBeNull();
});
it('tier 1 is role-scoped: provider uid match does not resolve consumer query', () => {
const index = createContractLookupIndex();
const c = makeContract({ symbolUid: 'uid-42', role: 'provider', repo: 'backend' });
indexContract(index, c, 'node-A');
expect(
findContractNode(index, 'backend', 'consumer', 'uid-42', 'src/routes.ts', 'getUsers'),
).toBeNull();
});
it('tier 2: falls through to filePath + symbolName when symbolUid is empty', () => {
const index = createContractLookupIndex();
const c = makeContract({
symbolUid: '',
symbolRef: { filePath: 'src/ctrl.ts', name: 'handler' },
symbolName: 'handler',
});
indexContract(index, c, 'node-B');
expect(findContractNode(index, 'backend', 'provider', '', 'src/ctrl.ts', 'handler')).toBe(
'node-B',
);
});
it('tier 2: falls through when the given symbolUid does not match anything', () => {
const index = createContractLookupIndex();
const c = makeContract({
symbolUid: 'uid-real',
symbolRef: { filePath: 'src/ctrl.ts', name: 'handler' },
});
indexContract(index, c, 'node-B');
// Wrong uid; but filePath+name still resolves.
expect(
findContractNode(index, 'backend', 'provider', 'uid-wrong', 'src/ctrl.ts', 'handler'),
).toBe('node-B');
});
it('tier 3: resolves by filePath alone when exactly one contract lives there', () => {
const index = createContractLookupIndex();
const c = makeContract({
symbolUid: '',
symbolRef: { filePath: 'src/solo.ts', name: 'actualName' },
});
indexContract(index, c, 'node-C');
// filePath+name miss (name is wrong), but tier 3 picks the sole entry.
expect(findContractNode(index, 'backend', 'provider', '', 'src/solo.ts', 'wrongName')).toBe(
'node-C',
);
});
it('tier 3: does NOT resolve when multiple contracts live in the same file', () => {
const index = createContractLookupIndex();
const a = makeContract({
symbolUid: '',
symbolRef: { filePath: 'src/multi.ts', name: 'handlerA' },
});
const b = makeContract({
symbolUid: '',
symbolRef: { filePath: 'src/multi.ts', name: 'handlerB' },
contractId: 'http::GET::/api/b',
});
indexContract(index, a, 'node-MA');
indexContract(index, b, 'node-MB');
// Wrong symbolName → no tier 2 match. Two contracts in the same file
// → tier 3 must refuse to guess.
expect(
findContractNode(index, 'backend', 'provider', '', 'src/multi.ts', 'unknown'),
).toBeNull();
});
it('prefers tier 1 over tier 2 when both could resolve', () => {
const index = createContractLookupIndex();
const tier1Contract = makeContract({
symbolUid: 'uid-1',
symbolRef: { filePath: 'src/a.ts', name: 'first' },
});
const tier2Contract = makeContract({
symbolUid: '',
symbolRef: { filePath: 'src/a.ts', name: 'first' },
contractId: 'http::POST::/api/x',
});
indexContract(index, tier1Contract, 'tier1-id');
indexContract(index, tier2Contract, 'tier2-id');
expect(findContractNode(index, 'backend', 'provider', 'uid-1', 'src/a.ts', 'first')).toBe(
'tier1-id',
);
});
});