Merge branch 'main' into codex/cpp-udc-conversion-ranking

This commit is contained in:
Gergő Magyar 2026-05-26 08:48:57 +01:00 • committed by GitHub
commit 4a01307ffe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 539 additions and 43 deletions

View file

@ -658,7 +658,7 @@ UPSTREAM (what depends on this):
authRouter [IMPORTS] -> src/routes/auth.ts
```
Options: `maxDepth`, `minConfidence`, `relationTypes` (`CALLS`, `IMPORTS`, `EXTENDS`, `IMPLEMENTS`), `includeTests`
Options: `maxDepth`, `minConfidence`, `relationTypes` (`CALLS`, `IMPORTS`, `EXTENDS`, `IMPLEMENTS`), `includeTests`, `limit` (max symbols per depth, default 100), `offset` (pagination start per depth), `summaryOnly` (counts and risk only, omits symbol list)
### Process-Grouped Search

View file

@ -206,19 +206,39 @@ export function formatImpactResult(result: any): string {
3: 'MAY NEED TESTING (transitive)',
};
for (const depth of [1, 2, 3]) {
const items = byDepth[depth];
if (!items || items.length === 0) continue;
lines.push(`d=${depth}: ${depthLabels[depth] || ''} (${items.length})`);
for (const item of items.slice(0, 12)) {
const conf = item.confidence < 1 ? ` (conf: ${item.confidence})` : '';
lines.push(` ${item.type} ${item.name} → ${item.filePath} [${item.relationType}]${conf}`);
}
if (items.length > 12) {
lines.push(` ... and ${items.length - 12} more`);
if (!result.byDepth && result.byDepthCounts) {
lines.push('(summary only — use summaryOnly: false to see symbol lists)');
const depthCounts = result.byDepthCounts;
for (const depth of [1, 2, 3]) {
const count = depthCounts[depth] ?? 0;
if (count === 0) continue;
lines.push(`d=${depth}: ${depthLabels[depth] || ''} (${count})`);
}
lines.push('');
} else {
const depthCounts = result.byDepthCounts || {};
for (const depth of [1, 2, 3]) {
const items = byDepth[depth] || [];
const trueCount = depthCounts[depth] ?? items.length;
if (trueCount === 0) continue;
lines.push(`d=${depth}: ${depthLabels[depth] || ''} (${trueCount})`);
if (items.length === 0) {
lines.push(` (0 items on this page — adjust offset)`);
} else {
const shown = Math.min(items.length, 12);
for (const item of items.slice(0, shown)) {
const conf = item.confidence < 1 ? ` (conf: ${item.confidence})` : '';
lines.push(
` ${item.type} ${item.name} → ${item.filePath} [${item.relationType}]${conf}`,
);
}
if (trueCount > shown) {
lines.push(` ... and ${trueCount - shown} more`);
}
}
lines.push('');
}
}
return lines.join('\n').trim();

View file

@ -103,6 +103,9 @@ const OPTION_DESCRIPTION_KEYS = {
'impact|-r, --repo <name>': 'help.option.repo.target',
'impact|--depth <n>': 'help.option.impact.depth',
'impact|--include-tests': 'help.option.impact.includeTests',
'impact|--limit <n>': 'help.option.impact.limit',
'impact|--offset <n>': 'help.option.impact.offset',
'impact|--summary-only': 'help.option.impact.summaryOnly',
'cypher|-r, --repo <name>': 'help.option.repo.target',
'detect-changes|-s, --scope <scope>': 'help.option.detectChanges.scope',
'detect-changes|-b, --base-ref <ref>': 'help.option.detectChanges.baseRef',

View file

@ -216,6 +216,9 @@ export const en = {
'help.option.impact.direction': 'upstream (dependants) or downstream (dependencies)',
'help.option.impact.depth': 'Max relationship depth (default: 3)',
'help.option.impact.includeTests': 'Include test files in results',
'help.option.impact.limit': 'Max symbols per depth level (default: 100)',
'help.option.impact.offset': 'Skip N symbols per depth level for pagination',
'help.option.impact.summaryOnly': 'Return counts and risk only, omit symbol list',
'help.option.detectChanges.scope': 'What to analyze: unstaged, staged, all, or compare',
'help.option.detectChanges.baseRef': 'Branch/commit for compare scope (e.g. main)',
'help.option.evalServer.host':

View file

@ -202,6 +202,9 @@ export const zhCN = {
'help.option.impact.direction': 'upstream(依赖它的项)或 downstream(它依赖的项)',
'help.option.impact.depth': '最大关系遍历深度(默认:3)',
'help.option.impact.includeTests': '在结果中包含测试文件',
'help.option.impact.limit': '每层深度最大符号数(默认:100)',
'help.option.impact.offset': '每层深度跳过 N 个符号(分页用)',
'help.option.impact.summaryOnly': '仅返回计数和风险等级,省略符号列表',
'help.option.detectChanges.scope': '分析范围:unstaged、staged、all 或 compare',
'help.option.detectChanges.baseRef': 'compare 范围的分支/提交(例如 main)',
'help.option.evalServer.host': '绑定地址(默认:127.0.0.1;用 0.0.0.0 暴露到所有网卡)',

View file

@ -225,6 +225,9 @@ program
.option('-r, --repo <name>', 'Target repository')
.option('--depth <n>', 'Max relationship depth (default: 3)')
.option('--include-tests', 'Include test files in results')
.option('--limit <n>', 'Max symbols per depth level (default: 100)')
.option('--offset <n>', 'Skip N symbols per depth level for pagination')
.option('--summary-only', 'Return counts and risk only, omit symbol list')
.action(createLazyAction(() => import('./tool.js'), 'impactCommand'));
program

View file

@ -117,6 +117,9 @@ export async function impactCommand(
repo?: string;
depth?: string;
includeTests?: boolean;
limit?: string;
offset?: string;
summaryOnly?: boolean;
},
): Promise<void> {
if (!target?.trim()) {
@ -126,12 +129,19 @@ export async function impactCommand(
try {
const backend = await getBackend();
const rawLimit = parseInt(options?.limit ?? '', 10);
const rawOffset = parseInt(options?.offset ?? '', 10);
const parsedLimit = Number.isFinite(rawLimit) ? rawLimit : undefined;
const parsedOffset = Number.isFinite(rawOffset) ? rawOffset : undefined;
const result = await backend.callTool('impact', {
target,
direction: options?.direction || 'upstream',
maxDepth: options?.depth ? parseInt(options.depth, 10) : undefined,
includeTests: options?.includeTests ?? false,
repo: options?.repo,
limit: parsedLimit,
offset: parsedOffset,
summaryOnly: options?.summaryOnly ?? undefined,
});
output(result);
} catch (err: unknown) {

View file

@ -25,6 +25,10 @@ import { getGroupDir } from './storage.js';
import { closeBridgeDb, openBridgeDbReadOnly, queryBridge, readBridgeMeta } from './bridge-db.js';
import { BRIDGE_SCHEMA_VERSION } from './bridge-schema.js';
// High limit for the local phase of group impact so collectImpactSymbolUids
// sees (nearly) all symbols. Bypasses the MCP-facing default of 100.
const GROUP_LOCAL_PHASE_LIMIT = 10000;
/** Cross-boundary hops beyond this value are clamped (multi-hop reserved for future work). */
export const MAX_SUPPORTED_CROSS_DEPTH = 1;
@ -429,6 +433,7 @@ export async function runGroupImpact(
relationTypes: relationTypes && relationTypes.length > 0 ? relationTypes : undefined,
includeTests,
minConfidence,
limit: GROUP_LOCAL_PHASE_LIMIT,
};
const deadline = Date.now() + Math.max(0, timeoutMs);

View file

@ -43,6 +43,7 @@ export interface GroupToolPort {
relationTypes?: string[];
includeTests?: boolean;
minConfidence?: number;
limit?: number;
},
): Promise<unknown>;
query(

View file

@ -304,6 +304,21 @@ export function resolveWorktreeCwd(repoPath: string, launchCwd: string): string
*/
export const REPO_ID_HASH_LENGTH = 6;
interface ImpactParams {
target: string;
target_uid?: string;
file_path?: string;
kind?: string;
direction: 'upstream' | 'downstream';
maxDepth?: number;
relationTypes?: string[];
includeTests?: boolean;
minConfidence?: number;
limit?: number;
offset?: number;
summaryOnly?: boolean;
}
export class LocalBackend {
private repos: Map<string, RepoHandle> = new Map();
private contextCache: Map<string, CodebaseContext> = new Map();
@ -2750,20 +2765,7 @@ export class LocalBackend {
};
}
private async impact(
repo: RepoHandle,
params: {
target: string;
target_uid?: string;
file_path?: string;
kind?: string;
direction: 'upstream' | 'downstream';
maxDepth?: number;
relationTypes?: string[];
includeTests?: boolean;
minConfidence?: number;
},
): Promise<any> {
private async impact(repo: RepoHandle, params: ImpactParams): Promise<any> {
try {
return await this._impactImpl(repo, params);
} catch (err: any) {
@ -2780,20 +2782,7 @@ export class LocalBackend {
}
}
private async _impactImpl(
repo: RepoHandle,
params: {
target: string;
target_uid?: string;
file_path?: string;
kind?: string;
direction: 'upstream' | 'downstream';
maxDepth?: number;
relationTypes?: string[];
includeTests?: boolean;
minConfidence?: number;
},
): Promise<any> {
private async _impactImpl(repo: RepoHandle, params: ImpactParams): Promise<any> {
await this.ensureInitialized(repo.id);
const { target, direction } = params;
@ -2896,6 +2885,9 @@ export class LocalBackend {
relationTypes: effectiveRelationTypes,
includeTests,
minConfidence,
limit: Number.isFinite(params.limit) ? params.limit : 100,
offset: Number.isFinite(params.offset) ? params.offset : 0,
summaryOnly: params.summaryOnly,
});
}
@ -2912,9 +2904,20 @@ export class LocalBackend {
relationTypes: string[];
includeTests: boolean;
minConfidence: number;
limit?: number;
offset?: number;
summaryOnly?: boolean;
},
): Promise<any> {
const { maxDepth, relationTypes, includeTests, minConfidence } = opts;
const hasExplicitLimit = typeof opts.limit === 'number' && Number.isFinite(opts.limit);
const paginationLimit = hasExplicitLimit
? Math.max(1, Math.min(Math.trunc(opts.limit!), 10000))
: Infinity;
const rawOffset =
typeof opts.offset === 'number' && Number.isFinite(opts.offset) ? opts.offset : 0;
const paginationOffset = Math.max(0, Math.trunc(rawOffset));
const summaryOnly = opts.summaryOnly ?? false;
const relTypeFilter = relationTypes.map((t) => `'${t}'`).join(', ');
const confidenceFilter = minConfidence > 0 ? ` AND r.confidence >= ${minConfidence}` : '';
@ -3326,7 +3329,13 @@ export class LocalBackend {
risk = 'MEDIUM';
}
return {
// Build per-depth counts (always included, even in summaryOnly mode)
const byDepthCounts: Record<number, number> = {};
for (const [depth, items] of Object.entries(grouped)) {
byDepthCounts[Number(depth)] = items.length;
}
const base = {
target: {
id: symId,
name: sym.name || sym[1],
@ -3342,9 +3351,37 @@ export class LocalBackend {
processes_affected: processCount,
modules_affected: moduleCount,
},
byDepthCounts,
affected_processes: affectedProcesses,
affected_modules: affectedModules,
byDepth: grouped,
};
if (summaryOnly) {
return base;
}
// Apply limit/offset pagination per depth level
const paginatedGrouped: Record<number, any[]> = {};
let anyTruncated = false;
for (const [depth, items] of Object.entries(grouped)) {
const total = items.length;
const sliced = items.slice(paginationOffset, paginationOffset + paginationLimit);
paginatedGrouped[Number(depth)] = sliced;
if (paginationOffset > 0 || paginationOffset + paginationLimit < total) {
anyTruncated = true;
}
}
return {
...base,
...(anyTruncated && {
pagination: {
...(Number.isFinite(paginationLimit) && { limit: paginationLimit }),
offset: paginationOffset,
truncated: true,
},
}),
byDepth: paginatedGrouped,
};
}
@ -3498,6 +3535,9 @@ export class LocalBackend {
if (typeof params.subgroup === 'string') impactArgs.subgroup = params.subgroup;
if (params.timeoutMs !== undefined) impactArgs.timeoutMs = params.timeoutMs;
if (params.timeout !== undefined) impactArgs.timeout = params.timeout;
// limit/offset/summaryOnly are not forwarded to group-mode impact:
// runGroupImpact uses GROUP_LOCAL_PHASE_LIMIT internally for UID
// collection and does not re-paginate the local result yet.
return svc.groupImpact(impactArgs);
}
if (method === 'query') {

View file

@ -336,13 +336,15 @@ Output includes:
- summary: direct callers, processes affected, modules affected
- affected_processes: which execution flows break and at which step
- affected_modules: which functional areas are hit (direct vs indirect)
- byDepth: all affected symbols grouped by traversal depth
- byDepth: affected symbols grouped by traversal depth (paginated by limit/offset; omitted when summaryOnly:true — use byDepthCounts for totals per depth, pagination object when truncated)
Depth groups:
- d=1: WILL BREAK (direct callers/importers)
- d=2: LIKELY AFFECTED (indirect)
- d=3: MAY NEED TESTING (transitive)
TIP: For hub symbols (base error classes, shared utilities) with many direct callers, use summaryOnly: true first to see counts and risk, then drill into specific depths with limit/offset. maxDepth alone does not bound output size when most dependents are at depth 1. limit and offset apply independently to each depth level, not to the total result set — use byDepthCounts to see totals per depth.
TIP: Default traversal uses CALLS/IMPORTS/EXTENDS/IMPLEMENTS. For class members, include HAS_METHOD and HAS_PROPERTY in relationTypes. For field access analysis, include ACCESSES in relationTypes.
Handles disambiguation: when multiple symbols share the target name, returns ranked candidates (each with a relevance score) instead of silently picking one. Use target_uid for zero-ambiguity lookup, or narrow with file_path and/or kind hints.
@ -422,6 +424,27 @@ SERVICE: optional monorepo path prefix (case-sensitive path segments). When "rep
description:
'Optional group subgroup prefix (member repo paths) limiting which repos participate in cross fan-out.',
},
limit: {
type: 'integer',
description:
'Max symbols returned in byDepth per depth level (default: 100). Single-repo only; ignored in group mode (@groupName). Use small values for hub symbols to avoid output truncation.',
default: 100,
minimum: 1,
maximum: 10000,
},
offset: {
type: 'integer',
description:
'Skip this many symbols per depth level before applying limit. Single-repo only; ignored in group mode (@groupName). Use with limit for pagination.',
default: 0,
minimum: 0,
},
summaryOnly: {
type: 'boolean',
description:
'When true, returns target, summary, risk, byDepthCounts, affected_processes, and affected_modules — omits byDepth. Single-repo only; ignored in group mode (@groupName). Use for hub symbols to get actionable signal without output explosion.',
default: false,
},
timeoutMs: {
type: 'number',
description:

View file

@ -0,0 +1,385 @@
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 };
}
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 [];
return [{ id: 'hub1', name: 'HubSymbol', filePath: 'hub.ts' }];
});
executeQueryMock.mockImplementation(async () => {
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;
});
}
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 [];
return [{ id: 'hub1', name: 'HubSymbol', filePath: 'hub.ts' }];
});
executeQueryMock.mockImplementation(async (...args: any[]) => {
const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? '');
if (query.includes('r.type IN') && !query.includes('STEP_IN_PROCESS')) {
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 [];
});
}
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);
});
});