GitNexus/gitnexus/test/unit/impact-risk.test.ts
Gergő Magyar 54f97c86c7
fix(impact): make File risk comparable via shared axes (#3075) (#3082)
* docs(plans): add impact file risk plan

Capture the evidence, constraints, and verification path for fixing incomparable File and symbol impact risk.

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

* refactor(impact): centralize risk scoring

Keep the existing thresholds in one shared scorer and expose a common-axis comparison for targets with unavailable enrichment axes.

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

* fix(impact): expose incomparable file risk scale

Mark File impact results when process and module axes are unavailable, and provide a common-axis score for honest cross-kind comparisons.

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

* fix(impact): explain cross-kind risk comparisons

Surface the common-axis score in CLI and agent guidance while reusing the shared threshold ladder in the web impact tool.

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

* fix(impact): fail closed when enrichment is incomplete

Preserve proved HIGH/CRITICAL process counts, treat failed queries as UNKNOWN, and surface riskScale metadata on MCP, group, CLI, and Graph-RAG File walks.

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

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 11:58:15 +00:00

237 lines
6.6 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
scoreImpactRisk,
unusedAxesForImpactWalk,
type ImpactRiskInput,
type UnusedImpactRiskAxis,
} from 'gitnexus-shared';
const fileUnusedAxes: readonly UnusedImpactRiskAxis[] = [
{
axis: 'processes',
reason: 'file-nodes-have-no-process-or-community-membership',
},
{
axis: 'modules',
reason: 'file-nodes-have-no-process-or-community-membership',
},
];
const base: ImpactRiskInput = {
direction: 'upstream',
directCount: 0,
processCount: 0,
moduleCount: 0,
impactedCount: 1,
};
describe('scoreImpactRisk', () => {
it('makes the issue #3075 File and Function scales explicit', () => {
const file = scoreImpactRisk({
...base,
directCount: 13,
impactedCount: 25,
unusedAxes: fileUnusedAxes,
});
const fn = scoreImpactRisk({
...base,
directCount: 2,
processCount: 4,
moduleCount: 2,
impactedCount: 15,
});
expect(file).toEqual({
risk: 'MEDIUM',
riskSharedAxes: 'MEDIUM',
riskScale: {
comparableAcrossKinds: false,
unusedAxes: fileUnusedAxes,
},
});
expect(fn).toEqual({
risk: 'HIGH',
riskSharedAxes: 'LOW',
riskScale: {
comparableAcrossKinds: true,
unusedAxes: [],
},
});
});
it('preserves UNKNOWN only for an empty upstream walk', () => {
expect(scoreImpactRisk({ ...base, impactedCount: 0 }).risk).toBe('UNKNOWN');
expect(scoreImpactRisk({ ...base, direction: 'downstream', impactedCount: 0 }).risk).toBe(
'LOW',
);
});
it('marks skipped enrichment as a non-comparable scale', () => {
const skippedAxes: readonly UnusedImpactRiskAxis[] = [
{ axis: 'processes', reason: 'enrichment-skipped' },
{ axis: 'modules', reason: 'enrichment-skipped' },
];
expect(scoreImpactRisk({ ...base, unusedAxes: skippedAxes }).riskScale).toEqual({
comparableAcrossKinds: false,
unusedAxes: skippedAxes,
});
});
it('preserves direct and total thresholds when enrichment axes are unused', () => {
expect(
scoreImpactRisk({
...base,
directCount: 30,
impactedCount: 30,
unusedAxes: fileUnusedAxes,
}).risk,
).toBe('CRITICAL');
expect(
scoreImpactRisk({
...base,
directCount: 15,
impactedCount: 15,
unusedAxes: fileUnusedAxes,
}).risk,
).toBe('HIGH');
expect(
scoreImpactRisk({
...base,
directCount: 2,
processCount: 10,
moduleCount: 10,
unusedAxes: fileUnusedAxes,
}).risk,
).toBe('LOW');
});
it('zeros unused process/module counts on the primary risk ladder', () => {
const skippedAxes: readonly UnusedImpactRiskAxis[] = [
{ axis: 'processes', reason: 'enrichment-skipped' },
{ axis: 'modules', reason: 'enrichment-skipped' },
];
const scored = scoreImpactRisk({
...base,
directCount: 2,
processCount: 4,
moduleCount: 4,
impactedCount: 15,
unusedAxes: skippedAxes,
});
expect(scored.risk).toBe('LOW');
expect(scored.riskSharedAxes).toBe('LOW');
});
it('preserves a warning already proved before a later enrichment failure', () => {
const scored = scoreImpactRisk({
...base,
directCount: 2,
processCount: 5,
impactedCount: 5,
unusedAxes: [{ axis: 'processes', reason: 'enrichment-query-failed' }],
});
expect(scored.risk).toBe('CRITICAL');
expect(scored.riskSharedAxes).toBe('LOW');
expect(scored.riskScale.comparableAcrossKinds).toBe(false);
});
it('fails closed when query failure leaves only a LOW or MEDIUM observed score', () => {
for (const directCount of [2, 6]) {
const scored = scoreImpactRisk({
...base,
direction: 'downstream',
directCount,
processCount: 0,
impactedCount: directCount,
unusedAxes: [{ axis: 'processes', reason: 'enrichment-query-failed' }],
});
expect(scored.risk).toBe('UNKNOWN');
}
});
it('fails closed when a truncated sample leaves only a LOW or MEDIUM observed score', () => {
const truncatedAxes: readonly UnusedImpactRiskAxis[] = [
{ axis: 'processes', reason: 'enrichment-truncated' },
{ axis: 'modules', reason: 'enrichment-truncated' },
];
const scored = scoreImpactRisk({
...base,
direction: 'downstream',
directCount: 2,
processCount: 1,
moduleCount: 1,
impactedCount: 8,
unusedAxes: truncatedAxes,
});
expect(scored.risk).toBe('UNKNOWN');
expect(scored.riskScale.comparableAcrossKinds).toBe(false);
});
});
describe('unusedAxesForImpactWalk', () => {
it('marks File, skipEnrichment, zero budget, and query failure distinctly', () => {
expect(
unusedAxesForImpactWalk({
isFileTarget: true,
skipEnrichment: false,
maxChunks: 10,
processQueryFailed: true,
moduleQueryFailed: true,
impactedCount: 1,
}).every((a) => a.reason === 'file-nodes-have-no-process-or-community-membership'),
).toBe(true);
expect(
unusedAxesForImpactWalk({
isFileTarget: false,
skipEnrichment: true,
maxChunks: 0,
processQueryFailed: false,
moduleQueryFailed: false,
impactedCount: 1,
}).map((a) => a.reason),
).toEqual(['enrichment-skipped', 'enrichment-skipped']);
expect(
unusedAxesForImpactWalk({
isFileTarget: false,
skipEnrichment: false,
maxChunks: 0,
processQueryFailed: false,
moduleQueryFailed: false,
impactedCount: 1,
}).map((a) => a.reason),
).toEqual(['enrichment-budget-exhausted', 'enrichment-budget-exhausted']);
expect(
unusedAxesForImpactWalk({
isFileTarget: false,
skipEnrichment: false,
maxChunks: 10,
processQueryFailed: true,
moduleQueryFailed: false,
impactedCount: 1,
}),
).toEqual([{ axis: 'processes', reason: 'enrichment-query-failed' }]);
expect(
unusedAxesForImpactWalk({
isFileTarget: false,
skipEnrichment: false,
maxChunks: 0,
processQueryFailed: false,
moduleQueryFailed: false,
impactedCount: 0,
}),
).toEqual([]);
expect(
unusedAxesForImpactWalk({
isFileTarget: false,
skipEnrichment: false,
maxChunks: 10,
processQueryFailed: false,
moduleQueryFailed: false,
impactedCount: 501,
enrichmentTruncated: true,
}).map((a) => a.reason),
).toEqual(['enrichment-truncated', 'enrichment-truncated']);
});
});