GitNexus/gitnexus/test/integration/cli-limit-e2e.test.ts
Gergő Magyar 8402963198
fix(ci): shard platform-sensitive matrix + spawn built CLI to fix Windows cross-platform timeout (#2394)
* fix(ci): shard platform-sensitive matrix + spawn built CLI to fix Windows cross-platform timeout

The `windows-latest (platform-sensitive)` job was hitting its 15-min internal
vitest watchdog in run-cross-platform.ts. It's cumulative slowness, not a hang:
the fixed 72-file suite is dominated by ~50 CLI/worker process spawns, and
Windows is ~5x slower than macOS at process startup (macOS ran the same set in
~3min of tests). Two complementary changes bring it back under the watchdog with
headroom, without touching any test assertion:

- Shard the platform-sensitive matrix (windows/macos × shard [1,2]) and forward
  `--shard=i/2` through run-cross-platform.ts to vitest, which partitions the
  fixed file list deterministically (sha1, equal file-count) — halving each
  runner. macOS/Ubuntu were already under budget.
- New test/helpers/cli-entry.ts (`CLI_SPAWN_PREFIX`): spawn the built
  `dist/cli/index.js` when `GITNEXUS_E2E_CLI=dist` (set on the cross-platform job,
  which already builds) instead of `node --import tsx src/cli/index.ts`, which
  re-transpiles the whole CLI on every spawn. Defaults to tsx-on-source so local
  runs always reflect current source; `GITNEXUS_E2E_CLI=dist` on an unbuilt tree
  throws an actionable "run npm run build" error. dist is opt-in only — never
  inferred from a generic `CI` env — so an ambient `CI=1` can't silently run a
  stale build. Converted 8 spawn-based e2e suites; added test/unit/cli-entry.test.ts.

The Ubuntu coverage job leaves `GITNEXUS_E2E_CLI` unset, so the tsx-on-source path
stays exercised in CI too (both entry points covered).

Measured on Linux: cli-limit-e2e 121.5s→91s, cli-e2e 289s→217s (~25%); larger on
Windows where the transpile is a bigger share of each spawn.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(ci): derive platform-sensitive shard count from one source (#2394)

The shard total was hardcoded in three coupled, unenforced places (matrix
length, job-name suffix, --shard denominator); editing one without the others
silently dropped a shard's tests with green CI. Add a checkout-free shard-plan
job whose single TOTAL generates both the shard index list (consumed via
fromJSON) and the /N denominator (job name + --shard arg), so they cannot
drift. Asserts TOTAL>=1 to rule out an empty-matrix silent skip. No behavior
change — still 2 shards per OS.

Addresses PR #2394 tri-review finding F2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ci): 3 shards for real Windows headroom + honest sharding comments (#2394)

vitest shards by file COUNT, not runtime, so the heaviest spawn suites cluster
into one shard: live CI showed Windows shard 1/2 at 12m12s (~81% of the 15-min
watchdog) vs shard 2/2 at 3m0s. The old comments claimed "comfortable/generous
headroom", which the count-based split doesn't deliver at 2 shards. Bump TOTAL
to 3 (one line, single source) so even the busiest Windows shard clears the
watchdog, and reword the comments to describe count-based (not time-based)
sharding.

Addresses PR #2394 tri-review finding F1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(ci): extract testable parseShardArg from run-cross-platform (#2394)

The --shard parse/forward glue had no unit test. Extract it into a pure
scripts/shard-arg.ts (mirroring the computeSpawnPrefix extraction precedent) so
the branch logic is lockable without the script's top-level execFileSync, and
add test/unit/shard-arg.test.ts (absent -> undefined, valid token -> passed
through, found amid other args). Behavior unchanged; U4 adds the malformed
fail-loud on top.

Addresses PR #2394 tri-review finding F3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ci): fail loud on a malformed --shard arg (#2394)

A shard-shaped-but-malformed arg (--shard=1, --shard, --shard=abc) was silently
ignored, dropping the shard flag so both legs ran the full unsharded ~50-spawn
suite — re-arming the Windows watchdog timeout with no signal. parseShardArg now
throws an actionable error on any --shard/--shard=… arg that fails the strict
regex (unrelated flags like --shardx= pass through), and the call site in
run-cross-platform.ts catches it into console.error + exit 1, kept outside the
execFileSync try so the message isn't swallowed by that catch's watchdog-only
branch.

Addresses PR #2394 tri-review finding F4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(test): fail loud on an unknown GITNEXUS_E2E_CLI value (#2394)

computeSpawnPrefix silently degraded any unknown GITNEXUS_E2E_CLI value to
tsx-on-source, so a typo (e.g. `dsit`) would make CI believe it tests the dist
entry point while actually running src. Throw on any value other than
'dist'/'src'/unset (the safe tsx default is preserved for unset/''/'src', so it
still never selects dist without an explicit opt-in). Flip the unknown-mode unit
test to assert the throw and add the missing {mode:undefined, distExists:true}
case. Only ci-tests.yml sets the var (=dist), so no existing suite is affected.

Addresses PR #2394 tri-review findings minor-a/b.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(ci): run cli-entry.test.ts on the cross-platform matrix (#2394)

cli-entry.test.ts resolves CLI_SPAWN_PREFIX from a real path, and its last
assertion (cli[/\\]index) has a Windows backslash branch that only Ubuntu
exercised. Register it in PLATFORM_LOGIC so it runs on the Windows/macOS matrix
too. (shard-arg.test.ts stays out — pure string logic, OS-independent.) List
grows 73 -> 74; the generated shard matrix keeps coverage complete.

Addresses PR #2394 tri-review finding minor-c.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(test): share tsxLoaderUrl(), dedup the last tsx-loader boilerplate (#2394)

bridge-cache-reopen.test.ts carried its own copy of the tsx-loader-resolution
boilerplate (createRequire -> resolve('tsx/package.json') -> pathToFileURL) —
the one site the PR's CLI_SPAWN_PREFIX migration didn't cover (it spawns a seed
script, not the CLI). Export the existing tsxLoaderUrl() from cli-entry.ts and
reuse it here; the resolved loader URL is byte-identical.

Addresses PR #2394 tri-review finding minor-d.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(test): make skipUnlessFtsAvailable install FTS on miss so shards are self-sufficient (#2394)

Sharding the platform-sensitive suite into 3 exposed a latent test-isolation
bug: load-only FTS primitives (test/integration/lbug-core-adapter.test.ts) only
passed because a sibling installer test happened to co-locate in the same shard
and install FTS into the shared ~/.lbdb first. At 3 shards, lbug-core-adapter
landed in a shard with no installer sibling, so its load-only loadFTSExtension()
failed deterministically on macOS+Windows shard 2/3 under GITNEXUS_REQUIRE_FTS=1.

Make the gate self-sufficient: on a load-only miss under REQUIRE_FTS, install
FTS with `auto` (LOAD-first, then one bounded network INSTALL) before treating
it as a hard failure — mirroring withTestIndexedDB. A pre-installed extension
still costs no network (auto is LOAD-first); offline/local runs (no env var)
still skip gracefully. Verified: with a fresh HOME (no pre-installed FTS) +
REQUIRE_FTS=1, lbug-core-adapter now passes 15/15 (previously threw).

Addresses the 3-shard CI failure surfaced while validating PR #2394's F1 fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci: warm-cache the LadybugDB FTS extension across platform shards (#2394)

Follow-up to the FTS self-install fix: cache ~/.lbdb/extension per OS + lockfile
so a warm run skips the network install entirely and the parallel shards share
one download across runs. Pure reliability/speed — on a cache miss the tests
still self-install FTS on demand (test/helpers/fts-availability.ts), so this is
never a correctness dependency, just a way to cut the network-install surface
that made the sharded FTS tests flaky. Keyed by lockfile hash (a LadybugDB
version bump re-installs); per-OS since the extension is a native binary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ci): pass shard via env to clear zizmor template-injection (#2394)

Interpolating ${{ matrix.shard }} (now sourced from the shard-plan job output)
directly into the run: shell tripped zizmor's template-injection audit
(code-scanning alert #824, ci-tests.yml:147). Move the value into a SHARD env
var — assigned via ${{ }} but referenced as "$SHARD" in the shell, which is not
an injection sink — and set shell: bash so the expansion is uniform across the
windows + macOS matrix (the default run shell is pwsh on Windows, where $SHARD
would be empty and trip the new malformed-shard fail-loud). Verified locally
with zizmor: the :147 template-injection finding is gone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci: shard the ubuntu coverage job and merge blobs before the threshold gate (#2394)

The coverage job ran the full suite unsharded (~16 min). Shard it like the
cross-platform matrix, then merge the per-shard coverage before enforcing the
threshold gate:

- shard-plan now also single-sources the coverage shard count (cov_total /
  cov_shards), so the coverage matrix + /N denominator can't drift.
- The `tests` job becomes a coverage shard matrix: each shard runs
  `vitest run --shard --coverage --reporter=blob` with thresholds forced to 0
  (a single shard's partial coverage can never meet the gate) and uploads its
  blob. FTS self-installs per shard, so sharding the full suite is safe.
- New `coverage-merge` job (needs: tests) reduces the blobs with
  `vitest --mergeReports`, enforcing the REAL config thresholds on the combined
  ('new') coverage — this is the gate. It also emits the merged test-results.json
  and runs the unsharded web + docker suites, so the `test-reports` artifact
  keeps the exact shape ci-report.yml consumes for its base-branch ('baseline')
  vs new coverage delta.

The shard arg goes through a SHARD env var + shell: bash (no template-injection).
Validated locally: shard blobs write and merge into a coverage-summary.json +
merged test-results.json; the merge enforces thresholds on the union. CI Gate
still aggregates the coverage-merge result via the reusable-workflow call.

Note: the coverage check names change (ubuntu / coverage 1/3 … + merge) — update
any pinned branch-protection required checks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ci): include hidden files when uploading the coverage blob (#2394)

The coverage shards write their blob to gitnexus/.vitest-reports/ (a dotdir).
actions/upload-artifact excludes hidden files by default, so the coverage-blob-*
artifacts uploaded empty — the merge job then downloaded 0 artifacts and
vitest --mergeReports failed with ENOENT scandir '.vitest-reports'. Set
include-hidden-files: true on the blob upload so the blobs actually ship.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ci): group shard-plan GITHUB_OUTPUT writes to satisfy shellcheck SC2129 (#2394)

Adding the coverage shard outputs (cov_shards/cov_total) made the shard-plan gen
step write four individual `>> "$GITHUB_OUTPUT"` redirects, which shellcheck
(run by the actionlint check) flags as SC2129. Group the echoes into a single
`{ …; } >> "$GITHUB_OUTPUT"` block.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(test): cost-balanced shard sequencer to cut CPU contention (#2394)

vitest's default --shard hashes file paths and splits by file COUNT, which
clustered the spawn-heavy suites onto one runner (Windows platform shard 1 ran
~4x the others). Add a custom sequence.sequencer that overrides only shard()
and balances by estimated WORK instead:

- specWeight() weights the fileParallelism:false spawn-heavy suites (cli-e2e,
  lbug-db — already isolated to run sequentially) far above the parallel default
  files, plus file size as a cheap finer signal. Deterministic per checkout.
- assignShards() does greedy longest-processing-time bin-packing (heaviest file
  into the currently-lightest shard). The partition stays complete and disjoint
  — verified: on the 74-file cross-platform set the three shards weigh
  7611/7610/8064 (the sequential-heavy files spread ~7/7/8) with zero overlap and
  no file dropped, vs the hash split's count-only balance.

sort() is left to the base sequencer so project groupOrder / duration-cache
ordering is untouched. Pure logic split into shard-balance.ts with a unit test
locking the disjoint+complete, balance, and determinism properties.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ci): install + cache FTS up front on the coverage (and cross-platform) shards (#2394)

coverage 3/3 failed on extension-binary-real.test.ts: it uses the file-path FTS
gate (requireFtsResourceOrSkip), which resolves ~/.lbdb/extension at MODULE LOAD
and cannot self-install the way the load-path gate (skipUnlessFtsAvailable, U8)
does. The coverage job had no FTS cache and relied on an installer test running
first in the shard — the balancing sequencer reshuffled the shards and dropped
extension-binary-real into a shard with no installer, so FTS was absent.

Remove the ordering dependency: add scripts/ensure-fts.ts (init a throwaway lbug
db, loadFTSExtension with policy:auto → LOAD-first, INSTALL on miss) and run it
up front on every coverage AND cross-platform shard, after restoring the per-OS
FTS cache. The coverage job now shares that same cache key (it previously had
none — this is the "share the cached FTS with coverage" the failure pointed at).
Cold cache installs once; warm cache is a no-network load. Verified locally:
ensure-fts installs FTS into a fresh HOME and is a no-op when already present.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 09:09:11 +01:00

378 lines
14 KiB
TypeScript

/**
* 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;
function cliEnv(extraEnv: Record<string, string> = {}) {
return {
...process.env,
GITNEXUS_HOME: suiteGitnexusHome,
NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(),
...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(),
});
}
/**
* Parse stdout as JSON, returning null on failure (e.g., text output).
*/
function parseStdout(result: ReturnType<typeof runCliRaw>): 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<T>(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',
},
});
// Run analyze to populate .gitnexus/ index (required for all tool commands)
const analyzeResult = runCliRaw(['analyze', '--force'], MINI_REPO, 60000);
if (analyzeResult.status !== 0) {
throw new Error(
`Analyze failed (status ${analyzeResult.status}):\nstdout: ${analyzeResult.stdout}\nstderr: ${analyzeResult.stderr}`,
);
}
});
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<ContextResult>([
'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<ContextResult>(['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<ContextResult>([
'context',
'logMessage',
'--limit',
'0',
'--repo',
'mini-repo',
]);
const base = runJson<ContextResult>(['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<ContextResult>([
'context',
'logMessage',
'--limit',
'abc',
'--repo',
'mini-repo',
]);
const base = runJson<ContextResult>(['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<ImpactResult>([
'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<ImpactResult>([
'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<ImpactResult>([
'impact',
'logMessage',
'--direction',
'upstream',
'--limit',
'0',
'--repo',
'mini-repo',
]);
const base = runJson<ImpactResult>([
'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<CypherTabular>([
'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<CypherTabular>([
'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<CypherTabular>([
'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', () => {
it('truncates processes to --limit 1', () => {
// "message" matches logMessage / createLogEntry / formatLogEntry → 4 processes
const limited = runJson<QueryResult>([
'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<QueryResult>(['query', 'message', '--repo', 'mini-repo']);
expect(len(base.processes)).toBeGreaterThan(1);
});
});
});