diff --git a/.archgate/adrs/ARCH-012-command-error-boundaries.md b/.archgate/adrs/ARCH-012-command-error-boundaries.md index 7f07465f..a1a167e6 100644 --- a/.archgate/adrs/ARCH-012-command-error-boundaries.md +++ b/.archgate/adrs/ARCH-012-command-error-boundaries.md @@ -85,6 +85,7 @@ The top-level `main().catch()` in `cli.ts` remains as a safety net for truly une ### Automated Enforcement - **Archgate rule** `ARCH-012/async-action-error-boundary`: Scans async command actions for try-catch blocks. Severity: `warning` (some commands may have valid reasons for alternative patterns). +- **Archgate rule** `ARCH-012/exit-prompt-error-rethrow`: Verifies that async command actions with try-catch blocks include the `ExitPromptError` re-throw pattern. Severity: `error` — missing re-throws silently convert user cancellation (Ctrl+C, exit 130) into command failure (exit 1). ### Manual Enforcement diff --git a/.archgate/adrs/ARCH-012-command-error-boundaries.rules.ts b/.archgate/adrs/ARCH-012-command-error-boundaries.rules.ts index ab45efe2..0d7d6833 100644 --- a/.archgate/adrs/ARCH-012-command-error-boundaries.rules.ts +++ b/.archgate/adrs/ARCH-012-command-error-boundaries.rules.ts @@ -37,5 +37,39 @@ export default { await Promise.all(checks); }, }, + "exit-prompt-error-rethrow": { + description: + "Catch blocks in async command actions must re-throw ExitPromptError for proper Ctrl+C handling (exit 130)", + async check(ctx) { + // Only check non-index command files that have async actions + const files = ctx.scopedFiles.filter( + (f) => f.includes("commands/") && !f.endsWith("index.ts") + ); + + const checks = files.map(async (file) => { + const content = await ctx.readFile(file); + + // Only check files with async actions that have try-catch + const hasAsyncActionWithTryCatch = + /\.action\(\s*async\s[\s\S]*?\btry\s*\{/.test(content); + if (!hasAsyncActionWithTryCatch) return; + + // Check for the ExitPromptError re-throw pattern anywhere in the file. + // The canonical pattern is: + // if (err instanceof Error && err.name === "ExitPromptError") throw err; + const hasExitPromptRethrow = /ExitPromptError/.test(content); + + if (!hasExitPromptRethrow) { + ctx.report.violation({ + message: + "Catch block in async command action must re-throw ExitPromptError so Ctrl+C exits with code 130 instead of code 1", + file, + fix: 'Add `if (err instanceof Error && err.name === "ExitPromptError") throw err;` as the first line in the catch block', + }); + } + }); + await Promise.all(checks); + }, + }, }, } satisfies RuleSet; diff --git a/.claude/agent-memory/archgate-developer/MEMORY.md b/.claude/agent-memory/archgate-developer/MEMORY.md index 9c586cf1..3fe0d890 100644 --- a/.claude/agent-memory/archgate-developer/MEMORY.md +++ b/.claude/agent-memory/archgate-developer/MEMORY.md @@ -50,6 +50,8 @@ Skipping steps 2 or 3 is a workflow violation. The user should NEVER have to inv - **Windows binary upgrade: never use detached child processes for `.old` cleanup** — On Windows, `replaceBinary()` renames the running exe to `.old` because the OS file-locks it. Cleaning up the `.old` via a detached `cmd /c ping -n 2 ... & del` process is unreliable (process may not spawn, `del` may fail silently, timing races). Instead, `cleanupStaleBinary()` runs at the next CLI startup as a fire-and-forget `unlink()` — the file is guaranteed unlocked by then. The cleanup is platform-agnostic (uses `getArtifactInfo()` to resolve the binary name), so it works on any supported platform even though only Windows currently creates `.old` files. The sync `unlinkSync` in `replaceBinary()` is kept as defense-in-depth for leftover `.old` files from previous upgrades. Do NOT reintroduce detached cleanup processes. - **`bun:sqlite` file handles persist after `db.close()` on Windows — wrap test cleanup in try/catch** — Tests that create temp SQLite databases via `new Database(path)` will fail with `EBUSY: resource busy or locked` when `rmSync` tries to remove the temp directory in `afterEach`, even after calling `db.close()`. Windows holds the file handle briefly. Fix: (1) set `PRAGMA journal_mode = DELETE` in test DBs to avoid creating WAL/SHM files, and (2) wrap `rmSync` in `afterEach` with `try { rmSync(...) } catch { /* SQLite handles may persist */ }`. Each test must use a unique temp dir name so leftover files don't collide. - **Inquirer prompts leave cursor at wrong column on Windows — always reset with `cursorTo`** — After an `inquirer.prompt()` call finishes (especially checkbox with long wrapped answer lines), the cursor is left at the end of the rendered answer text. On Windows terminals, `\n` moves down but does NOT reset to column 0, so all subsequent output starts at the wrong horizontal offset. Fix: call `cursorTo(process.stdout, 0)` from `node:readline` after each prompt returns, guarded by `if (process.stdout.isTTY)`. Applied in `src/helpers/editor-detect.ts` for `promptEditorSelection()` and `promptSingleEditorSelection()`. The `upgrade.ts` command already uses the same `clearLine`/`cursorTo` pattern for download progress cleanup. +- **`inquirer` must be lazy-loaded via dynamic `import()` — never statically imported at module level** — `inquirer` costs ~200ms to parse. Static `import inquirer from "inquirer"` at module level forces every CLI invocation to pay this cost — even `--help`, `--version`, and non-interactive commands that never prompt. Always use `const { default: inquirer } = await import("inquirer")` at the call site, inside the action handler or function that actually needs it. Applied in `src/commands/adr/create.ts`, `src/commands/init.ts`, `src/helpers/editor-detect.ts`, `src/helpers/login-flow.ts`. Same principle applies to any heavy dependency used only in specific code paths (e.g., Sentry and PostHog SDKs are already lazy-loaded in `sentry.ts` and `telemetry.ts`). +- **Telemetry/Sentry init should be started eagerly but awaited lazily** — In `src/cli.ts`, `initSentry()` + `initTelemetry()` are started before command registration (so they run concurrently with setup) but only awaited in the `preAction` hook (right before the first telemetry event). This defers ~150ms of SDK parsing + git subprocess cost for `--help`/`--version` which never trigger `preAction`. The `preAction` hook is `async` to support this `await`. - **`GITHUB_TOKEN`-authored pushes do NOT trigger downstream workflows — release.yml MUST use the GH App token** — When an Actions workflow pushes commits or opens PRs using `${{ github.token }}` / `secrets.GITHUB_TOKEN`, GitHub intentionally suppresses the resulting `push` / `pull_request` events to prevent recursion. Symptom on release PRs: the head SHA has no `pull_request`-event check runs, so `Validate Code` / `Lint, Test & Check` / `DCO Sign-off Check` are missing from the PR rollup and branch protection treats the PR as missing required checks. PR [#131](https://github.com/archgate/cli/pull/131) papered over this by manually `gh workflow run` + posting commit statuses, but `workflow_dispatch` runs land on `head_branch: release` with `pull_requests: []` — they are not associated with the PR ref, so `Lint, Test & Check` stayed orphaned and the bug recurred on PR [#251](https://github.com/archgate/cli/pull/251). Root-cause fix: in `release.yml` the `pull-request` job MUST generate a GitHub App installation token via `actions/create-github-app-token` (using `secrets.GH_APP_APP_ID` / `secrets.GH_APP_PRIVATE_KEY`) and pass it to BOTH `actions/checkout` and `simple-release-action`. App-token-authored pushes DO trigger `pull_request` events naturally. Apply the same pattern to any future workflow that pushes to a branch whose downstream CI must run. ## Validation Pipeline diff --git a/src/cli.ts b/src/cli.ts index 1c85429b..36715a73 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -67,16 +67,16 @@ cleanupStaleBinary(); async function main() { await installGit(); - // Initialize error tracking and telemetry (no-ops if opted out). + // Start error tracking and telemetry initialization concurrently but do NOT + // await them here. Both SDKs are lazy-loaded via dynamic `import()` inside + // these functions, so the `ARCHGATE_TELEMETRY=0` path is a cheap no-op. // - // Both SDKs are lazy-loaded via dynamic `import()` inside these functions, - // so the `ARCHGATE_TELEMETRY=0` path skips the parse/init cost entirely. - // - // Await telemetry so the repo context is resolved before the preAction - // hook fires `command_executed` — otherwise that event always lands - // without `repo_id` (see PR #211). The two init calls run concurrently, - // so the wall-clock cost is bounded by whichever is slowest. - await Promise.all([initSentry(), initTelemetry()]); + // The promise is awaited in the preAction hook — right before the first + // telemetry event fires — so `repo_id` is always present on `command_executed` + // events (see PR #211). This defers ~150ms of SDK parse + git subprocess cost + // off the critical path for --help, --version, and fast-exit commands that + // never trigger preAction. + const telemetryReady = Promise.all([initSentry(), initTelemetry()]); const logLevelOption = new Option("--log-level ", "Set log verbosity") .choices(["error", "warn", "info", "debug"] as const) @@ -93,7 +93,13 @@ async function main() { // Commander invokes the hook callback with (hookedCommand, actionCommand); // the second arg is the actual subcommand being executed. We use the action // command so `adr create` etc. resolves correctly instead of always "root". - program.hook("preAction", (_hookedCommand, actionCommand) => { + program.hook("preAction", async (_hookedCommand, actionCommand) => { + // Ensure telemetry SDKs are initialized before emitting any events. + // By the time a real command's preAction fires, the init promises have + // usually already resolved (they started before command registration), + // so this await is effectively free in practice. + await telemetryReady; + // Apply log level from global option before any command runs const rootOpts = program.opts(); setLogLevel(rootOpts.logLevel as LogLevel); diff --git a/src/commands/adr/create.ts b/src/commands/adr/create.ts index 0b840976..05665612 100644 --- a/src/commands/adr/create.ts +++ b/src/commands/adr/create.ts @@ -1,7 +1,6 @@ import { cursorTo } from "node:readline"; import type { Command } from "@commander-js/extra-typings"; -import inquirer from "inquirer"; import type { AdrDomain } from "../../formats/adr"; import { createAdrFile } from "../../helpers/adr-writer"; @@ -56,6 +55,10 @@ export function registerAdrCreateCommand(adr: Command) { body = opts.body; } else { const choices = getAllDomainNames(projectRoot); + // Lazy-load inquirer — it costs ~200ms to parse and is only + // needed for interactive prompts, not for scripted --title/--domain + // invocations or --help/--version. + const { default: inquirer } = await import("inquirer"); // Interactive mode const answers = await inquirer.prompt([ { @@ -108,6 +111,8 @@ export function registerAdrCreateCommand(adr: Command) { console.log(`Created ADR: ${result.filePath}`); } } catch (err) { + // Re-throw ExitPromptError so main().catch() handles Ctrl+C (exit 130) + if (err instanceof Error && err.name === "ExitPromptError") throw err; logError(err instanceof Error ? err.message : String(err)); await exitWith(1); } diff --git a/src/commands/adr/domain/add.ts b/src/commands/adr/domain/add.ts index f6e5e438..d3588888 100644 --- a/src/commands/adr/domain/add.ts +++ b/src/commands/adr/domain/add.ts @@ -43,6 +43,7 @@ export function registerDomainAddCommand(domain: Command) { console.log(`Registered custom domain: ${name} → ${prefix}`); } } catch (err) { + if (err instanceof Error && err.name === "ExitPromptError") throw err; logError(err instanceof Error ? err.message : String(err)); await exitWith(1); } diff --git a/src/commands/adr/domain/remove.ts b/src/commands/adr/domain/remove.ts index 841a1c36..e3a9cd4f 100644 --- a/src/commands/adr/domain/remove.ts +++ b/src/commands/adr/domain/remove.ts @@ -63,6 +63,7 @@ export function registerDomainRemoveCommand(domain: Command) { console.log(`Removed custom domain: ${name}`); } } catch (err) { + if (err instanceof Error && err.name === "ExitPromptError") throw err; logError(err instanceof Error ? err.message : String(err)); await exitWith(1); } diff --git a/src/commands/adr/list.ts b/src/commands/adr/list.ts index 7980cca3..e6d8cc7d 100644 --- a/src/commands/adr/list.ts +++ b/src/commands/adr/list.ts @@ -82,6 +82,7 @@ export function registerAdrListCommand(adr: Command) { ); } } catch (err) { + if (err instanceof Error && err.name === "ExitPromptError") throw err; logError(err instanceof Error ? err.message : String(err)); await exitWith(1); } diff --git a/src/commands/adr/show.ts b/src/commands/adr/show.ts index faadcbe1..ba1810de 100644 --- a/src/commands/adr/show.ts +++ b/src/commands/adr/show.ts @@ -31,6 +31,7 @@ export function registerAdrShowCommand(adr: Command) { const content = await Bun.file(adr.filePath).text(); console.log(content); } catch (err) { + if (err instanceof Error && err.name === "ExitPromptError") throw err; logError(err instanceof Error ? err.message : String(err)); await exitWith(1); } diff --git a/src/commands/adr/update.ts b/src/commands/adr/update.ts index afc67a42..121e6d4d 100644 --- a/src/commands/adr/update.ts +++ b/src/commands/adr/update.ts @@ -63,6 +63,7 @@ export function registerAdrUpdateCommand(adr: Command) { console.log(`Updated ADR: ${result.filePath}`); } } catch (err) { + if (err instanceof Error && err.name === "ExitPromptError") throw err; const message = err instanceof Error ? err.message : String(err); logError(message); await exitWith(1); diff --git a/src/commands/check.ts b/src/commands/check.ts index 8ef8459b..eae2ee08 100644 --- a/src/commands/check.ts +++ b/src/commands/check.ts @@ -40,6 +40,7 @@ export function registerCheckCommand(program: Command) { try { loadResults = await loadRuleAdrs(projectRoot, opts.adr); } catch (err) { + if (err instanceof Error && err.name === "ExitPromptError") throw err; logError( err instanceof Error ? err.message : `Failed to load rules: ${err}` ); diff --git a/src/commands/clean.ts b/src/commands/clean.ts index c1a52e43..b18fbe82 100644 --- a/src/commands/clean.ts +++ b/src/commands/clean.ts @@ -46,6 +46,8 @@ export function registerCleanCommand(program: Command) { console.log(`${destinationPath} cleaned up`); } } catch (error) { + if (error instanceof Error && error.name === "ExitPromptError") + throw error; logError( `Failed to clean ${destinationPath}.`, error instanceof Error ? error.message : String(error) diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index dc7a46a6..924bc678 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -97,6 +97,7 @@ export function registerDoctorCommand(program: Command) { printConsole(report); } } catch (err) { + if (err instanceof Error && err.name === "ExitPromptError") throw err; logError(err instanceof Error ? err.message : String(err)); await exitWith(1); } diff --git a/src/commands/init.ts b/src/commands/init.ts index 61c8d764..33033405 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -5,7 +5,6 @@ import { styleText } from "node:util"; import type { Command } from "@commander-js/extra-typings"; import { Option } from "@commander-js/extra-typings"; -import inquirer from "inquirer"; import { loadCredentials } from "../helpers/credential-store"; import { detectEditors, promptEditorSelection } from "../helpers/editor-detect"; @@ -83,6 +82,9 @@ export function registerInitCommand(program: Command) { opts.installPlugin === undefined && process.stdin.isTTY ) { + // Lazy-load inquirer — it costs ~200ms to parse and is only needed + // for interactive prompts, not for scripted or --help invocations. + const { default: inquirer } = await import("inquirer"); const { wantPlugin } = await inquirer.prompt([ { type: "confirm", @@ -179,6 +181,8 @@ export function registerInitCommand(program: Command) { : {}), }); } catch (err) { + // Re-throw ExitPromptError so main().catch() handles Ctrl+C (exit 130) + if (err instanceof Error && err.name === "ExitPromptError") throw err; if (isTlsError(err)) { logError(tlsHintMessage()); await exitWith(1); diff --git a/src/commands/login.ts b/src/commands/login.ts index 8f549195..aa80bf38 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -35,6 +35,7 @@ export function registerLoginCommand(program: Command) { await exitWith(1); } } catch (err) { + if (err instanceof Error && err.name === "ExitPromptError") throw err; const failureReason = isTlsError(err) ? "tls" : "other"; trackLoginResult({ subcommand: "login", @@ -63,6 +64,7 @@ export function registerLoginCommand(program: Command) { console.log("Not logged in. Run `archgate login` to authenticate."); } } catch (err) { + if (err instanceof Error && err.name === "ExitPromptError") throw err; logError(err instanceof Error ? err.message : String(err)); await exitWith(1); } @@ -77,6 +79,7 @@ export function registerLoginCommand(program: Command) { trackLoginResult({ subcommand: "logout", success: true }); console.log("Logged out successfully."); } catch (err) { + if (err instanceof Error && err.name === "ExitPromptError") throw err; logError(err instanceof Error ? err.message : String(err)); await exitWith(1); } @@ -96,6 +99,7 @@ export function registerLoginCommand(program: Command) { await exitWith(1); } } catch (err) { + if (err instanceof Error && err.name === "ExitPromptError") throw err; const failureReason = isTlsError(err) ? "tls" : "other"; trackLoginResult({ subcommand: "refresh", diff --git a/src/commands/plugin/url.ts b/src/commands/plugin/url.ts index 8af85e92..89e2ca85 100644 --- a/src/commands/plugin/url.ts +++ b/src/commands/plugin/url.ts @@ -55,6 +55,7 @@ export function registerPluginUrlCommand(plugin: Command) { console.log(url); } catch (err) { + if (err instanceof Error && err.name === "ExitPromptError") throw err; logError(err instanceof Error ? err.message : String(err)); await exitWith(1); } diff --git a/src/commands/review-context.ts b/src/commands/review-context.ts index 1b5b328c..413affe2 100644 --- a/src/commands/review-context.ts +++ b/src/commands/review-context.ts @@ -34,6 +34,7 @@ export function registerReviewContextCommand(program: Command) { console.log(formatJSON(context)); } catch (err) { + if (err instanceof Error && err.name === "ExitPromptError") throw err; logError(err instanceof Error ? err.message : String(err)); await exitWith(1); } diff --git a/src/commands/session-context/claude-code.ts b/src/commands/session-context/claude-code.ts index ec4b6521..19be7c8b 100644 --- a/src/commands/session-context/claude-code.ts +++ b/src/commands/session-context/claude-code.ts @@ -32,6 +32,7 @@ export function registerClaudeCodeSessionContextCommand(parent: Command) { console.log(formatJSON(result.data)); } catch (err) { + if (err instanceof Error && err.name === "ExitPromptError") throw err; logError(err instanceof Error ? err.message : String(err)); await exitWith(1); } diff --git a/src/commands/session-context/copilot.ts b/src/commands/session-context/copilot.ts index 9212c061..108886f4 100644 --- a/src/commands/session-context/copilot.ts +++ b/src/commands/session-context/copilot.ts @@ -34,6 +34,7 @@ export function registerCopilotSessionContextCommand(parent: Command) { console.log(formatJSON(result.data)); } catch (err) { + if (err instanceof Error && err.name === "ExitPromptError") throw err; logError(err instanceof Error ? err.message : String(err)); await exitWith(1); } diff --git a/src/commands/session-context/cursor.ts b/src/commands/session-context/cursor.ts index 603b1ab0..205c7f51 100644 --- a/src/commands/session-context/cursor.ts +++ b/src/commands/session-context/cursor.ts @@ -34,6 +34,7 @@ export function registerCursorSessionContextCommand(parent: Command) { console.log(formatJSON(result.data)); } catch (err) { + if (err instanceof Error && err.name === "ExitPromptError") throw err; logError(err instanceof Error ? err.message : String(err)); await exitWith(1); } diff --git a/src/commands/session-context/opencode.ts b/src/commands/session-context/opencode.ts index 088c300c..60705cb9 100644 --- a/src/commands/session-context/opencode.ts +++ b/src/commands/session-context/opencode.ts @@ -34,6 +34,7 @@ export function registerOpencodeSessionContextCommand(parent: Command) { console.log(formatJSON(result.data)); } catch (err) { + if (err instanceof Error && err.name === "ExitPromptError") throw err; logError(err instanceof Error ? err.message : String(err)); await exitWith(1); } diff --git a/src/commands/telemetry.ts b/src/commands/telemetry.ts index db5d03a5..183fefa7 100644 --- a/src/commands/telemetry.ts +++ b/src/commands/telemetry.ts @@ -68,6 +68,7 @@ export function registerTelemetryCommand(program: Command) { "Telemetry enabled. Thank you for helping improve Archgate." ); } catch (err) { + if (err instanceof Error && err.name === "ExitPromptError") throw err; logError(err instanceof Error ? err.message : String(err)); await exitWith(1); } @@ -86,6 +87,7 @@ export function registerTelemetryCommand(program: Command) { await setTelemetryEnabled(false); console.log("Telemetry disabled. No usage data will be collected."); } catch (err) { + if (err instanceof Error && err.name === "ExitPromptError") throw err; logError(err instanceof Error ? err.message : String(err)); await exitWith(1); } diff --git a/src/commands/upgrade.ts b/src/commands/upgrade.ts index 10b5caac..fc870006 100644 --- a/src/commands/upgrade.ts +++ b/src/commands/upgrade.ts @@ -275,6 +275,7 @@ async function upgradeBinary(tag: string): Promise { logDebug("Replacing binary:", process.execPath); replaceBinary(process.execPath, newBinaryPath); } catch (err) { + if (err instanceof Error && err.name === "ExitPromptError") throw err; finishDownloadProgress(); logError( "Failed to upgrade binary.", @@ -369,6 +370,7 @@ export function registerUpgradeCommand(program: Command) { console.log(`Archgate upgraded to ${latestVersion} successfully.`); } catch (err) { + if (err instanceof Error && err.name === "ExitPromptError") throw err; trackUpgradeResult({ from_version: "unknown", to_version: "unknown", diff --git a/src/helpers/editor-detect.ts b/src/helpers/editor-detect.ts index 74f93a19..a800176d 100644 --- a/src/helpers/editor-detect.ts +++ b/src/helpers/editor-detect.ts @@ -7,8 +7,6 @@ import { cursorTo } from "node:readline"; -import inquirer from "inquirer"; - import { EDITOR_LABELS } from "./init-project"; import type { EditorTarget } from "./init-project"; import { logDebug } from "./log"; @@ -67,6 +65,9 @@ export async function detectEditors(): Promise { export async function promptEditorSelection( detected: DetectedEditor[] ): Promise { + // Lazy-load inquirer — it costs ~200ms to parse and is only needed when + // the user is interactively prompted, not on every CLI startup. + const { default: inquirer } = await import("inquirer"); const { selected } = await inquirer.prompt([ { type: "checkbox", @@ -95,6 +96,7 @@ export async function promptEditorSelection( export async function promptSingleEditorSelection( detected: DetectedEditor[] ): Promise { + const { default: inquirer } = await import("inquirer"); const available = detected.filter((e) => e.available); const defaultEditor = available.length > 0 ? available[0].id : "claude"; diff --git a/src/helpers/login-flow.ts b/src/helpers/login-flow.ts index 1cc63e54..dbba9409 100644 --- a/src/helpers/login-flow.ts +++ b/src/helpers/login-flow.ts @@ -6,8 +6,6 @@ import { cursorTo } from "node:readline"; import { styleText } from "node:util"; -import inquirer from "inquirer"; - /** * Reset cursor to column 0 after an inquirer prompt on Windows. * See editor-detect.ts for the full explanation of the bug. @@ -119,6 +117,9 @@ async function runSignupPrompt( githubEmail: string | null, preselectedEditor?: string ): Promise { + // Lazy-load inquirer — it costs ~200ms to parse and is only needed for + // interactive signup prompts, not on every CLI startup. + const { default: inquirer } = await import("inquirer"); const { email } = await inquirer.prompt({ type: "input", name: "email", diff --git a/tests/integration/cli-perf.test.ts b/tests/integration/cli-perf.test.ts index 22d2b1b3..ad6a31eb 100644 --- a/tests/integration/cli-perf.test.ts +++ b/tests/integration/cli-perf.test.ts @@ -1,26 +1,21 @@ /** - * Performance regression tests — guard against the "un-cancelled timer" - * class of bugs that added a 3-second tail to every command that exits - * naturally through `main()` returning. + * Performance regression tests for CLI startup and exit latency. * - * See PR #213 for the full list of call sites fixed. The specific - * regression this file protects against is any leaked `setTimeout` / - * `Bun.sleep` that keeps the Bun event loop alive past its intended - * shutdown — most commonly in telemetry / Sentry flush, the git - * credential helper, or the WSL fallback in `resolveCommand`. + * Two concerns, two describe blocks: * - * Strategy: run short commands end-to-end via the real CLI entry - * and assert wall-clock time stays under a generous threshold. A - * lingering timer immediately pushes the time past the threshold - * (the old tail was ~3s on Windows); genuinely slow runs on cold - * CI still fit well within the budget. + * 1. **Exit tail guard** — catches leaked `setTimeout` / `Bun.sleep` that + * keep the event loop alive after the command completes (the ~3s tail + * from PR #213). Budget: 4000ms — generous, only fires on timer leaks. * - * The thresholds are intentionally generous so the test doesn't - * flake on slow runners — they're chosen to catch the *regression* - * (commands lingering 3s+) without failing on normal CI noise. - * Don't tighten these to assert absolute performance targets; do - * that elsewhere if needed. The job of these tests is one thing: - * catch the exit tail returning. + * 2. **Startup latency budget** — catches import-time regressions like + * static `import inquirer` (costs ~200ms) or blocking telemetry init + * (~150ms). Budgets are ~3-4x the measured baseline so they don't + * flake on slow CI, but tight enough to catch a heavy dependency + * being pulled into the startup path. + * + * Strategy: run commands end-to-end via `Bun.spawn`, take the median of + * multiple runs to smooth out cold-start variance, and assert wall-clock + * time stays under the budget. */ import { describe, expect, test } from "bun:test"; @@ -123,3 +118,136 @@ describe("CLI performance — exit tail regression guard", () => { FAST_COMMAND_MAX_MS * 4 ); }); + +// --------------------------------------------------------------------------- +// Startup latency budgets +// --------------------------------------------------------------------------- +// +// These budgets are tighter than the exit-tail guard above. They protect +// against import-time regressions: +// +// - Re-adding a static `import inquirer` (costs ~200ms) +// - Blocking on telemetry/sentry init before command parsing (~150ms) +// - Pulling a heavy new dependency into the top-level import chain +// +// Baseline (measured 2026-05-09 on Windows, subprocess via Bun.spawn): +// --help: ~260ms --version: ~250ms +// adr list: ~400ms check: ~750ms +// +// Budgets are set at ~3-4x the baseline to absorb CI variance (GitHub +// Actions Windows runners are typically 1.5-2x slower than local dev) +// without masking a real regression. If a budget fires, profile the +// startup with `bun -e "..."` import-time measurements (see the commit +// that introduced these tests for the technique). + +/** + * Budget for commands that do zero project I/O — pure startup + parse + + * exit. These exercise the full import chain but touch no .archgate/ files. + */ +const STARTUP_ONLY_MAX_MS = 1000; + +/** + * Budget for commands that do light project I/O (read + parse ADR files). + */ +const LIGHT_COMMAND_MAX_MS = 1500; + +/** + * Budget for commands that do heavy project I/O (load rules, scan files, + * run checks). + */ +const HEAVY_COMMAND_MAX_MS = 2500; + +function startupBudgetError( + label: string, + median: number, + all: number[], + budget: number +): string { + return ( + `\`${label}\` took ${Math.round(median)}ms ` + + `(median of ${all.map((m) => Math.round(m)).join(", ")}ms). ` + + `Budget is ${budget}ms. ` + + `This usually means a heavy dependency was added to the static import chain ` + + `(e.g. inquirer, a new SDK) or an async init is blocking before command parsing. ` + + `Profile with: bun -e "const t=performance.now(); await import('./src/...'); ` + + `console.log(performance.now()-t)"` + ); +} + +describe("CLI performance — startup latency budget", () => { + test( + "`--help` stays within startup budget", + async () => { + const { median, all } = await medianDurationMs(["--help"], 3); + if (median >= STARTUP_ONLY_MAX_MS) { + throw new Error( + startupBudgetError( + "archgate --help", + median, + all, + STARTUP_ONLY_MAX_MS + ) + ); + } + expect(median).toBeLessThan(STARTUP_ONLY_MAX_MS); + }, + STARTUP_ONLY_MAX_MS * 5 + ); + + test( + "`--version` stays within startup budget", + async () => { + const { median, all } = await medianDurationMs(["--version"], 3); + if (median >= STARTUP_ONLY_MAX_MS) { + throw new Error( + startupBudgetError( + "archgate --version", + median, + all, + STARTUP_ONLY_MAX_MS + ) + ); + } + expect(median).toBeLessThan(STARTUP_ONLY_MAX_MS); + }, + STARTUP_ONLY_MAX_MS * 5 + ); + + test( + "`adr list` stays within light-command budget", + async () => { + const { median, all } = await medianDurationMs(["adr", "list"], 3); + if (median >= LIGHT_COMMAND_MAX_MS) { + throw new Error( + startupBudgetError( + "archgate adr list", + median, + all, + LIGHT_COMMAND_MAX_MS + ) + ); + } + expect(median).toBeLessThan(LIGHT_COMMAND_MAX_MS); + }, + LIGHT_COMMAND_MAX_MS * 5 + ); + + test( + "`check` stays within heavy-command budget", + async () => { + const { median, all } = await medianDurationMs(["check"], 3); + if (median >= HEAVY_COMMAND_MAX_MS) { + throw new Error( + startupBudgetError( + "archgate check", + median, + all, + HEAVY_COMMAND_MAX_MS + ) + ); + } + expect(median).toBeLessThan(HEAVY_COMMAND_MAX_MS); + }, + HEAVY_COMMAND_MAX_MS * 5 + ); +});