Writing Rules
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.
.archgate/adrs/ ARCH-001-command-structure.md # The decision ARCH-001-command-structure.rules.ts # The automated checksBasic setup
Section titled “Basic setup”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.
/// <reference path="../rules.d.ts" />
export default { rules: { "my-rule-id": { description: "What this rule checks", async check(ctx) { // Your check logic here }, }, },} satisfies RuleSet;A single rules file can define multiple rules:
/// <reference path="../rules.d.ts" />
export default { rules: { "first-rule": { description: "Checks one thing", async check(ctx) { // ... }, }, "second-rule": { description: "Checks another thing", async check(ctx) { // ... }, }, },} satisfies RuleSet;The Context API
Section titled “The Context API”The ctx object passed to every check function provides file reading, searching, and reporting capabilities. Here is a detailed reference with examples.
ctx.scopedFiles
Section titled “ctx.scopedFiles”An array of file paths matching the ADR’s files glob from its frontmatter. If the ADR has no files field, this includes all project files.
for (const file of ctx.scopedFiles) { const content = await ctx.readFile(file); // Check content...}Use ctx.scopedFiles when your rule should only apply to files the ADR governs. For example, a command structure rule scoped to src/commands/**/*.ts will only receive command files.
ctx.changedFiles
Section titled “ctx.changedFiles”An array of file paths that differ from the base branch. Auto-detected by default, or populated from --staged / --base <ref>. Useful for incremental checking and cross-file dependency rules.
// Incremental checking -- only validate changed filesconst filesToCheck = ctx.scopedFiles.filter((f) => ctx.changedFiles.includes(f));
// Cross-file dependency -- if file A changed, file B must also changeif (ctx.changedFiles.includes("config/database.yml")) { if (!ctx.changedFiles.includes("deploy/manifest.yml")) { ctx.report.violation({ message: "config changed but manifest was not bumped", file: "config/database.yml", }); }}ctx.readFile(path)
Section titled “ctx.readFile(path)”Read a file’s content as a string. The path is relative to the project root.
const content = await ctx.readFile("src/cli.ts");ctx.readJSON(path)
Section titled “ctx.readJSON(path)”Read and parse a JSON file. Returns unknown — cast it to the expected shape.
const pkg = (await ctx.readJSON("package.json")) as { dependencies?: Record<string, string>;};ctx.grep(file, pattern)
Section titled “ctx.grep(file, pattern)”Search a single file with a regular expression. Returns an array of GrepMatch objects, each with file, line, column, and content properties.
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, });}ctx.grepFiles(pattern, fileGlob)
Section titled “ctx.grepFiles(pattern, fileGlob)”Search across multiple files matching a glob pattern. Returns a flat array of GrepMatch objects from all matching files. Files ignored by .gitignore are excluded by default. Set respectGitignore: false in the ADR frontmatter to include them.
const matches = await ctx.grepFiles(/TODO:/, "src/**/*.ts");for (const match of matches) { ctx.report.warning({ message: "TODO comment found", file: match.file, line: match.line, });}ctx.glob(pattern)
Section titled “ctx.glob(pattern)”Find files by glob pattern. Returns an array of file paths relative to the project root. Files ignored by .gitignore are excluded by default. Set respectGitignore: false in the ADR frontmatter to include them.
const testFiles = await ctx.glob("tests/**/*.test.ts");ctx.report
Section titled “ctx.report”The reporting interface with three severity methods:
ctx.report.violation(detail)— error severity (exit code 1, blocks CI)ctx.report.warning(detail)— warning severity (logged but does not block)ctx.report.info(detail)— informational (logged for visibility)
Each method accepts an object with:
| Field | Type | Required | Description |
| --------- | -------- | -------- | -------------------------------------- |
| message | string | Yes | What the violation is |
| file | string | No | Path to the offending file |
| line | number | No | Line number of the violation |
| fix | string | No | Suggested fix (shown to the developer) |
ctx.report.violation({ message: "Command file must export a register*Command function", file: "src/commands/check.ts", line: 5, fix: "Add: export function registerCheckCommand(program: Command) { ... }",});ctx.projectRoot
Section titled “ctx.projectRoot”The absolute path to the project root directory. Useful when you need to construct absolute paths.
Severity levels
Section titled “Severity levels”Each rule can set a default severity in its configuration. The severity determines how violations are treated:
| Severity | Exit code | Behavior |
| --------- | --------- | ------------------------------------ |
| error | 1 | Blocks CI, must be fixed |
| warning | 0 | Logged but does not block |
| info | 0 | Informational, logged for visibility |
Set the severity in the rule definition:
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.
You can also report at different severities within the same rule using ctx.report.violation(), ctx.report.warning(), and ctx.report.info() directly.
Rule timeout
Section titled “Rule timeout”Each rule has a 30-second execution timeout. If a rule exceeds this limit, it is treated as an error. This prevents runaway checks from blocking the pipeline.
Keep rules fast by:
- Using
ctx.grepFiles()instead of reading every file manually - Using
Promise.all()to check files in parallel - Scoping rules with the
filesfrontmatter field to limit the number of files processed
The fix field
Section titled “The fix field”The fix field is an optional string shown to the developer alongside the violation message. It describes what action to take to resolve the issue. Fixes are not auto-applied — they are guidance.
ctx.report.violation({ message: `Unapproved dependency: "chalk"`, file: "package.json", fix: "Use styleText() from node:util instead of chalk",});When displayed, the fix appears below the violation message:
ARCH-006/no-unapproved-deps package.json Unapproved dependency: "chalk" Fix: Use styleText() from node:util instead of chalkTips for writing rules
Section titled “Tips for writing rules”-
Use
Promise.all()for parallel file checks. When checking multiple files independently, process them in parallel instead of sequentially.// Good: parallelconst checks = files.map(async (file) => {const content = await ctx.readFile(file);// ...});await Promise.all(checks);// Avoid: sequentialfor (const file of files) {const content = await ctx.readFile(file);// ...} -
Use
ctx.changedFilesfor incremental checking.ctx.changedFilesis auto-populated with the branch diff (or staged files with--staged). Filterctx.scopedFilesagainst it to check only what changed, or use it directly for cross-file dependency rules. -
Keep rules focused on one concern. A rule that checks both naming conventions and import patterns should be split into two rules with separate IDs.
-
Use
ctx.grepFiles()over manual iteration. When searching for a pattern across many files,ctx.grepFiles()is more efficient than reading each file and running a regex. -
Provide actionable
fixmessages. A fix like “Don’t do this” is not helpful. Tell the developer exactly what to do instead. -
Filter out non-applicable files early. If your rule only applies to certain files within the scope, filter
ctx.scopedFilesbefore processing:const commandFiles = ctx.scopedFiles.filter((f) => !f.endsWith("index.ts")); -
Handle missing files gracefully. If your rule reads a specific file like
package.json, wrap the read in a try/catch and return early if the file does not exist.
Opt-out directives
Section titled “Opt-out directives”There are two ways to handle exceptions in Archgate: engine-level suppression (works with any rule automatically) and custom rule-level directives (implemented by the rule author for domain-specific opt-outs).
Engine-level suppression
Section titled “Engine-level suppression”Archgate supports inline archgate-ignore comments that suppress violations without modifying the rule itself. The engine parses these comments and filters matching violations before reporting.
Next-line suppression — suppresses the violation on the immediately following line:
// archgate-ignore ARCH-006/no-unapproved-deps legacy dep, migration planned for Q3import chalk from "chalk";File-level suppression — suppresses all matching violations anywhere in the file:
// archgate-ignore-file ARCH-005/test-mirrors-src generated file, no manual testMultiple rules — stack comments to suppress more than one rule on the same line:
// archgate-ignore ARCH-006/no-unapproved-deps legacy dep// archgate-ignore ARCH-003/use-style-text third-party lib handles colorsimport chalk from "chalk";Consecutive suppression comments all target the first non-suppression line that follows.
The format is ADR-ID/rule-id followed by a reason. The reason is required — a suppression without a reason is ignored and produces a warning:
[suppression] Suppression for ARCH-006/no-unapproved-deps is missing a reason src/foo.ts:1Both // and # comment styles are supported, so suppressions work in TypeScript, JavaScript, YAML, Python, shell scripts, and other file types your rules may scan.
Custom rule-level directives
Section titled “Custom rule-level directives”For domain-specific opt-outs, rule authors can implement their own comment-based directives inside the check function. This pattern gives the rule full control over the directive syntax, placement, and validation.
// In your .rules.ts file:async check(ctx) { const files = await ctx.glob("src/components/**/*Connected.tsx");
for (const file of files) { const content = await ctx.readFile(file);
// Support opt-out directive at the top of the file if (/^\/\/\s*@no-presentational:/u.test(content.trimStart())) continue;
// ... rule logic that may report a violation ... ctx.report.violation({ message: "Missing presentational component", file, fix: 'Add "// @no-presentational: <reason>" at the top of the file to opt out', }); }}The developer opts out by adding the directive to their file:
// @no-presentational: this component only redirects, no UI to renderimport { useNavigate } from "react-router";When to use which
Section titled “When to use which”| Approach | Best for | Who controls it |
| ----------------- | ------------------------------------------------ | ------------------------ |
| archgate-ignore | Ad-hoc exceptions for any rule | Developer using the rule |
| Custom directive | Domain-specific opt-outs with structured reasons | Rule author |
Use archgate-ignore when a developer needs to suppress a one-off violation. Use custom directives when the opt-out is a first-class concept in your rule’s domain — for example, marking a component as intentionally unpaired, or a file as auto-generated.
Next steps
Section titled “Next steps”- Common Rule Patterns — Copy-pasteable patterns organized by category: dependency management, import restrictions, file structure, code quality, database schema, and architecture boundaries.
- Rule API Reference — Full reference for all rule API types and functions.
- CI Integration — Wire
archgate checkinto your pipeline to enforce rules on every PR.