feat(group): add cross-impact, groupImpact, and groupContext

- Extend GroupImpactResult with timeoutMs, truncationReason, crossDepthWarning
- Add GroupContextResult and per-repo context aggregation
- Implement runGroupImpact (local impact + bridge Cypher fan-out) in cross-impact.ts
- Resilient contracts.json parsing with skippedCorrupt metadata
- Extend GroupToolPort with context; wire LocalBackend
- Unit tests for cross-impact and GroupService

Made-with: Cursor
This commit is contained in:
ivkond 2026-04-16 01:02:02 +03:00
parent 2667c3cd72
commit 82f395dd4c
7 changed files with 1129 additions and 5 deletions

View file

@ -0,0 +1,559 @@
/**
* Cross-repo impact (Phase 1 local walk + Phase 2 bridge fan-out).
* All bridge Cypher for this feature lives in this module.
*/
import fsp from 'node:fs/promises';
import path from 'node:path';
import type {
BridgeHandle,
ContractType,
CrossRepoImpact,
GroupConfig,
GroupImpactResult,
MatchType,
OutOfScopeLink,
} from './types.js';
import type { GroupRepoHandle, GroupToolPort } from './service.js';
import { loadGroupConfig } from './config-parser.js';
import { fileMatchesServicePrefix, normalizeServicePrefix } from './group-path-utils.js';
import { getGroupDir } from './storage.js';
import {
closeBridgeDb,
openBridgeDbReadOnly,
queryBridge,
readBridgeMeta,
} from './bridge-db.js';
import { BRIDGE_SCHEMA_VERSION } from './bridge-schema.js';
/** Cross-boundary hops beyond this value are clamped (multi-hop reserved for future work). */
export const MAX_SUPPORTED_CROSS_DEPTH = 1;
/** Default wall-clock budget for the Phase 1 `impact` leg when callers omit `timeoutMs`. */
export const DEFAULT_LOCAL_IMPACT_TIMEOUT_MS = 30_000;
const CY_NEIGHBORS_UPSTREAM = `
MATCH (consumer:Contract)-[l:ContractLink]->(provider:Contract)
WHERE provider.repo = $localRepo
AND provider.symbolUid IN $uids
AND provider.role = 'provider'
RETURN consumer.repo AS neighborRepo,
consumer.symbolUid AS neighborUid,
consumer.filePath AS neighborFilePath,
l.matchType AS matchType,
l.confidence AS confidence,
l.contractId AS contractId,
consumer.type AS contractType
`;
const CY_NEIGHBORS_DOWNSTREAM = `
MATCH (consumer:Contract)-[l:ContractLink]->(provider:Contract)
WHERE consumer.repo = $localRepo
AND consumer.symbolUid IN $uids
AND consumer.role = 'consumer'
RETURN provider.repo AS neighborRepo,
provider.symbolUid AS neighborUid,
provider.filePath AS neighborFilePath,
l.matchType AS matchType,
l.confidence AS confidence,
l.contractId AS contractId,
provider.type AS contractType
`;
type BridgeNeighborRow = {
neighborRepo: string;
neighborUid: string;
neighborFilePath?: string;
matchType: string;
confidence: number;
contractId: string;
contractType: string;
};
export interface RunGroupImpactDeps {
port: GroupToolPort;
gitnexusDir: string;
}
function repoInSubgroup(repoPath: string, subgroup?: string): boolean {
if (!subgroup?.trim()) return true;
const s = subgroup.replace(/\/+$/, '');
return repoPath === s || repoPath.startsWith(`${s}/`);
}
function parseDirection(raw: unknown): 'upstream' | 'downstream' | null {
if (raw === 'upstream' || raw === 'downstream') return raw;
return null;
}
function clampCrossDepth(raw: unknown): { depth: number; warning?: string } {
const n = typeof raw === 'number' && Number.isFinite(raw) ? Math.floor(raw) : 1;
const d = n < 1 ? 1 : n;
if (d > MAX_SUPPORTED_CROSS_DEPTH) {
return {
depth: MAX_SUPPORTED_CROSS_DEPTH,
warning: `crossDepth was ${d}; multi-hop cross-boundary traversal beyond ${MAX_SUPPORTED_CROSS_DEPTH} is not implemented yet. Using crossDepth ${MAX_SUPPORTED_CROSS_DEPTH}.`,
};
}
return { depth: d };
}
export function validateGroupImpactParams(params: Record<string, unknown>): {
ok: true;
name: string;
repoPath: string;
target: string;
direction: 'upstream' | 'downstream';
maxDepth: number;
crossDepth: number;
crossDepthWarning?: string;
relationTypes?: string[];
includeTests: boolean;
minConfidence: number;
service?: string;
subgroup?: string;
timeoutMs: number;
} | { ok: false; error: string } {
const name = String(params.name ?? '').trim();
const repoPath = String(params.repo ?? '').trim();
const target = String(params.target ?? '').trim();
if (!name) return { ok: false, error: 'name is required' };
if (!repoPath) return { ok: false, error: 'repo is required (group repo path, e.g. app/backend)' };
if (!target) return { ok: false, error: 'target is required' };
const direction = parseDirection(params.direction);
if (!direction) return { ok: false, error: 'direction must be upstream or downstream' };
let maxDepth = typeof params.maxDepth === 'number' && params.maxDepth > 0 ? params.maxDepth : 3;
if (maxDepth > 32) maxDepth = 32;
const { depth: crossDepth, warning: crossDepthWarning } = clampCrossDepth(params.crossDepth);
const relationTypes = Array.isArray(params.relationTypes)
? params.relationTypes.filter((t): t is string => typeof t === 'string')
: undefined;
const includeTests = Boolean(params.includeTests);
let minConfidence = typeof params.minConfidence === 'number' ? params.minConfidence : 0;
if (minConfidence < 0) minConfidence = 0;
if (minConfidence > 1) minConfidence = 1;
const service = normalizeServicePrefix(params.service);
const subgroup = typeof params.subgroup === 'string' ? params.subgroup : undefined;
let timeoutMs =
typeof params.timeoutMs === 'number' && params.timeoutMs > 0
? params.timeoutMs
: typeof params.timeout === 'number' && params.timeout > 0
? params.timeout
: DEFAULT_LOCAL_IMPACT_TIMEOUT_MS;
if (timeoutMs > 3_600_000) timeoutMs = 3_600_000;
return {
ok: true,
name,
repoPath,
target,
direction,
maxDepth,
crossDepth,
crossDepthWarning,
relationTypes,
includeTests,
minConfidence,
service,
subgroup,
timeoutMs,
};
}
async function resolveGroupRepo(
port: GroupToolPort,
config: GroupConfig,
repoPath: string,
): Promise<GroupRepoHandle | { error: string }> {
const registryName = config.repos[repoPath];
if (!registryName) {
return { error: `Unknown repo path "${repoPath}" in this group.` };
}
try {
return await port.resolveRepo(registryName);
} catch (e) {
return { error: e instanceof Error ? e.message : String(e) };
}
}
async function safeLocalImpact(
port: GroupToolPort,
repo: GroupRepoHandle,
impactParams: Parameters<GroupToolPort['impact']>[1],
timeoutMs: number,
): Promise<{ value: unknown; timedOut: boolean }> {
let timer: ReturnType<typeof setTimeout> | undefined;
const impactP = port.impact(repo, impactParams).catch((err) => ({
error: err instanceof Error ? err.message : String(err),
}));
const timeoutP = new Promise<'timeout'>((resolve) => {
timer = setTimeout(() => resolve('timeout'), timeoutMs);
});
const won = await Promise.race([
impactP.then((v) => ({ tag: 'impact' as const, v })),
timeoutP.then(() => ({ tag: 'timeout' as const })),
]);
if (timer !== undefined) clearTimeout(timer);
if (won.tag === 'timeout') {
return {
value: { error: 'Local impact timed out', partial: true },
timedOut: true,
};
}
return { value: won.v, timedOut: false };
}
export function collectImpactSymbolUids(
local: unknown,
servicePrefix: string | undefined,
): { uids: string[]; targetFilePath?: string } {
const uids = new Set<string>();
let targetFilePath: string | undefined;
const obj = local as Record<string, unknown> | null;
if (!obj || typeof obj !== 'object') return { uids: [], targetFilePath };
const target = obj.target as { id?: string; filePath?: string } | undefined;
if (target?.id) {
targetFilePath = typeof target.filePath === 'string' ? target.filePath : undefined;
if (fileMatchesServicePrefix(targetFilePath, servicePrefix)) {
uids.add(String(target.id));
}
}
const byDepth = obj.byDepth as Record<string | number, unknown> | undefined;
if (byDepth && typeof byDepth === 'object') {
for (const items of Object.values(byDepth)) {
if (!Array.isArray(items)) continue;
for (const it of items) {
const row = it as { id?: string; filePath?: string };
if (row?.id && fileMatchesServicePrefix(row.filePath, servicePrefix)) {
uids.add(String(row.id));
}
}
}
}
return { uids: [...uids], targetFilePath };
}
function extractProcessNames(impact: unknown): string[] {
const o = impact as { affected_processes?: Array<{ name?: string }> };
if (!o?.affected_processes) return [];
return o.affected_processes.map((p) => String(p.name ?? '')).filter(Boolean);
}
function mergeRisk(localRisk: string, cross: CrossRepoImpact[]): string {
const highConf = cross.some((c) => c.contract.confidence >= 0.85);
if (localRisk === 'CRITICAL') return 'CRITICAL';
if (cross.length >= 3) return 'CRITICAL';
if (highConf) return 'HIGH';
if (cross.length > 0 && (localRisk === 'LOW' || localRisk === 'UNKNOWN')) return 'MEDIUM';
return localRisk;
}
async function ensureBridgeReady(
groupDir: string,
): Promise<{ handle: BridgeHandle } | { error: string }> {
const meta = await readBridgeMeta(groupDir);
if (meta.version > 0 && meta.version !== BRIDGE_SCHEMA_VERSION) {
return {
error: `Bridge schema version mismatch (meta.json has ${meta.version}, expected ${BRIDGE_SCHEMA_VERSION}). Run gitnexus group sync for this group.`,
};
}
const dbPath = path.join(groupDir, 'bridge.lbug');
try {
await fsp.access(dbPath);
} catch {
return {
error: `No bridge.lbug in this group directory. Run gitnexus group sync (schema ${BRIDGE_SCHEMA_VERSION}).`,
};
}
const handle = await openBridgeDbReadOnly(groupDir);
if (!handle) {
return {
error: `Could not open bridge.lbug read-only (schema ${BRIDGE_SCHEMA_VERSION}). Run gitnexus group sync.`,
};
}
return { handle };
}
function rowToNeighbor(r: Record<string, unknown>): BridgeNeighborRow | null {
const neighborRepo = String(r.neighborRepo ?? r[0] ?? '');
const neighborUid = String(r.neighborUid ?? r[1] ?? '');
if (!neighborRepo || !neighborUid) return null;
return {
neighborRepo,
neighborUid,
neighborFilePath: r.neighborFilePath !== undefined ? String(r.neighborFilePath) : String(r[2] ?? ''),
matchType: String(r.matchType ?? r[3] ?? 'exact'),
confidence: Number(r.confidence ?? r[4] ?? 0),
contractId: String(r.contractId ?? r[5] ?? ''),
contractType: String(r.contractType ?? r[6] ?? 'custom'),
};
}
export async function runGroupImpact(
deps: RunGroupImpactDeps,
params: Record<string, unknown>,
): Promise<GroupImpactResult | { error: string }> {
const parsed = validateGroupImpactParams(params);
if (!parsed.ok) return { error: parsed.error };
const {
name,
repoPath,
target,
direction,
maxDepth,
crossDepth: _crossDepth,
crossDepthWarning,
relationTypes,
includeTests,
minConfidence,
service: servicePrefix,
subgroup,
timeoutMs,
} = parsed;
const groupDir = getGroupDir(deps.gitnexusDir, name);
let config: GroupConfig;
try {
config = await loadGroupConfig(groupDir);
} catch (e) {
return { error: e instanceof Error ? e.message : String(e) };
}
const resolved = await resolveGroupRepo(deps.port, config, repoPath);
if ('error' in resolved) return { error: resolved.error };
const impactParams: Parameters<GroupToolPort['impact']>[1] = {
target,
direction,
maxDepth,
relationTypes: relationTypes && relationTypes.length > 0 ? relationTypes : undefined,
includeTests,
minConfidence,
};
const { value: local, timedOut: localTimedOut } = await safeLocalImpact(
deps.port,
resolved,
impactParams,
timeoutMs,
);
if (localTimedOut) {
const base = local as Record<string, unknown>;
return {
local,
group: name,
cross: [],
outOfScope: [],
truncated: true,
truncatedRepos: [],
summary: {
direct: 0,
processes_affected: 0,
modules_affected: 0,
cross_repo_hits: 0,
},
risk: 'UNKNOWN',
timeoutMs,
truncationReason: 'timeout',
crossDepthWarning,
};
}
const localObj = local as Record<string, unknown> | null;
if (localObj?.error && typeof localObj.error === 'string') {
const empty: GroupImpactResult = {
local,
group: name,
cross: [],
outOfScope: [],
truncated: false,
truncatedRepos: [],
summary: {
direct: 0,
processes_affected: 0,
modules_affected: 0,
cross_repo_hits: 0,
},
risk: 'UNKNOWN',
timeoutMs,
crossDepthWarning,
};
return empty;
}
if (servicePrefix) {
const tf = (localObj?.target as { filePath?: string } | undefined)?.filePath;
if (!fileMatchesServicePrefix(tf, servicePrefix)) {
return {
local: {},
group: name,
cross: [],
outOfScope: [],
truncated: false,
truncatedRepos: [],
summary: {
direct: 0,
processes_affected: 0,
modules_affected: 0,
cross_repo_hits: 0,
},
risk: 'LOW',
timeoutMs,
crossDepthWarning,
};
}
}
const { uids } = collectImpactSymbolUids(local, servicePrefix);
if (uids.length === 0) {
const s = (local as { summary?: Record<string, number> })?.summary || {};
return {
local,
group: name,
cross: [],
outOfScope: [],
truncated: Boolean((local as { partial?: boolean }).partial),
truncatedRepos: [],
summary: {
direct: s.direct ?? 0,
processes_affected: s.processes_affected ?? 0,
modules_affected: s.modules_affected ?? 0,
cross_repo_hits: 0,
},
risk: String((local as { risk?: string }).risk ?? 'LOW'),
timeoutMs,
truncationReason: (local as { partial?: boolean }).partial ? 'partial' : undefined,
crossDepthWarning,
};
}
const bridgePrep = await ensureBridgeReady(groupDir);
if ('error' in bridgePrep) return { error: bridgePrep.error };
const handle = bridgePrep.handle;
const cross: CrossRepoImpact[] = [];
const outOfScope: OutOfScopeLink[] = [];
const truncatedRepos: string[] = [];
try {
const cypher = direction === 'upstream' ? CY_NEIGHBORS_UPSTREAM : CY_NEIGHBORS_DOWNSTREAM;
const rows = await queryBridge<Record<string, unknown>>(handle, cypher, {
localRepo: repoPath,
uids,
});
const neighbors: BridgeNeighborRow[] = [];
for (const raw of rows) {
const n = rowToNeighbor(raw);
if (n) neighbors.push(n);
}
neighbors.sort((a, b) => b.confidence - a.confidence);
const deadline = Date.now() + Math.max(0, timeoutMs);
const seen = new Set<string>();
for (const n of neighbors) {
if (servicePrefix && !fileMatchesServicePrefix(n.neighborFilePath, servicePrefix)) {
continue;
}
if (!repoInSubgroup(n.neighborRepo, subgroup)) {
// CrossLink convention: consumer -> provider
outOfScope.push({
from: direction === 'upstream' ? n.neighborRepo : repoPath,
to: direction === 'upstream' ? repoPath : n.neighborRepo,
contractId: n.contractId,
confidence: n.confidence,
});
continue;
}
const key = `${n.neighborRepo}\0${n.neighborUid}\0${n.contractId}`;
if (seen.has(key)) continue;
seen.add(key);
if (Date.now() > deadline) {
truncatedRepos.push(n.neighborRepo);
continue;
}
const regName = config.repos[n.neighborRepo];
if (!regName) continue;
let neighborHandle: GroupRepoHandle;
try {
neighborHandle = await deps.port.resolveRepo(regName);
} catch {
truncatedRepos.push(n.neighborRepo);
continue;
}
const fan = await deps.port.impactByUid(
neighborHandle.id,
n.neighborUid,
direction,
{
maxDepth,
relationTypes: relationTypes ?? [],
minConfidence,
includeTests,
},
);
if (fan == null) {
truncatedRepos.push(n.neighborRepo);
continue;
}
cross.push({
repo: regName,
repo_path: n.neighborRepo,
contract: {
id: n.contractId,
type: n.contractType as ContractType,
match_type: (n.matchType as MatchType) || 'exact',
confidence: n.confidence,
},
by_depth: ((fan as { byDepth?: unknown }).byDepth ?? {}) as Record<string, unknown[]>,
affected_processes: extractProcessNames(fan),
});
}
} finally {
await closeBridgeDb(handle);
}
const localSum = (local as { summary?: Record<string, number> })?.summary || {};
const localRisk = String((local as { risk?: string }).risk ?? 'LOW');
const localPartial = Boolean((local as { partial?: boolean }).partial);
const truncated = truncatedRepos.length > 0 || localPartial;
const result: GroupImpactResult = {
local,
group: name,
cross,
outOfScope,
truncated,
truncatedRepos: [...new Set(truncatedRepos)],
summary: {
direct: localSum.direct ?? 0,
processes_affected: localSum.processes_affected ?? 0,
modules_affected: localSum.modules_affected ?? 0,
cross_repo_hits: cross.length,
},
risk: mergeRisk(localRisk, cross),
timeoutMs,
truncationReason: truncated ? 'partial' : undefined,
crossDepthWarning,
};
return result;
}
export { normalizeServicePrefix, fileMatchesServicePrefix } from './group-path-utils.js';

