mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
* 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>
662 lines
20 KiB
TypeScript
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 });
|
|
}
|
|
});
|
|
});
|