mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
Merge branch 'main' into codex/cpp-udc-conversion-ranking
This commit is contained in:
commit
4a01307ffe
12 changed files with 539 additions and 43 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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 暴露到所有网卡)',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ export interface GroupToolPort {
|
|||
relationTypes?: string[];
|
||||
includeTests?: boolean;
|
||||
minConfidence?: number;
|
||||
limit?: number;
|
||||
},
|
||||
): Promise<unknown>;
|
||||
query(
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
385
gitnexus/test/unit/impact-pagination.test.ts
Normal file
385
gitnexus/test/unit/impact-pagination.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue