diff --git a/.archgate/lint/oxlint.ts b/.archgate/lint/oxlint.ts new file mode 100644 index 00000000..34531794 --- /dev/null +++ b/.archgate/lint/oxlint.ts @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate + +// Custom oxlint JS plugin: validate `type:` literals passed to +// inquirer.prompt() against the prompt types registered by the installed +// inquirer version. +// +// Why this exists: inquirer v14 removed the legacy "list" prompt type +// (renamed "select" in v10), which crashed `archgate login` at runtime with +// 'Prompt type "list" is not registered'. Nothing else can catch this class +// of bug statically or in CI: +// - tsc cannot: inquirer's legacy prompt() types accept ANY `type: string` +// via the CustomQuestion escape hatch that exists for registerPrompt(). +// - tests cannot: interactive prompts need a TTY, so every test mocks the +// inquirer module entirely and the runtime prompt registry never runs. +// The invariant is purely syntactic, so it belongs in the linter. +// +// The registered set is read from the installed inquirer at plugin load, so +// the rule self-updates on dependency bumps — a future rename/removal makes +// stale call sites fail lint in the bump PR itself. +// +// The plugin runs natively as TypeScript under Bun, so there is no build step. + +/** Minimal ESTree-ish node shape. The oxlint AST is ESLint-compatible. */ +type AstNode = { type: string } & Record; + +/** Narrow an unknown value to an AST node (an object with a string `type`). */ +function asNode(value: unknown): AstNode | undefined { + if ( + value !== null && + typeof value === "object" && + typeof (value as { type?: unknown }).type === "string" + ) { + return value as AstNode; + } + return undefined; +} + +/** + * Prompt types registered by the INSTALLED inquirer, read at plugin load. + * + * Deliberately not a hardcoded allowlist: when a future inquirer version + * renames or removes a prompt type (as v10 did with "list" -> "select"), + * the registry shrinks and stale call sites fail `bun run lint` immediately + * in the dependency-bump PR — no manual list maintenance. + * + * If inquirer ever changes the registry's API shape, the loud throw below + * fails the whole lint run rather than silently disabling the rule. + */ +const { default: inquirer } = await import("inquirer"); +const REGISTERED_PROMPT_TYPES = new Set(Object.keys(inquirer.prompt.prompts)); +if (REGISTERED_PROMPT_TYPES.size === 0) { + throw new Error( + "archgate/valid-inquirer-prompt-type: inquirer.prompt.prompts is empty — the registry API may have changed; update .archgate/lint/oxlint.ts" + ); +} + +/** True when the callee is exactly `inquirer.prompt`. */ +function isInquirerPromptCallee(callee: AstNode | undefined): boolean { + if (callee?.type !== "MemberExpression") return false; + const object = asNode(callee.object); + const property = asNode(callee.property); + return ( + object?.type === "Identifier" && + object.name === "inquirer" && + property?.type === "Identifier" && + property.name === "prompt" + ); +} + +/** + * Collect the question ObjectExpressions from inquirer.prompt() arguments. + * Handles both the single-object form `prompt({...})` and the array form + * `prompt([{...}, {...}])`. + */ +function questionObjects(args: unknown): AstNode[] { + const result: AstNode[] = []; + const list = Array.isArray(args) ? args : []; + for (const arg of list) { + const node = asNode(arg); + if (node?.type === "ObjectExpression") { + result.push(node); + } else if (node?.type === "ArrayExpression") { + const elements = Array.isArray(node.elements) ? node.elements : []; + for (const element of elements) { + const child = asNode(element); + if (child?.type === "ObjectExpression") result.push(child); + } + } + } + return result; +} + +/** Find the value node of the `type` property in a question object, if any. */ +function typeValueNode(question: AstNode): AstNode | undefined { + const properties = Array.isArray(question.properties) + ? question.properties + : []; + for (const item of properties) { + const property = asNode(item); + if (property?.type !== "Property") continue; + const key = asNode(property.key); + const keyName = + key?.type === "Identifier" + ? key.name + : key?.type === "Literal" + ? key.value + : undefined; + if (keyName !== "type") continue; + return asNode(property.value); + } + return undefined; +} + +interface ReportDescriptor { + node: AstNode; + message: string; +} + +interface RuleContext { + report(descriptor: ReportDescriptor): void; +} + +const validInquirerPromptType = { + create(context: RuleContext) { + return { + CallExpression(node: AstNode) { + if (!isInquirerPromptCallee(asNode(node.callee))) return; + + for (const question of questionObjects(node.arguments)) { + const value = typeValueNode(question); + if (value?.type !== "Literal" || typeof value.value !== "string") + continue; + if (REGISTERED_PROMPT_TYPES.has(value.value)) continue; + + context.report({ + node: value, + message: `Prompt type "${value.value}" is not registered in the installed inquirer and will crash at runtime. Registered types: ${[...REGISTERED_PROMPT_TYPES].join(", ")}. (The legacy "list" type was renamed "select" in inquirer v10.)`, + }); + } + }, + }; + }, +}; + +const plugin = { + meta: { name: "archgate" }, + rules: { "valid-inquirer-prompt-type": validInquirerPromptType }, +}; + +export default plugin; diff --git a/.claude/agent-memory/archgate-developer/MEMORY.md b/.claude/agent-memory/archgate-developer/MEMORY.md index 4b8e5983..5aae525f 100644 --- a/.claude/agent-memory/archgate-developer/MEMORY.md +++ b/.claude/agent-memory/archgate-developer/MEMORY.md @@ -23,6 +23,7 @@ Skipping steps 2 or 3 is a workflow violation. The user should NEVER have to inv ## Approach Guidance - [No prod changes for testability](feedback_no_prod_changes_for_tests.md) — mock implementations in tests (spyOn os.homedir works cross-module); never alter prod semantics for test isolation +- [Pick the right enforcement layer](feedback_prefer_tests_over_adr_rules.md) — static syntactic invariants → custom oxlint rule in `.archgate/lint/oxlint.ts`; tests are for executable behavior; ADR .rules.ts for cross-file/governance checks ## Known Bugs diff --git a/.claude/agent-memory/archgate-developer/feedback_prefer_tests_over_adr_rules.md b/.claude/agent-memory/archgate-developer/feedback_prefer_tests_over_adr_rules.md new file mode 100644 index 00000000..2b6eed85 --- /dev/null +++ b/.claude/agent-memory/archgate-developer/feedback_prefer_tests_over_adr_rules.md @@ -0,0 +1,18 @@ +--- +name: pick-the-right-enforcement-layer +description: Static syntactic invariants belong in a custom oxlint rule (.archgate/lint/oxlint.ts), not in ADR .rules.ts and not in tests +metadata: + type: feedback +--- + +Pick the enforcement layer by the nature of the invariant — don't default to ADR rules. + +**Why:** After the inquirer v14 `"list"` → `"select"` crash, I first drafted an ARCH-019 `.rules.ts` allowlist rule — user rejected it ("making an adr rule is stupid. we should make a proper test"). I then wrote a bun test scanning source files — user rejected that too ("if we have no tty, then this is more a linting rule than proper testing"). The final shape is a custom oxlint JS plugin rule (`archgate/valid-inquirer-prompt-type` in `.archgate/lint/oxlint.ts`), which gets real AST access instead of line-scanning and runs in the existing `bun run lint` gate. + +**How to apply:** + +- **Tests** verify _behavior_ — if the check can't actually execute the code path (e.g. interactive prompts with no TTY in CI), it is not a test, no matter where the file lives. +- **Custom oxlint rules** (`.archgate/lint/oxlint.ts`, registered in `.oxlintrc.json` `jsPlugins`, enabled as `archgate/`) enforce _static syntactic invariants_ — pattern X in source must/must-not look like Y. AST-based, precise spans, IDE-visible. +- **ADR `.rules.ts`** are for _project-structure/governance checks_ that don't fit a per-file lint model (cross-file sync, docs parity, dependency policy). +- `.archgate/lint/` is the archgate-standard home for ADR-complementing linter rules (see its README); the plugin file is NOT in tsconfig `include` or knip `project`, but oxlint lints it, so it must itself pass all oxlint rules. +- **oxlint jsPlugins have full module resolution and top-level await** — a plugin can `await import("inquirer")` at load and derive its allowlist from the installed dependency's runtime state (e.g. `Object.keys(inquirer.prompt.prompts)`). Prefer this over hardcoded allowlists: the rule then self-updates on dependency bumps and stale call sites fail lint in the bump PR itself. Guard with a loud throw if the upstream API shape changes, so the rule can never silently disable itself. diff --git a/.oxlintrc.json b/.oxlintrc.json index 4d26490e..17968f41 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,5 +1,5 @@ { - "jsPlugins": ["./lint/expect-expect.ts"], + "jsPlugins": ["./lint/expect-expect.ts", "./.archgate/lint/oxlint.ts"], "categories": { "correctness": "error", "pedantic": "error", @@ -10,6 +10,7 @@ "max-lines-per-function": "off", "max-nested-callbacks": "off", "max-depth": "off", + "archgate/valid-inquirer-prompt-type": "error", "no-await-in-loop": "warn", "no-inline-comments": "off", "prefer-top-level-await": "off", diff --git a/src/commands/adr/create.ts b/src/commands/adr/create.ts index b551310b..81b80d25 100644 --- a/src/commands/adr/create.ts +++ b/src/commands/adr/create.ts @@ -65,7 +65,7 @@ export function registerAdrCreateCommand(adr: Command) { const answers = await withPromptFix(() => inquirer.prompt([ { - type: "list", + type: "select", name: "domain", message: "Domain:", choices: choices.map((d) => ({ name: d, value: d })), diff --git a/src/commands/adr/sync.ts b/src/commands/adr/sync.ts index bf9351da..305ff5da 100644 --- a/src/commands/adr/sync.ts +++ b/src/commands/adr/sync.ts @@ -402,7 +402,7 @@ export function registerAdrSyncCommand(adr: Command) { const { choice } = await withPromptFix(() => inquirer.prompt([ { - type: "list", + type: "select", name: "choice", message: `${diff.adrId}: What would you like to do?`, choices: [ diff --git a/src/commands/init.ts b/src/commands/init.ts index 0f40844d..43574018 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -219,7 +219,7 @@ async function runGreenfieldWizard(projectRoot: string): Promise { const { wantPacks } = await withPromptFix(() => inquirer.prompt([ { - type: "list", + type: "select", name: "wantPacks", message: "No existing ADRs detected. Would you like to import starter packs?", diff --git a/src/helpers/editor-detect.ts b/src/helpers/editor-detect.ts index 303ed737..4f582f7a 100644 --- a/src/helpers/editor-detect.ts +++ b/src/helpers/editor-detect.ts @@ -102,7 +102,7 @@ export async function promptSingleEditorSelection( const { selected } = await withPromptFix(() => inquirer.prompt([ { - type: "list", + type: "select", name: "selected", message: "Select editor:", choices: detected.map((e) => ({ diff --git a/src/helpers/login-flow.ts b/src/helpers/login-flow.ts index 244c9b6a..9b20e2b1 100644 --- a/src/helpers/login-flow.ts +++ b/src/helpers/login-flow.ts @@ -132,7 +132,7 @@ async function runSignupPrompt( if (!editor) { const ans = await withPromptFix(() => inquirer.prompt({ - type: "list", + type: "select", name: "editor", message: "Which editor will you use with archgate?", choices: [