View file

@ -0,0 +1,19 @@
/**
* Shared service-path normalization for group tools (`service` monorepo filter).
* Segments are compared case-sensitively (typical POSIX-style repo paths).
*/
export function normalizeServicePrefix(service: unknown): string | undefined {
if (service === undefined || service === null) return undefined;
const s = String(service).trim().replace(/\/+$/, '');
return s.length > 0 ? s : undefined;
}
export function fileMatchesServicePrefix(
filePath: string | undefined,
prefix: string | undefined,
): boolean {
if (!prefix) return true;
if (!filePath) return false;
return filePath === prefix || filePath.startsWith(`${prefix}/`);
}

View file

@ -3,10 +3,25 @@
* DB access is injected via GroupToolPort so this module stays free of LocalBackend private API.
*/
import fsp from 'node:fs/promises';
import path from 'node:path';
import { checkStaleness } from '../git-staleness.js';
import { loadGroupConfig } from './config-parser.js';
import { getDefaultGitnexusDir, getGroupDir, listGroups, readContractRegistry } from './storage.js';
import { fileMatchesServicePrefix, normalizeServicePrefix } from './group-path-utils.js';
import {
getDefaultGitnexusDir,
getGroupDir,
listGroups,
readContractRegistry,
} from './storage.js';
import { syncGroup } from './sync.js';
import type {
ContractRegistry,
CrossLink,
GroupConfig,
GroupContextResult,
StoredContract,
} from './types.js';
export interface GroupRepoHandle {
id: string;
@ -52,6 +67,15 @@ export interface GroupToolPort {
includeTests: boolean;
},
): Promise<unknown | null>;
context(
repo: GroupRepoHandle,
params: {
name?: string;
uid?: string;
file_path?: string;
include_content?: boolean;
},
): Promise<unknown>;
}
function repoInSubgroup(repoPath: string, subgroup?: string): boolean {
@ -60,6 +84,123 @@ function repoInSubgroup(repoPath: string, subgroup?: string): boolean {
return repoPath === s || repoPath.startsWith(`${s}/`);
}
function isStoredContract(raw: unknown): raw is StoredContract {
if (!raw || typeof raw !== 'object') return false;
const o = raw as Record<string, unknown>;
return (
typeof o.contractId === 'string' &&
typeof o.type === 'string' &&
typeof o.repo === 'string' &&
typeof o.role === 'string' &&
(o.role === 'provider' || o.role === 'consumer') &&
typeof o.symbolUid === 'string' &&
typeof o.symbolName === 'string' &&
typeof o.confidence === 'number' &&
o.meta !== undefined &&
typeof o.meta === 'object' &&
o.meta !== null &&
o.symbolRef !== undefined &&
typeof o.symbolRef === 'object' &&
o.symbolRef !== null &&
typeof (o.symbolRef as Record<string, unknown>).filePath === 'string' &&
typeof (o.symbolRef as Record<string, unknown>).name === 'string'
);
}
function isCrossLink(raw: unknown): raw is CrossLink {
if (!raw || typeof raw !== 'object') return false;
const o = raw as Record<string, unknown>;
const from = o.from as Record<string, unknown> | undefined;
const to = o.to as Record<string, unknown> | undefined;
if (!from || !to) return false;
if (typeof from.repo !== 'string' || typeof to.repo !== 'string') return false;
return typeof o.contractId === 'string' && typeof o.type === 'string';
}
async function loadContractRegistryResilient(
groupDir: string,
): Promise<
| { ok: true; registry: ContractRegistry; skippedCorrupt: number }
| { ok: false; error: string }
> {
const filePath = path.join(groupDir, 'contracts.json');
let raw: string;
try {
raw = await fsp.readFile(filePath, 'utf-8');
} catch (e) {
if ((e as NodeJS.ErrnoException).code === 'ENOENT') {
return { ok: false, error: `No contracts.json for this group. Run group_sync first.` };
}
return { ok: false, error: e instanceof Error ? e.message : String(e) };
}
let root: unknown;
try {
root = JSON.parse(raw);
} catch {
return { ok: false, error: 'contracts.json is not valid JSON' };
}
if (!root || typeof root !== 'object' || Array.isArray(root)) {
return { ok: false, error: 'contracts.json has an invalid root object' };
}
const base = root as Record<string, unknown>;
const contractsRaw = base.contracts;
const crossRaw = base.crossLinks;
let skippedCorrupt = 0;
const contracts: StoredContract[] = [];
if (Array.isArray(contractsRaw)) {
for (const row of contractsRaw) {
try {
if (isStoredContract(row)) {
contracts.push(row);
} else {
skippedCorrupt++;
console.warn('[group] skipping corrupt contract row in contracts.json');
}
} catch {
skippedCorrupt++;
console.warn('[group] skipping corrupt contract row in contracts.json');
}
}
}
const crossLinks: CrossLink[] = [];
if (Array.isArray(crossRaw)) {
for (const row of crossRaw) {
try {
if (isCrossLink(row)) {
crossLinks.push(row);
} else {
skippedCorrupt++;
console.warn('[group] skipping corrupt crossLinks row in contracts.json');
}
} catch {
skippedCorrupt++;
console.warn('[group] skipping corrupt crossLinks row in contracts.json');
}
}
}
const registry: ContractRegistry = {
version: typeof base.version === 'number' ? base.version : 0,
generatedAt: typeof base.generatedAt === 'string' ? base.generatedAt : '',
repoSnapshots:
base.repoSnapshots && typeof base.repoSnapshots === 'object' && base.repoSnapshots !== null
? (base.repoSnapshots as Record<string, { indexedAt: string; lastCommit: string }>)
: {},
missingRepos: Array.isArray(base.missingRepos)
? (base.missingRepos as string[])
: [],
contracts,
crossLinks,
};
return { ok: true, registry, skippedCorrupt };
}
export class GroupService {
constructor(private readonly port: GroupToolPort) {}
@ -103,10 +244,14 @@ 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 loaded = await loadContractRegistryResilient(groupDir);
if (!loaded.ok) {
if (loaded.error.includes('No contracts.json')) {
return { error: `No contracts.json for group "${name}". Run group_sync first.` };
}
return { error: loaded.error };
}
const { registry, skippedCorrupt } = loaded;
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);
@ -119,7 +264,84 @@ export class GroupService {
);
contracts = contracts.filter((c) => !matchedIds.has(`${c.repo}::${c.contractId}`));
}
return { contracts, crossLinks: registry.crossLinks };
const out: Record<string, unknown> = { contracts, crossLinks: registry.crossLinks };
if (skippedCorrupt > 0) out.skippedCorrupt = skippedCorrupt;
return out;
}
async groupImpact(params: Record<string, unknown>): Promise<unknown> {
const { runGroupImpact } = await import('./cross-impact.js');
return runGroupImpact({ port: this.port, gitnexusDir: getDefaultGitnexusDir() }, params);
}
async groupContext(params: Record<string, unknown>): Promise<GroupContextResult> {
const name = String(params.name ?? '').trim();
const target = typeof params.target === 'string' ? params.target.trim() : '';
const uid = typeof params.uid === 'string' ? params.uid.trim() : undefined;
const file_path = typeof params.file_path === 'string' ? params.file_path : undefined;
const include_content = Boolean(params.include_content);
const servicePrefix = normalizeServicePrefix(params.service);
const subgroup = typeof params.subgroup === 'string' ? params.subgroup : undefined;
if (!name) {
return { group: '', error: 'name is required', results: [] };
}
if (!uid && !target) {
return { group: name, error: 'target or uid is required', results: [] };
}
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
let config: GroupConfig;
try {
config = await loadGroupConfig(groupDir);
} catch (e) {
return {
group: name,
target: target || uid,
service: servicePrefix,
error: e instanceof Error ? e.message : String(e),
results: [],
};
}
const results: GroupContextResult['results'] = [];
for (const [repoPath, registryName] of Object.entries(config.repos)) {
if (!repoInSubgroup(repoPath, subgroup)) continue;
try {
const repoObj = await this.port.resolveRepo(registryName);
const payload = await this.port.context(repoObj, {
name: target || undefined,
uid,
file_path,
include_content,
});
if (servicePrefix) {
const st = (payload as { status?: string })?.status;
const sym = (payload as { symbol?: { filePath?: string } })?.symbol;
if (st === 'found' && !fileMatchesServicePrefix(sym?.filePath, servicePrefix)) {
results.push({ repoPath, registryName, payload: {} });
continue;
}
}
results.push({ repoPath, registryName, payload });
} catch (e) {
results.push({
repoPath,
registryName,
payload: { error: e instanceof Error ? e.message : String(e) },
});
}
}
return {
group: name,
target: target || uid,
service: servicePrefix,
results,
};
}
async groupQuery(params: Record<string, unknown>): Promise<unknown> {

View file

@ -96,6 +96,9 @@ export interface RepoHandle {
storagePath: string;
}
/** Why local impact or fan-out stopped early (e.g. wall-clock budget exhausted). */
export type GroupImpactTruncationReason = 'timeout' | 'partial';
export interface GroupImpactResult {
local: unknown;
group: string;
@ -110,6 +113,36 @@ export interface GroupImpactResult {
cross_repo_hits: number;
};
risk: string;
/**
* Milliseconds budget applied to the **Phase 1 local impact** leg (`safeLocalImpact`).
* If the walk hits this wall first, expect `truncationReason: 'timeout'` and a partial `local` payload.
*/
timeoutMs?: number;
/** Present when local impact or fan-out stopped early (timeout, graph cap, etc.). */
truncationReason?: GroupImpactTruncationReason;
/**
* Human-readable note when `crossDepth` was clamped (e.g. multi-hop not implemented yet).
*/
crossDepthWarning?: string;
}
/** One repo’s `context` tool payload in a group-scoped context run. */
export interface GroupContextRepoEntry {
repoPath: string;
registryName: string;
payload: unknown;
}
/**
* Aggregated group `context`: explicit per-repo rows (no merged symbol payloads).
* Use top-level `error` only for unrecoverable failures, not for “no matches” or service scope misses.
*/
export interface GroupContextResult {
group: string;
target?: string;
service?: string;
error?: string;
results: GroupContextRepoEntry[];
}
export interface CrossRepoImpact {

View file

@ -193,6 +193,7 @@ export class LocalBackend {
impact: (r, p) => this.impact(r as RepoHandle, p),
query: (r, p) => this.query(r as RepoHandle, p),
impactByUid: (id, uid, d, o) => this.impactByUid(id, uid, d, o),
context: (r, p) => this.context(r as RepoHandle, p),
};
this.groupToolSvc = new GroupService(port);
}

View file

@ -0,0 +1,191 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import {
validateGroupImpactParams,
runGroupImpact,
MAX_SUPPORTED_CROSS_DEPTH,
DEFAULT_LOCAL_IMPACT_TIMEOUT_MS,
collectImpactSymbolUids,
fileMatchesServicePrefix,
} from '../../../src/core/group/cross-impact.js';
import type { GroupToolPort } from '../../../src/core/group/service.js';
import { writeBridgeMeta } from '../../../src/core/group/bridge-db.js';
import { BRIDGE_SCHEMA_VERSION } from '../../../src/core/group/bridge-schema.js';
function tmpGroup(): { tmpDir: string; groupDir: string; cleanup: () => void } {
const tmpDir = path.join(os.tmpdir(), `gitnexus-ci-${Date.now()}-${Math.random()}`);
const groupDir = path.join(tmpDir, 'groups', 'g1');
fs.mkdirSync(groupDir, { recursive: true });
fs.writeFileSync(
path.join(groupDir, 'group.yaml'),
`version: 1
name: g1
description: ""
repos:
app/backend: reg-be
app/frontend: reg-fe
links: []
packages: {}
detect:
http: true
grpc: true
topics: true
shared_libs: true
embedding_fallback: true
matching:
bm25_threshold: 0.7
embedding_threshold: 0.65
max_candidates_per_step: 3
`,
);
return {
tmpDir,
groupDir,
cleanup: () => fs.rmSync(tmpDir, { recursive: true, force: true }),
};
}
describe('cross-impact', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('test_validateGroupImpactParams_rejects_bad_direction', () => {
const r = validateGroupImpactParams({ name: 'g', repo: 'a', target: 't', direction: 'sideways' });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toContain('direction');
});
it('test_validateGroupImpactParams_clamps_crossDepth_and_warns', () => {
const r = validateGroupImpactParams({
name: 'g',
repo: 'a',
target: 't',
direction: 'upstream',
crossDepth: 99,
});
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.crossDepth).toBe(MAX_SUPPORTED_CROSS_DEPTH);
expect(r.crossDepthWarning).toBeDefined();
}
});
it('test_validateGroupImpactParams_default_timeout', () => {
const r = validateGroupImpactParams({
name: 'g',
repo: 'a',
target: 't',
direction: 'downstream',
});
expect(r.ok).toBe(true);
if (r.ok) expect(r.timeoutMs).toBe(DEFAULT_LOCAL_IMPACT_TIMEOUT_MS);
});
it('test_collectImpactSymbolUids_respects_service_prefix', () => {
const local = {
target: { id: 'a', filePath: 'services/auth/x.ts' },
byDepth: {
1: [{ id: 'b', filePath: 'other/y.ts' }],
},
};
const uids = collectImpactSymbolUids(local, 'services/auth').uids;
expect(uids).toContain('a');
expect(uids).not.toContain('b');
});
it('test_fileMatchesServicePrefix', () => {
expect(fileMatchesServicePrefix('services/auth/a.ts', 'services/auth')).toBe(true);
expect(fileMatchesServicePrefix('services/aut', 'services/auth')).toBe(false);
});
it('test_runGroupImpact_local_timeout_returns_truncation', async () => {
const { tmpDir, cleanup } = tmpGroup();
vi.stubEnv('GITNEXUS_HOME', tmpDir);
try {
let impactCalls = 0;
const port: GroupToolPort = {
resolveRepo: vi.fn(async () => ({
id: 'be',
name: 'reg-be',
repoPath: '/r',
storagePath: '/r/.gitnexus',
})),
impact: vi.fn(async () => {
impactCalls++;
await new Promise((r) => setTimeout(r, 200));
return { summary: { direct: 1 }, byDepth: { 1: [{ id: 'x' }] } };
}),
query: vi.fn(),
impactByUid: vi.fn(),
context: vi.fn(),
};
const r = await runGroupImpact(
{ port, gitnexusDir: tmpDir },
{
name: 'g1',
repo: 'app/backend',
target: 'Sym',
direction: 'upstream',
timeoutMs: 15,
},
);
expect(impactCalls).toBe(1);
expect('error' in r).toBe(false);
if (!('error' in r)) {
expect(r.truncationReason).toBe('timeout');
expect(r.truncated).toBe(true);
}
} finally {
vi.unstubAllEnvs();
cleanup();
}
});
it('test_runGroupImpact_bridge_schema_mismatch_returns_error', async () => {
const { tmpDir, groupDir, cleanup } = tmpGroup();
vi.stubEnv('GITNEXUS_HOME', tmpDir);
await writeBridgeMeta(groupDir, {
version: BRIDGE_SCHEMA_VERSION + 9,
generatedAt: new Date().toISOString(),
missingRepos: [],
});
try {
const port: GroupToolPort = {
resolveRepo: vi.fn(async () => ({
id: 'be',
name: 'reg-be',
repoPath: '/r',
storagePath: '/r/.gitnexus',
})),
impact: vi.fn(async () => ({
target: { id: 'u1', filePath: 'src/a.ts' },
summary: { direct: 1, processes_affected: 0, modules_affected: 0 },
byDepth: { 1: [{ id: 'u1', filePath: 'src/a.ts' }] },
risk: 'LOW',
})),
query: vi.fn(),
impactByUid: vi.fn(),
context: vi.fn(),
};
const r = await runGroupImpact(
{ port, gitnexusDir: tmpDir },
{
name: 'g1',
repo: 'app/backend',
target: 'Sym',
direction: 'upstream',
},
);
expect('error' in r).toBe(true);
if ('error' in r) {
expect(r.error).toContain('schema');
}
} finally {
vi.unstubAllEnvs();
cleanup();
}
});
});

View file

@ -44,6 +44,10 @@ function makePort(overrides: Partial<GroupToolPort> = {}): GroupToolPort {
impact: vi.fn(async () => ({ symbols: [] })),
query: vi.fn(async () => ({ processes: [] })),
impactByUid: vi.fn(async () => null),
context: vi.fn(async () => ({
status: 'found',
symbol: { filePath: 'services/auth/x.ts', uid: 'u1', name: 'X' },
})),
...overrides,
};
}
@ -233,6 +237,46 @@ describe('GroupService', () => {
cleanup();
}
});
it('test_groupContracts_skips_corrupt_contract_rows', async () => {
const { groupDir, cleanup, tmpDir } = makeTmpGroup();
try {
vi.stubEnv('GITNEXUS_HOME', tmpDir);
const badJson = `{
"version": 1,
"generatedAt": "2026-01-01T00:00:00.000Z",
"repoSnapshots": {},
"missingRepos": [],
"contracts": [
{ "not": "a-contract" },
{
"contractId": "http::GET::/ok",
"type": "http",
"repo": "app/backend",
"role": "provider",
"symbolUid": "u",
"symbolRef": { "filePath": "a.ts", "name": "f" },
"symbolName": "f",
"confidence": 1,
"meta": {}
}
],
"crossLinks": []
}`;
fs.writeFileSync(path.join(groupDir, 'contracts.json'), badJson, 'utf-8');
const svc = new GroupService(makePort());
const result = (await svc.groupContracts({ name: 'test-group' })) as {
contracts: unknown[];
skippedCorrupt?: number;
};
expect(result.contracts).toHaveLength(1);
expect(result.skippedCorrupt).toBe(1);
} finally {
vi.unstubAllEnvs();
cleanup();
}
});
});
describe('groupSync', () => {
@ -330,6 +374,61 @@ describe('GroupService', () => {
});
});
describe('groupImpact', () => {
it('test_groupImpact_returns_validation_error', async () => {
const svc = new GroupService(makePort());
const r = (await svc.groupImpact({})) as { error: string };
expect(r.error).toContain('name');
});
});
describe('groupContext', () => {
it('test_groupContext_requires_target_or_uid', async () => {
const svc = new GroupService(makePort());
const r = await svc.groupContext({ name: 'test-group' });
expect(r.error).toContain('target');
});
it('test_groupContext_iterates_repos', async () => {
const { cleanup, tmpDir } = makeTmpGroup();
try {
vi.stubEnv('GITNEXUS_HOME', tmpDir);
const port = makePort();
const svc = new GroupService(port);
const r = await svc.groupContext({ name: 'test-group', target: 'MySym' });
expect(r.group).toBe('test-group');
expect(r.results).toHaveLength(2);
expect(port.context).toHaveBeenCalledTimes(2);
} finally {
vi.unstubAllEnvs();
cleanup();
}
});
it('test_groupContext_service_prefix_filters_payload', async () => {
const { cleanup, tmpDir } = makeTmpGroup();
try {
vi.stubEnv('GITNEXUS_HOME', tmpDir);
const port = makePort({
context: vi.fn(async () => ({
status: 'found',
symbol: { filePath: 'other/path/x.ts', uid: 'u1', name: 'X' },
})),
});
const svc = new GroupService(port);
const r = await svc.groupContext({
name: 'test-group',
target: 'MySym',
service: 'services/auth',
});
expect(r.results.every((x) => Object.keys(x.payload as object).length === 0)).toBe(true);
} finally {
vi.unstubAllEnvs();
cleanup();
}
});
});
describe('groupStatus', () => {
it('test_groupStatus_returns_error_when_name_empty', async () => {
const svc = new GroupService(makePort());