GitNexus/gitnexus/test/unit/impact-pagination.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

395 lines
12 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.

import { describe, it, expect, vi, beforeEach } from 'vitest';
const executeQueryMock = vi.fn();
const executeParameterizedMock = vi.fn();
vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
initLbug: vi.fn(),
executeQuery: (...args: any[]) => executeQueryMock(...args),
executeParameterized: (...args: any[]) => executeParameterizedMock(...args),
closeLbug: vi.fn(),
isLbugReady: vi.fn().mockReturnValue(true),
};
});
vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
initLbug: vi.fn(),
executeQuery: (...args: any[]) => executeQueryMock(...args),
executeParameterized: (...args: any[]) => executeParameterizedMock(...args),
closeLbug: vi.fn(),
isLbugReady: vi.fn().mockReturnValue(true),
};
});
import { LocalBackend } from '../../src/mcp/local/local-backend';
import { collectImpactSymbolUids } from '../../src/core/group/cross-impact';
function makeBackend() {
const backend = new LocalBackend();
const repoHandle = {
id: 'repo1',
name: 'repo1',
repoPath: '/tmp/repo',
storagePath: '/tmp/repo/.gitnexus',
lbugPath: '/tmp/repo/.gitnexus/lbug',
indexedAt: 'now',
lastCommit: 'c',
stats: {},
} as any;
(backend as any).repos.set(repoHandle.id, repoHandle);
(backend as any).ensureInitialized = vi.fn().mockResolvedValue(undefined);
return { backend, repoHandle };
}
// The BFS frontier query is now parameterized (bound $frontierIds/$relTypes,
// #1907 U3), so the caller rows come back through executeParameterizedMock
// (matched on `r.type IN`) rather than executeQueryMock. Symbol resolution and
// the label-enrichment UNION still fall through to the default symbol row.
function setupMultiDepthHub(d1Count: number, d2Count: number) {
let depth = 0;
executeParameterizedMock.mockImplementation(async (...args: any[]) => {
const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? '');
if (query.includes('STEP_IN_PROCESS')) return [];
if (query.includes('MEMBER_OF')) return [];
// The #1858 epistemic-boundary probe (computeEpistemicBoundary) runs
// concurrently with the BFS and also matches `r.type IN`, but targets the
// `iface` alias. Return empty so it stays `epistemic: 'exact'` and does not
// consume a depth slot from the frontier counter below.
if (query.includes('iface')) return [];
if (query.includes('r.type IN')) {
depth++;
const count = depth === 1 ? d1Count : depth === 2 ? d2Count : 0;
const res: any[] = [];
for (let i = 0; i < count; i++) {
res.push({
id: `d${depth}-caller-${i}`,
name: `d${depth}caller${i}`,
filePath: `src/d${depth}-caller-${i}.ts`,
relType: 'CALLS',
confidence: null,
});
}
return res;
}
return [{ id: 'hub1', name: 'HubSymbol', filePath: 'hub.ts' }];
});
executeQueryMock.mockImplementation(async () => []);
}
function setupHubSymbol(count: number) {
executeParameterizedMock.mockImplementation(async (...args: any[]) => {
const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? '');
if (query.includes('STEP_IN_PROCESS')) return [];
if (query.includes('MEMBER_OF')) return [];
// See setupMultiDepthHub — keep the #1858 epistemic probe from matching the
// `r.type IN` caller branch below.
if (query.includes('iface')) return [];
if (query.includes('r.type IN')) {
const res: any[] = [];
for (let i = 0; i < count; i++) {
res.push({
id: `caller-${i}`,
name: `caller${i}`,
filePath: `src/caller-${i}.ts`,
relType: 'CALLS',
confidence: null,
});
}
return res;
}
return [{ id: 'hub1', name: 'HubSymbol', filePath: 'hub.ts' }];
});
executeQueryMock.mockImplementation(async () => []);
}
describe('impact: pagination and summaryOnly (#414)', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns byDepthCounts in default response', async () => {
const { backend, repoHandle } = makeBackend();
setupHubSymbol(50);
const res = await (backend as any)._impactImpl(repoHandle, {
target: 'HubSymbol',
direction: 'upstream',
maxDepth: 1,
});
expect(res.byDepthCounts).toEqual({ 1: 50 });
expect(res.impactedCount).toBe(50);
expect(res.byDepth).toBeDefined();
expect(res.byDepth[1].length).toBe(50);
});
it('limit caps byDepth symbols per depth level', async () => {
const { backend, repoHandle } = makeBackend();
setupHubSymbol(200);
const res = await (backend as any)._impactImpl(repoHandle, {
target: 'HubSymbol',
direction: 'upstream',
maxDepth: 1,
limit: 20,
});
expect(res.impactedCount).toBe(200);
expect(res.byDepthCounts).toEqual({ 1: 200 });
expect(res.byDepth[1].length).toBe(20);
expect(res.pagination).toEqual({
limit: 20,
offset: 0,
truncated: true,
});
});
it('offset skips symbols before applying limit', async () => {
const { backend, repoHandle } = makeBackend();
setupHubSymbol(200);
const res = await (backend as any)._impactImpl(repoHandle, {
target: 'HubSymbol',
direction: 'upstream',
maxDepth: 1,
limit: 20,
offset: 10,
});
expect(res.byDepth[1].length).toBe(20);
expect(res.byDepth[1][0].name).toBe('caller10');
expect(res.pagination).toEqual({
limit: 20,
offset: 10,
truncated: true,
});
});
it('no pagination metadata when all results fit within limit', async () => {
const { backend, repoHandle } = makeBackend();
setupHubSymbol(30);
const res = await (backend as any)._impactImpl(repoHandle, {
target: 'HubSymbol',
direction: 'upstream',
maxDepth: 1,
limit: 50,
});
expect(res.byDepth[1].length).toBe(30);
expect(res.pagination).toBeUndefined();
});
it('default limit of 100 caps large result sets', async () => {
const { backend, repoHandle } = makeBackend();
setupHubSymbol(400);
const res = await (backend as any)._impactImpl(repoHandle, {
target: 'HubSymbol',
direction: 'upstream',
maxDepth: 1,
});
expect(res.impactedCount).toBe(400);
expect(res.byDepthCounts).toEqual({ 1: 400 });
expect(res.byDepth[1].length).toBe(100);
expect(res.pagination).toEqual({
limit: 100,
offset: 0,
truncated: true,
});
});
it('summaryOnly omits byDepth entirely', async () => {
const { backend, repoHandle } = makeBackend();
setupHubSymbol(400);
const res = await (backend as any)._impactImpl(repoHandle, {
target: 'HubSymbol',
direction: 'upstream',
maxDepth: 1,
summaryOnly: true,
});
expect(res.impactedCount).toBe(400);
expect(res.risk).toBe('CRITICAL');
expect(res.byDepthCounts).toEqual({ 1: 400 });
expect(res.summary.direct).toBe(400);
expect(res.affected_processes).toBeDefined();
expect(res.affected_modules).toBeDefined();
expect(res.byDepth).toBeUndefined();
expect(res.pagination).toBeUndefined();
});
it('summaryOnly response is small even for hub symbols', async () => {
const { backend, repoHandle } = makeBackend();
setupHubSymbol(800);
const res = await (backend as any)._impactImpl(repoHandle, {
target: 'HubSymbol',
direction: 'upstream',
maxDepth: 1,
summaryOnly: true,
});
expect(res.impactedCount).toBe(800);
expect(res.byDepthCounts).toEqual({ 1: 800 });
expect(res.byDepth).toBeUndefined();
expect(res.pagination).toBeUndefined();
});
it('limit clamps to 1–10000 range', async () => {
const { backend, repoHandle } = makeBackend();
setupHubSymbol(10);
const resZero = await (backend as any)._impactImpl(repoHandle, {
target: 'HubSymbol',
direction: 'upstream',
maxDepth: 1,
limit: 0,
});
expect(resZero.byDepth[1].length).toBe(1);
const resNeg = await (backend as any)._impactImpl(repoHandle, {
target: 'HubSymbol',
direction: 'upstream',
maxDepth: 1,
limit: -5,
});
expect(resNeg.byDepth[1].length).toBe(1);
});
it('multi-depth: each depth paginates independently', async () => {
const { backend, repoHandle } = makeBackend();
setupMultiDepthHub(150, 50);
const res = await (backend as any)._impactImpl(repoHandle, {
target: 'HubSymbol',
direction: 'upstream',
maxDepth: 2,
limit: 30,
});
expect(res.impactedCount).toBe(200);
expect(res.byDepthCounts).toEqual({ 1: 150, 2: 50 });
expect(res.byDepth[1].length).toBe(30);
expect(res.byDepth[2].length).toBe(30);
expect(res.pagination.truncated).toBe(true);
});
it('offset-only truncation: pagination metadata present when offset > 0 even if tail fits', async () => {
const { backend, repoHandle } = makeBackend();
setupHubSymbol(50);
const res = await (backend as any)._impactImpl(repoHandle, {
target: 'HubSymbol',
direction: 'upstream',
maxDepth: 1,
limit: 100,
offset: 10,
});
expect(res.byDepth[1].length).toBe(40);
expect(res.pagination).toBeDefined();
expect(res.pagination.truncated).toBe(true);
expect(res.pagination.offset).toBe(10);
});
it('offset past end: returns empty byDepth with pagination metadata', async () => {
const { backend, repoHandle } = makeBackend();
setupHubSymbol(50);
const res = await (backend as any)._impactImpl(repoHandle, {
target: 'HubSymbol',
direction: 'upstream',
maxDepth: 1,
limit: 20,
offset: 100,
});
expect(res.impactedCount).toBe(50);
expect(res.byDepthCounts).toEqual({ 1: 50 });
expect(res.byDepth[1].length).toBe(0);
expect(res.pagination).toBeDefined();
expect(res.pagination.truncated).toBe(true);
});
it('float limit/offset are truncated to integers', async () => {
const { backend, repoHandle } = makeBackend();
setupHubSymbol(50);
const res = await (backend as any)._impactImpl(repoHandle, {
target: 'HubSymbol',
direction: 'upstream',
maxDepth: 1,
limit: 20.7,
offset: 5.9,
});
expect(res.byDepth[1].length).toBe(20);
expect(res.byDepth[1][0].name).toBe('caller5');
expect(res.pagination.limit).toBe(20);
expect(res.pagination.offset).toBe(5);
});
it('_runImpactBFS without limit returns all symbols (internal caller path)', async () => {
const { backend, repoHandle } = makeBackend();
setupHubSymbol(400);
const sym = { id: 'hub1', name: 'HubSymbol', filePath: 'hub.ts' };
const res = await (backend as any)._runImpactBFS(repoHandle, sym, 'Function', 'upstream', {
maxDepth: 1,
relationTypes: ['CALLS'],
includeTests: false,
minConfidence: 0,
});
expect(res.impactedCount).toBe(400);
expect(res.byDepth[1].length).toBe(400);
expect(res.pagination).toBeUndefined();
});
});
describe('collectImpactSymbolUids with paginated results', () => {
it('collects all UIDs from complete byDepth', () => {
const impact = {
target: { id: 'target1', filePath: 'src/target.ts' },
byDepth: {
1: [
{ id: 'a', filePath: 'src/a.ts' },
{ id: 'b', filePath: 'src/b.ts' },
{ id: 'c', filePath: 'src/c.ts' },
],
},
};
const { uids } = collectImpactSymbolUids(impact, undefined);
expect(uids).toContain('target1');
expect(uids).toContain('a');
expect(uids).toContain('b');
expect(uids).toContain('c');
expect(uids.length).toBe(4);
});
it('only gets paginated subset when byDepth is capped', () => {
const impact = {
target: { id: 'target1', filePath: 'src/target.ts' },
byDepthCounts: { 1: 300 },
byDepth: {
1: Array.from({ length: 100 }, (_, i) => ({
id: `sym-${i}`,
filePath: `src/sym-${i}.ts`,
})),
},
pagination: { limit: 100, offset: 0, truncated: true },
};
const { uids } = collectImpactSymbolUids(impact, undefined);
expect(uids.length).toBe(101);
});
});