feat(group): bridge.lbug storage and gRPC canonical ID normalization

Bridge.lbug — contract storage in LadybugDB:
- bridge-schema.ts: Contract, RepoSnapshot, ContractLink DDL
- bridge-db.ts: open/close, writeBridge (atomic swap), queryBridge,
  openBridgeDbReadOnly (with version gate), readBridgeMeta, openBridgeOrFallback
- BridgeHandle, BridgeMeta types; LegacyContractRegistry alias

gRPC canonical ID — proto-aware extraction:
- buildProtoMap: scan .proto files (excludes node_modules/vendor)
- resolveProtoConflict: directory proximity disambiguation
- serviceContractId: grpc::pkg.Service/* format
- All 4 source scanners (Go/Java/Python/TS) resolve via proto map
- Confidence adjustments: reduced without proto, boosted with

Matching — wildcard support:
- buildProviderIndex exported for reuse
- runExactMatch skips gRPC /* contracts
- runWildcardMatch: bare-name and FQ service matching

Consumer migration:
- sync.ts: writeBridge + wildcard pass, returns remaining after wildcard
- cross-impact.ts: Cypher-based Phase 2 with direction-dependent queries,
  hint param for empty-UID fan-out, error-object guard
- service.ts: openBridgeOrFallback with bridge/json/none branching,
  groupContracts bridge path returns flat contracts + crossLinks + unmatchedOnly
- CLI: status/impact use openBridgeOrFallback, deprecation warnings
- MCP tools: descriptions updated (contracts.json → bridge.lbug)
- Old storage API removed (writeContractRegistry, readContractRegistry)
- Cross-link insertion uses endpoint symbolRef lookup (not link.contractId)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
ivkond 2026-04-04 23:30:04 +03:00
parent b1c4c56f23
commit ae68bfbfb2
25 changed files with 2777 additions and 283 deletions

View file

@ -4,6 +4,12 @@ All notable changes to GitNexus will be documented in this file.
## [Unreleased]
### Added
- Cross-repo impact analysis (`group_impact` tool and CLI command)
- Bridge.lbug storage: contract registry stored in LadybugDB instead of `contracts.json`
- gRPC canonical ID normalization: proto-aware extraction with wildcard matching
- Backward compatibility: automatic `contracts.json` fallback with deprecation warning
### Changed
- Migrated from KuzuDB to LadybugDB v0.15 (`@ladybugdb/core`, `@ladybugdb/wasm-core`)
- Renamed all internal paths from `kuzu` to `lbug` (storage: `.gitnexus/kuzu` → `.gitnexus/lbug`)

View file

@ -214,13 +214,14 @@ gitnexus group remove <name> <repo> # Remove a repo from a group
gitnexus group list [name] # List groups, or show one group's config
gitnexus group sync <name> # Extract contracts and match across repos/services
gitnexus group contracts <name> # Inspect extracted contracts and cross-links
gitnexus group impact <name> # Cross-repo blast radius analysis
gitnexus group query <name> <q> # Search execution flows across all repos in a group
gitnexus group status <name> # Check staleness of repos in a group
```
### What Your AI Agent Gets
**16 tools** exposed via MCP (11 per-repo + 5 group):
**17 tools** exposed via MCP (11 per-repo + 6 group):
| Tool | What It Does | `repo` Param |
| ------------------ | ----------------------------------------------------------------- | -------------- |
@ -234,11 +235,14 @@ gitnexus group status <name> # Check staleness of repos in a group
| `group_list` | List configured repository groups | — |
| `group_sync` | Extract contracts and match across repos/services | — |
| `group_contracts`| Inspect extracted contracts and cross-links | — |
| `group_impact` | Cross-repo blast radius analysis | — |
| `group_query` | Search execution flows across all repos in a group | — |
| `group_status` | Check staleness of repos in a group | — |
> When only one repo is indexed, the `repo` parameter is optional. With multiple repos, specify which one: `query({query: "auth", repo: "my-app"})`.
> **Storage migration:** Group contract data is now stored in `bridge.lbug` (LadybugDB) instead of `contracts.json`. Existing groups with `contracts.json` are supported via automatic fallback with a deprecation warning. Run `gitnexus group sync <name>` to migrate.
**Resources** for instant context:
| Resource | Purpose |

View file

@ -1,7 +1,9 @@
# RFC: Cross-Index Impact Analysis — Repository Groups
> **Superseded:** Contract storage migrated from `contracts.json` to `bridge.lbug` (LadybugDB). See [`docs/superpowers/specs/2026-04-03-bridge-lbug-grpc-normalization-design.md`](../superpowers/specs/2026-04-03-bridge-lbug-grpc-normalization-design.md) for the current design.
**Date:** 2026-03-31
**Status:** Draft
**Status:** Superseded
**Author:** @ivkond
**Related Issues:** [#256](https://github.com/abhigyanpatwari/GitNexus/issues/256), [#306](https://github.com/abhigyanpatwari/GitNexus/issues/306), [#77](https://github.com/abhigyanpatwari/GitNexus/issues/77)

View file

@ -1,5 +1,7 @@
# PR #626 HIGH-Priority Fixes Implementation Plan
> **Historical:** This plan was executed for PR #626. Contract storage has since migrated from `contracts.json` to `bridge.lbug`. See `2026-04-04-bridge-lbug-grpc-normalization.md` for the current plan.
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix 4 HIGH-priority issues from PR #626 code review before merge.

View file

@ -172,6 +172,7 @@ gitnexus group remove <name> <repo> # Remove a repo from a group
gitnexus group list [name] # List groups, or show one group's config
gitnexus group sync <name> # Extract contracts and match across repos/services
gitnexus group contracts <name> # Inspect extracted contracts and cross-links
gitnexus group impact <name> # Cross-repo blast radius analysis
gitnexus group query <name> <q> # Search execution flows across all repos in a group
gitnexus group status <name> # Check staleness of repos in a group
```

View file

@ -97,16 +97,23 @@ export function registerGroupCommands(program: Command): void {
.command('status <name>')
.description('Check staleness of group and repos')
.action(async (name: string) => {
const { readContractRegistry, getGroupDir, getDefaultGitnexusDir } =
const { getGroupDir, getDefaultGitnexusDir, openBridgeOrFallback } =
await import('../core/group/storage.js');
const { closeBridgeDb } = await import('../core/group/bridge-db.js');
const { LocalBackend } = await import('../mcp/local/local-backend.js');
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
const registry = await readContractRegistry(groupDir);
const fallback = await openBridgeOrFallback(groupDir);
const lastSync =
fallback.type === 'bridge'
? fallback.meta.generatedAt
: fallback.type === 'json'
? fallback.registry.generatedAt
: null;
if (fallback.type === 'json') console.warn(fallback.deprecationWarning);
if (fallback.type === 'bridge') await closeBridgeDb(fallback.handle);
console.log(
`Group: ${name}${registry ? ` (last sync: ${registry.generatedAt})` : ' (never synced)'}\n`,
);
console.log(`Group: ${name}${lastSync ? ` (last sync: ${lastSync})` : ' (never synced)'}\n`);
const backend = new LocalBackend();
try {
@ -181,7 +188,7 @@ export function registerGroupCommands(program: Command): void {
console.log(` exact: ${exactLinks.length} cross-links (confidence 1.0)`);
console.log(` unmatched: ${result.unmatched.length} contracts`);
console.log(
`\nWrote contracts.json (${result.contracts.length} contracts, ${result.crossLinks.length} cross-links)`,
`\nWrote bridge.lbug (${result.contracts.length} contracts, ${result.crossLinks.length} cross-links)`,
);
}
} finally {
@ -202,17 +209,20 @@ export function registerGroupCommands(program: Command): void {
.option('--timeout <ms>', 'Total wall time budget in ms', '30000')
.option('--json', 'JSON output')
.action(async (name: string, opts: Record<string, string | boolean | undefined>) => {
const { getGroupDir, getDefaultGitnexusDir, readContractRegistry } =
const { getGroupDir, getDefaultGitnexusDir, openBridgeOrFallback } =
await import('../core/group/storage.js');
const { closeBridgeDb } = await import('../core/group/bridge-db.js');
const { LocalBackend } = await import('../mcp/local/local-backend.js');
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
const regFile = await readContractRegistry(groupDir);
if (!regFile) {
console.error(`No contracts.json found. Run: gitnexus group sync ${name}`);
const fallback = await openBridgeOrFallback(groupDir);
if (fallback.type === 'none') {
console.error(`No contract data found. Run: gitnexus group sync ${name}`);
process.exitCode = 1;
return;
}
if (fallback.type === 'json') console.warn(fallback.deprecationWarning);
if (fallback.type === 'bridge') await closeBridgeDb(fallback.handle);
const repoGroupPath = opts.repo as string;
const targetSymbol = opts.target as string;

View file

@ -0,0 +1,304 @@
import fsp from 'node:fs/promises';
import path from 'node:path';
import { createHash } from 'node:crypto';
import lbug from '@ladybugdb/core';
import type { LbugValue } from '@ladybugdb/core';
import type { BridgeHandle, BridgeMeta, StoredContract, CrossLink, RepoSnapshot } from './types.js';
import { BRIDGE_SCHEMA_QUERIES, BRIDGE_SCHEMA_VERSION } from './bridge-schema.js';
export function contractNodeId(
repo: string,
contractId: string,
role: string,
filePath: string,
): string {
return createHash('sha256').update(`${repo}\0${contractId}\0${role}\0${filePath}`).digest('hex');
}
export async function openBridgeDb(dbPath: string): Promise<BridgeHandle> {
const parentDir = path.dirname(dbPath);
await fsp.mkdir(parentDir, { recursive: true });
const db = new lbug.Database(dbPath, 0, false, false); // writable
const conn = new lbug.Connection(db);
return { _db: db, _conn: conn, groupDir: parentDir } as BridgeHandle;
}
export async function ensureBridgeSchema(handle: BridgeHandle): Promise<void> {
const conn = handle._conn as lbug.Connection;
for (const q of BRIDGE_SCHEMA_QUERIES) {
try {
await conn.query(q);
} catch (err: any) {
const msg = err?.message ?? '';
if (!msg.includes('already exists')) throw err;
}
}
}
export async function queryBridge<T>(
handle: BridgeHandle,
cypher: string,
params?: Record<string, LbugValue>,
): Promise<T[]> {
const conn = handle._conn as lbug.Connection;
if (params && Object.keys(params).length > 0) {
const stmt = await conn.prepare(cypher);
if (!stmt.isSuccess()) {
const errMsg = await stmt.getErrorMessage();
throw new Error(`Bridge query prepare failed: ${errMsg}`);
}
const queryResult = await conn.execute(stmt, params);
const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
return (await result.getAll()) as T[];
}
const queryResult = await conn.query(cypher);
const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
return (await result.getAll()) as T[];
}
export async function closeBridgeDb(handle: BridgeHandle): Promise<void> {
try {
await (handle._conn as lbug.Connection).close();
} catch {
/* ignore */
}
try {
await (handle._db as lbug.Database).close();
} catch {
/* ignore */
}
}
/* ------------------------------------------------------------------ */
/* retryRename — handles transient EBUSY/EPERM/EACCES on Windows */
/* ------------------------------------------------------------------ */
const RETRY_CODES = new Set(['EBUSY', 'EPERM', 'EACCES']);
async function retryRename(src: string, dst: string, attempts = 3): Promise<void> {
for (let i = 1; i <= attempts; i++) {
try {
await fsp.rename(src, dst);
return;
} catch (err: any) {
if (!RETRY_CODES.has(err.code) || i === attempts) throw err;
await new Promise((r) => setTimeout(r, 100 * Math.pow(2, i - 1)));
}
}
}
/* ------------------------------------------------------------------ */
/* writeBridgeMeta / readBridgeMeta */
/* ------------------------------------------------------------------ */
export async function writeBridgeMeta(groupDir: string, meta: BridgeMeta): Promise<void> {
const target = path.join(groupDir, 'meta.json');
const tmp = `${target}.tmp.${Date.now()}`;
await fsp.writeFile(tmp, JSON.stringify(meta, null, 2), 'utf-8');
await fsp.rename(tmp, target);
}
export async function readBridgeMeta(groupDir: string): Promise<BridgeMeta> {
try {
const content = await fsp.readFile(path.join(groupDir, 'meta.json'), 'utf-8');
return JSON.parse(content) as BridgeMeta;
} catch {
return { version: 0, generatedAt: '', missingRepos: [] };
}
}
/* ------------------------------------------------------------------ */
/* writeBridge — atomic write-to-temp-then-rename */
/* ------------------------------------------------------------------ */
export interface WriteBridgeInput {
contracts: StoredContract[];
crossLinks: CrossLink[];
repoSnapshots: Record<string, RepoSnapshot>;
missingRepos: string[];
}
export async function writeBridge(groupDir: string, input: WriteBridgeInput): Promise<void> {
await fsp.mkdir(groupDir, { recursive: true });
const finalPath = path.join(groupDir, 'bridge.lbug');
const tmpPath = path.join(groupDir, 'bridge.lbug.tmp');
const bakPath = path.join(groupDir, 'bridge.lbug.bak');
// Clean up any leftover tmp
try {
await fsp.rm(tmpPath, { recursive: true, force: true });
} catch {
/* ignore */
}
// 1. Create temp DB, insert all data
const handle = await openBridgeDb(tmpPath);
await ensureBridgeSchema(handle);
// Insert contracts
for (const c of input.contracts) {
const id = contractNodeId(c.repo, c.contractId, c.role, c.symbolRef.filePath);
await queryBridge(
handle,
`CREATE (n:Contract {
id: $id,
contractId: $contractId,
type: $type,
role: $role,
repo: $repo,
service: $service,
symbolUid: $symbolUid,
filePath: $filePath,
symbolName: $symbolName,
confidence: $confidence,
meta: $meta
})`,
{
id,
contractId: c.contractId,
type: c.type,
role: c.role,
repo: c.repo,
service: c.service ?? '',
symbolUid: c.symbolUid,
filePath: c.symbolRef.filePath,
symbolName: c.symbolName,
confidence: c.confidence,
meta: JSON.stringify(c.meta),
},
);
}
// Insert repo snapshots
for (const [repoId, snap] of Object.entries(input.repoSnapshots)) {
await queryBridge(
handle,
`CREATE (s:RepoSnapshot {
id: $id,
indexedAt: $indexedAt,
lastCommit: $lastCommit
})`,
{
id: repoId,
indexedAt: snap.indexedAt,
lastCommit: snap.lastCommit,
},
);
}
// Insert cross-links (tolerating missing nodes).
// Use repo-scoped matching: find FROM node by (repo, role=consumer) and TO by (repo, role=provider)
// with symbolRef matching, because link.contractId is the consumer's ID which may differ
// from the provider's contractId (e.g. wildcard consumer vs method-level provider).
for (const link of input.crossLinks) {
// Find from-node: match by repo + role + symbolRef (consumer side)
const fromRows = await queryBridge<{ id: string }>(
handle,
`MATCH (c:Contract) WHERE c.repo = $repo AND c.role = 'consumer'
AND c.filePath = $fp AND c.symbolName = $sn RETURN c.id AS id LIMIT 1`,
{ repo: link.from.repo, fp: link.from.symbolRef.filePath, sn: link.from.symbolRef.name },
);
// Find to-node: match by repo + role + symbolRef (provider side)
const toRows = await queryBridge<{ id: string }>(
handle,
`MATCH (c:Contract) WHERE c.repo = $repo AND c.role = 'provider'
AND c.filePath = $fp AND c.symbolName = $sn RETURN c.id AS id LIMIT 1`,
{ repo: link.to.repo, fp: link.to.symbolRef.filePath, sn: link.to.symbolRef.name },
);
if (!fromRows.length || !toRows.length) continue;
const fromId = fromRows[0].id;
const toId = toRows[0].id;
await queryBridge(
handle,
`
MATCH (a:Contract), (b:Contract)
WHERE a.id = $fromId AND b.id = $toId
CREATE (a)-[:ContractLink {
matchType: $matchType,
confidence: $confidence,
contractId: $contractId,
fromRepo: $fromRepo,
toRepo: $toRepo
}]->(b)
`,
{
fromId,
toId,
matchType: link.matchType,
confidence: link.confidence,
contractId: link.contractId,
fromRepo: link.from.repo,
toRepo: link.to.repo,
},
);
}
// 2. Close temp DB
await closeBridgeDb(handle);
// 3. Atomic swap: old→.bak, tmp→final, rm .bak
try {
await fsp.access(finalPath);
await retryRename(finalPath, bakPath);
} catch {
/* no existing db */
}
await retryRename(tmpPath, finalPath);
try {
await fsp.rm(bakPath, { recursive: true, force: true });
} catch {
/* ignore */
}
// 4. Write meta.json
await writeBridgeMeta(groupDir, {
version: BRIDGE_SCHEMA_VERSION,
generatedAt: new Date().toISOString(),
missingRepos: input.missingRepos,
});
}
/* ------------------------------------------------------------------ */
/* openBridgeDbReadOnly */
/* ------------------------------------------------------------------ */
export async function openBridgeDbReadOnly(groupDir: string): Promise<BridgeHandle | null> {
const dbPath = path.join(groupDir, 'bridge.lbug');
try {
await fsp.access(dbPath);
} catch {
// Check for .bak recovery
const bakPath = path.join(groupDir, 'bridge.lbug.bak');
try {
await fsp.access(bakPath);
await fsp.rename(bakPath, dbPath);
} catch {
return null;
}
}
// Version gate: check meta.json version compatibility
const meta = await readBridgeMeta(groupDir);
if (meta.version > 0 && meta.version !== BRIDGE_SCHEMA_VERSION) {
return null; // incompatible schema version — fallback to JSON or re-sync
}
try {
const db = new lbug.Database(dbPath, 0, false, true); // readOnly
const conn = new lbug.Connection(db);
return { _db: db, _conn: conn, groupDir } as BridgeHandle;
} catch {
return null;
}
}
/* ------------------------------------------------------------------ */
/* bridgeExists */
/* ------------------------------------------------------------------ */
export async function bridgeExists(groupDir: string): Promise<boolean> {
const handle = await openBridgeDbReadOnly(groupDir);
if (!handle) return false;
await closeBridgeDb(handle);
return true;
}

