GitNexus/gitnexus/test/unit/eval-formatters.test.ts
Gergő Magyar ae5ec94fd9
fix: stop impact()/route_map under-reporting blast radius (#2129, #1858, #1589/#1852) (#2136)
* fix(query): stop impact()/context() under-reporting blast radius (#2129, #1858)

Two read-side fixes to the "run impact before editing" safety workflow, both
about the tools rendering "I could not give a single confident answer" as
"no impact" — the most dangerous failure mode for a refactor-safety tool.

#2129 — ambiguous resolution no longer hides a real caller behind a bare
`impactedCount: 0`. When a bare name collides with several symbols, the resolver
returns `ambiguous`; previously the payload carried a flat `impactedCount: 0`,
so the real caller (which calls a *different* same-name node) was invisible
unless the user already knew to disambiguate. The ambiguous branch now runs a
bounded, summary-only BFS per candidate (capped at 6) and surfaces each
candidate's true count plus the top-level `maxImpactedCount` / `maxRisk`, ranked
most-impactful-first. `risk` stays `UNKNOWN` (ambiguity must not read as "safe"),
`impactedCount` stays 0 (no single resolved symbol). The BFS and edge storage
are unchanged — an empirical repro confirmed they are correct; the bug was
purely in how the ambiguous case reported. Disambiguation by uid still returns
the exact result.

#1858 — impact()/context() now carry an additive `epistemic` field. When the
queried symbol sits on an interface / indirection boundary (it implements or
extends an interface, or is one) whose consumers bind via a DI container or
dynamic dispatch, those callers are not traced to the concrete symbol, so the
count is a lower bound. The result is annotated `epistemic: 'lower-bound'` with a
human-readable `boundaries[]` note; a fully resolved leaf stays
`epistemic: 'exact'`. Aligned to the surviving numeric confidence model (the
0.85 IMPACT_RELATION_CONFIDENCE heritage floor), not the long-deleted
TIER_CONFIDENCE enum. Purely additive — no existing field or count changes.

Tests: impact-ambiguous-blast-radius (per-candidate surfacing + uid
disambiguation) and impact-epistemic-lower-bound (interface boundary →
lower-bound, resolved leaf → exact, context parity).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(routes): configurable fetch wrappers + faster consumer scan (#1589/#1852)

Closes the residual gap behind the now-merged #1852 (which fixed #1589): the
fetch-wrapper consumer scan only traced wrappers the parse phase auto-detected
as calling the bare global `fetch()`. A wrapper built on axios / a custom
client, or one named outside the built-in convention, was invisible — route_map
silently returned `consumers: []` (the exact "named outside convention → silent
zero" hole #1858 calls out as needing a backstop).

- Configurable wrappers: `.gitnexusrc` gains a `fetchWrappers: [...]` list
  (validated as identifier/member names, de-duped, capped, regex-safe), threaded
  AnalyzeOptions → PipelineOptions → routes phase. Configured names are unioned
  with the auto-detected ones; configured names alone now trigger the scan even
  when nothing was auto-detected.
- Perf (F3 from #1852's review): the cross-file scan built one RegExp per
  (file × wrapper) — O(files × wrappers). It now builds a single alternation
  regex per file (O(files)) and reuses file contents already read for handler
  extraction instead of re-reading them.

Tests: configurable-fetch-wrapper (axios-based `doRequest` wrapper — invisible
without config, traced with it) + .gitnexusrc `fetchWrappers` validation cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(review): harden the under-reporting fixes after adversarial review

Addresses findings from a reviewer-swarm pass over the two prior commits:

- CLI text false-safe (major): `formatImpactResult` (eval-server.ts) had no
  ambiguous branch, so `gitnexus impact <colliding-name>` printed "No
  dependencies found. This symbol appears isolated." for an ambiguous target —
  the exact false-safe #2129 exists to kill, defeating the JSON-layer fix at the
  text surface. Added an ambiguous branch (per-candidate blast radius +
  maxImpactedCount/maxRisk) and a lower-bound branch for both the zero-count and
  non-zero paths, mirroring the context formatter. Covered by new unit tests.
- Group fan-out dead work (major): impactByUid now passes skipEpistemic:true —
  the group cross-impact fan-out consumes only byDepth, so computing the #1858
  boundary per neighbor was wasted round-trips on the highest-volume path.
- Ambiguous all-UNKNOWN risk (minor): if every per-candidate probe fails, maxRisk
  now reports 'UNKNOWN' instead of falling to the 'LOW' seed (which would read as
  "safe").
- Candidate-probe cost (minor): the per-candidate summary BFS now sets
  skipEnrichment:true, bypassing the process/module aggregation passes it does
  not use.
- Epistemic latency (minor): computeEpistemicBoundary now runs concurrently with
  the impact BFS instead of as a trailing serial round-trip.
- Wrapper over-match (minor): the consumer-scan regex uses a `(?<![.\w$])`
  lookbehind instead of `\b`, so a bare configured name like `get` matches the
  free call `get('/x')` but not a member access `client.get(` (and `apiFetch`
  no longer matches `myApiFetch`).
- Boundary wording (nit): correct article ("a class" vs "an interface") and
  singular/plural ("1 implementation").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(lint): drop unused describe import in new impact tests

The withTestLbugDB harness wraps describe internally, so the explicit
describe import was unused — unused-imports/no-unused-imports is an error
(not a warning) in the root eslint config, failing quality/lint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(query): flag partialProbe when an ambiguous candidate probe fails (#2129 review F1)

The ambiguous-impact branch hoists maxRisk/maxImpactedCount so a colliding
name can't read as "isolated". But if a per-candidate BFS throws (e.g. DB
pool contention during the ≤6-way fan-out), it was recorded as
risk:'UNKNOWN', impactedCount:0 and silently masked by any benign sibling
success — maxRisk reduced to the benign tier and maxImpactedCount reflected
only successful probes. Track probeFailed and surface partialProbe:true
(additive, intentionally distinct from the traversal-interrupted `partial`
flag); formatImpactResult prints a lower-bound warning. Covered by a
formatter unit test (a natural in-harness probe throw is unreachable —
_runImpactBFS is fully self-catching under summaryOnly+skipEpistemic+
skipEnrichment).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(query): report the full match count when ambiguous candidates are truncated (#2129 review F11)

The ambiguous candidate list is capped at AMBIGUOUS_MAX_CANDIDATES (6), but
the CLI headline read the truncated `candidates[]` length — so a name
matching 9 symbols printed "6 symbols share this name" while the JSON message
stated the true count. Add an additive `totalCandidates` field carrying the
full match count, include a "showing N of M" clause in the message when
truncated, and have formatImpactResult report the full count. Covered by
formatter unit tests for the truncated and non-truncated cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(query): run context() epistemic probe concurrently with methodMetadata (#1858 review F2)

impact() overlaps the #1858 boundary probe with its BFS, but _contextImpl
awaited computeEpistemicBoundary serially after every other query. Start the
probe right after `symKind` is known (the earliest point it can — symKind
depends on the incoming/outgoing round-trips) so it runs concurrently with the
methodMetadata fetch, and await it at result assembly. Output is unchanged
(covered by the existing epistemic context() tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(query): flag a leaf interface as lower-bound in context() (#1858 review F3)

context() passed `symKind` to computeEpistemicBoundary, but symKind collapses
a single-resolved Interface to 'Class' (resolvedLabel is '' on the
single-candidate path), so the `symType === 'Interface'` self-boundary branch
never fired and a directly-queried leaf interface (implements nothing, but
consumed) was under-reported as 'exact'. Pass an interface-preserving type
(`resolvedLabel || sym.type || symKind`) instead — enrichCandidateLabels runs
before the single-candidate early return and patches sym.type to 'Interface',
mirroring impact()'s derivation. impact() was already unaffected. Covered by a
new context()-on-a-leaf-interface test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(query): hoist epistemic relation-type lists + add USES to the allowlist (#1858/#2129 review F4, F5)

F4: promote computeEpistemicBoundary's function-local heritage/consumer
relation-type lists to module-level readonly constants
(EPISTEMIC_HERITAGE_RELATION_TYPES / EPISTEMIC_CONSUMER_RELATION_TYPES) next to
VALID_RELATION_TYPES / IMPACT_RELATION_CONFIDENCE, so a future heritage edge
type is visible to the probe. Kept as arrays (not Sets) because they bind as
Cypher params.

F5 (latent bug): USES is emitted (emit-references.ts) and already in the
default impact relTypes + context() queries, but was missing from
VALID_RELATION_TYPES — so impact({relationTypes:['USES']}) filtered to [] and
silently ran the full default traversal. Add it (0.5 confidence fallback,
matching FETCHES/WRAPS). Updates the security.test.ts allowlist assertions
(size 15→16, USES now valid).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(query): document the _runImpactBFS enrichment skip-flag composition (#1858/#2129 review F6)

The three skip-flags (skipPerSymbolEnrichment / skipEpistemic / skipEnrichment)
suppress distinct sub-phases and compose implicitly. Add a JSDoc block at the
opts type listing what each suppresses, the three real call patterns, and the
key interaction (skipEnrichment makes skipPerSymbolEnrichment a no-op).
Comment-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(cli): genericize the shared string-array validation messages (#1589/#1852 review F7)

The shared `string-array` ValueKind hardcoded fetch-wrapper phrasing in three
messages (non-array, identifier-shape, empty-list). Since `source` already
names the config key, genericize all three so the shared normalizer carries no
fetchWrappers coupling — a future string-array config key gets sensible errors.
Test assertions updated to the new wording.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(query): type the ambiguous candidate summary + epistemicPromise (#1858/#2129 review F8)

The ambiguous per-candidate summary was read through `any`, so a rename of
_runImpactBFS's return fields would silently zero candidate counts. Name the
read shape ({impactedCount, risk, summary?.direct}) at the narrowing site, and
type epistemicPromise as the optional-epistemic union (the skip case's `{}`
subtype) — keeping computeEpistemicBoundary's own return precise (epistemic
required). Type-only; no runtime change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(routes): trust validated fetchWrappers config, drop redundant re-filter (#1589/#1852 review F9)

`ctx.options.fetchWrappers` is already trimmed/shape-validated/de-duped/capped
in analyze-config.ts, so the routes-phase re-trim/re-typeof pre-pass was
redundant. Pass it straight through; the single Set-construction filter remains
to guard the auto-detected functionName values (which don't pass through
analyze-config). No behavior change — covered by the existing fetch-wrapper
route suites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(routes): make the wrapper-call boundary Unicode-aware (#1852 review F10)

The consumer-scan lookbehind used ASCII `\w`, so a configured bare wrapper name
preceded by a non-ASCII identifier character (`caféget('/x')`) satisfied the
boundary and produced a spurious FETCHES edge. Switch to the `u` flag with
Unicode property classes (`(?<![.\p{L}\p{N}_$])`). Covered by a fixture
consumer (`cafédoRequest('/api/things')`) asserting no spurious edge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(routes): count wrapper-scan line numbers incrementally (#1852 review F12)

The wrapper consumer scan computed each match's line number via
content.substring(0, match.index).split('\n').length — an O(matchIndex)
allocation per match. Matches arrive in ascending index, so accumulate
newlines with a running counter instead. 1-based line numbers are byte-identical
(covered by the existing fetch-wrapper route suites).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(test): keep the #1858 epistemic probe from skewing the impact-pagination mock

The impact-pagination mock counts every query containing `r.type IN` as a BFS
depth level. Once the #1858 epistemic boundary probe was parallelized with the
BFS (it fires `MATCH (x)-[r]->(iface) ... r.type IN $heritage` before the
frontier loop), that query was miscounted as depth-1, shifting the real depths
so multi-depth impactedCount read 50 instead of 200. Short-circuit the
epistemic queries (uniquely aliased `iface`) to empty in both mock setups so
only frontier queries count. Test-only; production is unaffected (the epistemic
query is a separate real query there).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 11:25:48 +01:00

639 lines
20 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';
// ─── 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('handles zero impact', () => {
const result = formatImpactResult({
target: { name: 'foo' },
direction: 'upstream',
impactedCount: 0,
byDepth: {},
});
expect(result).toContain('No upstream dependencies');
});
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');
});
});
// ─── 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('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');
});
});