Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .archgate/adrs/ARCH-001-command-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,8 @@ export function registerAdrCommand(program: Command) {

- **Archgate rule** ARCH-001/register-function-export: Scans all command files under src/commands/ (excluding index.ts group files) and verifies each exports a register\*Command function. Severity: error.
- **Archgate rule** ARCH-001/no-business-logic: Detects complex data transformation patterns in command files that should be in helpers. Severity: error.
- **Build check** bun run build:check: Compiles src/cli.ts with bun build --compile --bytecode as part of bun run validate. A top-level await regression causes an immediate, descriptive parse error.
- **Archgate rule** ARCH-001\no-top-level-await-in-entry: Scans src/cli.ts for top-level await (await outside an indented function body) and flags it before a compile is needed. Severity: error.
- **Build check** bun run build:check: Compiles src/cli.ts with bun build --compile --bytecode as part of bun run validate. A top-level await regression causes an immediate, descriptive parse error. This remains the authoritative check; the rule above is a fast local early-warning.

### Manual Enforcement

Expand Down
46 changes: 46 additions & 0 deletions .archgate/adrs/ARCH-001-command-structure.rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,51 @@ export default {
}
},
},
"no-top-level-await-in-entry": {
description:
"src/cli.ts must not use top-level await (bun build --compile --bytecode rejects it)",
severity: "error",
async check(ctx) {
// The CLI entry point is the only file compiled with --bytecode, so the
// top-level-await ban applies specifically to it. Read directly rather
// than via scopedFiles (which is scoped to src/commands/**). `bun run
// build:check` is the authoritative backstop; this rule is a fast,
// local early-warning that does not require a full compile.
let content: string;
try {
content = await ctx.readFile("src/cli.ts");
} catch {
return;
}

const awaitWord = /\bawait\b/u;
const lines = content.split("\n");
for (const [index, line] of lines.entries()) {
// Lines indented >= 2 spaces are inside a function body (2-space
// indentation) and are therefore not top-level.
const leading = line.length - line.trimStart().length;
if (leading >= 2) continue;

const trimmed = line.trimStart();
if (
trimmed.startsWith("//") ||
trimmed.startsWith("*") ||
trimmed.startsWith("/*")
) {
continue;
}

if (awaitWord.test(line)) {
ctx.report.violation({
message:
"Top-level await in src/cli.ts breaks `bun build --compile --bytecode`",
file: "src/cli.ts",
line: index + 1,
fix: "Move async bootstrap logic into `async function main()` and call `main().catch(...)`",
});
}
}
},
},
},
} satisfies RuleSet;
13 changes: 12 additions & 1 deletion .archgate/adrs/ARCH-017-multi-ecosystem-distribution.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
id: ARCH-017
title: Multi-Ecosystem Distribution
domain: distribution
rules: false
rules: true
---

# Multi-Ecosystem Distribution
Expand Down Expand Up @@ -61,6 +61,7 @@ All shims produce identical user-facing error messages on stderr:
- Don't add runtime dependencies to any shim package
- Don't use a different cache location per ecosystem
- Don't skip SHA256 verification
- Don't add a top-level `main` field to the root `package.json` — npm always includes the `main` entry point in the published tarball regardless of the `files` array, which would bundle the CLI source into the thin shim. The npm package exposes only `bin/archgate.cjs` (and sub-path exports like `./rules`); it needs no default entry point.

## Consequences

Expand All @@ -77,6 +78,16 @@ All shims produce identical user-facing error messages on stderr:
- Multiple codebases to maintain (one per ecosystem), though the logic is simple and rarely changes
- Network dependency on GitHub Releases for the initial download

## Compliance and Enforcement

### Automated

- **Archgate rule** ARCH-017/no-npm-main-field: Verifies the root `package.json` has no top-level `main` field, so the npm shim never bundles the CLI entry point. Severity: error.

### Manual

Code reviewers MUST verify new shims download (never bundle) the binary, add zero runtime dependencies, share the `~/.archgate/bin/` cache, and register their version file in `.simple-release.js` and the ARCH-013 companion rule.

## References

