/** * P1 Integration Tests: CLI --limit flag E2E * * Verifies that the --limit flag correctly truncates results for all 5 * tool commands: context, impact, cypher, detect-changes, query. * * Uses the same subprocess spawn pattern as cli-e2e.test.ts. * Copies mini-repo fixture to a temp dir, runs analyze, then tests * --limit truncation against each command. * * Assertions are exact (per DoD.md §"Assertions are meaningful") and * unconditional — no `if (status === null) return` / `if (Array.isArray)` * guards that would let a broken --limit slice pass vacuously. Targets are * chosen so the no-limit baseline genuinely exceeds the limit (e.g. `logMessage` * has 2 callers and 4 processes), so a no-op slice turns the test red. * * @see src/cli/tool.ts — limit application logic */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { spawnSync } from 'child_process'; import path from 'path'; import fs from 'fs'; import os from 'os'; import { fileURLToPath } from 'url'; import { cleanupTempDirSync } from '../helpers/test-db.js'; import { CLI_SPAWN_PREFIX } from '../helpers/cli-entry.js'; const testDir = path.dirname(fileURLToPath(import.meta.url)); const FIXTURE_SRC = path.resolve(testDir, '..', 'fixtures', 'mini-repo'); let MINI_REPO: string; let tmpParent: string; let suiteGitnexusHome: string; /** False when setup analyze fell back to `--skip-fts` after CREATE_FTS_INDEX native-aborted. */ let ftsIndexed = false; function cliEnv(extraEnv: Record = {}) { return { ...process.env, GITNEXUS_HOME: suiteGitnexusHome, NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(), // Cold parse-worker loads every tree-sitter grammar before the ready // handshake. The default 5s budget classifies that as a deterministic // crash-loop on a loaded WSL/CI host (status 1) or the 60s spawnSync // timeout kills the child first (status null). Sibling integration // suites pin 60s. GITNEXUS_WORKER_READY_TIMEOUT_MS: process.env.GITNEXUS_WORKER_READY_TIMEOUT_MS || '60000', ...extraEnv, }; } function runCliRaw(extraArgs: string[], cwd: string, timeoutMs = 30000) { return spawnSync(process.execPath, [...CLI_SPAWN_PREFIX, ...extraArgs], { cwd, encoding: 'utf8', timeout: timeoutMs, stdio: ['pipe', 'pipe', 'pipe'], env: cliEnv(), }); } function isNativeAbort(result: ReturnType): boolean { return ( result.signal === 'SIGSEGV' || result.signal === 'SIGABRT' || result.signal === 'SIGBUS' || result.status === 139 ); } function isFatalAnalyzeHarness(result: ReturnType): boolean { return ( result.stderr?.includes('Worker script not found') === true || result.stderr?.includes('deterministic crash-loop') === true ); } /** * Parse stdout as JSON, returning null on failure (e.g., text output). */ function parseStdout(result: ReturnType): unknown { try { return JSON.parse(result.stdout.trim()); } catch { return null; } } // ─── Typed result shapes (avoid `any`; just the fields these tests read) ────── type CallBuckets = { calls?: unknown[]; accesses?: unknown[] }; type ContextResult = { incoming?: CallBuckets; outgoing?: CallBuckets; processes?: unknown[] }; type ImpactResult = { affected_processes?: unknown[]; affected_modules?: unknown[] }; type CypherTabular = { markdown?: string; row_count?: number }; type QueryResult = { processes?: unknown[] }; /** Run a JSON tool command, asserting it exited 0 and produced parseable JSON. */ function runJson(args: string[]): T { const r = runCliRaw(args, MINI_REPO); expect(r.status, `exit nonzero — stderr: ${r.stderr}`).toBe(0); const data = parseStdout(r); expect(data, `stdout not JSON: ${r.stdout.slice(0, 200)}`).toBeTruthy(); return data as T; } /** Run a text-output tool command, asserting it exited 0. */ function runText(args: string[]): string { const r = runCliRaw(args, MINI_REPO); expect(r.status, `exit nonzero — stderr: ${r.stderr}`).toBe(0); return r.stdout; } /** detect-changes lists symbols as " Symbol name → file"; count those lines. */ function countChangedSymbolLines(stdout: string): number { return stdout.split('\n').filter((line) => /^\s+\w+\s+\w+\s+→/.test(line)).length; } const len = (a?: unknown[]): number => (Array.isArray(a) ? a.length : 0); // ─── Setup ─────────────────────────────────────────────────────────────────── beforeAll(() => { tmpParent = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-cli-limit-')); suiteGitnexusHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-cli-limit-home-')); MINI_REPO = path.join(tmpParent, 'mini-repo'); fs.cpSync(FIXTURE_SRC, MINI_REPO, { recursive: true }); // Initialize as git repo spawnSync('git', ['init'], { cwd: MINI_REPO, stdio: 'pipe' }); spawnSync('git', ['add', '-A'], { cwd: MINI_REPO, stdio: 'pipe' }); spawnSync('git', ['commit', '-m', 'initial commit'], { cwd: MINI_REPO, stdio: 'pipe', env: { ...process.env, GIT_AUTHOR_NAME: 'test', GIT_AUTHOR_EMAIL: 'test@test', GIT_COMMITTER_NAME: 'test', GIT_COMMITTER_EMAIL: 'test@test', }, }); // Index once so every --limit command has a registered repo. Match cli-e2e: // a tiny fixture analyzes in seconds on a quiet machine, but spawnSync // status null is SIGTERM from the timeout under load (not an analyze // exit). Retry timeouts; alreadyUpToDate makes a repeat cheap. // // CREATE_FTS_INDEX can SIGSEGV the analyze process on some WSL/native // hosts (status null, signal SIGSEGV) even when `doctor` reports FTS // LOAD-able. Do not retry that path — rebuild with --skip-fts so graph // tools still run. query --limit needs BM25 and is skipped in that case. let analyzeResult: ReturnType | undefined; for (let attempt = 0; attempt < 3; attempt++) { analyzeResult = runCliRaw(['analyze', '--force'], MINI_REPO, 90_000); if (analyzeResult.status === 0) { ftsIndexed = true; break; } if (isFatalAnalyzeHarness(analyzeResult) || isNativeAbort(analyzeResult)) break; } if ( analyzeResult && !ftsIndexed && isNativeAbort(analyzeResult) && !isFatalAnalyzeHarness(analyzeResult) ) { analyzeResult = runCliRaw(['analyze', '--force', '--skip-fts'], MINI_REPO, 90_000); } if (!analyzeResult || analyzeResult.status !== 0) { const err = analyzeResult?.error; throw new Error( `Analyze failed (status ${analyzeResult?.status}, signal ${analyzeResult?.signal}, error ${err?.message ?? 'none'}):\nstdout: ${analyzeResult?.stdout}\nstderr: ${analyzeResult?.stderr}`, ); } }, 300_000); afterAll(() => { if (tmpParent) cleanupTempDirSync(tmpParent); if (suiteGitnexusHome) cleanupTempDirSync(suiteGitnexusHome); }); // ─── Tests ─────────────────────────────────────────────────────────────────── describe('CLI --limit flag E2E', () => { // `logMessage` has 2 callers (processRequest, errorMiddleware) and participates // in 4 processes — so its baseline genuinely exceeds `--limit 1`, making the // truncation assertions non-vacuous. // ─── context ──────────────────────────────────────────────────────────── describe('context --limit', () => { it('truncates incoming/outgoing calls and processes to --limit 1', () => { const limited = runJson([ 'context', 'logMessage', '--limit', '1', '--repo', 'mini-repo', ]); expect(len(limited.incoming?.calls)).toBe(1); expect(len(limited.outgoing?.calls)).toBe(1); expect(len(limited.processes)).toBe(1); }); it('returns the full set without --limit (baseline exceeds the limit)', () => { const base = runJson(['context', 'logMessage', '--repo', 'mini-repo']); expect(len(base.incoming?.calls)).toBe(2); expect(len(base.outgoing?.calls)).toBe(2); expect(len(base.processes)).toBe(4); }); it('treats --limit 0 as no limit (resolves to undefined)', () => { const zero = runJson([ 'context', 'logMessage', '--limit', '0', '--repo', 'mini-repo', ]); const base = runJson(['context', 'logMessage', '--repo', 'mini-repo']); expect(len(zero.processes)).toBe(len(base.processes)); expect(len(zero.incoming?.calls)).toBe(len(base.incoming?.calls)); }); it('treats a non-numeric --limit as no limit (no silent empty)', () => { // Regression for the headline bug: `--limit abc` used to parse to NaN → // slice(0, NaN) === [] → results silently emptied with exit 0. parseLimit() // now rejects non-numeric input, so it must behave exactly like no --limit. const invalid = runJson([ 'context', 'logMessage', '--limit', 'abc', '--repo', 'mini-repo', ]); const base = runJson(['context', 'logMessage', '--repo', 'mini-repo']); const total = (d: ContextResult) => len(d.incoming?.calls) + len(d.outgoing?.calls) + len(d.outgoing?.accesses) + len(d.processes); expect(total(invalid)).toBe(total(base)); expect(total(invalid)).toBeGreaterThan(0); // not the old silent-empty }); }); // ─── impact ───────────────────────────────────────────────────────────── describe('impact --limit', () => { it('truncates affected_processes/modules to --limit 1', () => { const limited = runJson([ 'impact', 'logMessage', '--direction', 'upstream', '--limit', '1', '--repo', 'mini-repo', ]); expect(len(limited.affected_processes)).toBe(1); expect(len(limited.affected_modules)).toBe(1); }); it('returns the full affected set without --limit (baseline exceeds the limit)', () => { const base = runJson([ 'impact', 'logMessage', '--direction', 'upstream', '--repo', 'mini-repo', ]); expect(len(base.affected_processes)).toBe(2); expect(len(base.affected_modules)).toBe(2); }); it('treats --limit 0 as no limit', () => { const zero = runJson([ 'impact', 'logMessage', '--direction', 'upstream', '--limit', '0', '--repo', 'mini-repo', ]); const base = runJson([ 'impact', 'logMessage', '--direction', 'upstream', '--repo', 'mini-repo', ]); expect(len(zero.affected_processes)).toBe(len(base.affected_processes)); expect(len(zero.affected_modules)).toBe(len(base.affected_modules)); }); }); // ─── cypher ─────────────────────────────────────────────────────────────── describe('cypher --limit', () => { it('truncates tabular result rows to --limit and keeps row_count honest', () => { const limited = runJson([ 'cypher', 'MATCH (n:Function) RETURN n.name AS name LIMIT 100', '--limit', '2', '--repo', 'mini-repo', ]); expect(limited.row_count).toBe(2); // header + separator + exactly 2 data rows expect((limited.markdown ?? '').split('\n')).toHaveLength(4); }); it('slices multi-line-cell rows by logical row, not physical line (#2310)', () => { // n.content holds multi-line source; the markdown table must still slice to // exactly `--limit` complete rows (regression for the corruption fix). const limited = runJson([ 'cypher', 'MATCH (n:Function) RETURN n.name AS name, n.content AS content LIMIT 8', '--limit', '3', '--repo', 'mini-repo', ]); expect(limited.row_count).toBe(3); const lines = (limited.markdown ?? '').split('\n'); expect(lines).toHaveLength(5); // header + separator + 3 rows, no row spanning lines expect(limited.markdown ?? '').not.toMatch(/\n[^|]/); }); it('returns more rows without --limit (baseline exceeds the limit)', () => { const base = runJson([ 'cypher', 'MATCH (n:Function) RETURN n.name AS name LIMIT 100', '--repo', 'mini-repo', ]); expect(base.row_count).toBeGreaterThan(2); }); }); // ─── detect-changes ─────────────────────────────────────────────────────── describe('detect-changes --limit', () => { // Modify two exported functions in two files → two changed symbols, so // `--limit 1` truncates the listed symbols from 2 to 1. Idempotent: re-runs // don't change the symbol set. (Edits land in the temp copy only.) function makeTwoSymbolChange() { const edits: Array<[string, RegExp, string]> = [ ['src/logger.ts', /export function logMessage\([^)]*\)[^{]*\{/, '\n const _touchLog = 1;'], [ 'src/middleware.ts', /export function processRequest\([^)]*\)[^{]*\{/, '\n const _touchMw = 1;', ], ]; for (const [rel, re, insert] of edits) { const p = path.join(MINI_REPO, rel); const src = fs.readFileSync(p, 'utf8'); if (src.includes(insert.trim())) continue; // idempotent fs.writeFileSync( p, src.replace(re, (m) => m + insert), ); } } it('truncates changed_symbols to --limit 1', () => { makeTwoSymbolChange(); const stdout = runText(['detect-changes', '--limit', '1', '--repo', 'mini-repo']); expect(countChangedSymbolLines(stdout)).toBe(1); }); it('lists both changed symbols without --limit (baseline exceeds the limit)', () => { makeTwoSymbolChange(); const stdout = runText(['detect-changes', '--repo', 'mini-repo']); expect(countChangedSymbolLines(stdout)).toBe(2); }); it('treats --limit 0 as no limit', () => { makeTwoSymbolChange(); const zero = runText(['detect-changes', '--limit', '0', '--repo', 'mini-repo']); const base = runText(['detect-changes', '--repo', 'mini-repo']); expect(countChangedSymbolLines(zero)).toBe(countChangedSymbolLines(base)); }); it('header total, listed count, and overflow marker stay consistent under --limit', () => { // Header keeps the TRUE total (2 symbols), the list is capped to 1, and the // overflow marker reports the real remainder (1) — not the sliced length. makeTwoSymbolChange(); const stdout = runText(['detect-changes', '--limit', '1', '--repo', 'mini-repo']); expect(countChangedSymbolLines(stdout)).toBe(1); expect(stdout).toMatch(/2 symbols/); expect(stdout).toMatch(/and 1 more/); }); }); // ─── query ────────────────────────────────────────────────────────────── describe('query --limit', () => { beforeEach((ctx) => { if (ftsIndexed) return; if (process.env.GITNEXUS_REQUIRE_FTS === '1') { throw new Error( 'GITNEXUS_REQUIRE_FTS=1 but setup analyze native-aborted during CREATE_FTS_INDEX; ' + 'query --limit cannot be verified without BM25.', ); } ctx.skip( 'query --limit needs BM25; CREATE_FTS_INDEX native-aborted and analyze fell back to --skip-fts', ); }); it('truncates processes to --limit 1', () => { // "message" matches logMessage / createLogEntry / formatLogEntry → 4 processes const limited = runJson([ 'query', 'message', '--limit', '1', '--repo', 'mini-repo', ]); expect(len(limited.processes)).toBe(1); }); it('returns more processes without --limit (baseline exceeds the limit)', () => { const base = runJson(['query', 'message', '--repo', 'mini-repo']); expect(len(base.processes)).toBeGreaterThan(1); }); }); });