mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-04 02:31:36 +00:00
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:
parent
2667c3cd72
commit
82f395dd4c
7 changed files with 1129 additions and 5 deletions
559
gitnexus/src/core/group/cross-impact.ts
Normal file
559
gitnexus/src/core/group/cross-impact.ts
Normal 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';
|
||||
19
gitnexus/src/core/group/group-path-utils.ts
Normal file
19
gitnexus/src/core/group/group-path-utils.ts
Normal 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}/`);
|
||||
}
|
||||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
191
gitnexus/test/unit/group/cross-impact.test.ts
Normal file
191
gitnexus/test/unit/group/cross-impact.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue