mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-29 06:32:28 +00:00
* 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 <test@example.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
192 lines
5.4 KiB
TypeScript
192 lines
5.4 KiB
TypeScript
/**
|
|
* Direct CLI Tool Commands
|
|
*
|
|
* Exposes GitNexus tools (query, context, impact, cypher) as direct CLI commands.
|
|
* Bypasses MCP entirely — invokes LocalBackend directly for minimal overhead.
|
|
*
|
|
* Usage:
|
|
* gitnexus query "authentication flow"
|
|
* gitnexus context --name "validateUser"
|
|
* gitnexus impact --target "AuthService" --direction upstream
|
|
* gitnexus cypher "MATCH (n:Function) RETURN n.name LIMIT 10"
|
|
*
|
|
* Note: Output goes to stdout via fs.writeSync(fd 1), bypassing LadybugDB's
|
|
* native module which captures the Node.js process.stdout stream during init.
|
|
* See the output() function for details (#324).
|
|
*/
|
|
|
|
import { writeSync } from 'node:fs';
|
|
import { LocalBackend } from '../mcp/local/local-backend.js';
|
|
import { cliErrorKey } from './cli-message.js';
|
|
import { formatDetectChangesResult } from './detect-changes-format.js';
|
|
|
|
let _backend: LocalBackend | null = null;
|
|
|
|
async function getBackend(): Promise<LocalBackend> {
|
|
if (_backend) return _backend;
|
|
_backend = new LocalBackend();
|
|
const ok = await _backend.init();
|
|
if (!ok) {
|
|
cliErrorKey('tool.noIndexed');
|
|
process.exit(1);
|
|
}
|
|
return _backend;
|
|
}
|
|
|
|
/**
|
|
* Write tool output to stdout using low-level fd write.
|
|
*
|
|
* LadybugDB's native module captures Node.js process.stdout during init,
|
|
* but the underlying OS file descriptor 1 (stdout) remains intact.
|
|
* By using fs.writeSync(1, ...) we bypass the Node.js stream layer
|
|
* and write directly to the real stdout fd (#324).
|
|
*
|
|
* Falls back to stderr if the fd write fails (e.g., broken pipe).
|
|
*/
|
|
function output(data: any): void {
|
|
const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
|
|
try {
|
|
writeSync(1, text + '\n');
|
|
} catch (err: any) {
|
|
if (err?.code === 'EPIPE') {
|
|
// Consumer closed the pipe (e.g., `gitnexus cypher ... | head -1`)
|
|
// Exit cleanly per Unix convention
|
|
process.exit(0);
|
|
}
|
|
// Fallback: stderr (previous behavior, works on all platforms)
|
|
process.stderr.write(text + '\n');
|
|
}
|
|
}
|
|
|
|
export async function queryCommand(
|
|
queryText: string,
|
|
options?: {
|
|
repo?: string;
|
|
context?: string;
|
|
goal?: string;
|
|
limit?: string;
|
|
content?: boolean;
|
|
},
|
|
): Promise<void> {
|
|
if (!queryText?.trim()) {
|
|
cliErrorKey('tool.usage.query');
|
|
process.exit(1);
|
|
}
|
|
|
|
const backend = await getBackend();
|
|
const result = await backend.callTool('query', {
|
|
query: queryText,
|
|
task_context: options?.context,
|
|
goal: options?.goal,
|
|
limit: options?.limit ? parseInt(options.limit) : undefined,
|
|
include_content: options?.content ?? false,
|
|
repo: options?.repo,
|
|
});
|
|
output(result);
|
|
}
|
|
|
|
export async function contextCommand(
|
|
name: string,
|
|
options?: {
|
|
repo?: string;
|
|
file?: string;
|
|
uid?: string;
|
|
content?: boolean;
|
|
},
|
|
): Promise<void> {
|
|
if (!name?.trim() && !options?.uid) {
|
|
cliErrorKey('tool.usage.context');
|
|
process.exit(1);
|
|
}
|
|
|
|
const backend = await getBackend();
|
|
const result = await backend.callTool('context', {
|
|
name: name || undefined,
|
|
uid: options?.uid,
|
|
file_path: options?.file,
|
|
include_content: options?.content ?? false,
|
|
repo: options?.repo,
|
|
});
|
|
output(result);
|
|
}
|
|
|
|
export async function impactCommand(
|
|
target: string,
|
|
options?: {
|
|
direction?: string;
|
|
repo?: string;
|
|
depth?: string;
|
|
includeTests?: boolean;
|
|
limit?: string;
|
|
offset?: string;
|
|
summaryOnly?: boolean;
|
|
},
|
|
): Promise<void> {
|
|
if (!target?.trim()) {
|
|
cliErrorKey('tool.usage.impact');
|
|
process.exit(1);
|
|
}
|
|
|
|
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) {
|
|
// Belt-and-suspenders: catch infrastructure failures (getBackend, callTool transport)
|
|
// The backend's impact() already returns structured errors for graph query failures
|
|
output({
|
|
error:
|
|
(err instanceof Error ? err.message : String(err)) || 'Impact analysis failed unexpectedly',
|
|
target: { name: target },
|
|
direction: options?.direction || 'upstream',
|
|
suggestion: 'Try reducing --depth or using gitnexus context <symbol> as a fallback',
|
|
});
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
export async function cypherCommand(
|
|
query: string,
|
|
options?: {
|
|
repo?: string;
|
|
},
|
|
): Promise<void> {
|
|
if (!query?.trim()) {
|
|
cliErrorKey('tool.usage.cypher');
|
|
process.exit(1);
|
|
}
|
|
|
|
const backend = await getBackend();
|
|
const result = await backend.callTool('cypher', {
|
|
query,
|
|
repo: options?.repo,
|
|
});
|
|
output(result);
|
|
}
|
|
|
|
export async function detectChangesCommand(options?: {
|
|
scope?: string;
|
|
baseRef?: string;
|
|
repo?: string;
|
|
}): Promise<void> {
|
|
const backend = await getBackend();
|
|
const result = await backend.callTool('detect_changes', {
|
|
scope: options?.scope || 'unstaged',
|
|
base_ref: options?.baseRef,
|
|
repo: options?.repo,
|
|
});
|
|
output(formatDetectChangesResult(result));
|
|
}
|