GitNexus/gitnexus/test/unit/eval-formatters.test.ts
Gergő Magyar aa2d6aaf7d
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / ci (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
fix(query): keep process_symbols attaches per (id, process_id) (#3353)
* fix(query): keep process_symbols attaches per (id, process_id)

Hubs that belong to several processes were collapsed to one row by unique-id first-wins, so later process cards advertised symbol_count with nothing to expand. Slice, pair-key, and recount now live in one shaper so each listed process can open its own attaches.

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

* refactor(query): drop leftover unique-id attach bind name

The shaper already returns process_symbols; keep that name through the query response so unique-id vocabulary does not linger at the call site.

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

* style(query): wrap process attach call for prettier

CI quality/format rejects the previous call shape.

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

* test(query): assert hub lines under each process card

A global duplicate count still passes when both lines render under one process.

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

* fix(query): recount group symbol_count after the service prefix

The single-repo (id, process_id) join was readable as if @group results included process_symbols. Scope that sentence, and set symbol_count from the filtered attaches when a service prefix is set.

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

* fix(query): document the attach contract and count prefix rows once

The Unreleased notes and query tool text now say how to join process_symbols, including the @group follow-up. Service-prefix filtering counts those rows in one pass.

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

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 18:58:11 +01:00

828 lines
28 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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('lists a shared hub under each process that has an attach (R7)', () => {
const result = formatQueryResult({
processes: [
{ id: 'proc:A', summary: 'User Login', step_count: 3, symbol_count: 1 },
{ id: 'proc:B', summary: 'Beta Flow', step_count: 3, symbol_count: 1 },
],
process_symbols: [
{
id: 'func:validate',
process_id: 'proc:A',
type: 'Function',
name: 'validate',
filePath: 'src/auth.ts',
startLine: 17,
},
{
id: 'func:validate',
process_id: 'proc:B',
type: 'Function',
name: 'validate',
filePath: 'src/beta.ts',
startLine: 17,
},
],
definitions: [],
});
expect(result).toContain('User Login (3 steps, 1 symbols)');
expect(result).toContain('Beta Flow (3 steps, 1 symbols)');
const sections = result.split(/\n\d+\. /);
const login = sections.find((section) => section.startsWith('User Login'));
const beta = sections.find((section) => section.startsWith('Beta Flow'));
expect(login).toContain('validate → src/auth.ts:17');
expect(login).not.toContain('src/beta.ts');
expect(beta).toContain('validate → src/beta.ts:17');
expect(beta).not.toContain('src/auth.ts');
});
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');
});
});