mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-06 02:49:56 +00:00
feat(group): bridge.lbug storage and gRPC canonical ID normalization
Bridge.lbug — contract storage in LadybugDB: - bridge-schema.ts: Contract, RepoSnapshot, ContractLink DDL - bridge-db.ts: open/close, writeBridge (atomic swap), queryBridge, openBridgeDbReadOnly (with version gate), readBridgeMeta, openBridgeOrFallback - BridgeHandle, BridgeMeta types; LegacyContractRegistry alias gRPC canonical ID — proto-aware extraction: - buildProtoMap: scan .proto files (excludes node_modules/vendor) - resolveProtoConflict: directory proximity disambiguation - serviceContractId: grpc::pkg.Service/* format - All 4 source scanners (Go/Java/Python/TS) resolve via proto map - Confidence adjustments: reduced without proto, boosted with Matching — wildcard support: - buildProviderIndex exported for reuse - runExactMatch skips gRPC /* contracts - runWildcardMatch: bare-name and FQ service matching Consumer migration: - sync.ts: writeBridge + wildcard pass, returns remaining after wildcard - cross-impact.ts: Cypher-based Phase 2 with direction-dependent queries, hint param for empty-UID fan-out, error-object guard - service.ts: openBridgeOrFallback with bridge/json/none branching, groupContracts bridge path returns flat contracts + crossLinks + unmatchedOnly - CLI: status/impact use openBridgeOrFallback, deprecation warnings - MCP tools: descriptions updated (contracts.json → bridge.lbug) - Old storage API removed (writeContractRegistry, readContractRegistry) - Cross-link insertion uses endpoint symbolRef lookup (not link.contractId) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b1c4c56f23
commit
ae68bfbfb2
25 changed files with 2777 additions and 283 deletions
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -214,13 +214,14 @@ gitnexus group remove <name> <repo> # Remove a repo from a group
|
|||
gitnexus group list [name] # List groups, or show one group's config
|
||||
gitnexus group sync <name> # Extract contracts and match across repos/services
|
||||
gitnexus group contracts <name> # Inspect extracted contracts and cross-links
|
||||
gitnexus group impact <name> # Cross-repo blast radius analysis
|
||||
gitnexus group query <name> <q> # Search execution flows across all repos in a group
|
||||
gitnexus group status <name> # Check staleness of repos in a group
|
||||
```
|
||||
|
||||
### What Your AI Agent Gets
|
||||
|
||||
**16 tools** exposed via MCP (11 per-repo + 5 group):
|
||||
**17 tools** exposed via MCP (11 per-repo + 6 group):
|
||||
|
||||
| Tool | What It Does | `repo` Param |
|
||||
| ------------------ | ----------------------------------------------------------------- | -------------- |
|
||||
|
|
@ -234,11 +235,14 @@ gitnexus group status <name> # Check staleness of repos in a group
|
|||
| `group_list` | List configured repository groups | — |
|
||||
| `group_sync` | Extract contracts and match across repos/services | — |
|
||||
| `group_contracts`| Inspect extracted contracts and cross-links | — |
|
||||
| `group_impact` | Cross-repo blast radius analysis | — |
|
||||
| `group_query` | Search execution flows across all repos in a group | — |
|
||||
| `group_status` | Check staleness of repos in a group | — |
|
||||
|
||||
> When only one repo is indexed, the `repo` parameter is optional. With multiple repos, specify which one: `query({query: "auth", repo: "my-app"})`.
|
||||
|
||||
> **Storage migration:** Group contract data is now stored in `bridge.lbug` (LadybugDB) instead of `contracts.json`. Existing groups with `contracts.json` are supported via automatic fallback with a deprecation warning. Run `gitnexus group sync <name>` to migrate.
|
||||
|
||||
**Resources** for instant context:
|
||||
|
||||
| Resource | Purpose |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -172,6 +172,7 @@ gitnexus group remove <name> <repo> # Remove a repo from a group
|
|||
gitnexus group list [name] # List groups, or show one group's config
|
||||
gitnexus group sync <name> # Extract contracts and match across repos/services
|
||||
gitnexus group contracts <name> # Inspect extracted contracts and cross-links
|
||||
gitnexus group impact <name> # Cross-repo blast radius analysis
|
||||
gitnexus group query <name> <q> # Search execution flows across all repos in a group
|
||||
gitnexus group status <name> # Check staleness of repos in a group
|
||||
```
|
||||
|
|
|
|||
|
|
@ -97,16 +97,23 @@ export function registerGroupCommands(program: Command): void {
|
|||
.command('status <name>')
|
||||
.description('Check staleness of group and repos')
|
||||
.action(async (name: string) => {
|
||||
const { readContractRegistry, getGroupDir, getDefaultGitnexusDir } =
|
||||
const { getGroupDir, getDefaultGitnexusDir, openBridgeOrFallback } =
|
||||
await import('../core/group/storage.js');
|
||||
const { closeBridgeDb } = await import('../core/group/bridge-db.js');
|
||||
const { LocalBackend } = await import('../mcp/local/local-backend.js');
|
||||
|
||||
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
|
||||
const registry = await readContractRegistry(groupDir);
|
||||
const fallback = await openBridgeOrFallback(groupDir);
|
||||
const lastSync =
|
||||
fallback.type === 'bridge'
|
||||
? fallback.meta.generatedAt
|
||||
: fallback.type === 'json'
|
||||
? fallback.registry.generatedAt
|
||||
: null;
|
||||
if (fallback.type === 'json') console.warn(fallback.deprecationWarning);
|
||||
if (fallback.type === 'bridge') await closeBridgeDb(fallback.handle);
|
||||
|
||||
console.log(
|
||||
`Group: ${name}${registry ? ` (last sync: ${registry.generatedAt})` : ' (never synced)'}\n`,
|
||||
);
|
||||
console.log(`Group: ${name}${lastSync ? ` (last sync: ${lastSync})` : ' (never synced)'}\n`);
|
||||
|
||||
const backend = new LocalBackend();
|
||||
try {
|
||||
|
|
@ -181,7 +188,7 @@ export function registerGroupCommands(program: Command): void {
|
|||
console.log(` exact: ${exactLinks.length} cross-links (confidence 1.0)`);
|
||||
console.log(` unmatched: ${result.unmatched.length} contracts`);
|
||||
console.log(
|
||||
`\nWrote contracts.json (${result.contracts.length} contracts, ${result.crossLinks.length} cross-links)`,
|
||||
`\nWrote bridge.lbug (${result.contracts.length} contracts, ${result.crossLinks.length} cross-links)`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
|
|
@ -202,17 +209,20 @@ export function registerGroupCommands(program: Command): void {
|
|||
.option('--timeout <ms>', 'Total wall time budget in ms', '30000')
|
||||
.option('--json', 'JSON output')
|
||||
.action(async (name: string, opts: Record<string, string | boolean | undefined>) => {
|
||||
const { getGroupDir, getDefaultGitnexusDir, readContractRegistry } =
|
||||
const { getGroupDir, getDefaultGitnexusDir, openBridgeOrFallback } =
|
||||
await import('../core/group/storage.js');
|
||||
const { closeBridgeDb } = await import('../core/group/bridge-db.js');
|
||||
const { LocalBackend } = await import('../mcp/local/local-backend.js');
|
||||
|
||||
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
|
||||
const regFile = await readContractRegistry(groupDir);
|
||||
if (!regFile) {
|
||||
console.error(`No contracts.json found. Run: gitnexus group sync ${name}`);
|
||||
const fallback = await openBridgeOrFallback(groupDir);
|
||||
if (fallback.type === 'none') {
|
||||
console.error(`No contract data found. Run: gitnexus group sync ${name}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (fallback.type === 'json') console.warn(fallback.deprecationWarning);
|
||||
if (fallback.type === 'bridge') await closeBridgeDb(fallback.handle);
|
||||
|
||||
const repoGroupPath = opts.repo as string;
|
||||
const targetSymbol = opts.target as string;
|
||||
|
|
|
|||
304
gitnexus/src/core/group/bridge-db.ts
Normal file
304
gitnexus/src/core/group/bridge-db.ts
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import lbug from '@ladybugdb/core';
|
||||
import type { LbugValue } from '@ladybugdb/core';
|
||||
import type { BridgeHandle, BridgeMeta, StoredContract, CrossLink, RepoSnapshot } from './types.js';
|
||||
import { BRIDGE_SCHEMA_QUERIES, BRIDGE_SCHEMA_VERSION } from './bridge-schema.js';
|
||||
|
||||
export function contractNodeId(
|
||||
repo: string,
|
||||
contractId: string,
|
||||
role: string,
|
||||
filePath: string,
|
||||
): string {
|
||||
return createHash('sha256').update(`${repo}\0${contractId}\0${role}\0${filePath}`).digest('hex');
|
||||
}
|
||||
|
||||
export async function openBridgeDb(dbPath: string): Promise<BridgeHandle> {
|
||||
const parentDir = path.dirname(dbPath);
|
||||
await fsp.mkdir(parentDir, { recursive: true });
|
||||
const db = new lbug.Database(dbPath, 0, false, false); // writable
|
||||
const conn = new lbug.Connection(db);
|
||||
return { _db: db, _conn: conn, groupDir: parentDir } as BridgeHandle;
|
||||
}
|
||||
|
||||
export async function ensureBridgeSchema(handle: BridgeHandle): Promise<void> {
|
||||
const conn = handle._conn as lbug.Connection;
|
||||
for (const q of BRIDGE_SCHEMA_QUERIES) {
|
||||
try {
|
||||
await conn.query(q);
|
||||
} catch (err: any) {
|
||||
const msg = err?.message ?? '';
|
||||
if (!msg.includes('already exists')) throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function queryBridge<T>(
|
||||
handle: BridgeHandle,
|
||||
cypher: string,
|
||||
params?: Record<string, LbugValue>,
|
||||
): Promise<T[]> {
|
||||
const conn = handle._conn as lbug.Connection;
|
||||
if (params && Object.keys(params).length > 0) {
|
||||
const stmt = await conn.prepare(cypher);
|
||||
if (!stmt.isSuccess()) {
|
||||
const errMsg = await stmt.getErrorMessage();
|
||||
throw new Error(`Bridge query prepare failed: ${errMsg}`);
|
||||
}
|
||||
const queryResult = await conn.execute(stmt, params);
|
||||
const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
|
||||
return (await result.getAll()) as T[];
|
||||
}
|
||||
const queryResult = await conn.query(cypher);
|
||||
const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
|
||||
return (await result.getAll()) as T[];
|
||||
}
|
||||
|
||||
export async function closeBridgeDb(handle: BridgeHandle): Promise<void> {
|
||||
try {
|
||||
await (handle._conn as lbug.Connection).close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
await (handle._db as lbug.Database).close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* retryRename — handles transient EBUSY/EPERM/EACCES on Windows */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const RETRY_CODES = new Set(['EBUSY', 'EPERM', 'EACCES']);
|
||||
|
||||
async function retryRename(src: string, dst: string, attempts = 3): Promise<void> {
|
||||
for (let i = 1; i <= attempts; i++) {
|
||||
try {
|
||||
await fsp.rename(src, dst);
|
||||
return;
|
||||
} catch (err: any) {
|
||||
if (!RETRY_CODES.has(err.code) || i === attempts) throw err;
|
||||
await new Promise((r) => setTimeout(r, 100 * Math.pow(2, i - 1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* writeBridgeMeta / readBridgeMeta */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export async function writeBridgeMeta(groupDir: string, meta: BridgeMeta): Promise<void> {
|
||||
const target = path.join(groupDir, 'meta.json');
|
||||
const tmp = `${target}.tmp.${Date.now()}`;
|
||||
await fsp.writeFile(tmp, JSON.stringify(meta, null, 2), 'utf-8');
|
||||
await fsp.rename(tmp, target);
|
||||
}
|
||||
|
||||
export async function readBridgeMeta(groupDir: string): Promise<BridgeMeta> {
|
||||
try {
|
||||
const content = await fsp.readFile(path.join(groupDir, 'meta.json'), 'utf-8');
|
||||
return JSON.parse(content) as BridgeMeta;
|
||||
} catch {
|
||||
return { version: 0, generatedAt: '', missingRepos: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* writeBridge — atomic write-to-temp-then-rename */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface WriteBridgeInput {
|
||||
contracts: StoredContract[];
|
||||
crossLinks: CrossLink[];
|
||||
repoSnapshots: Record<string, RepoSnapshot>;
|
||||
missingRepos: string[];
|
||||
}
|
||||
|
||||
export async function writeBridge(groupDir: string, input: WriteBridgeInput): Promise<void> {
|
||||
await fsp.mkdir(groupDir, { recursive: true });
|
||||
|
||||
const finalPath = path.join(groupDir, 'bridge.lbug');
|
||||
const tmpPath = path.join(groupDir, 'bridge.lbug.tmp');
|
||||
const bakPath = path.join(groupDir, 'bridge.lbug.bak');
|
||||
|
||||
// Clean up any leftover tmp
|
||||
try {
|
||||
await fsp.rm(tmpPath, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
// 1. Create temp DB, insert all data
|
||||
const handle = await openBridgeDb(tmpPath);
|
||||
await ensureBridgeSchema(handle);
|
||||
|
||||
// Insert contracts
|
||||
for (const c of input.contracts) {
|
||||
const id = contractNodeId(c.repo, c.contractId, c.role, c.symbolRef.filePath);
|
||||
await queryBridge(
|
||||
handle,
|
||||
`CREATE (n:Contract {
|
||||
id: $id,
|
||||
contractId: $contractId,
|
||||
type: $type,
|
||||
role: $role,
|
||||
repo: $repo,
|
||||
service: $service,
|
||||
symbolUid: $symbolUid,
|
||||
filePath: $filePath,
|
||||
symbolName: $symbolName,
|
||||
confidence: $confidence,
|
||||
meta: $meta
|
||||
})`,
|
||||
{
|
||||
id,
|
||||
contractId: c.contractId,
|
||||
type: c.type,
|
||||
role: c.role,
|
||||
repo: c.repo,
|
||||
service: c.service ?? '',
|
||||
symbolUid: c.symbolUid,
|
||||
filePath: c.symbolRef.filePath,
|
||||
symbolName: c.symbolName,
|
||||
confidence: c.confidence,
|
||||
meta: JSON.stringify(c.meta),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Insert repo snapshots
|
||||
for (const [repoId, snap] of Object.entries(input.repoSnapshots)) {
|
||||
await queryBridge(
|
||||
handle,
|
||||
`CREATE (s:RepoSnapshot {
|
||||
id: $id,
|
||||
indexedAt: $indexedAt,
|
||||
lastCommit: $lastCommit
|
||||
})`,
|
||||
{
|
||||
id: repoId,
|
||||
indexedAt: snap.indexedAt,
|
||||
lastCommit: snap.lastCommit,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Insert cross-links (tolerating missing nodes).
|
||||
// Use repo-scoped matching: find FROM node by (repo, role=consumer) and TO by (repo, role=provider)
|
||||
// with symbolRef matching, because link.contractId is the consumer's ID which may differ
|
||||
// from the provider's contractId (e.g. wildcard consumer vs method-level provider).
|
||||
for (const link of input.crossLinks) {
|
||||
// Find from-node: match by repo + role + symbolRef (consumer side)
|
||||
const fromRows = await queryBridge<{ id: string }>(
|
||||
handle,
|
||||
`MATCH (c:Contract) WHERE c.repo = $repo AND c.role = 'consumer'
|
||||
AND c.filePath = $fp AND c.symbolName = $sn RETURN c.id AS id LIMIT 1`,
|
||||
{ repo: link.from.repo, fp: link.from.symbolRef.filePath, sn: link.from.symbolRef.name },
|
||||
);
|
||||
// Find to-node: match by repo + role + symbolRef (provider side)
|
||||
const toRows = await queryBridge<{ id: string }>(
|
||||
handle,
|
||||
`MATCH (c:Contract) WHERE c.repo = $repo AND c.role = 'provider'
|
||||
AND c.filePath = $fp AND c.symbolName = $sn RETURN c.id AS id LIMIT 1`,
|
||||
{ repo: link.to.repo, fp: link.to.symbolRef.filePath, sn: link.to.symbolRef.name },
|
||||
);
|
||||
if (!fromRows.length || !toRows.length) continue;
|
||||
const fromId = fromRows[0].id;
|
||||
const toId = toRows[0].id;
|
||||
await queryBridge(
|
||||
handle,
|
||||
`
|
||||
MATCH (a:Contract), (b:Contract)
|
||||
WHERE a.id = $fromId AND b.id = $toId
|
||||
CREATE (a)-[:ContractLink {
|
||||
matchType: $matchType,
|
||||
confidence: $confidence,
|
||||
contractId: $contractId,
|
||||
fromRepo: $fromRepo,
|
||||
toRepo: $toRepo
|
||||
}]->(b)
|
||||
`,
|
||||
{
|
||||
fromId,
|
||||
toId,
|
||||
matchType: link.matchType,
|
||||
confidence: link.confidence,
|
||||
contractId: link.contractId,
|
||||
fromRepo: link.from.repo,
|
||||
toRepo: link.to.repo,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Close temp DB
|
||||
await closeBridgeDb(handle);
|
||||
|
||||
// 3. Atomic swap: old→.bak, tmp→final, rm .bak
|
||||
try {
|
||||
await fsp.access(finalPath);
|
||||
await retryRename(finalPath, bakPath);
|
||||
} catch {
|
||||
/* no existing db */
|
||||
}
|
||||
await retryRename(tmpPath, finalPath);
|
||||
try {
|
||||
await fsp.rm(bakPath, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
// 4. Write meta.json
|
||||
await writeBridgeMeta(groupDir, {
|
||||
version: BRIDGE_SCHEMA_VERSION,
|
||||
generatedAt: new Date().toISOString(),
|
||||
missingRepos: input.missingRepos,
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* openBridgeDbReadOnly */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export async function openBridgeDbReadOnly(groupDir: string): Promise<BridgeHandle | null> {
|
||||
const dbPath = path.join(groupDir, 'bridge.lbug');
|
||||
try {
|
||||
await fsp.access(dbPath);
|
||||
} catch {
|
||||
// Check for .bak recovery
|
||||
const bakPath = path.join(groupDir, 'bridge.lbug.bak');
|
||||
try {
|
||||
await fsp.access(bakPath);
|
||||
await fsp.rename(bakPath, dbPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// Version gate: check meta.json version compatibility
|
||||
const meta = await readBridgeMeta(groupDir);
|
||||
if (meta.version > 0 && meta.version !== BRIDGE_SCHEMA_VERSION) {
|
||||
return null; // incompatible schema version — fallback to JSON or re-sync
|
||||
}
|
||||
|
||||
try {
|
||||
const db = new lbug.Database(dbPath, 0, false, true); // readOnly
|
||||
const conn = new lbug.Connection(db);
|
||||
return { _db: db, _conn: conn, groupDir } as BridgeHandle;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* bridgeExists */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export async function bridgeExists(groupDir: string): Promise<boolean> {
|
||||
const handle = await openBridgeDbReadOnly(groupDir);
|
||||
if (!handle) return false;
|
||||
await closeBridgeDb(handle);
|
||||
return true;
|
||||
}
|
||||
42
gitnexus/src/core/group/bridge-schema.ts
Normal file
42
gitnexus/src/core/group/bridge-schema.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* Bridge LadybugDB schema for cross-repo Contract Registry.
|
||||
* Separate from per-repo schema in lbug/schema.ts.
|
||||
*/
|
||||
|
||||
export const BRIDGE_SCHEMA_VERSION = 1;
|
||||
|
||||
export const CONTRACT_SCHEMA = `
|
||||
CREATE NODE TABLE Contract (
|
||||
id STRING,
|
||||
contractId STRING,
|
||||
type STRING,
|
||||
role STRING,
|
||||
repo STRING,
|
||||
service STRING DEFAULT '',
|
||||
symbolUid STRING DEFAULT '',
|
||||
filePath STRING DEFAULT '',
|
||||
symbolName STRING DEFAULT '',
|
||||
confidence DOUBLE DEFAULT 0.0,
|
||||
meta STRING DEFAULT '{}',
|
||||
PRIMARY KEY (id)
|
||||
)`;
|
||||
|
||||
export const REPO_SNAPSHOT_SCHEMA = `
|
||||
CREATE NODE TABLE RepoSnapshot (
|
||||
id STRING,
|
||||
indexedAt STRING DEFAULT '',
|
||||
lastCommit STRING DEFAULT '',
|
||||
PRIMARY KEY (id)
|
||||
)`;
|
||||
|
||||
export const CONTRACT_LINK_SCHEMA = `
|
||||
CREATE REL TABLE ContractLink (
|
||||
FROM Contract TO Contract,
|
||||
matchType STRING,
|
||||
confidence DOUBLE,
|
||||
contractId STRING,
|
||||
fromRepo STRING,
|
||||
toRepo STRING
|
||||
)`;
|
||||
|
||||
export const BRIDGE_SCHEMA_QUERIES = [CONTRACT_SCHEMA, REPO_SNAPSHOT_SCHEMA, CONTRACT_LINK_SCHEMA];
|
||||
|
|
@ -6,7 +6,7 @@ import type {
|
|||
OutOfScopeLink,
|
||||
} from './types.js';
|
||||
|
||||
export interface GroupImpactOptions {
|
||||
export interface LegacyGroupImpactOptions {
|
||||
groupName: string;
|
||||
target: string;
|
||||
repoPath: string;
|
||||
|
|
@ -25,6 +25,26 @@ export interface GroupImpactOptions {
|
|||
crossDepth?: number;
|
||||
}
|
||||
|
||||
export interface GroupImpactOptions {
|
||||
groupName: string;
|
||||
target: string;
|
||||
repoPath: string;
|
||||
direction: 'upstream' | 'downstream';
|
||||
bridgeQuery: <T>(cypher: string, params: Record<string, unknown>) => Promise<T[]>;
|
||||
localImpactFn: (target: string, direction: string) => Promise<unknown>;
|
||||
crossImpactFn: (
|
||||
targetGroupPath: string,
|
||||
symbolUid: string,
|
||||
direction: string,
|
||||
hint?: { filePath: string; symbolName: string },
|
||||
) => Promise<unknown | null>;
|
||||
maxDepth?: number;
|
||||
minConfidence?: number;
|
||||
subgroup?: string;
|
||||
timeout?: number;
|
||||
crossDepth?: number;
|
||||
}
|
||||
|
||||
function collectPhase1Uids(local: Record<string, unknown>): Set<string> {
|
||||
const uids = new Set<string>();
|
||||
const target = local.target as { id?: string } | undefined;
|
||||
|
|
@ -97,7 +117,9 @@ function mergeRisk(
|
|||
return order[idx] ?? base;
|
||||
}
|
||||
|
||||
export async function runGroupImpact(opts: GroupImpactOptions): Promise<GroupImpactResult> {
|
||||
export async function runGroupImpactLegacy(
|
||||
opts: LegacyGroupImpactOptions,
|
||||
): Promise<GroupImpactResult> {
|
||||
const timeout = opts.timeout ?? 30000;
|
||||
const minConfidence = opts.minConfidence ?? 0.5;
|
||||
const crossDepth = Math.min(1, opts.crossDepth ?? 1);
|
||||
|
|
@ -223,3 +245,184 @@ export async function runGroupImpact(opts: GroupImpactOptions): Promise<GroupImp
|
|||
risk,
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Cypher-based Phase 2 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const UPSTREAM_QUERY_BASE = `
|
||||
MATCH (consumer:Contract)-[l:ContractLink]->(provider:Contract)
|
||||
WHERE provider.repo = $sourceRepo
|
||||
AND (provider.symbolUid IN $localUids
|
||||
OR (NOT provider.symbolUid IN $localUids AND (provider.filePath + '::' + provider.symbolName) IN $localRefs))
|
||||
AND l.confidence >= $minConfidence`;
|
||||
|
||||
const UPSTREAM_QUERY_SUBGROUP = ` AND (consumer.repo = $subgroup OR consumer.repo STARTS WITH $subgroup + '/')`;
|
||||
|
||||
const UPSTREAM_QUERY_RETURN = `
|
||||
RETURN consumer.repo AS fanOutRepo, consumer.symbolUid AS fanOutUid,
|
||||
consumer.filePath AS fanOutFilePath, consumer.symbolName AS fanOutSymbolName,
|
||||
provider.symbolUid AS matchedLocalUid,
|
||||
provider.filePath AS matchedLocalFilePath,
|
||||
provider.symbolName AS matchedLocalSymbolName,
|
||||
l.matchType AS matchType, l.confidence AS confidence, l.contractId AS contractId,
|
||||
consumer.type AS contractType
|
||||
ORDER BY l.confidence DESC`;
|
||||
|
||||
const DOWNSTREAM_QUERY_BASE = `
|
||||
MATCH (consumer:Contract)-[l:ContractLink]->(provider:Contract)
|
||||
WHERE consumer.repo = $sourceRepo
|
||||
AND (consumer.symbolUid IN $localUids
|
||||
OR (NOT consumer.symbolUid IN $localUids AND (consumer.filePath + '::' + consumer.symbolName) IN $localRefs))
|
||||
AND l.confidence >= $minConfidence`;
|
||||
|
||||
const DOWNSTREAM_QUERY_SUBGROUP = ` AND (provider.repo = $subgroup OR provider.repo STARTS WITH $subgroup + '/')`;
|
||||
|
||||
const DOWNSTREAM_QUERY_RETURN = `
|
||||
RETURN provider.repo AS fanOutRepo, provider.symbolUid AS fanOutUid,
|
||||
provider.filePath AS fanOutFilePath, provider.symbolName AS fanOutSymbolName,
|
||||
consumer.symbolUid AS matchedLocalUid,
|
||||
consumer.filePath AS matchedLocalFilePath,
|
||||
consumer.symbolName AS matchedLocalSymbolName,
|
||||
l.matchType AS matchType, l.confidence AS confidence, l.contractId AS contractId,
|
||||
consumer.type AS contractType
|
||||
ORDER BY l.confidence DESC`;
|
||||
|
||||
interface CrossImpactRow {
|
||||
fanOutRepo: string;
|
||||
fanOutUid: string;
|
||||
fanOutFilePath: string;
|
||||
fanOutSymbolName: string;
|
||||
matchedLocalUid: string;
|
||||
matchedLocalFilePath: string;
|
||||
matchedLocalSymbolName: string;
|
||||
matchType: string;
|
||||
confidence: number;
|
||||
contractId: string;
|
||||
contractType: string;
|
||||
}
|
||||
|
||||
export async function runGroupImpact(opts: GroupImpactOptions): Promise<GroupImpactResult> {
|
||||
const timeout = opts.timeout ?? 30000;
|
||||
const minConfidence = opts.minConfidence ?? 0.5;
|
||||
const crossDepth = Math.min(1, opts.crossDepth ?? 1);
|
||||
|
||||
const tStart = Date.now();
|
||||
const wallDeadline = tStart + timeout;
|
||||
const phase1Timeout = Math.min(5000, timeout);
|
||||
|
||||
const localResult = await Promise.race([
|
||||
opts.localImpactFn(opts.target, opts.direction).then((v) => ({ ok: true as const, v })),
|
||||
new Promise<{ ok: false }>((resolve) =>
|
||||
setTimeout(() => resolve({ ok: false }), phase1Timeout),
|
||||
),
|
||||
]);
|
||||
|
||||
let truncated = !localResult.ok;
|
||||
const local = localResult.ok
|
||||
? (localResult.v as Record<string, unknown>)
|
||||
: ({
|
||||
target: { id: '', name: opts.target, filePath: '' },
|
||||
direction: opts.direction,
|
||||
impactedCount: 0,
|
||||
risk: 'LOW',
|
||||
summary: { direct: 0, processes_affected: 0, modules_affected: 0 },
|
||||
affected_processes: [],
|
||||
affected_modules: [],
|
||||
byDepth: {},
|
||||
} as Record<string, unknown>);
|
||||
|
||||
const uids = collectPhase1Uids(local);
|
||||
const phase1Refs = collectPhase1Refs(local);
|
||||
const cross: CrossRepoImpact[] = [];
|
||||
const truncatedRepos: string[] = [];
|
||||
|
||||
/* Phase 2 — Cypher bridge query */
|
||||
const normalizedSubgroup = opts.subgroup?.trim().replace(/\/+$/, '') || null;
|
||||
|
||||
const isUpstream = opts.direction === 'upstream';
|
||||
const queryBase = isUpstream ? UPSTREAM_QUERY_BASE : DOWNSTREAM_QUERY_BASE;
|
||||
const querySubgroup = isUpstream ? UPSTREAM_QUERY_SUBGROUP : DOWNSTREAM_QUERY_SUBGROUP;
|
||||
const queryReturn = isUpstream ? UPSTREAM_QUERY_RETURN : DOWNSTREAM_QUERY_RETURN;
|
||||
|
||||
const cypher = normalizedSubgroup
|
||||
? queryBase + querySubgroup + queryReturn
|
||||
: queryBase + queryReturn;
|
||||
|
||||
const queryParams: Record<string, unknown> = {
|
||||
sourceRepo: opts.repoPath,
|
||||
localUids: [...uids],
|
||||
localRefs: [...phase1Refs],
|
||||
minConfidence,
|
||||
};
|
||||
if (normalizedSubgroup) {
|
||||
queryParams.subgroup = normalizedSubgroup;
|
||||
}
|
||||
|
||||
const rows = await opts.bridgeQuery<CrossImpactRow>(cypher, queryParams);
|
||||
|
||||
let maxCrossConf = 0;
|
||||
const distinctRepos = new Set<string>();
|
||||
|
||||
for (const row of rows) {
|
||||
if (Date.now() > wallDeadline) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
if (crossDepth < 1) break;
|
||||
|
||||
const hint = row.fanOutUid
|
||||
? undefined
|
||||
: { filePath: row.fanOutFilePath, symbolName: row.fanOutSymbolName };
|
||||
const remote = await opts.crossImpactFn(row.fanOutRepo, row.fanOutUid, opts.direction, hint);
|
||||
|
||||
if (remote && typeof remote === 'object' && !('error' in (remote as Record<string, unknown>))) {
|
||||
maxCrossConf = Math.max(maxCrossConf, row.confidence);
|
||||
distinctRepos.add(row.fanOutRepo);
|
||||
const r = remote as Record<string, unknown>;
|
||||
cross.push({
|
||||
repo: row.fanOutRepo,
|
||||
repo_path: row.fanOutRepo,
|
||||
contract: {
|
||||
id: row.contractId,
|
||||
type: row.contractType as CrossRepoImpact['contract']['type'],
|
||||
match_type: row.matchType as CrossRepoImpact['contract']['match_type'],
|
||||
confidence: row.confidence,
|
||||
},
|
||||
by_depth: (r.byDepth || {}) as Record<string, unknown[]>,
|
||||
affected_processes: (r.affected_processes || []) as string[],
|
||||
});
|
||||
}
|
||||
|
||||
if (Date.now() > wallDeadline) {
|
||||
truncated = true;
|
||||
truncatedRepos.push(row.fanOutRepo);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const summaryLocal = (local.summary || {}) as {
|
||||
direct?: number;
|
||||
processes_affected?: number;
|
||||
modules_affected?: number;
|
||||
};
|
||||
|
||||
const baseRisk = String(local.risk || 'LOW');
|
||||
const risk = mergeRisk(baseRisk, cross.length, maxCrossConf, distinctRepos.size);
|
||||
|
||||
return {
|
||||
local,
|
||||
group: opts.groupName,
|
||||
cross,
|
||||
outOfScope: [],
|
||||
truncated,
|
||||
truncatedRepos,
|
||||
summary: {
|
||||
direct: summaryLocal.direct ?? 0,
|
||||
processes_affected: summaryLocal.processes_affected ?? 0,
|
||||
modules_affected: summaryLocal.modules_affected ?? 0,
|
||||
cross_repo_hits: cross.length,
|
||||
},
|
||||
risk,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,6 +75,84 @@ function makeContract(
|
|||
};
|
||||
}
|
||||
|
||||
export interface ProtoServiceInfo {
|
||||
package: string;
|
||||
serviceName: string;
|
||||
methods: string[];
|
||||
protoPath: string;
|
||||
}
|
||||
|
||||
export async function buildProtoMap(repoPath: string): Promise<Map<string, ProtoServiceInfo[]>> {
|
||||
const map = new Map<string, ProtoServiceInfo[]>();
|
||||
const protoFiles = await glob('**/*.proto', {
|
||||
cwd: repoPath,
|
||||
absolute: false,
|
||||
nodir: true,
|
||||
ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**'],
|
||||
});
|
||||
|
||||
for (const rel of protoFiles) {
|
||||
const content = readSafe(repoPath, rel);
|
||||
if (!content) continue;
|
||||
|
||||
const pkgMatch = content.match(/^\s*package\s+([\w.]+)\s*;/m);
|
||||
const pkg = pkgMatch?.[1] ?? '';
|
||||
|
||||
const serviceBlocks = extractServiceBlocks(content);
|
||||
for (const block of serviceBlocks) {
|
||||
const rpcRe = /rpc\s+(\w+)\s*\(/g;
|
||||
const methods: string[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = rpcRe.exec(block.body)) !== null) {
|
||||
methods.push(m[1]);
|
||||
}
|
||||
const info: ProtoServiceInfo = {
|
||||
package: pkg,
|
||||
serviceName: block.name,
|
||||
methods,
|
||||
protoPath: rel.replace(/\\/g, '/'),
|
||||
};
|
||||
const existing = map.get(block.name) ?? [];
|
||||
existing.push(info);
|
||||
map.set(block.name, existing);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function resolveProtoConflict(
|
||||
_serviceName: string,
|
||||
sourceFilePath: string,
|
||||
candidates: ProtoServiceInfo[],
|
||||
): ProtoServiceInfo | null {
|
||||
if (candidates.length === 0) return null;
|
||||
if (candidates.length === 1) return candidates[0];
|
||||
|
||||
// Directory proximity heuristic
|
||||
const sourceDir = path.dirname(sourceFilePath).replace(/\\/g, '/');
|
||||
let best = candidates[0];
|
||||
let bestScore = 0;
|
||||
for (const c of candidates) {
|
||||
const protoDir = path.dirname(c.protoPath).replace(/\\/g, '/');
|
||||
let shared = 0;
|
||||
const min = Math.min(sourceDir.length, protoDir.length);
|
||||
for (let i = 0; i < min; i++) {
|
||||
if (sourceDir[i] === protoDir[i]) shared++;
|
||||
else break;
|
||||
}
|
||||
if (shared > bestScore) {
|
||||
bestScore = shared;
|
||||
best = c;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function serviceContractId(pkg: string, serviceName: string): string {
|
||||
const prefix = pkg ? `${pkg}.${serviceName}` : serviceName;
|
||||
return `grpc::${prefix}/*`;
|
||||
}
|
||||
|
||||
export class GrpcExtractor implements ContractExtractor {
|
||||
type = 'grpc' as const;
|
||||
|
||||
|
|
@ -100,6 +178,9 @@ export class GrpcExtractor implements ContractExtractor {
|
|||
if (content) out.push(...this.parseProtoFile(content, rel));
|
||||
}
|
||||
|
||||
// Build proto map for source scanner resolution
|
||||
const protoMap = await buildProtoMap(repoPath);
|
||||
|
||||
// Source files — server/client detection
|
||||
const sourceFiles = await glob('**/*.{go,java,py,ts,tsx,js,jsx}', {
|
||||
cwd: repoPath,
|
||||
|
|
@ -112,16 +193,16 @@ export class GrpcExtractor implements ContractExtractor {
|
|||
const ext = path.extname(rel).toLowerCase();
|
||||
|
||||
if (ext === '.go') {
|
||||
out.push(...this.scanGoProviders(content, rel));
|
||||
out.push(...this.scanGoConsumers(content, rel));
|
||||
out.push(...this.scanGoProviders(content, rel, protoMap));
|
||||
out.push(...this.scanGoConsumers(content, rel, protoMap));
|
||||
} else if (ext === '.java') {
|
||||
out.push(...this.scanJavaProviders(content, rel));
|
||||
out.push(...this.scanJavaConsumers(content, rel));
|
||||
out.push(...this.scanJavaProviders(content, rel, protoMap));
|
||||
out.push(...this.scanJavaConsumers(content, rel, protoMap));
|
||||
} else if (ext === '.py') {
|
||||
out.push(...this.scanPythonProviders(content, rel));
|
||||
out.push(...this.scanPythonConsumers(content, rel));
|
||||
out.push(...this.scanPythonProviders(content, rel, protoMap));
|
||||
out.push(...this.scanPythonConsumers(content, rel, protoMap));
|
||||
} else if (['.ts', '.tsx', '.js', '.jsx'].includes(ext)) {
|
||||
out.push(...this.scanTsProviders(content, rel));
|
||||
out.push(...this.scanTsProviders(content, rel, protoMap));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -154,7 +235,11 @@ export class GrpcExtractor implements ContractExtractor {
|
|||
return out;
|
||||
}
|
||||
|
||||
private scanGoProviders(content: string, filePath: string): ExtractedContract[] {
|
||||
private scanGoProviders(
|
||||
content: string,
|
||||
filePath: string,
|
||||
protoMap: Map<string, ProtoServiceInfo[]>,
|
||||
): ExtractedContract[] {
|
||||
const out: ExtractedContract[] = [];
|
||||
|
||||
// pb.RegisterXxxServer(
|
||||
|
|
@ -162,15 +247,17 @@ export class GrpcExtractor implements ContractExtractor {
|
|||
let m: RegExpExecArray | null;
|
||||
while ((m = registerRe.exec(content)) !== null) {
|
||||
const serviceName = m[1];
|
||||
const candidates = protoMap.get(serviceName);
|
||||
const proto = resolveProtoConflict(serviceName, filePath, candidates ?? []);
|
||||
const cid = proto
|
||||
? serviceContractId(proto.package, proto.serviceName)
|
||||
: serviceOnlyContractId(serviceName);
|
||||
const conf = proto ? 0.8 : 0.65;
|
||||
out.push(
|
||||
makeContract(
|
||||
serviceOnlyContractId(serviceName),
|
||||
'provider',
|
||||
filePath,
|
||||
`Register${serviceName}Server`,
|
||||
0.8,
|
||||
{ service: serviceName, source: 'go_register' },
|
||||
),
|
||||
makeContract(cid, 'provider', filePath, `Register${serviceName}Server`, conf, {
|
||||
service: serviceName,
|
||||
source: 'go_register',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -178,51 +265,74 @@ export class GrpcExtractor implements ContractExtractor {
|
|||
const unimplRe = /\w+\.Unimplemented(\w+)Server\b/g;
|
||||
while ((m = unimplRe.exec(content)) !== null) {
|
||||
const serviceName = m[1];
|
||||
const candidates = protoMap.get(serviceName);
|
||||
const proto = resolveProtoConflict(serviceName, filePath, candidates ?? []);
|
||||
const cid = proto
|
||||
? serviceContractId(proto.package, proto.serviceName)
|
||||
: serviceOnlyContractId(serviceName);
|
||||
const conf = proto ? 0.8 : 0.65;
|
||||
out.push(
|
||||
makeContract(
|
||||
serviceOnlyContractId(serviceName),
|
||||
'provider',
|
||||
filePath,
|
||||
`Unimplemented${serviceName}Server`,
|
||||
0.8,
|
||||
{ service: serviceName, source: 'go_unimplemented' },
|
||||
),
|
||||
makeContract(cid, 'provider', filePath, `Unimplemented${serviceName}Server`, conf, {
|
||||
service: serviceName,
|
||||
source: 'go_unimplemented',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
private scanGoConsumers(content: string, filePath: string): ExtractedContract[] {
|
||||
private scanGoConsumers(
|
||||
content: string,
|
||||
filePath: string,
|
||||
protoMap: Map<string, ProtoServiceInfo[]>,
|
||||
): ExtractedContract[] {
|
||||
const out: ExtractedContract[] = [];
|
||||
const re = /\w+\.New(\w+)Client\s*\(/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(content)) !== null) {
|
||||
const serviceName = m[1];
|
||||
const candidates = protoMap.get(serviceName);
|
||||
const proto = resolveProtoConflict(serviceName, filePath, candidates ?? []);
|
||||
const cid = proto
|
||||
? serviceContractId(proto.package, proto.serviceName)
|
||||
: serviceOnlyContractId(serviceName);
|
||||
const conf = proto ? 0.75 : 0.55;
|
||||
out.push(
|
||||
makeContract(
|
||||
serviceOnlyContractId(serviceName),
|
||||
'consumer',
|
||||
filePath,
|
||||
`New${serviceName}Client`,
|
||||
0.7,
|
||||
{ service: serviceName, source: 'go_client' },
|
||||
),
|
||||
makeContract(cid, 'consumer', filePath, `New${serviceName}Client`, conf, {
|
||||
service: serviceName,
|
||||
source: 'go_client',
|
||||
}),
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private scanJavaProviders(content: string, filePath: string): ExtractedContract[] {
|
||||
private scanJavaProviders(
|
||||
content: string,
|
||||
filePath: string,
|
||||
protoMap: Map<string, ProtoServiceInfo[]>,
|
||||
): ExtractedContract[] {
|
||||
const out: ExtractedContract[] = [];
|
||||
|
||||
const resolveJava = (svcName: string): { cid: string; conf: number } => {
|
||||
const candidates = protoMap.get(svcName);
|
||||
const proto = resolveProtoConflict(svcName, filePath, candidates ?? []);
|
||||
const cid = proto
|
||||
? serviceContractId(proto.package, proto.serviceName)
|
||||
: serviceOnlyContractId(svcName);
|
||||
const conf = proto ? 0.8 : 0.65;
|
||||
return { cid, conf };
|
||||
};
|
||||
|
||||
// @GrpcService
|
||||
if (content.includes('@GrpcService')) {
|
||||
const implBaseRe = /extends\s+(\w+)Grpc\.(\w+)ImplBase/;
|
||||
const m = content.match(implBaseRe);
|
||||
if (m) {
|
||||
const { cid, conf } = resolveJava(m[1]);
|
||||
out.push(
|
||||
makeContract(serviceOnlyContractId(m[1]), 'provider', filePath, m[2], 0.8, {
|
||||
makeContract(cid, 'provider', filePath, m[2], conf, {
|
||||
service: m[1],
|
||||
source: 'java_grpc_service',
|
||||
}),
|
||||
|
|
@ -234,8 +344,9 @@ export class GrpcExtractor implements ContractExtractor {
|
|||
const cm = content.match(classRe);
|
||||
if (cm) {
|
||||
const svcName = cm[2].replace(/Grpc$/, '');
|
||||
const { cid, conf } = resolveJava(svcName);
|
||||
out.push(
|
||||
makeContract(serviceOnlyContractId(svcName), 'provider', filePath, cm[1], 0.8, {
|
||||
makeContract(cid, 'provider', filePath, cm[1], conf, {
|
||||
service: svcName,
|
||||
source: 'java_grpc_service',
|
||||
}),
|
||||
|
|
@ -250,8 +361,9 @@ export class GrpcExtractor implements ContractExtractor {
|
|||
const m = content.match(implRe);
|
||||
if (m) {
|
||||
const svcName = m[2] || m[1].replace(/Grpc$/, '');
|
||||
const { cid, conf } = resolveJava(svcName);
|
||||
out.push(
|
||||
makeContract(serviceOnlyContractId(svcName), 'provider', filePath, svcName, 0.8, {
|
||||
makeContract(cid, 'provider', filePath, svcName, conf, {
|
||||
service: svcName,
|
||||
source: 'java_impl_base',
|
||||
}),
|
||||
|
|
@ -262,49 +374,65 @@ export class GrpcExtractor implements ContractExtractor {
|
|||
return out;
|
||||
}
|
||||
|
||||
private scanJavaConsumers(content: string, filePath: string): ExtractedContract[] {
|
||||
private scanJavaConsumers(
|
||||
content: string,
|
||||
filePath: string,
|
||||
protoMap: Map<string, ProtoServiceInfo[]>,
|
||||
): ExtractedContract[] {
|
||||
const out: ExtractedContract[] = [];
|
||||
// XxxGrpc.newBlockingStub( or XxxGrpc.newStub(
|
||||
const re = /(\w+)Grpc\.new(?:Blocking)?Stub\s*\(/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(content)) !== null) {
|
||||
const serviceName = m[1];
|
||||
const candidates = protoMap.get(serviceName);
|
||||
const proto = resolveProtoConflict(serviceName, filePath, candidates ?? []);
|
||||
const cid = proto
|
||||
? serviceContractId(proto.package, proto.serviceName)
|
||||
: serviceOnlyContractId(serviceName);
|
||||
const conf = proto ? 0.75 : 0.55;
|
||||
out.push(
|
||||
makeContract(
|
||||
serviceOnlyContractId(serviceName),
|
||||
'consumer',
|
||||
filePath,
|
||||
`${serviceName}Stub`,
|
||||
0.7,
|
||||
{ service: serviceName, source: 'java_stub' },
|
||||
),
|
||||
makeContract(cid, 'consumer', filePath, `${serviceName}Stub`, conf, {
|
||||
service: serviceName,
|
||||
source: 'java_stub',
|
||||
}),
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private scanPythonProviders(content: string, filePath: string): ExtractedContract[] {
|
||||
private scanPythonProviders(
|
||||
content: string,
|
||||
filePath: string,
|
||||
protoMap: Map<string, ProtoServiceInfo[]>,
|
||||
): ExtractedContract[] {
|
||||
const out: ExtractedContract[] = [];
|
||||
// add_XxxServicer_to_server(
|
||||
const re = /add_(\w+?)Servicer_to_server\s*\(/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(content)) !== null) {
|
||||
const serviceName = m[1];
|
||||
const candidates = protoMap.get(serviceName);
|
||||
const proto = resolveProtoConflict(serviceName, filePath, candidates ?? []);
|
||||
const cid = proto
|
||||
? serviceContractId(proto.package, proto.serviceName)
|
||||
: serviceOnlyContractId(serviceName);
|
||||
const conf = proto ? 0.8 : 0.65;
|
||||
out.push(
|
||||
makeContract(
|
||||
serviceOnlyContractId(serviceName),
|
||||
'provider',
|
||||
filePath,
|
||||
`add_${serviceName}Servicer_to_server`,
|
||||
0.8,
|
||||
{ service: serviceName, source: 'python_servicer' },
|
||||
),
|
||||
makeContract(cid, 'provider', filePath, `add_${serviceName}Servicer_to_server`, conf, {
|
||||
service: serviceName,
|
||||
source: 'python_servicer',
|
||||
}),
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private scanPythonConsumers(content: string, filePath: string): ExtractedContract[] {
|
||||
private scanPythonConsumers(
|
||||
content: string,
|
||||
filePath: string,
|
||||
protoMap: Map<string, ProtoServiceInfo[]>,
|
||||
): ExtractedContract[] {
|
||||
const out: ExtractedContract[] = [];
|
||||
// XxxStub(
|
||||
const re = /(\w+)Stub\s*\(/g;
|
||||
|
|
@ -313,8 +441,14 @@ export class GrpcExtractor implements ContractExtractor {
|
|||
const name = m[1];
|
||||
// Filter out common false positives
|
||||
if (['Mock', 'Test', 'Fake', 'Stub'].includes(name)) continue;
|
||||
const candidates = protoMap.get(name);
|
||||
const proto = resolveProtoConflict(name, filePath, candidates ?? []);
|
||||
const cid = proto
|
||||
? serviceContractId(proto.package, proto.serviceName)
|
||||
: serviceOnlyContractId(name);
|
||||
const conf = proto ? 0.75 : 0.55;
|
||||
out.push(
|
||||
makeContract(serviceOnlyContractId(name), 'consumer', filePath, `${name}Stub`, 0.7, {
|
||||
makeContract(cid, 'consumer', filePath, `${name}Stub`, conf, {
|
||||
service: name,
|
||||
source: 'python_stub',
|
||||
}),
|
||||
|
|
@ -323,7 +457,11 @@ export class GrpcExtractor implements ContractExtractor {
|
|||
return out;
|
||||
}
|
||||
|
||||
private scanTsProviders(content: string, filePath: string): ExtractedContract[] {
|
||||
private scanTsProviders(
|
||||
content: string,
|
||||
filePath: string,
|
||||
protoMap: Map<string, ProtoServiceInfo[]>,
|
||||
): ExtractedContract[] {
|
||||
const out: ExtractedContract[] = [];
|
||||
// @GrpcMethod('ServiceName', 'MethodName')
|
||||
const re = /@GrpcMethod\s*\(\s*['"](\w+)['"]\s*,\s*['"](\w+)['"]\s*\)/g;
|
||||
|
|
@ -331,7 +469,10 @@ export class GrpcExtractor implements ContractExtractor {
|
|||
while ((m = re.exec(content)) !== null) {
|
||||
const serviceName = m[1];
|
||||
const methodName = m[2];
|
||||
const cid = contractId('', serviceName, methodName);
|
||||
const candidates = protoMap.get(serviceName);
|
||||
const proto = resolveProtoConflict(serviceName, filePath, candidates ?? []);
|
||||
const pkg = proto?.package ?? '';
|
||||
const cid = contractId(pkg, serviceName, methodName);
|
||||
out.push(
|
||||
makeContract(cid, 'provider', filePath, `${serviceName}.${methodName}`, 0.8, {
|
||||
service: serviceName,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,15 @@ export interface MatchResult {
|
|||
unmatched: StoredContract[];
|
||||
}
|
||||
|
||||
export interface WildcardMatchResult {
|
||||
matched: CrossLink[];
|
||||
remaining: StoredContract[];
|
||||
}
|
||||
|
||||
function isGrpcWildcard(cid: string): boolean {
|
||||
return cid.startsWith('grpc::') && cid.endsWith('/*');
|
||||
}
|
||||
|
||||
export function normalizeContractId(id: string): string {
|
||||
const colonIdx = id.indexOf('::');
|
||||
if (colonIdx === -1) return id;
|
||||
|
|
@ -66,27 +75,36 @@ function findMatchingKeys(contractId: string, index: Map<string, StoredContract[
|
|||
return [];
|
||||
}
|
||||
|
||||
export function runExactMatch(contracts: StoredContract[]): MatchResult {
|
||||
export function buildProviderIndex(contracts: StoredContract[]): Map<string, StoredContract[]> {
|
||||
const providers = contracts.filter((c) => c.role === 'provider');
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
|
||||
const providerIndex = new Map<string, StoredContract[]>();
|
||||
const index = new Map<string, StoredContract[]>();
|
||||
for (const p of providers) {
|
||||
const key = normalizeContractId(p.contractId);
|
||||
const list = providerIndex.get(key) || [];
|
||||
const list = index.get(key) || [];
|
||||
list.push(p);
|
||||
providerIndex.set(key, list);
|
||||
index.set(key, list);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
export function runExactMatch(
|
||||
contracts: StoredContract[],
|
||||
providerIndex?: Map<string, StoredContract[]>,
|
||||
): MatchResult {
|
||||
const index = providerIndex ?? buildProviderIndex(contracts);
|
||||
|
||||
// Skip gRPC wildcard consumers — they go to wildcard pass only
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer' && !isGrpcWildcard(c.contractId));
|
||||
|
||||
const matched: CrossLink[] = [];
|
||||
const matchedConsumerIds = new Set<string>();
|
||||
const matchedProviderIds = new Set<string>();
|
||||
|
||||
for (const consumer of consumers) {
|
||||
const matchingKeys = findMatchingKeys(consumer.contractId, providerIndex);
|
||||
const matchingKeys = findMatchingKeys(consumer.contractId, index);
|
||||
if (matchingKeys.length === 0) continue;
|
||||
|
||||
const allMatchingProviders = matchingKeys.flatMap((k) => providerIndex.get(k) || []);
|
||||
const allMatchingProviders = matchingKeys.flatMap((k) => index.get(k) || []);
|
||||
for (const provider of allMatchingProviders) {
|
||||
if (provider.repo === consumer.repo) {
|
||||
if (!provider.service || !consumer.service || provider.service === consumer.service) {
|
||||
|
|
@ -118,10 +136,86 @@ export function runExactMatch(contracts: StoredContract[]): MatchResult {
|
|||
}
|
||||
}
|
||||
|
||||
const unmatched = contracts.filter((c) => {
|
||||
// normalUnmatched: contracts that weren't matched in exact pass
|
||||
const normalUnmatched = contracts.filter((c) => {
|
||||
if (isGrpcWildcard(c.contractId)) return false; // excluded from exact, handled separately
|
||||
const id = `${c.repo}::${c.contractId}`;
|
||||
return c.role === 'provider' ? !matchedProviderIds.has(id) : !matchedConsumerIds.has(id);
|
||||
});
|
||||
|
||||
// Re-add gRPC wildcard contracts — they were never in exact matching
|
||||
const grpcWildcards = contracts.filter((c) => isGrpcWildcard(c.contractId));
|
||||
const unmatched = [...normalUnmatched, ...grpcWildcards];
|
||||
|
||||
return { matched, unmatched };
|
||||
}
|
||||
|
||||
export function runWildcardMatch(
|
||||
unmatched: StoredContract[],
|
||||
providerIndex: Map<string, StoredContract[]>,
|
||||
): WildcardMatchResult {
|
||||
const wildcardConsumers = unmatched.filter(
|
||||
(c) => c.role === 'consumer' && isGrpcWildcard(c.contractId),
|
||||
);
|
||||
const matched: CrossLink[] = [];
|
||||
const matchedConsumerIds = new Set<string>();
|
||||
|
||||
for (const consumer of wildcardConsumers) {
|
||||
const normalized = normalizeContractId(consumer.contractId);
|
||||
// "grpc::com.example.userservice/*" → "com.example.userservice"
|
||||
// "grpc::userservice/*" → "userservice"
|
||||
const fqService = normalized.slice(normalized.indexOf('::') + 2, -2); // strip "grpc::" and "/*"
|
||||
|
||||
for (const [key, providers] of providerIndex) {
|
||||
// Only match against non-wildcard gRPC providers (method-level IDs)
|
||||
if (!key.startsWith('grpc::') || key.endsWith('/*')) continue;
|
||||
const afterPrefix = key.slice(6); // strip "grpc::"
|
||||
const slashIdx = afterPrefix.indexOf('/');
|
||||
if (slashIdx < 0) continue;
|
||||
const providerFqService = afterPrefix.slice(0, slashIdx);
|
||||
|
||||
// Match: exact FQ service, or bare-name match when consumer has no package
|
||||
const isMatch =
|
||||
providerFqService === fqService ||
|
||||
(!fqService.includes('.') && providerFqService.endsWith('.' + fqService));
|
||||
|
||||
if (!isMatch) continue;
|
||||
|
||||
for (const provider of providers) {
|
||||
// Skip same-repo same-service (same logic as runExactMatch)
|
||||
if (provider.repo === consumer.repo) {
|
||||
if (!provider.service || !consumer.service || provider.service === consumer.service) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
matched.push({
|
||||
from: {
|
||||
repo: consumer.repo,
|
||||
service: consumer.service,
|
||||
symbolUid: consumer.symbolUid,
|
||||
symbolRef: consumer.symbolRef,
|
||||
},
|
||||
to: {
|
||||
repo: provider.repo,
|
||||
service: provider.service,
|
||||
symbolUid: provider.symbolUid,
|
||||
symbolRef: provider.symbolRef,
|
||||
},
|
||||
type: consumer.type,
|
||||
contractId: consumer.contractId, // consumer's wildcard ID
|
||||
matchType: 'wildcard',
|
||||
confidence: Math.min(provider.confidence, consumer.confidence),
|
||||
});
|
||||
matchedConsumerIds.add(`${consumer.repo}::${consumer.contractId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const remaining = unmatched.filter((c) => {
|
||||
if (c.role !== 'consumer' || !isGrpcWildcard(c.contractId)) return true;
|
||||
return !matchedConsumerIds.has(`${c.repo}::${c.contractId}`);
|
||||
});
|
||||
|
||||
return { matched, remaining };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,10 @@
|
|||
*/
|
||||
|
||||
import { checkStaleness } from '../git-staleness.js';
|
||||
import { queryBridge, closeBridgeDb } from './bridge-db.js';
|
||||
import { loadGroupConfig } from './config-parser.js';
|
||||
import { runGroupImpact } from './cross-impact.js';
|
||||
import { getDefaultGitnexusDir, getGroupDir, listGroups, readContractRegistry } from './storage.js';
|
||||
import { runGroupImpact, runGroupImpactLegacy } from './cross-impact.js';
|
||||
import { getDefaultGitnexusDir, getGroupDir, listGroups, openBridgeOrFallback } from './storage.js';
|
||||
import { syncGroup } from './sync.js';
|
||||
|
||||
export interface GroupRepoHandle {
|
||||
|
|
@ -104,23 +105,115 @@ export class GroupService {
|
|||
const name = String(params.name ?? '').trim();
|
||||
if (!name) return { error: 'name is required' };
|
||||
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
|
||||
const registry = await readContractRegistry(groupDir);
|
||||
if (!registry) {
|
||||
return { error: `No contracts.json for group "${name}". Run group_sync first.` };
|
||||
|
||||
const fallback = await openBridgeOrFallback(groupDir);
|
||||
if (fallback.type === 'none') {
|
||||
return { error: `No contract data for group "${name}". Run group_sync first.` };
|
||||
}
|
||||
let contracts = registry.contracts;
|
||||
if (params.type) contracts = contracts.filter((c) => c.type === params.type);
|
||||
if (params.repo) contracts = contracts.filter((c) => c.repo === params.repo);
|
||||
if (params.unmatchedOnly) {
|
||||
const matchedIds = new Set(
|
||||
registry.crossLinks.flatMap((l) => [
|
||||
`${l.from.repo}::${l.contractId}`,
|
||||
`${l.to.repo}::${l.contractId}`,
|
||||
]),
|
||||
|
||||
if (fallback.type === 'json') {
|
||||
const registry = fallback.registry;
|
||||
let contracts = registry.contracts;
|
||||
if (params.type) contracts = contracts.filter((c) => c.type === params.type);
|
||||
if (params.repo) contracts = contracts.filter((c) => c.repo === params.repo);
|
||||
if (params.unmatchedOnly) {
|
||||
const matchedIds = new Set(
|
||||
registry.crossLinks.flatMap((l) => [
|
||||
`${l.from.repo}::${l.contractId}`,
|
||||
`${l.to.repo}::${l.contractId}`,
|
||||
]),
|
||||
);
|
||||
contracts = contracts.filter((c) => !matchedIds.has(`${c.repo}::${c.contractId}`));
|
||||
}
|
||||
return { contracts, crossLinks: registry.crossLinks };
|
||||
}
|
||||
|
||||
// Bridge path — query Contract nodes with flat field projection
|
||||
const handle = fallback.handle;
|
||||
try {
|
||||
let cypher = 'MATCH (c:Contract)';
|
||||
const queryParams: Record<string, unknown> = {};
|
||||
const whereClauses: string[] = [];
|
||||
if (params.type) {
|
||||
whereClauses.push('c.type = $type');
|
||||
queryParams.type = params.type;
|
||||
}
|
||||
if (params.repo) {
|
||||
whereClauses.push('c.repo = $repo');
|
||||
queryParams.repo = params.repo;
|
||||
}
|
||||
if (whereClauses.length > 0) {
|
||||
cypher += ` WHERE ${whereClauses.join(' AND ')}`;
|
||||
}
|
||||
cypher +=
|
||||
' RETURN c.contractId AS contractId, c.type AS type, c.role AS role, c.repo AS repo,' +
|
||||
' c.service AS service, c.symbolUid AS symbolUid, c.filePath AS filePath,' +
|
||||
' c.symbolName AS symbolName, c.confidence AS confidence, c.meta AS meta';
|
||||
|
||||
const rawContracts = await queryBridge<{
|
||||
contractId: string;
|
||||
type: string;
|
||||
role: string;
|
||||
repo: string;
|
||||
service: string;
|
||||
symbolUid: string;
|
||||
filePath: string;
|
||||
symbolName: string;
|
||||
confidence: number;
|
||||
meta: string;
|
||||
}>(handle, cypher, queryParams as Record<string, import('@ladybugdb/core').LbugValue>);
|
||||
|
||||
// Reconstruct StoredContract shape for CLI compatibility
|
||||
let contracts = rawContracts.map((r) => ({
|
||||
contractId: r.contractId,
|
||||
type: r.type,
|
||||
role: r.role,
|
||||
repo: r.repo,
|
||||
service: r.service,
|
||||
symbolUid: r.symbolUid,
|
||||
symbolRef: { filePath: r.filePath, name: r.symbolName },
|
||||
symbolName: r.symbolName,
|
||||
confidence: r.confidence,
|
||||
meta: typeof r.meta === 'string' ? JSON.parse(r.meta) : r.meta,
|
||||
}));
|
||||
|
||||
// Query cross-links
|
||||
const rawLinks = await queryBridge<{
|
||||
fromRepo: string;
|
||||
toRepo: string;
|
||||
matchType: string;
|
||||
confidence: number;
|
||||
linkContractId: string;
|
||||
}>(
|
||||
handle,
|
||||
`MATCH (a:Contract)-[l:ContractLink]->(b:Contract)
|
||||
RETURN l.fromRepo AS fromRepo, l.toRepo AS toRepo,
|
||||
l.matchType AS matchType, l.confidence AS confidence,
|
||||
l.contractId AS linkContractId`,
|
||||
);
|
||||
contracts = contracts.filter((c) => !matchedIds.has(`${c.repo}::${c.contractId}`));
|
||||
const crossLinks = rawLinks.map((l) => ({
|
||||
from: { repo: l.fromRepo },
|
||||
to: { repo: l.toRepo },
|
||||
matchType: l.matchType,
|
||||
confidence: l.confidence,
|
||||
contractId: l.linkContractId,
|
||||
}));
|
||||
|
||||
// Apply unmatchedOnly filter
|
||||
if (params.unmatchedOnly) {
|
||||
const matchedIds = new Set(
|
||||
crossLinks.flatMap((l) => [
|
||||
`${l.from.repo}::${l.contractId}`,
|
||||
`${l.to.repo}::${l.contractId}`,
|
||||
]),
|
||||
);
|
||||
contracts = contracts.filter((c) => !matchedIds.has(`${c.repo}::${c.contractId}`));
|
||||
}
|
||||
|
||||
return { contracts, crossLinks };
|
||||
} finally {
|
||||
await closeBridgeDb(handle);
|
||||
}
|
||||
return { contracts, crossLinks: registry.crossLinks };
|
||||
}
|
||||
|
||||
async groupImpact(params: Record<string, unknown>): Promise<unknown> {
|
||||
|
|
@ -146,9 +239,10 @@ export class GroupService {
|
|||
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
|
||||
|
||||
const config = await loadGroupConfig(groupDir);
|
||||
const registry = await readContractRegistry(groupDir);
|
||||
if (!registry) {
|
||||
return { error: `No contracts.json for group "${name}". Run group_sync first.` };
|
||||
|
||||
const fallback = await openBridgeOrFallback(groupDir);
|
||||
if (fallback.type === 'none') {
|
||||
return { error: `No contract data for group "${name}". Run group_sync first.` };
|
||||
}
|
||||
|
||||
const requestedCrossDepth =
|
||||
|
|
@ -163,56 +257,123 @@ export class GroupService {
|
|||
|
||||
const defaultRelTypes = ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS'];
|
||||
|
||||
const impactOpts = {
|
||||
maxDepth,
|
||||
relationTypes: defaultRelTypes,
|
||||
minConfidence: 0,
|
||||
includeTests: false,
|
||||
};
|
||||
|
||||
const resolveGroupRepo = async (groupPath: string): Promise<GroupRepoHandle> => {
|
||||
const registryName = config.repos[groupPath];
|
||||
if (!registryName) throw new Error(`Repo "${groupPath}" not found in group "${name}"`);
|
||||
return this.port.resolveRepo(registryName);
|
||||
};
|
||||
|
||||
const result = await runGroupImpact({
|
||||
groupName: name,
|
||||
target: targetSymbol,
|
||||
repoPath: repoGroupPath,
|
||||
direction,
|
||||
registry,
|
||||
localImpactFn: async (t: string, d: string) => {
|
||||
const repoObj = await resolveGroupRepo(repoGroupPath);
|
||||
return this.port.impact(repoObj, {
|
||||
target: t,
|
||||
direction: d as 'upstream' | 'downstream',
|
||||
maxDepth,
|
||||
relationTypes: defaultRelTypes,
|
||||
minConfidence: 0,
|
||||
includeTests: false,
|
||||
});
|
||||
},
|
||||
crossImpactFn: async (targetGroupPath: string, uid: string, d: string) => {
|
||||
const registryName = config.repos[targetGroupPath];
|
||||
if (!registryName) return null;
|
||||
try {
|
||||
const repoObj = await this.port.resolveRepo(registryName);
|
||||
return this.port.impactByUid(repoObj.id, uid, d, {
|
||||
maxDepth,
|
||||
relationTypes: defaultRelTypes,
|
||||
minConfidence: 0,
|
||||
includeTests: false,
|
||||
if (fallback.type === 'json') {
|
||||
// Legacy JSON path
|
||||
const result = await runGroupImpactLegacy({
|
||||
groupName: name,
|
||||
target: targetSymbol,
|
||||
repoPath: repoGroupPath,
|
||||
direction,
|
||||
registry: fallback.registry,
|
||||
localImpactFn: async (t: string, d: string) => {
|
||||
const repoObj = await resolveGroupRepo(repoGroupPath);
|
||||
return this.port.impact(repoObj, {
|
||||
target: t,
|
||||
direction: d as 'upstream' | 'downstream',
|
||||
...impactOpts,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
maxDepth,
|
||||
minConfidence,
|
||||
subgroup,
|
||||
timeout,
|
||||
crossDepth,
|
||||
});
|
||||
},
|
||||
crossImpactFn: async (targetGroupPath: string, uid: string, d: string) => {
|
||||
const registryName = config.repos[targetGroupPath];
|
||||
if (!registryName) return null;
|
||||
try {
|
||||
const repoObj = await this.port.resolveRepo(registryName);
|
||||
return this.port.impactByUid(repoObj.id, uid, d, impactOpts);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
maxDepth,
|
||||
minConfidence,
|
||||
subgroup,
|
||||
timeout,
|
||||
crossDepth,
|
||||
});
|
||||
|
||||
if (crossDepthWarning) {
|
||||
(result as unknown as Record<string, unknown>).crossDepthWarning = crossDepthWarning;
|
||||
if (crossDepthWarning) {
|
||||
(result as unknown as Record<string, unknown>).crossDepthWarning = crossDepthWarning;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return result;
|
||||
// Bridge path
|
||||
const handle = fallback.handle;
|
||||
try {
|
||||
const result = await runGroupImpact({
|
||||
groupName: name,
|
||||
target: targetSymbol,
|
||||
repoPath: repoGroupPath,
|
||||
direction,
|
||||
bridgeQuery: (cypher, p) =>
|
||||
queryBridge(handle, cypher, p as Record<string, import('@ladybugdb/core').LbugValue>),
|
||||
localImpactFn: async (t: string, d: string) => {
|
||||
const repoObj = await resolveGroupRepo(repoGroupPath);
|
||||
return this.port.impact(repoObj, {
|
||||
target: t,
|
||||
direction: d as 'upstream' | 'downstream',
|
||||
...impactOpts,
|
||||
});
|
||||
},
|
||||
crossImpactFn: async (
|
||||
targetGroupPath: string,
|
||||
uid: string,
|
||||
d: string,
|
||||
hint?: { filePath: string; symbolName: string },
|
||||
) => {
|
||||
const registryName = config.repos[targetGroupPath];
|
||||
if (!registryName) return null;
|
||||
try {
|
||||
const repoObj = await this.port.resolveRepo(registryName);
|
||||
if (uid) {
|
||||
return this.port.impactByUid(repoObj.id, uid, d, impactOpts);
|
||||
}
|
||||
// Name-based fallback for empty UID (gRPC contracts)
|
||||
if (hint?.symbolName) {
|
||||
const hintResult = await this.port.impact(repoObj, {
|
||||
target: hint.symbolName,
|
||||
direction: d as 'upstream' | 'downstream',
|
||||
...impactOpts,
|
||||
});
|
||||
if (
|
||||
hintResult &&
|
||||
typeof hintResult === 'object' &&
|
||||
'error' in (hintResult as Record<string, unknown>)
|
||||
)
|
||||
return null;
|
||||
return hintResult;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
maxDepth,
|
||||
minConfidence,
|
||||
subgroup,
|
||||
timeout,
|
||||
crossDepth,
|
||||
});
|
||||
|
||||
if (crossDepthWarning) {
|
||||
(result as unknown as Record<string, unknown>).crossDepthWarning = crossDepthWarning;
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
await closeBridgeDb(handle);
|
||||
}
|
||||
}
|
||||
|
||||
async groupQuery(params: Record<string, unknown>): Promise<unknown> {
|
||||
|
|
@ -265,52 +426,118 @@ export class GroupService {
|
|||
if (!name) return { error: 'name is required' };
|
||||
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
|
||||
const config = await loadGroupConfig(groupDir);
|
||||
const registry = await readContractRegistry(groupDir);
|
||||
|
||||
const repoStatuses: Record<
|
||||
string,
|
||||
{
|
||||
indexStale: boolean;
|
||||
contractsStale: boolean;
|
||||
missing: boolean;
|
||||
commitsBehind?: number;
|
||||
}
|
||||
> = {};
|
||||
const fallback = await openBridgeOrFallback(groupDir);
|
||||
if (fallback.type === 'none') {
|
||||
return { group: name, lastSync: null, missingRepos: [], repos: {} };
|
||||
}
|
||||
|
||||
const fsp = await import('node:fs/promises');
|
||||
const pathMod = await import('node:path');
|
||||
|
||||
for (const [repoPath, registryName] of Object.entries(config.repos)) {
|
||||
try {
|
||||
const repoObj = await this.port.resolveRepo(registryName);
|
||||
const metaPath = pathMod.join(repoObj.storagePath, 'meta.json');
|
||||
const metaRaw = await fsp.readFile(metaPath, 'utf-8').catch(() => '{}');
|
||||
const meta = JSON.parse(metaRaw) as { lastCommit?: string; indexedAt?: string };
|
||||
if (fallback.type === 'json') {
|
||||
const registry = fallback.registry;
|
||||
const repoStatuses: Record<
|
||||
string,
|
||||
{
|
||||
indexStale: boolean;
|
||||
contractsStale: boolean;
|
||||
missing: boolean;
|
||||
commitsBehind?: number;
|
||||
}
|
||||
> = {};
|
||||
|
||||
const staleness = meta.lastCommit
|
||||
? checkStaleness(repoObj.repoPath, meta.lastCommit)
|
||||
: { isStale: true, commitsBehind: -1 };
|
||||
for (const [repoPath, registryName] of Object.entries(config.repos)) {
|
||||
try {
|
||||
const repoObj = await this.port.resolveRepo(registryName);
|
||||
const metaPath = pathMod.join(repoObj.storagePath, 'meta.json');
|
||||
const metaRaw = await fsp.readFile(metaPath, 'utf-8').catch(() => '{}');
|
||||
const meta = JSON.parse(metaRaw) as { lastCommit?: string; indexedAt?: string };
|
||||
|
||||
const snapshot = registry?.repoSnapshots[repoPath];
|
||||
const contractsStale =
|
||||
snapshot && meta.indexedAt ? snapshot.indexedAt !== meta.indexedAt : !snapshot;
|
||||
const staleness = meta.lastCommit
|
||||
? checkStaleness(repoObj.repoPath, meta.lastCommit)
|
||||
: { isStale: true, commitsBehind: -1 };
|
||||
|
||||
repoStatuses[repoPath] = {
|
||||
indexStale: staleness.isStale,
|
||||
contractsStale: Boolean(contractsStale),
|
||||
missing: false,
|
||||
commitsBehind: staleness.commitsBehind,
|
||||
};
|
||||
} catch {
|
||||
repoStatuses[repoPath] = { indexStale: false, contractsStale: false, missing: true };
|
||||
const snapshot = registry.repoSnapshots[repoPath];
|
||||
const contractsStale =
|
||||
snapshot && meta.indexedAt ? snapshot.indexedAt !== meta.indexedAt : !snapshot;
|
||||
|
||||
repoStatuses[repoPath] = {
|
||||
indexStale: staleness.isStale,
|
||||
contractsStale: Boolean(contractsStale),
|
||||
missing: false,
|
||||
commitsBehind: staleness.commitsBehind,
|
||||
};
|
||||
} catch {
|
||||
repoStatuses[repoPath] = { indexStale: false, contractsStale: false, missing: true };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
group: name,
|
||||
lastSync: registry.generatedAt || null,
|
||||
missingRepos: registry.missingRepos || [],
|
||||
repos: repoStatuses,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
group: name,
|
||||
lastSync: registry?.generatedAt || null,
|
||||
missingRepos: registry?.missingRepos || [],
|
||||
repos: repoStatuses,
|
||||
};
|
||||
// Bridge path
|
||||
const handle = fallback.handle;
|
||||
const meta = fallback.meta;
|
||||
try {
|
||||
const snapshots = await queryBridge<{ id: string; indexedAt: string; lastCommit: string }>(
|
||||
handle,
|
||||
'MATCH (s:RepoSnapshot) RETURN s.id AS id, s.indexedAt AS indexedAt, s.lastCommit AS lastCommit',
|
||||
);
|
||||
const bridgeSnapshots: Record<string, { indexedAt: string; lastCommit: string }> = {};
|
||||
for (const s of snapshots) {
|
||||
bridgeSnapshots[s.id] = { indexedAt: s.indexedAt, lastCommit: s.lastCommit };
|
||||
}
|
||||
|
||||
const repoStatuses: Record<
|
||||
string,
|
||||
{
|
||||
indexStale: boolean;
|
||||
contractsStale: boolean;
|
||||
missing: boolean;
|
||||
commitsBehind?: number;
|
||||
}
|
||||
> = {};
|
||||
|
||||
for (const [repoPath, registryName] of Object.entries(config.repos)) {
|
||||
try {
|
||||
const repoObj = await this.port.resolveRepo(registryName);
|
||||
const metaPath = pathMod.join(repoObj.storagePath, 'meta.json');
|
||||
const metaRaw = await fsp.readFile(metaPath, 'utf-8').catch(() => '{}');
|
||||
const repoMeta = JSON.parse(metaRaw) as { lastCommit?: string; indexedAt?: string };
|
||||
|
||||
const staleness = repoMeta.lastCommit
|
||||
? checkStaleness(repoObj.repoPath, repoMeta.lastCommit)
|
||||
: { isStale: true, commitsBehind: -1 };
|
||||
|
||||
const snapshot = bridgeSnapshots[repoPath];
|
||||
const contractsStale =
|
||||
snapshot && repoMeta.indexedAt ? snapshot.indexedAt !== repoMeta.indexedAt : !snapshot;
|
||||
|
||||
repoStatuses[repoPath] = {
|
||||
indexStale: staleness.isStale,
|
||||
contractsStale: Boolean(contractsStale),
|
||||
missing: false,
|
||||
commitsBehind: staleness.commitsBehind,
|
||||
};
|
||||
} catch {
|
||||
repoStatuses[repoPath] = { indexStale: false, contractsStale: false, missing: true };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
group: name,
|
||||
lastSync: meta.generatedAt || null,
|
||||
missingRepos: meta.missingRepos || [],
|
||||
repos: repoStatuses,
|
||||
};
|
||||
} finally {
|
||||
await closeBridgeDb(handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,13 @@ import * as fs from 'node:fs';
|
|||
import * as fsp from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import type { ContractRegistry } from './types.js';
|
||||
|
||||
const CONTRACTS_FILE = 'contracts.json';
|
||||
import type {
|
||||
ContractRegistry,
|
||||
BridgeHandle,
|
||||
BridgeMeta,
|
||||
LegacyContractRegistry,
|
||||
} from './types.js';
|
||||
import { openBridgeDbReadOnly, readBridgeMeta } from './bridge-db.js';
|
||||
|
||||
export function getDefaultGitnexusDir(): string {
|
||||
return process.env.GITNEXUS_HOME || path.join(os.homedir(), '.gitnexus');
|
||||
|
|
@ -29,19 +33,12 @@ export function getGroupDir(gitnexusDir: string, groupName: string): string {
|
|||
return path.join(gitnexusDir, 'groups', groupName);
|
||||
}
|
||||
|
||||
export async function writeContractRegistry(
|
||||
groupDir: string,
|
||||
registry: ContractRegistry,
|
||||
): Promise<void> {
|
||||
const targetPath = path.join(groupDir, CONTRACTS_FILE);
|
||||
const tmpPath = `${targetPath}.tmp.${Date.now()}`;
|
||||
|
||||
await fsp.writeFile(tmpPath, JSON.stringify(registry, null, 2), 'utf-8');
|
||||
await fsp.rename(tmpPath, targetPath);
|
||||
}
|
||||
|
||||
export async function readContractRegistry(groupDir: string): Promise<ContractRegistry | null> {
|
||||
const filePath = path.join(groupDir, CONTRACTS_FILE);
|
||||
/**
|
||||
* @deprecated Used only as internal JSON fallback for openBridgeOrFallback.
|
||||
* New data is written to bridge.lbug via writeBridge.
|
||||
*/
|
||||
async function readContractRegistryJson(groupDir: string): Promise<ContractRegistry | null> {
|
||||
const filePath = path.join(groupDir, 'contracts.json');
|
||||
try {
|
||||
const content = await fsp.readFile(filePath, 'utf-8');
|
||||
return JSON.parse(content) as ContractRegistry;
|
||||
|
|
@ -107,3 +104,28 @@ matching:
|
|||
await fsp.writeFile(path.join(groupDir, 'group.yaml'), template, 'utf-8');
|
||||
return groupDir;
|
||||
}
|
||||
|
||||
export async function openBridgeOrFallback(
|
||||
groupDir: string,
|
||||
): Promise<
|
||||
| { type: 'bridge'; handle: BridgeHandle; meta: BridgeMeta }
|
||||
| { type: 'json'; registry: LegacyContractRegistry; deprecationWarning: string }
|
||||
| { type: 'none' }
|
||||
> {
|
||||
const handle = await openBridgeDbReadOnly(groupDir);
|
||||
if (handle) {
|
||||
const meta = await readBridgeMeta(groupDir);
|
||||
return { type: 'bridge', handle, meta };
|
||||
}
|
||||
// JSON fallback
|
||||
const registry = await readContractRegistryJson(groupDir);
|
||||
if (registry) {
|
||||
return {
|
||||
type: 'json',
|
||||
registry,
|
||||
deprecationWarning:
|
||||
'contracts.json is deprecated. Run "gitnexus group sync <name>" to migrate to bridge.lbug.',
|
||||
};
|
||||
}
|
||||
return { type: 'none' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
124
gitnexus/test/unit/group/bridge-db-edge.test.ts
Normal file
124
gitnexus/test/unit/group/bridge-db-edge.test.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import {
|
||||
writeBridge,
|
||||
openBridgeDbReadOnly,
|
||||
queryBridge,
|
||||
closeBridgeDb,
|
||||
} from '../../../src/core/group/bridge-db.js';
|
||||
import type { StoredContract, CrossLink } from '../../../src/core/group/types.js';
|
||||
|
||||
const makeContract = (overrides: Partial<StoredContract> = {}): StoredContract => ({
|
||||
contractId: 'http::GET::/api/users',
|
||||
type: 'http',
|
||||
role: 'provider',
|
||||
symbolUid: 'uid-1',
|
||||
symbolRef: { filePath: 'src/routes.ts', name: 'getUsers' },
|
||||
symbolName: 'getUsers',
|
||||
confidence: 0.85,
|
||||
meta: {},
|
||||
repo: 'backend',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('bridge-db edge cases', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'bridge-edge-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fsp.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('test_openBridgeDbReadOnly_version_gate_returns_null_for_incompatible', async () => {
|
||||
// Create a dummy bridge.lbug file so the access check passes
|
||||
await fsp.writeFile(path.join(tmpDir, 'bridge.lbug'), 'dummy');
|
||||
// Write meta.json with an incompatible version (999)
|
||||
await fsp.writeFile(
|
||||
path.join(tmpDir, 'meta.json'),
|
||||
JSON.stringify({ version: 999, generatedAt: '', missingRepos: [] }),
|
||||
);
|
||||
|
||||
const handle = await openBridgeDbReadOnly(tmpDir);
|
||||
expect(handle).toBeNull();
|
||||
});
|
||||
|
||||
it('test_openBridgeDbReadOnly_bak_recovery_restores_bridge', async () => {
|
||||
// Write a valid bridge
|
||||
await writeBridge(tmpDir, {
|
||||
contracts: [makeContract()],
|
||||
crossLinks: [],
|
||||
repoSnapshots: {},
|
||||
missingRepos: [],
|
||||
});
|
||||
// Move bridge.lbug → bridge.lbug.bak (simulating interrupted swap)
|
||||
const dbPath = path.join(tmpDir, 'bridge.lbug');
|
||||
const bakPath = path.join(tmpDir, 'bridge.lbug.bak');
|
||||
await fsp.rename(dbPath, bakPath);
|
||||
|
||||
// openBridgeDbReadOnly should auto-recover from .bak
|
||||
const handle = await openBridgeDbReadOnly(tmpDir);
|
||||
expect(handle).not.toBeNull();
|
||||
const rows = await queryBridge<{ repo: string }>(
|
||||
handle!,
|
||||
'MATCH (c:Contract) RETURN c.repo AS repo',
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
await closeBridgeDb(handle!);
|
||||
});
|
||||
|
||||
it('test_writeBridge_crossLink_with_missing_to_node_silently_skipped', async () => {
|
||||
const provider = makeContract({ repo: 'backend', role: 'provider' });
|
||||
const consumer = makeContract({
|
||||
repo: 'frontend',
|
||||
role: 'consumer',
|
||||
symbolRef: { filePath: 'src/api.ts', name: 'fetchUsers' },
|
||||
symbolName: 'fetchUsers',
|
||||
});
|
||||
// CrossLink referencing a 'to' endpoint that doesn't match any contract node
|
||||
const link: CrossLink = {
|
||||
from: {
|
||||
repo: 'frontend',
|
||||
symbolUid: '',
|
||||
symbolRef: { filePath: 'src/api.ts', name: 'fetchUsers' },
|
||||
},
|
||||
to: {
|
||||
repo: 'nonexistent-repo',
|
||||
symbolUid: 'uid-missing',
|
||||
symbolRef: { filePath: 'src/missing.ts', name: 'missingFn' },
|
||||
},
|
||||
type: 'http',
|
||||
contractId: 'http::GET::/api/users',
|
||||
matchType: 'exact',
|
||||
confidence: 1.0,
|
||||
};
|
||||
|
||||
// Should not throw — the link is silently skipped
|
||||
await writeBridge(tmpDir, {
|
||||
contracts: [provider, consumer],
|
||||
crossLinks: [link],
|
||||
repoSnapshots: {},
|
||||
missingRepos: [],
|
||||
});
|
||||
|
||||
const handle = await openBridgeDbReadOnly(tmpDir);
|
||||
expect(handle).not.toBeNull();
|
||||
// No cross-links should exist since 'to' node was missing
|
||||
const rows = await queryBridge<{ matchType: string }>(
|
||||
handle!,
|
||||
'MATCH (a:Contract)-[l:ContractLink]->(b:Contract) RETURN l.matchType AS matchType',
|
||||
);
|
||||
expect(rows).toHaveLength(0);
|
||||
// But contracts should still be present
|
||||
const contractRows = await queryBridge<{ repo: string }>(
|
||||
handle!,
|
||||
'MATCH (c:Contract) RETURN c.repo AS repo',
|
||||
);
|
||||
expect(contractRows).toHaveLength(2);
|
||||
await closeBridgeDb(handle!);
|
||||
});
|
||||
});
|
||||
271
gitnexus/test/unit/group/bridge-db.test.ts
Normal file
271
gitnexus/test/unit/group/bridge-db.test.ts
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import {
|
||||
openBridgeDb,
|
||||
ensureBridgeSchema,
|
||||
queryBridge,
|
||||
closeBridgeDb,
|
||||
contractNodeId,
|
||||
writeBridge,
|
||||
openBridgeDbReadOnly,
|
||||
readBridgeMeta,
|
||||
bridgeExists,
|
||||
} from '../../../src/core/group/bridge-db.js';
|
||||
import type { StoredContract, CrossLink } from '../../../src/core/group/types.js';
|
||||
|
||||
describe('bridge-db core', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'bridge-test-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fsp.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('test_openBridgeDb_returns_handle_and_closes', async () => {
|
||||
const dbPath = path.join(tmpDir, 'test.lbug');
|
||||
const handle = await openBridgeDb(dbPath);
|
||||
expect(handle).toBeDefined();
|
||||
expect(handle._db).toBeDefined();
|
||||
expect(handle._conn).toBeDefined();
|
||||
expect(handle.groupDir).toBe(tmpDir);
|
||||
// Close should not throw
|
||||
await closeBridgeDb(handle);
|
||||
});
|
||||
|
||||
it('test_ensureBridgeSchema_creates_tables_idempotent', async () => {
|
||||
const dbPath = path.join(tmpDir, 'test.lbug');
|
||||
const handle = await openBridgeDb(dbPath);
|
||||
await ensureBridgeSchema(handle);
|
||||
// Run again — should not throw
|
||||
await ensureBridgeSchema(handle);
|
||||
const rows = await queryBridge<{ cnt: number }>(
|
||||
handle,
|
||||
'MATCH (c:Contract) RETURN count(c) AS cnt',
|
||||
);
|
||||
expect(rows[0].cnt).toBe(0);
|
||||
await closeBridgeDb(handle);
|
||||
});
|
||||
|
||||
it('test_queryBridge_returns_inserted_data', async () => {
|
||||
const dbPath = path.join(tmpDir, 'test.lbug');
|
||||
const handle = await openBridgeDb(dbPath);
|
||||
await ensureBridgeSchema(handle);
|
||||
await queryBridge(
|
||||
handle,
|
||||
`CREATE (c:Contract {
|
||||
id: 'abc123', contractId: 'http::GET::/api', type: 'http', role: 'provider',
|
||||
repo: 'backend', confidence: 0.9
|
||||
})`,
|
||||
);
|
||||
const rows = await queryBridge<{ repo: string; confidence: number }>(
|
||||
handle,
|
||||
'MATCH (c:Contract) RETURN c.repo AS repo, c.confidence AS confidence',
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].repo).toBe('backend');
|
||||
expect(rows[0].confidence).toBe(0.9);
|
||||
await closeBridgeDb(handle);
|
||||
});
|
||||
|
||||
it('test_queryBridge_parameterized', async () => {
|
||||
const dbPath = path.join(tmpDir, 'test.lbug');
|
||||
const handle = await openBridgeDb(dbPath);
|
||||
await ensureBridgeSchema(handle);
|
||||
await queryBridge(
|
||||
handle,
|
||||
`CREATE (c:Contract {
|
||||
id: 'p1', contractId: 'http::GET::/api', type: 'http', role: 'provider',
|
||||
repo: 'backend', confidence: 0.9
|
||||
})`,
|
||||
);
|
||||
const rows = await queryBridge<{ repo: string }>(
|
||||
handle,
|
||||
'MATCH (c:Contract) WHERE c.repo = $r RETURN c.repo AS repo',
|
||||
{ r: 'backend' },
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].repo).toBe('backend');
|
||||
await closeBridgeDb(handle);
|
||||
});
|
||||
|
||||
it('test_contractNodeId_full_sha256', () => {
|
||||
const id = contractNodeId('backend', 'http::GET::/api', 'provider', 'src/routes.ts');
|
||||
expect(id).toHaveLength(64); // full SHA-256 hex
|
||||
// Same inputs → same hash
|
||||
const id2 = contractNodeId('backend', 'http::GET::/api', 'provider', 'src/routes.ts');
|
||||
expect(id).toBe(id2);
|
||||
// Different filePath → different hash
|
||||
const id3 = contractNodeId('backend', 'http::GET::/api', 'provider', 'src/other.ts');
|
||||
expect(id).not.toBe(id3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeBridge + read', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'bridge-write-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fsp.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const makeContract = (overrides: Partial<StoredContract> = {}): StoredContract => ({
|
||||
contractId: 'http::GET::/api/users',
|
||||
type: 'http',
|
||||
role: 'provider',
|
||||
symbolUid: 'uid-1',
|
||||
symbolRef: { filePath: 'src/routes.ts', name: 'getUsers' },
|
||||
symbolName: 'getUsers',
|
||||
confidence: 0.85,
|
||||
meta: {},
|
||||
repo: 'backend',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('test_writeBridge_creates_bridge_lbug_file', async () => {
|
||||
await writeBridge(tmpDir, {
|
||||
contracts: [makeContract()],
|
||||
crossLinks: [],
|
||||
repoSnapshots: { backend: { indexedAt: '2026-01-01', lastCommit: 'abc' } },
|
||||
missingRepos: ['missing-repo'],
|
||||
});
|
||||
const exists = await bridgeExists(tmpDir);
|
||||
expect(exists).toBe(true);
|
||||
});
|
||||
|
||||
it('test_writeBridge_contracts_queryable', async () => {
|
||||
await writeBridge(tmpDir, {
|
||||
contracts: [makeContract(), makeContract({ repo: 'frontend', role: 'consumer' })],
|
||||
crossLinks: [],
|
||||
repoSnapshots: {},
|
||||
missingRepos: [],
|
||||
});
|
||||
const handle = await openBridgeDbReadOnly(tmpDir);
|
||||
expect(handle).not.toBeNull();
|
||||
const rows = await queryBridge<{ repo: string }>(
|
||||
handle!,
|
||||
'MATCH (c:Contract) RETURN c.repo AS repo',
|
||||
);
|
||||
expect(rows).toHaveLength(2);
|
||||
await closeBridgeDb(handle!);
|
||||
});
|
||||
|
||||
it('test_writeBridge_meta_json_persists_missingRepos', async () => {
|
||||
await writeBridge(tmpDir, {
|
||||
contracts: [],
|
||||
crossLinks: [],
|
||||
repoSnapshots: {},
|
||||
missingRepos: ['repo-a', 'repo-b'],
|
||||
});
|
||||
const meta = await readBridgeMeta(tmpDir);
|
||||
expect(meta.missingRepos).toEqual(['repo-a', 'repo-b']);
|
||||
expect(meta.version).toBeGreaterThan(0);
|
||||
expect(meta.generatedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('test_writeBridge_repoSnapshots_queryable', async () => {
|
||||
await writeBridge(tmpDir, {
|
||||
contracts: [],
|
||||
crossLinks: [],
|
||||
repoSnapshots: { 'hr/backend': { indexedAt: '2026-01-01', lastCommit: 'abc' } },
|
||||
missingRepos: [],
|
||||
});
|
||||
const handle = await openBridgeDbReadOnly(tmpDir);
|
||||
const rows = await queryBridge<{ id: string; indexedAt: string }>(
|
||||
handle!,
|
||||
'MATCH (s:RepoSnapshot) RETURN s.id AS id, s.indexedAt AS indexedAt',
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].id).toBe('hr/backend');
|
||||
expect(rows[0].indexedAt).toBe('2026-01-01');
|
||||
await closeBridgeDb(handle!);
|
||||
});
|
||||
|
||||
it('test_writeBridge_crossLinks_queryable', async () => {
|
||||
const provider = makeContract({ repo: 'backend', role: 'provider' });
|
||||
const consumer = makeContract({
|
||||
repo: 'frontend',
|
||||
role: 'consumer',
|
||||
symbolRef: { filePath: 'src/api.ts', name: 'fetchUsers' },
|
||||
symbolName: 'fetchUsers',
|
||||
});
|
||||
const link: CrossLink = {
|
||||
from: {
|
||||
repo: 'frontend',
|
||||
symbolUid: '',
|
||||
symbolRef: { filePath: 'src/api.ts', name: 'fetchUsers' },
|
||||
},
|
||||
to: {
|
||||
repo: 'backend',
|
||||
symbolUid: 'uid-1',
|
||||
symbolRef: { filePath: 'src/routes.ts', name: 'getUsers' },
|
||||
},
|
||||
type: 'http',
|
||||
contractId: 'http::GET::/api/users',
|
||||
matchType: 'exact',
|
||||
confidence: 1.0,
|
||||
};
|
||||
await writeBridge(tmpDir, {
|
||||
contracts: [provider, consumer],
|
||||
crossLinks: [link],
|
||||
repoSnapshots: {},
|
||||
missingRepos: [],
|
||||
});
|
||||
const handle = await openBridgeDbReadOnly(tmpDir);
|
||||
const rows = await queryBridge<{ fromRepo: string; toRepo: string; matchType: string }>(
|
||||
handle!,
|
||||
'MATCH (a:Contract)-[l:ContractLink]->(b:Contract) RETURN l.fromRepo AS fromRepo, l.toRepo AS toRepo, l.matchType AS matchType',
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].fromRepo).toBe('frontend');
|
||||
expect(rows[0].toRepo).toBe('backend');
|
||||
expect(rows[0].matchType).toBe('exact');
|
||||
await closeBridgeDb(handle!);
|
||||
});
|
||||
|
||||
it('test_openBridgeDbReadOnly_returns_null_for_missing', async () => {
|
||||
const handle = await openBridgeDbReadOnly(path.join(tmpDir, 'nonexistent'));
|
||||
expect(handle).toBeNull();
|
||||
});
|
||||
|
||||
it('test_bridgeExists_false_for_missing', async () => {
|
||||
expect(await bridgeExists(path.join(tmpDir, 'nonexistent'))).toBe(false);
|
||||
});
|
||||
|
||||
it('test_writeBridge_overwrites_previous', async () => {
|
||||
await writeBridge(tmpDir, {
|
||||
contracts: [makeContract()],
|
||||
crossLinks: [],
|
||||
repoSnapshots: {},
|
||||
missingRepos: [],
|
||||
});
|
||||
await writeBridge(tmpDir, {
|
||||
contracts: [makeContract({ repo: 'new-repo' })],
|
||||
crossLinks: [],
|
||||
repoSnapshots: {},
|
||||
missingRepos: [],
|
||||
});
|
||||
const handle = await openBridgeDbReadOnly(tmpDir);
|
||||
const rows = await queryBridge<{ repo: string }>(
|
||||
handle!,
|
||||
'MATCH (c:Contract) RETURN c.repo AS repo',
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].repo).toBe('new-repo');
|
||||
await closeBridgeDb(handle!);
|
||||
});
|
||||
|
||||
it('test_readBridgeMeta_returns_defaults_for_missing', async () => {
|
||||
const meta = await readBridgeMeta(path.join(tmpDir, 'nonexistent'));
|
||||
expect(meta.version).toBe(0);
|
||||
expect(meta.generatedAt).toBe('');
|
||||
expect(meta.missingRepos).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { runGroupImpact } from '../../../src/core/group/cross-impact.js';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { runGroupImpactLegacy, runGroupImpact } from '../../../src/core/group/cross-impact.js';
|
||||
import type { ContractRegistry } from '../../../src/core/group/types.js';
|
||||
|
||||
describe('runGroupImpact', () => {
|
||||
describe('runGroupImpactLegacy', () => {
|
||||
const mockRegistry: ContractRegistry = {
|
||||
version: 1,
|
||||
generatedAt: '2026-03-31T10:00:00Z',
|
||||
|
|
@ -33,7 +33,7 @@ describe('runGroupImpact', () => {
|
|||
};
|
||||
|
||||
it('returns local impact when no cross-links match', async () => {
|
||||
const result = await runGroupImpact({
|
||||
const result = await runGroupImpactLegacy({
|
||||
groupName: 'test',
|
||||
target: 'SomeUnrelatedFn',
|
||||
repoPath: 'app/backend',
|
||||
|
|
@ -58,7 +58,7 @@ describe('runGroupImpact', () => {
|
|||
});
|
||||
|
||||
it('fans out through cross-links for upstream direction', async () => {
|
||||
const result = await runGroupImpact({
|
||||
const result = await runGroupImpactLegacy({
|
||||
groupName: 'test',
|
||||
target: 'UserController.list',
|
||||
repoPath: 'app/backend',
|
||||
|
|
@ -100,7 +100,7 @@ describe('runGroupImpact', () => {
|
|||
});
|
||||
|
||||
it('fans out for downstream direction (consumer repo → provider repo)', async () => {
|
||||
const result = await runGroupImpact({
|
||||
const result = await runGroupImpactLegacy({
|
||||
groupName: 'test',
|
||||
target: 'fetchUsers',
|
||||
repoPath: 'app/frontend',
|
||||
|
|
@ -135,7 +135,7 @@ describe('runGroupImpact', () => {
|
|||
});
|
||||
|
||||
it('respects subgroup filter', async () => {
|
||||
const result = await runGroupImpact({
|
||||
const result = await runGroupImpactLegacy({
|
||||
groupName: 'test',
|
||||
target: 'UserController.list',
|
||||
repoPath: 'app/backend',
|
||||
|
|
@ -163,7 +163,7 @@ describe('runGroupImpact', () => {
|
|||
});
|
||||
|
||||
it('respects timeout and returns truncated result', async () => {
|
||||
const result = await runGroupImpact({
|
||||
const result = await runGroupImpactLegacy({
|
||||
groupName: 'test',
|
||||
target: 'UserController.list',
|
||||
repoPath: 'app/backend',
|
||||
|
|
@ -189,3 +189,214 @@ describe('runGroupImpact', () => {
|
|||
expect(result.truncated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runGroupImpact (Cypher-based)', () => {
|
||||
function makeBridgeQuery(rows: Record<string, unknown>[]) {
|
||||
return vi.fn().mockResolvedValue(rows);
|
||||
}
|
||||
|
||||
const localImpactFn = async () => ({
|
||||
target: { id: 'uid-ctrl', name: 'UserController.list', filePath: 'src/ctrl.ts' },
|
||||
direction: 'upstream',
|
||||
impactedCount: 2,
|
||||
risk: 'LOW',
|
||||
summary: { direct: 2, processes_affected: 0, modules_affected: 0 },
|
||||
affected_processes: [],
|
||||
affected_modules: [],
|
||||
byDepth: {
|
||||
'1': [{ id: 'uid-ctrl', name: 'UserController.list', filePath: 'src/ctrl.ts' }],
|
||||
},
|
||||
});
|
||||
|
||||
it('test_runGroupImpact_upstream_calls_bridgeQuery_with_correct_params', async () => {
|
||||
const bridgeQuery = makeBridgeQuery([
|
||||
{
|
||||
fanOutRepo: 'app/frontend',
|
||||
fanOutUid: 'uid-fetch',
|
||||
fanOutFilePath: 'src/api.ts',
|
||||
fanOutSymbolName: 'fetchUsers',
|
||||
matchedLocalUid: 'uid-ctrl',
|
||||
matchedLocalFilePath: 'src/ctrl.ts',
|
||||
matchedLocalSymbolName: 'UserController.list',
|
||||
matchType: 'exact',
|
||||
confidence: 1.0,
|
||||
contractId: 'http::GET::/api/users',
|
||||
contractType: 'http',
|
||||
},
|
||||
]);
|
||||
const crossImpactFn = vi.fn().mockResolvedValue({
|
||||
byDepth: {
|
||||
'1': [
|
||||
{ id: 'uid-profile', name: 'UserProfile', filePath: 'src/components/UserProfile.tsx' },
|
||||
],
|
||||
},
|
||||
affected_processes: [],
|
||||
});
|
||||
|
||||
const result = await runGroupImpact({
|
||||
groupName: 'test',
|
||||
target: 'UserController.list',
|
||||
repoPath: 'app/backend',
|
||||
direction: 'upstream',
|
||||
bridgeQuery,
|
||||
localImpactFn,
|
||||
crossImpactFn,
|
||||
});
|
||||
|
||||
expect(bridgeQuery).toHaveBeenCalledOnce();
|
||||
const [cypher, params] = bridgeQuery.mock.calls[0];
|
||||
expect(cypher).toContain('provider.repo = $sourceRepo');
|
||||
expect(params.sourceRepo).toBe('app/backend');
|
||||
expect(params.localUids).toContain('uid-ctrl');
|
||||
expect(params.minConfidence).toBe(0.5);
|
||||
expect(params.subgroup).toBeUndefined();
|
||||
|
||||
expect(result.cross).toHaveLength(1);
|
||||
expect(result.cross[0].repo_path).toBe('app/frontend');
|
||||
expect(result.cross[0].contract.match_type).toBe('exact');
|
||||
expect(result.summary.cross_repo_hits).toBe(1);
|
||||
expect(result.outOfScope).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('test_runGroupImpact_downstream_fans_out_to_provider', async () => {
|
||||
const bridgeQuery = makeBridgeQuery([
|
||||
{
|
||||
fanOutRepo: 'app/backend',
|
||||
fanOutUid: 'uid-ctrl',
|
||||
fanOutFilePath: 'src/ctrl.ts',
|
||||
fanOutSymbolName: 'UserController.list',
|
||||
matchedLocalUid: 'uid-fetch',
|
||||
matchedLocalFilePath: 'src/api.ts',
|
||||
matchedLocalSymbolName: 'fetchUsers',
|
||||
matchType: 'exact',
|
||||
confidence: 0.9,
|
||||
contractId: 'http::GET::/api/users',
|
||||
contractType: 'http',
|
||||
},
|
||||
]);
|
||||
const crossImpactFn = vi.fn().mockResolvedValue({
|
||||
byDepth: { '1': [{ id: 'uid-ctrl', name: 'UserController.list', filePath: 'src/ctrl.ts' }] },
|
||||
affected_processes: [],
|
||||
});
|
||||
|
||||
const result = await runGroupImpact({
|
||||
groupName: 'test',
|
||||
target: 'fetchUsers',
|
||||
repoPath: 'app/frontend',
|
||||
direction: 'downstream',
|
||||
bridgeQuery,
|
||||
localImpactFn: async () => ({
|
||||
target: { id: 'uid-fetch', name: 'fetchUsers', filePath: 'src/api.ts' },
|
||||
direction: 'downstream',
|
||||
impactedCount: 1,
|
||||
risk: 'LOW',
|
||||
summary: { direct: 1, processes_affected: 0, modules_affected: 0 },
|
||||
affected_processes: [],
|
||||
affected_modules: [],
|
||||
byDepth: { '1': [{ id: 'uid-fetch', name: 'fetchUsers', filePath: 'src/api.ts' }] },
|
||||
}),
|
||||
crossImpactFn,
|
||||
});
|
||||
|
||||
expect(bridgeQuery).toHaveBeenCalledOnce();
|
||||
const [cypher] = bridgeQuery.mock.calls[0];
|
||||
expect(cypher).toContain('consumer.repo = $sourceRepo');
|
||||
|
||||
expect(crossImpactFn).toHaveBeenCalledWith('app/backend', 'uid-ctrl', 'downstream', undefined);
|
||||
expect(result.cross).toHaveLength(1);
|
||||
expect(result.cross[0].repo_path).toBe('app/backend');
|
||||
expect(result.summary.cross_repo_hits).toBe(1);
|
||||
});
|
||||
|
||||
it('test_runGroupImpact_hint_passed_when_uid_empty', async () => {
|
||||
const bridgeQuery = makeBridgeQuery([
|
||||
{
|
||||
fanOutRepo: 'app/frontend',
|
||||
fanOutUid: '',
|
||||
fanOutFilePath: 'src/api.ts',
|
||||
fanOutSymbolName: 'fetchUsers',
|
||||
matchedLocalUid: 'uid-ctrl',
|
||||
matchedLocalFilePath: 'src/ctrl.ts',
|
||||
matchedLocalSymbolName: 'UserController.list',
|
||||
matchType: 'bm25',
|
||||
confidence: 0.7,
|
||||
contractId: 'grpc::UserService',
|
||||
contractType: 'grpc',
|
||||
},
|
||||
]);
|
||||
const crossImpactFn = vi.fn().mockResolvedValue({
|
||||
byDepth: {},
|
||||
affected_processes: [],
|
||||
});
|
||||
|
||||
await runGroupImpact({
|
||||
groupName: 'test',
|
||||
target: 'UserController.list',
|
||||
repoPath: 'app/backend',
|
||||
direction: 'upstream',
|
||||
bridgeQuery,
|
||||
localImpactFn,
|
||||
crossImpactFn,
|
||||
});
|
||||
|
||||
expect(crossImpactFn).toHaveBeenCalledWith('app/frontend', '', 'upstream', {
|
||||
filePath: 'src/api.ts',
|
||||
symbolName: 'fetchUsers',
|
||||
});
|
||||
});
|
||||
|
||||
it('test_runGroupImpact_subgroup_passed_to_bridgeQuery', async () => {
|
||||
const bridgeQuery = makeBridgeQuery([]);
|
||||
const crossImpactFn = vi.fn();
|
||||
|
||||
await runGroupImpact({
|
||||
groupName: 'test',
|
||||
target: 'UserController.list',
|
||||
repoPath: 'app/backend',
|
||||
direction: 'upstream',
|
||||
subgroup: 'team/backend',
|
||||
bridgeQuery,
|
||||
localImpactFn,
|
||||
crossImpactFn,
|
||||
});
|
||||
|
||||
expect(bridgeQuery).toHaveBeenCalledOnce();
|
||||
const [cypher, params] = bridgeQuery.mock.calls[0];
|
||||
// When subgroup is provided, the query should include the subgroup filter clause
|
||||
expect(params.subgroup).toBe('team/backend');
|
||||
// The Cypher should include the subgroup WHERE clause
|
||||
expect(cypher).toContain('$subgroup');
|
||||
});
|
||||
|
||||
it('test_runGroupImpact_error_object_not_counted_as_hit', async () => {
|
||||
const bridgeQuery = makeBridgeQuery([
|
||||
{
|
||||
fanOutRepo: 'app/frontend',
|
||||
fanOutUid: 'uid-fetch',
|
||||
fanOutFilePath: 'src/api.ts',
|
||||
fanOutSymbolName: 'fetchUsers',
|
||||
matchedLocalUid: 'uid-ctrl',
|
||||
matchedLocalFilePath: 'src/ctrl.ts',
|
||||
matchedLocalSymbolName: 'UserController.list',
|
||||
matchType: 'exact',
|
||||
confidence: 1.0,
|
||||
contractId: 'http::GET::/api/users',
|
||||
contractType: 'http',
|
||||
},
|
||||
]);
|
||||
const crossImpactFn = vi.fn().mockResolvedValue({ error: 'repo not indexed' });
|
||||
|
||||
const result = await runGroupImpact({
|
||||
groupName: 'test',
|
||||
target: 'UserController.list',
|
||||
repoPath: 'app/backend',
|
||||
direction: 'upstream',
|
||||
bridgeQuery,
|
||||
localImpactFn,
|
||||
crossImpactFn,
|
||||
});
|
||||
|
||||
expect(result.cross).toHaveLength(0);
|
||||
expect(result.summary.cross_repo_hits).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,15 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { GrpcExtractor } from '../../../src/core/group/extractors/grpc-extractor.js';
|
||||
import {
|
||||
GrpcExtractor,
|
||||
buildProtoMap,
|
||||
resolveProtoConflict,
|
||||
serviceContractId,
|
||||
} from '../../../src/core/group/extractors/grpc-extractor.js';
|
||||
import type { ProtoServiceInfo } from '../../../src/core/group/extractors/grpc-extractor.js';
|
||||
import type { RepoHandle } from '../../../src/core/group/types.js';
|
||||
|
||||
describe('GrpcExtractor', () => {
|
||||
|
|
@ -228,7 +235,7 @@ func main() {
|
|||
expect(providers.length).toBeGreaterThanOrEqual(1);
|
||||
expect(providers[0].contractId).toContain('grpc::');
|
||||
expect(providers[0].contractId).toContain('AuthService');
|
||||
expect(providers[0].confidence).toBe(0.8);
|
||||
expect(providers[0].confidence).toBe(0.65);
|
||||
});
|
||||
|
||||
it('test_extract_go_unimplemented_server_returns_provider', async () => {
|
||||
|
|
@ -267,7 +274,7 @@ func NewAuthClient(conn *grpc.ClientConn) pb.AuthServiceClient {
|
|||
|
||||
expect(consumers.length).toBeGreaterThanOrEqual(1);
|
||||
expect(consumers[0].contractId).toContain('AuthService');
|
||||
expect(consumers[0].confidence).toBe(0.7);
|
||||
expect(consumers[0].confidence).toBe(0.55);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -287,7 +294,7 @@ public class AuthGrpcService extends AuthServiceGrpc.AuthServiceImplBase {
|
|||
|
||||
expect(providers.length).toBeGreaterThanOrEqual(1);
|
||||
expect(providers[0].contractId).toContain('AuthService');
|
||||
expect(providers[0].confidence).toBe(0.8);
|
||||
expect(providers[0].confidence).toBe(0.65);
|
||||
});
|
||||
|
||||
it('test_extract_java_blocking_stub_returns_consumer', async () => {
|
||||
|
|
@ -306,7 +313,7 @@ public class AuthGrpcService extends AuthServiceGrpc.AuthServiceImplBase {
|
|||
|
||||
expect(consumers.length).toBeGreaterThanOrEqual(1);
|
||||
expect(consumers[0].contractId).toContain('AuthService');
|
||||
expect(consumers[0].confidence).toBe(0.7);
|
||||
expect(consumers[0].confidence).toBe(0.55);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -328,7 +335,7 @@ def serve():
|
|||
|
||||
expect(providers.length).toBeGreaterThanOrEqual(1);
|
||||
expect(providers[0].contractId).toContain('AuthService');
|
||||
expect(providers[0].confidence).toBe(0.8);
|
||||
expect(providers[0].confidence).toBe(0.65);
|
||||
});
|
||||
|
||||
it('test_extract_python_stub_returns_consumer', async () => {
|
||||
|
|
@ -346,7 +353,7 @@ stub = auth_pb2_grpc.AuthServiceStub(channel)`,
|
|||
|
||||
expect(consumers.length).toBeGreaterThanOrEqual(1);
|
||||
expect(consumers[0].contractId).toContain('AuthService');
|
||||
expect(consumers[0].confidence).toBe(0.7);
|
||||
expect(consumers[0].confidence).toBe(0.55);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -389,3 +396,244 @@ export class AuthController {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildProtoMap', () => {
|
||||
let tmpDir: string;
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'proto-test-'));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await fsp.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('test_buildProtoMap_single_proto_parses_package_service_methods', async () => {
|
||||
const protoContent = `
|
||||
syntax = "proto3";
|
||||
package com.example;
|
||||
|
||||
service UserService {
|
||||
rpc GetUser (GetUserRequest) returns (GetUserResponse);
|
||||
rpc ListUsers (ListUsersRequest) returns (ListUsersResponse);
|
||||
}`;
|
||||
await fsp.mkdir(path.join(tmpDir, 'proto'), { recursive: true });
|
||||
await fsp.writeFile(path.join(tmpDir, 'proto', 'user.proto'), protoContent);
|
||||
|
||||
const map = await buildProtoMap(tmpDir);
|
||||
expect(map.has('UserService')).toBe(true);
|
||||
const entries = map.get('UserService')!;
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].package).toBe('com.example');
|
||||
expect(entries[0].serviceName).toBe('UserService');
|
||||
expect(entries[0].methods).toEqual(['GetUser', 'ListUsers']);
|
||||
expect(entries[0].protoPath).toBe('proto/user.proto');
|
||||
});
|
||||
|
||||
it('test_buildProtoMap_no_package_declaration', async () => {
|
||||
const protoContent = `
|
||||
syntax = "proto3";
|
||||
service Foo { rpc Bar (Req) returns (Res); }`;
|
||||
await fsp.writeFile(path.join(tmpDir, 'foo.proto'), protoContent);
|
||||
|
||||
const map = await buildProtoMap(tmpDir);
|
||||
const entries = map.get('Foo')!;
|
||||
expect(entries[0].package).toBe('');
|
||||
});
|
||||
|
||||
it('test_buildProtoMap_no_protos_returns_empty', async () => {
|
||||
const map = await buildProtoMap(tmpDir);
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
|
||||
it('test_buildProtoMap_conflicting_names', async () => {
|
||||
await fsp.mkdir(path.join(tmpDir, 'a'), { recursive: true });
|
||||
await fsp.mkdir(path.join(tmpDir, 'b'), { recursive: true });
|
||||
await fsp.writeFile(
|
||||
path.join(tmpDir, 'a', 'svc.proto'),
|
||||
'package pkg.a;\nservice Svc { rpc Do (R) returns (R); }',
|
||||
);
|
||||
await fsp.writeFile(
|
||||
path.join(tmpDir, 'b', 'svc.proto'),
|
||||
'package pkg.b;\nservice Svc { rpc Do (R) returns (R); }',
|
||||
);
|
||||
|
||||
const map = await buildProtoMap(tmpDir);
|
||||
expect(map.get('Svc')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveProtoConflict', () => {
|
||||
const makeInfo = (pkg: string, protoPath: string): ProtoServiceInfo => ({
|
||||
package: pkg,
|
||||
serviceName: 'Svc',
|
||||
methods: ['Do'],
|
||||
protoPath,
|
||||
});
|
||||
|
||||
it('test_single_candidate_returns_it', () => {
|
||||
const result = resolveProtoConflict('Svc', 'src/main.go', [makeInfo('pkg', 'proto/svc.proto')]);
|
||||
expect(result?.package).toBe('pkg');
|
||||
});
|
||||
|
||||
it('test_multiple_candidates_picks_closest_directory', () => {
|
||||
const candidates = [
|
||||
makeInfo('far', 'other/dir/svc.proto'),
|
||||
makeInfo('close', 'src/proto/svc.proto'),
|
||||
];
|
||||
const result = resolveProtoConflict('Svc', 'src/server.go', candidates);
|
||||
expect(result?.package).toBe('close');
|
||||
});
|
||||
|
||||
it('test_no_candidates_returns_null', () => {
|
||||
expect(resolveProtoConflict('Svc', 'src/main.go', [])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('serviceContractId', () => {
|
||||
it('test_with_package', () => {
|
||||
expect(serviceContractId('com.example', 'UserService')).toBe('grpc::com.example.UserService/*');
|
||||
});
|
||||
|
||||
it('test_without_package', () => {
|
||||
expect(serviceContractId('', 'UserService')).toBe('grpc::UserService/*');
|
||||
});
|
||||
});
|
||||
|
||||
describe('proto-aware source scanners', () => {
|
||||
let tmpDir: string;
|
||||
let extractor: GrpcExtractor;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'scanner-test-'));
|
||||
extractor = new GrpcExtractor();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await fsp.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const makeRepo = (repoPath: string): RepoHandle => ({
|
||||
id: 'test-repo',
|
||||
path: '',
|
||||
repoPath,
|
||||
storagePath: '',
|
||||
});
|
||||
|
||||
it('test_go_provider_with_proto_uses_canonical_service_id', async () => {
|
||||
await fsp.mkdir(path.join(tmpDir, 'proto'), { recursive: true });
|
||||
await fsp.writeFile(
|
||||
path.join(tmpDir, 'proto', 'user.proto'),
|
||||
'package com.example;\nservice UserService { rpc GetUser (R) returns (R); }',
|
||||
);
|
||||
await fsp.mkdir(path.join(tmpDir, 'src'), { recursive: true });
|
||||
await fsp.writeFile(
|
||||
path.join(tmpDir, 'src', 'server.go'),
|
||||
'package main\nfunc init() { pb.RegisterUserServiceServer(srv, &impl{}) }',
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
|
||||
const goProvider = contracts.find((c) => c.meta.source === 'go_register');
|
||||
expect(goProvider).toBeDefined();
|
||||
expect(goProvider!.contractId).toBe('grpc::com.example.UserService/*');
|
||||
expect(goProvider!.confidence).toBe(0.8);
|
||||
});
|
||||
|
||||
it('test_go_provider_without_proto_reduced_confidence', async () => {
|
||||
await fsp.mkdir(path.join(tmpDir, 'src'), { recursive: true });
|
||||
await fsp.writeFile(
|
||||
path.join(tmpDir, 'src', 'server.go'),
|
||||
'package main\nfunc init() { pb.RegisterFooServer(srv, &impl{}) }',
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
|
||||
const goProvider = contracts.find((c) => c.meta.source === 'go_register');
|
||||
expect(goProvider).toBeDefined();
|
||||
expect(goProvider!.contractId).toBe('grpc::Foo/*');
|
||||
expect(goProvider!.confidence).toBe(0.65);
|
||||
});
|
||||
|
||||
it('test_go_consumer_with_proto_uses_canonical_service_id', async () => {
|
||||
await fsp.mkdir(path.join(tmpDir, 'proto'), { recursive: true });
|
||||
await fsp.writeFile(
|
||||
path.join(tmpDir, 'proto', 'user.proto'),
|
||||
'package com.example;\nservice UserService { rpc GetUser (R) returns (R); }',
|
||||
);
|
||||
await fsp.mkdir(path.join(tmpDir, 'src'), { recursive: true });
|
||||
await fsp.writeFile(
|
||||
path.join(tmpDir, 'src', 'client.go'),
|
||||
'package main\nfunc init() { client := pb.NewUserServiceClient(conn) }',
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
|
||||
const goConsumer = contracts.find((c) => c.meta.source === 'go_client');
|
||||
expect(goConsumer).toBeDefined();
|
||||
expect(goConsumer!.contractId).toBe('grpc::com.example.UserService/*');
|
||||
expect(goConsumer!.confidence).toBe(0.75);
|
||||
});
|
||||
|
||||
it('test_java_provider_with_proto_uses_canonical_service_id', async () => {
|
||||
await fsp.mkdir(path.join(tmpDir, 'proto'), { recursive: true });
|
||||
await fsp.writeFile(
|
||||
path.join(tmpDir, 'proto', 'user.proto'),
|
||||
'package com.example;\nservice UserService { rpc GetUser (R) returns (R); }',
|
||||
);
|
||||
await fsp.mkdir(path.join(tmpDir, 'src', 'main', 'java'), { recursive: true });
|
||||
await fsp.writeFile(
|
||||
path.join(tmpDir, 'src', 'main', 'java', 'UserGrpcService.java'),
|
||||
`@GrpcService
|
||||
public class UserGrpcService extends UserServiceGrpc.UserServiceImplBase {
|
||||
@Override
|
||||
public void getUser(GetUserRequest req, StreamObserver<GetUserResponse> obs) {}
|
||||
}`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
|
||||
const javaProvider = contracts.find((c) => c.meta.source === 'java_grpc_service');
|
||||
expect(javaProvider).toBeDefined();
|
||||
expect(javaProvider!.contractId).toBe('grpc::com.example.UserService/*');
|
||||
expect(javaProvider!.confidence).toBe(0.8);
|
||||
});
|
||||
|
||||
it('test_python_consumer_with_proto_uses_canonical_service_id', async () => {
|
||||
await fsp.mkdir(path.join(tmpDir, 'proto'), { recursive: true });
|
||||
await fsp.writeFile(
|
||||
path.join(tmpDir, 'proto', 'user.proto'),
|
||||
'package com.example;\nservice UserService { rpc GetUser (R) returns (R); }',
|
||||
);
|
||||
await fsp.writeFile(
|
||||
path.join(tmpDir, 'client.py'),
|
||||
`import grpc
|
||||
channel = grpc.insecure_channel('localhost:50051')
|
||||
stub = UserServiceStub(channel)`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
|
||||
const pyConsumer = contracts.find((c) => c.meta.source === 'python_stub');
|
||||
expect(pyConsumer).toBeDefined();
|
||||
expect(pyConsumer!.contractId).toBe('grpc::com.example.UserService/*');
|
||||
expect(pyConsumer!.confidence).toBe(0.75);
|
||||
});
|
||||
|
||||
it('test_ts_provider_with_proto_adds_package', async () => {
|
||||
await fsp.mkdir(path.join(tmpDir, 'proto'), { recursive: true });
|
||||
await fsp.writeFile(
|
||||
path.join(tmpDir, 'proto', 'user.proto'),
|
||||
'package com.example;\nservice UserService { rpc GetUser (R) returns (R); }',
|
||||
);
|
||||
await fsp.mkdir(path.join(tmpDir, 'src'), { recursive: true });
|
||||
await fsp.writeFile(
|
||||
path.join(tmpDir, 'src', 'controller.ts'),
|
||||
"@GrpcMethod('UserService', 'GetUser')\nasync getUser() {}",
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
|
||||
const tsProvider = contracts.find((c) => c.meta.source === 'ts_grpc_method');
|
||||
expect(tsProvider).toBeDefined();
|
||||
expect(tsProvider!.contractId).toBe('grpc::com.example.UserService/GetUser');
|
||||
expect(tsProvider!.confidence).toBe(0.8);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { runExactMatch, normalizeContractId } from '../../../src/core/group/matching.js';
|
||||
import {
|
||||
runExactMatch,
|
||||
normalizeContractId,
|
||||
buildProviderIndex,
|
||||
runWildcardMatch,
|
||||
} from '../../../src/core/group/matching.js';
|
||||
import type { StoredContract } from '../../../src/core/group/types.js';
|
||||
|
||||
describe('normalizeContractId', () => {
|
||||
|
|
@ -21,6 +26,16 @@ describe('normalizeContractId', () => {
|
|||
expect(normalizeContractId('grpc::/MyPkg/DoThing')).toBe('grpc::/MyPkg/DoThing');
|
||||
});
|
||||
|
||||
it('handles malformed grpc with leading slash and no package', () => {
|
||||
// grpc::/Method — leading slash, no package
|
||||
expect(normalizeContractId('grpc::/Method')).toBe('grpc::/Method');
|
||||
});
|
||||
|
||||
it('handles grpc with no slash at all', () => {
|
||||
// grpc::ServiceName — no slash, ambiguous; MVP: lowercase entire token
|
||||
expect(normalizeContractId('grpc::ServiceName')).toBe('grpc::servicename');
|
||||
});
|
||||
|
||||
it('trims and lowercases topic', () => {
|
||||
expect(normalizeContractId('topic:: Employee.Hired ')).toBe('topic::employee.hired');
|
||||
});
|
||||
|
|
@ -180,3 +195,211 @@ describe('runExactMatch', () => {
|
|||
expect(unmatched).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers for Task 6 tests
|
||||
// ---------------------------------------------------------------------------
|
||||
function makeGrpcContract(
|
||||
id: string,
|
||||
role: 'provider' | 'consumer',
|
||||
repo: string,
|
||||
overrides: Partial<StoredContract> = {},
|
||||
): StoredContract {
|
||||
return {
|
||||
contractId: id,
|
||||
type: 'grpc',
|
||||
role,
|
||||
symbolUid: `uid-${repo}-${id}`,
|
||||
symbolRef: { filePath: `src/${repo}.ts`, name: `fn-${id}` },
|
||||
symbolName: `fn-${id}`,
|
||||
confidence: 0.9,
|
||||
meta: {},
|
||||
repo,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildProviderIndex
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('buildProviderIndex', () => {
|
||||
it('test_buildProviderIndex_creates_normalized_keys', () => {
|
||||
const contracts: StoredContract[] = [
|
||||
makeGrpcContract('grpc::Com.Example.UserService/GetUser', 'provider', 'backend'),
|
||||
makeGrpcContract('grpc::Com.Example.UserService/GetUser', 'consumer', 'frontend'),
|
||||
];
|
||||
|
||||
const index = buildProviderIndex(contracts);
|
||||
|
||||
// Only providers should be in the index
|
||||
expect(index.size).toBe(1);
|
||||
// Key should be normalized (lowercased package)
|
||||
expect(index.has('grpc::com.example.userservice/GetUser')).toBe(true);
|
||||
expect(index.get('grpc::com.example.userservice/GetUser')).toHaveLength(1);
|
||||
expect(index.get('grpc::com.example.userservice/GetUser')![0].role).toBe('provider');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// runExactMatch — gRPC wildcard skip
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('runExactMatch — gRPC wildcard handling', () => {
|
||||
it('test_runExactMatch_skips_grpc_wildcard_contracts', () => {
|
||||
const contracts: StoredContract[] = [
|
||||
makeGrpcContract('grpc::com.example.UserService/*', 'consumer', 'frontend'),
|
||||
makeGrpcContract('grpc::com.example.UserService/*', 'provider', 'backend'),
|
||||
];
|
||||
|
||||
const { matched, unmatched } = runExactMatch(contracts);
|
||||
|
||||
// gRPC wildcards should NOT be matched in exact pass
|
||||
expect(matched).toHaveLength(0);
|
||||
// Both should appear in unmatched
|
||||
expect(unmatched).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('test_runExactMatch_does_not_skip_http_wildcards', () => {
|
||||
const contracts: StoredContract[] = [
|
||||
{
|
||||
contractId: 'http::GET::/api/users',
|
||||
type: 'http',
|
||||
role: 'provider',
|
||||
symbolUid: 'uid-backend-http',
|
||||
symbolRef: { filePath: 'src/backend.ts', name: 'fn-http' },
|
||||
symbolName: 'fn-http',
|
||||
confidence: 0.9,
|
||||
meta: {},
|
||||
repo: 'backend',
|
||||
},
|
||||
{
|
||||
contractId: 'http::*::/api/users',
|
||||
type: 'http',
|
||||
role: 'consumer',
|
||||
symbolUid: 'uid-frontend-http',
|
||||
symbolRef: { filePath: 'src/frontend.ts', name: 'fn-http' },
|
||||
symbolName: 'fn-http',
|
||||
confidence: 0.9,
|
||||
meta: {},
|
||||
repo: 'frontend',
|
||||
},
|
||||
];
|
||||
|
||||
const { matched } = runExactMatch(contracts);
|
||||
// HTTP wildcard should still match via findMatchingKeys
|
||||
expect(matched).toHaveLength(1);
|
||||
expect(matched[0].contractId).toBe('http::*::/api/users');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// runWildcardMatch
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('runWildcardMatch', () => {
|
||||
it('test_runWildcardMatch_fq_service_match', () => {
|
||||
const consumer = makeGrpcContract('grpc::com.example.UserService/*', 'consumer', 'frontend');
|
||||
const provider = makeGrpcContract(
|
||||
'grpc::com.example.UserService/GetUser',
|
||||
'provider',
|
||||
'backend',
|
||||
);
|
||||
|
||||
const providerIndex = buildProviderIndex([provider]);
|
||||
const { matched } = runWildcardMatch([consumer], providerIndex);
|
||||
|
||||
expect(matched).toHaveLength(1);
|
||||
expect(matched[0].from.repo).toBe('frontend');
|
||||
expect(matched[0].to.repo).toBe('backend');
|
||||
});
|
||||
|
||||
it('test_runWildcardMatch_bare_name_match', () => {
|
||||
const consumer = makeGrpcContract('grpc::UserService/*', 'consumer', 'frontend');
|
||||
const provider = makeGrpcContract(
|
||||
'grpc::com.example.UserService/GetUser',
|
||||
'provider',
|
||||
'backend',
|
||||
);
|
||||
|
||||
const providerIndex = buildProviderIndex([provider]);
|
||||
const { matched } = runWildcardMatch([consumer], providerIndex);
|
||||
|
||||
expect(matched).toHaveLength(1);
|
||||
expect(matched[0].from.repo).toBe('frontend');
|
||||
expect(matched[0].to.repo).toBe('backend');
|
||||
});
|
||||
|
||||
it('test_runWildcardMatch_no_match_different_service', () => {
|
||||
const consumer = makeGrpcContract('grpc::UserService/*', 'consumer', 'frontend');
|
||||
const provider = makeGrpcContract(
|
||||
'grpc::com.example.OtherService/GetUser',
|
||||
'provider',
|
||||
'backend',
|
||||
);
|
||||
|
||||
const providerIndex = buildProviderIndex([provider]);
|
||||
const { matched, remaining } = runWildcardMatch([consumer], providerIndex);
|
||||
|
||||
expect(matched).toHaveLength(0);
|
||||
expect(remaining).toContainEqual(consumer);
|
||||
});
|
||||
|
||||
it('test_runWildcardMatch_skips_wildcard_providers', () => {
|
||||
const consumer = makeGrpcContract('grpc::com.example.UserService/*', 'consumer', 'frontend');
|
||||
const provider = makeGrpcContract('grpc::com.example.UserService/*', 'provider', 'backend');
|
||||
|
||||
const providerIndex = buildProviderIndex([provider]);
|
||||
const { matched } = runWildcardMatch([consumer], providerIndex);
|
||||
|
||||
// Wildcard provider key ends with /*, so it should be skipped
|
||||
expect(matched).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('test_runWildcardMatch_confidence_min', () => {
|
||||
const consumer = makeGrpcContract('grpc::com.example.UserService/*', 'consumer', 'frontend', {
|
||||
confidence: 0.7,
|
||||
});
|
||||
const provider = makeGrpcContract(
|
||||
'grpc::com.example.UserService/GetUser',
|
||||
'provider',
|
||||
'backend',
|
||||
{
|
||||
confidence: 0.5,
|
||||
},
|
||||
);
|
||||
|
||||
const providerIndex = buildProviderIndex([provider]);
|
||||
const { matched } = runWildcardMatch([consumer], providerIndex);
|
||||
|
||||
expect(matched).toHaveLength(1);
|
||||
expect(matched[0].confidence).toBe(0.5);
|
||||
});
|
||||
|
||||
it('test_runWildcardMatch_matchType_wildcard', () => {
|
||||
const consumer = makeGrpcContract('grpc::com.example.UserService/*', 'consumer', 'frontend');
|
||||
const provider = makeGrpcContract(
|
||||
'grpc::com.example.UserService/GetUser',
|
||||
'provider',
|
||||
'backend',
|
||||
);
|
||||
|
||||
const providerIndex = buildProviderIndex([provider]);
|
||||
const { matched } = runWildcardMatch([consumer], providerIndex);
|
||||
|
||||
expect(matched).toHaveLength(1);
|
||||
expect(matched[0].matchType).toBe('wildcard');
|
||||
});
|
||||
|
||||
it('test_runWildcardMatch_contractId_is_consumers', () => {
|
||||
const consumer = makeGrpcContract('grpc::com.example.UserService/*', 'consumer', 'frontend');
|
||||
const provider = makeGrpcContract(
|
||||
'grpc::com.example.UserService/GetUser',
|
||||
'provider',
|
||||
'backend',
|
||||
);
|
||||
|
||||
const providerIndex = buildProviderIndex([provider]);
|
||||
const { matched } = runWildcardMatch([consumer], providerIndex);
|
||||
|
||||
expect(matched).toHaveLength(1);
|
||||
expect(matched[0].contractId).toBe('grpc::com.example.UserService/*');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,11 +7,23 @@ import {
|
|||
type GroupToolPort,
|
||||
type GroupRepoHandle,
|
||||
} from '../../../src/core/group/service.js';
|
||||
import { writeContractRegistry } from '../../../src/core/group/storage.js';
|
||||
import { writeBridge } from '../../../src/core/group/bridge-db.js';
|
||||
import type { ContractRegistry, StoredContract, CrossLink } from '../../../src/core/group/types.js';
|
||||
|
||||
/** Test helper: write legacy contracts.json for JSON-fallback tests */
|
||||
async function writeContractRegistryJson(
|
||||
groupDir: string,
|
||||
registry: ContractRegistry,
|
||||
): Promise<void> {
|
||||
const targetPath = path.join(groupDir, 'contracts.json');
|
||||
fs.writeFileSync(targetPath, JSON.stringify(registry, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
function makeTmpGroup(): { tmpDir: string; groupDir: string; cleanup: () => void } {
|
||||
const tmpDir = path.join(os.tmpdir(), `gitnexus-svc-${Date.now()}`);
|
||||
const tmpDir = path.join(
|
||||
os.tmpdir(),
|
||||
`gitnexus-svc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
);
|
||||
const groupDir = path.join(tmpDir, 'groups', 'test-group');
|
||||
fs.mkdirSync(groupDir, { recursive: true });
|
||||
|
||||
|
|
@ -113,20 +125,20 @@ describe('GroupService', () => {
|
|||
expect(result.error).toContain('name is required');
|
||||
});
|
||||
|
||||
it('test_groupContracts_returns_error_when_no_registry', async () => {
|
||||
it('test_groupContracts_no_data_returns_error', async () => {
|
||||
const { cleanup, tmpDir } = makeTmpGroup();
|
||||
try {
|
||||
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
||||
const svc = new GroupService(makePort());
|
||||
const result = (await svc.groupContracts({ name: 'test-group' })) as { error: string };
|
||||
expect(result.error).toContain('No contracts.json');
|
||||
expect(result.error).toContain('No contract data');
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('test_groupContracts_returns_all_contracts', async () => {
|
||||
it('test_groupContracts_json_fallback_returns_all_contracts', async () => {
|
||||
const { groupDir, cleanup, tmpDir } = makeTmpGroup();
|
||||
try {
|
||||
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
||||
|
|
@ -134,7 +146,7 @@ describe('GroupService', () => {
|
|||
makeContract('http::GET::/api/users', 'provider', 'app/backend'),
|
||||
makeContract('http::GET::/api/users', 'consumer', 'app/frontend'),
|
||||
];
|
||||
await writeContractRegistry(groupDir, makeRegistry(contracts));
|
||||
await writeContractRegistryJson(groupDir, makeRegistry(contracts));
|
||||
|
||||
const svc = new GroupService(makePort());
|
||||
const result = (await svc.groupContracts({ name: 'test-group' })) as {
|
||||
|
|
@ -147,7 +159,7 @@ describe('GroupService', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('test_groupContracts_filters_by_type', async () => {
|
||||
it('test_groupContracts_json_fallback_filters_by_type', async () => {
|
||||
const { groupDir, cleanup, tmpDir } = makeTmpGroup();
|
||||
try {
|
||||
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
||||
|
|
@ -158,7 +170,7 @@ describe('GroupService', () => {
|
|||
type: 'grpc' as const,
|
||||
},
|
||||
];
|
||||
await writeContractRegistry(groupDir, makeRegistry(contracts));
|
||||
await writeContractRegistryJson(groupDir, makeRegistry(contracts));
|
||||
|
||||
const svc = new GroupService(makePort());
|
||||
const result = (await svc.groupContracts({ name: 'test-group', type: 'grpc' })) as {
|
||||
|
|
@ -172,7 +184,7 @@ describe('GroupService', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('test_groupContracts_filters_by_repo', async () => {
|
||||
it('test_groupContracts_json_fallback_filters_by_repo', async () => {
|
||||
const { groupDir, cleanup, tmpDir } = makeTmpGroup();
|
||||
try {
|
||||
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
||||
|
|
@ -180,7 +192,7 @@ describe('GroupService', () => {
|
|||
makeContract('http::GET::/api/users', 'provider', 'app/backend'),
|
||||
makeContract('http::GET::/api/users', 'consumer', 'app/frontend'),
|
||||
];
|
||||
await writeContractRegistry(groupDir, makeRegistry(contracts));
|
||||
await writeContractRegistryJson(groupDir, makeRegistry(contracts));
|
||||
|
||||
const svc = new GroupService(makePort());
|
||||
const result = (await svc.groupContracts({ name: 'test-group', repo: 'app/backend' })) as {
|
||||
|
|
@ -194,7 +206,7 @@ describe('GroupService', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('test_groupContracts_unmatchedOnly_filters_matched', async () => {
|
||||
it('test_groupContracts_json_fallback_unmatchedOnly_filters_matched', async () => {
|
||||
const { groupDir, cleanup, tmpDir } = makeTmpGroup();
|
||||
try {
|
||||
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
||||
|
|
@ -217,7 +229,7 @@ describe('GroupService', () => {
|
|||
matchType: 'exact',
|
||||
confidence: 1.0,
|
||||
};
|
||||
await writeContractRegistry(
|
||||
await writeContractRegistryJson(
|
||||
groupDir,
|
||||
makeRegistry([provider, consumer, orphan], [crossLink]),
|
||||
);
|
||||
|
|
@ -233,6 +245,107 @@ describe('GroupService', () => {
|
|||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('test_groupContracts_with_bridge_returns_contracts', async () => {
|
||||
const { groupDir, cleanup, tmpDir } = makeTmpGroup();
|
||||
try {
|
||||
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
||||
const contracts = [
|
||||
makeContract('http::GET::/api/users', 'provider', 'app/backend'),
|
||||
makeContract('http::GET::/api/users', 'consumer', 'app/frontend'),
|
||||
];
|
||||
await writeBridge(groupDir, {
|
||||
contracts,
|
||||
crossLinks: [],
|
||||
repoSnapshots: {},
|
||||
missingRepos: [],
|
||||
});
|
||||
|
||||
const svc = new GroupService(makePort());
|
||||
const result = (await svc.groupContracts({ name: 'test-group' })) as {
|
||||
contracts: unknown[];
|
||||
crossLinks: unknown[];
|
||||
};
|
||||
expect(result.contracts).toHaveLength(2);
|
||||
expect(result.crossLinks).toEqual([]);
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('test_groupContracts_bridge_path_filters_by_type', async () => {
|
||||
const { groupDir, cleanup, tmpDir } = makeTmpGroup();
|
||||
try {
|
||||
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
||||
const contracts = [
|
||||
makeContract('http::GET::/api/users', 'provider', 'app/backend'),
|
||||
{
|
||||
...makeContract('grpc::auth.AuthService/Login', 'provider', 'app/backend'),
|
||||
type: 'grpc' as const,
|
||||
},
|
||||
];
|
||||
await writeBridge(groupDir, {
|
||||
contracts,
|
||||
crossLinks: [],
|
||||
repoSnapshots: {},
|
||||
missingRepos: [],
|
||||
});
|
||||
|
||||
const svc = new GroupService(makePort());
|
||||
const result = (await svc.groupContracts({ name: 'test-group', type: 'grpc' })) as {
|
||||
contracts: { type: string }[];
|
||||
};
|
||||
expect(result.contracts).toHaveLength(1);
|
||||
expect(result.contracts[0].type).toBe('grpc');
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('test_groupContracts_bridge_path_unmatchedOnly_filters_matched', async () => {
|
||||
const { groupDir, cleanup, tmpDir } = makeTmpGroup();
|
||||
try {
|
||||
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
||||
const provider = makeContract('http::GET::/api/users', 'provider', 'app/backend');
|
||||
const consumer = makeContract('http::GET::/api/users', 'consumer', 'app/frontend');
|
||||
const orphan = makeContract('http::GET::/api/health', 'provider', 'app/backend');
|
||||
const crossLink: CrossLink = {
|
||||
from: {
|
||||
repo: 'app/frontend',
|
||||
symbolUid: consumer.symbolUid,
|
||||
symbolRef: consumer.symbolRef,
|
||||
},
|
||||
to: {
|
||||
repo: 'app/backend',
|
||||
symbolUid: provider.symbolUid,
|
||||
symbolRef: provider.symbolRef,
|
||||
},
|
||||
type: 'http',
|
||||
contractId: 'http::GET::/api/users',
|
||||
matchType: 'exact',
|
||||
confidence: 1.0,
|
||||
};
|
||||
await writeBridge(groupDir, {
|
||||
contracts: [provider, consumer, orphan],
|
||||
crossLinks: [crossLink],
|
||||
repoSnapshots: {},
|
||||
missingRepos: [],
|
||||
});
|
||||
|
||||
const svc = new GroupService(makePort());
|
||||
const result = (await svc.groupContracts({ name: 'test-group', unmatchedOnly: true })) as {
|
||||
contracts: { contractId: string }[];
|
||||
};
|
||||
// Only the orphan should remain after filtering out matched ones
|
||||
expect(result.contracts).toHaveLength(1);
|
||||
expect(result.contracts[0].contractId).toBe('http::GET::/api/health');
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupSync', () => {
|
||||
|
|
@ -330,6 +443,111 @@ describe('GroupService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('groupImpact', () => {
|
||||
it('test_groupImpact_no_data_returns_error', async () => {
|
||||
const { cleanup, tmpDir } = makeTmpGroup();
|
||||
try {
|
||||
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
||||
const svc = new GroupService(makePort());
|
||||
const result = (await svc.groupImpact({
|
||||
name: 'test-group',
|
||||
target: 'someSymbol',
|
||||
repo: 'app/backend',
|
||||
})) as { error: string };
|
||||
expect(result.error).toContain('No contract data');
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('test_groupImpact_with_json_fallback_uses_legacy', async () => {
|
||||
const { groupDir, cleanup, tmpDir } = makeTmpGroup();
|
||||
try {
|
||||
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
||||
const contracts = [makeContract('http::GET::/api/users', 'provider', 'app/backend')];
|
||||
await writeContractRegistryJson(groupDir, makeRegistry(contracts));
|
||||
|
||||
const port = makePort({
|
||||
impact: vi.fn(async () => ({
|
||||
target: { id: 'uid-1', name: 'someSymbol', filePath: 'src/app.ts' },
|
||||
direction: 'upstream',
|
||||
impactedCount: 0,
|
||||
risk: 'LOW',
|
||||
summary: { direct: 0, processes_affected: 0, modules_affected: 0 },
|
||||
affected_processes: [],
|
||||
affected_modules: [],
|
||||
byDepth: {},
|
||||
})),
|
||||
});
|
||||
|
||||
const svc = new GroupService(port);
|
||||
const result = (await svc.groupImpact({
|
||||
name: 'test-group',
|
||||
target: 'someSymbol',
|
||||
repo: 'app/backend',
|
||||
})) as { local: unknown; group: string; risk: string };
|
||||
|
||||
expect(result.group).toBe('test-group');
|
||||
expect(result.local).toBeDefined();
|
||||
expect(result.risk).toBeDefined();
|
||||
expect(port.impact).toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('test_groupImpact_with_bridge_uses_new_runGroupImpact', async () => {
|
||||
const { groupDir, cleanup, tmpDir } = makeTmpGroup();
|
||||
try {
|
||||
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
||||
const contracts = [makeContract('http::GET::/api/users', 'provider', 'app/backend')];
|
||||
await writeBridge(groupDir, {
|
||||
contracts,
|
||||
crossLinks: [],
|
||||
repoSnapshots: {},
|
||||
missingRepos: [],
|
||||
});
|
||||
|
||||
const port = makePort({
|
||||
impact: vi.fn(async () => ({
|
||||
target: { id: 'uid-1', name: 'someSymbol', filePath: 'src/app.ts' },
|
||||
direction: 'upstream',
|
||||
impactedCount: 0,
|
||||
risk: 'LOW',
|
||||
summary: { direct: 0, processes_affected: 0, modules_affected: 0 },
|
||||
affected_processes: [],
|
||||
affected_modules: [],
|
||||
byDepth: {},
|
||||
})),
|
||||
});
|
||||
|
||||
const svc = new GroupService(port);
|
||||
const result = (await svc.groupImpact({
|
||||
name: 'test-group',
|
||||
target: 'someSymbol',
|
||||
repo: 'app/backend',
|
||||
})) as { local: unknown; group: string; risk: string; cross: unknown[] };
|
||||
|
||||
expect(result.group).toBe('test-group');
|
||||
expect(result.local).toBeDefined();
|
||||
expect(result.risk).toBeDefined();
|
||||
expect(result.cross).toEqual([]);
|
||||
expect(port.impact).toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('test_groupImpact_returns_error_when_params_missing', async () => {
|
||||
const svc = new GroupService(makePort());
|
||||
const result = (await svc.groupImpact({})) as { error: string };
|
||||
expect(result.error).toContain('name, target, and repo are required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupStatus', () => {
|
||||
it('test_groupStatus_returns_error_when_name_empty', async () => {
|
||||
const svc = new GroupService(makePort());
|
||||
|
|
@ -337,10 +555,32 @@ describe('GroupService', () => {
|
|||
expect(result.error).toContain('name is required');
|
||||
});
|
||||
|
||||
it('test_groupStatus_marks_unresolvable_repos_as_missing', async () => {
|
||||
it('test_groupStatus_no_data_returns_empty', async () => {
|
||||
const { cleanup, tmpDir } = makeTmpGroup();
|
||||
try {
|
||||
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
||||
const svc = new GroupService(makePort());
|
||||
const result = (await svc.groupStatus({ name: 'test-group' })) as {
|
||||
group: string;
|
||||
lastSync: null;
|
||||
missingRepos: string[];
|
||||
repos: Record<string, unknown>;
|
||||
};
|
||||
expect(result.group).toBe('test-group');
|
||||
expect(result.lastSync).toBeNull();
|
||||
expect(result.missingRepos).toEqual([]);
|
||||
expect(result.repos).toEqual({});
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('test_groupStatus_json_fallback_marks_unresolvable_repos_as_missing', async () => {
|
||||
const { groupDir, cleanup, tmpDir } = makeTmpGroup();
|
||||
try {
|
||||
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
||||
await writeContractRegistryJson(groupDir, makeRegistry([]));
|
||||
|
||||
const port = makePort({
|
||||
resolveRepo: vi.fn(async () => {
|
||||
|
|
@ -362,5 +602,83 @@ describe('GroupService', () => {
|
|||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('test_groupStatus_reads_from_bridge_meta_and_snapshots', async () => {
|
||||
const { groupDir, cleanup, tmpDir } = makeTmpGroup();
|
||||
try {
|
||||
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
||||
await writeBridge(groupDir, {
|
||||
contracts: [],
|
||||
crossLinks: [],
|
||||
repoSnapshots: {
|
||||
'app/backend': { indexedAt: '2026-01-01T00:00:00Z', lastCommit: 'abc123' },
|
||||
},
|
||||
missingRepos: ['app/frontend'],
|
||||
});
|
||||
|
||||
const port = makePort({
|
||||
resolveRepo: vi.fn(async () => {
|
||||
throw new Error('repo not found');
|
||||
}),
|
||||
});
|
||||
|
||||
const svc = new GroupService(port);
|
||||
const result = (await svc.groupStatus({ name: 'test-group' })) as {
|
||||
group: string;
|
||||
lastSync: string;
|
||||
missingRepos: string[];
|
||||
repos: Record<string, { missing: boolean }>;
|
||||
};
|
||||
|
||||
expect(result.group).toBe('test-group');
|
||||
expect(result.lastSync).toBeTruthy();
|
||||
expect(result.missingRepos).toContain('app/frontend');
|
||||
expect(result.repos['app/backend'].missing).toBe(true);
|
||||
expect(result.repos['app/frontend'].missing).toBe(true);
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('test_groupStatus_bridge_path_reads_repoSnapshots', async () => {
|
||||
const { groupDir, cleanup, tmpDir } = makeTmpGroup();
|
||||
try {
|
||||
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
||||
await writeBridge(groupDir, {
|
||||
contracts: [],
|
||||
crossLinks: [],
|
||||
repoSnapshots: {
|
||||
'app/backend': { indexedAt: '2026-02-01T00:00:00Z', lastCommit: 'abc123' },
|
||||
'app/frontend': { indexedAt: '2026-02-01T00:00:00Z', lastCommit: 'def456' },
|
||||
},
|
||||
missingRepos: [],
|
||||
});
|
||||
|
||||
const port = makePort({
|
||||
resolveRepo: vi.fn(async () => {
|
||||
throw new Error('repo not found');
|
||||
}),
|
||||
});
|
||||
|
||||
const svc = new GroupService(port);
|
||||
const result = (await svc.groupStatus({ name: 'test-group' })) as {
|
||||
group: string;
|
||||
lastSync: string;
|
||||
missingRepos: string[];
|
||||
repos: Record<string, { missing: boolean }>;
|
||||
};
|
||||
|
||||
expect(result.group).toBe('test-group');
|
||||
expect(result.lastSync).toBeTruthy();
|
||||
expect(result.missingRepos).toEqual([]);
|
||||
// Both repos should be marked missing since resolveRepo throws
|
||||
expect(result.repos['app/backend'].missing).toBe(true);
|
||||
expect(result.repos['app/frontend'].missing).toBe(true);
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue