mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-08 03:08:13 +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>
395 lines
12 KiB
TypeScript
395 lines
12 KiB
TypeScript
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);
|
||
});
|
||
});
|