mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-07 02:58:02 +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>
178 lines
5.7 KiB
TypeScript
178 lines
5.7 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import fsp from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import os from 'node:os';
|
|
import {
|
|
writeBridge,
|
|
openBridgeDbReadOnly,
|
|
queryBridge,
|
|
closeBridgeDb,
|
|
} from '../../../src/core/group/bridge-db.js';
|
|
import type { CrossLink } from '../../../src/core/group/types.js';
|
|
import { makeContract } from './fixtures.js';
|
|
|
|
describe('bridge-db edge cases', () => {
|
|
let tmpDir: string;
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'bridge-edge-'));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await fsp.rm(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('test_openBridgeDbReadOnly_version_gate_returns_null_for_incompatible', async () => {
|
|
// Create a dummy bridge.lbug file so the access check passes
|
|
await fsp.writeFile(path.join(tmpDir, 'bridge.lbug'), 'dummy');
|
|
// Write meta.json with an incompatible version (999)
|
|
await fsp.writeFile(
|
|
path.join(tmpDir, 'meta.json'),
|
|
JSON.stringify({ version: 999, generatedAt: '', missingRepos: [] }),
|
|
);
|
|
|
|
const handle = await openBridgeDbReadOnly(tmpDir);
|
|
expect(handle).toBeNull();
|
|
});
|
|
|
|
it('test_openBridgeDbReadOnly_bak_recovery_restores_bridge', async () => {
|
|
// Write a valid bridge
|
|
await writeBridge(tmpDir, {
|
|
contracts: [makeContract()],
|
|
crossLinks: [],
|
|
repoSnapshots: {},
|
|
missingRepos: [],
|
|
});
|
|
// Move bridge.lbug → bridge.lbug.bak (simulating interrupted swap)
|
|
const dbPath = path.join(tmpDir, 'bridge.lbug');
|
|
const bakPath = path.join(tmpDir, 'bridge.lbug.bak');
|
|
await fsp.rename(dbPath, bakPath);
|
|
|
|
// openBridgeDbReadOnly should auto-recover from .bak
|
|
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(1);
|
|
await closeBridgeDb(handle!);
|
|
});
|
|
|
|
it('test_writeBridge_crossLink_with_missing_to_node_silently_skipped', async () => {
|
|
const provider = makeContract({ repo: 'backend', role: 'provider' });
|
|
const consumer = makeContract({
|
|
repo: 'frontend',
|
|
role: 'consumer',
|
|
symbolRef: { filePath: 'src/api.ts', name: 'fetchUsers' },
|
|
symbolName: 'fetchUsers',
|
|
});
|
|
// CrossLink referencing a 'to' endpoint that doesn't match any contract node
|
|
const link: CrossLink = {
|
|
from: {
|
|
repo: 'frontend',
|
|
symbolUid: '',
|
|
symbolRef: { filePath: 'src/api.ts', name: 'fetchUsers' },
|
|
},
|
|
to: {
|
|
repo: 'nonexistent-repo',
|
|
symbolUid: 'uid-missing',
|
|
symbolRef: { filePath: 'src/missing.ts', name: 'missingFn' },
|
|
},
|
|
type: 'http',
|
|
contractId: 'http::GET::/api/users',
|
|
matchType: 'exact',
|
|
confidence: 1.0,
|
|
};
|
|
|
|
// Should not throw — the link is silently skipped
|
|
await writeBridge(tmpDir, {
|
|
contracts: [provider, consumer],
|
|
crossLinks: [link],
|
|
repoSnapshots: {},
|
|
missingRepos: [],
|
|
});
|
|
|
|
const handle = await openBridgeDbReadOnly(tmpDir);
|
|
expect(handle).not.toBeNull();
|
|
// No cross-links should exist since 'to' node was missing
|
|
const rows = await queryBridge<{ matchType: string }>(
|
|
handle!,
|
|
'MATCH (a:Contract)-[l:ContractLink]->(b:Contract) RETURN l.matchType AS matchType',
|
|
);
|
|
expect(rows).toHaveLength(0);
|
|
// But contracts should still be present
|
|
const contractRows = await queryBridge<{ repo: string }>(
|
|
handle!,
|
|
'MATCH (c:Contract) RETURN c.repo AS repo',
|
|
);
|
|
expect(contractRows).toHaveLength(2);
|
|
await closeBridgeDb(handle!);
|
|
});
|
|
|
|
it('test_writeBridge_manifest_grpc_link_with_symbol_uids_persists_queryable_contract_edge', async () => {
|
|
const provider = makeContract({
|
|
contractId: 'grpc::auth.AuthService/Login',
|
|
type: 'grpc',
|
|
role: 'provider',
|
|
repo: 'platform/auth',
|
|
symbolUid: 'uid-auth-login',
|
|
symbolRef: { filePath: 'src/auth.proto', name: 'Login' },
|
|
symbolName: 'auth.AuthService/Login',
|
|
});
|
|
const consumer = makeContract({
|
|
contractId: 'grpc::auth.AuthService/Login',
|
|
type: 'grpc',
|
|
role: 'consumer',
|
|
repo: 'platform/orders',
|
|
symbolUid: 'uid-orders-client',
|
|
symbolRef: { filePath: 'src/client.ts', name: 'AuthServiceClient' },
|
|
symbolName: 'auth.AuthService/Login',
|
|
});
|
|
const link: CrossLink = {
|
|
from: {
|
|
repo: 'platform/orders',
|
|
symbolUid: 'uid-orders-client',
|
|
symbolRef: { filePath: 'src/client.ts', name: 'AuthServiceClient' },
|
|
},
|
|
to: {
|
|
repo: 'platform/auth',
|
|
symbolUid: 'uid-auth-login',
|
|
symbolRef: { filePath: 'src/auth.proto', name: 'Login' },
|
|
},
|
|
type: 'grpc',
|
|
contractId: 'grpc::auth.AuthService/Login',
|
|
matchType: 'manifest',
|
|
confidence: 1.0,
|
|
};
|
|
|
|
await writeBridge(tmpDir, {
|
|
contracts: [provider, consumer],
|
|
crossLinks: [link],
|
|
repoSnapshots: {},
|
|
missingRepos: [],
|
|
});
|
|
|
|
const handle = await openBridgeDbReadOnly(tmpDir);
|
|
expect(handle).not.toBeNull();
|
|
const rows = await queryBridge<{
|
|
contractId: string;
|
|
matchType: string;
|
|
fromRepo: string;
|
|
toRepo: string;
|
|
}>(
|
|
handle!,
|
|
`MATCH (a:Contract)-[l:ContractLink]->(b:Contract)
|
|
RETURN l.contractId AS contractId, l.matchType AS matchType, l.fromRepo AS fromRepo, l.toRepo AS toRepo`,
|
|
);
|
|
expect(rows).toEqual([
|
|
{
|
|
contractId: 'grpc::auth.AuthService/Login',
|
|
matchType: 'manifest',
|
|
fromRepo: 'platform/orders',
|
|
toRepo: 'platform/auth',
|
|
},
|
|
]);
|
|
await closeBridgeDb(handle!);
|
|
});
|
|
});
|