- [ARCH-013 -- Version Synchronization](./ARCH-013-version-synchronization.md) -- Enforces version parity across all shim packages
Expand Down
30 changes: 30 additions & 0 deletions .archgate/adrs/ARCH-017-multi-ecosystem-distribution.rules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/// <reference path="../rules.d.ts" />

export default {
rules: {
"no-npm-main-field": {
description:
"Root package.json must not declare a `main` field — npm always publishes it, bundling the CLI into the thin shim",
severity: "error",
async check(ctx) {
let pkgJson: Record<string, unknown>;
try {
pkgJson = (await ctx.readJSON("package.json")) as Record<
string,
unknown
>;
} catch {
return;
}

if ("main" in pkgJson) {
ctx.report.violation({
message: `package.json declares a "main" field ("${String(pkgJson.main)}") — npm always includes it in the published tarball, bundling the CLI entry point into the thin shim`,
file: "package.json",
fix: 'Remove the "main" field; the npm package exposes only bin/archgate.cjs and sub-path exports (e.g. "./rules")',
});
}
},
},
},
} satisfies RuleSet;
87 changes: 87 additions & 0 deletions .archgate/adrs/ARCH-018-lazy-load-heavy-dependencies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
---
id: ARCH-018
title: Lazy-Load Heavy Dependencies
domain: architecture
rules: true
files: ["src/**/*.ts"]
---

# Lazy-Load Heavy Dependencies

## Context

The CLI is a single compiled binary whose entry point (`src/cli.ts`) statically imports every command's `register*Command` function so the help text and argument parser can be built. Static imports are evaluated eagerly: the moment `src/cli.ts` runs, the entire transitive import graph is parsed and executed — even for `archgate --help`, `archgate --version`, or any non-interactive command.

Several dependencies are large enough that parsing them dominates cold-start latency:

- **`inquirer`** (~200ms to parse) — only needed by interactive flows (`init`, `login`, `adr create`, `adr import`, `adr sync`, editor detection).
- **`posthog-node`** — only needed when telemetry is enabled and an event is actually sent.
- **`@sentry/node-core`** — only needed when error reporting initializes.

If these are imported statically at module load, every invocation pays their parse cost. For a CLI whose most common interactions are fast, non-interactive commands (and machine/agent invocations of `check`, `review-context`, `session-context`), that cost is pure waste.

### Alternatives Analysis

**Static imports everywhere (status quo for small deps)**: Simple, but forces every invocation to parse heavy modules it will never use. Rejected for heavy deps.

**Dynamic `import()` at the call site**: `const { default: inquirer } = await import("inquirer")` inside the function that needs it. The module is parsed only when that code path runs. This is the chosen approach.

**Eager-start / lazy-await for init-style work**: For SDKs that must initialize early but whose result is only needed later (Sentry, telemetry), start the async work before command registration and `await` it only at the first point of use (the `preAction` hook). This overlaps the cost with other startup work and skips it entirely for `--help`/`--version` which never reach `preAction`.

## Decision

Heavy dependencies MUST be loaded with dynamic `import()` at the point of use, never statically imported for their runtime value at module top level.

This applies to `inquirer`, `posthog-node`, `@sentry/*`, and any future dependency whose parse cost is significant and whose use is confined to specific code paths.

**Type-only imports are exempt.** `import type { PostHog } from "posthog-node"` and `import type * as SentryNs from "@sentry/node-core/light"` are erased at compile time and carry zero runtime cost — they are required for type safety and are allowed.

**Eager-start / lazy-await is permitted** for SDKs that must initialize early: start the init promise before command registration, then `await` it in the `preAction` hook so `--help`/`--version` skip it (see `src/cli.ts`, `initSentry()` + `initTelemetry()`).

## Do's and Don'ts

### Do

- **DO** load `inquirer` with `const { default: inquirer } = await import("inquirer")` inside the action handler or helper that prompts
- **DO** load `posthog-node` / `@sentry/*` SDK values with dynamic `import()` inside their init functions
- **DO** use `import type { ... }` for type-only references to these modules — it is free at runtime
- **DO** prefer eager-start + lazy-await (in `preAction`) for SDKs that need early initialization but whose result is consumed later

### Don't