View file

@ -0,0 +1,42 @@
/**
* Bridge LadybugDB schema for cross-repo Contract Registry.
* Separate from per-repo schema in lbug/schema.ts.
*/
export const BRIDGE_SCHEMA_VERSION = 1;
export const CONTRACT_SCHEMA = `
CREATE NODE TABLE Contract (
id STRING,
contractId STRING,
type STRING,
role STRING,
repo STRING,
service STRING DEFAULT '',
symbolUid STRING DEFAULT '',
filePath STRING DEFAULT '',
symbolName STRING DEFAULT '',
confidence DOUBLE DEFAULT 0.0,
meta STRING DEFAULT '{}',
PRIMARY KEY (id)
)`;
export const REPO_SNAPSHOT_SCHEMA = `
CREATE NODE TABLE RepoSnapshot (
id STRING,
indexedAt STRING DEFAULT '',
lastCommit STRING DEFAULT '',
PRIMARY KEY (id)
)`;
export const CONTRACT_LINK_SCHEMA = `
CREATE REL TABLE ContractLink (
FROM Contract TO Contract,
matchType STRING,
confidence DOUBLE,
contractId STRING,
fromRepo STRING,
toRepo STRING
)`;
export const BRIDGE_SCHEMA_QUERIES = [CONTRACT_SCHEMA, REPO_SNAPSHOT_SCHEMA, CONTRACT_LINK_SCHEMA];

View file

