Skip to content

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 checks

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 ctx object passed to every check function provides file reading, searching, and reporting capabilities. Here is a detailed reference with examples.

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.

An array of file paths that differ from the base branch, including uncommitted working-tree changes (staged, unstaged, and untracked non-ignored files). Auto-detected by default, or populated from --staged / --base <ref>. Useful for incremental checking and cross-file dependency rules.

// Incremental checking -- only validate changed files
const filesToCheck = ctx.scopedFiles.filter((f) =>
ctx.changedFiles.includes(f)
);
// Cross-file dependency -- if file A changed, file B must also change
if (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",
});
}
}

Read a file’s content as a string. The path is relative to the project root.

const content = await ctx.readFile("src/cli.ts");

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>;
};

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,
});
}

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,
});
}

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");

Parse a source file into its language-native AST. Supported languages: "typescript", "javascript", "python", and "ruby". TypeScript and JavaScript parse in-process into an ESTree Program; Python and Ruby shell out to the system interpreter’s standard-library parser. Throws on parse failure or missing interpreter — it never returns null.

const program = await ctx.ast("src/cli.ts", "typescript");

See Structural checks with ctx.ast() below for complete examples, and the Rule API Reference for the returned shape per language.

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:

FieldTypeRequiredDescription
messagestringYesWhat the violation is
filestringNoPath to the offending file
linenumberNoLine number of the violation
fixstringNoSuggested 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) { ... }",
});

The absolute path to the project root directory. Useful when you need to construct absolute paths.

Regex works for surface patterns, but breaks down for structural questions like “does this file contain only re-exports?” or “is this a bare except: clause?” — multi-line statements, comments, and string contents all defeat line-based matching. ctx.ast() parses a file into a real syntax tree so your rule can check structure directly.

The returned tree is language-native, not unified across languages:

  • TypeScript / JavaScript — an ESTree Program parsed in-process by meriyah. TypeScript is transpiled first, so type-only syntax (interface, type aliases, export type { ... } from) is erased from the tree; a file containing only type-level statements parses to an empty Program body.
  • Python — the standard-library ast module’s tree serialized to JSON. Each node is an object with a _type field plus the node’s own fields and lineno / col_offset positions.
  • Ruby — the standard-library Ripper’s Ripper.sexp output: nested arrays like ["program", [["command", ...]]] with [line, column] position pairs.

ctx.ast() throws on parse failure or a missing interpreter — it never returns null. The throw is isolated to the failing rule and surfaces as a rule execution error (exit code 2), so a broken environment shows up as a visible failure rather than a false pass.

A barrel file is an index.ts whose every top-level statement is a re-export. With the ESTree Program, that question becomes a direct check on statement types instead of a line-matching heuristic:

/// <reference path="../rules.d.ts" />
export default {
rules: {
"no-barrel-files": {
description: "index.ts files must not be pure re-export barrels",
async check(ctx) {
const indexFiles = ctx.scopedFiles.filter((f) =>
f.endsWith("/index.ts")
);
const checks = indexFiles.map(async (file) => {
const program = await ctx.ast(file, "typescript");
const isBarrel =
program.body.length > 0 &&
program.body.every(
(node) =>
node.type === "ExportAllDeclaration" ||
(node.type === "ExportNamedDeclaration" && node.source !== null)
);
if (isBarrel) {
ctx.report.violation({
message: `Barrel file detected: ${file} contains only re-exports`,
file,
fix: "Delete the barrel and import directly from the source modules",
});
}
});
await Promise.all(checks);
},
},
},
} satisfies RuleSet;

Because TypeScript is transpiled before parsing, export type { ... } from re-exports are erased — a barrel containing only type re-exports parses to an empty body, which the body.length > 0 guard skips. If you also need to flag type-only barrels, combine the AST check with a text check. The same transpilation also shifts positions: loc line numbers refer to the transpiled text, not your original .ts file, so a "typescript" rule must re-locate the construct in the original source (for example with ctx.readFile() and indexOf) before reporting a line — or omit line.

Walk the ESTree tree for CallExpression nodes whose callee is the identifier require. A recursive walk over object values and arrays covers every node type without enumerating them:

/// <reference path="../rules.d.ts" />
function findRequireCalls(node: unknown, lines: number[]): void {
if (Array.isArray(node)) {
for (const item of node) findRequireCalls(item, lines);
return;
}
if (node === null || typeof node !== "object") return;
const n = node as EsTreeNode;
const callee = n.callee as EsTreeNode | undefined;
if (
n.type === "CallExpression" &&
callee?.type === "Identifier" &&
callee.name === "require"
) {
if (n.loc) lines.push(n.loc.start.line);
}
for (const value of Object.values(n)) findRequireCalls(value, lines);
}
export default {
rules: {
"no-require-in-esm": {
description: ".mjs files must not call CommonJS require()",
async check(ctx) {
const files = await ctx.glob("src/**/*.mjs");
const checks = files.map(async (file) => {
const program = await ctx.ast(file, "javascript");
const lines: number[] = [];
findRequireCalls(program, lines);
for (const line of lines) {
ctx.report.violation({
message: "CommonJS require() call in an ES module",
file,
line,
fix: "Use a static import or await import() instead",
});
}
});
await Promise.all(checks);
},
},
},
} satisfies RuleSet;

