mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-11 22:53:04 +00:00
* 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 <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>
575 lines
19 KiB
TypeScript
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',
|
|
);
|
|
});
|
|
});
|