diff --git a/.archgate/adrs/ARCH-001-command-structure.rules.ts b/.archgate/adrs/ARCH-001-command-structure.rules.ts
index 35b33ad6..aa753071 100644
--- a/.archgate/adrs/ARCH-001-command-structure.rules.ts
+++ b/.archgate/adrs/ARCH-001-command-structure.rules.ts
@@ -1,46 +1,48 @@
-import { defineRules } from "../../src/formats/rules";
+///
-export default defineRules({
- "register-function-export": {
- description: "Command files must export a register*Command function",
- async check(ctx) {
- const files = ctx.scopedFiles.filter((f) => !f.endsWith("index.ts"));
- const checks = files.map(async (file) => {
- const content = await ctx.readFile(file);
- if (!/export\s+function\s+register\w+Command/.test(content)) {
- ctx.report.violation({
- message: "Command file must export a register*Command function",
- file,
- });
- }
- });
- await Promise.all(checks);
+export default {
+ rules: {
+ "register-function-export": {
+ description: "Command files must export a register*Command function",
+ async check(ctx) {
+ const files = ctx.scopedFiles.filter((f) => !f.endsWith("index.ts"));
+ const checks = files.map(async (file) => {
+ const content = await ctx.readFile(file);
+ if (!/export\s+function\s+register\w+Command/.test(content)) {
+ ctx.report.violation({
+ message: "Command file must export a register*Command function",
+ file,
+ });
+ }
+ });
+ await Promise.all(checks);
+ },
},
- },
- "no-business-logic": {
- description: "Command files should not contain business logic patterns",
- severity: "error",
- async check(ctx) {
- const files = ctx.scopedFiles.filter((f) => !f.endsWith("index.ts"));
- const matches = await Promise.all(
- files.map((file) =>
- ctx.grep(
- file,
- /\.(parse|match|replace|split)\(.*\).*\.(parse|match|replace|split)\(/
+ "no-business-logic": {
+ description: "Command files should not contain business logic patterns",
+ severity: "error",
+ async check(ctx) {
+ const files = ctx.scopedFiles.filter((f) => !f.endsWith("index.ts"));
+ const matches = await Promise.all(
+ files.map((file) =>
+ ctx.grep(
+ file,
+ /\.(parse|match|replace|split)\(.*\).*\.(parse|match|replace|split)\(/
+ )
)
- )
- );
- for (const fileMatches of matches) {
- for (const m of fileMatches) {
- ctx.report.violation({
- message:
- "Complex data transformations should be in helpers, not command files",
- file: m.file,
- line: m.line,
- fix: "Move transformation logic to a helper in src/helpers/ or src/formats/",
- });
+ );
+ for (const fileMatches of matches) {
+ for (const m of fileMatches) {
+ ctx.report.violation({
+ message:
+ "Complex data transformations should be in helpers, not command files",
+ file: m.file,
+ line: m.line,
+ fix: "Move transformation logic to a helper in src/helpers/ or src/formats/",
+ });
+ }
}
- }
+ },
},
},
-});
+} satisfies RuleSet;
diff --git a/.archgate/adrs/ARCH-002-error-handling.rules.ts b/.archgate/adrs/ARCH-002-error-handling.rules.ts
index a9c2d8d3..74c7deb8 100644
--- a/.archgate/adrs/ARCH-002-error-handling.rules.ts
+++ b/.archgate/adrs/ARCH-002-error-handling.rules.ts
@@ -1,78 +1,82 @@
-import { defineRules } from "../../src/formats/rules";
+///
-export default defineRules({
- "use-log-error": {
- description:
- "Use logError() instead of console.error() for user-facing errors",
- async check(ctx) {
- const files = ctx.scopedFiles.filter(
- (f) => !f.endsWith("helpers/log.ts") && !f.includes("tests/")
- );
- const matches = await Promise.all(
- files.map((file) => ctx.grep(file, /console\.error\(/))
- );
- for (const fileMatches of matches) {
- for (const m of fileMatches) {
- ctx.report.violation({
- message:
- "Use logError() from helpers/log.ts instead of console.error()",
- file: m.file,
- line: m.line,
- fix: "Import { logError } from '../helpers/log' and use logError()",
- });
+export default {
+ rules: {
+ "use-log-error": {
+ description:
+ "Use logError() instead of console.error() for user-facing errors",
+ async check(ctx) {
+ const files = ctx.scopedFiles.filter(
+ (f) => !f.endsWith("helpers/log.ts") && !f.includes("tests/")
+ );
+ const matches = await Promise.all(
+ files.map((file) => ctx.grep(file, /console\.error\(/))
+ );
+ for (const fileMatches of matches) {
+ for (const m of fileMatches) {
+ ctx.report.violation({
+ message:
+ "Use logError() from helpers/log.ts instead of console.error()",
+ file: m.file,
+ line: m.line,
+ fix: "Import { logError } from '../helpers/log' and use logError()",
+ });
+ }
}
- }
+ },
},
- },
- "use-log-helpers": {
- description:
- "Use log helpers instead of console.log/warn/info in helper and engine files",
- async check(ctx) {
- const files = ctx.scopedFiles.filter(
- (f) =>
- (f.includes("helpers/") || f.includes("engine/")) &&
- !f.endsWith("helpers/log.ts") &&
- !f.endsWith("engine/reporter.ts") &&
- !f.endsWith("helpers/login-flow.ts") &&
- !f.includes("tests/")
- );
- const matches = await Promise.all(
- files.map((file) => ctx.grep(file, /console\.(log|warn|info)\s*\(/))
- );
- for (const fileMatches of matches) {
- for (const m of fileMatches) {
- ctx.report.violation({
- message:
- "Use logInfo/logWarn/logDebug from helpers/log.ts instead of direct console output",
- file: m.file,
- line: m.line,
- fix: "Import { logInfo, logWarn } from '../helpers/log' and use logInfo() or logWarn()",
- });
+ "use-log-helpers": {
+ description:
+ "Use log helpers instead of console.log/warn/info in helper and engine files",
+ async check(ctx) {
+ const files = ctx.scopedFiles.filter(
+ (f) =>
+ (f.includes("helpers/") || f.includes("engine/")) &&
+ !f.endsWith("helpers/log.ts") &&
+ !f.endsWith("engine/reporter.ts") &&
+ !f.endsWith("helpers/login-flow.ts") &&
+ !f.includes("tests/")
+ );
+ const matches = await Promise.all(
+ files.map((file) => ctx.grep(file, /console\.(log|warn|info)\s*\(/))
+ );
+ for (const fileMatches of matches) {
+ for (const m of fileMatches) {
+ ctx.report.violation({
+ message:
+ "Use logInfo/logWarn/logDebug from helpers/log.ts instead of direct console output",
+ file: m.file,
+ line: m.line,
+ fix: "Import { logInfo, logWarn } from '../helpers/log' and use logInfo() or logWarn()",
+ });
+ }
}
- }
+ },
},
- },
- "exit-code-convention": {
- description: "Process.exit should use codes 0, 1, or 2 only",
- async check(ctx) {
- const matches = await Promise.all(
- ctx.scopedFiles.map((file) => ctx.grep(file, /process\.exit\((\d+)\)/))
- );
- for (const fileMatches of matches) {
- for (const m of fileMatches) {
- const codeMatch = m.content.match(/process\.exit\((\d+)\)/);
- if (codeMatch) {
- const code = Number(codeMatch[1]);
- if (code !== 0 && code !== 1 && code !== 2) {
- ctx.report.violation({
- message: `Exit code ${code} is not standard. Use 0 (success), 1 (failure), or 2 (internal error)`,
- file: m.file,
- line: m.line,
- });
+ "exit-code-convention": {
+ description: "Process.exit should use codes 0, 1, or 2 only",
+ async check(ctx) {
+ const matches = await Promise.all(
+ ctx.scopedFiles.map((file) =>
+ ctx.grep(file, /process\.exit\((\d+)\)/)
+ )
+ );
+ for (const fileMatches of matches) {
+ for (const m of fileMatches) {
+ const codeMatch = m.content.match(/process\.exit\((\d+)\)/);
+ if (codeMatch) {
+ const code = Number(codeMatch[1]);
+ if (code !== 0 && code !== 1 && code !== 2) {
+ ctx.report.violation({
+ message: `Exit code ${code} is not standard. Use 0 (success), 1 (failure), or 2 (internal error)`,
+ file: m.file,
+ line: m.line,
+ });
+ }
}
}
}
- }
+ },
},
},
-});
+} satisfies RuleSet;
diff --git a/.archgate/adrs/ARCH-003-output-formatting.rules.ts b/.archgate/adrs/ARCH-003-output-formatting.rules.ts
index 4e7bc79f..caf60450 100644
--- a/.archgate/adrs/ARCH-003-output-formatting.rules.ts
+++ b/.archgate/adrs/ARCH-003-output-formatting.rules.ts
@@ -1,54 +1,56 @@
-import { defineRules } from "../../src/formats/rules";
+///
const EMOJI_PATTERN =
/[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1F900}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u;
const EMOJI_IN_STRING =
/["'`].*[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1F900}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}].*["'`]/u;
-export default defineRules({
- "no-emoji-in-output": {
- description: "CLI output must not contain emoji characters",
- async check(ctx) {
- const files = ctx.scopedFiles.filter(
- (f) => !f.includes("tests/") && !f.includes(".archgate/")
- );
- const matches = await Promise.all(
- files.map((file) => ctx.grep(file, EMOJI_PATTERN))
- );
- for (const fileMatches of matches) {
- for (const m of fileMatches) {
- if (EMOJI_IN_STRING.test(m.content)) {
+export default {
+ rules: {
+ "no-emoji-in-output": {
+ description: "CLI output must not contain emoji characters",
+ async check(ctx) {
+ const files = ctx.scopedFiles.filter(
+ (f) => !f.includes("tests/") && !f.includes(".archgate/")
+ );
+ const matches = await Promise.all(
+ files.map((file) => ctx.grep(file, EMOJI_PATTERN))
+ );
+ for (const fileMatches of matches) {
+ for (const m of fileMatches) {
+ if (EMOJI_IN_STRING.test(m.content)) {
+ ctx.report.violation({
+ message: "Do not use emoji in CLI output strings",
+ file: m.file,
+ line: m.line,
+ fix: "Remove emoji from output strings",
+ });
+ }
+ }
+ }
+ },
+ },
+ "use-style-text": {
+ description: "Use styleText from node:util instead of raw ANSI codes",
+ async check(ctx) {
+ const files = ctx.scopedFiles.filter(
+ (f) => !f.includes("tests/") && !f.includes(".archgate/")
+ );
+ const matches = await Promise.all(
+ files.map((file) => ctx.grep(file, /\\u001b\[|\\x1b\[|\\033\[/))
+ );
+ for (const fileMatches of matches) {
+ for (const m of fileMatches) {
ctx.report.violation({
- message: "Do not use emoji in CLI output strings",
+ message:
+ "Use styleText() from node:util instead of raw ANSI escape codes",
file: m.file,
line: m.line,
- fix: "Remove emoji from output strings",
+ fix: "Import { styleText } from 'node:util' and use styleText(style, text)",
});
}
}
- }
- },
- },
- "use-style-text": {
- description: "Use styleText from node:util instead of raw ANSI codes",
- async check(ctx) {
- const files = ctx.scopedFiles.filter(
- (f) => !f.includes("tests/") && !f.includes(".archgate/")
- );
- const matches = await Promise.all(
- files.map((file) => ctx.grep(file, /\\u001b\[|\\x1b\[|\\033\[/))
- );
- for (const fileMatches of matches) {
- for (const m of fileMatches) {
- ctx.report.violation({
- message:
- "Use styleText() from node:util instead of raw ANSI escape codes",
- file: m.file,
- line: m.line,
- fix: "Import { styleText } from 'node:util' and use styleText(style, text)",
- });
- }
- }
+ },
},
},
-});
+} satisfies RuleSet;
diff --git a/.archgate/adrs/ARCH-004-no-barrel-files.rules.ts b/.archgate/adrs/ARCH-004-no-barrel-files.rules.ts
index 49d9b6f7..28f0d0e4 100644
--- a/.archgate/adrs/ARCH-004-no-barrel-files.rules.ts
+++ b/.archgate/adrs/ARCH-004-no-barrel-files.rules.ts
@@ -1,4 +1,4 @@
-import { defineRules } from "../../src/formats/rules";
+///
/**
* Determines whether a file is a barrel (re-export-only index.ts).
@@ -37,25 +37,29 @@ function isBarrelFile(content: string): boolean {
);
}
-export default defineRules({
- "no-barrel-files": {
- description: "index.ts files must not be pure re-export barrels",
- severity: "error",
- async check(ctx) {
- const indexFiles = ctx.scopedFiles.filter((f) => f.endsWith("/index.ts"));
+export default {
+ rules: {
+ "no-barrel-files": {
+ description: "index.ts files must not be pure re-export barrels",
+ severity: "error",
+ async check(ctx) {
+ const indexFiles = ctx.scopedFiles.filter((f) =>
+ f.endsWith("/index.ts")
+ );
- const checks = indexFiles.map(async (file) => {
- const content = await ctx.readFile(file);
- if (isBarrelFile(content)) {
- ctx.report.violation({
- message: `Barrel file detected: ${file} contains only re-exports and no logic. Import directly from source modules instead.`,
- file,
- fix: "Delete this barrel file and update all imports to point directly to the source module (e.g., import from './adr' instead of '.')",
- });
- }
- });
+ const checks = indexFiles.map(async (file) => {
+ const content = await ctx.readFile(file);
+ if (isBarrelFile(content)) {
+ ctx.report.violation({
+ message: `Barrel file detected: ${file} contains only re-exports and no logic. Import directly from source modules instead.`,
+ file,
+ fix: "Delete this barrel file and update all imports to point directly to the source module (e.g., import from './adr' instead of '.')",
+ });
+ }
+ });
- await Promise.all(checks);
+ await Promise.all(checks);
+ },
},
},
-});
+} satisfies RuleSet;
diff --git a/.archgate/adrs/ARCH-005-testing-standards.rules.ts b/.archgate/adrs/ARCH-005-testing-standards.rules.ts
index 9c0f29b9..25d15963 100644
--- a/.archgate/adrs/ARCH-005-testing-standards.rules.ts
+++ b/.archgate/adrs/ARCH-005-testing-standards.rules.ts
@@ -1,36 +1,39 @@
+///
import { basename, dirname } from "node:path";
-import { defineRules } from "../../src/formats/rules";
+///
-export default defineRules({
- "test-mirrors-src": {
- description: "Test directory structure should mirror src/ structure",
- severity: "error",
- async check(ctx) {
- // Get all src modules (non-index, non-cli.ts)
- const srcFiles = await ctx.glob("src/**/*.ts");
- const testFiles = await ctx.glob("tests/**/*.test.ts");
+export default {
+ rules: {
+ "test-mirrors-src": {
+ description: "Test directory structure should mirror src/ structure",
+ severity: "error",
+ async check(ctx) {
+ // Get all src modules (non-index, non-cli.ts)
+ const srcFiles = await ctx.glob("src/**/*.ts");
+ const testFiles = await ctx.glob("tests/**/*.test.ts");
- const testBasenames = new Set(
- testFiles.map((f) => basename(f, ".test.ts"))
- );
+ const testBasenames = new Set(
+ testFiles.map((f) => basename(f, ".test.ts"))
+ );
- for (const srcFile of srcFiles) {
- const name = basename(srcFile, ".ts");
- // Skip index files, entry point, and type-only files
- if (name === "index" || name === "cli") continue;
- // Skip files in directories that have an index (command groups)
- if (srcFile.includes("/commands/") && srcFile.endsWith("/index.ts"))
- continue;
+ for (const srcFile of srcFiles) {
+ const name = basename(srcFile, ".ts");
+ // Skip index files, entry point, and type-only files
+ if (name === "index" || name === "cli") continue;
+ // Skip files in directories that have an index (command groups)
+ if (srcFile.includes("/commands/") && srcFile.endsWith("/index.ts"))
+ continue;
- if (!testBasenames.has(name)) {
- ctx.report.violation({
- message: `Source file "${srcFile}" has no matching test file`,
- file: srcFile,
- fix: `Create a test file at tests/${dirname(srcFile).replace("src/", "")}/${name}.test.ts`,
- });
+ if (!testBasenames.has(name)) {
+ ctx.report.violation({
+ message: `Source file "${srcFile}" has no matching test file`,
+ file: srcFile,
+ fix: `Create a test file at tests/${dirname(srcFile).replace("src/", "")}/${name}.test.ts`,
+ });
+ }
}
- }
+ },
},
},
-});
+} satisfies RuleSet;
diff --git a/.archgate/adrs/ARCH-006-dependency-policy.rules.ts b/.archgate/adrs/ARCH-006-dependency-policy.rules.ts
index cba7d9b1..7edeab9e 100644
--- a/.archgate/adrs/ARCH-006-dependency-policy.rules.ts
+++ b/.archgate/adrs/ARCH-006-dependency-policy.rules.ts
@@ -1,4 +1,4 @@
-import { defineRules } from "../../src/formats/rules";
+///
const APPROVED_DEPS = [
"@commander-js/extra-typings",
@@ -7,27 +7,29 @@ const APPROVED_DEPS = [
"zod",
];
-export default defineRules({
- "no-unapproved-deps": {
- description: "Production dependencies must be on the approved list",
- async check(ctx) {
- let pkg: { dependencies?: Record };
- try {
- pkg = (await ctx.readJSON("package.json")) as typeof pkg;
- } catch {
- return; // No package.json — nothing to check
- }
+export default {
+ rules: {
+ "no-unapproved-deps": {
+ description: "Production dependencies must be on the approved list",
+ async check(ctx) {
+ let pkg: { dependencies?: Record };
+ try {
+ pkg = (await ctx.readJSON("package.json")) as typeof pkg;
+ } catch {
+ return; // No package.json — nothing to check
+ }
- const deps = Object.keys(pkg.dependencies ?? {});
- for (const dep of deps) {
- if (!APPROVED_DEPS.includes(dep)) {
- ctx.report.violation({
- message: `Unapproved production dependency: "${dep}". Approved: ${APPROVED_DEPS.join(", ")}`,
- file: "package.json",
- fix: `Either add "${dep}" to the approved list in ARCH-006 or move it to devDependencies`,
- });
+ const deps = Object.keys(pkg.dependencies ?? {});
+ for (const dep of deps) {
+ if (!APPROVED_DEPS.includes(dep)) {
+ ctx.report.violation({
+ message: `Unapproved production dependency: "${dep}". Approved: ${APPROVED_DEPS.join(", ")}`,
+ file: "package.json",
+ fix: `Either add "${dep}" to the approved list in ARCH-006 or move it to devDependencies`,
+ });
+ }
}
- }
+ },
},
},
-});
+} satisfies RuleSet;
diff --git a/.archgate/adrs/ARCH-007-cross-platform-subprocess-execution.rules.ts b/.archgate/adrs/ARCH-007-cross-platform-subprocess-execution.rules.ts
index b1dbbaf0..236d7e3a 100644
--- a/.archgate/adrs/ARCH-007-cross-platform-subprocess-execution.rules.ts
+++ b/.archgate/adrs/ARCH-007-cross-platform-subprocess-execution.rules.ts
@@ -1,63 +1,65 @@
-import { defineRules } from "../../src/formats/rules";
+///
-export default defineRules({
- "no-bun-shell": {
- description:
- "Subprocess execution must use Bun.spawn, not Bun.$ (shell hangs on Windows)",
- async check(ctx) {
- const files = ctx.scopedFiles.filter(
- (f) => !f.includes("tests/") && !f.includes(".archgate/")
- );
+export default {
+ rules: {
+ "no-bun-shell": {
+ description:
+ "Subprocess execution must use Bun.spawn, not Bun.$ (shell hangs on Windows)",
+ async check(ctx) {
+ const files = ctx.scopedFiles.filter(
+ (f) => !f.includes("tests/") && !f.includes(".archgate/")
+ );
- // Check for Bun.$ template literal usage
- const bunShellMatches = await Promise.all(
- files.map((file) => ctx.grep(file, /Bun\.\$`/))
- );
- for (const fileMatches of bunShellMatches) {
- for (const m of fileMatches) {
- ctx.report.violation({
- message:
- "Do not use Bun.$ template literals — they hang on Windows due to pipe deadlocks. Use Bun.spawn instead.",
- file: m.file,
- line: m.line,
- fix: "Replace Bun.$`cmd args` with Bun.spawn(['cmd', 'args'], { stdout: 'pipe', stderr: 'pipe' })",
- });
+ // Check for Bun.$ template literal usage
+ const bunShellMatches = await Promise.all(
+ files.map((file) => ctx.grep(file, /Bun\.\$`/))
+ );
+ for (const fileMatches of bunShellMatches) {
+ for (const m of fileMatches) {
+ ctx.report.violation({
+ message:
+ "Do not use Bun.$ template literals — they hang on Windows due to pipe deadlocks. Use Bun.spawn instead.",
+ file: m.file,
+ line: m.line,
+ fix: "Replace Bun.$`cmd args` with Bun.spawn(['cmd', 'args'], { stdout: 'pipe', stderr: 'pipe' })",
+ });
+ }
}
- }
- // Check for $ import from "bun" (the shell API)
- const dollarImportMatches = await Promise.all(
- files.map((file) =>
- ctx.grep(file, /import\s*\{[^}]*\$[^}]*\}\s*from\s*["']bun["']/)
- )
- );
- for (const fileMatches of dollarImportMatches) {
- for (const m of fileMatches) {
- ctx.report.violation({
- message:
- 'Do not import $ from "bun" — the Bun shell API hangs on Windows. Use Bun.spawn instead.',
- file: m.file,
- line: m.line,
- fix: 'Remove the $ import from "bun" and replace shell calls with Bun.spawn',
- });
+ // Check for $ import from "bun" (the shell API)
+ const dollarImportMatches = await Promise.all(
+ files.map((file) =>
+ ctx.grep(file, /import\s*\{[^}]*\$[^}]*\}\s*from\s*["']bun["']/)
+ )
+ );
+ for (const fileMatches of dollarImportMatches) {
+ for (const m of fileMatches) {
+ ctx.report.violation({
+ message:
+ 'Do not import $ from "bun" — the Bun shell API hangs on Windows. Use Bun.spawn instead.',
+ file: m.file,
+ line: m.line,
+ fix: 'Remove the $ import from "bun" and replace shell calls with Bun.spawn',
+ });
+ }
}
- }
- // Check for await $` pattern (destructured $ usage)
- const destructuredMatches = await Promise.all(
- files.map((file) => ctx.grep(file, /await\s+\$`/))
- );
- for (const fileMatches of destructuredMatches) {
- for (const m of fileMatches) {
- ctx.report.violation({
- message:
- "Do not use $` template literals (destructured Bun shell) — they hang on Windows. Use Bun.spawn instead.",
- file: m.file,
- line: m.line,
- fix: "Replace $`cmd args` with Bun.spawn(['cmd', 'args'], { stdout: 'pipe', stderr: 'pipe' })",
- });
+ // Check for await $` pattern (destructured $ usage)
+ const destructuredMatches = await Promise.all(
+ files.map((file) => ctx.grep(file, /await\s+\$`/))
+ );
+ for (const fileMatches of destructuredMatches) {
+ for (const m of fileMatches) {
+ ctx.report.violation({
+ message:
+ "Do not use $` template literals (destructured Bun shell) — they hang on Windows. Use Bun.spawn instead.",
+ file: m.file,
+ line: m.line,
+ fix: "Replace $`cmd args` with Bun.spawn(['cmd', 'args'], { stdout: 'pipe', stderr: 'pipe' })",
+ });
+ }
}
- }
+ },
},
},
-});
+} satisfies RuleSet;
diff --git a/.archgate/adrs/ARCH-008-typed-command-options.rules.ts b/.archgate/adrs/ARCH-008-typed-command-options.rules.ts
index 478246d5..67ddd352 100644
--- a/.archgate/adrs/ARCH-008-typed-command-options.rules.ts
+++ b/.archgate/adrs/ARCH-008-typed-command-options.rules.ts
@@ -1,63 +1,65 @@
-import { defineRules } from "../../src/formats/rules";
+///
-export default defineRules({
- "use-add-option-for-choices": {
- description:
- "Commands with fixed-choice options must use addOption with choices() instead of plain option()",
- severity: "error",
- async check(ctx) {
- const files = ctx.scopedFiles.filter((f) => !f.endsWith("index.ts"));
- // Detect .option() calls whose description enumerates known choice values
- // e.g. "editor integration to configure (claude, cursor, vscode, copilot)"
- // or "ADR domain: backend, frontend, data, architecture, general"
- const matches = await Promise.all(
- files.map((file) =>
- ctx.grep(
- file,
- /\.option\(\s*["']--\w+\s+<\w+>["'],\s*["'][^"']*(?:claude.*cursor|backend.*frontend)[^"']*["']/
+export default {
+ rules: {
+ "use-add-option-for-choices": {
+ description:
+ "Commands with fixed-choice options must use addOption with choices() instead of plain option()",
+ severity: "error",
+ async check(ctx) {
+ const files = ctx.scopedFiles.filter((f) => !f.endsWith("index.ts"));
+ // Detect .option() calls whose description enumerates known choice values
+ // e.g. "editor integration to configure (claude, cursor, vscode, copilot)"
+ // or "ADR domain: backend, frontend, data, architecture, general"
+ const matches = await Promise.all(
+ files.map((file) =>
+ ctx.grep(
+ file,
+ /\.option\(\s*["']--\w+\s+<\w+>["'],\s*["'][^"']*(?:claude.*cursor|backend.*frontend)[^"']*["']/
+ )
)
- )
- );
- for (const fileMatches of matches) {
- for (const m of fileMatches) {
- ctx.report.violation({
- message:
- "Use new Option().choices() with .addOption() instead of .option() for fixed-choice options",
- file: m.file,
- line: m.line,
- fix: "Replace .option() with new Option(...).choices([...] as const) and register via .addOption()",
- });
+ );
+ for (const fileMatches of matches) {
+ for (const m of fileMatches) {
+ ctx.report.violation({
+ message:
+ "Use new Option().choices() with .addOption() instead of .option() for fixed-choice options",
+ file: m.file,
+ line: m.line,
+ fix: "Replace .option() with new Option(...).choices([...] as const) and register via .addOption()",
+ });
+ }
}
- }
+ },
},
- },
- "use-add-option-for-arg-parser": {
- description:
- "Options with custom parsers must use addOption with argParser() instead of passing a parser to option()",
- severity: "error",
- async check(ctx) {
- const files = ctx.scopedFiles.filter((f) => !f.endsWith("index.ts"));
- // Detect .option() calls that pass a function as the third argument
- // e.g. .option("--max-entries ", "...", parseInt)
- const matches = await Promise.all(
- files.map((file) =>
- ctx.grep(
- file,
- /\.option\(\s*["']--\w+\s+<\w+>["'],\s*["'][^"']*["'],\s*(?:parseInt|parseFloat|\()/
+ "use-add-option-for-arg-parser": {
+ description:
+ "Options with custom parsers must use addOption with argParser() instead of passing a parser to option()",
+ severity: "error",
+ async check(ctx) {
+ const files = ctx.scopedFiles.filter((f) => !f.endsWith("index.ts"));
+ // Detect .option() calls that pass a function as the third argument
+ // e.g. .option("--max-entries ", "...", parseInt)
+ const matches = await Promise.all(
+ files.map((file) =>
+ ctx.grep(
+ file,
+ /\.option\(\s*["']--\w+\s+<\w+>["'],\s*["'][^"']*["'],\s*(?:parseInt|parseFloat|\()/
+ )
)
- )
- );
- for (const fileMatches of matches) {
- for (const m of fileMatches) {
- ctx.report.violation({
- message:
- "Use new Option().argParser() with .addOption() instead of passing a parser to .option()",
- file: m.file,
- line: m.line,
- fix: "Replace .option() with new Option(...).argParser((val) => ...) and register via .addOption()",
- });
+ );
+ for (const fileMatches of matches) {
+ for (const m of fileMatches) {
+ ctx.report.violation({
+ message:
+ "Use new Option().argParser() with .addOption() instead of passing a parser to .option()",
+ file: m.file,
+ line: m.line,
+ fix: "Replace .option() with new Option(...).argParser((val) => ...) and register via .addOption()",
+ });
+ }
}
- }
+ },
},
},
-});
+} satisfies RuleSet;
diff --git a/.archgate/adrs/ARCH-009-platform-detection-helper.rules.ts b/.archgate/adrs/ARCH-009-platform-detection-helper.rules.ts
index c6bfb863..bab7ae05 100644
--- a/.archgate/adrs/ARCH-009-platform-detection-helper.rules.ts
+++ b/.archgate/adrs/ARCH-009-platform-detection-helper.rules.ts
@@ -1,31 +1,33 @@
-import { defineRules } from "../../src/formats/rules";
+///
-export default defineRules({
- "no-direct-process-platform": {
- description:
- "Platform detection must use src/helpers/platform.ts, not process.platform directly",
- async check(ctx) {
- const files = ctx.scopedFiles.filter(
- (f) =>
- !f.includes("tests/") &&
- !f.includes(".archgate/") &&
- !f.endsWith("src/helpers/platform.ts")
- );
+export default {
+ rules: {
+ "no-direct-process-platform": {
+ description:
+ "Platform detection must use src/helpers/platform.ts, not process.platform directly",
+ async check(ctx) {
+ const files = ctx.scopedFiles.filter(
+ (f) =>
+ !f.includes("tests/") &&
+ !f.includes(".archgate/") &&
+ !f.endsWith("src/helpers/platform.ts")
+ );
- const matches = await Promise.all(
- files.map((file) => ctx.grep(file, /process\.platform/))
- );
- for (const fileMatches of matches) {
- for (const m of fileMatches) {
- ctx.report.violation({
- message:
- "Do not access process.platform directly — use isWindows(), isMacOS(), isLinux(), or getPlatformInfo() from src/helpers/platform.ts instead.",
- file: m.file,
- line: m.line,
- fix: 'Import { isWindows } from "../helpers/platform" (or the appropriate helper) and use it instead of process.platform',
- });
+ const matches = await Promise.all(
+ files.map((file) => ctx.grep(file, /process\.platform/))
+ );
+ for (const fileMatches of matches) {
+ for (const m of fileMatches) {
+ ctx.report.violation({
+ message:
+ "Do not access process.platform directly — use isWindows(), isMacOS(), isLinux(), or getPlatformInfo() from src/helpers/platform.ts instead.",
+ file: m.file,
+ line: m.line,
+ fix: 'Import { isWindows } from "../helpers/platform" (or the appropriate helper) and use it instead of process.platform',
+ });
+ }
}
- }
+ },
},
},
-});
+} satisfies RuleSet;
diff --git a/.archgate/adrs/ARCH-010-prefer-bun-built-in-json-parsing.md b/.archgate/adrs/ARCH-010-prefer-bun-built-in-json-parsing.md
new file mode 100644
index 00000000..b0172abc
--- /dev/null
+++ b/.archgate/adrs/ARCH-010-prefer-bun-built-in-json-parsing.md
@@ -0,0 +1,63 @@
+---
+id: ARCH-010
+title: Prefer Bun built-in JSON parsing
+domain: architecture
+rules: true
+files: ["src/**/*.ts"]
+---
+
+## Context
+
+Bun provides built-in JSON parsing via \`Bun.file(path).json()\` that is more ergonomic and consistent with other Bun file APIs (\`.text()\`, \`.arrayBuffer()\`, etc.) than the standard \`JSON.parse(await Bun.file(path).text())\` two-step pattern.
+
+Using \`Bun.file().json()\` keeps the codebase consistent with ARCH-006 (prefer Bun built-ins) and avoids the intermediate string allocation.
+
+**Alternatives considered:**
+
+- **\`JSON.parse()\` with \`fs.readFileSync()\`** — Node.js pattern, not idiomatic Bun. Also synchronous.
+- **\`JSON.parse(await Bun.file().text())\`** — Works but unnecessarily verbose. The \`.json()\` method does the same thing in one step.
+- **\`Bun.JSONC.parse()\`** — For JSONC (JSON with comments) only. Use when the file may contain comments (e.g., \`tsconfig.json\`).
+
+## Decision
+
+Use \`Bun.file(path).json()\` for reading JSON files. Reserve \`JSON.parse()\` for parsing JSON from non-file sources (API responses, string variables, etc.).
+
+## Do's and Don'ts
+
+### Do
+
+- Use \`await Bun.file("config.json").json()\` for reading JSON files
+- Use \`Bun.JSONC.parse()\` when the file may contain comments (tsconfig, etc.)
+- Use \`JSON.parse()\` for parsing JSON strings from non-file sources
+
+### Don't
+
+- Don't use \`JSON.parse(await Bun.file(path).text())\` — use \`.json()\` directly
+- Don't use \`JSON.parse(fs.readFileSync(path, "utf-8"))\` — use Bun.file instead
+
+## Consequences
+
+### Positive
+
+- Consistent with Bun idioms and ARCH-006 dependency policy
+- Slightly less code per JSON read operation
+- Avoids intermediate string allocation
+
+### Negative
+
+- Contributors familiar with Node.js may default to \`JSON.parse()\` out of habit
+
+## Compliance and Enforcement
+
+### Automated Enforcement
+
+- **Archgate rule** \`ARCH-010/prefer-bun-json\`: Scans for \`JSON.parse(await Bun.file\` patterns in source files and flags them. Severity: \`warning\`.
+
+### Manual Enforcement
+
+Code reviewers should prefer \`Bun.file().json()\` over \`JSON.parse()\` for file reads during review.
+
+## References
+
+- [Bun.file() API](https://bun.sh/docs/api/file-io)
+- [ARCH-006 — Dependency Policy](./ARCH-006-dependency-policy.md) — Parent policy on preferring Bun built-ins
diff --git a/.archgate/adrs/ARCH-010-prefer-bun-built-in-json-parsing.rules.ts b/.archgate/adrs/ARCH-010-prefer-bun-built-in-json-parsing.rules.ts
new file mode 100644
index 00000000..30b4fb93
--- /dev/null
+++ b/.archgate/adrs/ARCH-010-prefer-bun-built-in-json-parsing.rules.ts
@@ -0,0 +1,62 @@
+///
+
+export default {
+ rules: {
+ "prefer-bun-json": {
+ description:
+ "Use Bun.file().json() instead of JSON.parse with Bun.file().text()",
+ severity: "warning",
+ async check(ctx) {
+ const files = ctx.scopedFiles.filter(
+ (f) => !f.includes("tests/") && !f.includes(".archgate/")
+ );
+
+ // Pattern 1: single-expression JSON.parse(await Bun.file(...).text())
+ const inlineMatches = await Promise.all(
+ files.map((file) =>
+ ctx.grep(file, /JSON\.parse\(\s*await\s+Bun\.file/)
+ )
+ );
+ for (const fileMatches of inlineMatches) {
+ for (const m of fileMatches) {
+ ctx.report.warning({
+ message:
+ "Use Bun.file(path).json() instead of JSON.parse(await Bun.file(path).text())",
+ file: m.file,
+ line: m.line,
+ fix: "Replace with await Bun.file(path).json()",
+ });
+ }
+ }
+
+ // Pattern 2: two-line pattern — Bun.file().text() assigned to a
+ // variable that is then passed to JSON.parse on a subsequent line.
+ // Detect files that use both Bun.file().text() and JSON.parse.
+ await Promise.all(
+ files.map(async (file) => {
+ const content = await ctx.readFile(file);
+ if (!content.includes("JSON.parse")) return;
+
+ const textCalls = await ctx.grep(
+ file,
+ /Bun\.file\([^)]+\)\.text\(\)/
+ );
+ if (textCalls.length === 0) return;
+
+ // If JSON.parse appears after a Bun.file().text() call,
+ // it's likely parsing the result of that read.
+ for (const m of textCalls) {
+ ctx.report.warning({
+ message:
+ "Bun.file().text() followed by JSON.parse — use Bun.file().json() instead",
+ file: m.file,
+ line: m.line,
+ fix: "Replace .text() + JSON.parse() with .json()",
+ });
+ }
+ })
+ );
+ },
+ },
+ },
+} satisfies RuleSet;
diff --git a/.archgate/adrs/GEN-002-docs-i18n.rules.ts b/.archgate/adrs/GEN-002-docs-i18n.rules.ts
index 742b3ab5..0489993b 100644
--- a/.archgate/adrs/GEN-002-docs-i18n.rules.ts
+++ b/.archgate/adrs/GEN-002-docs-i18n.rules.ts
@@ -1,4 +1,4 @@
-import { defineRules } from "../../src/formats/rules";
+///
/**
* Configured documentation locales.
@@ -14,96 +14,98 @@ const LOCALE_LINK_PATTERNS = LOCALES.map(
(locale) => new RegExp(`(?:href="|\\]\\()/${locale}/`, "g")
);
-export default defineRules({
- "no-locale-prefix-in-links": {
- description:
- "Locale pages must not use locale-prefixed internal links — Starlight resolves them automatically",
- severity: "error",
- async check(ctx) {
- /* oxlint-disable no-await-in-loop -- sequential per-locale is fine for a small list */
- for (const locale of LOCALES) {
- const localePrefix = `${CONTENT_ROOT}/${locale}/`;
- const localeFiles = (await ctx.glob(`${localePrefix}**/*.mdx`)).filter(
- (f) => f.startsWith(localePrefix)
- );
- const pattern = LOCALE_LINK_PATTERNS[LOCALES.indexOf(locale)];
+export default {
+ rules: {
+ "no-locale-prefix-in-links": {
+ description:
+ "Locale pages must not use locale-prefixed internal links — Starlight resolves them automatically",
+ severity: "error",
+ async check(ctx) {
+ await Promise.all(
+ LOCALES.map(async (locale, i) => {
+ const localePrefix = `${CONTENT_ROOT}/${locale}/`;
+ const localeFiles = (
+ await ctx.glob(`${localePrefix}**/*.mdx`)
+ ).filter((f) => f.startsWith(localePrefix));
+ const pattern = LOCALE_LINK_PATTERNS[i];
- const matches = await Promise.all(
- localeFiles.map((file) => ctx.grep(file, pattern))
+ const matches = await Promise.all(
+ localeFiles.map((file) => ctx.grep(file, pattern))
+ );
+ for (const fileMatches of matches) {
+ for (const m of fileMatches) {
+ ctx.report.violation({
+ message: `Internal link contains locale prefix "/${locale}/". Remove the prefix — Starlight resolves locale routes automatically.`,
+ file: m.file,
+ line: m.line,
+ fix: `Replace "/${locale}/..." with "/..." in the link`,
+ });
+ }
+ }
+ })
);
- for (const fileMatches of matches) {
- for (const m of fileMatches) {
- ctx.report.violation({
- message: `Internal link contains locale prefix "/${locale}/". Remove the prefix — Starlight resolves locale routes automatically.`,
- file: m.file,
- line: m.line,
- fix: `Replace "/${locale}/..." with "/..." in the link`,
- });
- }
- }
- }
- /* oxlint-enable no-await-in-loop */
+ },
},
- },
- "i18n-page-parity": {
- description:
- "Every root MDX file must have a corresponding translation in each locale, and vice versa",
- severity: "error",
- async check(ctx) {
- const allMdxFiles = await ctx.glob(`${CONTENT_ROOT}/**/*.mdx`);
-
- // Separate root files from locale files
- const rootFiles: string[] = [];
- const localeFiles = new Map();
+ "i18n-page-parity": {
+ description:
+ "Every root MDX file must have a corresponding translation in each locale, and vice versa",
+ severity: "error",
+ async check(ctx) {
+ const allMdxFiles = await ctx.glob(`${CONTENT_ROOT}/**/*.mdx`);
- for (const locale of LOCALES) {
- localeFiles.set(locale, []);
- }
+ // Separate root files from locale files
+ const rootFiles: string[] = [];
+ const localeFiles = new Map();
- for (const file of allMdxFiles) {
- const matchedLocale = LOCALES.find((l) =>
- file.startsWith(`${CONTENT_ROOT}/${l}/`)
- );
- if (matchedLocale) {
- localeFiles.get(matchedLocale)!.push(file);
- } else {
- rootFiles.push(file);
+ for (const locale of LOCALES) {
+ localeFiles.set(locale, []);
}
- }
- const rootRelativePaths = rootFiles.map((f) =>
- f.replace(`${CONTENT_ROOT}/`, "")
- );
- const rootRelativeSet = new Set(rootRelativePaths);
+ for (const file of allMdxFiles) {
+ const matchedLocale = LOCALES.find((l) =>
+ file.startsWith(`${CONTENT_ROOT}/${l}/`)
+ );
+ if (matchedLocale) {
+ localeFiles.get(matchedLocale)!.push(file);
+ } else {
+ rootFiles.push(file);
+ }
+ }
- for (const locale of LOCALES) {
- const localePrefix = `${CONTENT_ROOT}/${locale}/`;
- const existingLocaleRelatives = new Set(
- localeFiles.get(locale)!.map((f) => f.replace(localePrefix, ""))
+ const rootRelativePaths = rootFiles.map((f) =>
+ f.replace(`${CONTENT_ROOT}/`, "")
);
+ const rootRelativeSet = new Set(rootRelativePaths);
- // Root -> locale: missing translations
- for (const relativePath of rootRelativePaths) {
- if (!existingLocaleRelatives.has(relativePath)) {
- ctx.report.violation({
- message: `Missing ${locale} translation for "${relativePath}"`,
- file: `${CONTENT_ROOT}/${relativePath}`,
- fix: `Create translated file at ${localePrefix}${relativePath}`,
- });
+ for (const locale of LOCALES) {
+ const localePrefix = `${CONTENT_ROOT}/${locale}/`;
+ const existingLocaleRelatives = new Set(
+ localeFiles.get(locale)!.map((f) => f.replace(localePrefix, ""))
+ );
+
+ // Root -> locale: missing translations
+ for (const relativePath of rootRelativePaths) {
+ if (!existingLocaleRelatives.has(relativePath)) {
+ ctx.report.violation({
+ message: `Missing ${locale} translation for "${relativePath}"`,
+ file: `${CONTENT_ROOT}/${relativePath}`,
+ fix: `Create translated file at ${localePrefix}${relativePath}`,
+ });
+ }
}
- }
- // Locale -> root: orphan translations
- for (const localeRelative of existingLocaleRelatives) {
- if (!rootRelativeSet.has(localeRelative)) {
- ctx.report.violation({
- message: `Orphan ${locale} translation "${localeRelative}" has no corresponding root file`,
- file: `${localePrefix}${localeRelative}`,
- fix: `Either create the root file at ${CONTENT_ROOT}/${localeRelative} or remove the orphan translation`,
- });
+ // Locale -> root: orphan translations
+ for (const localeRelative of existingLocaleRelatives) {
+ if (!rootRelativeSet.has(localeRelative)) {
+ ctx.report.violation({
+ message: `Orphan ${locale} translation "${localeRelative}" has no corresponding root file`,
+ file: `${localePrefix}${localeRelative}`,
+ fix: `Either create the root file at ${CONTENT_ROOT}/${localeRelative} or remove the orphan translation`,
+ });
+ }
}
}
- }
+ },
},
},
-});
+} satisfies RuleSet;
diff --git a/.gitignore b/.gitignore
index c6258bd5..3957f373 100644
--- a/.gitignore
+++ b/.gitignore
@@ -39,3 +39,6 @@ packages/*/bin/archgate
# Docs site build artifacts
docs/.astro/
+
+# Archgate generated type definitions (regenerated by archgate)
+.archgate/rules.d.ts
diff --git a/.oxlintrc.json b/.oxlintrc.json
index 4ff53c45..f890eeb7 100644
--- a/.oxlintrc.json
+++ b/.oxlintrc.json
@@ -10,5 +10,11 @@
"max-depth": "off",
"no-await-in-loop": "warn",
"typescript/no-deprecated": "error"
- }
+ },
+ "overrides": [
+ {
+ "files": [".archgate/adrs/*.rules.ts"],
+ "rules": { "typescript/triple-slash-reference": "off" }
+ }
+ ]
}
diff --git a/CLAUDE.md b/CLAUDE.md
index ea74169c..96041c4d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -90,9 +90,10 @@ Bun 1.3.9, Node LTS, npm 11.11.0. Minimum user-facing Bun: `>=1.2.21` (enforced
- `ARCH-007` — Cross-platform subprocess execution (Bun.spawn, no Bun.$)
- `ARCH-008` — Typed command options (use addOption for choices/argParser)
- `ARCH-009` — Centralized platform detection (use helpers/platform)
+- `ARCH-010` — Prefer Bun built-in JSON parsing (Bun.file().json())
- `GEN-001` — Documentation site (Astro Starlight)
- `GEN-002` — Documentation internationalization (en + pt-br parity)
## ADR Format
-YAML frontmatter (`id`, `title`, `domain`, `rules`, optional `files`). Sections: Context, Decision, Do's and Don'ts, Consequences, Compliance, References. Companion `.rules.ts` exports `defineRules()`.
+YAML frontmatter (`id`, `title`, `domain`, `rules`, optional `files`). Sections: Context, Decision, Do's and Don'ts, Consequences, Compliance, References. Companion `.rules.ts` exports a plain object `satisfies RuleSet`.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index e2a5615b..5bdfb8cf 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -111,7 +111,7 @@ src/
│ └── runner.ts # Rule execution engine
├── formats/
│ ├── adr.ts # ADR frontmatter schema and parsing
-│ └── rules.ts # Rule types and defineRules()
+│ └── rules.ts # Rule types (RuleSet, RuleConfig)
├── helpers/
│ ├── paths.ts # Path helpers (~/.archgate/, .archgate/)
│ ├── log.ts # Logging utilities (logDebug, logInfo, etc.)
diff --git a/README.md b/README.md
index 78643d29..2ff83ccd 100644
--- a/README.md
+++ b/README.md
@@ -95,7 +95,7 @@ archgate check
## Writing rules
-Each ADR can have a companion `.rules.ts` file that exports checks using `defineRules()` from the `archgate` package. Rules receive the list of files to check and return an array of violations with file paths and line numbers.
+Each ADR can have a companion `.rules.ts` file that exports a plain object typed with `satisfies RuleSet`. Rules receive the list of files to check and return an array of violations with file paths and line numbers.
See the [writing rules guide](https://cli.archgate.dev/guides/writing-rules/) for examples and the full [rule API reference](https://cli.archgate.dev/reference/rule-api/).
diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt
index b390006b..27c94131 100644
--- a/docs/public/llms-full.txt
+++ b/docs/public/llms-full.txt
@@ -24,7 +24,7 @@ Install standalone (no Node.js required): `curl -fsSL https://raw.githubusercont
- [Core Concepts — Rules](https://cli.archgate.dev/concepts/rules/): The TypeScript rule system that turns ADR decisions into automated compliance checks.
- [Core Concepts — Domains](https://cli.archgate.dev/concepts/domains/): Organize ADRs by domain for targeted governance.
- [Guide — Writing ADRs](https://cli.archgate.dev/guides/writing-adrs/): Complete guide to writing effective ADRs with YAML frontmatter and markdown structure.
-- [Guide — Writing Rules](https://cli.archgate.dev/guides/writing-rules/): Write TypeScript rules using the defineRules API with file matching and violation reporting.
+- [Guide — Writing Rules](https://cli.archgate.dev/guides/writing-rules/): Write TypeScript rules using the satisfies RuleSet pattern with file matching and violation reporting.
- [Guide — CI Integration](https://cli.archgate.dev/guides/ci-integration/): Add Archgate checks to GitHub Actions, GitLab CI, or any pipeline.
- [Guide — Claude Code Plugin](https://cli.archgate.dev/guides/claude-code-plugin/): Give AI agents a governance workflow that reads ADRs, validates code, and captures patterns.
- [Guide — VS Code Plugin](https://cli.archgate.dev/guides/vscode-plugin/): Real-time ADR compliance in VS Code.
@@ -34,7 +34,7 @@ Install standalone (no Node.js required): `curl -fsSL https://raw.githubusercont
- [Guide — Pre-commit Hooks](https://cli.archgate.dev/guides/pre-commit-hooks/): Automatically check ADR compliance before every commit.
- [Reference — CLI Commands](https://cli.archgate.dev/reference/cli-commands/): Complete reference for init, check, adr create/list/show, login, and more.
- [Reference — MCP Tools](https://cli.archgate.dev/reference/mcp-tools/): API reference for adr_list, adr_read, check_compliance, and review_context.
-- [Reference — Rule API](https://cli.archgate.dev/reference/rule-api/): TypeScript API reference for defineRules, RuleContext, and violation reporting.
+- [Reference — Rule API](https://cli.archgate.dev/reference/rule-api/): TypeScript API reference for RuleSet with satisfies, RuleContext, and violation reporting.
- [Reference — ADR Schema](https://cli.archgate.dev/reference/adr-schema/): YAML frontmatter schema and markdown structure reference for ADRs.
- [Examples — Common Rule Patterns](https://cli.archgate.dev/examples/common-rule-patterns/): Ready-to-use rule patterns for naming conventions, import restrictions, and more.
@@ -312,28 +312,31 @@ Below the frontmatter, write the decision in markdown. Archgate does not enforce
## 4. Add a companion rules file
-Create a `.rules.ts` file next to your ADR with the same name prefix. Rules are written in TypeScript using the `defineRules` function:
+Create a `.rules.ts` file next to your ADR with the same name prefix. Rules are written in TypeScript using the `RuleSet` type:
```typescript
-
-export default defineRules({
- "no-console-error": {
- description: "Use logError() instead of console.error()",
- async check(ctx) {
- for (const file of ctx.scopedFiles) {
- const matches = await ctx.grep(file, /console\.error\(/);
- for (const match of matches) {
- ctx.report.violation({
- message: "Use logError() instead of console.error()",
- file: match.file,
- line: match.line,
- fix: "Import logError from your helpers and use it instead",
- });
+///
+
+export default {
+ rules: {
+ "no-console-error": {
+ description: "Use logError() instead of console.error()",
+ async check(ctx) {
+ for (const file of ctx.scopedFiles) {
+ const matches = await ctx.grep(file, /console\.error\(/);
+ for (const match of matches) {
+ ctx.report.violation({
+ message: "Use logError() instead of console.error()",
+ file: match.file,
+ line: match.line,
+ fix: "Import logError from your helpers and use it instead",
+ });
+ }
}
- }
+ },
},
},
-});
+} satisfies RuleSet;
```
Each rule has a unique key, a description, and an async `check` function. Inside `check`, you have access to:
@@ -411,7 +414,7 @@ With the [Claude Code](/guides/claude-code-plugin/) or [Cursor](/guides/cursor-i
### ADR as Rules
-The rules file is a companion `.rules.ts` file that exports automated checks via `defineRules()`. When you run `archgate check`, the CLI loads every ADR that has `rules: true` in its frontmatter, executes the companion rules file against your codebase, and reports any violations with file paths and line numbers.
+The rules file is a companion `.rules.ts` file that exports a plain object typed with `satisfies RuleSet`. When you run `archgate check`, the CLI loads every ADR that has `rules: true` in its frontmatter, executes the companion rules file against your codebase, and reports any violations with file paths and line numbers.
Not every ADR needs rules. Some decisions are best enforced through code review alone. Set `rules: false` when no automated check is practical.
@@ -673,26 +676,29 @@ When in doubt between `architecture` and a specific domain, prefer the more spec
Source: https://cli.archgate.dev/concepts/rules/
-Rules are the executable side of an ADR. They live in companion `.rules.ts` files alongside the ADR document and export automated checks via the `defineRules()` function. When you run `archgate check`, the CLI loads each ADR that has `rules: true`, imports its companion rules file, and executes every check against your codebase.
+Rules are the executable side of an ADR. They live in companion `.rules.ts` files alongside the ADR document and export a plain object typed with `satisfies RuleSet`. When you run `archgate check`, the CLI loads each ADR that has `rules: true`, imports its companion rules file, and executes every check against your codebase.
## Defining Rules
-A rules file is a TypeScript module that default-exports the result of `defineRules()`. Import the function from the `archgate/rules` package:
+A rules file is a TypeScript module that default-exports a plain object conforming to the `RuleSet` type. The type is provided by the local shim auto-generated by `archgate init` (no npm install needed):
```typescript
-
-export default defineRules({
- "rule-key": {
- description: "What this rule checks",
- severity: "error",
- async check(ctx) {
- // Inspect files and report violations
+///
+
+export default {
+ rules: {
+ "rule-key": {
+ description: "What this rule checks",
+ severity: "error",
+ async check(ctx) {
+ // Inspect files and report violations
+ },
},
},
-});
+} satisfies RuleSet;
```
-Each key in the object passed to `defineRules()` becomes the rule ID. The full rule identifier shown in check output combines the ADR ID and the rule key, for example `ARCH-004/no-barrel-files`.
+Each key in the `rules` object becomes the rule ID. The full rule identifier shown in check output combines the ADR ID and the rule key, for example `ARCH-004/no-barrel-files`.
## Rule Structure
@@ -784,32 +790,35 @@ Each rule has a 30-second execution timeout. If a rule's `check` function does n
Here is a complete rules file that checks for a banned import pattern. It enforces that no source file imports directly from `node:fs` (the project requires using a wrapper instead).
```typescript
-
-export default defineRules({
- "no-direct-fs-import": {
- description:
- "Source files must not import directly from node:fs; use the fs wrapper",
- severity: "error",
- async check(ctx) {
- const sourceFiles = ctx.scopedFiles.filter(
- (f) => f.endsWith(".ts") && !f.endsWith(".test.ts")
- );
-
- for (const file of sourceFiles) {
- const matches = await ctx.grep(file, /from ["']node:fs["']/);
-
- for (const match of matches) {
- ctx.report.violation({
- message: `Direct import from "node:fs" is not allowed. Use the fs wrapper from "src/helpers/fs" instead.`,
- file: match.file,
- line: match.line,
- fix: 'Replace the import with: import { readFile, writeFile } from "../helpers/fs"',
- });
+///
+
+export default {
+ rules: {
+ "no-direct-fs-import": {
+ description:
+ "Source files must not import directly from node:fs; use the fs wrapper",
+ severity: "error",
+ async check(ctx) {
+ const sourceFiles = ctx.scopedFiles.filter(
+ (f) => f.endsWith(".ts") && !f.endsWith(".test.ts")
+ );
+
+ for (const file of sourceFiles) {
+ const matches = await ctx.grep(file, /from ["']node:fs["']/);
+
+ for (const match of matches) {
+ ctx.report.violation({
+ message: `Direct import from "node:fs" is not allowed. Use the fs wrapper from "src/helpers/fs" instead.`,
+ file: match.file,
+ line: match.line,
+ fix: 'Replace the import with: import { readFile, writeFile } from "../helpers/fs"',
+ });
+ }
}
- }
+ },
},
},
-});
+} satisfies RuleSet;
```
When this rule runs against a file containing `import { readFileSync } from "node:fs"`, the output looks like:
@@ -2065,38 +2074,69 @@ Rules are TypeScript functions that check your codebase for ADR compliance. They
## Basic setup
-Every rules file exports a default `defineRules()` call. Each key becomes a rule ID, and each rule has a `description` and an async `check` function that receives a context object.
+Every rules file exports a default plain object typed with `satisfies RuleSet`. Each key in the `rules` object becomes a rule ID, and each rule has a `description` and an async `check` function that receives a context object.
```typescript
-
-export default defineRules({
- "my-rule-id": {
- description: "What this rule checks",
- async check(ctx) {
- // Your check logic here
+///
+
+export default {
+ rules: {
+ "my-rule-id": {
+ description: "What this rule checks",
+ async check(ctx) {
+ // Your check logic here
+ },
},
},
-});
+} satisfies RuleSet;
+```
+
+:::note
+The `/// ` directive may conflict with the `@typescript-eslint/triple-slash-reference` rule in ESLint or oxlint. If your project uses this rule, disable it for `.archgate/adrs/` files.
+
+For **ESLint** (flat config):
+
+```js
+{
+ files: [".archgate/adrs/*.rules.ts"],
+ rules: { "@typescript-eslint/triple-slash-reference": "off" },
+}
+```
+
+For **oxlint** (`.oxlintrc.json`):
+
+```json
+{
+ "overrides": [
+ {
+ "files": [".archgate/adrs/*.rules.ts"],
+ "rules": { "typescript/triple-slash-reference": "off" }
+ }
+ ]
+}
```
A single rules file can define multiple rules:
```typescript
-
-export default defineRules({
- "first-rule": {
- description: "Checks one thing",
- async check(ctx) {
- // ...
+///
+
+export default {
+ rules: {
+ "first-rule": {
+ description: "Checks one thing",
+ async check(ctx) {
+ // ...
+ },
},
- },
- "second-rule": {
- description: "Checks another thing",
- async check(ctx) {
- // ...
+ "second-rule": {
+ description: "Checks another thing",
+ async check(ctx) {
+ // ...
+ },
},
},
-});
+} satisfies RuleSet;
```
## The Context API
@@ -2222,6 +2262,7 @@ The absolute path to the project root directory. Useful when you need to constru
Check that all production dependencies are on an approved list. This is the rule Archgate uses for its own ARCH-006 dependency policy.
```typescript
+///
const APPROVED_DEPS = [
"@commander-js/extra-typings",
@@ -2230,30 +2271,32 @@ const APPROVED_DEPS = [
"zod",
];
-export default defineRules({
- "no-unapproved-deps": {
- description: "Production dependencies must be on the approved list",
- async check(ctx) {
- let pkg: { dependencies?: Record };
- try {
- pkg = (await ctx.readJSON("package.json")) as typeof pkg;
- } catch {
- return;
- }
-
- const deps = Object.keys(pkg.dependencies ?? {});
- for (const dep of deps) {
- if (!APPROVED_DEPS.includes(dep)) {
- ctx.report.violation({
- message: `Unapproved production dependency: "${dep}". Approved: ${APPROVED_DEPS.join(", ")}`,
- file: "package.json",
- fix: `Either add "${dep}" to the approved list in the ADR or move it to devDependencies`,
- });
+export default {
+ rules: {
+ "no-unapproved-deps": {
+ description: "Production dependencies must be on the approved list",
+ async check(ctx) {
+ let pkg: { dependencies?: Record };
+ try {
+ pkg = (await ctx.readJSON("package.json")) as typeof pkg;
+ } catch {
+ return;
+ }
+
+ const deps = Object.keys(pkg.dependencies ?? {});
+ for (const dep of deps) {
+ if (!APPROVED_DEPS.includes(dep)) {
+ ctx.report.violation({
+ message: `Unapproved production dependency: "${dep}". Approved: ${APPROVED_DEPS.join(", ")}`,
+ file: "package.json",
+ fix: `Either add "${dep}" to the approved list in the ADR or move it to devDependencies`,
+ });
+ }
}
- }
+ },
},
},
-});
+} satisfies RuleSet;
```
### Example 2: Required export pattern
@@ -2261,25 +2304,28 @@ export default defineRules({
Verify that every command file exports a `register*Command` function. This is the rule Archgate uses for its own ARCH-001 command structure.
```typescript
-
-export default defineRules({
- "register-function-export": {
- description: "Command files must export a register*Command function",
- async check(ctx) {
- const files = ctx.scopedFiles.filter((f) => !f.endsWith("index.ts"));
- const checks = files.map(async (file) => {
- const content = await ctx.readFile(file);
- if (!/export\s+function\s+register\w+Command/.test(content)) {
- ctx.report.violation({
- message: "Command file must export a register*Command function",
- file,
- });
- }
- });
- await Promise.all(checks);
+///
+
+export default {
+ rules: {
+ "register-function-export": {
+ description: "Command files must export a register*Command function",
+ async check(ctx) {
+ const files = ctx.scopedFiles.filter((f) => !f.endsWith("index.ts"));
+ const checks = files.map(async (file) => {
+ const content = await ctx.readFile(file);
+ if (!/export\s+function\s+register\w+Command/.test(content)) {
+ ctx.report.violation({
+ message: "Command file must export a register*Command function",
+ file,
+ });
+ }
+ });
+ await Promise.all(checks);
+ },
},
},
-});
+} satisfies RuleSet;
```
### Example 3: Banned import pattern
@@ -2287,26 +2333,29 @@ export default defineRules({
Prevent importing a specific library when native alternatives exist.
```typescript
-
-export default defineRules({
- "no-lodash": {
- description: "Do not use lodash -- use native array methods instead",
- async check(ctx) {
- const matches = await ctx.grepFiles(
- /import\s+.*from\s+['"]lodash/,
- "src/**/*.ts"
- );
- for (const match of matches) {
- ctx.report.violation({
- message: "Do not import lodash. Use native array methods instead.",
- file: match.file,
- line: match.line,
- fix: "Replace lodash usage with native Array.prototype methods",
- });
- }
+///
+
+export default {
+ rules: {
+ "no-lodash": {
+ description: "Do not use lodash -- use native array methods instead",
+ async check(ctx) {
+ const matches = await ctx.grepFiles(
+ /import\s+.*from\s+['"]lodash/,
+ "src/**/*.ts"
+ );
+ for (const match of matches) {
+ ctx.report.violation({
+ message: "Do not import lodash. Use native array methods instead.",
+ file: match.file,
+ line: match.line,
+ fix: "Replace lodash usage with native Array.prototype methods",
+ });
+ }
+ },
},
},
-});
+} satisfies RuleSet;
```
### Example 4: File naming convention
@@ -2314,24 +2363,27 @@ export default defineRules({
Enforce kebab-case naming for source files.
```typescript
-
-export default defineRules({
- "kebab-case-files": {
- description: "Source files must use kebab-case naming",
- async check(ctx) {
- for (const file of ctx.scopedFiles) {
- const name = basename(file).replace(/\.(ts|tsx|js|jsx)$/, "");
- if (name !== name.toLowerCase() || name.includes("_")) {
- ctx.report.violation({
- message: `File name "${basename(file)}" must be kebab-case (lowercase with hyphens)`,
- file,
- fix: `Rename to ${name.toLowerCase().replace(/_/g, "-")}`,
- });
+///
+
+export default {
+ rules: {
+ "kebab-case-files": {
+ description: "Source files must use kebab-case naming",
+ async check(ctx) {
+ for (const file of ctx.scopedFiles) {
+ const name = basename(file).replace(/\.(ts|tsx|js|jsx)$/, "");
+ if (name !== name.toLowerCase() || name.includes("_")) {
+ ctx.report.violation({
+ message: `File name "${basename(file)}" must be kebab-case (lowercase with hyphens)`,
+ file,
+ fix: `Rename to ${name.toLowerCase().replace(/_/g, "-")}`,
+ });
+ }
}
- }
+ },
},
},
-});
+} satisfies RuleSet;
```
### Example 5: Maximum file length
@@ -2339,28 +2391,31 @@ export default defineRules({
Warn when files exceed a line count threshold.
```typescript
+///
const MAX_LINES = 300;
-export default defineRules({
- "max-file-length": {
- description: `Source files should not exceed ${MAX_LINES} lines`,
- async check(ctx) {
- const checks = ctx.scopedFiles.map(async (file) => {
- const content = await ctx.readFile(file);
- const lineCount = content.split("\n").length;
- if (lineCount > MAX_LINES) {
- ctx.report.warning({
- message: `File has ${lineCount} lines (max: ${MAX_LINES}). Consider splitting it.`,
- file,
- fix: "Extract related functions into separate modules",
- });
- }
- });
- await Promise.all(checks);
+export default {
+ rules: {
+ "max-file-length": {
+ description: `Source files should not exceed ${MAX_LINES} lines`,
+ async check(ctx) {
+ const checks = ctx.scopedFiles.map(async (file) => {
+ const content = await ctx.readFile(file);
+ const lineCount = content.split("\n").length;
+ if (lineCount > MAX_LINES) {
+ ctx.report.warning({
+ message: `File has ${lineCount} lines (max: ${MAX_LINES}). Consider splitting it.`,
+ file,
+ fix: "Extract related functions into separate modules",
+ });
+ }
+ });
+ await Promise.all(checks);
+ },
},
},
-});
+} satisfies RuleSet;
```
### Example 6: Required test coverage
@@ -2368,29 +2423,32 @@ export default defineRules({
Verify that every source file has a corresponding test file.
```typescript
-
-export default defineRules({
- "test-file-exists": {
- description: "Every source module must have a corresponding test file",
- async check(ctx) {
- const testFiles = await ctx.glob("tests/**/*.test.ts");
- const testBaseNames = new Set(
- testFiles.map((f) => basename(f).replace(".test.ts", ""))
- );
-
- for (const file of ctx.scopedFiles) {
- const name = basename(file).replace(/\.ts$/, "");
- if (!testBaseNames.has(name)) {
- ctx.report.warning({
- message: `No test file found for ${basename(file)}`,
- file,
- fix: `Create tests/${name}.test.ts`,
- });
+///
+
+export default {
+ rules: {
+ "test-file-exists": {
+ description: "Every source module must have a corresponding test file",
+ async check(ctx) {
+ const testFiles = await ctx.glob("tests/**/*.test.ts");
+ const testBaseNames = new Set(
+ testFiles.map((f) => basename(f).replace(".test.ts", ""))
+ );
+
+ for (const file of ctx.scopedFiles) {
+ const name = basename(file).replace(/\.ts$/, "");
+ if (!testBaseNames.has(name)) {
+ ctx.report.warning({
+ message: `No test file found for ${basename(file)}`,
+ file,
+ fix: `Create tests/${name}.test.ts`,
+ });
+ }
}
- }
+ },
},
},
-});
+} satisfies RuleSet;
```
## Severity levels
@@ -2406,16 +2464,18 @@ Each rule can set a default severity in its configuration. The severity determin
Set the severity in the rule definition:
```typescript
-export default defineRules({
- "my-rule": {
- description: "...",
- severity: "warning",
- async check(ctx) {
- // Violations from this rule are warnings, not errors
- ctx.report.violation({ message: "..." });
+export default {
+ rules: {
+ "my-rule": {
+ description: "...",
+ severity: "warning",
+ async check(ctx) {
+ // Violations from this rule are warnings, not errors
+ ctx.report.violation({ message: "..." });
+ },
},
},
-});
+} satisfies RuleSet;
```
If `severity` is omitted, it defaults to `error`.
@@ -2715,28 +2775,31 @@ ARCH-002-error-handling.md # rules: true in frontmatter
ARCH-002-error-handling.rules.ts # companion rules file
```
-The rules file must export a default `RuleSet` created via `defineRules()`:
+The rules file must export a default `RuleSet` using a plain object with `satisfies RuleSet`:
```typescript
-
-export default defineRules({
- "no-console-error": {
- description: "Use logError() instead of console.error()",
- async check(ctx) {
- for (const file of ctx.scopedFiles) {
- const matches = await ctx.grep(file, /console\.error\(/);
- for (const match of matches) {
- ctx.report.violation({
- message: "Use logError() instead of console.error()",
- file: match.file,
- line: match.line,
- fix: "Import logError from src/helpers/log and use it instead",
- });
+///
+
+export default {
+ rules: {
+ "no-console-error": {
+ description: "Use logError() instead of console.error()",
+ async check(ctx) {
+ for (const file of ctx.scopedFiles) {
+ const matches = await ctx.grep(file, /console\.error\(/);
+ for (const match of matches) {
+ ctx.report.violation({
+ message: "Use logError() instead of console.error()",
+ file: match.file,
+ line: match.line,
+ fix: "Import logError from src/helpers/log and use it instead",
+ });
+ }
}
- }
+ },
},
},
-});
+} satisfies RuleSet;
```
See the [Rule API](/reference/rule-api/) for the complete TypeScript API reference.
@@ -3416,27 +3479,30 @@ archgate clean
Source: https://cli.archgate.dev/reference/rule-api/
-Archgate rules are TypeScript files that export automated checks via `defineRules()`. Each rule receives a `RuleContext` with utilities for searching files, reading content, and reporting violations.
+Archgate rules are TypeScript files that export a plain object typed with `satisfies RuleSet`. Each rule receives a `RuleContext` with utilities for searching files, reading content, and reporting violations.
-## defineRules
+## RuleSet
```typescript
-
-export default defineRules({
- "my-rule-id": {
- description: "Human-readable description of what this rule checks",
- severity: "error", // optional, defaults to "error"
- async check(ctx) {
- // Rule logic here
+///
+
+export default {
+ rules: {
+ "my-rule-id": {
+ description: "Human-readable description of what this rule checks",
+ severity: "error", // optional, defaults to "error"
+ async check(ctx) {
+ // Rule logic here
+ },
},
},
-});
+} satisfies RuleSet;
```
-The `defineRules` function takes a record of rule configurations keyed by rule ID. Keys become the rule IDs that appear in check output and violation reports. The function returns a `RuleSet` object.
+A rules file default-exports a plain object with a `rules` record keyed by rule ID. Keys become the rule IDs that appear in check output and violation reports. The `satisfies RuleSet` annotation provides type checking without wrapping in a function call.
```typescript
-function defineRules(rules: Record): RuleSet;
+type RuleSet = { rules: Record };
```
---
@@ -3691,7 +3757,7 @@ interface ViolationDetail {
| Field | Type | Description |
| ---------- | ---------- | ------------------------------------ |
-| `ruleId` | `string` | Rule ID from the `defineRules` key |
+| `ruleId` | `string` | Rule ID from the `rules` object key |
| `adrId` | `string` | ADR ID from the frontmatter |
| `message` | `string` | Human-readable description |
| `file` | `string?` | File path where the issue was found |
@@ -3703,7 +3769,7 @@ interface ViolationDetail {
## RuleSet
-The return type of `defineRules`. You do not construct this directly.
+The type used with `satisfies` to type-check your rules object. Export a plain object that conforms to this shape.
```typescript
type RuleSet = { rules: Record };
@@ -3722,6 +3788,7 @@ This page provides complete, copy-pasteable rule examples for common governance
**When to use:** Restrict production dependencies to a curated list to prevent dependency bloat and supply-chain risk.
```typescript
+///
const APPROVED_DEPS = [
"@commander-js/extra-typings",
@@ -3730,30 +3797,32 @@ const APPROVED_DEPS = [
"zod",
];
-export default defineRules({
- "no-unapproved-deps": {
- description: "Production dependencies must be on the approved list",
- async check(ctx) {
- let pkg: { dependencies?: Record };
- try {
- pkg = (await ctx.readJSON("package.json")) as typeof pkg;
- } catch {
- return; // No package.json — nothing to check
- }
-
- const deps = Object.keys(pkg.dependencies ?? {});
- for (const dep of deps) {
- if (!APPROVED_DEPS.includes(dep)) {
- ctx.report.violation({
- message: `Unapproved production dependency: "${dep}". Approved: ${APPROVED_DEPS.join(", ")}`,
- file: "package.json",
- fix: `Either add "${dep}" to the approved list in the ADR or move it to devDependencies`,
- });
+export default {
+ rules: {
+ "no-unapproved-deps": {
+ description: "Production dependencies must be on the approved list",
+ async check(ctx) {
+ let pkg: { dependencies?: Record };
+ try {
+ pkg = (await ctx.readJSON("package.json")) as typeof pkg;
+ } catch {
+ return; // No package.json — nothing to check
}
- }
+
+ const deps = Object.keys(pkg.dependencies ?? {});
+ for (const dep of deps) {
+ if (!APPROVED_DEPS.includes(dep)) {
+ ctx.report.violation({
+ message: `Unapproved production dependency: "${dep}". Approved: ${APPROVED_DEPS.join(", ")}`,
+ file: "package.json",
+ fix: `Either add "${dep}" to the approved list in the ADR or move it to devDependencies`,
+ });
+ }
+ }
+ },
},
},
-});
+} satisfies RuleSet;
```
**How it works:** Reads `package.json`, iterates over production `dependencies`, and reports a violation for any package not in the `APPROVED_DEPS` array. Dependencies in `devDependencies` are not checked. The fix message guides the developer toward either getting the dependency approved or reclassifying it.
@@ -3765,25 +3834,28 @@ export default defineRules({
**When to use:** Ensure files in a specific directory export a required function signature, such as the `register*Command` pattern for CLI command files.
```typescript
-
-export default defineRules({
- "register-function-export": {
- description: "Command files must export a register*Command function",
- async check(ctx) {
- const files = ctx.scopedFiles.filter((f) => !f.endsWith("index.ts"));
- const checks = files.map(async (file) => {
- const content = await ctx.readFile(file);
- if (!/export\s+function\s+register\w+Command/.test(content)) {
- ctx.report.violation({
- message: "Command file must export a register*Command function",
- file,
- });
- }
- });
- await Promise.all(checks);
+///
+
+export default {
+ rules: {
+ "register-function-export": {
+ description: "Command files must export a register*Command function",
+ async check(ctx) {
+ const files = ctx.scopedFiles.filter((f) => !f.endsWith("index.ts"));
+ const checks = files.map(async (file) => {
+ const content = await ctx.readFile(file);
+ if (!/export\s+function\s+register\w+Command/.test(content)) {
+ ctx.report.violation({
+ message: "Command file must export a register*Command function",
+ file,
+ });
+ }
+ });
+ await Promise.all(checks);
+ },
},
},
-});
+} satisfies RuleSet;
```
**How it works:** Filters out `index.ts` barrel files, then checks each scoped file for an exported function matching the `register*Command` naming pattern. The regex looks for `export function register` followed by any word characters and `Command`. Files that do not match get a violation.
@@ -3801,6 +3873,7 @@ if (!/export\s+default\s+function\s+\w+/.test(content)) {
**When to use:** Prevent usage of a specific library or module across the codebase. Common use cases include banning heavy libraries like `lodash` or `moment` in favor of native alternatives, or preventing imports from internal modules that are being deprecated.
```typescript
+///
const BANNED_IMPORTS = [
{
@@ -3820,24 +3893,26 @@ const BANNED_IMPORTS = [
},
];
-export default defineRules({
- "no-banned-imports": {
- description: "Prevent usage of banned libraries",
- async check(ctx) {
- for (const banned of BANNED_IMPORTS) {
- const matches = await ctx.grepFiles(banned.pattern, "src/**/*.ts");
- for (const match of matches) {
- ctx.report.violation({
- message: `Banned import: "${banned.name}" is not allowed. Use ${banned.alternative} instead.`,
- file: match.file,
- line: match.line,
- fix: `Replace ${banned.name} with ${banned.alternative}`,
- });
+export default {
+ rules: {
+ "no-banned-imports": {
+ description: "Prevent usage of banned libraries",
+ async check(ctx) {
+ for (const banned of BANNED_IMPORTS) {
+ const matches = await ctx.grepFiles(banned.pattern, "src/**/*.ts");
+ for (const match of matches) {
+ ctx.report.violation({
+ message: `Banned import: "${banned.name}" is not allowed. Use ${banned.alternative} instead.`,
+ file: match.file,
+ line: match.line,
+ fix: `Replace ${banned.name} with ${banned.alternative}`,
+ });
+ }
}
- }
+ },
},
},
-});
+} satisfies RuleSet;
```
**How it works:** Defines a list of banned imports with the regex pattern to detect them, the library name for the error message, and the recommended alternative. Uses `ctx.grepFiles` to scan all TypeScript files for each banned pattern and reports a violation for every match.
@@ -3851,30 +3926,33 @@ To add more banned imports, add entries to the `BANNED_IMPORTS` array. The patte
**When to use:** Enforce consistent file naming across a directory, such as requiring kebab-case for all source files.
```typescript
+///
const KEBAB_CASE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*\.(ts|tsx|js|jsx)$/;
-export default defineRules({
- "kebab-case-filenames": {
- description: "Source files must use kebab-case naming",
- async check(ctx) {
- for (const file of ctx.scopedFiles) {
- const name = basename(file);
-
- // Skip test files and type declaration files
- if (name.endsWith(".test.ts") || name.endsWith(".d.ts")) continue;
-
- if (!KEBAB_CASE.test(name)) {
- ctx.report.violation({
- message: `File "${name}" does not follow kebab-case naming convention`,
- file,
- fix: `Rename to ${name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase()}`,
- });
+export default {
+ rules: {
+ "kebab-case-filenames": {
+ description: "Source files must use kebab-case naming",
+ async check(ctx) {
+ for (const file of ctx.scopedFiles) {
+ const name = basename(file);
+
+ // Skip test files and type declaration files
+ if (name.endsWith(".test.ts") || name.endsWith(".d.ts")) continue;
+
+ if (!KEBAB_CASE.test(name)) {
+ ctx.report.violation({
+ message: `File "${name}" does not follow kebab-case naming convention`,
+ file,
+ fix: `Rename to ${name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase()}`,
+ });
+ }
}
- }
+ },
},
},
-});
+} satisfies RuleSet;
```
**How it works:** Extracts the basename of each scoped file and tests it against a kebab-case regex. The regex requires lowercase letters and digits separated by hyphens, with a valid file extension. Test files and type declarations are excluded. The fix suggestion auto-converts camelCase to kebab-case.
@@ -3892,26 +3970,29 @@ const CAMEL_CASE = /^[a-z][a-zA-Z0-9]*\.(ts|tsx|js|jsx)$/;
**When to use:** Flag TODO, FIXME, HACK, and XXX comments so they are resolved before merging. Uses `warning` severity so it does not block CI, but makes the comments visible in check output.
```typescript
-
-export default defineRules({
- "no-todo-comments": {
- description: "TODO and FIXME comments should be resolved before merging",
- severity: "warning",
- async check(ctx) {
- const matches = await ctx.grepFiles(
- /\/\/\s*(TODO|FIXME|HACK|XXX):/i,
- "src/**/*.ts"
- );
- for (const match of matches) {
- ctx.report.warning({
- message: `${match.content.trim()} -- resolve before merging`,
- file: match.file,
- line: match.line,
- });
- }
+///
+
+export default {
+ rules: {
+ "no-todo-comments": {
+ description: "TODO and FIXME comments should be resolved before merging",
+ severity: "warning",
+ async check(ctx) {
+ const matches = await ctx.grepFiles(
+ /\/\/\s*(TODO|FIXME|HACK|XXX):/i,
+ "src/**/*.ts"
+ );
+ for (const match of matches) {
+ ctx.report.warning({
+ message: `${match.content.trim()} -- resolve before merging`,
+ file: match.file,
+ line: match.line,
+ });
+ }
+ },
},
},
-});
+} satisfies RuleSet;
```
**How it works:** Uses `ctx.grepFiles` to scan all TypeScript files in `src/` for comments starting with `TODO:`, `FIXME:`, `HACK:`, or `XXX:` (case-insensitive). Each match is reported as a warning with the original comment text. Because the severity is `"warning"`, the check exits with code 0 even when matches are found -- it surfaces the comments without blocking merges.
@@ -3925,29 +4006,32 @@ To make this a hard blocker, change the severity to `"error"` and use `ctx.repor
**When to use:** Ensure every source file has a corresponding test file, preventing untested code from being merged.
```typescript
-
-export default defineRules({
- "test-file-exists": {
- description: "Every source file should have a corresponding test file",
- severity: "warning",
- async check(ctx) {
- for (const file of ctx.scopedFiles) {
- const rel = relative(ctx.projectRoot, file);
- const testPath = rel
- .replace(/^src\//, "tests/")
- .replace(/\.ts$/, ".test.ts");
- const testFiles = await ctx.glob(testPath);
- if (testFiles.length === 0) {
- ctx.report.warning({
- message: `No test file found at ${testPath}`,
- file,
- fix: `Create a test file at ${testPath}`,
- });
+///
+
+export default {
+ rules: {
+ "test-file-exists": {
+ description: "Every source file should have a corresponding test file",
+ severity: "warning",
+ async check(ctx) {
+ for (const file of ctx.scopedFiles) {
+ const rel = relative(ctx.projectRoot, file);
+ const testPath = rel
+ .replace(/^src\//, "tests/")
+ .replace(/\.ts$/, ".test.ts");
+ const testFiles = await ctx.glob(testPath);
+ if (testFiles.length === 0) {
+ ctx.report.warning({
+ message: `No test file found at ${testPath}`,
+ file,
+ fix: `Create a test file at ${testPath}`,
+ });
+ }
}
- }
+ },
},
},
-});
+} satisfies RuleSet;
```
**How it works:** For each scoped source file, converts the path from `src/` to `tests/` and appends `.test.ts`. Then uses `ctx.glob` to check if the test file exists. If not, reports a warning with a fix suggesting the expected test file path.
diff --git a/docs/public/llms.txt b/docs/public/llms.txt
index 82949c97..841d21c4 100644
--- a/docs/public/llms.txt
+++ b/docs/public/llms.txt
@@ -24,7 +24,7 @@ Install standalone (no Node.js required): `curl -fsSL https://raw.githubusercont
- [Core Concepts — Rules](https://cli.archgate.dev/concepts/rules/): The TypeScript rule system that turns ADR decisions into automated compliance checks.
- [Core Concepts — Domains](https://cli.archgate.dev/concepts/domains/): Organize ADRs by domain for targeted governance.
- [Guide — Writing ADRs](https://cli.archgate.dev/guides/writing-adrs/): Complete guide to writing effective ADRs with YAML frontmatter and markdown structure.
-- [Guide — Writing Rules](https://cli.archgate.dev/guides/writing-rules/): Write TypeScript rules using the defineRules API with file matching and violation reporting.
+- [Guide — Writing Rules](https://cli.archgate.dev/guides/writing-rules/): Write TypeScript rules using the satisfies RuleSet pattern with file matching and violation reporting.
- [Guide — CI Integration](https://cli.archgate.dev/guides/ci-integration/): Add Archgate checks to GitHub Actions, GitLab CI, or any pipeline.
- [Guide — Claude Code Plugin](https://cli.archgate.dev/guides/claude-code-plugin/): Give AI agents a governance workflow that reads ADRs, validates code, and captures patterns.
- [Guide — VS Code Plugin](https://cli.archgate.dev/guides/vscode-plugin/): Real-time ADR compliance in VS Code.
@@ -34,7 +34,7 @@ Install standalone (no Node.js required): `curl -fsSL https://raw.githubusercont
- [Guide — Pre-commit Hooks](https://cli.archgate.dev/guides/pre-commit-hooks/): Automatically check ADR compliance before every commit.
- [Reference — CLI Commands](https://cli.archgate.dev/reference/cli-commands/): Complete reference for init, check, adr create/list/show, login, and more.
- [Reference — MCP Tools](https://cli.archgate.dev/reference/mcp-tools/): API reference for adr_list, adr_read, check_compliance, and review_context.
-- [Reference — Rule API](https://cli.archgate.dev/reference/rule-api/): TypeScript API reference for defineRules, RuleContext, and violation reporting.
+- [Reference — Rule API](https://cli.archgate.dev/reference/rule-api/): TypeScript API reference for RuleSet with satisfies, RuleContext, and violation reporting.
- [Reference — ADR Schema](https://cli.archgate.dev/reference/adr-schema/): YAML frontmatter schema and markdown structure reference for ADRs.
- [Examples — Common Rule Patterns](https://cli.archgate.dev/examples/common-rule-patterns/): Ready-to-use rule patterns for naming conventions, import restrictions, and more.
diff --git a/docs/src/components/CodeShowcase.astro b/docs/src/components/CodeShowcase.astro
index 7959966a..d1f3a4fa 100644
--- a/docs/src/components/CodeShowcase.astro
+++ b/docs/src/components/CodeShowcase.astro
@@ -34,29 +34,29 @@ Direct export default is prohibited.
DO: Use createRoute({ handler })
DON'T: Use export default function`;
-const rulesCode = `import { defineRules } from "archgate/rules";
+const rulesCode = `export default {
+ rules: {
+ "require-createRoute": {
+ description: "Use createRoute() factory",
+ severity: "error",
+ async check(ctx) {
+ const files = await ctx.glob("src/api/**/*.ts");
-export default defineRules((ctx) => [
- {
- name: "require-createRoute",
- severity: "error",
- async run() {
- const files = await ctx.glob("src/api/**/*.ts");
-
- for (const file of files) {
- const hits = await ctx.grep(
- file, /export\\s+default\\s+function/
- );
- for (const hit of hits) {
- ctx.report({
- file, line: hit.line,
- message: "Use createRoute() factory",
- });
+ for (const file of files) {
+ const hits = await ctx.grep(
+ file, /export\\s+default\\s+function/
+ );
+ for (const hit of hits) {
+ ctx.report.violation({
+ file, line: hit.line,
+ message: "Use createRoute() factory",
+ });
+ }
}
- }
+ },
},
},
-]);`;
+} satisfies RuleSet;`;
---
diff --git a/docs/src/content/docs/concepts/adrs.mdx b/docs/src/content/docs/concepts/adrs.mdx
index e66c417b..3ccac075 100644
--- a/docs/src/content/docs/concepts/adrs.mdx
+++ b/docs/src/content/docs/concepts/adrs.mdx
@@ -21,7 +21,7 @@ With the [Claude Code](/guides/claude-code-plugin/) or [Cursor](/guides/cursor-i
### ADR as Rules
-The rules file is a companion `.rules.ts` file that exports automated checks via `defineRules()`. When you run `archgate check`, the CLI loads every ADR that has `rules: true` in its frontmatter, executes the companion rules file against your codebase, and reports any violations with file paths and line numbers.
+The rules file is a companion `.rules.ts` file that exports a plain object typed with `satisfies RuleSet`. When you run `archgate check`, the CLI loads every ADR that has `rules: true` in its frontmatter, executes the companion rules file against your codebase, and reports any violations with file paths and line numbers.
Not every ADR needs rules. Some decisions are best enforced through code review alone. Set `rules: false` when no automated check is practical.
diff --git a/docs/src/content/docs/concepts/rules.mdx b/docs/src/content/docs/concepts/rules.mdx
index 212e756b..576cc52b 100644
--- a/docs/src/content/docs/concepts/rules.mdx
+++ b/docs/src/content/docs/concepts/rules.mdx
@@ -3,27 +3,29 @@ title: Rules
description: Understand Archgate's TypeScript rule system that turns Architecture Decision Records into automated compliance checks with file-level violation reporting.
---
-Rules are the executable side of an ADR. They live in companion `.rules.ts` files alongside the ADR document and export automated checks via the `defineRules()` function. When you run `archgate check`, the CLI loads each ADR that has `rules: true`, imports its companion rules file, and executes every check against your codebase.
+Rules are the executable side of an ADR. They live in companion `.rules.ts` files alongside the ADR document and export a plain object typed with `satisfies RuleSet`. When you run `archgate check`, the CLI loads each ADR that has `rules: true`, imports its companion rules file, and executes every check against your codebase.
## Defining Rules
-A rules file is a TypeScript module that default-exports the result of `defineRules()`. Import the function from the `archgate/rules` package:
+A rules file is a TypeScript module that default-exports a plain object conforming to the `RuleSet` type. The type is provided by the local shim auto-generated by `archgate init` (no npm install needed):
```typescript
-import { defineRules } from "archgate/rules";
-
-export default defineRules({
- "rule-key": {
- description: "What this rule checks",
- severity: "error",
- async check(ctx) {
- // Inspect files and report violations
+///
+
+export default {
+ rules: {
+ "rule-key": {
+ description: "What this rule checks",
+ severity: "error",
+ async check(ctx) {
+ // Inspect files and report violations
+ },
},
},
-});
+} satisfies RuleSet;
```
-Each key in the object passed to `defineRules()` becomes the rule ID. The full rule identifier shown in check output combines the ADR ID and the rule key, for example `ARCH-004/no-barrel-files`.
+Each key in the `rules` object becomes the rule ID. The full rule identifier shown in check output combines the ADR ID and the rule key, for example `ARCH-004/no-barrel-files`.
## Rule Structure
@@ -115,33 +117,35 @@ Each rule has a 30-second execution timeout. If a rule's `check` function does n
Here is a complete rules file that checks for a banned import pattern. It enforces that no source file imports directly from `node:fs` (the project requires using a wrapper instead).
```typescript
-import { defineRules } from "archgate/rules";
-
-export default defineRules({
- "no-direct-fs-import": {
- description:
- "Source files must not import directly from node:fs; use the fs wrapper",
- severity: "error",
- async check(ctx) {
- const sourceFiles = ctx.scopedFiles.filter(
- (f) => f.endsWith(".ts") && !f.endsWith(".test.ts")
- );
-
- for (const file of sourceFiles) {
- const matches = await ctx.grep(file, /from ["']node:fs["']/);
-
- for (const match of matches) {
- ctx.report.violation({
- message: `Direct import from "node:fs" is not allowed. Use the fs wrapper from "src/helpers/fs" instead.`,
- file: match.file,
- line: match.line,
- fix: 'Replace the import with: import { readFile, writeFile } from "../helpers/fs"',
- });
+///
+
+export default {
+ rules: {
+ "no-direct-fs-import": {
+ description:
+ "Source files must not import directly from node:fs; use the fs wrapper",
+ severity: "error",
+ async check(ctx) {
+ const sourceFiles = ctx.scopedFiles.filter(
+ (f) => f.endsWith(".ts") && !f.endsWith(".test.ts")
+ );
+
+ for (const file of sourceFiles) {
+ const matches = await ctx.grep(file, /from ["']node:fs["']/);
+
+ for (const match of matches) {
+ ctx.report.violation({
+ message: `Direct import from "node:fs" is not allowed. Use the fs wrapper from "src/helpers/fs" instead.`,
+ file: match.file,
+ line: match.line,
+ fix: 'Replace the import with: import { readFile, writeFile } from "../helpers/fs"',
+ });
+ }
}
- }
+ },
},
},
-});
+} satisfies RuleSet;
```
When this rule runs against a file containing `import { readFileSync } from "node:fs"`, the output looks like:
diff --git a/docs/src/content/docs/examples/common-rule-patterns.mdx b/docs/src/content/docs/examples/common-rule-patterns.mdx
index 806da03b..f38fd590 100644
--- a/docs/src/content/docs/examples/common-rule-patterns.mdx
+++ b/docs/src/content/docs/examples/common-rule-patterns.mdx
@@ -10,7 +10,7 @@ This page provides complete, copy-pasteable rule examples for common governance
**When to use:** Restrict production dependencies to a curated list to prevent dependency bloat and supply-chain risk.
```typescript
-import { defineRules } from "archgate/rules";
+///
const APPROVED_DEPS = [
"@commander-js/extra-typings",
@@ -19,30 +19,32 @@ const APPROVED_DEPS = [
"zod",
];
-export default defineRules({
- "no-unapproved-deps": {
- description: "Production dependencies must be on the approved list",
- async check(ctx) {
- let pkg: { dependencies?: Record };
- try {
- pkg = (await ctx.readJSON("package.json")) as typeof pkg;
- } catch {
- return; // No package.json — nothing to check
- }
-
- const deps = Object.keys(pkg.dependencies ?? {});
- for (const dep of deps) {
- if (!APPROVED_DEPS.includes(dep)) {
- ctx.report.violation({
- message: `Unapproved production dependency: "${dep}". Approved: ${APPROVED_DEPS.join(", ")}`,
- file: "package.json",
- fix: `Either add "${dep}" to the approved list in the ADR or move it to devDependencies`,
- });
+export default {
+ rules: {
+ "no-unapproved-deps": {
+ description: "Production dependencies must be on the approved list",
+ async check(ctx) {
+ let pkg: { dependencies?: Record };
+ try {
+ pkg = (await ctx.readJSON("package.json")) as typeof pkg;
+ } catch {
+ return; // No package.json — nothing to check
+ }
+
+ const deps = Object.keys(pkg.dependencies ?? {});
+ for (const dep of deps) {
+ if (!APPROVED_DEPS.includes(dep)) {
+ ctx.report.violation({
+ message: `Unapproved production dependency: "${dep}". Approved: ${APPROVED_DEPS.join(", ")}`,
+ file: "package.json",
+ fix: `Either add "${dep}" to the approved list in the ADR or move it to devDependencies`,
+ });
+ }
}
- }
+ },
},
},
-});
+} satisfies RuleSet;
```
**How it works:** Reads `package.json`, iterates over production `dependencies`, and reports a violation for any package not in the `APPROVED_DEPS` array. Dependencies in `devDependencies` are not checked. The fix message guides the developer toward either getting the dependency approved or reclassifying it.
@@ -54,26 +56,28 @@ export default defineRules({
**When to use:** Ensure files in a specific directory export a required function signature, such as the `register*Command` pattern for CLI command files.
```typescript
-import { defineRules } from "archgate/rules";
-
-export default defineRules({
- "register-function-export": {
- description: "Command files must export a register*Command function",
- async check(ctx) {
- const files = ctx.scopedFiles.filter((f) => !f.endsWith("index.ts"));
- const checks = files.map(async (file) => {
- const content = await ctx.readFile(file);
- if (!/export\s+function\s+register\w+Command/.test(content)) {
- ctx.report.violation({
- message: "Command file must export a register*Command function",
- file,
- });
- }
- });
- await Promise.all(checks);
+///
+
+export default {
+ rules: {
+ "register-function-export": {
+ description: "Command files must export a register*Command function",
+ async check(ctx) {
+ const files = ctx.scopedFiles.filter((f) => !f.endsWith("index.ts"));
+ const checks = files.map(async (file) => {
+ const content = await ctx.readFile(file);
+ if (!/export\s+function\s+register\w+Command/.test(content)) {
+ ctx.report.violation({
+ message: "Command file must export a register*Command function",
+ file,
+ });
+ }
+ });
+ await Promise.all(checks);
+ },
},
},
-});
+} satisfies RuleSet;
```
**How it works:** Filters out `index.ts` barrel files, then checks each scoped file for an exported function matching the `register*Command` naming pattern. The regex looks for `export function register` followed by any word characters and `Command`. Files that do not match get a violation.
@@ -91,7 +95,7 @@ if (!/export\s+default\s+function\s+\w+/.test(content)) {
**When to use:** Prevent usage of a specific library or module across the codebase. Common use cases include banning heavy libraries like `lodash` or `moment` in favor of native alternatives, or preventing imports from internal modules that are being deprecated.
```typescript
-import { defineRules } from "archgate/rules";
+///
const BANNED_IMPORTS = [
{
@@ -111,24 +115,26 @@ const BANNED_IMPORTS = [
},
];
-export default defineRules({
- "no-banned-imports": {
- description: "Prevent usage of banned libraries",
- async check(ctx) {
- for (const banned of BANNED_IMPORTS) {
- const matches = await ctx.grepFiles(banned.pattern, "src/**/*.ts");
- for (const match of matches) {
- ctx.report.violation({
- message: `Banned import: "${banned.name}" is not allowed. Use ${banned.alternative} instead.`,
- file: match.file,
- line: match.line,
- fix: `Replace ${banned.name} with ${banned.alternative}`,
- });
+export default {
+ rules: {
+ "no-banned-imports": {
+ description: "Prevent usage of banned libraries",
+ async check(ctx) {
+ for (const banned of BANNED_IMPORTS) {
+ const matches = await ctx.grepFiles(banned.pattern, "src/**/*.ts");
+ for (const match of matches) {
+ ctx.report.violation({
+ message: `Banned import: "${banned.name}" is not allowed. Use ${banned.alternative} instead.`,
+ file: match.file,
+ line: match.line,
+ fix: `Replace ${banned.name} with ${banned.alternative}`,
+ });
+ }
}
- }
+ },
},
},
-});
+} satisfies RuleSet;
```
**How it works:** Defines a list of banned imports with the regex pattern to detect them, the library name for the error message, and the recommended alternative. Uses `ctx.grepFiles` to scan all TypeScript files for each banned pattern and reports a violation for every match.
@@ -142,32 +148,34 @@ To add more banned imports, add entries to the `BANNED_IMPORTS` array. The patte
**When to use:** Enforce consistent file naming across a directory, such as requiring kebab-case for all source files.
```typescript
-import { defineRules } from "archgate/rules";
+///
import { basename } from "node:path";
const KEBAB_CASE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*\.(ts|tsx|js|jsx)$/;
-export default defineRules({
- "kebab-case-filenames": {
- description: "Source files must use kebab-case naming",
- async check(ctx) {
- for (const file of ctx.scopedFiles) {
- const name = basename(file);
-
- // Skip test files and type declaration files
- if (name.endsWith(".test.ts") || name.endsWith(".d.ts")) continue;
-
- if (!KEBAB_CASE.test(name)) {
- ctx.report.violation({
- message: `File "${name}" does not follow kebab-case naming convention`,
- file,
- fix: `Rename to ${name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase()}`,
- });
+export default {
+ rules: {
+ "kebab-case-filenames": {
+ description: "Source files must use kebab-case naming",
+ async check(ctx) {
+ for (const file of ctx.scopedFiles) {
+ const name = basename(file);
+
+ // Skip test files and type declaration files
+ if (name.endsWith(".test.ts") || name.endsWith(".d.ts")) continue;
+
+ if (!KEBAB_CASE.test(name)) {
+ ctx.report.violation({
+ message: `File "${name}" does not follow kebab-case naming convention`,
+ file,
+ fix: `Rename to ${name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase()}`,
+ });
+ }
}
- }
+ },
},
},
-});
+} satisfies RuleSet;
```
**How it works:** Extracts the basename of each scoped file and tests it against a kebab-case regex. The regex requires lowercase letters and digits separated by hyphens, with a valid file extension. Test files and type declarations are excluded. The fix suggestion auto-converts camelCase to kebab-case.
@@ -185,27 +193,29 @@ const CAMEL_CASE = /^[a-z][a-zA-Z0-9]*\.(ts|tsx|js|jsx)$/;
**When to use:** Flag TODO, FIXME, HACK, and XXX comments so they are resolved before merging. Uses `warning` severity so it does not block CI, but makes the comments visible in check output.
```typescript
-import { defineRules } from "archgate/rules";
-
-export default defineRules({
- "no-todo-comments": {
- description: "TODO and FIXME comments should be resolved before merging",
- severity: "warning",
- async check(ctx) {
- const matches = await ctx.grepFiles(
- /\/\/\s*(TODO|FIXME|HACK|XXX):/i,
- "src/**/*.ts"
- );
- for (const match of matches) {
- ctx.report.warning({
- message: `${match.content.trim()} -- resolve before merging`,
- file: match.file,
- line: match.line,
- });
- }
+///
+
+export default {
+ rules: {
+ "no-todo-comments": {
+ description: "TODO and FIXME comments should be resolved before merging",
+ severity: "warning",
+ async check(ctx) {
+ const matches = await ctx.grepFiles(
+ /\/\/\s*(TODO|FIXME|HACK|XXX):/i,
+ "src/**/*.ts"
+ );
+ for (const match of matches) {
+ ctx.report.warning({
+ message: `${match.content.trim()} -- resolve before merging`,
+ file: match.file,
+ line: match.line,
+ });
+ }
+ },
},
},
-});
+} satisfies RuleSet;
```
**How it works:** Uses `ctx.grepFiles` to scan all TypeScript files in `src/` for comments starting with `TODO:`, `FIXME:`, `HACK:`, or `XXX:` (case-insensitive). Each match is reported as a warning with the original comment text. Because the severity is `"warning"`, the check exits with code 0 even when matches are found -- it surfaces the comments without blocking merges.
@@ -219,31 +229,33 @@ To make this a hard blocker, change the severity to `"error"` and use `ctx.repor
**When to use:** Ensure every source file has a corresponding test file, preventing untested code from being merged.
```typescript
-import { defineRules } from "archgate/rules";
+///
import { relative } from "node:path";
-export default defineRules({
- "test-file-exists": {
- description: "Every source file should have a corresponding test file",
- severity: "warning",
- async check(ctx) {
- for (const file of ctx.scopedFiles) {
- const rel = relative(ctx.projectRoot, file);
- const testPath = rel
- .replace(/^src\//, "tests/")
- .replace(/\.ts$/, ".test.ts");
- const testFiles = await ctx.glob(testPath);
- if (testFiles.length === 0) {
- ctx.report.warning({
- message: `No test file found at ${testPath}`,
- file,
- fix: `Create a test file at ${testPath}`,
- });
+export default {
+ rules: {
+ "test-file-exists": {
+ description: "Every source file should have a corresponding test file",
+ severity: "warning",
+ async check(ctx) {
+ for (const file of ctx.scopedFiles) {
+ const rel = relative(ctx.projectRoot, file);
+ const testPath = rel
+ .replace(/^src\//, "tests/")
+ .replace(/\.ts$/, ".test.ts");
+ const testFiles = await ctx.glob(testPath);
+ if (testFiles.length === 0) {
+ ctx.report.warning({
+ message: `No test file found at ${testPath}`,
+ file,
+ fix: `Create a test file at ${testPath}`,
+ });
+ }
}
- }
+ },
},
},
-});
+} satisfies RuleSet;
```
**How it works:** For each scoped source file, converts the path from `src/` to `tests/` and appends `.test.ts`. Then uses `ctx.glob` to check if the test file exists. If not, reports a warning with a fix suggesting the expected test file path.
diff --git a/docs/src/content/docs/getting-started/quick-start.mdx b/docs/src/content/docs/getting-started/quick-start.mdx
index 6cabb145..b9c44720 100644
--- a/docs/src/content/docs/getting-started/quick-start.mdx
+++ b/docs/src/content/docs/getting-started/quick-start.mdx
@@ -63,29 +63,31 @@ Below the frontmatter, write the decision in markdown. Archgate does not enforce
## 4. Add a companion rules file
-Create a `.rules.ts` file next to your ADR with the same name prefix. Rules are written in TypeScript using the `defineRules` function:
+Create a `.rules.ts` file next to your ADR with the same name prefix. Rules are written in TypeScript using the `RuleSet` type:
```typescript
-import { defineRules } from "archgate/rules";
-
-export default defineRules({
- "no-console-error": {
- description: "Use logError() instead of console.error()",
- async check(ctx) {
- for (const file of ctx.scopedFiles) {
- const matches = await ctx.grep(file, /console\.error\(/);
- for (const match of matches) {
- ctx.report.violation({
- message: "Use logError() instead of console.error()",
- file: match.file,
- line: match.line,
- fix: "Import logError from your helpers and use it instead",
- });
+///
+
+export default {
+ rules: {
+ "no-console-error": {
+ description: "Use logError() instead of console.error()",
+ async check(ctx) {
+ for (const file of ctx.scopedFiles) {
+ const matches = await ctx.grep(file, /console\.error\(/);
+ for (const match of matches) {
+ ctx.report.violation({
+ message: "Use logError() instead of console.error()",
+ file: match.file,
+ line: match.line,
+ fix: "Import logError from your helpers and use it instead",
+ });
+ }
}
- }
+ },
},
},
-});
+} satisfies RuleSet;
```
Each rule has a unique key, a description, and an async `check` function. Inside `check`, you have access to:
diff --git a/docs/src/content/docs/guides/writing-rules.mdx b/docs/src/content/docs/guides/writing-rules.mdx
index 911d924c..4fd7f79f 100644
--- a/docs/src/content/docs/guides/writing-rules.mdx
+++ b/docs/src/content/docs/guides/writing-rules.mdx
@@ -1,6 +1,6 @@
---
title: Writing Rules
-description: Write TypeScript rules that automatically enforce your Architecture Decision Records. Learn the Archgate defineRules API, file matching, and violation reporting.
+description: Write TypeScript rules that automatically enforce your Architecture Decision Records. Learn the Archgate rule API with satisfies RuleSet, file matching, and violation reporting.
---
Rules are TypeScript functions that check your codebase for ADR compliance. They live in companion `.rules.ts` files next to ADR markdown files and run when you execute `archgate check`.
@@ -13,40 +13,71 @@ Rules are TypeScript functions that check your codebase for ADR compliance. They
## Basic setup
-Every rules file exports a default `defineRules()` call. Each key becomes a rule ID, and each rule has a `description` and an async `check` function that receives a context object.
+Every rules file exports a default plain object typed with `satisfies RuleSet`. Each key in the `rules` object becomes a rule ID, and each rule has a `description` and an async `check` function that receives a context object.
```typescript
-import { defineRules } from "archgate/rules";
-
-export default defineRules({
- "my-rule-id": {
- description: "What this rule checks",
- async check(ctx) {
- // Your check logic here
+///
+
+export default {
+ rules: {
+ "my-rule-id": {
+ description: "What this rule checks",
+ async check(ctx) {
+ // Your check logic here
+ },
},
},
-});
+} satisfies RuleSet;
+```
+
+:::note
+The `/// ` directive may conflict with the `@typescript-eslint/triple-slash-reference` rule in ESLint or oxlint. If your project uses this rule, disable it for `.archgate/adrs/` files.
+
+For **ESLint** (flat config):
+
+```js
+{
+ files: [".archgate/adrs/*.rules.ts"],
+ rules: { "@typescript-eslint/triple-slash-reference": "off" },
+}
+```
+
+For **oxlint** (`.oxlintrc.json`):
+
+```json
+{
+ "overrides": [
+ {
+ "files": [".archgate/adrs/*.rules.ts"],
+ "rules": { "typescript/triple-slash-reference": "off" }
+ }
+ ]
+}
```
+:::
+
A single rules file can define multiple rules:
```typescript
-import { defineRules } from "archgate/rules";
-
-export default defineRules({
- "first-rule": {
- description: "Checks one thing",
- async check(ctx) {
- // ...
+///
+
+export default {
+ rules: {
+ "first-rule": {
+ description: "Checks one thing",
+ async check(ctx) {
+ // ...
+ },
},
- },
- "second-rule": {
- description: "Checks another thing",
- async check(ctx) {
- // ...
+ "second-rule": {
+ description: "Checks another thing",
+ async check(ctx) {
+ // ...
+ },
},
},
-});
+} satisfies RuleSet;
```
## The Context API
@@ -172,7 +203,7 @@ The absolute path to the project root directory. Useful when you need to constru
Check that all production dependencies are on an approved list. This is the rule Archgate uses for its own ARCH-006 dependency policy.
```typescript
-import { defineRules } from "archgate/rules";
+///
const APPROVED_DEPS = [
"@commander-js/extra-typings",
@@ -181,30 +212,32 @@ const APPROVED_DEPS = [
"zod",
];
-export default defineRules({
- "no-unapproved-deps": {
- description: "Production dependencies must be on the approved list",
- async check(ctx) {
- let pkg: { dependencies?: Record };
- try {
- pkg = (await ctx.readJSON("package.json")) as typeof pkg;
- } catch {
- return;
- }
-
- const deps = Object.keys(pkg.dependencies ?? {});
- for (const dep of deps) {
- if (!APPROVED_DEPS.includes(dep)) {
- ctx.report.violation({
- message: `Unapproved production dependency: "${dep}". Approved: ${APPROVED_DEPS.join(", ")}`,
- file: "package.json",
- fix: `Either add "${dep}" to the approved list in the ADR or move it to devDependencies`,
- });
+export default {
+ rules: {
+ "no-unapproved-deps": {
+ description: "Production dependencies must be on the approved list",
+ async check(ctx) {
+ let pkg: { dependencies?: Record };
+ try {
+ pkg = (await ctx.readJSON("package.json")) as typeof pkg;
+ } catch {
+ return;
}
- }
+
+ const deps = Object.keys(pkg.dependencies ?? {});
+ for (const dep of deps) {
+ if (!APPROVED_DEPS.includes(dep)) {
+ ctx.report.violation({
+ message: `Unapproved production dependency: "${dep}". Approved: ${APPROVED_DEPS.join(", ")}`,
+ file: "package.json",
+ fix: `Either add "${dep}" to the approved list in the ADR or move it to devDependencies`,
+ });
+ }
+ }
+ },
},
},
-});
+} satisfies RuleSet;
```
### Example 2: Required export pattern
@@ -212,26 +245,28 @@ export default defineRules({
Verify that every command file exports a `register*Command` function. This is the rule Archgate uses for its own ARCH-001 command structure.
```typescript
-import { defineRules } from "archgate/rules";
-
-export default defineRules({
- "register-function-export": {
- description: "Command files must export a register*Command function",
- async check(ctx) {
- const files = ctx.scopedFiles.filter((f) => !f.endsWith("index.ts"));
- const checks = files.map(async (file) => {
- const content = await ctx.readFile(file);
- if (!/export\s+function\s+register\w+Command/.test(content)) {
- ctx.report.violation({
- message: "Command file must export a register*Command function",
- file,
- });
- }
- });
- await Promise.all(checks);
+///
+
+export default {
+ rules: {
+ "register-function-export": {
+ description: "Command files must export a register*Command function",
+ async check(ctx) {
+ const files = ctx.scopedFiles.filter((f) => !f.endsWith("index.ts"));
+ const checks = files.map(async (file) => {
+ const content = await ctx.readFile(file);
+ if (!/export\s+function\s+register\w+Command/.test(content)) {
+ ctx.report.violation({
+ message: "Command file must export a register*Command function",
+ file,
+ });
+ }
+ });
+ await Promise.all(checks);
+ },
},
},
-});
+} satisfies RuleSet;
```
### Example 3: Banned import pattern
@@ -239,27 +274,29 @@ export default defineRules({
Prevent importing a specific library when native alternatives exist.
```typescript
-import { defineRules } from "archgate/rules";
-
-export default defineRules({
- "no-lodash": {
- description: "Do not use lodash -- use native array methods instead",
- async check(ctx) {
- const matches = await ctx.grepFiles(
- /import\s+.*from\s+['"]lodash/,
- "src/**/*.ts"
- );
- for (const match of matches) {
- ctx.report.violation({
- message: "Do not import lodash. Use native array methods instead.",
- file: match.file,
- line: match.line,
- fix: "Replace lodash usage with native Array.prototype methods",
- });
- }
+///
+
+export default {
+ rules: {
+ "no-lodash": {
+ description: "Do not use lodash -- use native array methods instead",
+ async check(ctx) {
+ const matches = await ctx.grepFiles(
+ /import\s+.*from\s+['"]lodash/,
+ "src/**/*.ts"
+ );
+ for (const match of matches) {
+ ctx.report.violation({
+ message: "Do not import lodash. Use native array methods instead.",
+ file: match.file,
+ line: match.line,
+ fix: "Replace lodash usage with native Array.prototype methods",
+ });
+ }
+ },
},
},
-});
+} satisfies RuleSet;
```
### Example 4: File naming convention
@@ -267,26 +304,28 @@ export default defineRules({
Enforce kebab-case naming for source files.
```typescript
-import { defineRules } from "archgate/rules";
+///
import { basename } from "node:path";
-export default defineRules({
- "kebab-case-files": {
- description: "Source files must use kebab-case naming",
- async check(ctx) {
- for (const file of ctx.scopedFiles) {
- const name = basename(file).replace(/\.(ts|tsx|js|jsx)$/, "");
- if (name !== name.toLowerCase() || name.includes("_")) {
- ctx.report.violation({
- message: `File name "${basename(file)}" must be kebab-case (lowercase with hyphens)`,
- file,
- fix: `Rename to ${name.toLowerCase().replace(/_/g, "-")}`,
- });
+export default {
+ rules: {
+ "kebab-case-files": {
+ description: "Source files must use kebab-case naming",
+ async check(ctx) {
+ for (const file of ctx.scopedFiles) {
+ const name = basename(file).replace(/\.(ts|tsx|js|jsx)$/, "");
+ if (name !== name.toLowerCase() || name.includes("_")) {
+ ctx.report.violation({
+ message: `File name "${basename(file)}" must be kebab-case (lowercase with hyphens)`,
+ file,
+ fix: `Rename to ${name.toLowerCase().replace(/_/g, "-")}`,
+ });
+ }
}
- }
+ },
},
},
-});
+} satisfies RuleSet;
```
### Example 5: Maximum file length
@@ -294,29 +333,31 @@ export default defineRules({
Warn when files exceed a line count threshold.
```typescript
-import { defineRules } from "archgate/rules";
+///
const MAX_LINES = 300;
-export default defineRules({
- "max-file-length": {
- description: `Source files should not exceed ${MAX_LINES} lines`,
- async check(ctx) {
- const checks = ctx.scopedFiles.map(async (file) => {
- const content = await ctx.readFile(file);
- const lineCount = content.split("\n").length;
- if (lineCount > MAX_LINES) {
- ctx.report.warning({
- message: `File has ${lineCount} lines (max: ${MAX_LINES}). Consider splitting it.`,
- file,
- fix: "Extract related functions into separate modules",
- });
- }
- });
- await Promise.all(checks);
+export default {
+ rules: {
+ "max-file-length": {
+ description: `Source files should not exceed ${MAX_LINES} lines`,
+ async check(ctx) {
+ const checks = ctx.scopedFiles.map(async (file) => {
+ const content = await ctx.readFile(file);
+ const lineCount = content.split("\n").length;
+ if (lineCount > MAX_LINES) {
+ ctx.report.warning({
+ message: `File has ${lineCount} lines (max: ${MAX_LINES}). Consider splitting it.`,
+ file,
+ fix: "Extract related functions into separate modules",
+ });
+ }
+ });
+ await Promise.all(checks);
+ },
},
},
-});
+} satisfies RuleSet;
```
### Example 6: Required test coverage
@@ -324,31 +365,33 @@ export default defineRules({
Verify that every source file has a corresponding test file.
```typescript
-import { defineRules } from "archgate/rules";
+///
import { basename } from "node:path";
-export default defineRules({
- "test-file-exists": {
- description: "Every source module must have a corresponding test file",
- async check(ctx) {
- const testFiles = await ctx.glob("tests/**/*.test.ts");
- const testBaseNames = new Set(
- testFiles.map((f) => basename(f).replace(".test.ts", ""))
- );
-
- for (const file of ctx.scopedFiles) {
- const name = basename(file).replace(/\.ts$/, "");
- if (!testBaseNames.has(name)) {
- ctx.report.warning({
- message: `No test file found for ${basename(file)}`,
- file,
- fix: `Create tests/${name}.test.ts`,
- });
+export default {
+ rules: {
+ "test-file-exists": {
+ description: "Every source module must have a corresponding test file",
+ async check(ctx) {
+ const testFiles = await ctx.glob("tests/**/*.test.ts");
+ const testBaseNames = new Set(
+ testFiles.map((f) => basename(f).replace(".test.ts", ""))
+ );
+
+ for (const file of ctx.scopedFiles) {
+ const name = basename(file).replace(/\.ts$/, "");
+ if (!testBaseNames.has(name)) {
+ ctx.report.warning({
+ message: `No test file found for ${basename(file)}`,
+ file,
+ fix: `Create tests/${name}.test.ts`,
+ });
+ }
}
- }
+ },
},
},
-});
+} satisfies RuleSet;
```
## Severity levels
@@ -364,16 +407,18 @@ Each rule can set a default severity in its configuration. The severity determin
Set the severity in the rule definition:
```typescript
-export default defineRules({
- "my-rule": {
- description: "...",
- severity: "warning",
- async check(ctx) {
- // Violations from this rule are warnings, not errors
- ctx.report.violation({ message: "..." });
+export default {
+ rules: {
+ "my-rule": {
+ description: "...",
+ severity: "warning",
+ async check(ctx) {
+ // Violations from this rule are warnings, not errors
+ ctx.report.violation({ message: "..." });
+ },
},
},
-});
+} satisfies RuleSet;
```
If `severity` is omitted, it defaults to `error`.
diff --git a/docs/src/content/docs/pt-br/concepts/adrs.mdx b/docs/src/content/docs/pt-br/concepts/adrs.mdx
index e4fc1cbd..292881d4 100644
--- a/docs/src/content/docs/pt-br/concepts/adrs.mdx
+++ b/docs/src/content/docs/pt-br/concepts/adrs.mdx
@@ -21,7 +21,7 @@ Com o plugin do [Claude Code](/guides/claude-code-plugin/) ou [Cursor](/guides/c
### ADR como Regras
-O arquivo de regras é um arquivo `.rules.ts` complementar que exporta verificações automatizadas via `defineRules()`. Quando você executa `archgate check`, a CLI carrega cada ADR que tem `rules: true` em seu frontmatter, executa o arquivo de regras complementar contra seu codebase, e reporta quaisquer violações com caminhos de arquivo e números de linha.
+O arquivo de regras é um arquivo `.rules.ts` complementar que exporta um objeto simples tipado com `satisfies RuleSet`. Quando você executa `archgate check`, a CLI carrega cada ADR que tem `rules: true` em seu frontmatter, executa o arquivo de regras complementar contra seu codebase, e reporta quaisquer violações com caminhos de arquivo e números de linha.
Nem todo ADR precisa de regras. Algumas decisões são melhor aplicadas apenas por revisão de código. Defina `rules: false` quando nenhuma verificação automatizada for prática.
diff --git a/docs/src/content/docs/pt-br/concepts/rules.mdx b/docs/src/content/docs/pt-br/concepts/rules.mdx
index 59a37bc9..09edd575 100644
--- a/docs/src/content/docs/pt-br/concepts/rules.mdx
+++ b/docs/src/content/docs/pt-br/concepts/rules.mdx
@@ -3,27 +3,29 @@ title: Regras
description: Entenda o sistema de regras TypeScript do Archgate que transforma Architecture Decision Records em verificações automatizadas com relatórios por arquivo.
---
-Regras são o lado executável de um ADR. Elas vivem em arquivos `.rules.ts` complementares ao lado do documento ADR e exportam verificações automatizadas via a função `defineRules()`. Quando você executa `archgate check`, a CLI carrega cada ADR que tem `rules: true`, importa seu arquivo de regras complementar e executa cada verificação contra seu codebase.
+Regras são o lado executável de um ADR. Elas vivem em arquivos `.rules.ts` complementares ao lado do documento ADR e exportam um objeto simples tipado com `satisfies RuleSet`. Quando você executa `archgate check`, a CLI carrega cada ADR que tem `rules: true`, importa seu arquivo de regras complementar e executa cada verificação contra seu codebase.
## Definindo Regras
-Um arquivo de regras é um módulo TypeScript que exporta por padrão o resultado de `defineRules()`. Importe a função do pacote `archgate/rules`:
+Um arquivo de regras é um módulo TypeScript que exporta por padrão um objeto simples em conformidade com o tipo `RuleSet`. O tipo é fornecido pelo shim local gerado automaticamente por `archgate init` (sem necessidade de instalar via npm):
```typescript
-import { defineRules } from "archgate/rules";
-
-export default defineRules({
- "rule-key": {
- description: "What this rule checks",
- severity: "error",
- async check(ctx) {
- // Inspect files and report violations
+///
+
+export default {
+ rules: {
+ "rule-key": {
+ description: "What this rule checks",
+ severity: "error",
+ async check(ctx) {
+ // Inspect files and report violations
+ },
},
},
-});
+} satisfies RuleSet;
```
-Cada chave no objeto passado para `defineRules()` se torna o ID da regra. O identificador completo da regra mostrado na saída do check combina o ID do ADR e a chave da regra, por exemplo `ARCH-004/no-barrel-files`.
+Cada chave no objeto `rules` se torna o ID da regra. O identificador completo da regra mostrado na saída do check combina o ID do ADR e a chave da regra, por exemplo `ARCH-004/no-barrel-files`.
## Estrutura da Regra
@@ -115,33 +117,35 @@ Cada regra tem um timeout de execução de 30 segundos. Se a função `check` de
Aqui está um arquivo de regras completo que verifica um padrão de import proibido. Ele garante que nenhum arquivo fonte importe diretamente de `node:fs` (o projeto requer o uso de um wrapper).
```typescript
-import { defineRules } from "archgate/rules";
-
-export default defineRules({
- "no-direct-fs-import": {
- description:
- "Source files must not import directly from node:fs; use the fs wrapper",
- severity: "error",
- async check(ctx) {
- const sourceFiles = ctx.scopedFiles.filter(
- (f) => f.endsWith(".ts") && !f.endsWith(".test.ts")
- );
-
- for (const file of sourceFiles) {
- const matches = await ctx.grep(file, /from ["']node:fs["']/);
-
- for (const match of matches) {
- ctx.report.violation({
- message: `Direct import from "node:fs" is not allowed. Use the fs wrapper from "src/helpers/fs" instead.`,
- file: match.file,
- line: match.line,
- fix: 'Replace the import with: import { readFile, writeFile } from "../helpers/fs"',
- });
+///
+
+export default {
+ rules: {
+ "no-direct-fs-import": {
+ description:
+ "Source files must not import directly from node:fs; use the fs wrapper",
+ severity: "error",
+ async check(ctx) {
+ const sourceFiles = ctx.scopedFiles.filter(
+ (f) => f.endsWith(".ts") && !f.endsWith(".test.ts")
+ );
+
+ for (const file of sourceFiles) {
+ const matches = await ctx.grep(file, /from ["']node:fs["']/);
+
+ for (const match of matches) {
+ ctx.report.violation({
+ message: `Direct import from "node:fs" is not allowed. Use the fs wrapper from "src/helpers/fs" instead.`,
+ file: match.file,
+ line: match.line,
+ fix: 'Replace the import with: import { readFile, writeFile } from "../helpers/fs"',
+ });
+ }
}
- }
+ },
},
},
-});
+} satisfies RuleSet;
```
Quando essa regra é executada contra um arquivo contendo `import { readFileSync } from "node:fs"`, a saída se parece com:
diff --git a/docs/src/content/docs/pt-br/examples/common-rule-patterns.mdx b/docs/src/content/docs/pt-br/examples/common-rule-patterns.mdx
index 25eaa2d7..477ee085 100644
--- a/docs/src/content/docs/pt-br/examples/common-rule-patterns.mdx
+++ b/docs/src/content/docs/pt-br/examples/common-rule-patterns.mdx
@@ -10,7 +10,7 @@ Esta página fornece exemplos completos de regras, prontos para copiar e colar,
**Quando usar:** Restringir dependências de produção a uma lista curada para evitar inchaços de dependências e riscos na cadeia de suprimentos.
```typescript
-import { defineRules } from "archgate/rules";
+///
const APPROVED_DEPS = [
"@commander-js/extra-typings",
@@ -19,30 +19,32 @@ const APPROVED_DEPS = [
"zod",
];
-export default defineRules({
- "no-unapproved-deps": {
- description: "Production dependencies must be on the approved list",
- async check(ctx) {
- let pkg: { dependencies?: Record };
- try {
- pkg = (await ctx.readJSON("package.json")) as typeof pkg;
- } catch {
- return; // No package.json — nothing to check
- }
-
- const deps = Object.keys(pkg.dependencies ?? {});
- for (const dep of deps) {
- if (!APPROVED_DEPS.includes(dep)) {
- ctx.report.violation({
- message: `Unapproved production dependency: "${dep}". Approved: ${APPROVED_DEPS.join(", ")}`,
- file: "package.json",
- fix: `Either add "${dep}" to the approved list in the ADR or move it to devDependencies`,
- });
+export default {
+ rules: {
+ "no-unapproved-deps": {
+ description: "Production dependencies must be on the approved list",
+ async check(ctx) {
+ let pkg: { dependencies?: Record };
+ try {
+ pkg = (await ctx.readJSON("package.json")) as typeof pkg;
+ } catch {
+ return; // No package.json — nothing to check
+ }
+
+ const deps = Object.keys(pkg.dependencies ?? {});
+ for (const dep of deps) {
+ if (!APPROVED_DEPS.includes(dep)) {
+ ctx.report.violation({
+ message: `Unapproved production dependency: "${dep}". Approved: ${APPROVED_DEPS.join(", ")}`,
+ file: "package.json",
+ fix: `Either add "${dep}" to the approved list in the ADR or move it to devDependencies`,
+ });
+ }
}
- }
+ },
},
},
-});
+} satisfies RuleSet;
```
**Como funciona:** Lê o `package.json`, itera sobre as `dependencies` de produção e reporta uma violação para qualquer pacote que não esteja no array `APPROVED_DEPS`. Dependências em `devDependencies` não são verificadas. A mensagem de correção orienta o desenvolvedor a obter a aprovação da dependência ou reclassificá-la.
@@ -54,26 +56,28 @@ export default defineRules({
**Quando usar:** Garantir que arquivos em um diretório específico exportem uma assinatura de função obrigatória, como o padrão `register*Command` para arquivos de comandos CLI.
```typescript
-import { defineRules } from "archgate/rules";
-
-export default defineRules({
- "register-function-export": {
- description: "Command files must export a register*Command function",
- async check(ctx) {
- const files = ctx.scopedFiles.filter((f) => !f.endsWith("index.ts"));
- const checks = files.map(async (file) => {
- const content = await ctx.readFile(file);
- if (!/export\s+function\s+register\w+Command/.test(content)) {
- ctx.report.violation({
- message: "Command file must export a register*Command function",
- file,
- });
- }
- });
- await Promise.all(checks);
+///
+
+export default {
+ rules: {
+ "register-function-export": {
+ description: "Command files must export a register*Command function",
+ async check(ctx) {
+ const files = ctx.scopedFiles.filter((f) => !f.endsWith("index.ts"));
+ const checks = files.map(async (file) => {
+ const content = await ctx.readFile(file);
+ if (!/export\s+function\s+register\w+Command/.test(content)) {
+ ctx.report.violation({
+ message: "Command file must export a register*Command function",
+ file,
+ });
+ }
+ });
+ await Promise.all(checks);
+ },
},
},
-});
+} satisfies RuleSet;
```
**Como funciona:** Filtra arquivos `index.ts` (barrel files), e então verifica cada arquivo no escopo em busca de uma função exportada que corresponda ao padrão de nomenclatura `register*Command`. A regex procura por `export function register` seguido de quaisquer caracteres alfanuméricos e `Command`. Arquivos que não correspondem recebem uma violação.
@@ -91,7 +95,7 @@ if (!/export\s+default\s+function\s+\w+/.test(content)) {
**Quando usar:** Impedir o uso de uma biblioteca ou módulo específico em toda a base de código. Casos de uso comuns incluem banir bibliotecas pesadas como `lodash` ou `moment` em favor de alternativas nativas, ou impedir importações de módulos internos que estão sendo descontinuados.
```typescript
-import { defineRules } from "archgate/rules";
+///
const BANNED_IMPORTS = [
{
@@ -111,24 +115,26 @@ const BANNED_IMPORTS = [
},
];
-export default defineRules({
- "no-banned-imports": {
- description: "Prevent usage of banned libraries",
- async check(ctx) {
- for (const banned of BANNED_IMPORTS) {
- const matches = await ctx.grepFiles(banned.pattern, "src/**/*.ts");
- for (const match of matches) {
- ctx.report.violation({
- message: `Banned import: "${banned.name}" is not allowed. Use ${banned.alternative} instead.`,
- file: match.file,
- line: match.line,
- fix: `Replace ${banned.name} with ${banned.alternative}`,
- });
+export default {
+ rules: {
+ "no-banned-imports": {
+ description: "Prevent usage of banned libraries",
+ async check(ctx) {
+ for (const banned of BANNED_IMPORTS) {
+ const matches = await ctx.grepFiles(banned.pattern, "src/**/*.ts");
+ for (const match of matches) {
+ ctx.report.violation({
+ message: `Banned import: "${banned.name}" is not allowed. Use ${banned.alternative} instead.`,
+ file: match.file,
+ line: match.line,
+ fix: `Replace ${banned.name} with ${banned.alternative}`,
+ });
+ }
}
- }
+ },
},
},
-});
+} satisfies RuleSet;
```
**Como funciona:** Define uma lista de importações banidas com o padrão regex para detectá-las, o nome da biblioteca para a mensagem de erro e a alternativa recomendada. Usa `ctx.grepFiles` para escanear todos os arquivos TypeScript em busca de cada padrão banido e reporta uma violação para cada correspondência.
@@ -142,32 +148,34 @@ Para adicionar mais importações banidas, adicione entradas ao array `BANNED_IM
**Quando usar:** Impor nomenclatura consistente de arquivos em um diretório, como exigir kebab-case para todos os arquivos fonte.
```typescript
-import { defineRules } from "archgate/rules";
+///
import { basename } from "node:path";
const KEBAB_CASE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*\.(ts|tsx|js|jsx)$/;
-export default defineRules({
- "kebab-case-filenames": {
- description: "Source files must use kebab-case naming",
- async check(ctx) {
- for (const file of ctx.scopedFiles) {
- const name = basename(file);
-
- // Skip test files and type declaration files
- if (name.endsWith(".test.ts") || name.endsWith(".d.ts")) continue;
-
- if (!KEBAB_CASE.test(name)) {
- ctx.report.violation({
- message: `File "${name}" does not follow kebab-case naming convention`,
- file,
- fix: `Rename to ${name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase()}`,
- });
+export default {
+ rules: {
+ "kebab-case-filenames": {
+ description: "Source files must use kebab-case naming",
+ async check(ctx) {
+ for (const file of ctx.scopedFiles) {
+ const name = basename(file);
+
+ // Skip test files and type declaration files
+ if (name.endsWith(".test.ts") || name.endsWith(".d.ts")) continue;
+
+ if (!KEBAB_CASE.test(name)) {
+ ctx.report.violation({
+ message: `File "${name}" does not follow kebab-case naming convention`,
+ file,
+ fix: `Rename to ${name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase()}`,
+ });
+ }
}
- }
+ },
},
},
-});
+} satisfies RuleSet;
```
**Como funciona:** Extrai o basename de cada arquivo no escopo e o testa contra uma regex de kebab-case. A regex exige letras minúsculas e dígitos separados por hífens, com uma extensão de arquivo válida. Arquivos de teste e declarações de tipo são excluídos. A sugestão de correção converte automaticamente camelCase para kebab-case.
@@ -185,27 +193,29 @@ const CAMEL_CASE = /^[a-z][a-zA-Z0-9]*\.(ts|tsx|js|jsx)$/;
**Quando usar:** Sinalizar comentários TODO, FIXME, HACK e XXX para que sejam resolvidos antes do merge. Usa severidade `warning` para não bloquear o CI, mas torna os comentários visíveis na saída da verificação.
```typescript
-import { defineRules } from "archgate/rules";
-
-export default defineRules({
- "no-todo-comments": {
- description: "TODO and FIXME comments should be resolved before merging",
- severity: "warning",
- async check(ctx) {
- const matches = await ctx.grepFiles(
- /\/\/\s*(TODO|FIXME|HACK|XXX):/i,
- "src/**/*.ts"
- );
- for (const match of matches) {
- ctx.report.warning({
- message: `${match.content.trim()} -- resolve before merging`,
- file: match.file,
- line: match.line,
- });
- }
+///
+
+export default {
+ rules: {
+ "no-todo-comments": {
+ description: "TODO and FIXME comments should be resolved before merging",
+ severity: "warning",
+ async check(ctx) {
+ const matches = await ctx.grepFiles(
+ /\/\/\s*(TODO|FIXME|HACK|XXX):/i,
+ "src/**/*.ts"
+ );
+ for (const match of matches) {
+ ctx.report.warning({
+ message: `${match.content.trim()} -- resolve before merging`,
+ file: match.file,
+ line: match.line,
+ });
+ }
+ },
},
},
-});
+} satisfies RuleSet;
```
**Como funciona:** Usa `ctx.grepFiles` para escanear todos os arquivos TypeScript em `src/` em busca de comentários que comecem com `TODO:`, `FIXME:`, `HACK:` ou `XXX:` (sem distinção de maiúsculas/minúsculas). Cada correspondência é reportada como um aviso com o texto original do comentário. Como a severidade é `"warning"`, a verificação termina com código 0 mesmo quando correspondências são encontradas -- ela exibe os comentários sem bloquear merges.
@@ -219,31 +229,33 @@ Para tornar isso um bloqueio definitivo, altere a severidade para `"error"` e us
**Quando usar:** Garantir que todo arquivo fonte tenha um arquivo de teste correspondente, impedindo que código sem testes seja mergeado.
```typescript
-import { defineRules } from "archgate/rules";
+///
import { relative } from "node:path";
-export default defineRules({
- "test-file-exists": {
- description: "Every source file should have a corresponding test file",
- severity: "warning",
- async check(ctx) {
- for (const file of ctx.scopedFiles) {
- const rel = relative(ctx.projectRoot, file);
- const testPath = rel
- .replace(/^src\//, "tests/")
- .replace(/\.ts$/, ".test.ts");
- const testFiles = await ctx.glob(testPath);
- if (testFiles.length === 0) {
- ctx.report.warning({
- message: `No test file found at ${testPath}`,
- file,
- fix: `Create a test file at ${testPath}`,
- });
+export default {
+ rules: {
+ "test-file-exists": {
+ description: "Every source file should have a corresponding test file",
+ severity: "warning",
+ async check(ctx) {
+ for (const file of ctx.scopedFiles) {
+ const rel = relative(ctx.projectRoot, file);
+ const testPath = rel
+ .replace(/^src\//, "tests/")
+ .replace(/\.ts$/, ".test.ts");
+ const testFiles = await ctx.glob(testPath);
+ if (testFiles.length === 0) {
+ ctx.report.warning({
+ message: `No test file found at ${testPath}`,
+ file,
+ fix: `Create a test file at ${testPath}`,
+ });
+ }
}
- }
+ },
},
},
-});
+} satisfies RuleSet;
```
**Como funciona:** Para cada arquivo fonte no escopo, converte o caminho de `src/` para `tests/` e adiciona `.test.ts`. Em seguida, usa `ctx.glob` para verificar se o arquivo de teste existe. Caso não exista, reporta um aviso com uma correção sugerindo o caminho esperado do arquivo de teste.
diff --git a/docs/src/content/docs/pt-br/getting-started/quick-start.mdx b/docs/src/content/docs/pt-br/getting-started/quick-start.mdx
index 98329351..576f8941 100644
--- a/docs/src/content/docs/pt-br/getting-started/quick-start.mdx
+++ b/docs/src/content/docs/pt-br/getting-started/quick-start.mdx
@@ -63,29 +63,31 @@ Abaixo do frontmatter, escreva a decisão em markdown. O Archgate não impõe um
## 4. Adicionar um arquivo de regras complementar
-Crie um arquivo `.rules.ts` ao lado do seu ADR com o mesmo prefixo de nome. As regras são escritas em TypeScript usando a função `defineRules`:
+Crie um arquivo `.rules.ts` ao lado do seu ADR com o mesmo prefixo de nome. As regras são escritas em TypeScript usando o tipo `RuleSet`:
```typescript
-import { defineRules } from "archgate/rules";
-
-export default defineRules({
- "no-console-error": {
- description: "Use logError() instead of console.error()",
- async check(ctx) {
- for (const file of ctx.scopedFiles) {
- const matches = await ctx.grep(file, /console\.error\(/);
- for (const match of matches) {
- ctx.report.violation({
- message: "Use logError() instead of console.error()",
- file: match.file,
- line: match.line,
- fix: "Import logError from your helpers and use it instead",
- });
+///
+
+export default {
+ rules: {
+ "no-console-error": {
+ description: "Use logError() instead of console.error()",
+ async check(ctx) {
+ for (const file of ctx.scopedFiles) {
+ const matches = await ctx.grep(file, /console\.error\(/);
+ for (const match of matches) {
+ ctx.report.violation({
+ message: "Use logError() instead of console.error()",
+ file: match.file,
+ line: match.line,
+ fix: "Import logError from your helpers and use it instead",
+ });
+ }
}
- }
+ },
},
},
-});
+} satisfies RuleSet;
```
Cada regra possui uma chave única, uma descrição e uma função assíncrona `check`. Dentro de `check`, você tem acesso a:
diff --git a/docs/src/content/docs/pt-br/guides/writing-rules.mdx b/docs/src/content/docs/pt-br/guides/writing-rules.mdx
index 152522aa..f6b7de0a 100644
--- a/docs/src/content/docs/pt-br/guides/writing-rules.mdx
+++ b/docs/src/content/docs/pt-br/guides/writing-rules.mdx
@@ -1,6 +1,6 @@
---
title: Escrevendo Regras
-description: Escreva regras TypeScript que aplicam automaticamente seus Architecture Decision Records. Aprenda a API defineRules, correspondência de arquivos e relatórios.
+description: Escreva regras TypeScript que aplicam automaticamente seus Architecture Decision Records. Aprenda a API de regras com satisfies RuleSet, correspondência de arquivos e relatórios.
---
Regras são funções TypeScript que verificam seu codebase quanto à conformidade com ADRs. Elas ficam em arquivos `.rules.ts` complementares ao lado dos arquivos markdown dos ADRs e são executadas quando você roda `archgate check`.
@@ -13,40 +13,71 @@ Regras são funções TypeScript que verificam seu codebase quanto à conformida
## Configuração básica
-Todo arquivo de regras exporta uma chamada `defineRules()` por padrão. Cada chave se torna um ID de regra, e cada regra tem uma `description` e uma função async `check` que recebe um objeto de contexto.
+Todo arquivo de regras exporta por padrão um objeto simples tipado com `satisfies RuleSet`. Cada chave no objeto `rules` se torna um ID de regra, e cada regra tem uma `description` e uma função async `check` que recebe um objeto de contexto.
```typescript
-import { defineRules } from "archgate/rules";
-
-export default defineRules({
- "my-rule-id": {
- description: "What this rule checks",
- async check(ctx) {
- // Your check logic here
+///
+
+export default {
+ rules: {
+ "my-rule-id": {
+ description: "What this rule checks",
+ async check(ctx) {
+ // Your check logic here
+ },
},
},
-});
+} satisfies RuleSet;
+```
+
+:::note
+A diretiva `/// ` pode conflitar com a regra `@typescript-eslint/triple-slash-reference` no ESLint ou oxlint. Se seu projeto usa essa regra, desative-a para arquivos em `.archgate/adrs/`.
+
+Para **ESLint** (flat config):
+
+```js
+{
+ files: [".archgate/adrs/*.rules.ts"],
+ rules: { "@typescript-eslint/triple-slash-reference": "off" },
+}
+```
+
+Para **oxlint** (`.oxlintrc.json`):
+
+```json
+{
+ "overrides": [
+ {
+ "files": [".archgate/adrs/*.rules.ts"],
+ "rules": { "typescript/triple-slash-reference": "off" }
+ }
+ ]
+}
```
+:::
+
Um único arquivo de regras pode definir múltiplas regras:
```typescript
-import { defineRules } from "archgate/rules";
-
-export default defineRules({
- "first-rule": {
- description: "Checks one thing",
- async check(ctx) {
- // ...
+///
+
+export default {
+ rules: {
+ "first-rule": {
+ description: "Checks one thing",
+ async check(ctx) {
+ // ...
+ },
},
- },
- "second-rule": {
- description: "Checks another thing",
- async check(ctx) {
- // ...
+ "second-rule": {
+ description: "Checks another thing",
+ async check(ctx) {
+ // ...
+ },
},
},
-});
+} satisfies RuleSet;
```
## A API de Contexto
@@ -172,7 +203,7 @@ O caminho absoluto para o diretório raiz do projeto. Útil quando você precisa
Verifica se todas as dependências de produção estão em uma lista aprovada. Esta é a regra que o Archgate usa para sua própria política de dependências ARCH-006.
```typescript
-import { defineRules } from "archgate/rules";
+///
const APPROVED_DEPS = [
"@commander-js/extra-typings",
@@ -181,30 +212,32 @@ const APPROVED_DEPS = [
"zod",
];
-export default defineRules({
- "no-unapproved-deps": {
- description: "Production dependencies must be on the approved list",
- async check(ctx) {
- let pkg: { dependencies?: Record };
- try {
- pkg = (await ctx.readJSON("package.json")) as typeof pkg;
- } catch {
- return;
- }
-
- const deps = Object.keys(pkg.dependencies ?? {});
- for (const dep of deps) {
- if (!APPROVED_DEPS.includes(dep)) {
- ctx.report.violation({
- message: `Unapproved production dependency: "${dep}". Approved: ${APPROVED_DEPS.join(", ")}`,
- file: "package.json",
- fix: `Either add "${dep}" to the approved list in the ADR or move it to devDependencies`,
- });
+export default {
+ rules: {
+ "no-unapproved-deps": {
+ description: "Production dependencies must be on the approved list",
+ async check(ctx) {
+ let pkg: { dependencies?: Record };
+ try {
+ pkg = (await ctx.readJSON("package.json")) as typeof pkg;
+ } catch {
+ return;
}
- }
+
+ const deps = Object.keys(pkg.dependencies ?? {});
+ for (const dep of deps) {
+ if (!APPROVED_DEPS.includes(dep)) {
+ ctx.report.violation({
+ message: `Unapproved production dependency: "${dep}". Approved: ${APPROVED_DEPS.join(", ")}`,
+ file: "package.json",
+ fix: `Either add "${dep}" to the approved list in the ADR or move it to devDependencies`,
+ });
+ }
+ }
+ },
},
},
-});
+} satisfies RuleSet;
```
### Exemplo 2: Padrão de export obrigatório
@@ -212,26 +245,28 @@ export default defineRules({
Verifica se todo arquivo de comando exporta uma função `register*Command`. Esta é a regra que o Archgate usa para sua própria estrutura de comandos ARCH-001.
```typescript
-import { defineRules } from "archgate/rules";
-
-export default defineRules({
- "register-function-export": {
- description: "Command files must export a register*Command function",
- async check(ctx) {
- const files = ctx.scopedFiles.filter((f) => !f.endsWith("index.ts"));
- const checks = files.map(async (file) => {
- const content = await ctx.readFile(file);
- if (!/export\s+function\s+register\w+Command/.test(content)) {
- ctx.report.violation({
- message: "Command file must export a register*Command function",
- file,
- });
- }
- });
- await Promise.all(checks);
+///
+
+export default {
+ rules: {
+ "register-function-export": {
+ description: "Command files must export a register*Command function",
+ async check(ctx) {
+ const files = ctx.scopedFiles.filter((f) => !f.endsWith("index.ts"));
+ const checks = files.map(async (file) => {
+ const content = await ctx.readFile(file);
+ if (!/export\s+function\s+register\w+Command/.test(content)) {
+ ctx.report.violation({
+ message: "Command file must export a register*Command function",
+ file,
+ });
+ }
+ });
+ await Promise.all(checks);
+ },
},
},
-});
+} satisfies RuleSet;
```
### Exemplo 3: Padrão de import proibido
@@ -239,27 +274,29 @@ export default defineRules({
Impede a importação de uma biblioteca específica quando alternativas nativas existem.
```typescript
-import { defineRules } from "archgate/rules";
-
-export default defineRules({
- "no-lodash": {
- description: "Do not use lodash -- use native array methods instead",
- async check(ctx) {
- const matches = await ctx.grepFiles(
- /import\s+.*from\s+['"]lodash/,
- "src/**/*.ts"
- );
- for (const match of matches) {
- ctx.report.violation({
- message: "Do not import lodash. Use native array methods instead.",
- file: match.file,
- line: match.line,
- fix: "Replace lodash usage with native Array.prototype methods",
- });
- }
+///
+
+export default {
+ rules: {
+ "no-lodash": {
+ description: "Do not use lodash -- use native array methods instead",
+ async check(ctx) {
+ const matches = await ctx.grepFiles(
+ /import\s+.*from\s+['"]lodash/,
+ "src/**/*.ts"
+ );
+ for (const match of matches) {
+ ctx.report.violation({
+ message: "Do not import lodash. Use native array methods instead.",
+ file: match.file,
+ line: match.line,
+ fix: "Replace lodash usage with native Array.prototype methods",
+ });
+ }
+ },
},
},
-});
+} satisfies RuleSet;
```
### Exemplo 4: Convenção de nomenclatura de arquivos
@@ -267,26 +304,28 @@ export default defineRules({
Aplica nomenclatura kebab-case para arquivos fonte.
```typescript
-import { defineRules } from "archgate/rules";
+///
import { basename } from "node:path";
-export default defineRules({
- "kebab-case-files": {
- description: "Source files must use kebab-case naming",
- async check(ctx) {
- for (const file of ctx.scopedFiles) {
- const name = basename(file).replace(/\.(ts|tsx|js|jsx)$/, "");
- if (name !== name.toLowerCase() || name.includes("_")) {
- ctx.report.violation({
- message: `File name "${basename(file)}" must be kebab-case (lowercase with hyphens)`,
- file,
- fix: `Rename to ${name.toLowerCase().replace(/_/g, "-")}`,
- });
+export default {
+ rules: {
+ "kebab-case-files": {
+ description: "Source files must use kebab-case naming",
+ async check(ctx) {
+ for (const file of ctx.scopedFiles) {
+ const name = basename(file).replace(/\.(ts|tsx|js|jsx)$/, "");
+ if (name !== name.toLowerCase() || name.includes("_")) {
+ ctx.report.violation({
+ message: `File name "${basename(file)}" must be kebab-case (lowercase with hyphens)`,
+ file,
+ fix: `Rename to ${name.toLowerCase().replace(/_/g, "-")}`,
+ });
+ }
}
- }
+ },
},
},
-});
+} satisfies RuleSet;
```
### Exemplo 5: Tamanho máximo de arquivo
@@ -294,29 +333,31 @@ export default defineRules({
Emite um aviso quando arquivos excedem um limite de linhas.
```typescript
-import { defineRules } from "archgate/rules";
+///
const MAX_LINES = 300;
-export default defineRules({
- "max-file-length": {
- description: `Source files should not exceed ${MAX_LINES} lines`,
- async check(ctx) {
- const checks = ctx.scopedFiles.map(async (file) => {
- const content = await ctx.readFile(file);
- const lineCount = content.split("\n").length;
- if (lineCount > MAX_LINES) {
- ctx.report.warning({
- message: `File has ${lineCount} lines (max: ${MAX_LINES}). Consider splitting it.`,
- file,
- fix: "Extract related functions into separate modules",
- });
- }
- });
- await Promise.all(checks);
+export default {
+ rules: {
+ "max-file-length": {
+ description: `Source files should not exceed ${MAX_LINES} lines`,
+ async check(ctx) {
+ const checks = ctx.scopedFiles.map(async (file) => {
+ const content = await ctx.readFile(file);
+ const lineCount = content.split("\n").length;
+ if (lineCount > MAX_LINES) {
+ ctx.report.warning({
+ message: `File has ${lineCount} lines (max: ${MAX_LINES}). Consider splitting it.`,
+ file,
+ fix: "Extract related functions into separate modules",
+ });
+ }
+ });
+ await Promise.all(checks);
+ },
},
},
-});
+} satisfies RuleSet;
```
### Exemplo 6: Cobertura de testes obrigatória
@@ -324,31 +365,33 @@ export default defineRules({
Verifica se todo arquivo fonte possui um arquivo de teste correspondente.
```typescript
-import { defineRules } from "archgate/rules";
+///
import { basename } from "node:path";
-export default defineRules({
- "test-file-exists": {
- description: "Every source module must have a corresponding test file",
- async check(ctx) {
- const testFiles = await ctx.glob("tests/**/*.test.ts");
- const testBaseNames = new Set(
- testFiles.map((f) => basename(f).replace(".test.ts", ""))
- );
-
- for (const file of ctx.scopedFiles) {
- const name = basename(file).replace(/\.ts$/, "");
- if (!testBaseNames.has(name)) {
- ctx.report.warning({
- message: `No test file found for ${basename(file)}`,
- file,
- fix: `Create tests/${name}.test.ts`,
- });
+export default {
+ rules: {
+ "test-file-exists": {
+ description: "Every source module must have a corresponding test file",
+ async check(ctx) {
+ const testFiles = await ctx.glob("tests/**/*.test.ts");
+ const testBaseNames = new Set(
+ testFiles.map((f) => basename(f).replace(".test.ts", ""))
+ );
+
+ for (const file of ctx.scopedFiles) {
+ const name = basename(file).replace(/\.ts$/, "");
+ if (!testBaseNames.has(name)) {
+ ctx.report.warning({
+ message: `No test file found for ${basename(file)}`,
+ file,
+ fix: `Create tests/${name}.test.ts`,
+ });
+ }
}
- }
+ },
},
},
-});
+} satisfies RuleSet;
```
## Níveis de severidade
@@ -364,16 +407,18 @@ Cada regra pode definir uma severidade padrão em sua configuração. A severida
Defina a severidade na definição da regra:
```typescript
-export default defineRules({
- "my-rule": {
- description: "...",
- severity: "warning",
- async check(ctx) {
- // Violations from this rule are warnings, not errors
- ctx.report.violation({ message: "..." });
+export default {
+ rules: {
+ "my-rule": {
+ description: "...",
+ severity: "warning",
+ async check(ctx) {
+ // Violations from this rule are warnings, not errors
+ ctx.report.violation({ message: "..." });
+ },
},
},
-});
+} satisfies RuleSet;
```
Se `severity` for omitido, o padrão é `error`.
diff --git a/docs/src/content/docs/pt-br/reference/adr-schema.mdx b/docs/src/content/docs/pt-br/reference/adr-schema.mdx
index 2d6981e5..f032dd03 100644
--- a/docs/src/content/docs/pt-br/reference/adr-schema.mdx
+++ b/docs/src/content/docs/pt-br/reference/adr-schema.mdx
@@ -218,29 +218,31 @@ ARCH-002-error-handling.md # rules: true in frontmatter
ARCH-002-error-handling.rules.ts # companion rules file
```
-O arquivo de regras deve exportar um `RuleSet` padrão criado via `defineRules()`:
+O arquivo de regras deve exportar um `RuleSet` padrão usando um objeto simples com `satisfies RuleSet`:
```typescript
-import { defineRules } from "archgate/rules";
-
-export default defineRules({
- "no-console-error": {
- description: "Use logError() instead of console.error()",
- async check(ctx) {
- for (const file of ctx.scopedFiles) {
- const matches = await ctx.grep(file, /console\.error\(/);
- for (const match of matches) {
- ctx.report.violation({
- message: "Use logError() instead of console.error()",
- file: match.file,
- line: match.line,
- fix: "Import logError from src/helpers/log and use it instead",
- });
+///
+
+export default {
+ rules: {
+ "no-console-error": {
+ description: "Use logError() instead of console.error()",
+ async check(ctx) {
+ for (const file of ctx.scopedFiles) {
+ const matches = await ctx.grep(file, /console\.error\(/);
+ for (const match of matches) {
+ ctx.report.violation({
+ message: "Use logError() instead of console.error()",
+ file: match.file,
+ line: match.line,
+ fix: "Import logError from src/helpers/log and use it instead",
+ });
+ }
}
- }
+ },
},
},
-});
+} satisfies RuleSet;
```
Consulte a [API de Regras](/reference/rule-api/) para a referência completa da API TypeScript.
diff --git a/docs/src/content/docs/pt-br/reference/rule-api.mdx b/docs/src/content/docs/pt-br/reference/rule-api.mdx
index d967cf7a..955d3154 100644
--- a/docs/src/content/docs/pt-br/reference/rule-api.mdx
+++ b/docs/src/content/docs/pt-br/reference/rule-api.mdx
@@ -1,30 +1,32 @@
---
title: API de Regras
-description: Referência da API TypeScript para escrever regras do Archgate. Aprenda defineRules, RuleContext, relatórios de violação, correspondência de arquivos e verificações AST.
+description: Referência da API TypeScript para escrever regras do Archgate. Aprenda RuleSet com satisfies, RuleContext, relatórios de violação, correspondência de arquivos e verificações AST.
---
-As regras do Archgate são arquivos TypeScript que exportam verificações automatizadas via `defineRules()`. Cada regra recebe um `RuleContext` com utilitários para buscar arquivos, ler conteúdo e reportar violações.
+As regras do Archgate são arquivos TypeScript que exportam um objeto simples tipado com `satisfies RuleSet`. Cada regra recebe um `RuleContext` com utilitários para buscar arquivos, ler conteúdo e reportar violações.
-## defineRules
+## RuleSet
```typescript
-import { defineRules } from "archgate/rules";
-
-export default defineRules({
- "my-rule-id": {
- description: "Human-readable description of what this rule checks",
- severity: "error", // optional, defaults to "error"
- async check(ctx) {
- // Rule logic here
+///
+
+export default {
+ rules: {
+ "my-rule-id": {
+ description: "Human-readable description of what this rule checks",
+ severity: "error", // optional, defaults to "error"
+ async check(ctx) {
+ // Rule logic here
+ },
},
},
-});
+} satisfies RuleSet;
```
-A função `defineRules` recebe um registro de configurações de regras indexadas por ID de regra. As chaves se tornam os IDs das regras que aparecem na saída de verificação e nos relatórios de violação. A função retorna um objeto `RuleSet`.
+Um arquivo de regras exporta por padrão um objeto simples com um registro `rules` indexado por ID de regra. As chaves se tornam os IDs das regras que aparecem na saída de verificação e nos relatórios de violação. A anotação `satisfies RuleSet` fornece verificação de tipos sem envolver em uma chamada de função.
```typescript
-function defineRules(rules: Record): RuleSet;
+type RuleSet = { rules: Record };
```
---
@@ -279,7 +281,7 @@ interface ViolationDetail {
| Campo | Tipo | Descrição |
| ---------- | ---------- | ------------------------------------------------- |
-| `ruleId` | `string` | ID da regra da chave de `defineRules` |
+| `ruleId` | `string` | ID da regra da chave do objeto `rules` |
| `adrId` | `string` | ID do ADR do frontmatter |
| `message` | `string` | Descrição legível |
| `file` | `string?` | Caminho do arquivo onde o problema foi encontrado |
@@ -291,7 +293,7 @@ interface ViolationDetail {
## RuleSet
-O tipo de retorno de `defineRules`. Você não constrói isso diretamente.
+O tipo usado com `satisfies` para verificar a tipagem do seu objeto de regras. Exporte um objeto simples que esteja em conformidade com esta forma.
```typescript
type RuleSet = { rules: Record };
diff --git a/docs/src/content/docs/reference/adr-schema.mdx b/docs/src/content/docs/reference/adr-schema.mdx
index 6b404ed6..ac92b136 100644
--- a/docs/src/content/docs/reference/adr-schema.mdx
+++ b/docs/src/content/docs/reference/adr-schema.mdx
@@ -218,29 +218,31 @@ ARCH-002-error-handling.md # rules: true in frontmatter
ARCH-002-error-handling.rules.ts # companion rules file
```
-The rules file must export a default `RuleSet` created via `defineRules()`:
+The rules file must export a default `RuleSet` using a plain object with `satisfies RuleSet`:
```typescript
-import { defineRules } from "archgate/rules";
-
-export default defineRules({
- "no-console-error": {
- description: "Use logError() instead of console.error()",
- async check(ctx) {
- for (const file of ctx.scopedFiles) {
- const matches = await ctx.grep(file, /console\.error\(/);
- for (const match of matches) {
- ctx.report.violation({
- message: "Use logError() instead of console.error()",
- file: match.file,
- line: match.line,
- fix: "Import logError from src/helpers/log and use it instead",
- });
+///
+
+export default {
+ rules: {
+ "no-console-error": {
+ description: "Use logError() instead of console.error()",
+ async check(ctx) {
+ for (const file of ctx.scopedFiles) {
+ const matches = await ctx.grep(file, /console\.error\(/);
+ for (const match of matches) {
+ ctx.report.violation({
+ message: "Use logError() instead of console.error()",
+ file: match.file,
+ line: match.line,
+ fix: "Import logError from src/helpers/log and use it instead",
+ });
+ }
}
- }
+ },
},
},
-});
+} satisfies RuleSet;
```
See the [Rule API](/reference/rule-api/) for the complete TypeScript API reference.
diff --git a/docs/src/content/docs/reference/rule-api.mdx b/docs/src/content/docs/reference/rule-api.mdx
index dd013c9a..85ba116e 100644
--- a/docs/src/content/docs/reference/rule-api.mdx
+++ b/docs/src/content/docs/reference/rule-api.mdx
@@ -1,30 +1,32 @@
---
title: Rule API
-description: TypeScript API reference for writing Archgate rules. Learn defineRules, RuleContext, violation reporting, file matching, and AST-based compliance checks.
+description: TypeScript API reference for writing Archgate rules. Learn RuleSet with satisfies, RuleContext, violation reporting, file matching, and AST-based compliance checks.
---
-Archgate rules are TypeScript files that export automated checks via `defineRules()`. Each rule receives a `RuleContext` with utilities for searching files, reading content, and reporting violations.
+Archgate rules are TypeScript files that export a plain object typed with `satisfies RuleSet`. Each rule receives a `RuleContext` with utilities for searching files, reading content, and reporting violations.
-## defineRules
+## RuleSet
```typescript
-import { defineRules } from "archgate/rules";
-
-export default defineRules({
- "my-rule-id": {
- description: "Human-readable description of what this rule checks",
- severity: "error", // optional, defaults to "error"
- async check(ctx) {
- // Rule logic here
+///
+
+export default {
+ rules: {
+ "my-rule-id": {
+ description: "Human-readable description of what this rule checks",
+ severity: "error", // optional, defaults to "error"
+ async check(ctx) {
+ // Rule logic here
+ },
},
},
-});
+} satisfies RuleSet;
```
-The `defineRules` function takes a record of rule configurations keyed by rule ID. Keys become the rule IDs that appear in check output and violation reports. The function returns a `RuleSet` object.
+A rules file default-exports a plain object with a `rules` record keyed by rule ID. Keys become the rule IDs that appear in check output and violation reports. The `satisfies RuleSet` annotation provides type checking without wrapping in a function call.
```typescript
-function defineRules(rules: Record): RuleSet;
+type RuleSet = { rules: Record };
```
---
@@ -279,7 +281,7 @@ interface ViolationDetail {
| Field | Type | Description |
| ---------- | ---------- | ------------------------------------ |
-| `ruleId` | `string` | Rule ID from the `defineRules` key |
+| `ruleId` | `string` | Rule ID from the `rules` object key |
| `adrId` | `string` | ADR ID from the frontmatter |
| `message` | `string` | Human-readable description |
| `file` | `string?` | File path where the issue was found |
@@ -291,7 +293,7 @@ interface ViolationDetail {
## RuleSet
-The return type of `defineRules`. You do not construct this directly.
+The type used with `satisfies` to type-check your rules object. Export a plain object that conforms to this shape.
```typescript
type RuleSet = { rules: Record };
diff --git a/package.json b/package.json
index ed271dc2..c63e3e91 100644
--- a/package.json
+++ b/package.json
@@ -27,7 +27,6 @@
"archgate": "bin/archgate.cjs"
},
"files": [
- "src/formats/rules.ts",
"bin/archgate.cjs"
],
"os": [
@@ -36,9 +35,6 @@
"win32"
],
"type": "module",
- "exports": {
- "./rules": "./src/formats/rules.ts"
- },
"scripts": {
"build:check": "bun build src/cli.ts --compile --bytecode --outfile dist/.build-check && rm -f dist/.build-check dist/.build-check.exe",
"changelog": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0",
diff --git a/src/engine/context.ts b/src/engine/context.ts
index 1d8d4c21..878a0e5e 100644
--- a/src/engine/context.ts
+++ b/src/engine/context.ts
@@ -174,7 +174,6 @@ export function matchFilesToAdrs(
/** Load all ADR documents (not just those with rules) from the project. */
async function loadAllAdrs(projectRoot: string): Promise {
const pp = projectPaths(projectRoot);
- const adrs: AdrDocument[] = [];
let files: string[];
try {
@@ -183,16 +182,19 @@ async function loadAllAdrs(projectRoot: string): Promise {
return [];
}
- for (const file of files) {
- try {
- // oxlint-disable-next-line no-await-in-loop -- sequential file discovery
- const content = await Bun.file(join(pp.adrsDir, file)).text();
- adrs.push(parseAdr(content, join(pp.adrsDir, file)));
- } catch {
- // Skip unparseable ADRs
- }
- }
- return adrs;
+ const results = await Promise.all(
+ files.map(async (file) => {
+ try {
+ const filePath = join(pp.adrsDir, file);
+ const content = await Bun.file(filePath).text();
+ return parseAdr(content, filePath);
+ } catch {
+ return null;
+ }
+ })
+ );
+
+ return results.filter((adr): adr is AdrDocument => adr !== null);
}
const EMPTY_SUMMARY: ReportSummary = {
diff --git a/src/engine/git-files.ts b/src/engine/git-files.ts
index 07464250..e8670f58 100644
--- a/src/engine/git-files.ts
+++ b/src/engine/git-files.ts
@@ -40,18 +40,21 @@ export async function resolveScopedFiles(
): Promise {
const patterns = adrFileGlobs ?? ["**/*"];
const trackedFiles = await getGitTrackedFiles(projectRoot);
- const allFiles: string[] = [];
- for (const pattern of patterns) {
- const glob = new Bun.Glob(pattern);
- // oxlint-disable-next-line no-await-in-loop -- async iterator
- for await (const file of glob.scan({ cwd: projectRoot, dot: false })) {
- const normalized = file.replaceAll("\\", "/");
- if (trackedFiles && !trackedFiles.has(normalized)) continue;
- if (!allFiles.includes(normalized)) allFiles.push(normalized);
- }
- }
- return allFiles.sort();
+ const results = await Promise.all(
+ patterns.map(async (pattern) => {
+ const glob = new Bun.Glob(pattern);
+ const files: string[] = [];
+ for await (const file of glob.scan({ cwd: projectRoot, dot: false })) {
+ const normalized = file.replaceAll("\\", "/");
+ if (trackedFiles && !trackedFiles.has(normalized)) continue;
+ files.push(normalized);
+ }
+ return files;
+ })
+ );
+
+ return [...new Set(results.flat())].sort();
}
/** Get changed files from git staging area. */
diff --git a/src/engine/loader.ts b/src/engine/loader.ts
index baf9067f..7b7706ed 100644
--- a/src/engine/loader.ts
+++ b/src/engine/loader.ts
@@ -24,6 +24,7 @@ const RuleSetSchema = z.object({
});
import { logDebug, logWarn } from "../helpers/log";
import { projectPaths } from "../helpers/paths";
+import { ensureRulesShim } from "../helpers/rules-shim";
export interface LoadedAdr {
adr: AdrDocument;
@@ -38,52 +39,55 @@ export async function loadRuleAdrs(
filterAdrId?: string
): Promise {
const pp = projectPaths(projectRoot);
- const loaded: LoadedAdr[] = [];
- const adrDirs: string[] = [pp.adrsDir];
+ // Ensure rules.d.ts exists so .rules.ts files get type checking
+ // without requiring node_modules (supports non-JS projects)
+ await ensureRulesShim(projectRoot);
- for (const adrsDir of adrDirs) {
- let files: string[];
- try {
- files = readdirSync(adrsDir).filter((f) => f.endsWith(".md"));
- } catch {
- continue;
- }
+ const adrsDir = pp.adrsDir;
- for (const file of files) {
- const filePath = join(adrsDir, file);
- let adr: AdrDocument;
+ let files: string[];
+ try {
+ files = readdirSync(adrsDir).filter((f) => f.endsWith(".md"));
+ } catch {
+ return [];
+ }
+ // Phase 1: Read and parse all ADR files in parallel
+ const parsedAdrs = await Promise.all(
+ files.map(async (file) => {
+ const filePath = join(adrsDir, file);
try {
- // oxlint-disable-next-line no-await-in-loop -- sequential file discovery is intentional
const content = await Bun.file(filePath).text();
- adr = parseAdr(content, filePath);
+ return { file, adr: parseAdr(content, filePath) };
} catch (err) {
logDebug(`Skipping unparseable ADR: ${filePath}`, err);
- continue;
- }
-
- // Skip if no rules
- if (!adr.frontmatter.rules) {
- continue;
- }
-
- // Filter by specific ADR ID if requested
- if (filterAdrId && adr.frontmatter.id !== filterAdrId) {
- continue;
+ return null;
}
+ })
+ );
+
+ // Filter to ADRs that have rules enabled
+ const ruleAdrs = parsedAdrs.filter(
+ (entry): entry is NonNullable => {
+ if (entry === null) return false;
+ if (!entry.adr.frontmatter.rules) return false;
+ if (filterAdrId && entry.adr.frontmatter.id !== filterAdrId) return false;
+ return true;
+ }
+ );
- // Find companion .rules.ts file
+ // Phase 2: Verify companion files exist and import rule sets in parallel
+ const ruleResults = await Promise.all(
+ ruleAdrs.map(async ({ file, adr }) => {
const baseName = basename(file, ".md");
const rulesFile = join(adrsDir, `${baseName}.rules.ts`);
- // oxlint-disable-next-line no-await-in-loop -- sequential file discovery is intentional
const rulesFileExists = await Bun.file(rulesFile).exists();
if (!rulesFileExists) {
- logWarn(
+ throw new Error(
`ADR ${adr.frontmatter.id} has rules: true but no companion file found: ${rulesFile}`
);
- continue;
}
try {
@@ -91,7 +95,6 @@ export async function loadRuleAdrs(
// to force re-reading from disk on every call (critical for repeated invocations).
// Use file:// URL to handle Windows backslash paths in import().
const rulesUrl = `${pathToFileURL(rulesFile).href}?t=${Date.now()}`;
- // oxlint-disable-next-line no-await-in-loop -- dynamic import must be sequential
const mod = await import(rulesUrl);
const parsed = RuleSetSchema.safeParse(mod.default);
@@ -99,19 +102,20 @@ export async function loadRuleAdrs(
logWarn(
`ADR ${adr.frontmatter.id}: companion file does not export a valid RuleSet as default`
);
- continue;
+ return null;
}
const ruleSet: RuleSet = parsed.data;
- loaded.push({ adr, ruleSet });
logDebug(
`Loaded ${Object.keys(ruleSet.rules).length} rules from ${adr.frontmatter.id}`
);
+ return { adr, ruleSet };
} catch (err) {
logWarn(`Failed to import rules for ${adr.frontmatter.id}: ${err}`);
+ return null;
}
- }
- }
+ })
+ );
- return loaded;
+ return ruleResults.filter((r): r is LoadedAdr => r !== null);
}
diff --git a/src/formats/rules.ts b/src/formats/rules.ts
index 00833a8e..37804159 100644
--- a/src/formats/rules.ts
+++ b/src/formats/rules.ts
@@ -58,11 +58,3 @@ export interface RuleConfig {
// --- Rule Set ---
export type RuleSet = { rules: Record };
-
-/**
- * Define rules in a .rules.ts companion file.
- * Keys become rule IDs, preventing duplicates.
- */
-export function defineRules(rules: Record): RuleSet {
- return { rules };
-}
diff --git a/src/helpers/adr-writer.ts b/src/helpers/adr-writer.ts
index 26fbb82f..a554bfd9 100644
--- a/src/helpers/adr-writer.ts
+++ b/src/helpers/adr-writer.ts
@@ -8,6 +8,7 @@ import {
parseAdr,
} from "../formats/adr";
import { generateAdrTemplate } from "./adr-templates";
+import { generateRulesTemplate } from "./rules-shim";
export function slugify(title: string): string {
return title
@@ -90,6 +91,14 @@ export async function createAdrFile(
const fileName = `${id}-${slug}.md`;
const filePath = join(adrsDir, fileName);
await Bun.write(filePath, content);
+
+ // Generate companion .rules.ts when rules are enabled
+ if (opts.rules) {
+ const rulesFileName = `${id}-${slug}.rules.ts`;
+ const rulesFilePath = join(adrsDir, rulesFileName);
+ await Bun.write(rulesFilePath, generateRulesTemplate());
+ }
+
return { id, fileName, filePath };
}
@@ -104,21 +113,19 @@ export async function findAdrFileById(
const files = readdirSync(adrsDir).filter((f) => f.endsWith(".md"));
- for (const file of files) {
- const filePath = join(adrsDir, file);
- try {
- // oxlint-disable-next-line no-await-in-loop -- sequential file search
- const content = await Bun.file(filePath).text();
- const adr = parseAdr(content, filePath);
- if (adr.frontmatter.id === id) {
- return adr;
+ const results = await Promise.all(
+ files.map(async (file) => {
+ const filePath = join(adrsDir, file);
+ try {
+ const content = await Bun.file(filePath).text();
+ return parseAdr(content, filePath);
+ } catch {
+ return null;
}
- } catch {
- // Skip unparseable files
- }
- }
+ })
+ );
- return null;
+ return results.find((adr) => adr?.frontmatter.id === id) ?? null;
}
export interface UpdateAdrResult {
diff --git a/src/helpers/claude-settings.ts b/src/helpers/claude-settings.ts
index 52325316..54db9191 100644
--- a/src/helpers/claude-settings.ts
+++ b/src/helpers/claude-settings.ts
@@ -80,8 +80,7 @@ export async function configureClaudeSettings(
// Read existing settings or start with empty object
let existing: ClaudeSettings = {};
if (existsSync(settingsPath)) {
- const content = await Bun.file(settingsPath).text();
- existing = JSON.parse(content) as ClaudeSettings;
+ existing = (await Bun.file(settingsPath).json()) as ClaudeSettings;
}
const merged = mergeClaudeSettings(existing, ARCHGATE_CLAUDE_SETTINGS);
diff --git a/src/helpers/init-project.ts b/src/helpers/init-project.ts
index fa706a82..16bbd819 100644
--- a/src/helpers/init-project.ts
+++ b/src/helpers/init-project.ts
@@ -1,11 +1,12 @@
import { existsSync, readdirSync } from "node:fs";
-import { basename } from "node:path";
+import { basename, join } from "node:path";
import { generateExampleAdr } from "./adr-templates";
import { configureClaudeSettings } from "./claude-settings";
import { configureCopilotSettings } from "./copilot-settings";
import { configureCursorSettings } from "./cursor-settings";
import { createPathIfNotExists, projectPaths } from "./paths";
+import { writeRulesShim } from "./rules-shim";
import { configureVscodeSettings } from "./vscode-settings";
export type EditorTarget = "claude" | "cursor" | "vscode" | "copilot";
@@ -46,6 +47,16 @@ export async function initProject(
createPathIfNotExists(paths.adrsDir);
createPathIfNotExists(paths.lintDir);
+ // Generate rules.d.ts so .rules.ts files get type checking
+ // without requiring node_modules
+ await writeRulesShim(projectRoot);
+
+ // Ensure generated shim files are gitignored
+ await ensureGitignoreEntries(projectRoot);
+
+ // Disable triple-slash-reference lint rule for .archgate/adrs/ if linter detected
+ await ensureLinterOverrides(projectRoot);
+
// Only generate the example ADR when no ADRs exist yet
const hasExistingAdrs =
existsSync(paths.adrsDir) &&
@@ -132,6 +143,78 @@ async function configureEditorSettings(
}
}
+const GITIGNORE_ENTRIES = [".archgate/rules.d.ts"];
+const GITIGNORE_HEADER =
+ "# Archgate generated runtime (regenerated by archgate)";
+
+/**
+ * Ensure the generated rules shim files are listed in .gitignore.
+ * Creates the .gitignore if it does not exist.
+ */
+async function ensureGitignoreEntries(projectRoot: string): Promise {
+ const gitignorePath = join(projectRoot, ".gitignore");
+ let content = "";
+
+ if (existsSync(gitignorePath)) {
+ content = await Bun.file(gitignorePath).text();
+ }
+
+ const missing = GITIGNORE_ENTRIES.filter((entry) => !content.includes(entry));
+
+ if (missing.length === 0) return;
+
+ const block = `\n${GITIGNORE_HEADER}\n${missing.join("\n")}\n`;
+ await Bun.write(gitignorePath, content + block);
+}
+
+const ARCHGATE_RULES_GLOB = ".archgate/adrs/*.rules.ts";
+const TRIPLE_SLASH_RULE_ESLINT = "@typescript-eslint/triple-slash-reference";
+const TRIPLE_SLASH_RULE_OXLINT = "typescript/triple-slash-reference";
+
+/**
+ * Detect JSON-based linter configs and add an override to disable
+ * the triple-slash-reference rule for archgate rule files.
+ * Only modifies .oxlintrc.json and .eslintrc.json — JS configs
+ * require manual setup (documented in the writing-rules guide).
+ */
+async function ensureLinterOverrides(projectRoot: string): Promise {
+ await ensureOxlintOverride(projectRoot);
+ await ensureEslintrcOverride(projectRoot);
+}
+
+async function addJsonOverride(
+ configPath: string,
+ ruleName: string
+): Promise {
+ if (!existsSync(configPath)) return;
+
+ const raw = await Bun.file(configPath).text();
+ if (raw.includes(ARCHGATE_RULES_GLOB)) return;
+
+ const config = await Bun.file(configPath).json();
+ const overrides: unknown[] = config.overrides ?? [];
+ overrides.push({
+ files: [ARCHGATE_RULES_GLOB],
+ rules: { [ruleName]: "off" },
+ });
+ config.overrides = overrides;
+ await Bun.write(configPath, `${JSON.stringify(config, null, 2)}\n`);
+}
+
+async function ensureOxlintOverride(projectRoot: string): Promise {
+ await addJsonOverride(
+ join(projectRoot, ".oxlintrc.json"),
+ TRIPLE_SLASH_RULE_OXLINT
+ );
+}
+
+async function ensureEslintrcOverride(projectRoot: string): Promise {
+ await addJsonOverride(
+ join(projectRoot, ".eslintrc.json"),
+ TRIPLE_SLASH_RULE_ESLINT
+ );
+}
+
/**
* Attempt to install the archgate plugin using stored credentials.
* Returns null-safe result — never throws.
diff --git a/src/helpers/rules-shim.ts b/src/helpers/rules-shim.ts
new file mode 100644
index 00000000..1345f927
--- /dev/null
+++ b/src/helpers/rules-shim.ts
@@ -0,0 +1,99 @@
+import { logDebug } from "./log";
+import { projectPath } from "./paths";
+
+/**
+ * Generate ambient TypeScript type definitions for rules.
+ * Uses `declare` (no `export`) so types are available globally
+ * via a triple-slash reference — no imports needed.
+ */
+export function generateRulesDts(): string {
+ return `/** @generated by archgate — do not edit */
+
+declare type Severity = "error" | "warning" | "info";
+
+declare interface GrepMatch {
+ file: string;
+ line: number;
+ column: number;
+ content: string;
+}
+
+declare interface ViolationDetail {
+ ruleId: string;
+ adrId: string;
+ message: string;
+ file?: string;
+ line?: number;
+ fix?: string;
+ severity: Severity;
+}
+
+declare interface RuleReport {
+ violation(
+ detail: Omit
+ ): void;
+ warning(
+ detail: Omit
+ ): void;
+ info(
+ detail: Omit
+ ): void;
+}
+
+declare interface RuleContext {
+ projectRoot: string;
+ scopedFiles: string[];
+ changedFiles: string[];
+ glob(pattern: string): Promise;
+ grep(file: string, pattern: RegExp): Promise;
+ grepFiles(pattern: RegExp, fileGlob: string): Promise;
+ readFile(path: string): Promise;
+ readJSON(path: string): Promise;
+ report: RuleReport;
+}
+
+declare interface RuleConfig {
+ description: string;
+ severity?: Severity;
+ check: (ctx: RuleContext) => Promise;
+}
+
+declare type RuleSet = { rules: Record };
+`;
+}
+
+/**
+ * Generate the companion .rules.ts template content for new ADRs.
+ */
+export function generateRulesTemplate(): string {
+ return `///
+
+export default {
+ rules: {
+ // "rule-name": {
+ // description: "What this rule checks",
+ // async check(ctx) {
+ // // Use ctx.scopedFiles, ctx.readFile(), ctx.grep(), etc.
+ // // ctx.report.violation({ message: "..." });
+ // },
+ // },
+ },
+} satisfies RuleSet;
+`;
+}
+
+/**
+ * Write rules.d.ts to the .archgate/ directory.
+ * Always overwrites — idempotent.
+ */
+export async function writeRulesShim(projectRoot: string): Promise {
+ const dtsPath = projectPath(projectRoot, "rules.d.ts");
+ await Bun.write(dtsPath, generateRulesDts());
+ logDebug("Rules type definitions written:", dtsPath);
+}
+
+/**
+ * Alias for writeRulesShim — always overwrites to ensure freshness
+ * when the CLI version changes.
+ */
+export const ensureRulesShim = writeRulesShim;
diff --git a/tests/commands/check.test.ts b/tests/commands/check.test.ts
index ece45dd0..ea2bd5b7 100644
--- a/tests/commands/check.test.ts
+++ b/tests/commands/check.test.ts
@@ -7,16 +7,6 @@ import { loadRuleAdrs } from "../../src/engine/loader";
import { getExitCode } from "../../src/engine/reporter";
import { runChecks } from "../../src/engine/runner";
-// Absolute path to the real defineRules module (forward slashes for import specifiers)
-const RULES_MODULE_PATH = join(
- import.meta.dir,
- "..",
- "..",
- "src",
- "formats",
- "rules.ts"
-).replaceAll("\\", "/");
-
describe("check command integration", () => {
let tempDir: string;
@@ -36,7 +26,6 @@ describe("check command integration", () => {
});
test("exits 0 when all rules pass", async () => {
- // ADR with a rule that always passes
writeFileSync(
join(tempDir, ".archgate", "adrs", "TEST-001-passing.md"),
`---
@@ -51,13 +40,14 @@ rules: true
);
writeFileSync(
join(tempDir, ".archgate", "adrs", "TEST-001-passing.rules.ts"),
- `import { defineRules } from "${RULES_MODULE_PATH}";
-export default defineRules({
- "always-pass": {
- description: "Always passes",
- async check() {},
+ `export default {
+ rules: {
+ "always-pass": {
+ description: "Always passes",
+ async check() {},
+ },
},
-});
+};
`
);
@@ -84,24 +74,25 @@ files: ["src/**/*.ts"]
);
writeFileSync(
join(tempDir, ".archgate", "adrs", "TEST-002-failing.rules.ts"),
- `import { defineRules } from "${RULES_MODULE_PATH}";
-export default defineRules({
- "no-console": {
- description: "No console.log",
- async check(ctx) {
- for (const file of ctx.scopedFiles) {
- const matches = await ctx.grep(file, /console\\.log/);
- for (const m of matches) {
- ctx.report.violation({
- message: "Found console.log",
- file: m.file,
- line: m.line,
- });
+ `export default {
+ rules: {
+ "no-console": {
+ description: "No console.log",
+ async check(ctx) {
+ for (const file of ctx.scopedFiles) {
+ const matches = await ctx.grep(file, /console\\.log/);
+ for (const m of matches) {
+ ctx.report.violation({
+ message: "Found console.log",
+ file: m.file,
+ line: m.line,
+ });
+ }
}
- }
+ },
},
},
-});
+};
`
);
@@ -126,13 +117,14 @@ rules: true
);
writeFileSync(
join(tempDir, ".archgate", "adrs", "TEST-003-filter.rules.ts"),
- `import { defineRules } from "${RULES_MODULE_PATH}";
-export default defineRules({
- "filtered": {
- description: "Filtered rule",
- async check() {},
+ `export default {
+ rules: {
+ "filtered": {
+ description: "Filtered rule",
+ async check() {},
+ },
},
-});
+};
`
);
diff --git a/tests/engine/loader.test.ts b/tests/engine/loader.test.ts
index 0eba7ee5..34a49743 100644
--- a/tests/engine/loader.test.ts
+++ b/tests/engine/loader.test.ts
@@ -11,16 +11,6 @@ import { join } from "node:path";
import { loadRuleAdrs } from "../../src/engine/loader";
-// Absolute path to the real defineRules module (forward slashes for import specifiers)
-const RULES_MODULE_PATH = join(
- import.meta.dir,
- "..",
- "..",
- "src",
- "formats",
- "rules.ts"
-).replaceAll("\\", "/");
-
describe("loadRuleAdrs", () => {
let tempDir: string;
@@ -38,13 +28,14 @@ describe("loadRuleAdrs", () => {
function writeRulesTs(adrsDir: string, baseName: string) {
writeFileSync(
join(adrsDir, `${baseName}.rules.ts`),
- `import { defineRules } from "${RULES_MODULE_PATH}";
-export default defineRules({
- "sample-rule": {
- description: "Sample rule",
- async check(ctx) {},
+ `export default {
+ rules: {
+ "sample-rule": {
+ description: "Sample rule",
+ async check(ctx) {},
+ },
},
-});
+};
`
);
}
@@ -73,14 +64,15 @@ export default defineRules({
expect(loaded).toHaveLength(0);
});
- test("warns and skips when companion file is missing", async () => {
+ test("throws when companion file is missing", () => {
copyFileSync(
join(fixturesDir, "TEST-004-missing-companion.md"),
join(tempDir, ".archgate", "adrs", "TEST-004-missing-companion.md")
);
- const loaded = await loadRuleAdrs(tempDir);
- expect(loaded).toHaveLength(0);
+ expect(loadRuleAdrs(tempDir)).rejects.toThrow(
+ "has rules: true but no companion file found"
+ );
});
test("filters by ADR ID", async () => {
diff --git a/tests/engine/runner.test.ts b/tests/engine/runner.test.ts
index 55f7035e..e0382a9e 100644
--- a/tests/engine/runner.test.ts
+++ b/tests/engine/runner.test.ts
@@ -6,7 +6,7 @@ import { join } from "node:path";
import type { LoadedAdr } from "../../src/engine/loader";
import { runChecks } from "../../src/engine/runner";
import type { AdrDocument } from "../../src/formats/adr";
-import { defineRules } from "../../src/formats/rules";
+import type { RuleSet } from "../../src/formats/rules";
describe("runChecks", () => {
let tempDir: string;
@@ -20,9 +20,11 @@ describe("runChecks", () => {
rmSync(tempDir, { recursive: true, force: true });
});
+ const EMPTY_RULE_SET: RuleSet = { rules: {} };
+
function makeLoadedAdr(
overrides: Partial = {},
- ruleSet = defineRules({})
+ ruleSet: RuleSet = EMPTY_RULE_SET
): LoadedAdr {
return {
adr: {
@@ -45,25 +47,27 @@ describe("runChecks", () => {
const loaded = makeLoadedAdr(
{ files: ["src/**/*.ts"] },
- defineRules({
- "no-console": {
- description: "No console.log",
- async check(ctx) {
- const results = await Promise.all(
- ctx.scopedFiles.map((file) => ctx.grep(file, /console\.log/))
- );
- for (const matches of results) {
- for (const m of matches) {
- ctx.report.violation({
- message: "Found console.log",
- file: m.file,
- line: m.line,
- });
+ {
+ rules: {
+ "no-console": {
+ description: "No console.log",
+ async check(ctx) {
+ const results = await Promise.all(
+ ctx.scopedFiles.map((file) => ctx.grep(file, /console\.log/))
+ );
+ for (const matches of results) {
+ for (const m of matches) {
+ ctx.report.violation({
+ message: "Found console.log",
+ file: m.file,
+ line: m.line,
+ });
+ }
}
- }
+ },
},
},
- })
+ }
);
const result = await runChecks(tempDir, [loaded]);
@@ -78,25 +82,27 @@ describe("runChecks", () => {
const loaded = makeLoadedAdr(
{ files: ["src/**/*.ts"] },
- defineRules({
- "no-console": {
- description: "No console.log",
- async check(ctx) {
- const results = await Promise.all(
- ctx.scopedFiles.map((file) => ctx.grep(file, /console\.log/))
- );
- for (const matches of results) {
- for (const m of matches) {
- ctx.report.violation({
- message: "Found console.log",
- file: m.file,
- line: m.line,
- });
+ {
+ rules: {
+ "no-console": {
+ description: "No console.log",
+ async check(ctx) {
+ const results = await Promise.all(
+ ctx.scopedFiles.map((file) => ctx.grep(file, /console\.log/))
+ );
+ for (const matches of results) {
+ for (const m of matches) {
+ ctx.report.violation({
+ message: "Found console.log",
+ file: m.file,
+ line: m.line,
+ });
+ }
}
- }
+ },
},
},
- })
+ }
);
const result = await runChecks(tempDir, [loaded]);
@@ -106,14 +112,16 @@ describe("runChecks", () => {
test("captures rule execution errors", async () => {
const loaded = makeLoadedAdr(
{},
- defineRules({
- "broken-rule": {
- description: "Throws an error",
- check() {
- throw new Error("Something went wrong");
+ {
+ rules: {
+ "broken-rule": {
+ description: "Throws an error",
+ check() {
+ throw new Error("Something went wrong");
+ },
},
},
- })
+ }
);
const result = await runChecks(tempDir, [loaded]);
@@ -125,17 +133,19 @@ describe("runChecks", () => {
const loaded = makeLoadedAdr(
{ files: ["src/**/*.ts"] },
- defineRules({
- "check-todos": {
- description: "Check TODOs",
- severity: "warning",
- check(ctx) {
- ctx.report.warning({ message: "Found a TODO" });
- ctx.report.info({ message: "Info message" });
- return Promise.resolve();
+ {
+ rules: {
+ "check-todos": {
+ description: "Check TODOs",
+ severity: "warning",
+ check(ctx) {
+ ctx.report.warning({ message: "Found a TODO" });
+ ctx.report.info({ message: "Info message" });
+ return Promise.resolve();
+ },
},
},
- })
+ }
);
const result = await runChecks(tempDir, [loaded]);
@@ -152,14 +162,16 @@ describe("runChecks", () => {
const loaded = makeLoadedAdr(
{},
- defineRules({
- "glob-test": {
- description: "Test glob",
- async check(ctx) {
- foundFiles = await ctx.glob("src/**/*.ts");
+ {
+ rules: {
+ "glob-test": {
+ description: "Test glob",
+ async check(ctx) {
+ foundFiles = await ctx.glob("src/**/*.ts");
+ },
},
},
- })
+ }
);
await runChecks(tempDir, [loaded]);
@@ -180,14 +192,16 @@ describe("runChecks", () => {
const loaded = makeLoadedAdr(
{},
- defineRules({
- "grep-test": {
- description: "Test grepFiles",
- async check(ctx) {
- matches = await ctx.grepFiles(/hello/, "src/**/*.ts");
+ {
+ rules: {
+ "grep-test": {
+ description: "Test grepFiles",
+ async check(ctx) {
+ matches = await ctx.grepFiles(/hello/, "src/**/*.ts");
+ },
},
},
- })
+ }
);
await runChecks(tempDir, [loaded]);
@@ -203,15 +217,17 @@ describe("runChecks", () => {
const loaded = makeLoadedAdr(
{},
- defineRules({
- "read-test": {
- description: "Test readFile/readJSON",
- async check(ctx) {
- fileContent = await ctx.readFile("src/data.json");
- jsonContent = await ctx.readJSON("src/data.json");
+ {
+ rules: {
+ "read-test": {
+ description: "Test readFile/readJSON",
+ async check(ctx) {
+ fileContent = await ctx.readFile("src/data.json");
+ jsonContent = await ctx.readJSON("src/data.json");
+ },
},
},
- })
+ }
);
await runChecks(tempDir, [loaded]);
@@ -222,9 +238,11 @@ describe("runChecks", () => {
test("returns results with timing info", async () => {
const loaded = makeLoadedAdr(
{},
- defineRules({
- "timing-test": { description: "Test timing", async check() {} },
- })
+ {
+ rules: {
+ "timing-test": { description: "Test timing", async check() {} },
+ },
+ }
);
const result = await runChecks(tempDir, [loaded]);
diff --git a/tests/fixtures/rules/TEST-001-sample.rules.ts b/tests/fixtures/rules/TEST-001-sample.rules.ts
index 43679aa1..afda3b73 100644
--- a/tests/fixtures/rules/TEST-001-sample.rules.ts
+++ b/tests/fixtures/rules/TEST-001-sample.rules.ts
@@ -1,40 +1,42 @@
-import { defineRules } from "../../../src/formats/rules";
+import type { RuleSet } from "../../../src/formats/rules";
-export default defineRules({
- "no-todo-comments": {
- description: "Disallow TODO comments in source files",
- severity: "warning",
- async check(ctx) {
- const matches = await Promise.all(
- ctx.scopedFiles.map((file) => ctx.grep(file, /\/\/\s*TODO/i))
- );
- for (const fileMatches of matches) {
- for (const match of fileMatches) {
- ctx.report.warning({
- message: `Found TODO comment: ${match.content.trim()}`,
- file: match.file,
- line: match.line,
- });
+export default {
+ rules: {
+ "no-todo-comments": {
+ description: "Disallow TODO comments in source files",
+ severity: "warning",
+ async check(ctx) {
+ const matches = await Promise.all(
+ ctx.scopedFiles.map((file) => ctx.grep(file, /\/\/\s*TODO/i))
+ );
+ for (const fileMatches of matches) {
+ for (const match of fileMatches) {
+ ctx.report.warning({
+ message: `Found TODO comment: ${match.content.trim()}`,
+ file: match.file,
+ line: match.line,
+ });
+ }
}
- }
+ },
},
- },
- "no-console-log": {
- description: "Disallow console.log in source files",
- async check(ctx) {
- const matches = await Promise.all(
- ctx.scopedFiles.map((file) => ctx.grep(file, /console\.log\(/))
- );
- for (const fileMatches of matches) {
- for (const match of fileMatches) {
- ctx.report.violation({
- message: "Use logInfo/logError instead of console.log",
- file: match.file,
- line: match.line,
- fix: "Replace console.log with the appropriate log helper",
- });
+ "no-console-log": {
+ description: "Disallow console.log in source files",
+ async check(ctx) {
+ const matches = await Promise.all(
+ ctx.scopedFiles.map((file) => ctx.grep(file, /console\.log\(/))
+ );
+ for (const fileMatches of matches) {
+ for (const match of fileMatches) {
+ ctx.report.violation({
+ message: "Use logInfo/logError instead of console.log",
+ file: match.file,
+ line: match.line,
+ fix: "Replace console.log with the appropriate log helper",
+ });
+ }
}
- }
+ },
},
},
-});
+} satisfies RuleSet;
diff --git a/tests/formats/rules.test.ts b/tests/formats/rules.test.ts
index 1b484036..cdb6ced0 100644
--- a/tests/formats/rules.test.ts
+++ b/tests/formats/rules.test.ts
@@ -1,69 +1,63 @@
import { describe, expect, test } from "bun:test";
-import { defineRules } from "../../src/formats/rules";
-import type { RuleConfig, RuleSet } from "../../src/formats/rules";
-
-describe("defineRules", () => {
- test("wraps rules record into a RuleSet", () => {
- const rules: Record = {
- "no-console": {
- description: "Disallow console.log",
- check: async () => {},
+import type { RuleSet } from "../../src/formats/rules";
+
+describe("RuleSet type", () => {
+ test("plain object satisfies RuleSet shape", () => {
+ const ruleSet: RuleSet = {
+ rules: {
+ "no-console": {
+ description: "Disallow console.log",
+ check: async () => {},
+ },
},
};
- const result = defineRules(rules);
- expect(result).toHaveProperty("rules");
- expect(result.rules).toBe(rules);
+ expect(ruleSet).toHaveProperty("rules");
+ expect(ruleSet.rules["no-console"]).toBeDefined();
});
- test("returns correct structure with multiple rules", () => {
- const result = defineRules({
- "rule-a": {
- description: "Rule A",
- severity: "warning",
- check: async () => {},
- },
- "rule-b": {
- description: "Rule B",
- severity: "error",
- check: async () => {},
- },
- "rule-c": {
- description: "Rule C",
- severity: "info",
- check: async () => {},
+ test("supports multiple rules with severity", () => {
+ const ruleSet: RuleSet = {
+ rules: {
+ "rule-a": {
+ description: "Rule A",
+ severity: "warning",
+ check: async () => {},
+ },
+ "rule-b": {
+ description: "Rule B",
+ severity: "error",
+ check: async () => {},
+ },
+ "rule-c": {
+ description: "Rule C",
+ severity: "info",
+ check: async () => {},
+ },
},
- });
+ };
- expect(Object.keys(result.rules)).toEqual(["rule-a", "rule-b", "rule-c"]);
- expect(result.rules["rule-a"].severity).toBe("warning");
- expect(result.rules["rule-b"].severity).toBe("error");
- expect(result.rules["rule-c"].severity).toBe("info");
+ expect(Object.keys(ruleSet.rules)).toEqual(["rule-a", "rule-b", "rule-c"]);
+ expect(ruleSet.rules["rule-a"].severity).toBe("warning");
+ expect(ruleSet.rules["rule-b"].severity).toBe("error");
+ expect(ruleSet.rules["rule-c"].severity).toBe("info");
});
- test("defaults severity to undefined (engine applies 'error' default)", () => {
- const result = defineRules({
- "my-rule": { description: "A rule", check: async () => {} },
- });
+ test("severity defaults to undefined (engine applies 'error' default)", () => {
+ const ruleSet: RuleSet = {
+ rules: { "my-rule": { description: "A rule", check: async () => {} } },
+ };
- expect(result.rules["my-rule"].severity).toBeUndefined();
+ expect(ruleSet.rules["my-rule"].severity).toBeUndefined();
});
test("preserves check function references", () => {
const checkFn = async () => {};
- const result = defineRules({
- "test-rule": { description: "Test", check: checkFn },
- });
-
- expect(result.rules["test-rule"].check).toBe(checkFn);
- });
-
- test("satisfies RuleSet type", () => {
- const result: RuleSet = defineRules({
- "typed-rule": { description: "Typed", check: async () => {} },
- });
+ const ruleSet: RuleSet = {
+ rules: { "test-rule": { description: "Test", check: checkFn } },
+ };
- expect(result.rules).toBeDefined();
+ expect(ruleSet.rules["test-rule"].check).toBe(checkFn);
});
});
diff --git a/tests/helpers/adr-writer.test.ts b/tests/helpers/adr-writer.test.ts
index 67cca05f..7c5f9655 100644
--- a/tests/helpers/adr-writer.test.ts
+++ b/tests/helpers/adr-writer.test.ts
@@ -20,19 +20,10 @@ import {
} from "../../src/helpers/adr-writer";
describe("slugify", () => {
- test("converts title to lowercase kebab-case", () => {
+ test("converts to lowercase kebab-case and handles edge cases", () => {
expect(slugify("Use PostgreSQL")).toBe("use-postgresql");
- });
-
- test("replaces multiple non-alphanumeric chars with single dash", () => {
expect(slugify("Hello World!!!")).toBe("hello-world");
- });
-
- test("strips leading and trailing dashes", () => {
expect(slugify("--trimmed--")).toBe("trimmed");
- });
-
- test("handles single word", () => {
expect(slugify("Monorepo")).toBe("monorepo");
});
});
@@ -48,28 +39,18 @@ describe("getNextId", () => {
rmSync(tempDir, { recursive: true, force: true });
});
- test("returns 001 when directory does not exist", () => {
- const id = getNextId(join(tempDir, "nonexistent"), "BE");
- expect(id).toBe("BE-001");
- });
-
- test("returns 001 when directory is empty", () => {
+ test("returns 001 for nonexistent or empty directory", () => {
+ expect(getNextId(join(tempDir, "nonexistent"), "BE")).toBe("BE-001");
mkdirSync(tempDir, { recursive: true });
- const id = getNextId(tempDir, "FE");
- expect(id).toBe("FE-001");
+ expect(getNextId(tempDir, "FE")).toBe("FE-001");
});
- test("increments from the highest existing number", () => {
+ test("increments from highest and ignores different prefixes", () => {
writeFileSync(join(tempDir, "GEN-001-example.md"), "");
writeFileSync(join(tempDir, "GEN-003-third.md"), "");
- const id = getNextId(tempDir, "GEN");
- expect(id).toBe("GEN-004");
- });
-
- test("ignores files with different prefixes", () => {
+ expect(getNextId(tempDir, "GEN")).toBe("GEN-004");
writeFileSync(join(tempDir, "BE-005-backend.md"), "");
- const id = getNextId(tempDir, "FE");
- expect(id).toBe("FE-001");
+ expect(getNextId(tempDir, "FE")).toBe("FE-001");
});
});
@@ -97,27 +78,17 @@ describe("buildAdrContent", () => {
expect(doc.body).toContain("Custom content here.");
});
- test("includes files in frontmatter when provided with body", () => {
+ test("includes files and rules in frontmatter when provided with body", () => {
const content = buildAdrContent({
id: "FE-001",
title: "With Files",
domain: "frontend",
body: "# Body",
files: ["src/**/*.tsx"],
+ rules: true,
});
const doc = parseAdr(content, "FE-001-with-files.md");
expect(doc.frontmatter.files).toEqual(["src/**/*.tsx"]);
- });
-
- test("sets rules field when provided with body", () => {
- const content = buildAdrContent({
- id: "ARCH-001",
- title: "With Rules",
- domain: "architecture",
- body: "# Body",
- rules: true,
- });
- const doc = parseAdr(content, "ARCH-001-with-rules.md");
expect(doc.frontmatter.rules).toBe(true);
});
});
@@ -163,6 +134,36 @@ describe("createAdrFile", () => {
});
expect(result.id).toBe("FE-002");
});
+
+ test("generates companion .rules.ts only when rules is true", async () => {
+ const withRules = await createAdrFile(tempDir, {
+ title: "With Rules",
+ domain: "architecture",
+ body: "## Context\n\nSome context.",
+ rules: true,
+ });
+ const rulesPath = withRules.filePath.replace(".md", ".rules.ts");
+ expect(existsSync(rulesPath)).toBe(true);
+ const content = await Bun.file(rulesPath).text();
+ expect(content).toContain('/// ');
+ expect(content).toContain("satisfies RuleSet");
+
+ const noRules = await createAdrFile(tempDir, {
+ title: "No Rules",
+ domain: "general",
+ body: "## Context\n\nCtx.",
+ rules: false,
+ });
+ expect(existsSync(noRules.filePath.replace(".md", ".rules.ts"))).toBe(
+ false
+ );
+
+ const def = await createAdrFile(tempDir, {
+ title: "Default",
+ domain: "general",
+ });
+ expect(existsSync(def.filePath.replace(".md", ".rules.ts"))).toBe(false);
+ });
});
describe("findAdrFileById", () => {
@@ -174,22 +175,16 @@ describe("findAdrFileById", () => {
rmSync(tempDir, { recursive: true, force: true });
});
- test("returns null when directory does not exist", async () => {
- const result = await findAdrFileById(
- join(tempDir, "nonexistent"),
- "GEN-001"
- );
- expect(result).toBeNull();
- });
-
- test("returns null when ADR ID is not found", async () => {
+ test("returns null when directory missing or ID not found", async () => {
+ expect(
+ await findAdrFileById(join(tempDir, "nonexistent"), "GEN-001")
+ ).toBeNull();
await createAdrFile(tempDir, {
title: "Existing",
domain: "general",
- body: "## Context\n\nSome context.",
+ body: "## Context\n\nCtx.",
});
- const result = await findAdrFileById(tempDir, "GEN-999");
- expect(result).toBeNull();
+ expect(await findAdrFileById(tempDir, "GEN-999")).toBeNull();
});
test("returns the matching AdrDocument", async () => {
diff --git a/tests/helpers/init-project.test.ts b/tests/helpers/init-project.test.ts
index 849e7344..e6ad8f4e 100644
--- a/tests/helpers/init-project.test.ts
+++ b/tests/helpers/init-project.test.ts
@@ -125,4 +125,65 @@ describe("initProject", () => {
join(tempDir, ".claude", "settings.local.json")
);
});
+
+ test("generates rules.d.ts in .archgate/", async () => {
+ await initProject(tempDir);
+
+ const dtsPath = join(tempDir, ".archgate", "rules.d.ts");
+ expect(existsSync(dtsPath)).toBe(true);
+
+ const dtsContent = await Bun.file(dtsPath).text();
+ expect(dtsContent).toContain("declare interface RuleContext");
+ });
+
+ test("adds rules.d.ts to .gitignore", async () => {
+ await initProject(tempDir);
+
+ const gitignorePath = join(tempDir, ".gitignore");
+ expect(existsSync(gitignorePath)).toBe(true);
+
+ const content = await Bun.file(gitignorePath).text();
+ expect(content).toContain(".archgate/rules.d.ts");
+ });
+
+ test("does not duplicate .gitignore entries on re-init", async () => {
+ await initProject(tempDir);
+ await initProject(tempDir);
+
+ const content = await Bun.file(join(tempDir, ".gitignore")).text();
+ const dtsCount = content.split(".archgate/rules.d.ts").length - 1;
+ expect(dtsCount).toBe(1);
+ });
+
+ test("adds oxlint override for triple-slash-reference", async () => {
+ await Bun.write(join(tempDir, ".oxlintrc.json"), '{"rules":{}}');
+ await initProject(tempDir);
+
+ const config = await Bun.file(join(tempDir, ".oxlintrc.json")).json();
+ expect(config.overrides).toHaveLength(1);
+ expect(config.overrides[0].files).toEqual([".archgate/adrs/*.rules.ts"]);
+ expect(config.overrides[0].rules["typescript/triple-slash-reference"]).toBe(
+ "off"
+ );
+ });
+
+ test("adds eslintrc override for triple-slash-reference", async () => {
+ await Bun.write(join(tempDir, ".eslintrc.json"), '{"rules":{}}');
+ await initProject(tempDir);
+
+ const config = await Bun.file(join(tempDir, ".eslintrc.json")).json();
+ expect(config.overrides).toHaveLength(1);
+ expect(
+ config.overrides[0].rules["@typescript-eslint/triple-slash-reference"]
+ ).toBe("off");
+ });
+
+ test("does not duplicate linter overrides on re-init", async () => {
+ await Bun.write(join(tempDir, ".oxlintrc.json"), "{}");
+ await initProject(tempDir);
+ await initProject(tempDir);
+
+ const config = await Bun.file(join(tempDir, ".oxlintrc.json")).json();
+ expect(config.overrides).toHaveLength(1);
+ });
});
diff --git a/tests/helpers/rules-shim.test.ts b/tests/helpers/rules-shim.test.ts
new file mode 100644
index 00000000..92739a4a
--- /dev/null
+++ b/tests/helpers/rules-shim.test.ts
@@ -0,0 +1,84 @@
+import { describe, expect, test, beforeEach, afterEach } from "bun:test";
+import { mkdtempSync, rmSync, mkdirSync, existsSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+import {
+ generateRulesDts,
+ generateRulesTemplate,
+ writeRulesShim,
+ ensureRulesShim,
+} from "../../src/helpers/rules-shim";
+
+describe("rules-shim", () => {
+ let tempDir: string;
+
+ beforeEach(() => {
+ tempDir = mkdtempSync(join(tmpdir(), "archgate-rules-shim-test-"));
+ mkdirSync(join(tempDir, ".archgate"), { recursive: true });
+ });
+
+ afterEach(() => {
+ rmSync(tempDir, { recursive: true, force: true });
+ });
+
+ test("generateRulesDts includes all ambient type declarations", () => {
+ const dts = generateRulesDts();
+ expect(dts).toContain("declare type Severity");
+ expect(dts).toContain("declare interface GrepMatch");
+ expect(dts).toContain("declare interface ViolationDetail");
+ expect(dts).toContain("declare interface RuleReport");
+ expect(dts).toContain("declare interface RuleContext");
+ expect(dts).toContain("declare interface RuleConfig");
+ expect(dts).toContain("declare type RuleSet");
+ expect(dts).toContain("@generated by archgate");
+ // Should NOT have export — ambient declarations
+ expect(dts).not.toContain("export ");
+ });
+
+ test("generateRulesTemplate uses satisfies RuleSet with triple-slash reference", () => {
+ const template = generateRulesTemplate();
+ expect(template).toContain('/// ');
+ expect(template).toContain("satisfies RuleSet");
+ expect(template).toContain("export default {");
+ // Should NOT reference defineRules
+ expect(template).not.toContain("defineRules");
+ });
+
+ test("writeRulesShim creates rules.d.ts in .archgate/", async () => {
+ await writeRulesShim(tempDir);
+
+ const dtsPath = join(tempDir, ".archgate", "rules.d.ts");
+ expect(existsSync(dtsPath)).toBe(true);
+
+ const dtsContent = await Bun.file(dtsPath).text();
+ expect(dtsContent).toContain("declare interface RuleContext");
+
+ // Should NOT create rules.js
+ expect(existsSync(join(tempDir, ".archgate", "rules.js"))).toBe(false);
+ });
+
+ test("writeRulesShim is idempotent — overwrites safely", async () => {
+ await writeRulesShim(tempDir);
+ await writeRulesShim(tempDir);
+
+ const dtsContent = await Bun.file(
+ join(tempDir, ".archgate", "rules.d.ts")
+ ).text();
+ expect(dtsContent).toContain("declare interface RuleContext");
+ });
+
+ test("ensureRulesShim always writes to keep file fresh", async () => {
+ await writeRulesShim(tempDir);
+
+ const dtsPath = join(tempDir, ".archgate", "rules.d.ts");
+
+ // Overwrite with stale content
+ await Bun.write(dtsPath, "stale");
+
+ await ensureRulesShim(tempDir);
+
+ const content = await Bun.file(dtsPath).text();
+ expect(content).toContain("declare interface RuleContext");
+ });
+});