The loc.start.line reporting here is valid only because "javascript" parses the original source untranspiled — a "typescript" rule must locate the line in the original source instead, since its loc refers to the transpiled output.

In the Python ast module, an except: clause is an ExceptHandler node whose type field holds the caught exception expression — a bare except: has "type": null. Walk the JSON tree for that shape:

/// <reference path="../rules.d.ts" />
function findBareExcepts(node: unknown, lines: number[]): void {
if (Array.isArray(node)) {
for (const item of node) findBareExcepts(item, lines);
return;
}
if (node === null || typeof node !== "object") return;
const n = node as PythonAstNode;
if (n._type === "ExceptHandler" && n.type === null) {
if (n.lineno !== undefined) lines.push(n.lineno);
}
for (const value of Object.values(n)) findBareExcepts(value, lines);
}
export default {
rules: {
"no-bare-except": {
description: "Python code must not use bare except: clauses",
async check(ctx) {
const files = await ctx.glob("**/*.py");
const checks = files.map(async (file) => {
const tree = await ctx.ast(file, "python");
const lines: number[] = [];
findBareExcepts(tree, lines);
for (const line of lines) {
ctx.report.violation({
message:
"Bare except: catches every exception, including SystemExit",
file,
line,
fix: "Catch a specific exception class, e.g. except ValueError:",
});
}
});
await Promise.all(checks);
},
},
},
} satisfies RuleSet;

Ripper.sexp represents puts "hello" as ["command", ["@ident", "puts", [1, 0]], [...args]] — the [line, column] pair sits inside the @ident token. Walk the nested arrays for that shape:

/// <reference path="../rules.d.ts" />
function findPutsCalls(node: unknown, lines: number[]): void {
if (!Array.isArray(node)) return;
const [kind, first] = node;
if (
kind === "command" &&
Array.isArray(first) &&
first[0] === "@ident" &&
first[1] === "puts"
) {
const [line] = first[2] as [number, number];
lines.push(line);
}
for (const child of node) findPutsCalls(child, lines);
}
export default {
rules: {
"no-puts": {
description: "Ruby code must use the application logger, not puts",
async check(ctx) {
const files = await ctx.glob("app/**/*.rb");
const checks = files.map(async (file) => {
const sexp = await ctx.ast(file, "ruby");
const lines: number[] = [];
findPutsCalls(sexp, lines);
for (const line of lines) {
ctx.report.violation({
message: "puts writes to stdout directly",
file,
line,
fix: "Replace puts with logger.info",
});
}
});
await Promise.all(checks);
},
},
},
} satisfies RuleSet;

Ripper uses a different node shape for the parenthesized form — puts("hello") appears under a method_add_arg / fcall pair rather than command — so a production rule would match the fcall token the same way.

Each rule can set a default severity in its configuration. The severity determines how violations are treated:

SeverityExit codeBehavior
error1Blocks CI, must be fixed
warning0Logged but does not block
info0Informational, 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.

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 files frontmatter field to limit the number of files processed

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 chalk
  1. Use Promise.all() for parallel file checks. When checking multiple files independently, process them in parallel instead of sequentially.

    // Good: parallel
    const checks = files.map(async (file) => {
    const content = await ctx.readFile(file);
    // ...
    });
    await Promise.all(checks);
    // Avoid: sequential
    for (const file of files) {
    const content = await ctx.readFile(file);
    // ...
    }
  2. Use ctx.changedFiles for incremental checking. ctx.changedFiles is auto-populated with the branch diff plus uncommitted working-tree changes (or staged files with --staged). Filter ctx.scopedFiles against it to check only what changed, or use it directly for cross-file dependency rules.

  3. 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.

  4. 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.

  5. Provide actionable fix messages. A fix like “Don’t do this” is not helpful. Tell the developer exactly what to do instead.

  6. Filter out non-applicable files early. If your rule only applies to certain files within the scope, filter ctx.scopedFiles before processing:

    const commandFiles = ctx.scopedFiles.filter((f) => !f.endsWith("index.ts"));
  7. 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.

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).

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 Q3
import 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 test

Multiple 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 colors
import 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:1

Both // and # comment styles are supported, so suppressions work in TypeScript, JavaScript, YAML, Python, shell scripts, and other file types your rules may scan.

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 render
import { useNavigate } from "react-router";
ApproachBest forWho controls it
archgate-ignoreAd-hoc exceptions for any ruleDeveloper using the rule
Custom directiveDomain-specific opt-outs with structured reasonsRule 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.

  • 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 check into your pipeline to enforce rules on every PR.