- **DON'T** write `import inquirer from "inquirer"` (or any value import of a heavy module) at module top level
- **DON'T** statically `import { PostHog } from "posthog-node"` for its value — use `import type` plus a dynamic `import()`
- **DON'T** import heavy SDKs transitively through a statically-imported helper that runs at load time — keep the dynamic boundary at the SDK

## Consequences

### Positive

- **Fast cold start** for the common case: `--help`, `--version`, and non-interactive commands never parse interactive/telemetry/error SDKs
- **Agent-friendly**: machine invocations of `check`/`review-context` pay only for what they use
- **Localized cost**: each heavy module's parse time is attributed to the one code path that needs it

### Negative

- **Slight verbosity**: call sites use `await import()` instead of a top-of-file import
- **`await` required at the call site**: the consuming function must be async (already true for command actions)

### Risks

- **A new heavy dependency added with a static import**: silently reintroduces the cold-start tax. **Mitigation:** the companion rule flags value-level static imports of the known heavy modules; extend `HEAVY_MODULES` when adding a new one.

## Compliance and Enforcement

### Automated

- **Archgate rule** ARCH-018/no-static-heavy-import: Scans `src/**/*.ts` for value-level static imports of heavy modules (`inquirer`, `posthog-node`, `@sentry/*`). `import type` is allowed. Severity: error.

### Manual

Code reviewers MUST verify that any newly added large dependency is loaded via dynamic `import()` at its point of use, and that `HEAVY_MODULES` in the companion rule is updated to cover it.

## References

- [ARCH-001: Command Structure](./ARCH-001-command-structure.md) — `src/cli.ts` statically imports all command register functions; this ADR keeps their transitive heavy deps lazy
- [ARCH-006: Dependency Policy](./ARCH-006-dependency-policy.md) — governs which dependencies are permitted at all
- [ARCH-019: Interactive Prompts via withPromptFix](./ARCH-019-inquirer-prompt-fix.md) — companion rule for the `inquirer` usage this ADR keeps lazy
55 changes: 55 additions & 0 deletions .archgate/adrs/ARCH-018-lazy-load-heavy-dependencies.rules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/// <reference path="../rules.d.ts" />

// Dependencies heavy enough that their parse cost should never be paid by
// invocations that don't use them. Value-level static imports of these are
// banned; `import type` is fine (erased at compile time). See ARCH-018.
const HEAVY_MODULES = ["inquirer", "posthog-node", "@sentry/"];

export default {
rules: {
"no-static-heavy-import": {
description:
"Heavy dependencies (inquirer, posthog-node, @sentry/*) must be loaded via dynamic import(), not statically imported for their value",
severity: "error",
async check(ctx) {
const files = ctx.scopedFiles.filter((f) => f.endsWith(".ts"));

const checks = files.map(async (file) => {
let content: string;
try {
content = await ctx.readFile(file);
} catch {
return;
}
const lines = content.split("\n");

for (const [index, line] of lines.entries()) {
// Only consider static `import ... from "..."` statements.
const fromMatch = line.match(
/^\s*import\s+(?<clause>[^;]*?)\s+from\s+["'](?<source>[^"']+)["']/u
);
if (!fromMatch?.groups) continue;

const { clause, source } = fromMatch.groups;

// `import type ...` is erased at runtime — always allowed.
if (/^type\b/u.test(clause.trim())) continue;

const isHeavy = HEAVY_MODULES.some((mod) =>
mod.endsWith("/") ? source.startsWith(mod) : source === mod
);
if (!isHeavy) continue;

ctx.report.violation({
message: `Static value import of heavy module "${source}" forces every CLI invocation to parse it`,
file,
line: index + 1,
fix: `Load it lazily: \`const { default: x } = await import("${source}")\` at the point of use (or use \`import type\` for type-only references)`,
});
}
});
await Promise.all(checks);
},
},
},
} satisfies RuleSet;
76 changes: 76 additions & 0 deletions .archgate/adrs/ARCH-019-inquirer-prompt-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
---
id: ARCH-019
title: Interactive Prompts via withPromptFix
domain: architecture
rules: true
files: ["src/**/*.ts"]
---

