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
151 changes: 151 additions & 0 deletions .archgate/lint/oxlint.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

/** 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;
1 change: 1 addition & 0 deletions .claude/agent-memory/archgate-developer/MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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/<rule>`) 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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
3 changes: 2 additions & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"jsPlugins": ["./lint/expect-expect.ts"],
"jsPlugins": ["./lint/expect-expect.ts", "./.archgate/lint/oxlint.ts"],
"categories": {
"correctness": "error",
"pedantic": "error",
Expand All @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/commands/adr/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })),
Expand Down
2 changes: 1 addition & 1 deletion src/commands/adr/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
2 changes: 1 addition & 1 deletion src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ async function runGreenfieldWizard(projectRoot: string): Promise<void> {
const { wantPacks } = await withPromptFix(() =>
inquirer.prompt([
{
type: "list",
type: "select",
name: "wantPacks",
message:
"No existing ADRs detected. Would you like to import starter packs?",
Expand Down
2 changes: 1 addition & 1 deletion src/helpers/editor-detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => ({
Expand Down
2 changes: 1 addition & 1 deletion src/helpers/login-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
Loading