@ -6,7 +6,7 @@ import type {
OutOfScopeLink,
} from './types.js';
export interface GroupImpactOptions {
export interface LegacyGroupImpactOptions {
groupName: string;
target: string;
repoPath: string;
@ -25,6 +25,26 @@ export interface GroupImpactOptions {
crossDepth?: number;
}
export interface GroupImpactOptions {
groupName: string;
target: string;
repoPath: string;
direction: 'upstream' | 'downstream';
bridgeQuery: <T>(cypher: string, params: Record<string, unknown>) => Promise<T[]>;
localImpactFn: (target: string, direction: string) => Promise<unknown>;
crossImpactFn: (
targetGroupPath: string,
symbolUid: string,
direction: string,
hint?: { filePath: string; symbolName: string },
) => Promise<unknown | null>;
maxDepth?: number;
minConfidence?: number;
subgroup?: string;
timeout?: number;
crossDepth?: number;
}
function collectPhase1Uids(local: Record<string, unknown>): Set<string> {
const uids = new Set<string>();
const target = local.target as { id?: string } | undefined;
@ -97,7 +117,9 @@ function mergeRisk(
return order[idx] ?? base;
}
export async function runGroupImpact(opts: GroupImpactOptions): Promise<GroupImpactResult> {
export async function runGroupImpactLegacy(
opts: LegacyGroupImpactOptions,
): Promise<GroupImpactResult> {
const timeout = opts.timeout ?? 30000;
const minConfidence = opts.minConfidence ?? 0.5;
const crossDepth = Math.min(1, opts.crossDepth ?? 1);
@ -223,3 +245,184 @@ export async function runGroupImpact(opts: GroupImpactOptions): Promise<GroupImp
risk,
};
}
/* ------------------------------------------------------------------ */
/* Cypher-based Phase 2 */
/* ------------------------------------------------------------------ */
const UPSTREAM_QUERY_BASE = `
MATCH (consumer:Contract)-[l:ContractLink]->(provider:Contract)
WHERE provider.repo = $sourceRepo
AND (provider.symbolUid IN $localUids
OR (NOT provider.symbolUid IN $localUids AND (provider.filePath + '::' + provider.symbolName) IN $localRefs))
AND l.confidence >= $minConfidence`;
const UPSTREAM_QUERY_SUBGROUP = ` AND (consumer.repo = $subgroup OR consumer.repo STARTS WITH $subgroup + '/')`;
const UPSTREAM_QUERY_RETURN = `
RETURN consumer.repo AS fanOutRepo, consumer.symbolUid AS fanOutUid,
consumer.filePath AS fanOutFilePath, consumer.symbolName AS fanOutSymbolName,
provider.symbolUid AS matchedLocalUid,
provider.filePath AS matchedLocalFilePath,
provider.symbolName AS matchedLocalSymbolName,
l.matchType AS matchType, l.confidence AS confidence, l.contractId AS contractId,
consumer.type AS contractType
ORDER BY l.confidence DESC`;
const DOWNSTREAM_QUERY_BASE = `
MATCH (consumer:Contract)-[l:ContractLink]->(provider:Contract)
WHERE consumer.repo = $sourceRepo
AND (consumer.symbolUid IN $localUids
OR (NOT consumer.symbolUid IN $localUids AND (consumer.filePath + '::' + consumer.symbolName) IN $localRefs))
AND l.confidence >= $minConfidence`;
const DOWNSTREAM_QUERY_SUBGROUP = ` AND (provider.repo = $subgroup OR provider.repo STARTS WITH $subgroup + '/')`;
const DOWNSTREAM_QUERY_RETURN = `
RETURN provider.repo AS fanOutRepo, provider.symbolUid AS fanOutUid,
provider.filePath AS fanOutFilePath, provider.symbolName AS fanOutSymbolName,
consumer.symbolUid AS matchedLocalUid,
consumer.filePath AS matchedLocalFilePath,
consumer.symbolName AS matchedLocalSymbolName,
l.matchType AS matchType, l.confidence AS confidence, l.contractId AS contractId,
consumer.type AS contractType
ORDER BY l.confidence DESC`;
interface CrossImpactRow {
fanOutRepo: string;
fanOutUid: string;
fanOutFilePath: string;
fanOutSymbolName: string;
matchedLocalUid: string;
matchedLocalFilePath: string;
matchedLocalSymbolName: string;
matchType: string;
confidence: number;
contractId: string;
contractType: string;
}
export async function runGroupImpact(opts: GroupImpactOptions): Promise<GroupImpactResult> {
const timeout = opts.timeout ?? 30000;
const minConfidence = opts.minConfidence ?? 0.5;
const crossDepth = Math.min(1, opts.crossDepth ?? 1);
const tStart = Date.now();
const wallDeadline = tStart + timeout;
const phase1Timeout = Math.min(5000, timeout);
const localResult = await Promise.race([
opts.localImpactFn(opts.target, opts.direction).then((v) => ({ ok: true as const, v })),
new Promise<{ ok: false }>((resolve) =>
setTimeout(() => resolve({ ok: false }), phase1Timeout),
),
]);
let truncated = !localResult.ok;
const local = localResult.ok
? (localResult.v as Record<string, unknown>)
: ({
target: { id: '', name: opts.target, filePath: '' },
direction: opts.direction,
impactedCount: 0,
risk: 'LOW',
summary: { direct: 0, processes_affected: 0, modules_affected: 0 },
affected_processes: [],
affected_modules: [],
byDepth: {},
} as Record<string, unknown>);
const uids = collectPhase1Uids(local);
const phase1Refs = collectPhase1Refs(local);
const cross: CrossRepoImpact[] = [];
const truncatedRepos: string[] = [];
/* Phase 2 — Cypher bridge query */
const normalizedSubgroup = opts.subgroup?.trim().replace(/\/+$/, '') || null;
const isUpstream = opts.direction === 'upstream';
const queryBase = isUpstream ? UPSTREAM_QUERY_BASE : DOWNSTREAM_QUERY_BASE;
const querySubgroup = isUpstream ? UPSTREAM_QUERY_SUBGROUP : DOWNSTREAM_QUERY_SUBGROUP;
const queryReturn = isUpstream ? UPSTREAM_QUERY_RETURN : DOWNSTREAM_QUERY_RETURN;
const cypher = normalizedSubgroup
? queryBase + querySubgroup + queryReturn
: queryBase + queryReturn;
const queryParams: Record<string, unknown> = {
sourceRepo: opts.repoPath,
localUids: [...uids],
localRefs: [...phase1Refs],
minConfidence,
};
if (normalizedSubgroup) {
queryParams.subgroup = normalizedSubgroup;
}
const rows = await opts.bridgeQuery<CrossImpactRow>(cypher, queryParams);
let maxCrossConf = 0;
const distinctRepos = new Set<string>();
for (const row of rows) {
if (Date.now() > wallDeadline) {
truncated = true;
break;
}
if (crossDepth < 1) break;
const hint = row.fanOutUid
? undefined
: { filePath: row.fanOutFilePath, symbolName: row.fanOutSymbolName };
const remote = await opts.crossImpactFn(row.fanOutRepo, row.fanOutUid, opts.direction, hint);
if (remote && typeof remote === 'object' && !('error' in (remote as Record<string, unknown>))) {
maxCrossConf = Math.max(maxCrossConf, row.confidence);
distinctRepos.add(row.fanOutRepo);
const r = remote as Record<string, unknown>;
cross.push({
repo: row.fanOutRepo,
repo_path: row.fanOutRepo,
contract: {
id: row.contractId,
type: row.contractType as CrossRepoImpact['contract']['type'],
match_type: row.matchType as CrossRepoImpact['contract']['match_type'],
confidence: row.confidence,
},
by_depth: (r.byDepth || {}) as Record<string, unknown[]>,
affected_processes: (r.affected_processes || []) as string[],
});
}
if (Date.now() > wallDeadline) {
truncated = true;
truncatedRepos.push(row.fanOutRepo);
break;
}
}
const summaryLocal = (local.summary || {}) as {
direct?: number;
processes_affected?: number;
modules_affected?: number;
};
const baseRisk = String(local.risk || 'LOW');
const risk = mergeRisk(baseRisk, cross.length, maxCrossConf, distinctRepos.size);
return {
local,
group: opts.groupName,
cross,
outOfScope: [],
truncated,
truncatedRepos,
summary: {
direct: summaryLocal.direct ?? 0,
processes_affected: summaryLocal.processes_affected ?? 0,
modules_affected: summaryLocal.modules_affected ?? 0,
cross_repo_hits: cross.length,
},
risk,
};
}

View file

@ -75,6 +75,84 @@ function makeContract(
};
}
export interface ProtoServiceInfo {
package: string;
serviceName: string;
methods: string[];
protoPath: string;
}
export async function buildProtoMap(repoPath: string): Promise<Map<string, ProtoServiceInfo[]>> {
const map = new Map<string, ProtoServiceInfo[]>();
const protoFiles = await glob('**/*.proto', {
cwd: repoPath,
absolute: false,
nodir: true,
ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**'],
});
for (const rel of protoFiles) {
const content = readSafe(repoPath, rel);
if (!content) continue;
const pkgMatch = content.match(/^\s*package\s+([\w.]+)\s*;/m);
const pkg = pkgMatch?.[1] ?? '';
const serviceBlocks = extractServiceBlocks(content);
for (const block of serviceBlocks) {
const rpcRe = /rpc\s+(\w+)\s*\(/g;
const methods: string[] = [];
let m: RegExpExecArray | null;
while ((m = rpcRe.exec(block.body)) !== null) {
methods.push(m[1]);
}
const info: ProtoServiceInfo = {
package: pkg,
serviceName: block.name,
methods,
protoPath: rel.replace(/\\/g, '/'),
};
const existing = map.get(block.name) ?? [];
existing.push(info);
map.set(block.name, existing);
}
}
return map;
}
export function resolveProtoConflict(
_serviceName: string,
sourceFilePath: string,
candidates: ProtoServiceInfo[],
): ProtoServiceInfo | null {
if (candidates.length === 0) return null;
if (candidates.length === 1) return candidates[0];
// Directory proximity heuristic
const sourceDir = path.dirname(sourceFilePath).replace(/\\/g, '/');
let best = candidates[0];
let bestScore = 0;
for (const c of candidates) {
const protoDir = path.dirname(c.protoPath).replace(/\\/g, '/');
let shared = 0;
const min = Math.min(sourceDir.length, protoDir.length);
for (let i = 0; i < min; i++) {
if (sourceDir[i] === protoDir[i]) shared++;
else break;
}
if (shared > bestScore) {
bestScore = shared;
best = c;
}
}
return best;
}
export function serviceContractId(pkg: string, serviceName: string): string {
const prefix = pkg ? `${pkg}.${serviceName}` : serviceName;
return `grpc::${prefix}/*`;
}
export class GrpcExtractor implements ContractExtractor {
type = 'grpc' as const;
@ -100,6 +178,9 @@ export class GrpcExtractor implements ContractExtractor {
if (content) out.push(...this.parseProtoFile(content, rel));
}
// Build proto map for source scanner resolution
const protoMap = await buildProtoMap(repoPath);
// Source files — server/client detection
const sourceFiles = await glob('**/*.{go,java,py,ts,tsx,js,jsx}', {
cwd: repoPath,
@ -112,16 +193,16 @@ export class GrpcExtractor implements ContractExtractor {
const ext = path.extname(rel).toLowerCase();
if (ext === '.go') {
out.push(...this.scanGoProviders(content, rel));
out.push(...this.scanGoConsumers(content, rel));
out.push(...this.scanGoProviders(content, rel, protoMap));
out.push(...this.scanGoConsumers(content, rel, protoMap));
} else if (ext === '.java') {
out.push(...this.scanJavaProviders(content, rel));
out.push(...this.scanJavaConsumers(content, rel));
out.push(...this.scanJavaProviders(content, rel, protoMap));
out.push(...this.scanJavaConsumers(content, rel, protoMap));
} else if (ext === '.py') {
out.push(...this.scanPythonProviders(content, rel));
out.push(...this.scanPythonConsumers(content, rel));
out.push(...this.scanPythonProviders(content, rel, protoMap));
out.push(...this.scanPythonConsumers(content, rel, protoMap));
} else if (['.ts', '.tsx', '.js', '.jsx'].includes(ext)) {
out.push(...this.scanTsProviders(content, rel));
out.push(...this.scanTsProviders(content, rel, protoMap));
}
}
@ -154,7 +235,11 @@ export class GrpcExtractor implements ContractExtractor {
return out;
}
private scanGoProviders(content: string, filePath: string): ExtractedContract[] {
private scanGoProviders(
content: string,
filePath: string,
protoMap: Map<string, ProtoServiceInfo[]>,
): ExtractedContract[] {
const out: ExtractedContract[] = [];
// pb.RegisterXxxServer(
@ -162,15 +247,17 @@ export class GrpcExtractor implements ContractExtractor {
let m: RegExpExecArray | null;
while ((m = registerRe.exec(content)) !== null) {
const serviceName = m[1];
const candidates = protoMap.get(serviceName);
const proto = resolveProtoConflict(serviceName, filePath, candidates ?? []);
const cid = proto
? serviceContractId(proto.package, proto.serviceName)
: serviceOnlyContractId(serviceName);
const conf = proto ? 0.8 : 0.65;
out.push(
makeContract(
serviceOnlyContractId(serviceName),
'provider',
filePath,
`Register${serviceName}Server`,
0.8,
{ service: serviceName, source: 'go_register' },
),
makeContract(cid, 'provider', filePath, `Register${serviceName}Server`, conf, {
service: serviceName,
source: 'go_register',
}),
);
}
@ -178,51 +265,74 @@ export class GrpcExtractor implements ContractExtractor {
const unimplRe = /\w+\.Unimplemented(\w+)Server\b/g;
while ((m = unimplRe.exec(content)) !== null) {
const serviceName = m[1];
const candidates = protoMap.get(serviceName);
const proto = resolveProtoConflict(serviceName, filePath, candidates ?? []);
const cid = proto
? serviceContractId(proto.package, proto.serviceName)
: serviceOnlyContractId(serviceName);
const conf = proto ? 0.8 : 0.65;
out.push(
makeContract(
serviceOnlyContractId(serviceName),
'provider',
filePath,
`Unimplemented${serviceName}Server`,
0.8,
{ service: serviceName, source: 'go_unimplemented' },
),
makeContract(cid, 'provider', filePath, `Unimplemented${serviceName}Server`, conf, {
service: serviceName,
source: 'go_unimplemented',
}),
);
}
return out;
}
private scanGoConsumers(content: string, filePath: string): ExtractedContract[] {
private scanGoConsumers(
content: string,
filePath: string,
protoMap: Map<string, ProtoServiceInfo[]>,
): ExtractedContract[] {
const out: ExtractedContract[] = [];
const re = /\w+\.New(\w+)Client\s*\(/g;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
const serviceName = m[1];
const candidates = protoMap.get(serviceName);
const proto = resolveProtoConflict(serviceName, filePath, candidates ?? []);
const cid = proto
? serviceContractId(proto.package, proto.serviceName)
: serviceOnlyContractId(serviceName);
const conf = proto ? 0.75 : 0.55;
out.push(
makeContract(
serviceOnlyContractId(serviceName),
'consumer',
filePath,
`New${serviceName}Client`,
0.7,
{ service: serviceName, source: 'go_client' },
),
makeContract(cid, 'consumer', filePath, `New${serviceName}Client`, conf, {
service: serviceName,
source: 'go_client',
}),
);
}
return out;
}
private scanJavaProviders(content: string, filePath: string): ExtractedContract[] {
private scanJavaProviders(
content: string,
filePath: string,
protoMap: Map<string, ProtoServiceInfo[]>,
): ExtractedContract[] {
const out: ExtractedContract[] = [];
const resolveJava = (svcName: string): { cid: string; conf: number } => {
const candidates = protoMap.get(svcName);
const proto = resolveProtoConflict(svcName, filePath, candidates ?? []);
const cid = proto
? serviceContractId(proto.package, proto.serviceName)
: serviceOnlyContractId(svcName);
const conf = proto ? 0.8 : 0.65;
return { cid, conf };
};
// @GrpcService
if (content.includes('@GrpcService')) {
const implBaseRe = /extends\s+(\w+)Grpc\.(\w+)ImplBase/;
const m = content.match(implBaseRe);
if (m) {
const { cid, conf } = resolveJava(m[1]);
out.push(
makeContract(serviceOnlyContractId(m[1]), 'provider', filePath, m[2], 0.8, {
makeContract(cid, 'provider', filePath, m[2], conf, {
service: m[1],
source: 'java_grpc_service',
}),
@ -234,8 +344,9 @@ export class GrpcExtractor implements ContractExtractor {
const cm = content.match(classRe);
if (cm) {
const svcName = cm[2].replace(/Grpc$/, '');
const { cid, conf } = resolveJava(svcName);
out.push(
makeContract(serviceOnlyContractId(svcName), 'provider', filePath, cm[1], 0.8, {
makeContract(cid, 'provider', filePath, cm[1], conf, {
service: svcName,
source: 'java_grpc_service',
}),
@ -250,8 +361,9 @@ export class GrpcExtractor implements ContractExtractor {
const m = content.match(implRe);
if (m) {
const svcName = m[2] || m[1].replace(/Grpc$/, '');
const { cid, conf } = resolveJava(svcName);
out.push(
makeContract(serviceOnlyContractId(svcName), 'provider', filePath, svcName, 0.8, {
makeContract(cid, 'provider', filePath, svcName, conf, {
service: svcName,
source: 'java_impl_base',
}),
@ -262,49 +374,65 @@ export class GrpcExtractor implements ContractExtractor {
return out;
}
private scanJavaConsumers(content: string, filePath: string): ExtractedContract[] {
private scanJavaConsumers(
content: string,
filePath: string,
protoMap: Map<string, ProtoServiceInfo[]>,
): ExtractedContract[] {
const out: ExtractedContract[] = [];
// XxxGrpc.newBlockingStub( or XxxGrpc.newStub(
const re = /(\w+)Grpc\.new(?:Blocking)?Stub\s*\(/g;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
const serviceName = m[1];
const candidates = protoMap.get(serviceName);
const proto = resolveProtoConflict(serviceName, filePath, candidates ?? []);
const cid = proto
? serviceContractId(proto.package, proto.serviceName)
: serviceOnlyContractId(serviceName);
const conf = proto ? 0.75 : 0.55;
out.push(
makeContract(
serviceOnlyContractId(serviceName),
'consumer',
filePath,
`${serviceName}Stub`,
0.7,
{ service: serviceName, source: 'java_stub' },
),
makeContract(cid, 'consumer', filePath, `${serviceName}Stub`, conf, {
service: serviceName,
source: 'java_stub',
}),
);
}
return out;
}
private scanPythonProviders(content: string, filePath: string): ExtractedContract[] {
private scanPythonProviders(
content: string,
filePath: string,
protoMap: Map<string, ProtoServiceInfo[]>,
): ExtractedContract[] {
const out: ExtractedContract[] = [];
// add_XxxServicer_to_server(
const re = /add_(\w+?)Servicer_to_server\s*\(/g;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
const serviceName = m[1];
const candidates = protoMap.get(serviceName);
const proto = resolveProtoConflict(serviceName, filePath, candidates ?? []);
const cid = proto
? serviceContractId(proto.package, proto.serviceName)
: serviceOnlyContractId(serviceName);
const conf = proto ? 0.8 : 0.65;
out.push(
makeContract(
serviceOnlyContractId(serviceName),
'provider',
filePath,
`add_${serviceName}Servicer_to_server`,
0.8,
{ service: serviceName, source: 'python_servicer' },
),
makeContract(cid, 'provider', filePath, `add_${serviceName}Servicer_to_server`, conf, {
service: serviceName,
source: 'python_servicer',
}),
);
}
return out;
}
private scanPythonConsumers(content: string, filePath: string): ExtractedContract[] {
private scanPythonConsumers(
content: string,
filePath: string,
protoMap: Map<string, ProtoServiceInfo[]>,
): ExtractedContract[] {
const out: ExtractedContract[] = [];
// XxxStub(
const re = /(\w+)Stub\s*\(/g;
@ -313,8 +441,14 @@ export class GrpcExtractor implements ContractExtractor {
const name = m[1];
// Filter out common false positives
if (['Mock', 'Test', 'Fake', 'Stub'].includes(name)) continue;
const candidates = protoMap.get(name);
const proto = resolveProtoConflict(name, filePath, candidates ?? []);
const cid = proto
? serviceContractId(proto.package, proto.serviceName)
: serviceOnlyContractId(name);
const conf = proto ? 0.75 : 0.55;
out.push(
makeContract(serviceOnlyContractId(name), 'consumer', filePath, `${name}Stub`, 0.7, {
makeContract(cid, 'consumer', filePath, `${name}Stub`, conf, {
service: name,
source: 'python_stub',
}),
@ -323,7 +457,11 @@ export class GrpcExtractor implements ContractExtractor {
return out;
}
private scanTsProviders(content: string, filePath: string): ExtractedContract[] {
private scanTsProviders(
content: string,
filePath: string,
protoMap: Map<string, ProtoServiceInfo[]>,
): ExtractedContract[] {
const out: ExtractedContract[] = [];
// @GrpcMethod('ServiceName', 'MethodName')
const re = /@GrpcMethod\s*\(\s*['"](\w+)['"]\s*,\s*['"](\w+)['"]\s*\)/g;
@ -331,7 +469,10 @@ export class GrpcExtractor implements ContractExtractor {
while ((m = re.exec(content)) !== null) {
const serviceName = m[1];
const methodName = m[2];
const cid = contractId('', serviceName, methodName);
const candidates = protoMap.get(serviceName);
const proto = resolveProtoConflict(serviceName, filePath, candidates ?? []);
const pkg = proto?.package ?? '';
const cid = contractId(pkg, serviceName, methodName);
out.push(
makeContract(cid, 'provider', filePath, `${serviceName}.${methodName}`, 0.8, {
service: serviceName,

View file

@ -5,6 +5,15 @@ export interface MatchResult {
unmatched: StoredContract[];
}
export interface WildcardMatchResult {
matched: CrossLink[];
remaining: StoredContract[];
}
function isGrpcWildcard(cid: string): boolean {
return cid.startsWith('grpc::') && cid.endsWith('/*');
}
export function normalizeContractId(id: string): string {
const colonIdx = id.indexOf('::');
if (colonIdx === -1) return id;
@ -66,27 +75,36 @@ function findMatchingKeys(contractId: string, index: Map<string, StoredContract[
return [];
}
export function runExactMatch(contracts: StoredContract[]): MatchResult {
export function buildProviderIndex(contracts: StoredContract[]): Map<string, StoredContract[]> {
const providers = contracts.filter((c) => c.role === 'provider');
const consumers = contracts.filter((c) => c.role === 'consumer');
const providerIndex = new Map<string, StoredContract[]>();
const index = new Map<string, StoredContract[]>();
for (const p of providers) {
const key = normalizeContractId(p.contractId);
const list = providerIndex.get(key) || [];
const list = index.get(key) || [];
list.push(p);
providerIndex.set(key, list);
index.set(key, list);
}
return index;
}
export function runExactMatch(
contracts: StoredContract[],
providerIndex?: Map<string, StoredContract[]>,
): MatchResult {
const index = providerIndex ?? buildProviderIndex(contracts);
// Skip gRPC wildcard consumers — they go to wildcard pass only
const consumers = contracts.filter((c) => c.role === 'consumer' && !isGrpcWildcard(c.contractId));
const matched: CrossLink[] = [];
const matchedConsumerIds = new Set<string>();
const matchedProviderIds = new Set<string>();
for (const consumer of consumers) {
const matchingKeys = findMatchingKeys(consumer.contractId, providerIndex);
const matchingKeys = findMatchingKeys(consumer.contractId, index);
if (matchingKeys.length === 0) continue;
const allMatchingProviders = matchingKeys.flatMap((k) => providerIndex.get(k) || []);
const allMatchingProviders = matchingKeys.flatMap((k) => index.get(k) || []);
for (const provider of allMatchingProviders) {
if (provider.repo === consumer.repo) {
if (!provider.service || !consumer.service || provider.service === consumer.service) {
@ -118,10 +136,86 @@ export function runExactMatch(contracts: StoredContract[]): MatchResult {
}
}
const unmatched = contracts.filter((c) => {
// normalUnmatched: contracts that weren't matched in exact pass
const normalUnmatched = contracts.filter((c) => {
if (isGrpcWildcard(c.contractId)) return false; // excluded from exact, handled separately
const id = `${c.repo}::${c.contractId}`;
return c.role === 'provider' ? !matchedProviderIds.has(id) : !matchedConsumerIds.has(id);
});
// Re-add gRPC wildcard contracts — they were never in exact matching
const grpcWildcards = contracts.filter((c) => isGrpcWildcard(c.contractId));
const unmatched = [...normalUnmatched, ...grpcWildcards];
return { matched, unmatched };
}
export function runWildcardMatch(
unmatched: StoredContract[],
providerIndex: Map<string, StoredContract[]>,
): WildcardMatchResult {
const wildcardConsumers = unmatched.filter(
(c) => c.role === 'consumer' && isGrpcWildcard(c.contractId),
);
const matched: CrossLink[] = [];
const matchedConsumerIds = new Set<string>();
for (const consumer of wildcardConsumers) {
const normalized = normalizeContractId(consumer.contractId);
// "grpc::com.example.userservice/*" → "com.example.userservice"
// "grpc::userservice/*" → "userservice"
const fqService = normalized.slice(normalized.indexOf('::') + 2, -2); // strip "grpc::" and "/*"
for (const [key, providers] of providerIndex) {
// Only match against non-wildcard gRPC providers (method-level IDs)
if (!key.startsWith('grpc::') || key.endsWith('/*')) continue;
const afterPrefix = key.slice(6); // strip "grpc::"
const slashIdx = afterPrefix.indexOf('/');
if (slashIdx < 0) continue;
const providerFqService = afterPrefix.slice(0, slashIdx);
// Match: exact FQ service, or bare-name match when consumer has no package
const isMatch =
providerFqService === fqService ||
(!fqService.includes('.') && providerFqService.endsWith('.' + fqService));
if (!isMatch) continue;
for (const provider of providers) {
// Skip same-repo same-service (same logic as runExactMatch)
if (provider.repo === consumer.repo) {
if (!provider.service || !consumer.service || provider.service === consumer.service) {
continue;
}
}
matched.push({
from: {
repo: consumer.repo,
service: consumer.service,
symbolUid: consumer.symbolUid,
symbolRef: consumer.symbolRef,
},
to: {
repo: provider.repo,
service: provider.service,
symbolUid: provider.symbolUid,
symbolRef: provider.symbolRef,
},
type: consumer.type,
contractId: consumer.contractId, // consumer's wildcard ID
matchType: 'wildcard',
confidence: Math.min(provider.confidence, consumer.confidence),
});
matchedConsumerIds.add(`${consumer.repo}::${consumer.contractId}`);
}
}
}
const remaining = unmatched.filter((c) => {
if (c.role !== 'consumer' || !isGrpcWildcard(c.contractId)) return true;
return !matchedConsumerIds.has(`${c.repo}::${c.contractId}`);
});
return { matched, remaining };
}

View file

@ -4,9 +4,10 @@
*/
import { checkStaleness } from '../git-staleness.js';
import { queryBridge, closeBridgeDb } from './bridge-db.js';
import { loadGroupConfig } from './config-parser.js';
import { runGroupImpact } from './cross-impact.js';
import { getDefaultGitnexusDir, getGroupDir, listGroups, readContractRegistry } from './storage.js';
import { runGroupImpact, runGroupImpactLegacy } from './cross-impact.js';
import { getDefaultGitnexusDir, getGroupDir, listGroups, openBridgeOrFallback } from './storage.js';
import { syncGroup } from './sync.js';
export interface GroupRepoHandle {
@ -104,23 +105,115 @@ export class GroupService {
const name = String(params.name ?? '').trim();
if (!name) return { error: 'name is required' };
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
const registry = await readContractRegistry(groupDir);
if (!registry) {
return { error: `No contracts.json for group "${name}". Run group_sync first.` };
const fallback = await openBridgeOrFallback(groupDir);
if (fallback.type === 'none') {
return { error: `No contract data for group "${name}". Run group_sync first.` };
}
let contracts = registry.contracts;
if (params.type) contracts = contracts.filter((c) => c.type === params.type);
if (params.repo) contracts = contracts.filter((c) => c.repo === params.repo);
if (params.unmatchedOnly) {
const matchedIds = new Set(
registry.crossLinks.flatMap((l) => [
`${l.from.repo}::${l.contractId}`,
`${l.to.repo}::${l.contractId}`,
]),
if (fallback.type === 'json') {
const registry = fallback.registry;
let contracts = registry.contracts;
if (params.type) contracts = contracts.filter((c) => c.type === params.type);
if (params.repo) contracts = contracts.filter((c) => c.repo === params.repo);
if (params.unmatchedOnly) {
const matchedIds = new Set(
registry.crossLinks.flatMap((l) => [
`${l.from.repo}::${l.contractId}`,
`${l.to.repo}::${l.contractId}`,
]),
);
contracts = contracts.filter((c) => !matchedIds.has(`${c.repo}::${c.contractId}`));
}
return { contracts, crossLinks: registry.crossLinks };
}
// Bridge path — query Contract nodes with flat field projection
const handle = fallback.handle;
try {
let cypher = 'MATCH (c:Contract)';
const queryParams: Record<string, unknown> = {};
const whereClauses: string[] = [];
if (params.type) {
whereClauses.push('c.type = $type');
queryParams.type = params.type;
}
if (params.repo) {
whereClauses.push('c.repo = $repo');
queryParams.repo = params.repo;
}
if (whereClauses.length > 0) {
cypher += ` WHERE ${whereClauses.join(' AND ')}`;
}
cypher +=
' RETURN c.contractId AS contractId, c.type AS type, c.role AS role, c.repo AS repo,' +
' c.service AS service, c.symbolUid AS symbolUid, c.filePath AS filePath,' +
' c.symbolName AS symbolName, c.confidence AS confidence, c.meta AS meta';
const rawContracts = await queryBridge<{
contractId: string;
type: string;
role: string;
repo: string;
service: string;
symbolUid: string;
filePath: string;
symbolName: string;
confidence: number;
meta: string;
}>(handle, cypher, queryParams as Record<string, import('@ladybugdb/core').LbugValue>);
// Reconstruct StoredContract shape for CLI compatibility
let contracts = rawContracts.map((r) => ({
contractId: r.contractId,
type: r.type,
role: r.role,
repo: r.repo,
service: r.service,
symbolUid: r.symbolUid,
symbolRef: { filePath: r.filePath, name: r.symbolName },
symbolName: r.symbolName,
confidence: r.confidence,
meta: typeof r.meta === 'string' ? JSON.parse(r.meta) : r.meta,
}));
// Query cross-links
const rawLinks = await queryBridge<{
fromRepo: string;
toRepo: string;
matchType: string;
confidence: number;
linkContractId: string;
}>(
handle,
`MATCH (a:Contract)-[l:ContractLink]->(b:Contract)
RETURN l.fromRepo AS fromRepo, l.toRepo AS toRepo,
l.matchType AS matchType, l.confidence AS confidence,
l.contractId AS linkContractId`,
);
contracts = contracts.filter((c) => !matchedIds.has(`${c.repo}::${c.contractId}`));
const crossLinks = rawLinks.map((l) => ({
from: { repo: l.fromRepo },
to: { repo: l.toRepo },
matchType: l.matchType,
confidence: l.confidence,
contractId: l.linkContractId,
}));
// Apply unmatchedOnly filter
if (params.unmatchedOnly) {
const matchedIds = new Set(
crossLinks.flatMap((l) => [
`${l.from.repo}::${l.contractId}`,
`${l.to.repo}::${l.contractId}`,
]),
);
contracts = contracts.filter((c) => !matchedIds.has(`${c.repo}::${c.contractId}`));
}
return { contracts, crossLinks };
} finally {
await closeBridgeDb(handle);
}
return { contracts, crossLinks: registry.crossLinks };
}
async groupImpact(params: Record<string, unknown>): Promise<unknown> {
@ -146,9 +239,10 @@ export class GroupService {
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
const config = await loadGroupConfig(groupDir);
const registry = await readContractRegistry(groupDir);
if (!registry) {
return { error: `No contracts.json for group "${name}". Run group_sync first.` };
const fallback = await openBridgeOrFallback(groupDir);
if (fallback.type === 'none') {
return { error: `No contract data for group "${name}". Run group_sync first.` };
}
const requestedCrossDepth =
@ -163,56 +257,123 @@ export class GroupService {
const defaultRelTypes = ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS'];
const impactOpts = {
maxDepth,
relationTypes: defaultRelTypes,
minConfidence: 0,
includeTests: false,
};
const resolveGroupRepo = async (groupPath: string): Promise<GroupRepoHandle> => {
const registryName = config.repos[groupPath];
if (!registryName) throw new Error(`Repo "${groupPath}" not found in group "${name}"`);
return this.port.resolveRepo(registryName);
};
const result = await runGroupImpact({
groupName: name,
target: targetSymbol,
repoPath: repoGroupPath,
direction,
registry,
localImpactFn: async (t: string, d: string) => {
const repoObj = await resolveGroupRepo(repoGroupPath);
return this.port.impact(repoObj, {
target: t,
direction: d as 'upstream' | 'downstream',
maxDepth,
relationTypes: defaultRelTypes,
minConfidence: 0,
includeTests: false,
});
},
crossImpactFn: async (targetGroupPath: string, uid: string, d: string) => {
const registryName = config.repos[targetGroupPath];
if (!registryName) return null;
try {
const repoObj = await this.port.resolveRepo(registryName);
return this.port.impactByUid(repoObj.id, uid, d, {
maxDepth,
relationTypes: defaultRelTypes,
minConfidence: 0,
includeTests: false,
if (fallback.type === 'json') {
// Legacy JSON path
const result = await runGroupImpactLegacy({
groupName: name,
target: targetSymbol,
repoPath: repoGroupPath,
direction,
registry: fallback.registry,
localImpactFn: async (t: string, d: string) => {
const repoObj = await resolveGroupRepo(repoGroupPath);
return this.port.impact(repoObj, {
target: t,
direction: d as 'upstream' | 'downstream',
...impactOpts,
});
} catch {
return null;
}
},
maxDepth,
minConfidence,
subgroup,
timeout,
crossDepth,
});
},
crossImpactFn: async (targetGroupPath: string, uid: string, d: string) => {
const registryName = config.repos[targetGroupPath];
if (!registryName) return null;
try {
const repoObj = await this.port.resolveRepo(registryName);
return this.port.impactByUid(repoObj.id, uid, d, impactOpts);
} catch {
return null;
}
},
maxDepth,
minConfidence,
subgroup,
timeout,
crossDepth,
});
if (crossDepthWarning) {
(result as unknown as Record<string, unknown>).crossDepthWarning = crossDepthWarning;
if (crossDepthWarning) {
(result as unknown as Record<string, unknown>).crossDepthWarning = crossDepthWarning;
}
return result;
}
return result;
// Bridge path
const handle = fallback.handle;
try {
const result = await runGroupImpact({
groupName: name,
target: targetSymbol,
repoPath: repoGroupPath,
direction,
bridgeQuery: (cypher, p) =>
queryBridge(handle, cypher, p as Record<string, import('@ladybugdb/core').LbugValue>),
localImpactFn: async (t: string, d: string) => {
const repoObj = await resolveGroupRepo(repoGroupPath);
return this.port.impact(repoObj, {
target: t,
direction: d as 'upstream' | 'downstream',
...impactOpts,
});
},
crossImpactFn: async (
targetGroupPath: string,
uid: string,
d: string,
hint?: { filePath: string; symbolName: string },
) => {
const registryName = config.repos[targetGroupPath];
if (!registryName) return null;
try {
const repoObj = await this.port.resolveRepo(registryName);
if (uid) {
return this.port.impactByUid(repoObj.id, uid, d, impactOpts);
}
// Name-based fallback for empty UID (gRPC contracts)
if (hint?.symbolName) {
const hintResult = await this.port.impact(repoObj, {
target: hint.symbolName,
direction: d as 'upstream' | 'downstream',
...impactOpts,
});
if (
hintResult &&
typeof hintResult === 'object' &&
'error' in (hintResult as Record<string, unknown>)
)
return null;
return hintResult;
}
return null;
} catch {
return null;
}
},
maxDepth,
minConfidence,
subgroup,
timeout,
crossDepth,
});
if (crossDepthWarning) {
(result as unknown as Record<string, unknown>).crossDepthWarning = crossDepthWarning;
}
return result;
} finally {
await closeBridgeDb(handle);
}
}
async groupQuery(params: Record<string, unknown>): Promise<unknown> {
@ -265,52 +426,118 @@ export class GroupService {
if (!name) return { error: 'name is required' };
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
const config = await loadGroupConfig(groupDir);
const registry = await readContractRegistry(groupDir);
const repoStatuses: Record<
string,
{
indexStale: boolean;
contractsStale: boolean;
missing: boolean;
commitsBehind?: number;
}
> = {};
const fallback = await openBridgeOrFallback(groupDir);
if (fallback.type === 'none') {
return { group: name, lastSync: null, missingRepos: [], repos: {} };
}
const fsp = await import('node:fs/promises');
const pathMod = await import('node:path');
for (const [repoPath, registryName] of Object.entries(config.repos)) {
try {
const repoObj = await this.port.resolveRepo(registryName);
const metaPath = pathMod.join(repoObj.storagePath, 'meta.json');
const metaRaw = await fsp.readFile(metaPath, 'utf-8').catch(() => '{}');
const meta = JSON.parse(metaRaw) as { lastCommit?: string; indexedAt?: string };
if (fallback.type === 'json') {
const registry = fallback.registry;
const repoStatuses: Record<
string,
{
indexStale: boolean;
contractsStale: boolean;
missing: boolean;
commitsBehind?: number;
}
> = {};
const staleness = meta.lastCommit
? checkStaleness(repoObj.repoPath, meta.lastCommit)
: { isStale: true, commitsBehind: -1 };
for (const [repoPath, registryName] of Object.entries(config.repos)) {
try {
const repoObj = await this.port.resolveRepo(registryName);
const metaPath = pathMod.join(repoObj.storagePath, 'meta.json');
const metaRaw = await fsp.readFile(metaPath, 'utf-8').catch(() => '{}');
const meta = JSON.parse(metaRaw) as { lastCommit?: string; indexedAt?: string };
const snapshot = registry?.repoSnapshots[repoPath];
const contractsStale =
snapshot && meta.indexedAt ? snapshot.indexedAt !== meta.indexedAt : !snapshot;
const staleness = meta.lastCommit
? checkStaleness(repoObj.repoPath, meta.lastCommit)
: { isStale: true, commitsBehind: -1 };
repoStatuses[repoPath] = {
indexStale: staleness.isStale,
contractsStale: Boolean(contractsStale),
missing: false,
commitsBehind: staleness.commitsBehind,
};
} catch {
repoStatuses[repoPath] = { indexStale: false, contractsStale: false, missing: true };
const snapshot = registry.repoSnapshots[repoPath];
const contractsStale =
snapshot && meta.indexedAt ? snapshot.indexedAt !== meta.indexedAt : !snapshot;
repoStatuses[repoPath] = {
indexStale: staleness.isStale,
contractsStale: Boolean(contractsStale),
missing: false,
commitsBehind: staleness.commitsBehind,
};
} catch {
repoStatuses[repoPath] = { indexStale: false, contractsStale: false, missing: true };
}
}
return {
group: name,
lastSync: registry.generatedAt || null,
missingRepos: registry.missingRepos || [],
repos: repoStatuses,
};
}
return {
group: name,
lastSync: registry?.generatedAt || null,
missingRepos: registry?.missingRepos || [],
repos: repoStatuses,
};
// Bridge path
const handle = fallback.handle;
const meta = fallback.meta;
try {
const snapshots = await queryBridge<{ id: string; indexedAt: string; lastCommit: string }>(
handle,
'MATCH (s:RepoSnapshot) RETURN s.id AS id, s.indexedAt AS indexedAt, s.lastCommit AS lastCommit',
);
const bridgeSnapshots: Record<string, { indexedAt: string; lastCommit: string }> = {};
for (const s of snapshots) {
bridgeSnapshots[s.id] = { indexedAt: s.indexedAt, lastCommit: s.lastCommit };
}
const repoStatuses: Record<
string,
{
indexStale: boolean;
contractsStale: boolean;
missing: boolean;
commitsBehind?: number;
}
> = {};
for (const [repoPath, registryName] of Object.entries(config.repos)) {
try {
const repoObj = await this.port.resolveRepo(registryName);
const metaPath = pathMod.join(repoObj.storagePath, 'meta.json');
const metaRaw = await fsp.readFile(metaPath, 'utf-8').catch(() => '{}');
const repoMeta = JSON.parse(metaRaw) as { lastCommit?: string; indexedAt?: string };
const staleness = repoMeta.lastCommit
? checkStaleness(repoObj.repoPath, repoMeta.lastCommit)
: { isStale: true, commitsBehind: -1 };
const snapshot = bridgeSnapshots[repoPath];
const contractsStale =
snapshot && repoMeta.indexedAt ? snapshot.indexedAt !== repoMeta.indexedAt : !snapshot;
repoStatuses[repoPath] = {
indexStale: staleness.isStale,
contractsStale: Boolean(contractsStale),
missing: false,
commitsBehind: staleness.commitsBehind,
};
} catch {
repoStatuses[repoPath] = { indexStale: false, contractsStale: false, missing: true };
}
}
return {
group: name,
lastSync: meta.generatedAt || null,
missingRepos: meta.missingRepos || [],
repos: repoStatuses,
};
} finally {
await closeBridgeDb(handle);
}
}
}

View file

@ -2,9 +2,13 @@ import * as fs from 'node:fs';
import * as fsp from 'node:fs/promises';
import * as path from 'node:path';
import * as os from 'node:os';
import type { ContractRegistry } from './types.js';
const CONTRACTS_FILE = 'contracts.json';
import type {
ContractRegistry,
BridgeHandle,
BridgeMeta,
LegacyContractRegistry,
} from './types.js';
import { openBridgeDbReadOnly, readBridgeMeta } from './bridge-db.js';
export function getDefaultGitnexusDir(): string {
return process.env.GITNEXUS_HOME || path.join(os.homedir(), '.gitnexus');
@ -29,19 +33,12 @@ export function getGroupDir(gitnexusDir: string, groupName: string): string {
return path.join(gitnexusDir, 'groups', groupName);
}
export async function writeContractRegistry(
groupDir: string,
registry: ContractRegistry,
): Promise<void> {
const targetPath = path.join(groupDir, CONTRACTS_FILE);
const tmpPath = `${targetPath}.tmp.${Date.now()}`;
await fsp.writeFile(tmpPath, JSON.stringify(registry, null, 2), 'utf-8');
await fsp.rename(tmpPath, targetPath);
}
export async function readContractRegistry(groupDir: string): Promise<ContractRegistry | null> {
const filePath = path.join(groupDir, CONTRACTS_FILE);
/**
* @deprecated Used only as internal JSON fallback for openBridgeOrFallback.
* New data is written to bridge.lbug via writeBridge.
*/
async function readContractRegistryJson(groupDir: string): Promise<ContractRegistry | null> {
const filePath = path.join(groupDir, 'contracts.json');
try {
const content = await fsp.readFile(filePath, 'utf-8');
return JSON.parse(content) as ContractRegistry;
@ -107,3 +104,28 @@ matching:
await fsp.writeFile(path.join(groupDir, 'group.yaml'), template, 'utf-8');
return groupDir;
}
export async function openBridgeOrFallback(
groupDir: string,
): Promise<
| { type: 'bridge'; handle: BridgeHandle; meta: BridgeMeta }
| { type: 'json'; registry: LegacyContractRegistry; deprecationWarning: string }
| { type: 'none' }
> {
const handle = await openBridgeDbReadOnly(groupDir);
if (handle) {
const meta = await readBridgeMeta(groupDir);
return { type: 'bridge', handle, meta };
}
// JSON fallback
const registry = await readContractRegistryJson(groupDir);
if (registry) {
return {
type: 'json',
registry,
deprecationWarning:
'contracts.json is deprecated. Run "gitnexus group sync <name>" to migrate to bridge.lbug.',
};
}
return { type: 'none' };
}

View file

@ -8,11 +8,10 @@ import { HttpRouteExtractor } from './extractors/http-route-extractor.js';
import { GrpcExtractor } from './extractors/grpc-extractor.js';
import { TopicExtractor } from './extractors/topic-extractor.js';
import { ManifestExtractor } from './extractors/manifest-extractor.js';
import { runExactMatch } from './matching.js';
import { buildProviderIndex, runExactMatch, runWildcardMatch } from './matching.js';
import { detectServiceBoundaries, assignService } from './service-boundary-detector.js';
import type { CypherExecutor } from './contract-extractor.js';
import { writeContractRegistry } from './storage.js';
import type { ContractRegistry } from './types.js';
import { writeBridge } from './bridge-db.js';
export interface SyncOptions {
extractorOverride?:
@ -163,27 +162,25 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
}
}
const { matched, unmatched } = runExactMatch(autoContracts);
const crossLinks: CrossLink[] = [...manifestResult.crossLinks, ...matched];
const providerIndex = buildProviderIndex(autoContracts);
const { matched: exactLinks, unmatched } = runExactMatch(autoContracts, providerIndex);
const { matched: wildcardLinks, remaining } = runWildcardMatch(unmatched, providerIndex);
const crossLinks: CrossLink[] = [...manifestResult.crossLinks, ...exactLinks, ...wildcardLinks];
const allContracts: StoredContract[] = [...manifestResult.contracts, ...autoContracts];
const registry: ContractRegistry = {
version: 1,
generatedAt: new Date().toISOString(),
repoSnapshots,
missingRepos,
contracts: allContracts,
crossLinks,
};
if (opts?.groupDir && !opts.skipWrite) {
await writeContractRegistry(opts.groupDir, registry);
await writeBridge(opts.groupDir, {
contracts: allContracts,
crossLinks,
repoSnapshots,
missingRepos,
});
}
return {
contracts: allContracts,
crossLinks,
unmatched,
unmatched: remaining,
missingRepos,
repoSnapshots,
};

View file

@ -1,5 +1,5 @@
export type ContractType = 'http' | 'grpc' | 'topic' | 'lib' | 'custom';
export type MatchType = 'exact' | 'manifest' | 'bm25' | 'embedding';
export type MatchType = 'exact' | 'manifest' | 'wildcard' | 'bm25' | 'embedding';
export type ContractRole = 'provider' | 'consumer';
export interface GroupConfig {
@ -131,3 +131,25 @@ export interface OutOfScopeLink {
contractId: string;
confidence: number;
}
/**
* @deprecated Use bridge.lbug instead. Kept for JSON fallback during migration.
* This is a type alias — ContractRegistry is NOT removed yet.
* In Task 10 (cleanup), ContractRegistry will be renamed to LegacyContractRegistry
* and all imports updated. For now, both names work.
*/
export type LegacyContractRegistry = ContractRegistry;
/** Opaque handle to an open bridge LadybugDB. */
export interface BridgeHandle {
/** Internal — do not access directly. */
readonly _db: unknown;
readonly _conn: unknown;
readonly groupDir: string;
}
export interface BridgeMeta {
version: number;
generatedAt: string;
missingRepos: string[];
}

View file

@ -392,7 +392,7 @@ WHEN TO USE: Discover groups before group_sync or group_impact. Optional "name"
},
{
name: 'group_sync',
description: `Rebuild the Contract Registry (contracts.json) for a group: extract HTTP contracts, apply manifest links, exact-match cross-links.
description: `Rebuild the Contract Registry (bridge.lbug) for a group: extract HTTP/gRPC/topic contracts, apply manifest links, exact-match and wildcard cross-links.
WHEN TO USE: After changing group.yaml or re-indexing member repos.`,
inputSchema: {
@ -410,7 +410,7 @@ WHEN TO USE: After changing group.yaml or re-indexing member repos.`,
},
{
name: 'group_contracts',
description: `Inspect contracts and cross-links from the group's contracts.json.
description: `Inspect contracts and cross-links from the group bridge graph.
WHEN TO USE: Debug cross-repo links after group_sync.`,
inputSchema: {

View file

@ -2,7 +2,7 @@
* Group impact wiring — mocks `localImpactFn` / `crossImpactFn`. E2E with real graphs is a follow-up.
*/
import { describe, it, expect } from 'vitest';
import { runGroupImpact } from '../../../src/core/group/cross-impact.js';
import { runGroupImpactLegacy } from '../../../src/core/group/cross-impact.js';
import type { ContractRegistry } from '../../../src/core/group/types.js';
function minimalRegistry(crossLinks: ContractRegistry['crossLinks']): ContractRegistry {
@ -56,7 +56,7 @@ describe('Group impact integration', () => {
return { byDepth: {}, affected_processes: [] };
};
const result = await runGroupImpact({
const result = await runGroupImpactLegacy({
groupName: 'g',
target: 'T',
repoPath: 'app/backend',

View file

@ -0,0 +1,124 @@
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 { StoredContract, CrossLink } from '../../../src/core/group/types.js';
const makeContract = (overrides: Partial<StoredContract> = {}): StoredContract => ({
contractId: 'http::GET::/api/users',
type: 'http',
role: 'provider',
symbolUid: 'uid-1',
symbolRef: { filePath: 'src/routes.ts', name: 'getUsers' },
symbolName: 'getUsers',
confidence: 0.85,
meta: {},
repo: 'backend',
...overrides,
});
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!);
});
});

View file

@ -0,0 +1,271 @@
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 {
openBridgeDb,
ensureBridgeSchema,
queryBridge,
closeBridgeDb,
contractNodeId,
writeBridge,
openBridgeDbReadOnly,
readBridgeMeta,
bridgeExists,
} from '../../../src/core/group/bridge-db.js';
import type { StoredContract, CrossLink } from '../../../src/core/group/types.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 });
});
const makeContract = (overrides: Partial<StoredContract> = {}): StoredContract => ({
contractId: 'http::GET::/api/users',
type: 'http',
role: 'provider',
symbolUid: 'uid-1',
symbolRef: { filePath: 'src/routes.ts', name: 'getUsers' },
symbolName: 'getUsers',
confidence: 0.85,
meta: {},
repo: 'backend',
...overrides,
});
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_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_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([]);
});
});

View file

@ -1,8 +1,8 @@
import { describe, it, expect } from 'vitest';
import { runGroupImpact } from '../../../src/core/group/cross-impact.js';
import { describe, it, expect, vi } from 'vitest';
import { runGroupImpactLegacy, runGroupImpact } from '../../../src/core/group/cross-impact.js';
import type { ContractRegistry } from '../../../src/core/group/types.js';
describe('runGroupImpact', () => {
describe('runGroupImpactLegacy', () => {
const mockRegistry: ContractRegistry = {
version: 1,
generatedAt: '2026-03-31T10:00:00Z',
@ -33,7 +33,7 @@ describe('runGroupImpact', () => {
};
it('returns local impact when no cross-links match', async () => {
const result = await runGroupImpact({
const result = await runGroupImpactLegacy({
groupName: 'test',
target: 'SomeUnrelatedFn',
repoPath: 'app/backend',
@ -58,7 +58,7 @@ describe('runGroupImpact', () => {
});
it('fans out through cross-links for upstream direction', async () => {
const result = await runGroupImpact({
const result = await runGroupImpactLegacy({
groupName: 'test',
target: 'UserController.list',
repoPath: 'app/backend',
@ -100,7 +100,7 @@ describe('runGroupImpact', () => {
});
it('fans out for downstream direction (consumer repo → provider repo)', async () => {
const result = await runGroupImpact({
const result = await runGroupImpactLegacy({
groupName: 'test',
target: 'fetchUsers',
repoPath: 'app/frontend',
@ -135,7 +135,7 @@ describe('runGroupImpact', () => {
});
it('respects subgroup filter', async () => {
const result = await runGroupImpact({
const result = await runGroupImpactLegacy({
groupName: 'test',
target: 'UserController.list',
repoPath: 'app/backend',
@ -163,7 +163,7 @@ describe('runGroupImpact', () => {
});
it('respects timeout and returns truncated result', async () => {
const result = await runGroupImpact({
const result = await runGroupImpactLegacy({
groupName: 'test',
target: 'UserController.list',
repoPath: 'app/backend',
@ -189,3 +189,214 @@ describe('runGroupImpact', () => {
expect(result.truncated).toBe(true);
});
});
describe('runGroupImpact (Cypher-based)', () => {
function makeBridgeQuery(rows: Record<string, unknown>[]) {
return vi.fn().mockResolvedValue(rows);
}
const localImpactFn = async () => ({
target: { id: 'uid-ctrl', name: 'UserController.list', filePath: 'src/ctrl.ts' },
direction: 'upstream',
impactedCount: 2,
risk: 'LOW',
summary: { direct: 2, processes_affected: 0, modules_affected: 0 },
affected_processes: [],
affected_modules: [],
byDepth: {
'1': [{ id: 'uid-ctrl', name: 'UserController.list', filePath: 'src/ctrl.ts' }],
},
});
it('test_runGroupImpact_upstream_calls_bridgeQuery_with_correct_params', async () => {
const bridgeQuery = makeBridgeQuery([
{
fanOutRepo: 'app/frontend',
fanOutUid: 'uid-fetch',
fanOutFilePath: 'src/api.ts',
fanOutSymbolName: 'fetchUsers',
matchedLocalUid: 'uid-ctrl',
matchedLocalFilePath: 'src/ctrl.ts',
matchedLocalSymbolName: 'UserController.list',
matchType: 'exact',
confidence: 1.0,
contractId: 'http::GET::/api/users',
contractType: 'http',
},
]);
const crossImpactFn = vi.fn().mockResolvedValue({
byDepth: {
'1': [
{ id: 'uid-profile', name: 'UserProfile', filePath: 'src/components/UserProfile.tsx' },
],
},
affected_processes: [],
});
const result = await runGroupImpact({
groupName: 'test',
target: 'UserController.list',
repoPath: 'app/backend',
direction: 'upstream',
bridgeQuery,
localImpactFn,
crossImpactFn,
});
expect(bridgeQuery).toHaveBeenCalledOnce();
const [cypher, params] = bridgeQuery.mock.calls[0];
expect(cypher).toContain('provider.repo = $sourceRepo');
expect(params.sourceRepo).toBe('app/backend');
expect(params.localUids).toContain('uid-ctrl');
expect(params.minConfidence).toBe(0.5);
expect(params.subgroup).toBeUndefined();
expect(result.cross).toHaveLength(1);
expect(result.cross[0].repo_path).toBe('app/frontend');
expect(result.cross[0].contract.match_type).toBe('exact');
expect(result.summary.cross_repo_hits).toBe(1);
expect(result.outOfScope).toHaveLength(0);
});
it('test_runGroupImpact_downstream_fans_out_to_provider', async () => {
const bridgeQuery = makeBridgeQuery([
{
fanOutRepo: 'app/backend',
fanOutUid: 'uid-ctrl',
fanOutFilePath: 'src/ctrl.ts',
fanOutSymbolName: 'UserController.list',
matchedLocalUid: 'uid-fetch',
matchedLocalFilePath: 'src/api.ts',
matchedLocalSymbolName: 'fetchUsers',
matchType: 'exact',
confidence: 0.9,
contractId: 'http::GET::/api/users',
contractType: 'http',
},
]);
const crossImpactFn = vi.fn().mockResolvedValue({
byDepth: { '1': [{ id: 'uid-ctrl', name: 'UserController.list', filePath: 'src/ctrl.ts' }] },
affected_processes: [],
});
const result = await runGroupImpact({
groupName: 'test',
target: 'fetchUsers',
repoPath: 'app/frontend',
direction: 'downstream',
bridgeQuery,
localImpactFn: async () => ({
target: { id: 'uid-fetch', name: 'fetchUsers', filePath: 'src/api.ts' },
direction: 'downstream',
impactedCount: 1,
risk: 'LOW',
summary: { direct: 1, processes_affected: 0, modules_affected: 0 },
affected_processes: [],
affected_modules: [],
byDepth: { '1': [{ id: 'uid-fetch', name: 'fetchUsers', filePath: 'src/api.ts' }] },
}),
crossImpactFn,
});
expect(bridgeQuery).toHaveBeenCalledOnce();
const [cypher] = bridgeQuery.mock.calls[0];
expect(cypher).toContain('consumer.repo = $sourceRepo');
expect(crossImpactFn).toHaveBeenCalledWith('app/backend', 'uid-ctrl', 'downstream', undefined);
expect(result.cross).toHaveLength(1);
expect(result.cross[0].repo_path).toBe('app/backend');
expect(result.summary.cross_repo_hits).toBe(1);
});
it('test_runGroupImpact_hint_passed_when_uid_empty', async () => {
const bridgeQuery = makeBridgeQuery([
{
fanOutRepo: 'app/frontend',
fanOutUid: '',
fanOutFilePath: 'src/api.ts',
fanOutSymbolName: 'fetchUsers',
matchedLocalUid: 'uid-ctrl',
matchedLocalFilePath: 'src/ctrl.ts',
matchedLocalSymbolName: 'UserController.list',
matchType: 'bm25',
confidence: 0.7,
contractId: 'grpc::UserService',
contractType: 'grpc',
},
]);
const crossImpactFn = vi.fn().mockResolvedValue({
byDepth: {},
affected_processes: [],
});
await runGroupImpact({
groupName: 'test',
target: 'UserController.list',
repoPath: 'app/backend',
direction: 'upstream',
bridgeQuery,
localImpactFn,
crossImpactFn,
});
expect(crossImpactFn).toHaveBeenCalledWith('app/frontend', '', 'upstream', {
filePath: 'src/api.ts',
symbolName: 'fetchUsers',
});
});
it('test_runGroupImpact_subgroup_passed_to_bridgeQuery', async () => {
const bridgeQuery = makeBridgeQuery([]);
const crossImpactFn = vi.fn();
await runGroupImpact({
groupName: 'test',
target: 'UserController.list',
repoPath: 'app/backend',
direction: 'upstream',
subgroup: 'team/backend',
bridgeQuery,
localImpactFn,
crossImpactFn,
});
expect(bridgeQuery).toHaveBeenCalledOnce();
const [cypher, params] = bridgeQuery.mock.calls[0];
// When subgroup is provided, the query should include the subgroup filter clause
expect(params.subgroup).toBe('team/backend');
// The Cypher should include the subgroup WHERE clause
expect(cypher).toContain('$subgroup');
});
it('test_runGroupImpact_error_object_not_counted_as_hit', async () => {
const bridgeQuery = makeBridgeQuery([
{
fanOutRepo: 'app/frontend',
fanOutUid: 'uid-fetch',
fanOutFilePath: 'src/api.ts',
fanOutSymbolName: 'fetchUsers',
matchedLocalUid: 'uid-ctrl',
matchedLocalFilePath: 'src/ctrl.ts',
matchedLocalSymbolName: 'UserController.list',
matchType: 'exact',
confidence: 1.0,
contractId: 'http::GET::/api/users',
contractType: 'http',
},
]);
const crossImpactFn = vi.fn().mockResolvedValue({ error: 'repo not indexed' });
const result = await runGroupImpact({
groupName: 'test',
target: 'UserController.list',
repoPath: 'app/backend',
direction: 'upstream',
bridgeQuery,
localImpactFn,
crossImpactFn,
});
expect(result.cross).toHaveLength(0);
expect(result.summary.cross_repo_hits).toBe(0);
});
});

View file

@ -1,8 +1,15 @@
import { describe, it, expect, 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 { GrpcExtractor } from '../../../src/core/group/extractors/grpc-extractor.js';
import {
GrpcExtractor,
buildProtoMap,
resolveProtoConflict,
serviceContractId,
} from '../../../src/core/group/extractors/grpc-extractor.js';
import type { ProtoServiceInfo } from '../../../src/core/group/extractors/grpc-extractor.js';
import type { RepoHandle } from '../../../src/core/group/types.js';
describe('GrpcExtractor', () => {
@ -228,7 +235,7 @@ func main() {
expect(providers.length).toBeGreaterThanOrEqual(1);
expect(providers[0].contractId).toContain('grpc::');
expect(providers[0].contractId).toContain('AuthService');
expect(providers[0].confidence).toBe(0.8);
expect(providers[0].confidence).toBe(0.65);
});
it('test_extract_go_unimplemented_server_returns_provider', async () => {
@ -267,7 +274,7 @@ func NewAuthClient(conn *grpc.ClientConn) pb.AuthServiceClient {
expect(consumers.length).toBeGreaterThanOrEqual(1);
expect(consumers[0].contractId).toContain('AuthService');
expect(consumers[0].confidence).toBe(0.7);
expect(consumers[0].confidence).toBe(0.55);
});
});
@ -287,7 +294,7 @@ public class AuthGrpcService extends AuthServiceGrpc.AuthServiceImplBase {
expect(providers.length).toBeGreaterThanOrEqual(1);
expect(providers[0].contractId).toContain('AuthService');
expect(providers[0].confidence).toBe(0.8);
expect(providers[0].confidence).toBe(0.65);
});
it('test_extract_java_blocking_stub_returns_consumer', async () => {
@ -306,7 +313,7 @@ public class AuthGrpcService extends AuthServiceGrpc.AuthServiceImplBase {
expect(consumers.length).toBeGreaterThanOrEqual(1);
expect(consumers[0].contractId).toContain('AuthService');
expect(consumers[0].confidence).toBe(0.7);
expect(consumers[0].confidence).toBe(0.55);
});
});
@ -328,7 +335,7 @@ def serve():
expect(providers.length).toBeGreaterThanOrEqual(1);
expect(providers[0].contractId).toContain('AuthService');
expect(providers[0].confidence).toBe(0.8);
expect(providers[0].confidence).toBe(0.65);
});
it('test_extract_python_stub_returns_consumer', async () => {
@ -346,7 +353,7 @@ stub = auth_pb2_grpc.AuthServiceStub(channel)`,
expect(consumers.length).toBeGreaterThanOrEqual(1);
expect(consumers[0].contractId).toContain('AuthService');
expect(consumers[0].confidence).toBe(0.7);
expect(consumers[0].confidence).toBe(0.55);
});
});
@ -389,3 +396,244 @@ export class AuthController {
});
});
});
describe('buildProtoMap', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'proto-test-'));
});
afterEach(async () => {
await fsp.rm(tmpDir, { recursive: true, force: true });
});
it('test_buildProtoMap_single_proto_parses_package_service_methods', async () => {
const protoContent = `
syntax = "proto3";
package com.example;
service UserService {
rpc GetUser (GetUserRequest) returns (GetUserResponse);
rpc ListUsers (ListUsersRequest) returns (ListUsersResponse);
}`;
await fsp.mkdir(path.join(tmpDir, 'proto'), { recursive: true });
await fsp.writeFile(path.join(tmpDir, 'proto', 'user.proto'), protoContent);
const map = await buildProtoMap(tmpDir);
expect(map.has('UserService')).toBe(true);
const entries = map.get('UserService')!;
expect(entries).toHaveLength(1);
expect(entries[0].package).toBe('com.example');
expect(entries[0].serviceName).toBe('UserService');
expect(entries[0].methods).toEqual(['GetUser', 'ListUsers']);
expect(entries[0].protoPath).toBe('proto/user.proto');
});
it('test_buildProtoMap_no_package_declaration', async () => {
const protoContent = `
syntax = "proto3";
service Foo { rpc Bar (Req) returns (Res); }`;
await fsp.writeFile(path.join(tmpDir, 'foo.proto'), protoContent);
const map = await buildProtoMap(tmpDir);
const entries = map.get('Foo')!;
expect(entries[0].package).toBe('');
});
it('test_buildProtoMap_no_protos_returns_empty', async () => {
const map = await buildProtoMap(tmpDir);
expect(map.size).toBe(0);
});
it('test_buildProtoMap_conflicting_names', async () => {
await fsp.mkdir(path.join(tmpDir, 'a'), { recursive: true });
await fsp.mkdir(path.join(tmpDir, 'b'), { recursive: true });
await fsp.writeFile(
path.join(tmpDir, 'a', 'svc.proto'),
'package pkg.a;\nservice Svc { rpc Do (R) returns (R); }',
);
await fsp.writeFile(
path.join(tmpDir, 'b', 'svc.proto'),
'package pkg.b;\nservice Svc { rpc Do (R) returns (R); }',
);
const map = await buildProtoMap(tmpDir);
expect(map.get('Svc')).toHaveLength(2);
});
});
describe('resolveProtoConflict', () => {
const makeInfo = (pkg: string, protoPath: string): ProtoServiceInfo => ({
package: pkg,
serviceName: 'Svc',
methods: ['Do'],
protoPath,
});
it('test_single_candidate_returns_it', () => {
const result = resolveProtoConflict('Svc', 'src/main.go', [makeInfo('pkg', 'proto/svc.proto')]);
expect(result?.package).toBe('pkg');
});
it('test_multiple_candidates_picks_closest_directory', () => {
const candidates = [
makeInfo('far', 'other/dir/svc.proto'),
makeInfo('close', 'src/proto/svc.proto'),
];
const result = resolveProtoConflict('Svc', 'src/server.go', candidates);
expect(result?.package).toBe('close');
});
it('test_no_candidates_returns_null', () => {
expect(resolveProtoConflict('Svc', 'src/main.go', [])).toBeNull();
});
});
describe('serviceContractId', () => {
it('test_with_package', () => {
expect(serviceContractId('com.example', 'UserService')).toBe('grpc::com.example.UserService/*');
});
it('test_without_package', () => {
expect(serviceContractId('', 'UserService')).toBe('grpc::UserService/*');
});
});
describe('proto-aware source scanners', () => {
let tmpDir: string;
let extractor: GrpcExtractor;
beforeEach(async () => {
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'scanner-test-'));
extractor = new GrpcExtractor();
});
afterEach(async () => {
await fsp.rm(tmpDir, { recursive: true, force: true });
});
const makeRepo = (repoPath: string): RepoHandle => ({
id: 'test-repo',
path: '',
repoPath,
storagePath: '',
});
it('test_go_provider_with_proto_uses_canonical_service_id', async () => {
await fsp.mkdir(path.join(tmpDir, 'proto'), { recursive: true });
await fsp.writeFile(
path.join(tmpDir, 'proto', 'user.proto'),
'package com.example;\nservice UserService { rpc GetUser (R) returns (R); }',
);
await fsp.mkdir(path.join(tmpDir, 'src'), { recursive: true });
await fsp.writeFile(
path.join(tmpDir, 'src', 'server.go'),
'package main\nfunc init() { pb.RegisterUserServiceServer(srv, &impl{}) }',
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const goProvider = contracts.find((c) => c.meta.source === 'go_register');
expect(goProvider).toBeDefined();
expect(goProvider!.contractId).toBe('grpc::com.example.UserService/*');
expect(goProvider!.confidence).toBe(0.8);
});
it('test_go_provider_without_proto_reduced_confidence', async () => {
await fsp.mkdir(path.join(tmpDir, 'src'), { recursive: true });
await fsp.writeFile(
path.join(tmpDir, 'src', 'server.go'),
'package main\nfunc init() { pb.RegisterFooServer(srv, &impl{}) }',
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const goProvider = contracts.find((c) => c.meta.source === 'go_register');
expect(goProvider).toBeDefined();
expect(goProvider!.contractId).toBe('grpc::Foo/*');
expect(goProvider!.confidence).toBe(0.65);
});
it('test_go_consumer_with_proto_uses_canonical_service_id', async () => {
await fsp.mkdir(path.join(tmpDir, 'proto'), { recursive: true });
await fsp.writeFile(
path.join(tmpDir, 'proto', 'user.proto'),
'package com.example;\nservice UserService { rpc GetUser (R) returns (R); }',
);
await fsp.mkdir(path.join(tmpDir, 'src'), { recursive: true });
await fsp.writeFile(
path.join(tmpDir, 'src', 'client.go'),
'package main\nfunc init() { client := pb.NewUserServiceClient(conn) }',
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const goConsumer = contracts.find((c) => c.meta.source === 'go_client');
expect(goConsumer).toBeDefined();
expect(goConsumer!.contractId).toBe('grpc::com.example.UserService/*');
expect(goConsumer!.confidence).toBe(0.75);
});
it('test_java_provider_with_proto_uses_canonical_service_id', async () => {
await fsp.mkdir(path.join(tmpDir, 'proto'), { recursive: true });
await fsp.writeFile(
path.join(tmpDir, 'proto', 'user.proto'),
'package com.example;\nservice UserService { rpc GetUser (R) returns (R); }',
);
await fsp.mkdir(path.join(tmpDir, 'src', 'main', 'java'), { recursive: true });
await fsp.writeFile(
path.join(tmpDir, 'src', 'main', 'java', 'UserGrpcService.java'),
`@GrpcService
public class UserGrpcService extends UserServiceGrpc.UserServiceImplBase {
@Override
public void getUser(GetUserRequest req, StreamObserver<GetUserResponse> obs) {}
}`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const javaProvider = contracts.find((c) => c.meta.source === 'java_grpc_service');
expect(javaProvider).toBeDefined();
expect(javaProvider!.contractId).toBe('grpc::com.example.UserService/*');
expect(javaProvider!.confidence).toBe(0.8);
});
it('test_python_consumer_with_proto_uses_canonical_service_id', async () => {
await fsp.mkdir(path.join(tmpDir, 'proto'), { recursive: true });
await fsp.writeFile(
path.join(tmpDir, 'proto', 'user.proto'),
'package com.example;\nservice UserService { rpc GetUser (R) returns (R); }',
);
await fsp.writeFile(
path.join(tmpDir, 'client.py'),
`import grpc
channel = grpc.insecure_channel('localhost:50051')
stub = UserServiceStub(channel)`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const pyConsumer = contracts.find((c) => c.meta.source === 'python_stub');
expect(pyConsumer).toBeDefined();
expect(pyConsumer!.contractId).toBe('grpc::com.example.UserService/*');
expect(pyConsumer!.confidence).toBe(0.75);
});
it('test_ts_provider_with_proto_adds_package', async () => {
await fsp.mkdir(path.join(tmpDir, 'proto'), { recursive: true });
await fsp.writeFile(
path.join(tmpDir, 'proto', 'user.proto'),
'package com.example;\nservice UserService { rpc GetUser (R) returns (R); }',
);
await fsp.mkdir(path.join(tmpDir, 'src'), { recursive: true });
await fsp.writeFile(
path.join(tmpDir, 'src', 'controller.ts'),
"@GrpcMethod('UserService', 'GetUser')\nasync getUser() {}",
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const tsProvider = contracts.find((c) => c.meta.source === 'ts_grpc_method');
expect(tsProvider).toBeDefined();
expect(tsProvider!.contractId).toBe('grpc::com.example.UserService/GetUser');
expect(tsProvider!.confidence).toBe(0.8);
});
});

View file

@ -1,5 +1,10 @@
import { describe, it, expect } from 'vitest';
import { runExactMatch, normalizeContractId } from '../../../src/core/group/matching.js';
import {
runExactMatch,
normalizeContractId,
buildProviderIndex,
runWildcardMatch,
} from '../../../src/core/group/matching.js';
import type { StoredContract } from '../../../src/core/group/types.js';
describe('normalizeContractId', () => {
@ -21,6 +26,16 @@ describe('normalizeContractId', () => {
expect(normalizeContractId('grpc::/MyPkg/DoThing')).toBe('grpc::/MyPkg/DoThing');
});
it('handles malformed grpc with leading slash and no package', () => {
// grpc::/Method — leading slash, no package
expect(normalizeContractId('grpc::/Method')).toBe('grpc::/Method');
});
it('handles grpc with no slash at all', () => {
// grpc::ServiceName — no slash, ambiguous; MVP: lowercase entire token
expect(normalizeContractId('grpc::ServiceName')).toBe('grpc::servicename');
});
it('trims and lowercases topic', () => {
expect(normalizeContractId('topic:: Employee.Hired ')).toBe('topic::employee.hired');
});
@ -180,3 +195,211 @@ describe('runExactMatch', () => {
expect(unmatched).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// Helpers for Task 6 tests
// ---------------------------------------------------------------------------
function makeGrpcContract(
id: string,
role: 'provider' | 'consumer',
repo: string,
overrides: Partial<StoredContract> = {},
): StoredContract {
return {
contractId: id,
type: 'grpc',
role,
symbolUid: `uid-${repo}-${id}`,
symbolRef: { filePath: `src/${repo}.ts`, name: `fn-${id}` },
symbolName: `fn-${id}`,
confidence: 0.9,
meta: {},
repo,
...overrides,
};
}
// ---------------------------------------------------------------------------
// buildProviderIndex
// ---------------------------------------------------------------------------
describe('buildProviderIndex', () => {
it('test_buildProviderIndex_creates_normalized_keys', () => {
const contracts: StoredContract[] = [
makeGrpcContract('grpc::Com.Example.UserService/GetUser', 'provider', 'backend'),
makeGrpcContract('grpc::Com.Example.UserService/GetUser', 'consumer', 'frontend'),
];
const index = buildProviderIndex(contracts);
// Only providers should be in the index
expect(index.size).toBe(1);
// Key should be normalized (lowercased package)
expect(index.has('grpc::com.example.userservice/GetUser')).toBe(true);
expect(index.get('grpc::com.example.userservice/GetUser')).toHaveLength(1);
expect(index.get('grpc::com.example.userservice/GetUser')![0].role).toBe('provider');
});
});
// ---------------------------------------------------------------------------
// runExactMatch — gRPC wildcard skip
// ---------------------------------------------------------------------------
describe('runExactMatch — gRPC wildcard handling', () => {
it('test_runExactMatch_skips_grpc_wildcard_contracts', () => {
const contracts: StoredContract[] = [
makeGrpcContract('grpc::com.example.UserService/*', 'consumer', 'frontend'),
makeGrpcContract('grpc::com.example.UserService/*', 'provider', 'backend'),
];
const { matched, unmatched } = runExactMatch(contracts);
// gRPC wildcards should NOT be matched in exact pass
expect(matched).toHaveLength(0);
// Both should appear in unmatched
expect(unmatched).toHaveLength(2);
});
it('test_runExactMatch_does_not_skip_http_wildcards', () => {
const contracts: StoredContract[] = [
{
contractId: 'http::GET::/api/users',
type: 'http',
role: 'provider',
symbolUid: 'uid-backend-http',
symbolRef: { filePath: 'src/backend.ts', name: 'fn-http' },
symbolName: 'fn-http',
confidence: 0.9,
meta: {},
repo: 'backend',
},
{
contractId: 'http::*::/api/users',
type: 'http',
role: 'consumer',
symbolUid: 'uid-frontend-http',
symbolRef: { filePath: 'src/frontend.ts', name: 'fn-http' },
symbolName: 'fn-http',
confidence: 0.9,
meta: {},
repo: 'frontend',
},
];
const { matched } = runExactMatch(contracts);
// HTTP wildcard should still match via findMatchingKeys
expect(matched).toHaveLength(1);
expect(matched[0].contractId).toBe('http::*::/api/users');
});
});
// ---------------------------------------------------------------------------
// runWildcardMatch
// ---------------------------------------------------------------------------
describe('runWildcardMatch', () => {
it('test_runWildcardMatch_fq_service_match', () => {
const consumer = makeGrpcContract('grpc::com.example.UserService/*', 'consumer', 'frontend');
const provider = makeGrpcContract(
'grpc::com.example.UserService/GetUser',
'provider',
'backend',
);
const providerIndex = buildProviderIndex([provider]);
const { matched } = runWildcardMatch([consumer], providerIndex);
expect(matched).toHaveLength(1);
expect(matched[0].from.repo).toBe('frontend');
expect(matched[0].to.repo).toBe('backend');
});
it('test_runWildcardMatch_bare_name_match', () => {
const consumer = makeGrpcContract('grpc::UserService/*', 'consumer', 'frontend');
const provider = makeGrpcContract(
'grpc::com.example.UserService/GetUser',
'provider',
'backend',
);
const providerIndex = buildProviderIndex([provider]);
const { matched } = runWildcardMatch([consumer], providerIndex);
expect(matched).toHaveLength(1);
expect(matched[0].from.repo).toBe('frontend');
expect(matched[0].to.repo).toBe('backend');
});
it('test_runWildcardMatch_no_match_different_service', () => {
const consumer = makeGrpcContract('grpc::UserService/*', 'consumer', 'frontend');
const provider = makeGrpcContract(
'grpc::com.example.OtherService/GetUser',
'provider',
'backend',
);
const providerIndex = buildProviderIndex([provider]);
const { matched, remaining } = runWildcardMatch([consumer], providerIndex);
expect(matched).toHaveLength(0);
expect(remaining).toContainEqual(consumer);
});
it('test_runWildcardMatch_skips_wildcard_providers', () => {
const consumer = makeGrpcContract('grpc::com.example.UserService/*', 'consumer', 'frontend');
const provider = makeGrpcContract('grpc::com.example.UserService/*', 'provider', 'backend');
const providerIndex = buildProviderIndex([provider]);
const { matched } = runWildcardMatch([consumer], providerIndex);
// Wildcard provider key ends with /*, so it should be skipped
expect(matched).toHaveLength(0);
});
it('test_runWildcardMatch_confidence_min', () => {
const consumer = makeGrpcContract('grpc::com.example.UserService/*', 'consumer', 'frontend', {
confidence: 0.7,
});
const provider = makeGrpcContract(
'grpc::com.example.UserService/GetUser',
'provider',
'backend',
{
confidence: 0.5,
},
);
const providerIndex = buildProviderIndex([provider]);
const { matched } = runWildcardMatch([consumer], providerIndex);
expect(matched).toHaveLength(1);
expect(matched[0].confidence).toBe(0.5);
});
it('test_runWildcardMatch_matchType_wildcard', () => {
const consumer = makeGrpcContract('grpc::com.example.UserService/*', 'consumer', 'frontend');
const provider = makeGrpcContract(
'grpc::com.example.UserService/GetUser',
'provider',
'backend',
);
const providerIndex = buildProviderIndex([provider]);
const { matched } = runWildcardMatch([consumer], providerIndex);
expect(matched).toHaveLength(1);
expect(matched[0].matchType).toBe('wildcard');
});
it('test_runWildcardMatch_contractId_is_consumers', () => {
const consumer = makeGrpcContract('grpc::com.example.UserService/*', 'consumer', 'frontend');
const provider = makeGrpcContract(
'grpc::com.example.UserService/GetUser',
'provider',
'backend',
);
const providerIndex = buildProviderIndex([provider]);
const { matched } = runWildcardMatch([consumer], providerIndex);
expect(matched).toHaveLength(1);
expect(matched[0].contractId).toBe('grpc::com.example.UserService/*');
});
});

View file

@ -7,11 +7,23 @@ import {
type GroupToolPort,
type GroupRepoHandle,
} from '../../../src/core/group/service.js';
import { writeContractRegistry } from '../../../src/core/group/storage.js';
import { writeBridge } from '../../../src/core/group/bridge-db.js';
import type { ContractRegistry, StoredContract, CrossLink } from '../../../src/core/group/types.js';
/** Test helper: write legacy contracts.json for JSON-fallback tests */
async function writeContractRegistryJson(
groupDir: string,
registry: ContractRegistry,
): Promise<void> {
const targetPath = path.join(groupDir, 'contracts.json');
fs.writeFileSync(targetPath, JSON.stringify(registry, null, 2), 'utf-8');
}
function makeTmpGroup(): { tmpDir: string; groupDir: string; cleanup: () => void } {
const tmpDir = path.join(os.tmpdir(), `gitnexus-svc-${Date.now()}`);
const tmpDir = path.join(
os.tmpdir(),
`gitnexus-svc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
);
const groupDir = path.join(tmpDir, 'groups', 'test-group');
fs.mkdirSync(groupDir, { recursive: true });
@ -113,20 +125,20 @@ describe('GroupService', () => {
expect(result.error).toContain('name is required');
});
it('test_groupContracts_returns_error_when_no_registry', async () => {
it('test_groupContracts_no_data_returns_error', async () => {
const { cleanup, tmpDir } = makeTmpGroup();
try {
vi.stubEnv('GITNEXUS_HOME', tmpDir);
const svc = new GroupService(makePort());
const result = (await svc.groupContracts({ name: 'test-group' })) as { error: string };
expect(result.error).toContain('No contracts.json');
expect(result.error).toContain('No contract data');
} finally {
vi.unstubAllEnvs();
cleanup();
}
});
it('test_groupContracts_returns_all_contracts', async () => {
it('test_groupContracts_json_fallback_returns_all_contracts', async () => {
const { groupDir, cleanup, tmpDir } = makeTmpGroup();
try {
vi.stubEnv('GITNEXUS_HOME', tmpDir);
@ -134,7 +146,7 @@ describe('GroupService', () => {
makeContract('http::GET::/api/users', 'provider', 'app/backend'),
makeContract('http::GET::/api/users', 'consumer', 'app/frontend'),
];
await writeContractRegistry(groupDir, makeRegistry(contracts));
await writeContractRegistryJson(groupDir, makeRegistry(contracts));
const svc = new GroupService(makePort());
const result = (await svc.groupContracts({ name: 'test-group' })) as {
@ -147,7 +159,7 @@ describe('GroupService', () => {
}
});
it('test_groupContracts_filters_by_type', async () => {
it('test_groupContracts_json_fallback_filters_by_type', async () => {
const { groupDir, cleanup, tmpDir } = makeTmpGroup();
try {
vi.stubEnv('GITNEXUS_HOME', tmpDir);
@ -158,7 +170,7 @@ describe('GroupService', () => {
type: 'grpc' as const,
},
];
await writeContractRegistry(groupDir, makeRegistry(contracts));
await writeContractRegistryJson(groupDir, makeRegistry(contracts));
const svc = new GroupService(makePort());
const result = (await svc.groupContracts({ name: 'test-group', type: 'grpc' })) as {
@ -172,7 +184,7 @@ describe('GroupService', () => {
}
});
it('test_groupContracts_filters_by_repo', async () => {
it('test_groupContracts_json_fallback_filters_by_repo', async () => {
const { groupDir, cleanup, tmpDir } = makeTmpGroup();
try {
vi.stubEnv('GITNEXUS_HOME', tmpDir);
@ -180,7 +192,7 @@ describe('GroupService', () => {
makeContract('http::GET::/api/users', 'provider', 'app/backend'),
makeContract('http::GET::/api/users', 'consumer', 'app/frontend'),
];
await writeContractRegistry(groupDir, makeRegistry(contracts));
await writeContractRegistryJson(groupDir, makeRegistry(contracts));
const svc = new GroupService(makePort());
const result = (await svc.groupContracts({ name: 'test-group', repo: 'app/backend' })) as {
@ -194,7 +206,7 @@ describe('GroupService', () => {
}
});
it('test_groupContracts_unmatchedOnly_filters_matched', async () => {
it('test_groupContracts_json_fallback_unmatchedOnly_filters_matched', async () => {
const { groupDir, cleanup, tmpDir } = makeTmpGroup();
try {
vi.stubEnv('GITNEXUS_HOME', tmpDir);
@ -217,7 +229,7 @@ describe('GroupService', () => {
matchType: 'exact',
confidence: 1.0,
};
await writeContractRegistry(
await writeContractRegistryJson(
groupDir,
makeRegistry([provider, consumer, orphan], [crossLink]),
);
@ -233,6 +245,107 @@ describe('GroupService', () => {
cleanup();
}
});
it('test_groupContracts_with_bridge_returns_contracts', async () => {
const { groupDir, cleanup, tmpDir } = makeTmpGroup();
try {
vi.stubEnv('GITNEXUS_HOME', tmpDir);
const contracts = [
makeContract('http::GET::/api/users', 'provider', 'app/backend'),
makeContract('http::GET::/api/users', 'consumer', 'app/frontend'),
];
await writeBridge(groupDir, {
contracts,
crossLinks: [],
repoSnapshots: {},
missingRepos: [],
});
const svc = new GroupService(makePort());
const result = (await svc.groupContracts({ name: 'test-group' })) as {
contracts: unknown[];
crossLinks: unknown[];
};
expect(result.contracts).toHaveLength(2);
expect(result.crossLinks).toEqual([]);
} finally {
vi.unstubAllEnvs();
cleanup();
}
});
it('test_groupContracts_bridge_path_filters_by_type', async () => {
const { groupDir, cleanup, tmpDir } = makeTmpGroup();
try {
vi.stubEnv('GITNEXUS_HOME', tmpDir);
const contracts = [
makeContract('http::GET::/api/users', 'provider', 'app/backend'),
{
...makeContract('grpc::auth.AuthService/Login', 'provider', 'app/backend'),
type: 'grpc' as const,
},
];
await writeBridge(groupDir, {
contracts,
crossLinks: [],
repoSnapshots: {},
missingRepos: [],
});
const svc = new GroupService(makePort());
const result = (await svc.groupContracts({ name: 'test-group', type: 'grpc' })) as {
contracts: { type: string }[];
};
expect(result.contracts).toHaveLength(1);
expect(result.contracts[0].type).toBe('grpc');
} finally {
vi.unstubAllEnvs();
cleanup();
}
});
it('test_groupContracts_bridge_path_unmatchedOnly_filters_matched', async () => {
const { groupDir, cleanup, tmpDir } = makeTmpGroup();
try {
vi.stubEnv('GITNEXUS_HOME', tmpDir);
const provider = makeContract('http::GET::/api/users', 'provider', 'app/backend');
const consumer = makeContract('http::GET::/api/users', 'consumer', 'app/frontend');
const orphan = makeContract('http::GET::/api/health', 'provider', 'app/backend');
const crossLink: CrossLink = {
from: {
repo: 'app/frontend',
symbolUid: consumer.symbolUid,
symbolRef: consumer.symbolRef,
},
to: {
repo: 'app/backend',
symbolUid: provider.symbolUid,
symbolRef: provider.symbolRef,
},
type: 'http',
contractId: 'http::GET::/api/users',
matchType: 'exact',
confidence: 1.0,
};
await writeBridge(groupDir, {
contracts: [provider, consumer, orphan],
crossLinks: [crossLink],
repoSnapshots: {},
missingRepos: [],
});
const svc = new GroupService(makePort());
const result = (await svc.groupContracts({ name: 'test-group', unmatchedOnly: true })) as {
contracts: { contractId: string }[];
};
// Only the orphan should remain after filtering out matched ones
expect(result.contracts).toHaveLength(1);
expect(result.contracts[0].contractId).toBe('http::GET::/api/health');
} finally {
vi.unstubAllEnvs();
cleanup();
}
});
});
describe('groupSync', () => {
@ -330,6 +443,111 @@ describe('GroupService', () => {
});
});
describe('groupImpact', () => {
it('test_groupImpact_no_data_returns_error', async () => {
const { cleanup, tmpDir } = makeTmpGroup();
try {
vi.stubEnv('GITNEXUS_HOME', tmpDir);
const svc = new GroupService(makePort());
const result = (await svc.groupImpact({
name: 'test-group',
target: 'someSymbol',
repo: 'app/backend',
})) as { error: string };
expect(result.error).toContain('No contract data');
} finally {
vi.unstubAllEnvs();
cleanup();
}
});
it('test_groupImpact_with_json_fallback_uses_legacy', async () => {
const { groupDir, cleanup, tmpDir } = makeTmpGroup();
try {
vi.stubEnv('GITNEXUS_HOME', tmpDir);
const contracts = [makeContract('http::GET::/api/users', 'provider', 'app/backend')];
await writeContractRegistryJson(groupDir, makeRegistry(contracts));
const port = makePort({
impact: vi.fn(async () => ({
target: { id: 'uid-1', name: 'someSymbol', filePath: 'src/app.ts' },
direction: 'upstream',
impactedCount: 0,
risk: 'LOW',
summary: { direct: 0, processes_affected: 0, modules_affected: 0 },
affected_processes: [],
affected_modules: [],
byDepth: {},
})),
});
const svc = new GroupService(port);
const result = (await svc.groupImpact({
name: 'test-group',
target: 'someSymbol',
repo: 'app/backend',
})) as { local: unknown; group: string; risk: string };
expect(result.group).toBe('test-group');
expect(result.local).toBeDefined();
expect(result.risk).toBeDefined();
expect(port.impact).toHaveBeenCalled();
} finally {
vi.unstubAllEnvs();
cleanup();
}
});
it('test_groupImpact_with_bridge_uses_new_runGroupImpact', async () => {
const { groupDir, cleanup, tmpDir } = makeTmpGroup();
try {
vi.stubEnv('GITNEXUS_HOME', tmpDir);
const contracts = [makeContract('http::GET::/api/users', 'provider', 'app/backend')];
await writeBridge(groupDir, {
contracts,
crossLinks: [],
repoSnapshots: {},
missingRepos: [],
});
const port = makePort({
impact: vi.fn(async () => ({
target: { id: 'uid-1', name: 'someSymbol', filePath: 'src/app.ts' },
direction: 'upstream',
impactedCount: 0,
risk: 'LOW',
summary: { direct: 0, processes_affected: 0, modules_affected: 0 },
affected_processes: [],
affected_modules: [],
byDepth: {},
})),
});
const svc = new GroupService(port);
const result = (await svc.groupImpact({
name: 'test-group',
target: 'someSymbol',
repo: 'app/backend',
})) as { local: unknown; group: string; risk: string; cross: unknown[] };
expect(result.group).toBe('test-group');
expect(result.local).toBeDefined();
expect(result.risk).toBeDefined();
expect(result.cross).toEqual([]);
expect(port.impact).toHaveBeenCalled();
} finally {
vi.unstubAllEnvs();
cleanup();
}
});
it('test_groupImpact_returns_error_when_params_missing', async () => {
const svc = new GroupService(makePort());
const result = (await svc.groupImpact({})) as { error: string };
expect(result.error).toContain('name, target, and repo are required');
});
});
describe('groupStatus', () => {
it('test_groupStatus_returns_error_when_name_empty', async () => {
const svc = new GroupService(makePort());
@ -337,10 +555,32 @@ describe('GroupService', () => {
expect(result.error).toContain('name is required');
});
it('test_groupStatus_marks_unresolvable_repos_as_missing', async () => {
it('test_groupStatus_no_data_returns_empty', async () => {
const { cleanup, tmpDir } = makeTmpGroup();
try {
vi.stubEnv('GITNEXUS_HOME', tmpDir);
const svc = new GroupService(makePort());
const result = (await svc.groupStatus({ name: 'test-group' })) as {
group: string;
lastSync: null;
missingRepos: string[];
repos: Record<string, unknown>;
};
expect(result.group).toBe('test-group');
expect(result.lastSync).toBeNull();
expect(result.missingRepos).toEqual([]);
expect(result.repos).toEqual({});
} finally {
vi.unstubAllEnvs();
cleanup();
}
});
it('test_groupStatus_json_fallback_marks_unresolvable_repos_as_missing', async () => {
const { groupDir, cleanup, tmpDir } = makeTmpGroup();
try {
vi.stubEnv('GITNEXUS_HOME', tmpDir);
await writeContractRegistryJson(groupDir, makeRegistry([]));
const port = makePort({
resolveRepo: vi.fn(async () => {
@ -362,5 +602,83 @@ describe('GroupService', () => {
cleanup();
}
});
it('test_groupStatus_reads_from_bridge_meta_and_snapshots', async () => {
const { groupDir, cleanup, tmpDir } = makeTmpGroup();
try {
vi.stubEnv('GITNEXUS_HOME', tmpDir);
await writeBridge(groupDir, {
contracts: [],
crossLinks: [],
repoSnapshots: {
'app/backend': { indexedAt: '2026-01-01T00:00:00Z', lastCommit: 'abc123' },
},
missingRepos: ['app/frontend'],
});
const port = makePort({
resolveRepo: vi.fn(async () => {
throw new Error('repo not found');
}),
});
const svc = new GroupService(port);
const result = (await svc.groupStatus({ name: 'test-group' })) as {
group: string;
lastSync: string;
missingRepos: string[];
repos: Record<string, { missing: boolean }>;
};
expect(result.group).toBe('test-group');
expect(result.lastSync).toBeTruthy();
expect(result.missingRepos).toContain('app/frontend');
expect(result.repos['app/backend'].missing).toBe(true);
expect(result.repos['app/frontend'].missing).toBe(true);
} finally {
vi.unstubAllEnvs();
cleanup();
}
});
it('test_groupStatus_bridge_path_reads_repoSnapshots', async () => {
const { groupDir, cleanup, tmpDir } = makeTmpGroup();
try {
vi.stubEnv('GITNEXUS_HOME', tmpDir);
await writeBridge(groupDir, {
contracts: [],
crossLinks: [],
repoSnapshots: {
'app/backend': { indexedAt: '2026-02-01T00:00:00Z', lastCommit: 'abc123' },
'app/frontend': { indexedAt: '2026-02-01T00:00:00Z', lastCommit: 'def456' },
},
missingRepos: [],
});
const port = makePort({
resolveRepo: vi.fn(async () => {
throw new Error('repo not found');
}),
});
const svc = new GroupService(port);
const result = (await svc.groupStatus({ name: 'test-group' })) as {
group: string;
lastSync: string;
missingRepos: string[];
repos: Record<string, { missing: boolean }>;
};
expect(result.group).toBe('test-group');
expect(result.lastSync).toBeTruthy();
expect(result.missingRepos).toEqual([]);
// Both repos should be marked missing since resolveRepo throws
expect(result.repos['app/backend'].missing).toBe(true);
expect(result.repos['app/frontend'].missing).toBe(true);
} finally {
vi.unstubAllEnvs();
cleanup();
}
});
});
});

View file

@ -5,12 +5,12 @@ import * as os from 'node:os';
import {
getGroupDir,
getGroupsBaseDir,
writeContractRegistry,
readContractRegistry,
listGroups,
createGroupDir,
validateGroupName,
openBridgeOrFallback,
} from '../../../src/core/group/storage.js';
import { writeBridge, closeBridgeDb } from '../../../src/core/group/bridge-db.js';
import type { ContractRegistry } from '../../../src/core/group/types.js';
describe('Group storage', () => {
@ -34,36 +34,6 @@ describe('Group storage', () => {
expect(dir).toBe(path.join(tmpDir, 'groups', 'company'));
});
it('writeContractRegistry writes atomically and readContractRegistry reads back', async () => {
const groupDir = path.join(tmpDir, 'groups', 'test-group');
fs.mkdirSync(groupDir, { recursive: true });
const registry: ContractRegistry = {
version: 1,
generatedAt: '2026-03-31T10:00:00Z',
repoSnapshots: {},
missingRepos: [],
contracts: [],
crossLinks: [],
};
await writeContractRegistry(groupDir, registry);
const filePath = path.join(groupDir, 'contracts.json');
expect(fs.existsSync(filePath)).toBe(true);
const loaded = await readContractRegistry(groupDir);
expect(loaded?.version).toBe(1);
expect(loaded?.generatedAt).toBe('2026-03-31T10:00:00Z');
});
it('readContractRegistry returns null when file does not exist', async () => {
const groupDir = path.join(tmpDir, 'groups', 'nonexistent');
fs.mkdirSync(groupDir, { recursive: true });
const result = await readContractRegistry(groupDir);
expect(result).toBeNull();
});
it('listGroups returns group names', async () => {
const groupsDir = path.join(tmpDir, 'groups');
fs.mkdirSync(path.join(groupsDir, 'company'), { recursive: true });
@ -135,4 +105,61 @@ describe('Group storage', () => {
await expect(createGroupDir(tmpDir, '../evil')).rejects.toThrow(/Invalid group name/);
});
});
describe('openBridgeOrFallback', () => {
it('test_openBridgeOrFallback_bridge_exists_returns_bridge', async () => {
const groupDir = path.join(tmpDir, 'bridge-test');
fs.mkdirSync(groupDir, { recursive: true });
await writeBridge(groupDir, {
contracts: [],
crossLinks: [],
repoSnapshots: {},
missingRepos: [],
});
const result = await openBridgeOrFallback(groupDir);
expect(result.type).toBe('bridge');
if (result.type === 'bridge') {
expect(result.handle).toBeDefined();
expect(result.meta).toBeDefined();
await closeBridgeDb(result.handle);
}
});
it('test_openBridgeOrFallback_json_only_returns_json_with_deprecation', async () => {
const groupDir = path.join(tmpDir, 'json-test');
fs.mkdirSync(groupDir, { recursive: true });
const registry: ContractRegistry = {
version: 1,
generatedAt: '2026-04-01T00:00:00Z',
repoSnapshots: {},
missingRepos: [],
contracts: [],
crossLinks: [],
};
fs.writeFileSync(
path.join(groupDir, 'contracts.json'),
JSON.stringify(registry, null, 2),
'utf-8',
);
const result = await openBridgeOrFallback(groupDir);
expect(result.type).toBe('json');
if (result.type === 'json') {
expect(result.registry.version).toBe(1);
expect(result.deprecationWarning).toContain('deprecated');
expect(result.deprecationWarning).toContain('bridge.lbug');
}
});
it('test_openBridgeOrFallback_neither_exists_returns_none', async () => {
const groupDir = path.join(tmpDir, 'empty-test');
fs.mkdirSync(groupDir, { recursive: true });
const result = await openBridgeOrFallback(groupDir);
expect(result.type).toBe('none');
});
});
});

View file

@ -3,6 +3,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { syncGroup, stableRepoPoolId } from '../../../src/core/group/sync.js';
import { bridgeExists } from '../../../src/core/group/bridge-db.js';
import type { GroupConfig, StoredContract, RepoHandle } from '../../../src/core/group/types.js';
import type { RegistryEntry } from '../../../src/storage/repo-manager.js';
@ -202,7 +203,7 @@ describe('syncGroup', () => {
}
});
it('writes registry to groupDir when skipWrite is false', async () => {
it('writes bridge.lbug to groupDir when skipWrite is false', async () => {
const tmpDir = path.join(os.tmpdir(), `gitnexus-sync-write-${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
@ -215,13 +216,7 @@ describe('syncGroup', () => {
});
expect(result.contracts).toHaveLength(0);
const registryPath = path.join(tmpDir, 'contracts.json');
expect(fs.existsSync(registryPath)).toBe(true);
const registry = JSON.parse(fs.readFileSync(registryPath, 'utf-8'));
expect(registry.version).toBe(1);
expect(registry.contracts).toHaveLength(0);
expect(await bridgeExists(tmpDir)).toBe(true);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}