GitNexus/gitnexus/test/unit/group/cross-impact.test.ts
Gergő Magyar 66b44afe8c
fix(group): make degraded links, sync warnings and UID-only impact actually work (#3113)
* feat(group-surface): impact selector pass-through + degraded links + sync hygiene

- @group impact forwards target_uid/file_path/kind through service port
  and cross-impact impactParams (was dead-wired: params accepted at MCP
  boundary then dropped at validation).
- crossLinks with unresolved provider symbols carry degraded: true,
  derived at the persistence boundary after merge/dedupe; sync reports
  'degraded links: N' and per-repo extraction failures instead of
  swallowing them; bridge write failures surface as sync warnings;
  contracts.json passes through dedupeContracts.
- Absolute-URL branch restores %7B/%7D around {param} after URL parsing.
- tests: consumer matrix + wildcard folding + degraded pins (261 new);
  SCHEMA_BUMP pin 47 -> 48 (wildcardImports cache shape); sync.ts NUL
  byte rewritten as text escape (no longer binary to git).

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(group): impact selector pass-through, degraded links, sync failure hygiene

- @group impact forwards target_uid/file_path/kind through the service
  port into cross-impact impactParams. These were accepted at the MCP
  boundary and then dropped in validation — a dead wire: disambiguating
  an ambiguous impact target never actually reached the per-member impact.
- Cross-links whose provider endpoint never resolves to a graph symbol are
  marked degraded: true at the single persistence boundary (post
  merge/dedupe, before re-export), counted as SyncResult.degradedLinks,
  and surfaced by the sync summary ('degraded links: N') — the remedy
  (re-analyze the provider repo) is documented on the field.
- Sync failure hygiene: a repo whose per-repo extraction throws records
  its reason in SyncResult.failedRepos (still lands in missingRepos, so
  downstream semantics are unchanged) instead of the old silent swallow
  that could persist half a repo's contracts; operator warnings
  accumulate in SyncResult.warnings.

Tests: cross-impact selector threading, degraded-link marking, per-repo
failure reporting.

* style: prettier

* fix(group): make degraded links, sync warnings and UID-only impact actually work

The three fixes this branch claims were wired at the type and payload level
but never at the boundary that produces the values:

- `degraded` was only ever cleared by the exported `dedupeCrossLinks`, which
  the sync path does not use, so `degradedLinks` was always 0. Derivation now
  lives in one exported `applyDegradedFlag` that both the sync finalize and
  the post-merge re-derivation call.
- The bridge-write catch logged an operator warning and dropped it, leaving
  `warnings` permanently `[]`.
- `@group impact` rejected a UID-only call before it parsed `target_uid`, so
  the documented "re-call with target_uid" disambiguation loop was
  unreachable in group mode even though the selectors were forwarded.
- `failedRepos[].repo` reported the registry display name while the repo
  landed in `unreadableRepos` under its group path, so the two lists could
  not be joined; the JSDoc also pointed at the wrong list.
- Restored the truncated `READ THE RESULT:` heading in the group_sync tool
  description and documented degradedLinks / failedRepos / warnings.

Tests pin each value at the boundary that produces it, including the exact
group_sync wire shape, which previously omitted all three new fields.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: l.cx <l.cx@winning.com.cn>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: ChunxueLi <mecoloud@users.noreply.gitee.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 20:03:53 +00:00

662 lines
20 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_validateGroupImpactParams_accepts_target_uid_without_name', () => {
const r = validateGroupImpactParams({
name: 'g',
repo: 'a',
target_uid: 'sym::uid::1',
direction: 'upstream',
});
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.target).toBe('sym::uid::1');
expect(r.target_uid).toBe('sym::uid::1');
}
});
it('test_validateGroupImpactParams_rejects_when_neither_target_nor_uid', () => {
const r = validateGroupImpactParams({
name: 'g',
repo: 'a',
direction: 'upstream',
});
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toBe('target or target_uid is required');
});
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(r).toMatchObject({
truncated: true,
truncationReason: 'timeout',
// The marker travels with `truncated` on EVERY return path, not just
// the terminal one — see the invariant table below (#2787).
riskEpistemic: 'lower-bound',
});
} 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();
}
});
/**
* `truncated: true` ⇒ `riskEpistemic: 'lower-bound'`, on every return path.
*
* `riskEpistemic` is the only field that tells a consumer the `risk` value is
* a floor rather than a verdict, and the MCP tool description now instructs
* callers to key on it. Two of `runGroupImpact`'s four returns used to set
* `truncated` without it — and the `uids.length === 0` path returns a REAL
* risk (not `UNKNOWN`), so a truncated analysis read as a complete `HIGH`.
*
* Every case below returns before the bridge is opened, so no bridge mock is
* involved; the local port shape alone decides which return is taken.
*/
const runLocalOnlyImpact = async (
impact: GroupToolPort['impact'],
params: Record<string, unknown> = {},
): Promise<unknown> => {
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,
query: vi.fn(),
impactByUid: vi.fn(),
context: vi.fn(),
};
return await runGroupImpact(
{ port, gitnexusDir: tmpDir },
{
name: 'g1',
repo: 'app/backend',
target: 'Sym',
direction: 'upstream',
...params,
},
);
} finally {
vi.unstubAllEnvs();
cleanup();
}
};
type LocalReturnCase = {
name: string;
impact: GroupToolPort['impact'];
params: Record<string, unknown>;
expected: Record<string, unknown>;
};
const truncatedReturns: LocalReturnCase[] = [
{
name: 'the local walk times out',
// Resolves later than the (clamped) budget, so the timeout arm of the
// race wins on any host — the assertion is on the returned shape, never
// on how long it took.
impact: async () => {
await new Promise((r) => setTimeout(r, 200));
return { summary: { direct: 1 }, byDepth: { 1: [{ id: 'x' }] } };
},
params: { timeoutMs: 15 },
expected: {
truncated: true,
truncationReason: 'timeout',
riskEpistemic: 'lower-bound',
risk: 'UNKNOWN',
},
},
{
name: 'a partial local walk yields no symbol uids',
impact: async () => ({
partial: true,
byDepth: {},
summary: { direct: 4, processes_affected: 2, modules_affected: 1 },
risk: 'HIGH',
}),
params: {},
expected: {
truncated: true,
truncationReason: 'partial',
riskEpistemic: 'lower-bound',
// A real risk, not UNKNOWN: without the marker this reads as a
// complete HIGH verdict on a walk that never finished.
risk: 'HIGH',
summary: { direct: 4, processes_affected: 2, modules_affected: 1 },
},
},
];
const completeReturns: LocalReturnCase[] = [
{
name: 'a complete local walk yields no symbol uids',
impact: async () => ({
byDepth: {},
summary: { direct: 0, processes_affected: 0, modules_affected: 0 },
risk: 'HIGH',
}),
params: {},
expected: { truncated: false, risk: 'HIGH' },
},
{
name: 'the target sits outside the requested service prefix',
impact: async () => ({
target: { id: 'u1', filePath: 'src/other.ts' },
byDepth: {},
summary: { direct: 1, processes_affected: 0, modules_affected: 0 },
risk: 'HIGH',
}),
params: { service: 'services/auth' },
expected: { truncated: false, risk: 'LOW' },
},
];
it.each(truncatedReturns)(
'marks risk as a lower bound when $name',
async ({ impact, params, expected }) => {
const r = await runLocalOnlyImpact(impact, params);
expect(r).toMatchObject(expected);
},
);
it.each(completeReturns)('claims no floor when $name', async ({ impact, params, expected }) => {
const r = await runLocalOnlyImpact(impact, params);
expect(r).toMatchObject(expected);
expect(r).not.toHaveProperty('riskEpistemic');
expect(r).not.toHaveProperty('truncationReason');
});
it('lifts local riskSharedAxes and riskScale when there are no symbol uids to fan out', async () => {
const riskScale = {
comparableAcrossKinds: false,
unusedAxes: [
{
axis: 'processes' as const,
reason: 'file-nodes-have-no-process-or-community-membership',
},
{
axis: 'modules',
reason: 'file-nodes-have-no-process-or-community-membership',
},
],
};
const r = await runLocalOnlyImpact(async () => ({
byDepth: {},
summary: { direct: 13, processes_affected: 0, modules_affected: 0 },
risk: 'MEDIUM',
riskSharedAxes: 'MEDIUM',
riskScale,
}));
expect(r).toMatchObject({
truncated: false,
risk: 'MEDIUM',
riskSharedAxes: 'MEDIUM',
riskScale,
});
});
it('test_runGroupImpact_threads_target_selectors_to_member_impact', async () => {
// The target_uid disambiguation loop is a three-segment chain: the MCP
// boundary forwards target_uid/file_path/kind, the GroupToolPort.impact
// contract declares them, and THIS module must thread them into the
// member-repo call. The first wiring attempt only touched the boundary
// and the port type — validateGroupImpactParams dropped the params, so
// port.impact never saw them and the loop silently did not work. The
// spy pins the full chain end to end.
const impact = vi.fn(async () => ({
byDepth: {},
summary: { direct: 0, processes_affected: 0, modules_affected: 0 },
risk: 'LOW',
}));
const r = await runLocalOnlyImpact(impact, {
target_uid: 'sym::uid::1',
file_path: 'src/service/UserApi.ts',
kind: 'Class',
});
expect(r).toMatchObject({ truncated: false });
expect(impact).toHaveBeenCalledTimes(1);
expect(impact).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
target: 'Sym',
target_uid: 'sym::uid::1',
file_path: 'src/service/UserApi.ts',
kind: 'Class',
}),
);
});
it('test_runGroupImpact_omits_target_selectors_when_absent', async () => {
// Companion negative: a plain name-only call must not smuggle empty
// selector strings through (the port treats '' as a real lookup key).
const impact = vi.fn(async () => ({
byDepth: {},
summary: { direct: 0, processes_affected: 0, modules_affected: 0 },
risk: 'LOW',
}));
await runLocalOnlyImpact(impact, { target_uid: '' });
expect(impact).toHaveBeenCalledTimes(1);
const passed = (impact as unknown as { mock: { calls: unknown[][] } }).mock
.calls[0][1] as Record<string, unknown>;
expect(passed.target_uid).toBeUndefined();
expect(passed.file_path).toBeUndefined();
expect(passed.kind).toBeUndefined();
});
it('test_runGroupImpact_accepts_target_uid_without_name', async () => {
const impact = vi.fn(async () => ({
byDepth: {},
summary: { direct: 0, processes_affected: 0, modules_affected: 0 },
risk: 'LOW',
}));
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,
query: vi.fn(),
impactByUid: vi.fn(),
context: vi.fn(),
};
const r = await runGroupImpact(
{ port, gitnexusDir: tmpDir },
{
name: 'g1',
repo: 'app/backend',
target_uid: 'sym::uid::1',
direction: 'upstream',
},
);
expect(r).toMatchObject({ truncated: false });
expect(impact).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
target: 'sym::uid::1',
target_uid: 'sym::uid::1',
}),
);
} 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();
}
});
it('hints the yaml member path when --repo is the registry alias', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-ci-alias-'));
const groupDir = path.join(tmpDir, 'groups', 'g1');
fs.mkdirSync(groupDir, { recursive: true });
fs.writeFileSync(
path.join(groupDir, 'group.yaml'),
`version: 1
name: g1
repos:
demo/api: demo-api
demo/web: demo-web
`,
);
vi.stubEnv('GITNEXUS_HOME', tmpDir);
try {
const port: GroupToolPort = {
resolveRepo: vi.fn(),
impact: vi.fn(),
query: vi.fn(),
impactByUid: vi.fn(),
context: vi.fn(),
};
const r = await runGroupImpact(
{ port, gitnexusDir: tmpDir },
{
name: 'g1',
repo: 'demo-api',
target: 'Sym',
direction: 'upstream',
},
);
expect('error' in r).toBe(true);
if ('error' in r) {
expect(r.error).toContain('demo/api');
expect(r.error).toMatch(/registry alias/i);
expect(r.error).not.toContain('demo/web');
}
const mixedCase = await runGroupImpact(
{ port, gitnexusDir: tmpDir },
{
name: 'g1',
repo: 'Demo-API',
target: 'Sym',
direction: 'upstream',
},
);
expect('error' in mixedCase).toBe(true);
if ('error' in mixedCase) {
expect(mixedCase.error).toContain('demo/api');
}
} finally {
vi.unstubAllEnvs();
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('lists every member path that shares the same registry alias', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-ci-alias-dup-'));
const groupDir = path.join(tmpDir, 'groups', 'g1');
fs.mkdirSync(groupDir, { recursive: true });
fs.writeFileSync(
path.join(groupDir, 'group.yaml'),
`version: 1
name: g1
repos:
demo/api: shared
demo/other: shared
`,
);
vi.stubEnv('GITNEXUS_HOME', tmpDir);
try {
const port: GroupToolPort = {
resolveRepo: vi.fn(),
impact: vi.fn(),
query: vi.fn(),
impactByUid: vi.fn(),
context: vi.fn(),
};
const r = await runGroupImpact(
{ port, gitnexusDir: tmpDir },
{
name: 'g1',
repo: 'shared',
target: 'Sym',
direction: 'upstream',
},
);
expect('error' in r).toBe(true);
if ('error' in r) {
expect(r.error).toContain('demo/api');
expect(r.error).toContain('demo/other');
}
} finally {
vi.unstubAllEnvs();
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
});