diff --git a/gitnexus/src/core/group/cross-impact.ts b/gitnexus/src/core/group/cross-impact.ts new file mode 100644 index 000000000..495c299ea --- /dev/null +++ b/gitnexus/src/core/group/cross-impact.ts @@ -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): { + 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 { + 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[1], + timeoutMs: number, +): Promise<{ value: unknown; timedOut: boolean }> { + let timer: ReturnType | 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(); + let targetFilePath: string | undefined; + const obj = local as Record | 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 | 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): 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, +): Promise { + 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[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; + 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 | 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 })?.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>(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(); + + 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, + affected_processes: extractProcessNames(fan), + }); + } + } finally { + await closeBridgeDb(handle); + } + + const localSum = (local as { summary?: Record })?.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'; diff --git a/gitnexus/src/core/group/group-path-utils.ts b/gitnexus/src/core/group/group-path-utils.ts new file mode 100644 index 000000000..e41163d64 --- /dev/null +++ b/gitnexus/src/core/group/group-path-utils.ts @@ -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}/`); +} diff --git a/gitnexus/src/core/group/service.ts b/gitnexus/src/core/group/service.ts index 1530cd6dd..b72d2bdaa 100644 --- a/gitnexus/src/core/group/service.ts +++ b/gitnexus/src/core/group/service.ts @@ -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; + context( + repo: GroupRepoHandle, + params: { + name?: string; + uid?: string; + file_path?: string; + include_content?: boolean; + }, + ): Promise; } 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; + 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).filePath === 'string' && + typeof (o.symbolRef as Record).name === 'string' + ); +} + +function isCrossLink(raw: unknown): raw is CrossLink { + if (!raw || typeof raw !== 'object') return false; + const o = raw as Record; + const from = o.from as Record | undefined; + const to = o.to as Record | 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; + 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) + : {}, + 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 = { contracts, crossLinks: registry.crossLinks }; + if (skippedCorrupt > 0) out.skippedCorrupt = skippedCorrupt; + return out; + } + + async groupImpact(params: Record): Promise { + const { runGroupImpact } = await import('./cross-impact.js'); + return runGroupImpact({ port: this.port, gitnexusDir: getDefaultGitnexusDir() }, params); + } + + async groupContext(params: Record): Promise { + 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): Promise { diff --git a/gitnexus/src/core/group/types.ts b/gitnexus/src/core/group/types.ts index b9ba97582..793d3d0ad 100644 --- a/gitnexus/src/core/group/types.ts +++ b/gitnexus/src/core/group/types.ts @@ -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 { diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 041bd27eb..2a86a88a9 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -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); } diff --git a/gitnexus/test/unit/group/cross-impact.test.ts b/gitnexus/test/unit/group/cross-impact.test.ts new file mode 100644 index 000000000..869130357 --- /dev/null +++ b/gitnexus/test/unit/group/cross-impact.test.ts @@ -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(); + } + }); +}); diff --git a/gitnexus/test/unit/group/service.test.ts b/gitnexus/test/unit/group/service.test.ts index e4b10443c..d0415cb6a 100644 --- a/gitnexus/test/unit/group/service.test.ts +++ b/gitnexus/test/unit/group/service.test.ts @@ -44,6 +44,10 @@ function makePort(overrides: Partial = {}): 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());