From c916c8836159ceae7ff1c082549fc6d51a6a671f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Tue, 26 May 2026 08:48:24 +0100 Subject: [PATCH] feat(mcp): add limit/offset/summaryOnly pagination to impact tool (#1818) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mcp): add limit/offset/summaryOnly pagination to impact tool (#414) The impact tool returns unbounded byDepth arrays for hub symbols (base error classes, shared utilities), producing 140KB+ responses that get truncated by MCP clients. maxDepth alone does not help when most dependents are at depth 1. Add three new parameters: - summaryOnly: returns counts/risk/processes/modules without byDepth - limit: caps symbols per depth level (default 100) - offset: skips symbols for pagination Also adds byDepthCounts to all responses so agents can see total counts even when the symbol list is paginated or omitted. Closes #414 * fix(mcp): prevent pagination from silently truncating cross-repo impact Address review findings on #1818: - F1 (blocker): _runImpactBFS no longer defaults to limit 100 when limit is not set — only _impactImpl (MCP entry) applies the default. Internal callers (impactByUid, group impact) get complete results. GroupToolPort.impact interface gains optional limit param, and cross-impact.ts passes limit: 10000 for local UID collection. - F2 (blocker): tool description updated — byDepth is now documented as paginated, not 'all affected symbols'. - F3: impactByUid calls _runImpactBFS without limit, so Phase-2 neighbor results are no longer capped at 100. - F4: pagination metadata now appears when offset > 0 (head truncation), not just tail truncation. Pagination.limit is null when uncapped. - F5: limit/offset schema types changed from number to integer; Math.trunc applied in implementation as defense-in-depth. - F6: 7 new tests — multi-depth pagination, offset-only truncation, offset past end, float inputs, _runImpactBFS internal uncapped path, collectImpactSymbolUids with paginated vs complete data. * fix(mcp): NaN guard on pagination params, complete GroupToolPort interface - Add Number.isFinite guard to limit/offset in _runImpactBFS so NaN inputs fall through to uncapped/zero defaults instead of producing silent empty byDepth with no truncation signal. - Add offset and summaryOnly to GroupToolPort.impact interface to match the implementation and prevent silent param loss at the port boundary. - Replace bounds-only toBeLessThan assertion with exact byDepthCounts and pagination assertions per DoD §2.7. * fix(mcp): address remaining review findings for impact pagination - #3: Forward limit/offset/summaryOnly through callToolAtGroupRepo so group-mode MCP callers can use the new pagination params. - #4: Extract GROUP_LOCAL_PHASE_LIMIT constant from magic 10000 in cross-impact.ts with a comment explaining the intent. - #7: eval-server formatImpactResult uses byDepthCounts[depth] for the 'and N more' suffix instead of paginated slice length. - #8: Extract ImpactParams interface from duplicate inline type definitions in impact() and _impactImpl(). - #9: Add --limit, --offset, --summary-only CLI flags to the impact command with i18n help strings (en + zh-CN). - #10: Clarify in tool description that limit/offset apply per depth level, not per total result set. * chore(autofix): apply prettier + eslint fixes via /autofix command * @ fix(mcp): address Copilot review feedback on impact pagination - Sanitize limit/offset with Number.isFinite in _impactImpl to prevent NaN passthrough from bypassing the default limit of 100 - Omit pagination.limit field instead of emitting null when paginationLimit is Infinity, keeping the response schema consistent - Move GROUP_LOCAL_PHASE_LIMIT after all imports in cross-impact.ts - Stop forwarding limit/offset/summaryOnly to group-mode impact since runGroupImpact overrides limit with GROUP_LOCAL_PHASE_LIMIT for UID collection and does not re-paginate - Validate CLI parseInt results with Number.isFinite before passing to the backend, falling back to undefined so defaults apply - Use byDepthCounts to decide whether to render depth sections in formatImpactResult, handling empty pages from offset past end @ * @ fix(mcp): address code review findings on impact pagination - Fix formatImpactResult "N more" count: use Math.min(items.length, 12) instead of hardcoded 12, so paginated pages with <12 items show the correct remaining count - Detect summaryOnly responses (byDepth absent, byDepthCounts present) and show a summary-mode message instead of misleading "(0 items on this page — adjust offset)" per depth level - Document that limit/offset/summaryOnly are single-repo only and ignored in group mode (@groupName) in MCP tool schema descriptions - List byDepthCounts in summaryOnly description and note byDepth absence when summaryOnly is true - Remove unused limit/offset/summaryOnly from GroupToolPort.impact interface since they are never forwarded to group impact - Deduplicate parseInt calls in CLI tool.ts: extract to local variables with consistent optional-chain usage @ * chore(autofix): apply prettier + eslint fixes via /autofix command * @ fix(group): restore limit in GroupToolPort.impact interface cross-impact.ts passes limit: GROUP_LOCAL_PHASE_LIMIT through the GroupToolPort.impact interface for UID collection. Only offset and summaryOnly were truly unused — limit must stay. @ * @ docs: add limit/offset/summaryOnly to impact tool options in README @ --------- Co-authored-by: Test Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- README.md | 2 +- gitnexus/src/cli/eval-server.ts | 42 +- gitnexus/src/cli/help-i18n.ts | 3 + gitnexus/src/cli/i18n/en.ts | 3 + gitnexus/src/cli/i18n/zh-CN.ts | 3 + gitnexus/src/cli/index.ts | 3 + gitnexus/src/cli/tool.ts | 10 + gitnexus/src/core/group/cross-impact.ts | 5 + gitnexus/src/core/group/service.ts | 1 + gitnexus/src/mcp/local/local-backend.ts | 100 +++-- gitnexus/src/mcp/tools.ts | 25 +- gitnexus/test/unit/impact-pagination.test.ts | 385 +++++++++++++++++++ 12 files changed, 539 insertions(+), 43 deletions(-) create mode 100644 gitnexus/test/unit/impact-pagination.test.ts diff --git a/README.md b/README.md index 1b77686a3..39b09b7d2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/gitnexus/src/cli/eval-server.ts b/gitnexus/src/cli/eval-server.ts index d8e171947..f28225e0c 100644 --- a/gitnexus/src/cli/eval-server.ts +++ b/gitnexus/src/cli/eval-server.ts @@ -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(); diff --git a/gitnexus/src/cli/help-i18n.ts b/gitnexus/src/cli/help-i18n.ts index 1c4312112..8d620fc6f 100644 --- a/gitnexus/src/cli/help-i18n.ts +++ b/gitnexus/src/cli/help-i18n.ts @@ -103,6 +103,9 @@ const OPTION_DESCRIPTION_KEYS = { 'impact|-r, --repo ': 'help.option.repo.target', 'impact|--depth ': 'help.option.impact.depth', 'impact|--include-tests': 'help.option.impact.includeTests', + 'impact|--limit ': 'help.option.impact.limit', + 'impact|--offset ': 'help.option.impact.offset', + 'impact|--summary-only': 'help.option.impact.summaryOnly', 'cypher|-r, --repo ': 'help.option.repo.target', 'detect-changes|-s, --scope ': 'help.option.detectChanges.scope', 'detect-changes|-b, --base-ref ': 'help.option.detectChanges.baseRef', diff --git a/gitnexus/src/cli/i18n/en.ts b/gitnexus/src/cli/i18n/en.ts index e808f244b..51cbc2816 100644 --- a/gitnexus/src/cli/i18n/en.ts +++ b/gitnexus/src/cli/i18n/en.ts @@ -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': diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts index 9e896f33e..2a1374fc6 100644 --- a/gitnexus/src/cli/i18n/zh-CN.ts +++ b/gitnexus/src/cli/i18n/zh-CN.ts @@ -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 暴露到所有网卡)', diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index a5347effd..91f6cdc13 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -225,6 +225,9 @@ program .option('-r, --repo ', 'Target repository') .option('--depth ', 'Max relationship depth (default: 3)') .option('--include-tests', 'Include test files in results') + .option('--limit ', 'Max symbols per depth level (default: 100)') + .option('--offset ', '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 diff --git a/gitnexus/src/cli/tool.ts b/gitnexus/src/cli/tool.ts index dd15f09c8..0e70c3970 100644 --- a/gitnexus/src/cli/tool.ts +++ b/gitnexus/src/cli/tool.ts @@ -117,6 +117,9 @@ export async function impactCommand( repo?: string; depth?: string; includeTests?: boolean; + limit?: string; + offset?: string; + summaryOnly?: boolean; }, ): Promise { 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) { diff --git a/gitnexus/src/core/group/cross-impact.ts b/gitnexus/src/core/group/cross-impact.ts index eab942a62..e8db52dd5 100644 --- a/gitnexus/src/core/group/cross-impact.ts +++ b/gitnexus/src/core/group/cross-impact.ts @@ -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); diff --git a/gitnexus/src/core/group/service.ts b/gitnexus/src/core/group/service.ts index d0473048f..b957db15b 100644 --- a/gitnexus/src/core/group/service.ts +++ b/gitnexus/src/core/group/service.ts @@ -43,6 +43,7 @@ export interface GroupToolPort { relationTypes?: string[]; includeTests?: boolean; minConfidence?: number; + limit?: number; }, ): Promise; query( diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 83c3f023f..cf9ab483b 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -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 = new Map(); private contextCache: Map = 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 { + private async impact(repo: RepoHandle, params: ImpactParams): Promise { 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 { + private async _impactImpl(repo: RepoHandle, params: ImpactParams): Promise { 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 { 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 = {}; + 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 = {}; + 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') { diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index 9300f5ae5..15b7dc7d4 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -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: diff --git a/gitnexus/test/unit/impact-pagination.test.ts b/gitnexus/test/unit/impact-pagination.test.ts new file mode 100644 index 000000000..a9d604c26 --- /dev/null +++ b/gitnexus/test/unit/impact-pagination.test.ts @@ -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); + }); +});