mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-08 22:22:52 +00:00
* 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>
639 lines
20 KiB
TypeScript
639 lines
20 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';
|
||
|
||
// ─── 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');
|
||
});
|
||
});
|