mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-07 08:26:11 +00:00
* fix(cli): do not call zero-symbol detect-changes a clean tree Print backend summary.message and distinguish a parsed diff with no overlapping indexed symbols from an empty git diff. Pin --color=never so color.ui=always cannot hide +++ b/ headers. * fix(cli): localize clean-tree detect-changes and skip no-overlap when partial Production empty diffs carry English summary.message; route that through t() so zh-CN fires. Do not claim no indexed-symbol overlap on queryDegraded partial results. Pin formatter tests to en and cover the production payload shapes. * fix(cli): prettier detect-changes-format and degraded eval assertion --------- Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
791 lines
27 KiB
TypeScript
791 lines
27 KiB
TypeScript
/**
|
||
* P1 Unit Tests: Eval Server Formatters
|
||
*
|
||
* Tests: formatQueryResult, formatContextResult, formatImpactResult,
|
||
* formatCypherResult, formatDetectChangesResult, formatListReposResult, MAX_BODY_SIZE
|
||
*/
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||
import {
|
||
formatQueryResult,
|
||
formatContextResult,
|
||
formatImpactResult,
|
||
formatCypherResult,
|
||
formatDetectChangesResult,
|
||
formatListReposResult,
|
||
MAX_BODY_SIZE,
|
||
validateHost,
|
||
} from '../../src/cli/eval-server.js';
|
||
import { formatSymbolLine } from '../../src/cli/format-symbol.js';
|
||
|
||
// ─── validateHost ────────────────────────────────────────────────────
|
||
|
||
beforeEach(() => {
|
||
vi.unstubAllEnvs();
|
||
vi.stubEnv('GITNEXUS_LANG', 'en');
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.unstubAllEnvs();
|
||
});
|
||
|
||
describe('validateHost', () => {
|
||
it('passes "localhost" through unchanged', () => {
|
||
expect(validateHost('localhost')).toBe('localhost');
|
||
});
|
||
|
||
it('accepts valid IPv4 addresses', () => {
|
||
expect(validateHost('127.0.0.1')).toBe('127.0.0.1');
|
||
expect(validateHost('0.0.0.0')).toBe('0.0.0.0');
|
||
expect(validateHost('192.168.1.5')).toBe('192.168.1.5');
|
||
expect(validateHost('10.0.0.1')).toBe('10.0.0.1');
|
||
});
|
||
|
||
it('accepts valid IPv6 addresses', () => {
|
||
expect(validateHost('::1')).toBe('::1');
|
||
expect(validateHost('::')).toBe('::');
|
||
expect(validateHost('2001:db8::1')).toBe('2001:db8::1');
|
||
});
|
||
|
||
it('returns null for a non-IP hostname', () => {
|
||
expect(validateHost('foo.bar')).toBeNull();
|
||
expect(validateHost('myhost.local')).toBeNull();
|
||
expect(validateHost('example.com')).toBeNull();
|
||
});
|
||
|
||
it('returns null for out-of-range IPv4 octets', () => {
|
||
expect(validateHost('999.999.999.999')).toBeNull();
|
||
expect(validateHost('192.168.1.256')).toBeNull();
|
||
});
|
||
|
||
it('returns null for incomplete IPv4 addresses', () => {
|
||
expect(validateHost('192.168.1')).toBeNull();
|
||
expect(validateHost('192.168')).toBeNull();
|
||
});
|
||
|
||
it('returns null for an empty string', () => {
|
||
expect(validateHost('')).toBeNull();
|
||
});
|
||
|
||
it('returns null for whitespace or padded IPs', () => {
|
||
expect(validateHost(' ')).toBeNull();
|
||
expect(validateHost(' 127.0.0.1')).toBeNull();
|
||
expect(validateHost('127.0.0.1 ')).toBeNull();
|
||
});
|
||
});
|
||
|
||
// ─── MAX_BODY_SIZE ───────────────────────────────────────────────────
|
||
|
||
describe('MAX_BODY_SIZE', () => {
|
||
it('is 1MB', () => {
|
||
expect(MAX_BODY_SIZE).toBe(1024 * 1024);
|
||
});
|
||
});
|
||
|
||
// ─── formatQueryResult ───────────────────────────────────────────────
|
||
|
||
describe('formatQueryResult', () => {
|
||
it('returns error message for error input', () => {
|
||
expect(formatQueryResult({ error: 'something failed' })).toBe('Error: something failed');
|
||
});
|
||
|
||
it('returns no-match message for empty results', () => {
|
||
const result = formatQueryResult({ processes: [], definitions: [] });
|
||
expect(result).toContain('No matching execution flows');
|
||
});
|
||
|
||
it('formats processes with symbols', () => {
|
||
const result = formatQueryResult({
|
||
processes: [{ id: 'p1', summary: 'User Login Flow', step_count: 3, symbol_count: 2 }],
|
||
process_symbols: [
|
||
{
|
||
process_id: 'p1',
|
||
type: 'Function',
|
||
name: 'login',
|
||
filePath: 'src/auth.ts',
|
||
startLine: 10,
|
||
},
|
||
{
|
||
process_id: 'p1',
|
||
type: 'Function',
|
||
name: 'validate',
|
||
filePath: 'src/auth.ts',
|
||
startLine: 20,
|
||
},
|
||
],
|
||
definitions: [],
|
||
});
|
||
expect(result).toContain('1 execution flow');
|
||
expect(result).toContain('User Login Flow');
|
||
expect(result).toContain('login');
|
||
expect(result).toContain(':10');
|
||
});
|
||
|
||
it('truncates symbols per process at 6', () => {
|
||
const symbols = Array.from({ length: 10 }, (_, i) => ({
|
||
process_id: 'p1',
|
||
type: 'Function',
|
||
name: `fn${i}`,
|
||
filePath: 'src/test.ts',
|
||
}));
|
||
const result = formatQueryResult({
|
||
processes: [{ id: 'p1', summary: 'Flow', step_count: 10, symbol_count: 10 }],
|
||
process_symbols: symbols,
|
||
definitions: [],
|
||
});
|
||
expect(result).toContain('and 4 more');
|
||
});
|
||
|
||
it('formats standalone definitions', () => {
|
||
const result = formatQueryResult({
|
||
processes: [],
|
||
definitions: [{ type: 'Interface', name: 'Config', filePath: 'src/types.ts' }],
|
||
});
|
||
expect(result).toContain('Standalone definitions');
|
||
expect(result).toContain('Config');
|
||
});
|
||
|
||
it('truncates definitions at 8', () => {
|
||
const defs = Array.from({ length: 12 }, (_, i) => ({
|
||
type: 'Interface',
|
||
name: `Type${i}`,
|
||
filePath: 'src/types.ts',
|
||
}));
|
||
const result = formatQueryResult({ processes: [], definitions: defs });
|
||
expect(result).toContain('and 4 more');
|
||
});
|
||
});
|
||
|
||
// ─── formatContextResult ─────────────────────────────────────────────
|
||
|
||
describe('formatContextResult', () => {
|
||
it('returns error message for error input', () => {
|
||
expect(formatContextResult({ error: 'not found' })).toBe('Error: not found');
|
||
});
|
||
|
||
it('handles ambiguous results', () => {
|
||
const result = formatContextResult({
|
||
status: 'ambiguous',
|
||
candidates: [
|
||
{ name: 'foo', kind: 'Function', filePath: 'src/a.ts', line: 10, uid: 'uid1' },
|
||
{ name: 'foo', kind: 'Function', filePath: 'src/b.ts', line: 5, uid: 'uid2' },
|
||
],
|
||
});
|
||
expect(result).toContain('Multiple symbols');
|
||
expect(result).toContain('uid1');
|
||
expect(result).toContain('uid2');
|
||
});
|
||
|
||
it('returns "Symbol not found" when no symbol', () => {
|
||
expect(formatContextResult({})).toBe('Symbol not found.');
|
||
});
|
||
|
||
it('formats symbol with incoming/outgoing refs', () => {
|
||
const result = formatContextResult({
|
||
symbol: { kind: 'Function', name: 'foo', filePath: 'src/a.ts', startLine: 1, endLine: 10 },
|
||
incoming: {
|
||
CALLS: [{ kind: 'Function', name: 'bar', filePath: 'src/b.ts' }],
|
||
},
|
||
outgoing: {
|
||
IMPORTS: [{ kind: 'Module', name: 'utils', filePath: 'src/utils.ts' }],
|
||
},
|
||
processes: [],
|
||
});
|
||
expect(result).toContain('Function foo');
|
||
expect(result).toContain('Called/imported by (1)');
|
||
expect(result).toContain('Calls/imports (1)');
|
||
});
|
||
|
||
it('formats process participation', () => {
|
||
const result = formatContextResult({
|
||
symbol: { kind: 'Function', name: 'foo', filePath: 'src/a.ts' },
|
||
incoming: {},
|
||
outgoing: {},
|
||
processes: [{ name: 'Auth Flow', step_index: 2, step_count: 5 }],
|
||
});
|
||
expect(result).toContain('1 execution flow');
|
||
expect(result).toContain('Auth Flow');
|
||
});
|
||
});
|
||
|
||
// ─── formatImpactResult ──────────────────────────────────────────────
|
||
|
||
describe('formatImpactResult', () => {
|
||
it('returns error message for error input', () => {
|
||
expect(formatImpactResult({ error: 'bad request' })).toContain('Error: bad request');
|
||
});
|
||
|
||
it('surfaces per-candidate blast radius for an ambiguous result, never the "isolated" headline (#2129)', () => {
|
||
const result = formatImpactResult({
|
||
status: 'ambiguous',
|
||
target: { name: 'classifyCard' },
|
||
direction: 'upstream',
|
||
impactedCount: 0,
|
||
risk: 'UNKNOWN',
|
||
maxImpactedCount: 3,
|
||
maxRisk: 'MEDIUM',
|
||
candidates: [
|
||
{
|
||
uid: 'Function:src/sync-logic.ts:classifyCard',
|
||
name: 'classifyCard',
|
||
kind: 'Function',
|
||
filePath: 'src/sync-logic.ts',
|
||
line: 1,
|
||
impactedCount: 3,
|
||
risk: 'MEDIUM',
|
||
},
|
||
{
|
||
uid: 'Function:src/ui-helpers.ts:classifyCard',
|
||
name: 'classifyCard',
|
||
kind: 'Function',
|
||
filePath: 'src/ui-helpers.ts',
|
||
line: 1,
|
||
impactedCount: 1,
|
||
risk: 'LOW',
|
||
},
|
||
],
|
||
});
|
||
// Must NOT print the false-safe "isolated" headline.
|
||
expect(result).not.toContain('isolated');
|
||
expect(result).toContain('AMBIGUOUS');
|
||
expect(result).toContain('Max blast radius 3');
|
||
// Both candidates + their real counts are visible.
|
||
expect(result).toContain('src/sync-logic.ts');
|
||
expect(result).toContain('[3 upstream');
|
||
expect(result).toContain('--uid');
|
||
// No probe failed here → no lower-bound warning.
|
||
expect(result).not.toContain('candidate probes failed');
|
||
});
|
||
|
||
it('warns that the max is a lower bound when a candidate probe failed (#2129 review F1)', () => {
|
||
const result = formatImpactResult({
|
||
status: 'ambiguous',
|
||
target: { name: 'classifyCard' },
|
||
direction: 'upstream',
|
||
impactedCount: 0,
|
||
risk: 'UNKNOWN',
|
||
maxImpactedCount: 2,
|
||
maxRisk: 'LOW',
|
||
partialProbe: true,
|
||
candidates: [
|
||
{
|
||
uid: 'A',
|
||
name: 'classifyCard',
|
||
kind: 'Function',
|
||
filePath: 'src/a.ts',
|
||
line: 1,
|
||
impactedCount: 2,
|
||
risk: 'LOW',
|
||
},
|
||
{
|
||
uid: 'B',
|
||
name: 'classifyCard',
|
||
kind: 'Function',
|
||
filePath: 'src/b.ts',
|
||
line: 1,
|
||
impactedCount: 0,
|
||
risk: 'UNKNOWN',
|
||
},
|
||
],
|
||
});
|
||
expect(result).not.toContain('isolated');
|
||
expect(result).toContain('candidate probes failed');
|
||
expect(result).toContain('lower bound');
|
||
// The honest max is still shown.
|
||
expect(result).toContain('Max blast radius 2');
|
||
});
|
||
|
||
it('reports the full match count when the candidate list is truncated (#2129 review F11)', () => {
|
||
const result = formatImpactResult({
|
||
status: 'ambiguous',
|
||
target: { name: 'handle' },
|
||
direction: 'upstream',
|
||
impactedCount: 0,
|
||
risk: 'UNKNOWN',
|
||
maxImpactedCount: 5,
|
||
maxRisk: 'HIGH',
|
||
totalCandidates: 9,
|
||
candidatesTruncated: true,
|
||
candidates: Array.from({ length: 6 }, (_, i) => ({
|
||
uid: `U${i}`,
|
||
name: 'handle',
|
||
kind: 'Function',
|
||
filePath: `src/h${i}.ts`,
|
||
line: 1,
|
||
impactedCount: i,
|
||
risk: 'LOW',
|
||
})),
|
||
});
|
||
// Full count (9), not the truncated array length (6).
|
||
expect(result).toContain('9 symbols');
|
||
expect(result).toContain('showing 6');
|
||
});
|
||
|
||
it('shows a plain count when the candidate list is not truncated', () => {
|
||
const result = formatImpactResult({
|
||
status: 'ambiguous',
|
||
target: { name: 'foo' },
|
||
direction: 'upstream',
|
||
impactedCount: 0,
|
||
risk: 'UNKNOWN',
|
||
maxImpactedCount: 1,
|
||
maxRisk: 'LOW',
|
||
totalCandidates: 2,
|
||
candidates: [
|
||
{
|
||
uid: 'A',
|
||
name: 'foo',
|
||
kind: 'Function',
|
||
filePath: 'src/a.ts',
|
||
line: 1,
|
||
impactedCount: 1,
|
||
risk: 'LOW',
|
||
},
|
||
{
|
||
uid: 'B',
|
||
name: 'foo',
|
||
kind: 'Function',
|
||
filePath: 'src/b.ts',
|
||
line: 1,
|
||
impactedCount: 0,
|
||
risk: 'LOW',
|
||
},
|
||
],
|
||
});
|
||
expect(result).toContain('2 symbols');
|
||
expect(result).not.toContain('showing');
|
||
});
|
||
|
||
it('surfaces the lower-bound boundary note when epistemic is lower-bound (#1858)', () => {
|
||
const result = formatImpactResult({
|
||
target: { kind: 'Class', name: 'EmailLogger' },
|
||
direction: 'upstream',
|
||
impactedCount: 0,
|
||
risk: 'LOW',
|
||
epistemic: 'lower-bound',
|
||
boundaries: ['Logger is an interface with 2 implementations; callers bind via DI.'],
|
||
byDepth: {},
|
||
});
|
||
expect(result).not.toContain('isolated');
|
||
expect(result.toLowerCase()).toContain('lower bound');
|
||
expect(result).toContain('Logger is an interface');
|
||
});
|
||
|
||
it('returns error with suggestion when provided', () => {
|
||
const result = formatImpactResult({
|
||
error: 'Impact analysis failed',
|
||
suggestion: 'Try gitnexus context <symbol> as a fallback',
|
||
});
|
||
expect(result).toContain('Error: Impact analysis failed');
|
||
expect(result).toContain('Suggestion: Try gitnexus context');
|
||
});
|
||
|
||
it('shows partial warning when traversal was interrupted', () => {
|
||
const result = formatImpactResult({
|
||
target: { kind: 'Function', name: 'foo' },
|
||
direction: 'upstream',
|
||
impactedCount: 2,
|
||
partial: true,
|
||
byDepth: {
|
||
1: [
|
||
{
|
||
type: 'Function',
|
||
name: 'caller1',
|
||
filePath: 'src/a.ts',
|
||
relationType: 'CALLS',
|
||
confidence: 1,
|
||
},
|
||
{
|
||
type: 'Function',
|
||
name: 'caller2',
|
||
filePath: 'src/b.ts',
|
||
relationType: 'CALLS',
|
||
confidence: 1,
|
||
},
|
||
],
|
||
},
|
||
});
|
||
expect(result).toContain('Partial results');
|
||
expect(result).toContain('caller1');
|
||
expect(result).toContain('caller2');
|
||
});
|
||
|
||
it('prints the shared-axes comparison when a target has unavailable risk axes', () => {
|
||
const result = formatImpactResult({
|
||
target: { kind: 'File', name: 'crypto.ts' },
|
||
direction: 'upstream',
|
||
impactedCount: 13,
|
||
risk: 'MEDIUM',
|
||
riskSharedAxes: 'MEDIUM',
|
||
riskScale: {
|
||
comparableAcrossKinds: false,
|
||
unusedAxes: [
|
||
{
|
||
axis: 'processes',
|
||
reason: 'file-nodes-have-no-process-or-community-membership',
|
||
},
|
||
{
|
||
axis: 'modules',
|
||
reason: 'file-nodes-have-no-process-or-community-membership',
|
||
},
|
||
],
|
||
},
|
||
byDepthCounts: { 1: 13 },
|
||
});
|
||
|
||
expect(result).toContain('Risk: MEDIUM');
|
||
expect(result).toContain('Shared-axes risk: MEDIUM');
|
||
expect(result).toContain('process/module axes are unavailable');
|
||
expect(result).toContain('do not use this to waive a HIGH/CRITICAL risk warning');
|
||
});
|
||
|
||
it('handles zero impact', () => {
|
||
const result = formatImpactResult({
|
||
target: { name: 'foo' },
|
||
direction: 'upstream',
|
||
impactedCount: 0,
|
||
byDepth: {},
|
||
});
|
||
expect(result).toContain('No upstream callers resolved');
|
||
expect(result).not.toContain('appears isolated');
|
||
});
|
||
|
||
it('prints UNKNOWN and riskNote for an empty upstream walk', () => {
|
||
const result = formatImpactResult({
|
||
target: { name: 'foo' },
|
||
direction: 'upstream',
|
||
impactedCount: 0,
|
||
risk: 'UNKNOWN',
|
||
riskNote: 'safe to change is a claim about callers and there were none to reason about',
|
||
byDepth: {},
|
||
});
|
||
expect(result).toContain('Risk: UNKNOWN');
|
||
expect(result).toContain('safe to change is a claim about callers');
|
||
expect(result).not.toContain('appears isolated');
|
||
});
|
||
|
||
it('formats impact by depth', () => {
|
||
const result = formatImpactResult({
|
||
target: { kind: 'Function', name: 'foo' },
|
||
direction: 'upstream',
|
||
impactedCount: 3,
|
||
byDepth: {
|
||
1: [
|
||
{
|
||
type: 'Function',
|
||
name: 'caller1',
|
||
filePath: 'src/a.ts',
|
||
relationType: 'CALLS',
|
||
confidence: 1,
|
||
},
|
||
{
|
||
type: 'Function',
|
||
name: 'caller2',
|
||
filePath: 'src/b.ts',
|
||
relationType: 'CALLS',
|
||
confidence: 0.8,
|
||
},
|
||
],
|
||
2: [
|
||
{
|
||
type: 'Class',
|
||
name: 'App',
|
||
filePath: 'src/app.ts',
|
||
relationType: 'IMPORTS',
|
||
confidence: 1,
|
||
},
|
||
],
|
||
},
|
||
});
|
||
expect(result).toContain('Blast radius');
|
||
expect(result).toContain('WILL BREAK');
|
||
expect(result).toContain('caller1');
|
||
expect(result).toContain('conf: 0.8');
|
||
expect(result).toContain('LIKELY AFFECTED');
|
||
});
|
||
|
||
it('truncates items per depth at 12', () => {
|
||
const items = Array.from({ length: 15 }, (_, i) => ({
|
||
type: 'Function',
|
||
name: `fn${i}`,
|
||
filePath: 'src/test.ts',
|
||
relationType: 'CALLS',
|
||
confidence: 1,
|
||
}));
|
||
const result = formatImpactResult({
|
||
target: { kind: 'Function', name: 'foo' },
|
||
direction: 'upstream',
|
||
impactedCount: 15,
|
||
byDepth: { 1: items },
|
||
});
|
||
expect(result).toContain('and 3 more');
|
||
});
|
||
});
|
||
|
||
// ─── formatCypherResult ──────────────────────────────────────────────
|
||
|
||
describe('formatCypherResult', () => {
|
||
it('returns error message for error input', () => {
|
||
expect(formatCypherResult({ error: 'syntax error' })).toBe('Error: syntax error');
|
||
});
|
||
|
||
it('handles empty array', () => {
|
||
expect(formatCypherResult([])).toBe('Query returned 0 rows.');
|
||
});
|
||
|
||
it('formats array of objects as table', () => {
|
||
const result = formatCypherResult([
|
||
{ name: 'foo', filePath: 'src/a.ts' },
|
||
{ name: 'bar', filePath: 'src/b.ts' },
|
||
]);
|
||
expect(result).toContain('2 row(s)');
|
||
expect(result).toContain('name: foo');
|
||
expect(result).toContain('name: bar');
|
||
});
|
||
|
||
it('truncates at 30 rows', () => {
|
||
const rows = Array.from({ length: 35 }, (_, i) => ({ id: i }));
|
||
const result = formatCypherResult(rows);
|
||
expect(result).toContain('5 more rows');
|
||
});
|
||
|
||
it('handles string result', () => {
|
||
expect(formatCypherResult('some text')).toBe('some text');
|
||
});
|
||
});
|
||
|
||
// ─── formatSymbolLine ────────────────────────────────────────────────
|
||
|
||
describe('formatSymbolLine', () => {
|
||
// `||`, not `??`, on every field: a node whose label came back as an EMPTY
|
||
// STRING (several node types do) still needs the placeholder — a `??` here
|
||
// would render " login → src/auth.ts" instead of " Symbol login → ...".
|
||
it.each<[string | undefined, string | undefined, string | undefined, string]>([
|
||
['Function', 'login', 'src/auth.ts', ' Function login → src/auth.ts'],
|
||
['', 'login', 'src/auth.ts', ' Symbol login → src/auth.ts'],
|
||
[undefined, 'login', 'src/auth.ts', ' Symbol login → src/auth.ts'],
|
||
['Function', '', 'src/auth.ts', ' Function ? → src/auth.ts'],
|
||
['Function', undefined, 'src/auth.ts', ' Function ? → src/auth.ts'],
|
||
['Function', 'login', '', ' Function login → ?'],
|
||
['Function', 'login', undefined, ' Function login → ?'],
|
||
[undefined, undefined, undefined, ' Symbol ? → ?'],
|
||
])('renders type=%s name=%s path=%s as "%s"', (type, name, filePath, expected) => {
|
||
expect(formatSymbolLine(type, name, filePath)).toBe(expected);
|
||
});
|
||
|
||
it('is the line both consumers render (detect_changes + query definitions)', () => {
|
||
const detectChanges = formatDetectChangesResult({
|
||
summary: { changed_files: 1, changed_count: 1, affected_count: 0, risk_level: 'LOW' },
|
||
changed_symbols: [{ type: '', name: 'foo', filePath: 'src/a.ts' }],
|
||
});
|
||
expect(detectChanges).toContain(formatSymbolLine('', 'foo', 'src/a.ts'));
|
||
|
||
const query = formatQueryResult({
|
||
processes: [],
|
||
definitions: [{ type: '', name: '', filePath: '' }],
|
||
});
|
||
expect(query).toContain(formatSymbolLine('', '', ''));
|
||
});
|
||
});
|
||
|
||
// ─── formatDetectChangesResult ───────────────────────────────────────
|
||
|
||
describe('formatDetectChangesResult', () => {
|
||
it('returns error message for error input', () => {
|
||
expect(formatDetectChangesResult({ error: 'git error' })).toBe('Error: git error');
|
||
});
|
||
|
||
it('handles no changes', () => {
|
||
const result = formatDetectChangesResult({ summary: { changed_count: 0 } });
|
||
expect(result).toBe('No changes detected.');
|
||
});
|
||
|
||
it('flags a degraded run instead of printing a clean bill of health (#2283)', () => {
|
||
// The backend sets `partial` when a graph query is swallowed, and leaves the
|
||
// counts at zero. Without the note the pre-commit gate reads as "clean".
|
||
const result = formatDetectChangesResult({ partial: true, summary: { changed_count: 0 } });
|
||
expect(result).toContain('PARTIAL RESULT');
|
||
expect(result).toContain('a graph query failed');
|
||
expect(result).not.toContain('No changes detected.');
|
||
});
|
||
|
||
it('flags a degraded run that still found symbols', () => {
|
||
const result = formatDetectChangesResult({
|
||
partial: true,
|
||
summary: { changed_files: 1, changed_count: 1, affected_count: 0, risk_level: 'LOW' },
|
||
changed_symbols: [{ type: 'Function', name: 'foo', filePath: 'src/a.ts' }],
|
||
});
|
||
expect(result).toContain('PARTIAL RESULT');
|
||
expect(result).toContain('foo');
|
||
});
|
||
|
||
it('flags a capped listing, so a short list is not read as a short diff', () => {
|
||
// `truncated` is `partial`'s sibling and NOT the same claim: the counts and
|
||
// risk level still cover every changed symbol, only the names were capped.
|
||
const result = formatDetectChangesResult({
|
||
truncated: true,
|
||
summary: { changed_files: 40, changed_count: 500, affected_count: 0, risk_level: 'HIGH' },
|
||
changed_symbols: [{ type: 'Function', name: 'foo', filePath: 'src/a.ts' }],
|
||
});
|
||
expect(result).toContain('LISTING CAPPED');
|
||
expect(result).not.toContain('PARTIAL RESULT');
|
||
expect(result).toContain('foo');
|
||
});
|
||
|
||
it('leads with both notes when a run was degraded AND capped', () => {
|
||
const result = formatDetectChangesResult({
|
||
partial: true,
|
||
truncated: true,
|
||
summary: { changed_files: 40, changed_count: 500, affected_count: 0, risk_level: 'HIGH' },
|
||
changed_symbols: [{ type: 'Function', name: 'foo', filePath: 'src/a.ts' }],
|
||
});
|
||
// A caveat printed after the summary is read too late, so both notes lead.
|
||
expect(result.indexOf('PARTIAL RESULT')).toBe(0);
|
||
expect(result.indexOf('LISTING CAPPED')).toBeGreaterThan(0);
|
||
expect(result.indexOf('LISTING CAPPED')).toBeLessThan(result.indexOf('Changes: 40 files'));
|
||
// And it must NOT keep the truncated-only reassurance that the counts are
|
||
// whole: `changed_count` was summed from the batches that succeeded, so with
|
||
// `partial` it is a floor. Claiming otherwise here contradicts the note above
|
||
// it and the tool description.
|
||
expect(result).toContain('lower bound');
|
||
expect(result).not.toContain('still cover all of them');
|
||
});
|
||
|
||
it('flags a capped listing that found nothing, alongside the no-changes line', () => {
|
||
const result = formatDetectChangesResult({ truncated: true, summary: { changed_count: 0 } });
|
||
expect(result).toContain('LISTING CAPPED');
|
||
expect(result).toContain('No changes detected.');
|
||
});
|
||
|
||
it('reports the overflow count once — the capped note carries no number of its own', () => {
|
||
const result = formatDetectChangesResult({
|
||
truncated: true,
|
||
summary: { changed_files: 40, changed_count: 500, affected_count: 0, risk_level: 'HIGH' },
|
||
changed_symbols: Array.from({ length: 15 }, (_, i) => ({
|
||
type: 'Function',
|
||
name: `fn${i}`,
|
||
filePath: 'src/test.ts',
|
||
})),
|
||
});
|
||
// Splitting on a needle yields (occurrences + 1) pieces.
|
||
expect(result.split('... and 485 more')).toHaveLength(2);
|
||
expect(result.split('LISTING CAPPED')).toHaveLength(2);
|
||
expect(result.match(/485/g)).toEqual(['485']);
|
||
});
|
||
|
||
it('formats changes with affected processes', () => {
|
||
const result = formatDetectChangesResult({
|
||
summary: { changed_files: 2, changed_count: 3, affected_count: 1, risk_level: 'MEDIUM' },
|
||
changed_symbols: [{ type: 'Function', name: 'foo', filePath: 'src/a.ts' }],
|
||
affected_processes: [
|
||
{ name: 'Auth Flow', step_count: 5, changed_steps: [{ symbol: 'foo' }] },
|
||
],
|
||
});
|
||
expect(result).toContain('2 files');
|
||
expect(result).toContain('MEDIUM');
|
||
expect(result).toContain('Auth Flow');
|
||
});
|
||
|
||
it('truncates changed symbols at 15', () => {
|
||
const symbols = Array.from({ length: 20 }, (_, i) => ({
|
||
type: 'Function',
|
||
name: `fn${i}`,
|
||
filePath: 'src/test.ts',
|
||
}));
|
||
const result = formatDetectChangesResult({
|
||
summary: { changed_files: 1, changed_count: 20, affected_count: 0, risk_level: 'HIGH' },
|
||
changed_symbols: symbols,
|
||
affected_processes: [],
|
||
});
|
||
expect(result).toContain('and 5 more');
|
||
});
|
||
|
||
it('localizes detect_changes labels for Simplified Chinese', () => {
|
||
vi.stubEnv('GITNEXUS_LANG', 'zh-CN');
|
||
|
||
const result = formatDetectChangesResult({
|
||
summary: { changed_files: 2, changed_count: 3, affected_count: 1, risk_level: 'MEDIUM' },
|
||
changed_symbols: [{ type: 'Function', name: 'foo', filePath: 'src/a.ts' }],
|
||
affected_processes: [
|
||
{ name: 'Auth Flow', step_count: 5, changed_steps: [{ symbol: 'foo' }] },
|
||
],
|
||
});
|
||
|
||
expect(result).toContain('变更:2 个文件,3 个符号');
|
||
expect(result).toContain('受影响流程:1');
|
||
expect(result).toContain('风险等级:MEDIUM');
|
||
expect(result).toContain('已变更符号:');
|
||
expect(result).toContain('受影响执行流程:');
|
||
expect(result).toContain('Auth Flow (5 步) — 已变更:foo');
|
||
});
|
||
});
|
||
|
||
// ─── formatListReposResult ───────────────────────────────────────────
|
||
|
||
describe('formatListReposResult', () => {
|
||
it('handles an empty page (no pagination)', () => {
|
||
expect(formatListReposResult({ repositories: [] })).toBe('No indexed repositories.');
|
||
});
|
||
|
||
it('formats a repo list (no pagination → no footer)', () => {
|
||
const result = formatListReposResult({
|
||
repositories: [
|
||
{
|
||
name: 'my-project',
|
||
path: '/home/user/my-project',
|
||
indexedAt: '2024-01-01',
|
||
lastCommit: 'abc1234',
|
||
stats: { nodes: 100, edges: 200, processes: 10 },
|
||
},
|
||
],
|
||
});
|
||
expect(result).toContain('Indexed repositories');
|
||
expect(result).toContain('my-project');
|
||
expect(result).toContain('100 symbols');
|
||
expect(result).not.toContain('Showing'); // no pagination → no footer
|
||
});
|
||
|
||
it('formats a paginated { repositories, pagination } result with a continuation footer', () => {
|
||
const result = formatListReposResult({
|
||
repositories: [
|
||
{
|
||
name: 'my-project',
|
||
path: '/home/user/my-project',
|
||
indexedAt: '2024-01-01',
|
||
lastCommit: 'abc1234',
|
||
stats: { nodes: 100, edges: 200, processes: 10 },
|
||
},
|
||
],
|
||
pagination: { total: 437, limit: 50, offset: 0, returned: 1, hasMore: true, nextOffset: 50 },
|
||
});
|
||
expect(result).toContain('Indexed repositories');
|
||
expect(result).toContain('my-project');
|
||
expect(result).toContain('Showing 1 of 437');
|
||
expect(result).toContain('offset 50'); // continuation hint
|
||
});
|
||
|
||
it('formats the final page (hasMore false) without a continuation hint', () => {
|
||
const result = formatListReposResult({
|
||
repositories: [
|
||
{
|
||
name: 'only',
|
||
path: '/p/only',
|
||
indexedAt: '2024-01-01',
|
||
lastCommit: 'abc1234',
|
||
stats: {},
|
||
},
|
||
],
|
||
pagination: { total: 1, limit: 50, offset: 0, returned: 1, hasMore: false },
|
||
});
|
||
expect(result).toContain('Showing 1 of 1');
|
||
expect(result).not.toContain('More available');
|
||
});
|
||
|
||
it('reports an empty page using pagination metadata', () => {
|
||
const result = formatListReposResult({
|
||
repositories: [],
|
||
pagination: { total: 437, limit: 50, offset: 1000, returned: 0, hasMore: false },
|
||
});
|
||
expect(result).toContain('No repositories on this page');
|
||
expect(result).toContain('437');
|
||
});
|
||
});
|