This is the abridged 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: