mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-23 00:41:36 +00:00
When the Phase 1 local-impact leg returned a structured { error: ... }
payload (missing symbol, graph-load failure, or an exception wrapped by
safeLocalImpact), runGroupImpact previously buried it inside a zero-hit
GroupImpactResult with empty cross / outOfScope arrays and risk 'UNKNOWN'.
Callers branch on top-level `error` (CLI, MCP wrapper), so the failure
path surfaced as a silent "no impact across the group" — a false
negative on a safety-critical blast-radius tool.
Fail closed: bubble the error as a top-level { error } prefixed with the
repoPath, matching how runGroupImpact already handles resolveGroupRepo,
config-load, and bridgePrep failures. Chose option 1 (bubble the error)
over option 2 (partial-result discriminant) because runGroupImpact only
runs local impact for a single member repo at this point — cross-repo
fan-out happens later via the bridge, so there is no partial success
data to preserve on the local-phase failure path.
Added two regression tests covering both the port-returned { error }
case and the thrown-exception case (wrapped by safeLocalImpact).
Made-with: Cursor
282 lines
8.3 KiB
TypeScript
282 lines
8.3 KiB
TypeScript
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_local_phase_error_bubbles_as_top_level_error', async () => {
|
|
// Regression for #1004: when the local-impact phase returns a structured
|
|
// `{ error: ... }` payload, groupImpact MUST surface it as a top-level
|
|
// `{ error }` instead of a zero-hit GroupImpactResult. Otherwise callers
|
|
// that branch on top-level `error` silently treat a failed analysis as
|
|
// "no impact across the group" — a false negative on the failure path
|
|
// of a blast-radius tool.
|
|
const { tmpDir, cleanup } = tmpGroup();
|
|
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
|
try {
|
|
const port: GroupToolPort = {
|
|
resolveRepo: vi.fn(async () => ({
|
|
id: 'be',
|
|
name: 'reg-be',
|
|
repoPath: '/r',
|
|
storagePath: '/r/.gitnexus',
|
|
})),
|
|
impact: vi.fn(async () => ({ error: 'symbol not found: Sym' })),
|
|
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('symbol not found: Sym');
|
|
expect(r.error).toContain('app/backend');
|
|
}
|
|
// And ensure we didn't silently fall back to a zero-hit success payload.
|
|
expect((r as { summary?: unknown }).summary).toBeUndefined();
|
|
expect((r as { cross?: unknown }).cross).toBeUndefined();
|
|
} finally {
|
|
vi.unstubAllEnvs();
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
it('test_runGroupImpact_local_phase_thrown_exception_bubbles_as_top_level_error', async () => {
|
|
// Companion to the #1004 regression: safeLocalImpact wraps thrown
|
|
// exceptions from port.impact() as `{ error }` payloads. Those must
|
|
// bubble to the caller as top-level errors too, not be swallowed into
|
|
// an empty success payload.
|
|
const { tmpDir, cleanup } = tmpGroup();
|
|
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
|
try {
|
|
const port: GroupToolPort = {
|
|
resolveRepo: vi.fn(async () => ({
|
|
id: 'be',
|
|
name: 'reg-be',
|
|
repoPath: '/r',
|
|
storagePath: '/r/.gitnexus',
|
|
})),
|
|
impact: vi.fn(async () => {
|
|
throw new Error('graph-load failure: .gitnexus missing');
|
|
}),
|
|
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('graph-load failure');
|
|
}
|
|
} 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();
|
|
}
|
|
});
|
|
});
|