From ae68bfbfb2daf283f86fbecc6718b7d77a9e4d1b Mon Sep 17 00:00:00 2001 From: ivkond Date: Sat, 4 Apr 2026 23:30:04 +0300 Subject: [PATCH] feat(group): bridge.lbug storage and gRPC canonical ID normalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 6 + README.md | 6 +- .../2026-03-31-cross-index-impact-design.md | 4 +- .../plans/2026-04-02-pr626-high-fixes.md | 2 + gitnexus/README.md | 1 + gitnexus/src/cli/group.ts | 30 +- gitnexus/src/core/group/bridge-db.ts | 304 +++++++++++++ gitnexus/src/core/group/bridge-schema.ts | 42 ++ gitnexus/src/core/group/cross-impact.ts | 207 ++++++++- .../core/group/extractors/grpc-extractor.ts | 259 ++++++++--- gitnexus/src/core/group/matching.ts | 112 ++++- gitnexus/src/core/group/service.ts | 417 ++++++++++++++---- gitnexus/src/core/group/storage.ts | 54 ++- gitnexus/src/core/group/sync.ts | 29 +- gitnexus/src/core/group/types.ts | 24 +- gitnexus/src/mcp/tools.ts | 4 +- .../integration/group/group-impact.test.ts | 4 +- .../test/unit/group/bridge-db-edge.test.ts | 124 ++++++ gitnexus/test/unit/group/bridge-db.test.ts | 271 ++++++++++++ gitnexus/test/unit/group/cross-impact.test.ts | 227 +++++++++- .../test/unit/group/grpc-extractor.test.ts | 262 ++++++++++- gitnexus/test/unit/group/matching.test.ts | 225 +++++++++- gitnexus/test/unit/group/service.test.ts | 344 ++++++++++++++- gitnexus/test/unit/group/storage.test.ts | 91 ++-- gitnexus/test/unit/group/sync.test.ts | 11 +- 25 files changed, 2777 insertions(+), 283 deletions(-) create mode 100644 gitnexus/src/core/group/bridge-db.ts create mode 100644 gitnexus/src/core/group/bridge-schema.ts create mode 100644 gitnexus/test/unit/group/bridge-db-edge.test.ts create mode 100644 gitnexus/test/unit/group/bridge-db.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b6298d78..b0ecf3859 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`) diff --git a/README.md b/README.md index 482b496bc..88003bc0b 100644 --- a/README.md +++ b/README.md @@ -214,13 +214,14 @@ gitnexus group remove # Remove a repo from a group gitnexus group list [name] # List groups, or show one group's config gitnexus group sync # Extract contracts and match across repos/services gitnexus group contracts # Inspect extracted contracts and cross-links +gitnexus group impact # Cross-repo blast radius analysis gitnexus group query # Search execution flows across all repos in a group gitnexus group status # 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 # 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 ` to migrate. + **Resources** for instant context: | Resource | Purpose | diff --git a/docs/specs/2026-03-31-cross-index-impact-design.md b/docs/specs/2026-03-31-cross-index-impact-design.md index 61834093b..61be405c4 100644 --- a/docs/specs/2026-03-31-cross-index-impact-design.md +++ b/docs/specs/2026-03-31-cross-index-impact-design.md @@ -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) diff --git a/docs/superpowers/plans/2026-04-02-pr626-high-fixes.md b/docs/superpowers/plans/2026-04-02-pr626-high-fixes.md index 0c9204e8c..42a419b36 100644 --- a/docs/superpowers/plans/2026-04-02-pr626-high-fixes.md +++ b/docs/superpowers/plans/2026-04-02-pr626-high-fixes.md @@ -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. diff --git a/gitnexus/README.md b/gitnexus/README.md index 7e87c93b4..5c1e9576f 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -172,6 +172,7 @@ gitnexus group remove # Remove a repo from a group gitnexus group list [name] # List groups, or show one group's config gitnexus group sync # Extract contracts and match across repos/services gitnexus group contracts # Inspect extracted contracts and cross-links +gitnexus group impact # Cross-repo blast radius analysis gitnexus group query # Search execution flows across all repos in a group gitnexus group status # Check staleness of repos in a group ``` diff --git a/gitnexus/src/cli/group.ts b/gitnexus/src/cli/group.ts index d0e5a6ada..6bd25b799 100644 --- a/gitnexus/src/cli/group.ts +++ b/gitnexus/src/cli/group.ts @@ -97,16 +97,23 @@ export function registerGroupCommands(program: Command): void { .command('status ') .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 ', 'Total wall time budget in ms', '30000') .option('--json', 'JSON output') .action(async (name: string, opts: Record) => { - 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; diff --git a/gitnexus/src/core/group/bridge-db.ts b/gitnexus/src/core/group/bridge-db.ts new file mode 100644 index 000000000..729886960 --- /dev/null +++ b/gitnexus/src/core/group/bridge-db.ts @@ -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 { + 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 { + 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( + handle: BridgeHandle, + cypher: string, + params?: Record, +): Promise { + 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 { + 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 { + 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 { + 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 { + 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; + missingRepos: string[]; +} + +export async function writeBridge(groupDir: string, input: WriteBridgeInput): Promise { + 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 { + 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 { + const handle = await openBridgeDbReadOnly(groupDir); + if (!handle) return false; + await closeBridgeDb(handle); + return true; +} diff --git a/gitnexus/src/core/group/bridge-schema.ts b/gitnexus/src/core/group/bridge-schema.ts new file mode 100644 index 000000000..7998e2823 --- /dev/null +++ b/gitnexus/src/core/group/bridge-schema.ts @@ -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]; diff --git a/gitnexus/src/core/group/cross-impact.ts b/gitnexus/src/core/group/cross-impact.ts index de73eba65..b06d3b178 100644 --- a/gitnexus/src/core/group/cross-impact.ts +++ b/gitnexus/src/core/group/cross-impact.ts @@ -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: (cypher: string, params: Record) => Promise; + localImpactFn: (target: string, direction: string) => Promise; + crossImpactFn: ( + targetGroupPath: string, + symbolUid: string, + direction: string, + hint?: { filePath: string; symbolName: string }, + ) => Promise; + maxDepth?: number; + minConfidence?: number; + subgroup?: string; + timeout?: number; + crossDepth?: number; +} + function collectPhase1Uids(local: Record): Set { const uids = new Set(); 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 { +export async function runGroupImpactLegacy( + opts: LegacyGroupImpactOptions, +): Promise { 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(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 { + 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) + : ({ + 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); + + 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 = { + sourceRepo: opts.repoPath, + localUids: [...uids], + localRefs: [...phase1Refs], + minConfidence, + }; + if (normalizedSubgroup) { + queryParams.subgroup = normalizedSubgroup; + } + + const rows = await opts.bridgeQuery(cypher, queryParams); + + let maxCrossConf = 0; + const distinctRepos = new Set(); + + 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))) { + maxCrossConf = Math.max(maxCrossConf, row.confidence); + distinctRepos.add(row.fanOutRepo); + const r = remote as Record; + 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, + 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, + }; +} diff --git a/gitnexus/src/core/group/extractors/grpc-extractor.ts b/gitnexus/src/core/group/extractors/grpc-extractor.ts index b4cefadc5..42fb9c852 100644 --- a/gitnexus/src/core/group/extractors/grpc-extractor.ts +++ b/gitnexus/src/core/group/extractors/grpc-extractor.ts @@ -75,6 +75,84 @@ function makeContract( }; } +export interface ProtoServiceInfo { + package: string; + serviceName: string; + methods: string[]; + protoPath: string; +} + +export async function buildProtoMap(repoPath: string): Promise> { + const map = new Map(); + 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, + ): 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, + ): 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, + ): 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, + ): 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, + ): 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, + ): 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, + ): 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, diff --git a/gitnexus/src/core/group/matching.ts b/gitnexus/src/core/group/matching.ts index 6d39f4ce4..2b648abe1 100644 --- a/gitnexus/src/core/group/matching.ts +++ b/gitnexus/src/core/group/matching.ts @@ -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 { const providers = contracts.filter((c) => c.role === 'provider'); - const consumers = contracts.filter((c) => c.role === 'consumer'); - - const providerIndex = new Map(); + const index = new Map(); 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, +): 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(); const matchedProviderIds = new Set(); 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, +): WildcardMatchResult { + const wildcardConsumers = unmatched.filter( + (c) => c.role === 'consumer' && isGrpcWildcard(c.contractId), + ); + const matched: CrossLink[] = []; + const matchedConsumerIds = new Set(); + + 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 }; +} diff --git a/gitnexus/src/core/group/service.ts b/gitnexus/src/core/group/service.ts index 58bf97a9e..18ba1518c 100644 --- a/gitnexus/src/core/group/service.ts +++ b/gitnexus/src/core/group/service.ts @@ -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 = {}; + 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); + + // 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): Promise { @@ -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 => { 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).crossDepthWarning = crossDepthWarning; + if (crossDepthWarning) { + (result as unknown as Record).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), + 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) + ) + return null; + return hintResult; + } + return null; + } catch { + return null; + } + }, + maxDepth, + minConfidence, + subgroup, + timeout, + crossDepth, + }); + + if (crossDepthWarning) { + (result as unknown as Record).crossDepthWarning = crossDepthWarning; + } + return result; + } finally { + await closeBridgeDb(handle); + } } async groupQuery(params: Record): Promise { @@ -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 = {}; + 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); + } } } diff --git a/gitnexus/src/core/group/storage.ts b/gitnexus/src/core/group/storage.ts index aa6a781a5..aefab11b9 100644 --- a/gitnexus/src/core/group/storage.ts +++ b/gitnexus/src/core/group/storage.ts @@ -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 { - 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 { - 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 { + 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 " to migrate to bridge.lbug.', + }; + } + return { type: 'none' }; +} diff --git a/gitnexus/src/core/group/sync.ts b/gitnexus/src/core/group/sync.ts index 5236f2eff..0451c7950 100644 --- a/gitnexus/src/core/group/sync.ts +++ b/gitnexus/src/core/group/sync.ts @@ -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, }; diff --git a/gitnexus/src/core/group/types.ts b/gitnexus/src/core/group/types.ts index 7ab0f071a..63cb72a1f 100644 --- a/gitnexus/src/core/group/types.ts +++ b/gitnexus/src/core/group/types.ts @@ -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[]; +} diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index 4fdf45606..52ff08329 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -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: { diff --git a/gitnexus/test/integration/group/group-impact.test.ts b/gitnexus/test/integration/group/group-impact.test.ts index a6fca44df..3274283d3 100644 --- a/gitnexus/test/integration/group/group-impact.test.ts +++ b/gitnexus/test/integration/group/group-impact.test.ts @@ -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', diff --git a/gitnexus/test/unit/group/bridge-db-edge.test.ts b/gitnexus/test/unit/group/bridge-db-edge.test.ts new file mode 100644 index 000000000..03238e35f --- /dev/null +++ b/gitnexus/test/unit/group/bridge-db-edge.test.ts @@ -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 => ({ + 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!); + }); +}); diff --git a/gitnexus/test/unit/group/bridge-db.test.ts b/gitnexus/test/unit/group/bridge-db.test.ts new file mode 100644 index 000000000..31f403dec --- /dev/null +++ b/gitnexus/test/unit/group/bridge-db.test.ts @@ -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 => ({ + 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([]); + }); +}); diff --git a/gitnexus/test/unit/group/cross-impact.test.ts b/gitnexus/test/unit/group/cross-impact.test.ts index 67cb26e9a..a571be25d 100644 --- a/gitnexus/test/unit/group/cross-impact.test.ts +++ b/gitnexus/test/unit/group/cross-impact.test.ts @@ -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[]) { + 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); + }); +}); diff --git a/gitnexus/test/unit/group/grpc-extractor.test.ts b/gitnexus/test/unit/group/grpc-extractor.test.ts index b4fc63b5c..d38135ba4 100644 --- a/gitnexus/test/unit/group/grpc-extractor.test.ts +++ b/gitnexus/test/unit/group/grpc-extractor.test.ts @@ -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 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); + }); +}); diff --git a/gitnexus/test/unit/group/matching.test.ts b/gitnexus/test/unit/group/matching.test.ts index bbbe5f664..c5713d909 100644 --- a/gitnexus/test/unit/group/matching.test.ts +++ b/gitnexus/test/unit/group/matching.test.ts @@ -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 { + 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/*'); + }); +}); diff --git a/gitnexus/test/unit/group/service.test.ts b/gitnexus/test/unit/group/service.test.ts index e4b10443c..eeb33ea45 100644 --- a/gitnexus/test/unit/group/service.test.ts +++ b/gitnexus/test/unit/group/service.test.ts @@ -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 { + 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; + }; + 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; + }; + + 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; + }; + + 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(); + } + }); }); }); diff --git a/gitnexus/test/unit/group/storage.test.ts b/gitnexus/test/unit/group/storage.test.ts index 8a4500e05..c526cbc14 100644 --- a/gitnexus/test/unit/group/storage.test.ts +++ b/gitnexus/test/unit/group/storage.test.ts @@ -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'); + }); + }); }); diff --git a/gitnexus/test/unit/group/sync.test.ts b/gitnexus/test/unit/group/sync.test.ts index 50c9093b9..b07baabe7 100644 --- a/gitnexus/test/unit/group/sync.test.ts +++ b/gitnexus/test/unit/group/sync.test.ts @@ -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 }); }