# Interactive Prompts via withPromptFix

## Context

When `inquirer` creates a readline interface on Windows, it enables Virtual Terminal Processing (VTP) and sets the console flag `DISABLE_NEWLINE_AUTO_RETURN`. That flag is **never restored** after the prompt closes. The consequence is global and persistent: once any inquirer prompt has run, a bare `\n` no longer returns the cursor to column 0 for **all subsequent output** in the same process — not just during the prompt. Tables, summaries, and multi-line messages printed after an interactive flow render as a staircase.

An earlier per-prompt `cursorTo(process.stdout, 0)` fix only corrected the cursor position on the answer line; it did not address the console-mode change, so later output was still broken.

The root-cause fix lives in `src/helpers/prompt.ts`, which exports `withPromptFix()`. It applies an idempotent, permanent patch to `process.stdout.write` that translates bare `\n` to `\r\n` (via the `(?<!\r)\n` pattern) and resets the cursor to column 0 after each prompt. Because the damage is process-global, the fix only works if **every** inquirer prompt goes through `withPromptFix()` — a single unwrapped prompt re-breaks newline handling for the rest of the process.

### Alternatives Analysis

**Wrap each prompt individually with ad-hoc cursor fixes**: Insufficient — it doesn't undo the console-mode change, only the cursor position. Rejected.

**Patch `process.stdout.write` once at startup unconditionally**: Pays the patch cost (and behavior change) even for runs that never prompt, and is harder to reason about. `withPromptFix()` applies the patch lazily on first prompt and is idempotent thereafter. Chosen.

**Mandatory wrapper at every call site (`withPromptFix(() => inquirer.prompt(...))`)**: Keeps the fix co-located with the prompt and is mechanically checkable. Chosen.

## Decision

Every `inquirer.prompt(...)` call MUST be wrapped in `withPromptFix(() => ...)` from `src/helpers/prompt.ts`. There are no exceptions: one unwrapped prompt corrupts newline handling for the remainder of the process.

Note: `inquirer` itself is loaded lazily (see ARCH-018). The `withPromptFix` wrapper is independent of how `inquirer` is imported — it governs how the prompt is _invoked_.

## Do's and Don'ts

### Do

- **DO** wrap every prompt: `const answer = await withPromptFix(() => inquirer.prompt([...]))`
- **DO** import `withPromptFix` from `src/helpers/prompt.ts` (or load it dynamically alongside `inquirer`)
- **DO** keep `withPromptFix` idempotent and the single source of the stdout patch

### Don't

- **DON'T** call `inquirer.prompt(...)` directly without the wrapper
- **DON'T** reimplement the cursor/newline fix at individual call sites — funnel everything through `withPromptFix`
- **DON'T** assume "it works on my machine" — the bug is Windows-only and does not reproduce on macOS/Linux

## Consequences

### Positive

- **Correct multi-line output on Windows** after any interactive flow
- **Single point of truth** for the console fix; call sites stay simple
- **Mechanically enforceable** — the wrapper presence is checkable

### Negative

- **Boilerplate** at every prompt call site (`withPromptFix(() => ...)`)

### Risks

- **A new prompt added without the wrapper** silently re-breaks newline handling for the rest of the run. **Mitigation:** the companion rule flags any `inquirer.prompt(` not preceded by (or on the same line as) `withPromptFix`.

## Compliance and Enforcement

### Automated

- **Archgate rule** ARCH-019/inquirer-prompt-wrapped: Scans `src/**/*.ts` for `inquirer.prompt(` calls and reports any that are not wrapped in `withPromptFix` (checked on the same line or the immediately-preceding non-blank line). Comment lines are ignored. Severity: error.

### Manual

Code reviewers MUST verify any new interactive flow wraps its prompts in `withPromptFix` and does not introduce a competing stdout/cursor patch.

## References

- [ARCH-018: Lazy-Load Heavy Dependencies](./ARCH-018-lazy-load-heavy-dependencies.md) — `inquirer` is loaded lazily; this ADR governs how its prompts are invoked
- [`src/helpers/prompt.ts`](../../src/helpers/prompt.ts) — defines `withPromptFix()` and the stdout patch
Loading
Loading