This is the full developer documentation for madr-lint
# madr-lint
> A fast, configurable linter for MADR — Markdown Architectural Decision Records.
`madr-lint` checks that your [MADR](https://adr.github.io/madr/) files follow the conventions your team relies on — required sections, a valid status, ISO‑8601 dates, filename format, and cross‑file integrity like unique numbering and non‑broken links.
MADR v2 / v3 / v4 aware
Reads YAML frontmatter (v3/v4) **and** v2 body-list metadata — both bold (`- **Status**:`) and the canonical plain (`* Status:`) shapes. Target a version explicitly or let it auto-detect.
ESLint-style rules
Named rules (`madr/required-sections`, …) with `error` / `warn` / `off` severities and per-rule options validated by a JSON Schema.
Per-file & cross-file
Fast per-file checks (sections, status, dates, filename) plus project rules for unique numbering, the supersedes graph, and link rot.
CLI, library & Action
Run it from the command line, call it programmatically, or drop it into CI as a GitHub Action. Text, JSON, SARIF, and GitHub annotation reporters.
Autofix
`--fix` / `--fix-dry-run` mechanically repair the violations that have a safe, unambiguous correction — a misspelled status, a malformed date, a one-sided `supersedes` link.
Gradual adoption
Inline `madr-lint-disable` suppression comments for one-off exceptions, and a baseline file to snapshot legacy violations so only new ones fail CI.
## Quick look
[Section titled “Quick look”](#quick-look)
```bash
# scaffold a config — detects your ADR directory and MADR version
npx madr-lint init
# lint the ADRs in your configured adrDir (default: docs/adr)
npx madr-lint
# or point it at explicit paths
npx madr-lint docs/adr
# auto-repair what's mechanically fixable
npx madr-lint --fix
# machine-readable output for CI
npx madr-lint --format sarif
```
madr-lint.config.ts
```typescript
import { defineConfig } from 'madr-lint';
export default defineConfig({
extends: ['madr-lint:recommended'],
madrVersion: 'auto',
adrDir: 'docs/adr',
rules: {
'madr/filename-format': ['error', { pattern: '^[0-9]{4}-.+\\.md$' }],
'madr/no-numbering-gap': 'off',
},
});
```
Head to [Getting started](/madr-lint/guides/getting-started/) to install and run your first lint, or jump to [Configuration](/madr-lint/guides/configuration/) for the full set of options.
*Building an AI agent or coding assistant integration? See [llms.txt](/madr-lint/llms.txt) for a machine-readable index of these docs, or [llms-full.txt](/madr-lint/llms-full.txt) for the full text in one fetch.*
# Adopting on an existing repo
> Roll madr-lint out on a repository full of legacy ADRs without fixing every violation first — snapshot today's problems into a baseline so only new violations fail the build.
The first time you run `madr-lint` on a repo with dozens of legacy ADRs, you get hundreds of errors. Fixing them all before your first green build is a non-starter — so `madr-lint` lets you **baseline** the existing violations and enforce the rules only on *new* ones. This is the same pattern as [`tsc-baseline`](https://github.com/tvsom/tsc-baseline), ESLint bulk suppressions, and Betterer.
## The four-step adoption
[Section titled “The four-step adoption”](#the-four-step-adoption)
### 1. See where you stand
[Section titled “1. See where you stand”](#1-see-where-you-stand)
```bash
madr-lint
# → 342 errors, 17 warnings
```
### 2. Run `--fix` first
[Section titled “2. Run --fix first”](#2-run---fix-first)
Before snapshotting anything, repair what’s mechanically safe to repair — there’s no reason to freeze a violation into the baseline that autofix would otherwise have fixed for free:
```bash
madr-lint --fix
# → Fixed 83 problems
# → 259 errors, 17 warnings
```
`--fix` mutates files in place and its exit code reflects what’s *left* unfixed, not what it repaired — a non-zero exit here is normal. Only 3 of `madr-lint`’s 8 rules are fixable today (`madr/status-enum`, `madr/date-iso8601`, `madr/supersedes-bidirectional`), so this is a dent, not a solution — the baseline in the next step absorbs the rest.
### 3. Snapshot the remaining violations
[Section titled “3. Snapshot the remaining violations”](#3-snapshot-the-remaining-violations)
```bash
madr-lint --update-baseline
# → Wrote 276 violations across 53 files to .madr-lint/baseline.json
```
This writes `.madr-lint/baseline.json` and exits `0`. **Commit that file** (and the fixes from step 2, if you haven’t already).
### 4. Enforce from here on out
[Section titled “4. Enforce from here on out”](#4-enforce-from-here-on-out)
```bash
madr-lint
# → 17 problems hidden by baseline (.madr-lint/baseline.json)
# exit code 0
```
Every violation already in the baseline is subtracted. Add a brand-new one — a new ADR with a missing section, or a fresh mistake in an old file — and it fails the build as normal:
docs/adr/0060-new-decision.md
```bash
madr-lint
# error madr/required-sections Missing required section: "Consequences"
#
# 1 error
# exit code 1
```
Wire step 4 into CI and you get “no new debt” enforcement from day one, while the legacy debt waits to be paid down on your schedule.
## How the fingerprint works
[Section titled “How the fingerprint works”](#how-the-fingerprint-works)
A baselined violation is identified by `(file path, rule, messageId)` mapped to a **count** — not by line number or message text. That is deliberate: it means the baseline **survives unrelated edits**. Insert a paragraph at the top of an ADR and every downstream violation shifts down a few lines, but the baseline still absorbs them because the fingerprint never looked at lines in the first place.
The count is what catches new debt. If a file was baselined with two `missingSection` violations and a later edit introduces a third, two are absorbed and the third is reported.
See [ADR-0007](https://github.com/knktkc/madr-lint/blob/main/docs/adr/0007-baseline-fingerprint-design.md) for the full design and the alternatives we rejected.
## Paying down the debt
[Section titled “Paying down the debt”](#paying-down-the-debt)
Fix some violations, then re-snapshot:
```bash
madr-lint --update-baseline
```
The rewrite prunes anything you have fixed, so the baseline file shrinks by exactly the lines you resolved — a clean, reviewable git diff. Keys are sorted and the file uses a stable 2-space indent, so re-running `--update-baseline` never produces spurious churn.
To audit everything the baseline is hiding, run without it:
```bash
madr-lint --no-baseline
```
## Flags
[Section titled “Flags”](#flags)
| Flag | Effect |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `--update-baseline` | Run a full lint (ignoring any existing baseline), rewrite `.madr-lint/baseline.json`, print a one-line summary, exit `0`. |
| `--no-baseline` | Ignore the baseline file entirely; report every violation. |
| *(default)* | Subtract `.madr-lint/baseline.json` when it exists; no-op when it does not. |
## Notes
[Section titled “Notes”](#notes)
* The baseline lives at `.madr-lint/baseline.json`, alongside the cache directory (`.madr-lint/cache`). Commit the baseline; the cache is safe to gitignore.
* Paths in the baseline are **relative to the project root with forward slashes**, so the same file works across macOS, Linux, and Windows CI.
* Subtraction applies to both errors and warnings, and runs *after* [inline suppression](/madr-lint/guides/suppressing-rules/). Use inline `madr-lint-disable` comments for the handful of legitimate, permanent exceptions; use the baseline for bulk legacy debt you intend to pay down.
* Editing or deleting the baseline takes effect immediately — it is independent of the [content-hash cache](/madr-lint/guides/cli/#caching), which always stores pre-baseline results.
* A baseline file that exists but cannot be parsed is ignored with a one-line `stderr` warning (run `--update-baseline` to regenerate it). A missing file stays silent.
* `--format json` reports how many diagnostics were absorbed via `summary.baselineHidden` (always present; `0` when no baseline is active). SARIF output is unaffected.
* `core/internal-error` (emitted when a rule itself crashes) is **never** baselined — it signals a bug, not debt.
# AI agents
> Feed madr-lint's docs to an LLM via llms.txt, and use the adopt-madr-lint / new-adr agent skills to roll out linting or author a clean ADR without re-deriving the workflow every time.
`madr-lint` is built to be operated by an agent as readily as by a human — structured JSON output, machine-parseable exit codes, and two ready-made [agent skills](https://www.anthropic.com/engineering/equipping-agents-for-the-real-world-with-agent-skills) that encode the adoption and authoring workflows so an agent doesn’t have to re-derive them from the docs on every run.
## `llms.txt`
[Section titled “llms.txt”](#llmstxt)
The docs site publishes an [`llms.txt`](https://knktkc.github.io/madr-lint/llms.txt) index for LLMs that support the convention, plus two full-text variants for a single-fetch context dump:
| File | Contents |
| --------------------------------------------------------------------- | --------------------------------------------- |
| [`llms.txt`](https://knktkc.github.io/madr-lint/llms.txt) | index — links to the other two |
| [`llms-small.txt`](https://knktkc.github.io/madr-lint/llms-small.txt) | abridged docs, non-essential content stripped |
| [`llms-full.txt`](https://knktkc.github.io/madr-lint/llms-full.txt) | the complete English docs, concatenated |
All three are generated from the same Astro Starlight content as the docs site itself (English only — the `/ja/` tree is a translation of the same material, so duplicating it wouldn’t add information for an LLM). Point an agent at `llms-full.txt` to give it the whole reference in one fetch instead of crawling the site page by page.
## Agent skills
[Section titled “Agent skills”](#agent-skills)
Two [Claude Code skills](https://docs.claude.com/en/docs/claude-code/skills) ship in this repository at [`skills/adopt-madr-lint/`](https://github.com/knktkc/madr-lint/tree/main/skills/adopt-madr-lint) and [`skills/new-adr/`](https://github.com/knktkc/madr-lint/tree/main/skills/new-adr). They are plain `SKILL.md` files — no special runtime, no madr-lint-specific tooling beyond the CLI itself — so they work with any agent harness that reads the SKILL.md convention, not only Claude Code.
### `adopt-madr-lint`
[Section titled “adopt-madr-lint”](#adopt-madr-lint)
Walks an agent through rolling out madr-lint on a repository that may already have dozens of legacy ADRs: detect the ADR directory → install → write a config → run a first lint pass → **baseline** existing debt so only new violations fail the build → wire the GitHub Action → optionally triage a handful of exceptions with inline suppression. It’s the [Adopting on an existing repo](/madr-lint/guides/adopting-existing-repo/) guide, [CLI](/madr-lint/guides/cli/) reference, and [GitHub Action](/madr-lint/guides/github-action/) guide compiled into one mechanical, step-by-step procedure with the decision points (fix now vs. baseline, which package manager, which directory) called out explicitly.
### `new-adr`
[Section titled “new-adr”](#new-adr)
Walks an agent through authoring a brand-new ADR that passes madr-lint on the first commit: determine the next number, pick a template for the configured MADR version (v4 frontmatter by default, with a v2 body-list variant), write the file, then validate with `npx madr-lint --format json` in a loop until it reports zero diagnostics.
### Installing them in a consumer repo
[Section titled “Installing them in a consumer repo”](#installing-them-in-a-consumer-repo)
Both skills are repository content, not part of the npm package — there is no `madr-lint` CLI flag that installs them (yet; see the note below). Bring them into a project by copying the files:
```bash
curl -fsSL -o .claude/skills/adopt-madr-lint/SKILL.md --create-dirs \
https://raw.githubusercontent.com/knktkc/madr-lint/main/skills/adopt-madr-lint/SKILL.md
curl -fsSL -o .claude/skills/new-adr/SKILL.md --create-dirs \
https://raw.githubusercontent.com/knktkc/madr-lint/main/skills/new-adr/SKILL.md
```
or clone/reference this repository’s `skills/` directory directly if your agent harness supports loading skills from an arbitrary path instead of only `.claude/skills/`.
### Distribution: why manual copying, for now
[Section titled “Distribution: why manual copying, for now”](#distribution-why-manual-copying-for-now)
The natural long-term answer is `npx madr-lint init --skills`, copying both `SKILL.md` files into the consumer’s `.claude/skills/` as part of scaffolding the config. [`madr-lint init`](/madr-lint/guides/getting-started/) itself has shipped ([#30](https://github.com/knktkc/madr-lint/issues/30)) — but without a `--skills` flag, so this repo ships the skills as plain, copyable files under `skills/` in the meantime rather than blocking on that flag. This is a small enough decision to record here rather than in a dedicated ADR: revisit it when `--skills` lands.
## `--format json` for programmatic consumption
[Section titled “--format json for programmatic consumption”](#--format-json-for-programmatic-consumption)
```bash
npx madr-lint --format json
```
```json
{
"version": 1,
"summary": { "total": 1, "errors": 1, "warnings": 0, "baselineHidden": 0 },
"results": [
{
"path": "docs/adr/0003-use-postgres.md",
"ruleName": "madr/required-sections",
"messageId": "missingSection",
"severity": "error",
"message": "Missing required section: \"Consequences\"",
"suggestion": "add a \"## Consequences\" heading to the document body",
"docsUrl": "https://knktkc.github.io/madr-lint/rules/required-sections/",
"fixable": false,
"data": { "section": "Consequences", "found": ["Context and Problem Statement", "Decision Outcome"] }
}
]
}
```
The shape above is what **v0.4.0** (the latest published release at the time of writing) actually emits — `path`, `ruleName`, `messageId`, `severity`, `message`, a rule-specific `data` object, and three more fields on every result: `suggestion` (a machine-actionable fix, or `null` when the rule has none), `docsUrl` (the rule’s documentation page), and `fixable` (whether `--fix` can mechanically repair this diagnostic — `false` above, since `madr/required-sections` has no autofix). Prefer reading `suggestion` over hand-rolling a fix message from `data`. Both skills above are written against this current, published shape.
Run with `--fix` (or preview with `--fix-dry-run`) and `summary` gains a `fixed` count of how many diagnostics were repaired in place this run, e.g. `"summary": { "total": 1, "errors": 1, "warnings": 0, "baselineHidden": 0, "fixed": 1 }`.
See the [CLI](/madr-lint/guides/cli/#json) guide for the full reporter reference and the [Programmatic API](/madr-lint/guides/api/) guide for using `madr-lint` as a library instead of shelling out.
## Exit codes
[Section titled “Exit codes”](#exit-codes)
| Exit code | Meaning |
| --------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `0` | No errors; warning count within `--max-warnings` limit (if set) |
| `1` | One or more `error`-severity diagnostics, or warning count exceeds `--max-warnings` |
| `2` | Usage or configuration error (invalid `--max-warnings` value, missing `--config` file, invalid rule options, unknown `--format`) |
An agent driving `madr-lint` from a script should branch on these three codes rather than parsing stderr text — `1` means “there is linting work to do,” `2` means “the invocation itself is wrong” (bad flag, bad config, bad options), which usually means fixing the command rather than the ADRs.
# Programmatic API
> Use madr-lint as a library — parse ADRs, run rules per-file or across a project, and reuse the recommended preset.
`madr-lint` ships an ESM library entry (`madr-lint`) alongside the CLI. It is useful for building editor integrations, custom runners, or one-off scripts.
```typescript
import {
parseFile,
runRule,
runRulesOnFile,
runRulesOnProject,
buildProjectFile,
recommended,
rules,
defineConfig,
} from 'madr-lint';
```
## Parse a file
[Section titled “Parse a file”](#parse-a-file)
`parseFile` returns the YAML frontmatter, the v2 body-list metadata, the merged `metadata` view, the mdast tree, and the body.
```typescript
import { parseFile } from 'madr-lint';
const parsed = parseFile('---\nstatus: accepted\n---\n\n# ADR-0001\n');
parsed.frontmatter; // { status: 'accepted' }
parsed.metadata; // { status: 'accepted' } (frontmatter + v2 list)
parsed.ast; // mdast Root
```
## Run a single rule
[Section titled “Run a single rule”](#run-a-single-rule)
```typescript
import { runRule, rules } from 'madr-lint';
const diagnostics = runRule(
rules.statusEnum,
{ path: '0001-x.md', content: '---\nstatus: draft\n---\n\n# x\n' },
{ options: { caseSensitive: false } },
);
// → [{ ruleName: 'madr/status-enum', messageId: 'invalidStatus', ... }]
```
## Diagnostic shape
[Section titled “Diagnostic shape”](#diagnostic-shape)
Every diagnostic the runner emits is self-contained — it carries a machine-actionable fix and a docs link, so a consumer never has to reconstruct them from the rule name:
```typescript
interface Diagnostic {
ruleName: string; // e.g. 'madr/required-sections'
messageId: string; // key into the rule's `messages` map
severity: 'error' | 'warn';
path: string; // POSIX-relative file path
loc?: { line: number; column: number };
data?: Record;
suggestion: string | null; // concrete remediation, or null when the rule defines none
docsUrl: string; // rule.meta.docs.url (the repo for core/internal-error)
fixable: boolean; // whether an autofix is available for THIS diagnostic
fix?: (fixer: Fixer) => TextEdit | TextEdit[] | null; // transient; see Autofix
}
```
`suggestion` and `docsUrl` are resolved by the runner at report time from the rule’s declarative `meta.suggestions[messageId]` and `meta.docs.url`. `suggestion` is interpolated with the diagnostic’s `data`, exactly like the message; rules never build these strings imperatively.
`fixable` is a durable boolean — it is serialized to the cache and to `json` output, and the text reporter renders a `🔧 fixable` marker for it. The `fix` thunk is **transient**: a closure dropped by JSON serialization (so it is absent on cache-hydrated diagnostics) and consumed by the autofix applier. See [Autofix](#autofix).
## Run per-file rules together
[Section titled “Run per-file rules together”](#run-per-file-rules-together)
Multiple per-file rules share a single AST traversal.
```typescript
import { runRulesOnFile, rules } from 'madr-lint';
const diagnostics = runRulesOnFile(
[rules.requiredSections, rules.statusEnum],
{ path: '0001-x.md', content: fileContents },
{ severity: 'error' },
);
```
## Run project (cross-file) rules
[Section titled “Run project (cross-file) rules”](#run-project-cross-file-rules)
Cross-file rules — unique numbering, the supersedes graph, link rot — take an array of pre-parsed `ProjectFile`s built with `buildProjectFile`.
```typescript
import { runRulesOnProject, buildProjectFile, rules } from 'madr-lint';
const files = [
buildProjectFile({ path: 'docs/adr/0001-a.md', content: a }),
buildProjectFile({ path: 'docs/adr/0001-b.md', content: b }),
];
const diagnostics = runRulesOnProject(
[rules.noDuplicateNumbering],
files,
{ severity: 'error' },
);
```
## Per-rule options in a batch
[Section titled “Per-rule options in a batch”](#per-rule-options-in-a-batch)
Pass `optionsByRule` (name → options) when running several rules that each need their own options:
```typescript
runRulesOnFile([rules.filenameFormat], file, {
optionsByRule: {
'madr/filename-format': { pattern: '^ADR-[0-9]+\\.md$' },
},
});
```
## Reuse the recommended preset
[Section titled “Reuse the recommended preset”](#reuse-the-recommended-preset)
```typescript
import { recommended, defineConfig } from 'madr-lint';
recommended['madr/required-sections']; // 'error'
const config = defineConfig({
extends: ['madr-lint:recommended'],
rules: { 'madr/no-numbering-gap': 'warn' },
});
```
## Baseline
[Section titled “Baseline”](#baseline)
Build and apply a [baseline](/madr-lint/guides/adopting-existing-repo/) programmatically — the same subtraction the CLI’s `--baseline` / `--update-baseline` flags use:
```typescript
import { buildBaseline, applyBaseline, writeBaseline, baselinePath } from 'madr-lint';
const baseline = buildBaseline(diagnostics);
writeBaseline(baselinePath(process.cwd()), baseline);
// Later, on a fresh lint run:
const { kept, hidden } = applyBaseline(newDiagnostics, baseline);
```
## Autofix
[Section titled “Autofix”](#autofix)
A rule opts into autofix by declaring `meta.fixable: 'code'` and attaching a lazy `fix` thunk to `context.report(...)`. The thunk works in **body** (mdast) coordinates — the same space as `node.position.*.offset` — and the `Fixer` translates to whole-file offsets, so a fix is correct even when frontmatter was stripped.
The offset range a fix targets usually comes from `context.metadataValueLoc`: `context.metadataValueLoc[field]` yields a body-coordinate `{ start, end }` for a `metadata` key whose effective value came from the v2 leading list **and** was a single contiguous text token (no inline markup) — verified by slicing the body back to the exact value. A key whose effective value came from frontmatter instead is **absent** (frontmatter is stripped before parsing, so it has no body offset and needs YAML-aware rewriting instead) — so a fix should only attach when the range exists:
```typescript
const valueRange = context.metadataValueLoc?.status;
context.report({
messageId: 'invalidStatus',
data: { status, allowed },
// Only attach a fix when metadataValueLoc has a range to target;
// omit `fix` (or return null from the thunk) to decline.
...(valueRange && {
fix: (fixer) =>
fixer.replaceRange([valueRange.start, valueRange.end], 'accepted'),
}),
});
```
The applier primitives are exported for tooling and for cross-file fixes:
```typescript
import {
applyEdits,
makeFixer,
fixFileContent,
frontmatterOffset,
} from 'madr-lint';
// Translate body offsets past stripped frontmatter, then splice.
const fixer = makeFixer(frontmatterOffset(content)); // fileOffset = body + frontmatter
const edit = fixer.replaceRange([start, end], 'accepted'); // TextEdit (whole-file)
const fixed = applyEdits(content, [edit]); // sorted, overlaps dropped, one pass
```
`fixFileContent(content, lint)` runs the fixpoint loop for one file: it collects edits from the diagnostics your `lint` callback returns (which should already be suppression- and baseline-filtered), applies them, re-lints, and repeats up to `MAX_FIX_PASSES` (10). It returns `{ fixedContent, remaining, changed, passes, applied }`.
For **cross-file** (project-rule) fixes, `collectProjectFixes(diagnostics, contentByPath)` groups the fix edits by the target file’s `path` — project fixes operate in whole-file coordinates (they may edit YAML frontmatter), and at most one fix lands per file per pass. `applyEditsCounted(content, edits)` is the counting variant of `applyEdits`, returning `{ text, applied }` where `applied` is the number of edits that actually landed after overlap and bounds filtering.
## Exports
[Section titled “Exports”](#exports)
| Export | Description |
| -------------------------- | -------------------------------------------------------------------------- |
| `parseFile` | Parse content → frontmatter, metadata, mdast, body |
| `extractListMetadata` | Extract v2 body-list metadata from an mdast tree |
| `frontmatterOffset` | Length gray-matter strips (`fileOffset = bodyOffset + this`) |
| `applyEdits` | Apply `TextEdit`s to a string (sorted, overlaps dropped, one pass) |
| `applyEditsCounted` | `applyEdits` variant returning `{ text, applied }` (edits that landed) |
| `makeFixer` | Build a `Fixer` that translates body offsets to whole-file `TextEdit`s |
| `collectFixes` | Invoke diagnostics’ `fix` thunks → whole-file `TextEdit[]` |
| `collectProjectFixes` | Collect project-rule (cross-file) fixes, grouped by target file path |
| `fixFileContent` | Run the per-file autofix fixpoint against a `lint` callback |
| `unifiedDiff` | Render a unified diff between two strings (used by `--fix-dry-run`) |
| `MAX_FIX_PASSES` | Fixpoint iteration cap (10) |
| `runRule` | Run one per-file rule |
| `runRulesOnFile` | Run per-file rules with one AST traversal |
| `runRulesOnProject` | Run cross-file (project) rules |
| `buildProjectFile` | Pre-parse a file for project rules |
| `rules` | Namespace of built-in rules |
| `recommended` | The recommended preset’s severities |
| `defineConfig` | Type-safe config helper |
| `RuleOptionsError` | Thrown when rule options fail validation |
| `isProjectRule` | Type guard for project vs per-file rules |
| `buildBaseline` | Aggregate diagnostics into a `Baseline` (path → rule → messageId → count) |
| `applyBaseline` | Subtract a `Baseline` from a diagnostic list, returning `{ kept, hidden }` |
| `loadBaseline` | Read and parse a baseline file, or `null` if absent/malformed |
| `serializeBaseline` | Deterministically serialize a `Baseline` to JSON text |
| `writeBaseline` | Serialize and write a `Baseline` to disk, creating parent dirs |
| `baselinePath` | Resolve the absolute path to `.madr-lint/baseline.json` for a cwd |
| `BASELINE_VERSION` | Current on-disk baseline schema version |
| `INTERNAL_ERROR_RULE_NAME` | Reserved rule name for runner-thrown errors; never baselined |
Types (`Rule`, `ProjectRule`, `RuleContext`, `Diagnostic`, `RuleSeverity`, `Baseline`, `BaselineApplyResult`, …) are exported for authoring custom rules and tooling.
# CLI
> The madr-lint command-line interface — arguments, flags, reporters and exit codes.
```bash
madr-lint [OPTIONS] [PATHS...]
madr-lint init [OPTIONS]
```
## Arguments
[Section titled “Arguments”](#arguments)
### `PATHS`
[Section titled “PATHS”](#paths)
One or more files or directories to lint. Directories are searched recursively for `.md` files.
When omitted, `madr-lint` lints the configured `adrDir` (default: `docs/adr`).
```bash
# lint the configured adrDir
madr-lint
# lint explicit paths
madr-lint docs/adr docs/decisions/0007-use-x.md
```
## Options
[Section titled “Options”](#options)
| Flag | Default | Description |
| ------------------------------ | ------------------ | ------------------------------------------------------------------------------------------- |
| `--format ` | `text` | Reporter: `text`, `json`, `sarif`, or `github`. |
| `--quiet` | off | Report errors only; suppress warnings from output. |
| `--max-warnings ` | (none) | Exit 1 when warning count exceeds `n`. `0` means any warning fails CI. Negative = no limit. |
| `--config ` | (auto) | Load exactly this config file (TS or JSON), bypassing discovery. |
| `--cache` / `--no-cache` | `--cache` | Use the per-file content-hash cache. |
| `--cache-dir ` | `.madr-lint/cache` | Cache directory. |
| `--baseline` / `--no-baseline` | `--baseline` | Subtract `.madr-lint/baseline.json` when present. |
| `--update-baseline` | | Rewrite `.madr-lint/baseline.json` from a full lint, then exit `0`. |
| `--fix` | off | Apply autofixes in place, then report the problems that remain. |
| `--fix-dry-run` | off | Print a unified diff of the fixes `--fix` would apply; write nothing. |
| `--help` | | Show help. |
| `--version` | | Print the version. |
CLI flags win over the config file — e.g. `--no-cache` overrides `cache: true`.
### `--quiet` × `--max-warnings` interplay
[Section titled “--quiet × --max-warnings interplay”](#--quiet----max-warnings-interplay)
`--quiet` filters warnings from **output** but the original warning count is still used for the `--max-warnings` threshold — mirroring ESLint’s documented semantics. This lets you run `--quiet --max-warnings 0` to keep CI logs free of warning noise while still failing the build when warnings exist.
When the threshold is exceeded, the reason is printed to **stderr** for every `--format`, so stdout payloads stay clean for machine consumers:
```text
madr-lint: 3 warning(s) found, exceeds --max-warnings 0
```
```bash
# CI: fail on any warning, but keep output clean
madr-lint --quiet --max-warnings 0
```
Warnings absorbed by the [baseline](/madr-lint/guides/adopting-existing-repo/) do **not** count toward `--max-warnings` — the baseline is subtracted before the threshold is checked, so inherited debt never fails CI. Only fresh warnings count. `--update-baseline` always exits 0, regardless of `--quiet` or `--max-warnings`.
## `madr-lint init`
[Section titled “madr-lint init”](#madr-lint-init)
Scaffold a config file. Non-interactive by design — every decision is a filesystem heuristic or a flag — so it is safe in CI and behind pipes:
```bash
npx madr-lint init
```
`init` detects three things and writes a config extending `madr-lint:recommended`:
* **ADR directory** — the first of `docs/adr`, `docs/decisions`, `doc/adr`, `adr`, `docs/architecture/decisions` whose top level contains at least one `NNNN-*.md` file. When none qualifies it falls back to `docs/adr` (the linter’s default) and says so.
* **MADR version** — samples up to 20 existing ADRs and lets the majority win: YAML frontmatter with `decision-makers` counts as v4, other frontmatter as v3, a v2 metadata list as v2. An empty directory, a tie, or no recognizable metadata yields `auto` (the default, so it is omitted from the written config).
* **Config format** — `madr-lint.config.ts` when the project looks TypeScript-ish (a `tsconfig.json`, or `typescript` among `package.json` dependencies), `.madrlintrc.json` otherwise.
`init` refuses to overwrite an existing config file (exit `2`); pass `--force` to replace it. After writing, it runs a cheap in-process lint of the detected directory — when that finds violations, the next-steps output suggests [`--update-baseline`](/madr-lint/guides/adopting-existing-repo/) so legacy debt does not block adoption.
### Flags
[Section titled “Flags”](#flags)
| Flag | Default | Description |
| -------------- | ---------- | -------------------------------------------------------------------------------------------------------------- |
| `--force` | off | Overwrite an existing config file instead of exiting `2`. |
| `--dir ` | (detected) | ADR directory to write into the config, overriding detection. |
| `--json` | off | Emit a machine-readable JSON summary (what was detected and written) instead of text — for agents and scripts. |
```bash
# monorepo: point the config at a specific package's ADRs
npx madr-lint init --dir services/api/docs/adr
# machine-readable summary
npx madr-lint init --json
```
The `--json` payload reports `written`, `configPath`, `configFormat`, `adrDir`, `adrDirSource` (`detected` / `fallback` / `override`), `madrVersion`, `filesChecked`, `errors`, `warnings`, `suggestUpdateBaseline`, and `docsUrl` (the getting-started guide).
## Autofix
[Section titled “Autofix”](#autofix)
Some diagnostics are **mechanically fixable**. `madr-lint` marks them with a dim `🔧 fixable` tag in `text` output and a `"fixable": true` field in `json`.
```bash
# apply fixes in place, then report anything left over
madr-lint --fix
# preview the exact changes without touching any file
madr-lint --fix-dry-run
```
`--fix` rewrites files (only those that actually change), then re-lints the fixed content and reports the **remaining** problems — the exit code reflects what is left, so `--fix` in CI still fails on anything a fix could not resolve. `--fix-dry-run` applies the same fixes in memory and shows a per-file unified diff, writing nothing; its exit code is what `--fix` would have produced. If both flags are given, `--fix-dry-run` wins (nothing is written).
Where the dry-run diff goes depends on `--format`, so machine-readable stdout is never polluted: `text` prints it to stdout (below); `json` embeds it in the payload as a top-level `diffs` array (see [`json`](#json)); `sarif` / `github` send it to stderr so their stdout stays parseable.
```text
--- a/docs/adr/0003-use-postgres.md
+++ b/docs/adr/0003-use-postgres.md
@@ -1,3 +1,3 @@
# ADR-0003
-- Status: Accepted
+- Status: accepted
✓ All clear.
1 problem fixable (dry run; no files written)
```
Fixing composes with the other flags:
* `--fix` + `--quiet` / `--max-warnings` operate on the **remaining** diagnostics.
* **Suppressed** ([`madr-lint-disable`](/madr-lint/guides/suppressing-rules/)) and **baselined** ([`.madr-lint/baseline.json`](/madr-lint/guides/adopting-existing-repo/)) problems are never rewritten — a fix you chose to keep stays put.
* `--update-baseline` cannot be combined with `--fix` / `--fix-dry-run` (ambiguous intent — rewrite files vs snapshot violations); the combination exits `2`.
* The cache is bypassed while fixing; a fixed file re-enters the normal pipeline on the next run with a fresh content hash.
Rules that currently offer fixes:
* [`madr/status-enum`](/madr-lint/rules/status-enum/) — normalizes a v2 body-list status onto the configured enum (case, curated misspellings, prefix case/typo).
* [`madr/date-iso8601`](/madr-lint/rules/date-iso8601/) — normalizes an unambiguous v2 body-list date (year-first numeric, English named-month) to `YYYY-MM-DD`.
* [`madr/supersedes-bidirectional`](/madr-lint/rules/supersedes-bidirectional/) — inserts a missing back-reference into the target ADR’s existing frontmatter (the one cross-file fix).
Each rule fixes only the unambiguous cases and leaves the rest reported — the rule’s page spells out exactly what it will and will not touch. In particular, values in YAML **frontmatter** are never rewritten today.
## Reporters
[Section titled “Reporters”](#reporters)
### `text` (default)
[Section titled “text (default)”](#text-default)
Human-readable, grouped by file. Where a rule offers a concrete fix, an indented `→` line shows it; a `🔧 fixable` tag flags a diagnostic that `--fix` can repair; the rule’s documentation URL is printed once per rule per file group (never per diagnostic, so output stays compact):
```text
docs/adr/0003-use-postgres.md
error madr/date-iso8601 Date "2026-13-01" is not a valid ISO 8601 calendar date (YYYY-MM-DD)
→ use the YYYY-MM-DD calendar-date format, e.g. 2025-03-14
error madr/required-sections Missing required section: "Consequences"
→ add a "## Consequences" heading to the document body
madr/date-iso8601 https://knktkc.github.io/madr-lint/rules/date-iso8601/
madr/required-sections https://knktkc.github.io/madr-lint/rules/required-sections/
2 errors
```
### `json`
[Section titled “json”](#json)
Structured output for tooling. Each result carries `suggestion` — a machine-actionable fix, or `null` when the rule defines none for that message — `docsUrl`, the rule’s documentation URL, and `fixable`, whether `--fix` can repair it. When a fix pass ran, `summary` also carries `fixed` (the number of fixes applied). Under `--fix-dry-run`, the payload additionally carries a top-level `diffs` array — one `{ "path", "diff" }` entry per changed file, with `diff` holding the unified diff text — so stdout stays pure JSON:
```bash
madr-lint --format json
```
```json
{
"version": 1,
"summary": { "total": 1, "errors": 1, "warnings": 0, "baselineHidden": 0 },
"results": [
{
"path": "docs/adr/0003-use-postgres.md",
"ruleName": "madr/required-sections",
"messageId": "missingSection",
"severity": "error",
"message": "Missing required section: \"Consequences\"",
"suggestion": "add a \"## Consequences\" heading to the document body",
"docsUrl": "https://knktkc.github.io/madr-lint/rules/required-sections/",
"fixable": false,
"data": { "section": "Consequences", "found": ["Context and Problem Statement", "Decision Outcome"] }
}
]
}
```
### `sarif`
[Section titled “sarif”](#sarif)
[SARIF](https://sariftools.github.io/sarif-spec/) for code-scanning integrations (e.g. GitHub code scanning):
```bash
madr-lint --format sarif > madr-lint.sarif
```
## Exit codes
[Section titled “Exit codes”](#exit-codes)
| Exit code | Meaning |
| --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `0` | No errors; warning count within `--max-warnings` limit (if set) |
| `1` | One or more `error`-severity diagnostics, or warning count exceeds `--max-warnings`. With `--fix` / `--fix-dry-run`, this reflects the problems that **remain** after fixing |
| `2` | Usage or configuration error (invalid `--max-warnings` value, missing `--config` file, invalid rule options, unknown `--format`, `--update-baseline` combined with `--fix`, existing config on `madr-lint init` without `--force`) |
## Caching
[Section titled “Caching”](#caching)
The cache stores per-file diagnostics keyed by content hash and is invalidated when the package version or resolved config changes. Cross-file rules always re-run.
```bash
# force a clean run
madr-lint --no-cache
# use a custom cache directory
madr-lint --cache-dir .cache/madr-lint
```
## Baseline
[Section titled “Baseline”](#baseline)
Adopting `madr-lint` on a repo that already has violations? Snapshot them into `.madr-lint/baseline.json` so only *new* violations fail the build:
```bash
# snapshot today's violations and commit the file
madr-lint --update-baseline
# subsequent runs subtract the baseline automatically
madr-lint
# audit everything, ignoring the baseline
madr-lint --no-baseline
```
Subtraction runs after the cache and after inline suppression, and never touches the cache — so editing or deleting the baseline takes effect immediately. See the [Adopting on an existing repo](/madr-lint/guides/adopting-existing-repo/) guide for the full workflow.
# Configuration
> Configure madr-lint with a config file — presets, MADR version, adrDir, ignore patterns, cache, and per-rule severities and options.
`madr-lint` is configured with a config file at the root of your project. A TypeScript config (`madr-lint.config.ts`) is canonical; JSON is supported as a fallback.
## Config file resolution
[Section titled “Config file resolution”](#config-file-resolution)
The CLI looks for the first file that exists, in this order:
```plaintext
.madrlintrc.json
.madrlintrc.ts
.madrlintrc.mts
.madrlintrc.js
.madrlintrc.mjs
.madrlintrc.cjs
madr-lint.config.ts
madr-lint.config.mts
madr-lint.config.js
madr-lint.config.mjs
madr-lint.config.cjs
```
`.json` is parsed directly; every other extension is loaded through [`jiti`](https://github.com/unjs/jiti), so TypeScript and both module systems work without a build step.
> **Config files are code.** A TypeScript/JavaScript config is **executed** when loaded — the same trust model as ESLint configs. Only run lints with `--config` paths or config files from sources you trust.
If **no** config file is found and you don’t set any rules, the CLI falls back to the `madr-lint:recommended` preset so it is useful with zero config.
## `defineConfig`
[Section titled “defineConfig”](#defineconfig)
Use the `defineConfig` helper for type-safe autocompletion. It is the identity function at runtime.
madr-lint.config.ts
```typescript
import { defineConfig } from 'madr-lint';
export default defineConfig({
extends: ['madr-lint:recommended'],
madrVersion: 'auto',
adrDir: 'docs/adr',
ignorePatterns: ['README.md', 'template.md'],
rules: {
'madr/required-sections': 'error',
'madr/filename-format': ['error', { pattern: '^[0-9]{4}-.+\\.md$' }],
'madr/no-numbering-gap': 'off',
},
});
```
The JSON equivalent (`.madrlintrc.json`):
```json
{
"extends": ["madr-lint:recommended"],
"madrVersion": "auto",
"adrDir": "docs/adr",
"ignorePatterns": ["README.md", "template.md"],
"rules": {
"madr/required-sections": "error",
"madr/filename-format": ["error", { "pattern": "^[0-9]{4}-.+\\.md$" }],
"madr/no-numbering-gap": "off"
}
}
```
## Top-level options
[Section titled “Top-level options”](#top-level-options)
| Option | Type | Default | Description |
| ---------------- | -------------------------------- | -------------------- | -------------------------------------------------------- |
| `extends` | `string[]` | `[]` | Presets to extend. Currently `'madr-lint:recommended'`. |
| `madrVersion` | `'v2' \| 'v3' \| 'v4' \| 'auto'` | `'auto'` | Target MADR version. `auto` detects per file. |
| `adrDir` | `string` | `'docs/adr'` | Directory linted when no paths are passed on the CLI. |
| `rules` | `Record` | `{}` | Per-rule severity and options (see below). |
| `ignorePatterns` | `string[]` | `[]` | Paths to skip (see [Ignore patterns](#ignore-patterns)). |
| `cache` | `boolean` | `true` | Enable the per-file content-hash cache. |
| `cacheLocation` | `string` | `'.madr-lint/cache'` | Directory for the cache manifest. |
## Configuring rules
[Section titled “Configuring rules”](#configuring-rules)
Each entry in `rules` maps a rule name to either a **severity string** or a **tuple** of `[severity, options]`.
```typescript
rules: {
// severity only — uses the rule's default options
'madr/status-enum': 'error',
// turn a rule off
'madr/no-numbering-gap': 'off',
// severity + options
'madr/filename-format': ['error', { pattern: '^ADR-[0-9]+\\.md$' }],
}
```
Severities are `'error'`, `'warn'`, or `'off'`:
* **`error`** — reported and makes the CLI exit with code `1`.
* **`warn`** — reported but does not fail the run.
* **`off`** — the rule does not run.
Options in the tuple are merged over the rule’s `defaultOptions` and validated against the rule’s JSON Schema. **Invalid options fail fast** with a clear message and exit code `2`:
```text
Invalid rule options in config: Invalid options for rule madr/filename-format: data/pattern must be string
```
See the [Rules reference](/madr-lint/rules/) for the options each rule accepts.
## Presets
[Section titled “Presets”](#presets)
### `madr-lint:recommended`
[Section titled “madr-lint:recommended”](#madr-lintrecommended)
Enables the spec-grounded rules at sensible severities. Extend it and override individual rules as needed.
| Rule | Recommended severity |
| ------------------------------- | -------------------------------- |
| `madr/required-sections` | `error` |
| `madr/status-enum` | `error` |
| `madr/date-iso8601` | `error` |
| `madr/filename-format` | `error` |
| `madr/no-broken-links` | `error` |
| `madr/no-duplicate-numbering` | `error` |
| `madr/supersedes-bidirectional` | `error` |
| `madr/no-numbering-gap` | `off` (convention-only — opt in) |
Your `rules` entries are merged **over** the preset, so you only list what you change:
```typescript
export default defineConfig({
extends: ['madr-lint:recommended'],
rules: {
// adopt the numbering-gap convention
'madr/no-numbering-gap': 'warn',
},
});
```
## MADR version
[Section titled “MADR version”](#madr-version)
`madrVersion` selects which MADR spec the rules validate against:
* **`auto`** (default) — detect per file (frontmatter ⇒ v3/v4, body-list ⇒ v2).
* **`v2`** — metadata is a body list (`* Status:` / `- **Status**:`).
* **`v3` / `v4`** — metadata is YAML frontmatter.
Metadata-reading rules such as `madr/status-enum` and `madr/date-iso8601` read a combined view of YAML frontmatter **and** v2 body-list metadata, so they work across versions.
## Ignore patterns
[Section titled “Ignore patterns”](#ignore-patterns)
`ignorePatterns` skips files by path. Patterns are matched with [picomatch](https://github.com/micromatch/picomatch), so common forms all work:
* exact basename — `README.md`
* full project-relative path — `docs/adr/template.md`
* path suffix — `adr/template.md`
* trailing wildcard — `9999-*`
* full glob — `docs/**/draft-*.md`
```typescript
export default defineConfig({
ignorePatterns: ['README.md', 'template.md', '9999-*', 'docs/**/draft-*.md'],
});
```
## Cache
[Section titled “Cache”](#cache)
A per-file content-hash cache speeds up re-runs. It is keyed by file content and invalidated when the package version or resolved config changes. Cross-file (project) rules always re-run.
```typescript
export default defineConfig({
cache: true, // default
cacheLocation: '.madr-lint/cache',
});
```
Disable it from the CLI with `--no-cache`, or point it elsewhere with `--cache-dir`. See the [CLI reference](/madr-lint/guides/cli/).
# Getting started
> Install madr-lint, run your first lint, and wire it into your project.
`madr-lint` is a linter for [MADR](https://adr.github.io/madr/) (Markdown Architectural Decision Records). It validates ADR structure, status values, dates, filenames, and cross-file integrity.
## Install
[Section titled “Install”](#install)
Install it as a dev dependency:
```bash
# npm
npm install --save-dev madr-lint
# pnpm
pnpm add -D madr-lint
# yarn
yarn add -D madr-lint
```
Requires **Node.js 22 or newer**.
You can also run it without installing:
```bash
npx madr-lint --help
```
## Scaffold a config
[Section titled “Scaffold a config”](#scaffold-a-config)
The fastest way to get started is `init` — it detects your ADR directory (`docs/adr`, `docs/decisions`, `doc/adr`, `adr`, or `docs/architecture/decisions`), the dominant MADR version of your existing ADRs, and whether your project uses TypeScript, then writes a config extending `madr-lint:recommended`:
```bash
npx madr-lint init
```
`init` is non-interactive — every decision is a filesystem heuristic or a flag — so it is safe in CI and behind pipes. It refuses to overwrite an existing config (pass `--force` to replace it), and when the initial lint of the detected directory finds violations, it points you at `--update-baseline` so legacy debt does not block adoption. See the [CLI guide](/madr-lint/guides/cli/#madr-lint-init) for `--dir` and `--json`.
## Run your first lint
[Section titled “Run your first lint”](#run-your-first-lint)
By default `madr-lint` lints the directory configured as `adrDir` (default: `docs/adr`):
```bash
npx madr-lint
```
Or point it at explicit files or directories — directories are searched recursively for `.md` files:
```bash
npx madr-lint docs/adr docs/decisions/0007-use-x.md
```
Example output:
```text
docs/adr/0003-use-postgres.md
error madr/status-enum Status "decided" is not one of: proposed,rejected,accepted,deprecated,superseded by ...
error madr/required-sections Missing required section: "Consequences"
2 errors
```
## Enable the recommended rules
[Section titled “Enable the recommended rules”](#enable-the-recommended-rules)
Out of the box, when no rules are configured, the CLI falls back to the `madr-lint:recommended` preset. `npx madr-lint init` (above) makes that explicit for you; to author the config by hand — and to start customizing — create a config file:
madr-lint.config.ts
```typescript
import { defineConfig } from 'madr-lint';
export default defineConfig({
extends: ['madr-lint:recommended'],
adrDir: 'docs/adr',
});
```
See [Configuration](/madr-lint/guides/configuration/) for every option, and [Rules](/madr-lint/rules/) for the full rule reference.
## Exit codes
[Section titled “Exit codes”](#exit-codes)
`madr-lint` is CI-friendly:
| Exit code | Meaning |
| --------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `0` | No errors; warning count within `--max-warnings` limit (if set) |
| `1` | One or more `error`-severity diagnostics, or warning count exceeds `--max-warnings` |
| `2` | Usage or configuration error (invalid `--max-warnings` value, missing `--config` file, invalid rule options, unknown `--format`) |
## Next steps
[Section titled “Next steps”](#next-steps)
* [Configuration](/madr-lint/guides/configuration/) — config file, presets, and per-rule options
* [CLI](/madr-lint/guides/cli/) — every command-line flag
* [GitHub Action](/madr-lint/guides/github-action/) — run it in CI
* [Rules](/madr-lint/rules/) — what each rule checks and its options
* [Suppressing rules](/madr-lint/guides/suppressing-rules/) — inline `madr-lint-disable` comments
* [Adopting on an existing repo](/madr-lint/guides/adopting-existing-repo/) — baseline legacy violations
# GitHub Action
> Run madr-lint in CI with GitHub Actions, including PR annotations and SARIF upload to code scanning.
`madr-lint` ships a composite GitHub Action that emits **PR diff annotations** for every violation. Errors and warnings appear inline on the pull-request diff without any extra setup.
## Quick start
[Section titled “Quick start”](#quick-start)
.github/workflows/adr-lint.yml
```yaml
name: ADR lint
on:
pull_request:
paths:
- 'docs/adr/**'
push:
branches: [main]
jobs:
madr-lint:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22 # madr-lint requires Node ≥22
- uses: knktkc/madr-lint@v0
with:
path: docs/adr
```
> The floating `v0` tag tracks the latest v0.x release; for stricter reproducibility, pin an exact tag like `@v0.4.0`.
The action exits non-zero when there are `error`-severity diagnostics, failing the job automatically.
By default the action installs the `latest` npm dist-tag; for production workflows we recommend [pinning an exact `version:`](#examples) instead.
## Inputs
[Section titled “Inputs”](#inputs)
| Input | Default | Description |
| ------------------- | ----------------------- | -------------------------------------------------- |
| `path` | *(config / `docs/adr`)* | Path(s) to lint — file or directory |
| `version` | `latest` | npm version or dist-tag of `madr-lint` to install |
| `working-directory` | `.` | Directory to run the action in |
| `args` | *(none)* | Extra CLI arguments passed verbatim to `madr-lint` |
### Examples
[Section titled “Examples”](#examples)
**Pin a specific version:**
```yaml
- uses: knktkc/madr-lint@v0
with:
version: '0.4.0'
path: docs/adr
```
Pinning an exact `version:` makes CI deterministic and protects it from a hijacked `latest` dist-tag (supply-chain compromise) — recommended for production workflows.
**Treat warnings as errors (fail on any warning):**
```yaml
- uses: knktkc/madr-lint@v0
with:
path: docs/adr
args: '--max-warnings 0'
```
**Monorepo — lint multiple directories:**
```yaml
- uses: knktkc/madr-lint@v0
with:
path: 'services/api/docs/adr services/web/docs/adr'
```
## Prerequisites
[Section titled “Prerequisites”](#prerequisites)
The action does **not** install Node itself. Add `actions/setup-node` before it (as shown above) and set `node-version: 22` (or higher).
## Upload SARIF to code scanning (advanced)
[Section titled “Upload SARIF to code scanning (advanced)”](#upload-sarif-to-code-scanning-advanced)
For findings to appear in the **Security → Code scanning** tab — and persist beyond the PR — upload a SARIF report via the `--format sarif` flag instead:
```yaml
jobs:
madr-lint:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Lint ADRs (SARIF)
run: npx madr-lint --format sarif docs/adr > madr-lint.sarif
continue-on-error: true
- name: Upload SARIF
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: madr-lint.sarif
```
## Tips
[Section titled “Tips”](#tips)
* Scope the trigger with `paths:` so the job only runs when ADRs change.
* Commit a config file (see [Configuration](/madr-lint/guides/configuration/)) so local and CI runs agree.
* Keep the per-file cache out of CI runners (it is a local speed-up); a cold run in CI is already fast.
# Suppressing rules
> Silence a single legitimate exception with inline HTML-comment directives — disable-file, disable/enable ranges, and disable-next-line — without turning a rule off project-wide.
Sometimes one legacy ADR legitimately violates a rule — say, a historical status value that `madr/status-enum` rejects. Turning the rule `off` for the whole project to accommodate one file is a bad trade. Inline suppression comments give you a per-file, per-line escape hatch, exactly like `eslint-disable` comments do for ESLint.
Directives are ordinary HTML comments in the ADR’s Markdown body. Rules never see them — suppression is applied centrally after all rules have reported.
## The four directives
[Section titled “The four directives”](#the-four-directives)
```markdown
Suppress everything in this file.
Suppress from this line to the end of the file,
or until a matching madr-lint-enable.
Re-enable previously disabled rules.
Suppress the next (non-blank) line only.
```
## Scoping to specific rules
[Section titled “Scoping to specific rules”](#scoping-to-specific-rules)
Every form optionally takes a comma-separated list of full rule IDs. Without a list, the directive applies to **all** rules.
```markdown
- Status: superseded-but-we-spelled-it-oddly
…both rules silenced from here…
…only madr/status-enum still silenced…
```
An `enable` re-enables what it names (or everything, when unscoped) — so an unscoped `disable` followed by a scoped `enable` leaves everything else disabled, mirroring ESLint semantics.
## `disable-next-line` targets the next non-blank line
[Section titled “disable-next-line targets the next non-blank line”](#disable-next-line-targets-the-next-non-blank-line)
Unlike ESLint, `madr-lint-disable-next-line` applies to the next **non-blank** line, not the literal next line. Markdown authors idiomatically leave a blank line after a comment block, and a directive that silently missed across it would be a footgun. Both of these work:
```markdown
[archived design doc](./2019-design.md)
```
```markdown
[archived design doc](./2019-design.md)
```
## Project (cross-file) rules
[Section titled “Project (cross-file) rules”](#project-cross-file-rules)
Diagnostics from project rules (e.g. `madr/no-duplicate-numbering`) are attributed to a file; a directive in that file suppresses them:
* **File-scoped** suppression — `disable-file`, or a `disable` with no later matching `enable` — silences the file’s project diagnostics.
* **Line-scoped** suppression (`disable-next-line`, bounded `disable`/`enable`) applies when the diagnostic carries a line, as `madr/no-broken-links` diagnostics do.
## Limitations and fine print
[Section titled “Limitations and fine print”](#limitations-and-fine-print)
* **Frontmatter cannot be targeted by line.** YAML frontmatter is stripped before the Markdown body is parsed, so a diagnostic about a frontmatter value (e.g. `status:` in frontmatter) carries no line number. Line-scoped directives cannot reach it — use a file-scoped `disable` for that rule instead. Values from the MADR v2 metadata list live in the body and CAN be targeted by line.
* **Diagnostics without a line** (e.g. `madr/filename-format`, a missing section, a missing metadata field) are only silenced by file-scoped suppression: `disable-file`, or a `disable` left open to the end of the file. A bounded `disable`/`enable` pair does not silence them.
* **A suppression comment placed between MADR v2 metadata-list items splits the list.** v2 metadata is a Markdown list (`* Status: accepted` / `* Date: 2024-1-5`); an HTML comment inserted *between* items breaks the list in two, and the v2 metadata bridge only reads up to the split — the field after the comment silently disappears from the parsed metadata. That degrades a line-suppressible `invalidDate` into an unsuppressible file-level `missingDate` (see “Diagnostics without a line” above) — a worse, unsuppressed error than the one you started with. Place directives above the whole list instead, or use `madr-lint-disable-file` for v2 files. Tracked as [#73](https://github.com/knktkc/madr-lint/issues/73).
* **One directive per comment, standing alone.** A comment that contains another comment on the same line (``) is rejected as a directive. Unknown keywords (e.g. `madr-lint-disable-line`) and ordinary HTML comments are ignored silently.
* **Stacked `disable-next-line` comments do not chain.** The first one targets the second comment’s line, not your content — put all rules in a single comma-separated list in one comment instead.
* **`core/internal-error` cannot be suppressed.** It signals a rule bug, not a finding about your ADR.
* **The cache stays correct.** Directives are part of the file content, so the content-hash cache invalidates automatically when you add or remove one.
## Prefer configuration for systematic exceptions
[Section titled “Prefer configuration for systematic exceptions”](#prefer-configuration-for-systematic-exceptions)
If you find yourself suppressing the same rule in many files, change the rule’s options or severity in your [config file](/madr-lint/guides/configuration/) instead — inline directives are for the one-off exception, not policy.
# Rules
> The built-in madr-lint rules — what each checks, whether it is per-file or cross-file, its options, and its recommended severity.
Every rule has an ESLint-style name (`madr/`) and supports the `error` / `warn` / `off` severities. Rules are either **per-file** (pure checks over one ADR) or **project** (cross-file integrity). Configure severities and options in your [config file](/madr-lint/guides/configuration/). A single legitimate exception does not need a config change — silence it inline with a [suppression comment](/madr-lint/guides/suppressing-rules/). **Fixable** rules can repair some of their violations mechanically with [`--fix`](/madr-lint/guides/cli/#autofix) — see each rule’s page for exactly which violations qualify.
## Per-file rules
[Section titled “Per-file rules”](#per-file-rules)
| Rule | Checks | Options | Fixable | Recommended |
| --------------------------------------------------------------- | ------------------------------------- | ------- | ------- | ----------- |
| [`madr/required-sections`](/madr-lint/rules/required-sections/) | Required heading sections are present | Yes | No | `error` |
| [`madr/status-enum`](/madr-lint/rules/status-enum/) | `status` is one of the allowed values | Yes | Yes | `error` |
| [`madr/date-iso8601`](/madr-lint/rules/date-iso8601/) | `date` is a valid ISO-8601 date | Yes | Yes | `error` |
| [`madr/filename-format`](/madr-lint/rules/filename-format/) | Filename matches the ADR convention | Yes | No | `error` |
## Project (cross-file) rules
[Section titled “Project (cross-file) rules”](#project-cross-file-rules)
| Rule | Checks | Options | Fixable | Recommended |
| ----------------------------------------------------------------------------- | ------------------------------------------ | ------- | ------- | ----------- |
| [`madr/no-broken-links`](/madr-lint/rules/no-broken-links/) | Relative links resolve to existing files | No | No | `error` |
| [`madr/no-duplicate-numbering`](/madr-lint/rules/no-duplicate-numbering/) | ADR numbers are unique | No | No | `error` |
| [`madr/no-numbering-gap`](/madr-lint/rules/no-numbering-gap/) | ADR numbers are contiguous (no gaps) | No | No | `off` |
| [`madr/supersedes-bidirectional`](/madr-lint/rules/supersedes-bidirectional/) | `supersedes` / `superseded-by` links agree | No | Yes | `error` |
`madr/no-numbering-gap` is a convention-only rule and is `off` in the recommended preset — enable it if your team treats ADR numbering as a contiguous sequence.
## Severity & options recap
[Section titled “Severity & options recap”](#severity--options-recap)
madr-lint.config.ts
```typescript
import { defineConfig } from 'madr-lint';
export default defineConfig({
extends: ['madr-lint:recommended'],
rules: {
'madr/status-enum': 'warn',
'madr/required-sections': ['error', { sections: ['Context', 'Decision', 'Consequences'] }],
'madr/no-numbering-gap': 'off',
},
});
```
See each rule’s page for its exact options, and [Configuration](/madr-lint/guides/configuration/) for the severity and options format.
# madr/date-iso8601
> Validate that an ADR's date field is a real ISO 8601 calendar date (YYYY-MM-DD).
Validates that an ADR’s `date` field is a valid ISO 8601 calendar date in `YYYY-MM-DD` format.
The rule reads `context.metadata[field]`, which is YAML frontmatter **merged** with v2 body-list metadata (frontmatter wins on conflict; explicit null/undefined frontmatter values are skipped). It therefore supports MADR v2 (bold `- **Date**:` and plain `* Date:` list items), v3 and v4.
`gray-matter` parses an unquoted `date: 2026-05-01` into a JavaScript `Date`; the rule normalizes a `Date` via `toISOString().slice(0, 10)`, keeps a `string` as-is, and treats anything else (null, boolean, number) as missing. Validation uses a `Date.UTC` round-trip so leap years and month lengths are handled correctly without external libraries.
## What it checks
[Section titled “What it checks”](#what-it-checks)
* `missingDate` — the configured field is absent, or its value is not a string / `Date`. Message: `Metadata does not contain a "" field (checked frontmatter and v2 bold-list)`, with `data.field`.
* `invalidDate` — the value is present but not a real `YYYY-MM-DD` date: wrong shape (`2026-5-1`, `26-05-01`, `today`) or a non-existent calendar date (`2026-13-01`, `2026-02-31`, `2025-02-29`). Message: `Date "" is not a valid ISO 8601 calendar date (YYYY-MM-DD)`, with `data.date`.
## Examples
[Section titled “Examples”](#examples)
### Valid
[Section titled “Valid”](#valid)
```markdown
---
date: 2026-05-01
---
```
Leap-year date (quoted so YAML keeps it a string):
```markdown
---
date: '2024-02-29'
---
```
### Invalid
[Section titled “Invalid”](#invalid)
| Frontmatter | Diagnostic | Reason |
| ------------------ | ------------- | ----------------------- |
| (no `date`) | `missingDate` | field absent |
| `date: 2026-13-01` | `invalidDate` | month 13 |
| `date: 2026-02-31` | `invalidDate` | February has no 31st |
| `date: 2025-02-29` | `invalidDate` | 2025 is not a leap year |
| `date: '2026-5-1'` | `invalidDate` | unpadded month/day |
| `date: '26-05-01'` | `invalidDate` | 2-digit year |
| `date: 'today'` | `invalidDate` | not a date string |
## 🔧 Autofix
[Section titled “🔧 Autofix”](#-autofix)
This rule is **fixable** (`madr-lint --fix`) — but only for **v2 body-list** dates, where the value has an exact source offset. Frontmatter dates are never rewritten (YAML-aware editing is out of scope), and only **unambiguous** shapes are normalized. Everything else is left as a report-only diagnostic.
Fixed — normalized to `YYYY-MM-DD`:
| Before | After | Shape |
| ---------------------- | -------------------- | ------------------------------------------------------ |
| `- Date: 2026/7/3` | `- Date: 2026-07-03` | year-first numeric (`/`, `.` or `-`, single separator) |
| `- Date: 3 Jul 2026` | `- Date: 2026-07-03` | day-first English named month |
| `- Date: July 3, 2026` | `- Date: 2026-07-03` | month-first English named month |
**Not** fixed (reported, never rewritten):
* **Ambiguous day/month order** — `03/07/2026` could be 3 July or 7 March; there is no safe choice, so it is never touched.
* **Two-digit years** — `26/07/03`.
* **Impossible calendar dates** — `2026/2/30`, `2026/13/01`; a fix never turns an invalid date into a *different* valid one.
* **Non-English or unknown month names** — `3 Mai 2026`.
* **Frontmatter-sourced values** — a `date:` in YAML frontmatter (fix an ISO value there by hand).
## Options
[Section titled “Options”](#options)
| Option | Type | Default | Description |
| ------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `field` | `string` | `'date'` | Metadata key to read (frontmatter or v2 body-list, with key normalization). Override for projects using e.g. `created` or `updated`. |
```ts
import { defineConfig } from 'madr-lint';
export default defineConfig({
rules: {
'madr/date-iso8601': ['error', { field: 'created' }],
},
});
```
## MADR version compatibility
[Section titled “MADR version compatibility”](#madr-version-compatibility)
| Version | Applies | Notes |
| ------- | ------- | ----------------------------------------------------------- |
| v2 | yes | body-list `- **Date**: 2026-05-01`, via the metadata bridge |
| v3 | yes | frontmatter `date: ...` |
| v4 | yes | frontmatter `date: ...` |
## When to disable
[Section titled “When to disable”](#when-to-disable)
Set `madr/date-iso8601` to `off` when migrating from a system that uses a different date format. Prefer overriding `field` to read a custom metadata key.
Like all rules, this rule can be suppressed inline — see [Suppressing rules](/madr-lint/guides/suppressing-rules/).
## Source
[Section titled “Source”](#source)
* Rule source:
* Spec:
# madr/filename-format
> Enforce the ADR filename convention NNNN-kebab-case-title.md.
Enforces the ADR filename convention `NNNN-kebab-case-title.md`.
The rule tests the file’s basename against a configurable regex. The default pattern is `^[0-9]{4}-[a-z0-9-]+\.md$`, which requires:
* `NNNN` — exactly four decimal digits, zero-padded
* a single hyphen
* one or more lowercase ASCII letters, digits, or hyphens (kebab-case)
* the `.md` extension
The pattern is compiled through the ReDoS-safety guard (`assertSafeRegex`) before use. The default is intentionally stricter than some MADR examples (it forbids uppercase letters, underscores, and non-`.md` extensions); relax it via the `pattern` option.
## What it checks
[Section titled “What it checks”](#what-it-checks)
* `invalidFilename` — the basename does not match the configured `pattern`. Message: `Filename "" does not match expected pattern ""`, with `data.filename` and `data.expected`. This is a file-level diagnostic (no source line/column).
## Examples
[Section titled “Examples”](#examples)
### Valid
[Section titled “Valid”](#valid)
```text
0001-mise.md
9999-multi-word-kebab-title.md
0042-numbers-in-name.md
```
### Invalid
[Section titled “Invalid”](#invalid)
```text
1-too-short.md (number not zero-padded to 4 digits)
0001_underscore.md (underscore separator instead of hyphen)
0001-Title-Case.md (uppercase letters in slug)
not-numbered.md (no leading 4-digit prefix)
0001nohyphen.md (missing hyphen after the number)
0001-trailing-dot..md (double dot before .md)
0001-test.markdown (wrong extension, must be .md)
0001-.md (empty slug)
```
## Options
[Section titled “Options”](#options)
| Option | Type | Default | Description |
| --------- | -------- | --------------------------- | -------------------------------------------------------------------------- |
| `pattern` | `string` | `^[0-9]{4}-[a-z0-9-]+\.md$` | Regex (as a string) the basename must match. Override to relax or tighten. |
```ts
import { defineConfig } from 'madr-lint';
export default defineConfig({
rules: {
'madr/filename-format': ['error', {
pattern: '^[0-9]{4}-.+\\.md$', // looser: any characters in the slug
}],
},
});
```
## MADR version compatibility
[Section titled “MADR version compatibility”](#madr-version-compatibility)
| Version | Applies |
| ------- | ------- |
| v2 | yes |
| v3 | yes |
| v4 | yes |
The filename convention is identical across MADR versions.
## When to disable
[Section titled “When to disable”](#when-to-disable)
Set `madr/filename-format` to `off` only when migrating an existing ADR collection that uses a different convention. Prefer overriding `pattern` to preserve some level of validation.
Like all rules, this rule can be suppressed inline — see [Suppressing rules](/madr-lint/guides/suppressing-rules/).
## Source
[Section titled “Source”](#source)
* Rule source:
* Spec:
# madr/no-broken-links
> Relative-path Markdown links in ADRs must resolve to an existing file.
A cross-file project rule that verifies relative-path Markdown links resolve to a file that exists.
It walks the mdast of every ADR, collects all `link` nodes, and resolves each relative URL. A target counts as present if it is one of the linted files **or** exists on the real filesystem (via a `fileExists` check the orchestrator injects — this covers non-Markdown assets and files outside the scanned paths). In pure in-memory runs, the set of linted files is the only source of truth.
Before resolving, the rule strips any `#anchor` and `?query` from the URL and percent-decodes the path (e.g. `my%20file.md`). A leading `/` is treated as project-rooted (the slash is stripped) rather than an OS-absolute path.
External and non-path links are skipped:
* URLs with a protocol (`http://`, `https://`, `mailto:`, `ftp:`, …)
* pure anchors (`#section`) and empty URLs
* URLs that are empty once the anchor/query is stripped
Note: the on-disk check inherits the host filesystem’s case-sensitivity, so a wrong-case link may pass on macOS/Windows yet fail on Linux/CI.
## What it checks
[Section titled “What it checks”](#what-it-checks)
* `brokenLink` — a relative link resolves to a path that is neither a linted file nor present on disk (or escapes the project root). Message: `Link to "" resolves to "", which does not exist in the project`, with `data.url` and `data.resolvedPath`. The diagnostic is emitted on the file that contains the broken link.
## Examples
[Section titled “Examples”](#examples)
### Valid
[Section titled “Valid”](#valid)
docs/adr/0001-x.md
```markdown
See [ADR-0042](./0042-y.md) for the new approach.
External: [mise](https://mise.jdx.dev)
Anchor: [back to top](#header)
```
(assuming `docs/adr/0042-y.md` exists)
### Invalid
[Section titled “Invalid”](#invalid)
docs/adr/0001-x.md
```markdown
See [the rewrite](./0042-rewrite.md)
```
If no `docs/adr/0042-rewrite.md` exists, emits `brokenLink` on `0001-x.md` with `data.resolvedPath: 'docs/adr/0042-rewrite.md'`.
## Options
[Section titled “Options”](#options)
This rule has no options.
```ts
import { defineConfig } from 'madr-lint';
export default defineConfig({
rules: {
'madr/no-broken-links': 'error',
},
});
```
## MADR version compatibility
[Section titled “MADR version compatibility”](#madr-version-compatibility)
| Version | Applies |
| ------- | ------- |
| v2 | yes |
| v3 | yes |
| v4 | yes |
Markdown link syntax is identical across MADR versions.
## When to disable
[Section titled “When to disable”](#when-to-disable)
Disable for repos where ADR cross-references are tracked outside the Markdown body (e.g. via Git tags or a separate ADR registry). External link rot is out of scope here — use a tool like `lychee` for that.
Like all rules, this rule can be suppressed inline — see [Suppressing rules](/madr-lint/guides/suppressing-rules/).
## Source
[Section titled “Source”](#source)
* Rule source:
* Spec:
# madr/no-duplicate-numbering
> No two ADRs may share the same NNNN- number prefix.
A cross-file project rule that reports when two or more ADRs share the same `NNNN-` number prefix in their basename.
The runner pre-parses every ADR file once and hands them all to the rule in a single `check()` call. The rule reads the leading four digits (`^(\d{4})-`) from each basename, groups files by number, and emits a diagnostic on **every** member of any duplicate group — so a reviewer sees the conflict in each affected file’s output, not just one.
## What it checks
[Section titled “What it checks”](#what-it-checks)
* `duplicateNumber` — two or more files resolve to the same `NNNN` prefix. Message: `ADR number is used by multiple files: `, with `data.number` and `data.paths` (the conflicting paths, comma-joined). One diagnostic is emitted per file in the group.
Files whose basename does not start with `NNNN-` (e.g. `template.md`, `README.md`, `0001invalid.md` without a hyphen) are silently ignored — `madr/filename-format` is responsible for those.
## Examples
[Section titled “Examples”](#examples)
### Valid
[Section titled “Valid”](#valid)
```text
docs/adr/
0001-mise.md
0002-aube.md
0003-oxc.md
```
No diagnostics.
### Invalid
[Section titled “Invalid”](#invalid)
```text
docs/adr/
0001-foo.md
0001-bar.md
```
Emits 2 diagnostics (one per file), both `duplicateNumber` with `data.number: '0001'` and `data.paths: '0001-foo.md, 0001-bar.md'`.
```text
docs/adr/
0001-a.md
0001-b.md
0001-c.md
0002-x.md
0002-y.md
```
Emits 5 diagnostics: 3 for `0001`, 2 for `0002`.
## Options
[Section titled “Options”](#options)
This rule has no options.
```ts
import { defineConfig } from 'madr-lint';
export default defineConfig({
rules: {
'madr/no-duplicate-numbering': 'error',
},
});
```
## MADR version compatibility
[Section titled “MADR version compatibility”](#madr-version-compatibility)
| Version | Applies |
| ------- | ------- |
| v2 | yes |
| v3 | yes |
| v4 | yes |
The filename numbering convention is identical across MADR versions.
## When to disable
[Section titled “When to disable”](#when-to-disable)
There is essentially no reason to disable this rule. If two ADRs share a number, one of them is wrong by definition.
Like all rules, this rule can be suppressed inline — see [Suppressing rules](/madr-lint/guides/suppressing-rules/).
## Source
[Section titled “Source”](#source)
* Rule source:
* Spec:
# madr/no-numbering-gap
> Detect gaps in ADR numbering (e.g. 0001 and 0003 exist but 0002 is missing).
A cross-file project rule that detects gaps in ADR numbering — when files numbered `0001-…` and `0003-…` exist without `0002-…`.
This is a **convention-only** rule, not a MADR spec rule, so it is **not enabled** by the `recommended` preset (default severity `off`). MADR does not require numbering to be gap-free; teams legitimately reserve numbers, discard draft ADRs, or merge from forks at different paces. Opt in explicitly when your team treats numbering as a strictly contiguous sequence.
The rule maps each ADR number (`^(\d{4})-` from the basename) to its file, sorts the numbers, and reports each gap. Files without an `NNNN-` prefix (e.g. `template.md`, `README.md`) are ignored. If fewer than two numbered files exist, it does nothing.
## What it checks
[Section titled “What it checks”](#what-it-checks)
* `numberingGap` — a gap exists between two consecutive present numbers. Message: `Numbering gap: missing between ADR- and ADR-`, with `data.from` (the number before the gap), `data.to` (the number after), and `data.missing` (the comma-joined missing numbers). The diagnostic is emitted on the file at `to` (the higher side of the gap).
## Examples
[Section titled “Examples”](#examples)
### Valid (no gaps)
[Section titled “Valid (no gaps)”](#valid-no-gaps)
```text
0001-a.md
0002-b.md
0003-c.md
```
### Single gap
[Section titled “Single gap”](#single-gap)
```text
0001-a.md
0003-c.md (0002 is missing)
```
Emits 1 diagnostic on `0003-c.md`: `data: { from: '0001', to: '0003', missing: '0002' }`.
### Wide gap
[Section titled “Wide gap”](#wide-gap)
```text
0001-a.md
0005-e.md (0002, 0003, 0004 missing)
```
Emits 1 diagnostic on `0005-e.md`: `data.missing: '0002, 0003, 0004'`. Each contiguous gap produces one diagnostic.
## Options
[Section titled “Options”](#options)
This rule has no options.
```ts
import { defineConfig } from 'madr-lint';
export default defineConfig({
extends: ['madr-lint:recommended'],
rules: {
'madr/no-numbering-gap': 'error', // opt in (default is 'off')
},
});
```
## MADR version compatibility
[Section titled “MADR version compatibility”](#madr-version-compatibility)
| Version | Applies |
| ------- | ------- |
| v2 | yes |
| v3 | yes |
| v4 | yes |
The numbering convention is identical across MADR versions.
## When to disable
[Section titled “When to disable”](#when-to-disable)
Keep this rule off (its default) when your numbering policy reserves slots, when drafts are routinely discarded mid-PR, or when you merge ADRs from multiple forks. Enable it only when numbering must be a contiguous sequence.
Like all rules, this rule can be suppressed inline — see [Suppressing rules](/madr-lint/guides/suppressing-rules/).
## Source
[Section titled “Source”](#source)
* Rule source:
* Spec:
# madr/required-sections
> Enforce that every ADR contains the required heading sections.
Enforces that an ADR Markdown file contains every heading listed in the `sections` option.
The rule walks the Markdown AST, collects the text of every heading (any level, with inline markup stripped via `mdast-util-to-string`, so `## **Status**` matches `Status`), and reports one diagnostic for each required heading that is not present.
## What it checks
[Section titled “What it checks”](#what-it-checks)
* `missingSection` — a required heading is not found among the file’s headings. One diagnostic is emitted per missing section. The message is `Missing required section: ""`. The diagnostic carries `data.section` (the missing heading) and `data.found` (every heading text seen in the file, for debugging). It is a file-level diagnostic — there is no node to point at.
Heading matching is controlled by `matchMode`: `exact` requires the full trimmed heading to equal the required text; `startsWith` matches any heading that begins with the required text (e.g. `Decision Outcome` matches `Decision Outcome (Architectural)`).
## Examples
[Section titled “Examples”](#examples)
### Valid
[Section titled “Valid”](#valid)
A file with the three default required sections:
```markdown
# ADR-0001: Use mise for runtime management
## Context and Problem Statement
...
## Decision Outcome
Adopted: ...
## Consequences
...
```
### Invalid
[Section titled “Invalid”](#invalid)
```markdown
# ADR-0001: Missing context
## Decision Outcome
...
## Consequences
...
```
Emits 1 diagnostic: `Missing required section: "Context and Problem Statement"`.
## Options
[Section titled “Options”](#options)
| Option | Type | Default | Description |
| ----------- | ------------------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `sections` | `string[]` | `['Context and Problem Statement', 'Decision Outcome', 'Consequences']` | Required heading texts. Order does not matter. |
| `matchMode` | `'exact' \| 'startsWith'` | `'exact'` | How each required entry is compared against a heading. `startsWith` lets `Decision Outcome (Architectural)` satisfy `Decision Outcome`. |
```ts
import { defineConfig } from 'madr-lint';
export default defineConfig({
rules: {
'madr/required-sections': ['error', {
sections: ['Context', 'Decision', 'Consequences'],
matchMode: 'startsWith',
}],
},
});
```
## MADR version compatibility
[Section titled “MADR version compatibility”](#madr-version-compatibility)
| Version | Applies |
| ------- | ------- |
| v2 | yes |
| v3 | yes |
| v4 | yes |
The default sections appear in every MADR version’s template, so heading names are consistent across v2/v3/v4.
## When to disable
[Section titled “When to disable”](#when-to-disable)
Set `madr/required-sections` to `off` only when migrating an ADR collection that uses different section names. Prefer overriding `sections` (and/or switching to `startsWith`) to preserve some level of validation.
Like all rules, this rule can be suppressed inline — see [Suppressing rules](/madr-lint/guides/suppressing-rules/).
## Source
[Section titled “Source”](#source)
* Rule source:
* Spec:
# madr/status-enum
> Validate that an ADR's status field is one of the allowed values.
Validates that an ADR’s `status` field is one of the allowed values.
The rule reads `context.metadata.status`, which is YAML frontmatter **merged** with v2 body-list metadata. This means it supports MADR v2 (both the bold `- **Status**:` and the plain `* Status:` list shapes), v3 and v4. On conflict, frontmatter wins; explicit null/undefined frontmatter values are skipped so a v2 body-list value is preserved.
## What it checks
[Section titled “What it checks”](#what-it-checks)
* `missingStatus` — no `status` field is found in the merged metadata (checked in both frontmatter and v2 bold-list), or the value is not a string. Message: `Metadata does not contain a "status" field (checked frontmatter and v2 bold-list)`.
* `invalidStatus` — `status` is present but is neither an exact match against `values` nor a prefix match against `prefixValues`. Message: `Status "" is not one of: `, with `data.status` and `data.allowed` (the allowed values plus each prefix rendered as `" ..."`).
Comparison is case-insensitive by default (`caseSensitive: false`). Prefix matching handles transitional states such as `superseded by ADR-0042` matching the `superseded by` prefix.
## Examples
[Section titled “Examples”](#examples)
### Valid
[Section titled “Valid”](#valid)
```markdown
---
status: accepted
date: 2026-05-01
---
# ADR-0001: ...
```
Case-insensitive by default, so `status: ACCEPTED` is valid. Prefix match:
```markdown
---
status: superseded by ADR-0042
---
```
MADR v2 body-list form is also read:
```markdown
# ADR-0001: ...
- **Status**: accepted
- **Date**: 2026-05-01
```
### Invalid
[Section titled “Invalid”](#invalid)
```markdown
# ADR-0001: ...
```
Emits `missingStatus` (no metadata at all).
```markdown
---
status: pending
---
```
Emits `invalidStatus` (`pending` is not in the allowed enum).
## 🔧 Autofix
[Section titled “🔧 Autofix”](#-autofix)
This rule is **fixable** (`madr-lint --fix`) — but only for **v2 body-list** status values, and only when the value maps onto the **configured enum** unambiguously. Frontmatter values are never rewritten (YAML-aware editing is out of scope).
Fixed — normalized to the canonical configured value:
| Before | After | Kind |
| ---------------------------------- | ---------------------------------- | ------------------------------ |
| `- Status: Accepted` | `- Status: accepted` | case difference † |
| `- Status: depricated` | `- Status: deprecated` | curated misspelling |
| `- Status: superceded by ADR-0042` | `- Status: superseded by ADR-0042` | prefix typo (tail preserved) |
| `- Status: Superseded By ADR-0042` | `- Status: superseded by ADR-0042` | prefix case (tail preserved) † |
† Case-only corrections apply under `caseSensitive: true`. With the default `caseSensitive: false`, a value that differs only by case is **valid** — it is never flagged, so there is nothing to fix. The misspelling rows (`depricated`, `superceded by …`) are invalid regardless of case setting and are fixed by default.
**Not** fixed (reported, never rewritten):
* **Ambiguous corrections** — when a value case-folds onto two configured entries, or matches two configured prefixes, no fix is offered.
* **Unconfigured targets** — a synonym only maps to a value/prefix that is actually in your `values` / `prefixValues`; if you removed `superseded by`, `superceded by …` is not fixed.
* **Genuine typos with no unique target** — e.g. `acccepted` (does not case-fold to any allowed value).
* **Frontmatter-sourced values** — a `status:` in YAML frontmatter (fix it by hand).
## Options
[Section titled “Options”](#options)
| Option | Type | Default | Description |
| --------------- | ---------- | ---------------------------------------------------- | -------------------------------------------------------------------- |
| `values` | `string[]` | `['proposed', 'rejected', 'accepted', 'deprecated']` | Exact-match allowed status values. |
| `prefixValues` | `string[]` | `['superseded by']` | `startsWith`-match allowed prefixes (e.g. `superseded by ADR-0042`). |
| `caseSensitive` | `boolean` | `false` | When `false`, comparisons are case-insensitive. |
```ts
import { defineConfig } from 'madr-lint';
export default defineConfig({
rules: {
'madr/status-enum': ['error', {
values: ['draft', 'review', 'final', 'archived'],
prefixValues: [],
caseSensitive: true,
}],
},
});
```
## MADR version compatibility
[Section titled “MADR version compatibility”](#madr-version-compatibility)
| Version | Applies | Notes |
| ------- | ------- | -------------------------------------------------------------------------------------------------- |
| v2 | yes | body-list `- **Status**: proposed` (bold) or `* Status: proposed` (plain), via the metadata bridge |
| v3 | yes | frontmatter `status: ...` |
| v4 | yes | frontmatter `status: ...` |
## When to disable
[Section titled “When to disable”](#when-to-disable)
Set `madr/status-enum` to `off` when migrating from a system with a different status vocabulary. Prefer overriding `values` / `prefixValues` to preserve some validation.
Like all rules, this rule can be suppressed inline — see [Suppressing rules](/madr-lint/guides/suppressing-rules/).
## Source
[Section titled “Source”](#source)
* Rule source:
* Spec:
# madr/supersedes-bidirectional
> Frontmatter supersedes and superseded-by must reference each other consistently.
A cross-file project rule that verifies the `supersedes` and `superseded-by` frontmatter fields point at each other consistently across the ADR collection.
When ADR-A supersedes ADR-B, A’s frontmatter should declare `supersedes: ADR-B` and B’s frontmatter should declare `superseded-by: ADR-A`. The rule reads each file’s frontmatter directly (not the merged metadata bridge), builds an `ADR-NNNN → file` index by basename (`^(\d{4})-`), and checks both directions. Files without an `NNNN-` prefix are not addressable and are skipped.
Both fields accept either a single string or an array of strings (many-to-one supersession). Non-string, non-array values (number, null, boolean) are silently ignored — type checks are not this rule’s concern.
## What it checks
[Section titled “What it checks”](#what-it-checks)
* `unknownReference` — a `supersedes` or `superseded-by` value references an `ADR-NNNN` for which no file exists. Message: ``Frontmatter `: ` references an ADR that does not exist``, with `data.ref` and `data.direction`. Emitted on the file that contains the dangling reference.
* `missingBackReference` — file A declares a forward reference to file B, but B does not declare the reciprocal reference. Message: `` declares `: `, but (this file) does not back-reference it via ``. Emitted on the file that is missing the back reference, naming the `source` file that pointed at it and the `expected` reference it should add.
## Examples
[Section titled “Examples”](#examples)
### Valid
[Section titled “Valid”](#valid)
```yaml
# 0001-old.md
---
status: superseded by ADR-0042
superseded-by: ADR-0042
---
```
```yaml
# 0042-new.md
---
status: accepted
supersedes: ADR-0001
---
```
### Invalid — missing back reference
[Section titled “Invalid — missing back reference”](#invalid--missing-back-reference)
```yaml
# 0001-old.md
---
# (no superseded-by here)
---
```
```yaml
# 0042-new.md
---
supersedes: ADR-0001
---
```
Emits `missingBackReference` on `0001-old.md` with `data.expected: 'ADR-0042'`.
### Invalid — unknown reference
[Section titled “Invalid — unknown reference”](#invalid--unknown-reference)
```yaml
# 0042-x.md
---
supersedes: ADR-9999 # no 9999-*.md exists
---
```
Emits `unknownReference` on `0042-x.md` with `data.ref: 'ADR-9999'`.
## 🔧 Autofix
[Section titled “🔧 Autofix”](#-autofix)
This rule is **fixable** (`madr-lint --fix`) — the first **cross-file** fix in madr-lint. When a `missingBackReference` is found, the fix inserts the reciprocal `: ` line into the **target** file’s frontmatter, immediately before the closing `---`. The frontmatter block is treated as opaque lines (no YAML reparse/reserialize), so every other byte — key order, comments, the file’s newline style — is preserved.
Before (`0001-old.md`, missing its back-reference to `0042-new.md`):
```yaml
---
status: superseded by ADR-0042
---
```
After `madr-lint --fix`:
```yaml
---
status: superseded by ADR-0042
superseded-by: ADR-0042
---
```
`unknownReference` is **not** fixable (it is contextual — only you know the correct ADR number). A `missingBackReference` is **not** fixed when:
* **The target has no frontmatter** — a v2 body-list ADR or a bare file. A frontmatter block is never created.
* **The key already exists** — if the target already declares `superseded-by:` (or `supersedes:`) with a different or partial value, the fix declines rather than duplicate the key or rewrite/append a value (out of scope). The check is case-insensitive: an existing `Superseded-By:` also blocks the insertion, so the fix never adds a second, contradictory-looking variant of the key. The diagnostic remains for you to resolve by hand.
* **Many-to-one, same pass** — when two source ADRs both need a back-reference in the *same* target, one insertion is applied per pass; the remaining one is reported (it needs an array value, which is a manual edit).
## Options
[Section titled “Options”](#options)
This rule has no options.
```ts
import { defineConfig } from 'madr-lint';
export default defineConfig({
rules: {
'madr/supersedes-bidirectional': 'error',
},
});
```
## MADR version compatibility
[Section titled “MADR version compatibility”](#madr-version-compatibility)
| Version | Applies | Notes |
| ------- | ------- | ------------------------------------------------------------- |
| v2 | no | `- **Supersedes**: ADR-NNNN` is body content, not frontmatter |
| v3 | yes | frontmatter `supersedes` / `superseded-by` |
| v4 | yes | same |
This rule reads frontmatter only, so it does not apply to MADR v2 body-list metadata.
## When to disable
[Section titled “When to disable”](#when-to-disable)
Disable for repos that track supersession outside ADR frontmatter — e.g. via Git tags, an external registry, or only a `status: superseded by ...` line without explicit `supersedes` / `superseded-by` fields.
Like all rules, this rule can be suppressed inline — see [Suppressing rules](/madr-lint/guides/suppressing-rules/).
## Source
[Section titled “Source”](#source)
* Rule source:
* Spec: