From 30ac7e56f1e886a008146bcffd07f98284b152fa Mon Sep 17 00:00:00 2001 From: "K. C. Ramakrishna" Date: Tue, 10 Nov 2026 10:36:04 +0530 Subject: [PATCH 1/3] docs: record why ADRs hold only the current decision (ADR 0009) Agents load ADRs as instructions and apply a superseded record as a current rule. ADR 0009 records that reasoning, the rejected lifecycle conventions and the trade-offs. The policy page links to it. Co-Authored-By: Claude Opus 5.5 --- docs/ADR_POLICY.md | 3 +- ...009-adrs-hold-only-the-current-decision.md | 52 +++++++++++++++++++ docs/architecture/adrs/README.md | 1 + 3 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 docs/architecture/adrs/0009-adrs-hold-only-the-current-decision.md diff --git a/docs/ADR_POLICY.md b/docs/ADR_POLICY.md index 28ffd006..8b791f6a 100644 --- a/docs/ADR_POLICY.md +++ b/docs/ADR_POLICY.md @@ -2,7 +2,8 @@ This page says how to write, change and delete an architecture decision record (ADR) in this repository. It is a variant of the usual ADR conventions, tuned for AI agents rather than people. Agents load ADRs as instructions, -so a stale or historical ADR is a defect, not a record. +so a stale or historical ADR is a defect, not a record. The reasons are in +[ADR 0009](architecture/adrs/0009-adrs-hold-only-the-current-decision.md). Every ADR folder follows it: the root `docs/architecture/adrs/`, `spec/decisions/`, `audit/docs/architecture/adrs/`, `implement/docs/architecture/adrs/` and `audit/engine/docs/architecture/adrs/`. Where each decision goes is diff --git a/docs/architecture/adrs/0009-adrs-hold-only-the-current-decision.md b/docs/architecture/adrs/0009-adrs-hold-only-the-current-decision.md new file mode 100644 index 00000000..4b1100e3 --- /dev/null +++ b/docs/architecture/adrs/0009-adrs-hold-only-the-current-decision.md @@ -0,0 +1,52 @@ +# 0009. ADRs Hold Only the Current Decision; Stale and Unnecessary Ones Are Deleted + +## Context + +The usual ADR conventions were written for people. A record keeps a status (proposed, accepted, superseded, +deprecated), a date and its deciders. A changed decision gets a new record, and the old one stays as a tombstone +that points forward. For a person, that file history is the archive. + +In this repository the main readers of an ADR are AI coding agents. An agent loads an ADR as an instruction. It +has no reliable sense of time, and it often reads one record without the record that supersedes it. A +superseded, deprecated or no-longer-needed ADR in an agent's context is therefore not history: the agent can +apply it as a current rule. Every stale line also costs context that a current rule could use. + +Git already keeps every past version of every file, with its author and date. The file does not need to keep +them too. + +## Decision + +1. **An ADR states only the current decision.** It has no status, date, review stamp, decider list or change log. + [`docs/ADR_POLICY.md`](../../ADR_POLICY.md) holds the rules. +2. **A changed decision is overwritten in place.** The old choice becomes one row of *Alternatives Considered*, + with the reason it is rejected now. No second ADR supersedes the first. +3. **A stale or unnecessary ADR is deleted,** with its index row and every reference to it. Nothing refers to a + deleted ADR again. Its number is retired. +4. **An ADR is written only when it earns its place.** It records a decision an agent would otherwise get + wrong. +5. **The same rules reach other repositories.** The implement recipe writes ADRs into a target under them, and + the greenfield templates ship them. + +## Alternatives Considered + +| Alternative | Why it is rejected | +| --- | --- | +| A status lifecycle with superseded records kept (Nygard, MADR) | Agents read a superseded record as a current instruction, and the tombstones fill their context. | +| Move stale ADRs to an archive folder | Search and code-index tools still find the folder, so agents still load the stale records. | +| Keep only `Status: Accepted` and a date | Every merged ADR is accepted, and git holds the date. The fields add noise and no meaning. | +| A history section inside each ADR | It puts past decisions back in the context the policy keeps clean. `git log --follow` gives the history. | + +## Consequences + +### Positive + +- An agent that loads an ADR gets a rule that holds today. +- ADRs stay short, so an agent can load the ones it needs without spending context on history. +- A reader sees each rejected option and its current reason in one table. + +### Negative / Trade-offs + +- A person who wants the history of a decision runs `git log --follow` on the file. +- A link to a deleted ADR from outside the repository (an old pull request, an issue) stops resolving. + The file stays in git history. +- Deleting a record takes judgement: the person who changes the code must decide that the ADR is stale. diff --git a/docs/architecture/adrs/README.md b/docs/architecture/adrs/README.md index c6e54d0b..290de3f3 100644 --- a/docs/architecture/adrs/README.md +++ b/docs/architecture/adrs/README.md @@ -16,6 +16,7 @@ The root router is [`docs/README.md`](../../README.md). | [0006](0006-first-run-agent-tool-offer.md) | One Committed `.mcp.json`; Agents Offer Each Person a One-Time Setup for Their Harness | One committed `.mcp.json`; each agent offers its person a one-time, consented setup for their harness. | | [0007](0007-security-verb-audits-dependencies-with-osv-scanner.md) | The Security Verb Audits the Dev Tools' Dependencies with osv-scanner | `make security` runs osv-scanner over every dev-tool lockfile after the secrets scan. | | [0008](0008-one-release-version-and-current-schemas-only.md) | Stamp One Release Version in Every Report and State, and Read Only the Current Schemas | Reports and state files carry the one release version; readers accept only the current schemas. | +| [0009](0009-adrs-hold-only-the-current-decision.md) | ADRs Hold Only the Current Decision; Stale and Unnecessary Ones Are Deleted | Agents read ADRs as instructions, so an ADR states only today's decision and a stale one is deleted. | ## Requirements: `spec/decisions/` From 568bc5c11c5f929c00db9aac0c41181e3dfdeca1 Mon Sep 17 00:00:00 2001 From: "K. C. Ramakrishna" Date: Tue, 10 Nov 2026 10:51:56 +0530 Subject: [PATCH 2/3] feat(engine): DOC-04 flags superseded, deprecated or rejected ADRs An ADR marked superseded, deprecated, rejected or obsolete is loaded by an agent as a current rule (root ADR 0009). DOC-04 now reads each numbered ADR for a status line or field, a `## Status` section, or a line that opens with "superseded by", "replaced by" or "deprecated by / in favour of". Any such tombstone is PARTIAL, with its file and line, even when the index lists every entry. A plain `Status: Accepted` is not flagged, and the word inside a sentence or table does not count. Ruleset 2026-11-10.5; no catalogue bump. One-defect fixture doc-04-adr-tombstone with its eval file, in-process tests, rubric, REQ-SH-019 line, changelog and audit master. Self-audit: DOC-04 PASS. Co-Authored-By: Claude Opus 5.5 --- .secrets.baseline | 11 +- audit/checks/03-documentation-contract.md | 16 ++- audit/engine/audit/__init__.py | 2 +- audit/engine/audit/rules/checks/doc.py | 122 +++++++++++++----- audit/engine/docs/reference/changelog.md | 1 + audit/engine/tests/fixtures/README.md | 1 + .../architecture/adrs/0001-use-postgres.md | 9 ++ .../docs/architecture/adrs/0002-use-mysql.md | 11 ++ .../docs/architecture/adrs/README.md | 6 + .../tests/packs/doc_nav/test_doc_nav_pack.py | 76 +++++++++++ audit/engine/tests/test_fixture_matrix.py | 1 + .../fixtures/doc-04-adr-tombstone.json | 14 ++ docs/reference/audit-master.md | 3 +- spec/readiness-requirements.md | 3 +- 14 files changed, 232 insertions(+), 44 deletions(-) create mode 100644 audit/engine/tests/fixtures/doc-04-adr-tombstone/docs/architecture/adrs/0001-use-postgres.md create mode 100644 audit/engine/tests/fixtures/doc-04-adr-tombstone/docs/architecture/adrs/0002-use-mysql.md create mode 100644 audit/engine/tests/fixtures/doc-04-adr-tombstone/docs/architecture/adrs/README.md create mode 100644 dev/eval/expected/fixtures/doc-04-adr-tombstone.json diff --git a/.secrets.baseline b/.secrets.baseline index 70613d57..9c4b16b1 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -1069,6 +1069,15 @@ "line_number": 4 } ], + "dev/eval/expected/fixtures/doc-04-adr-tombstone.json": [ + { + "type": "Hex High Entropy String", + "filename": "dev/eval/expected/fixtures/doc-04-adr-tombstone.json", + "hashed_secret": "88de86d8af06b423e2dcddbe877fe085f797f5d3", + "is_verified": false, + "line_number": 4 + } + ], "dev/eval/expected/fixtures/doc-04-adr-unindexed.json": [ { "type": "Hex High Entropy String", @@ -1615,5 +1624,5 @@ } ] }, - "generated_at": "2026-11-10T01:09:28Z" + "generated_at": "2026-11-10T05:18:50Z" } diff --git a/audit/checks/03-documentation-contract.md b/audit/checks/03-documentation-contract.md index 671d42cc..0d89b53d 100644 --- a/audit/checks/03-documentation-contract.md +++ b/audit/checks/03-documentation-contract.md @@ -144,6 +144,11 @@ Engine `DOC-04`. Looks for `docs/architecture/adrs/` or `docs/adr/` ho index (a `README.md`/`index.md`/`_index.md` beside them, or the parent directory's own README) is `PARTIAL`; an index that lists some but not all entries (matched by filename or the first four characters of the ADR number) is `PARTIAL`; every entry listed is `PASS`. + An entry that is a tombstone is `PARTIAL` even when the index lists every entry: a + status line or field, or a `## Status` section, that says superseded, deprecated, + rejected or obsolete, or a line that opens with "superseded by", "replaced by" or + "deprecated by / in favour of". The word inside a sentence or an alternatives table + does not count, and `Status: Accepted` is not flagged. Distrust a `PARTIAL` on "index omits some" when the index links to entries by a title that does not embed the filename or number fragment the engine's matcher looks for — verify by reading the index for a link to each ADR's actual path. @@ -151,16 +156,17 @@ Method automated Look for `docs/architecture/adrs/` first (the convention this project's own docs use), else `docs/adr/`; the index file beside the entries, or the architecture README that lists them instead. -Rubric PASS: a numbered-ADR directory exists, holds at least one entry, and an index lists every - entry. +Rubric PASS: a numbered-ADR directory exists, holds at least one entry, an index lists every + entry, and no entry is a tombstone. PARTIAL: the directory exists with entries but no index, or an index that omits some, or - the directory exists with no numbered entry at all. + the directory exists with no numbered entry at all, or an entry is marked superseded, + deprecated, rejected or obsolete (an agent loads it as a current rule). FAIL: no ADR directory (at either known location) exists. NOT_APPLICABLE: never — `REQ-SH-019` is a `SHOULD`, but the catalogue still scores it as `DOC-04`, at `COSMETIC` severity, so it is never excused as not applicable. UNKNOWN when: never — directory and file presence are always knowable from the inventory. -Evidence to cite A `file` evidence item per unlisted entry (for the omits-some case), or the directory path - alone (for the no-directory or no-entry cases). +Evidence to cite A `file` evidence item per unlisted entry (for the omits-some case), the file and line of + each tombstone, or the directory path alone (for the no-directory or no-entry cases). Absence proof A FAIL records a `glob` search for `docs/architecture/adrs/*.md` and `docs/adr/*.md`, each with a `0` hit count. Remediation Playbook: `establish-docs-contract`. Add `docs/architecture/adrs/` with diff --git a/audit/engine/audit/__init__.py b/audit/engine/audit/__init__.py index b8a2bbc9..79f1c7f6 100644 --- a/audit/engine/audit/__init__.py +++ b/audit/engine/audit/__init__.py @@ -24,7 +24,7 @@ #: Bumped whenever a rule pack, a check, or the instruction-variant table changes. #: Recorded in every report's provenance block so a report can be attributed to an #: exact catalogue revision (docs: Scan Engine & Tiering, § 7 Determinism). -__ruleset_revision__ = "2026-11-10.4" +__ruleset_revision__ = "2026-11-10.5" #: Highest framework phase this build evaluates. Phase 1 only in v1. __phase_ceiling__ = 1 diff --git a/audit/engine/audit/rules/checks/doc.py b/audit/engine/audit/rules/checks/doc.py index 4a76ef6b..a12a69e9 100644 --- a/audit/engine/audit/rules/checks/doc.py +++ b/audit/engine/audit/rules/checks/doc.py @@ -22,7 +22,9 @@ rather than infer it (the same degradation `SEC-01` uses). * **DOC-04** — an ADR directory (`docs/architecture/adrs/` or `docs/adr/`) with numbered entries and an index (a `README.md`/`index.md` beside them, or the directory's parent README) that lists them. - No directory is `FAIL`; entries without an index, or an index that omits some, is `PARTIAL`. + No directory is `FAIL`; entries without an index, or an index that omits some, is `PARTIAL`. An entry that + is a tombstone (:func:`_adr_tombstone`: marked superseded, deprecated, rejected or obsolete) is `PARTIAL` + too: an agent loads it as a current instruction. * **DOC-05** — the plain-language measures of the prose in `docs/` (``audit.rules.checks._prose``): the share of sentences over 25 words (20 in a numbered step), vague words per 1,000 words, and explanation or tutorial pages without a Mermaid diagram. Within every pass level is `PASS`; a long-sentence share over @@ -311,6 +313,38 @@ def _adr_entries(inventory: Inventory, directory: str) -> list[str]: ) +#: A status line or field that retires the record: `Status: Superseded`, `* **Status:** Deprecated`, +#: `| Status | Rejected |` or front matter `status: superseded by ADR-0005`. +_TOMBSTONE_STATUS_RE = re.compile( + r"^[ \t>*_|#-]*status[ \t*_:|-]*(superseded|deprecated|rejected|obsolete)\b", re.IGNORECASE | re.MULTILINE +) +#: A line that opens by pointing at the record that replaced this one. +_TOMBSTONE_POINTER_RE = re.compile( + r"^[ \t>*_-]*(superseded\s+by|replaced\s+by|deprecated\s+(by|in\s+favou?r\s+of))\b", + re.IGNORECASE | re.MULTILINE, +) +#: The MADR form: a `## Status` heading whose first line of text retires the record. +_STATUS_SECTION_RE = re.compile( + r"^#{1,6}\s*status\s*\n(?:[ \t]*\n)*[ \t>*_-]*(superseded|deprecated|rejected|obsolete)\b", + re.IGNORECASE | re.MULTILINE, +) + + +def _adr_tombstone(text: str) -> int | None: + """The 1-based line that marks this ADR as retired, or ``None`` for a current record. + + Only a status line or field, a `## Status` section, or a line that opens with "superseded by", + "replaced by" or "deprecated by / in favour of" counts. A word like "superseded" inside a sentence or a + table of alternatives does not, so an ADR may still discuss a rejected option. + """ + hits = [ + m.start() for rx in (_TOMBSTONE_STATUS_RE, _TOMBSTONE_POINTER_RE, _STATUS_SECTION_RE) for m in rx.finditer(text) + ] + if not hits: + return None + return text.count("\n", 0, min(hits)) + 1 + + def _adr_index(inventory: Inventory, directory: str) -> str | None: """A README/index beside the ADRs, falling back to the parent directory's README.""" for candidate in (f"{directory}/README.md", f"{directory}/index.md", f"{directory}/_index.md"): @@ -350,55 +384,73 @@ def check_doc04(*, spec, target, inventory, stack, components, session) -> Check return _outcome(spec, Verdict.PARTIAL, f"{directory}/ exists but holds no numbered ADR") index = _adr_index(inventory, directory) + findings: list[Finding] = [] + summaries: list[str] = [] + if index is None: - return _outcome( - spec, - Verdict.PARTIAL, - f"{len(entries)} ADR(s) present but no index file", - [ + summaries.append(f"{len(entries)} ADR(s) present but no index file") + findings.append( + Finding( + check=spec.id, + severity=spec.severity, + phase=spec.phase, + verdict=Verdict.PARTIAL, + statement=statement( + "discover the decisions of record from one page", + f"{directory}/ holds {len(entries)} ADR(s) but no index lists them", + ), + evidence=[Evidence(rel) for rel in entries[:12]], + remediation="Add a README (or index) beside the ADRs, or list them from the architecture README.", + ) + ) + else: + text = inventory.read(index) or "" + listed = [ + rel + for rel in entries + if rel.rsplit("/", 1)[-1] in text or re.search(rf"\b{rel.rsplit('/', 1)[-1][:4]}\b", text) + ] + if len(listed) < len(entries): + summaries.append(f"{len(listed)}/{len(entries)} ADR(s) listed in {index}") + findings.append( Finding( check=spec.id, severity=spec.severity, phase=spec.phase, verdict=Verdict.PARTIAL, statement=statement( - "discover the decisions of record from one page", - f"{directory}/ holds {len(entries)} ADR(s) but no index lists them", + "find every decision of record", + f"{index} lists {len(listed)} of {len(entries)} ADRs in {directory}/", ), - evidence=[Evidence(rel) for rel in entries[:12]], - remediation="Add a README (or index) beside the ADRs, or list them from the architecture README.", - ), - ], - ) - - text = inventory.read(index) or "" - listed = [ - rel - for rel in entries - if rel.rsplit("/", 1)[-1] in text or re.search(rf"\b{rel.rsplit('/', 1)[-1][:4]}\b", text) - ] - if len(listed) == len(entries): - return _outcome(spec, Verdict.PASS, f"{len(entries)} ADR(s) indexed in {index}") - - return _outcome( - spec, - Verdict.PARTIAL, - f"{len(listed)}/{len(entries)} ADR(s) listed in {index}", - [ + evidence=[Evidence(index)], + remediation="Add a row to the ADR index for each entry, in the same change set that adds the ADR.", + ) + ) + + tombstones = [(rel, line) for rel in entries if (line := _adr_tombstone(inventory.read(rel) or "")) is not None] + if tombstones: + summaries.append(f"{len(tombstones)} ADR(s) marked superseded, deprecated or rejected") + findings.append( Finding( check=spec.id, severity=spec.severity, phase=spec.phase, verdict=Verdict.PARTIAL, statement=statement( - "find every decision of record", - f"{index} lists {len(listed)} of {len(entries)} ADRs in {directory}/", + "trust that every ADR it loads is a current decision", + f"{len(tombstones)} ADR(s) in {directory}/ are marked superseded, deprecated or rejected, " + "and an agent can apply them as current rules", ), - evidence=[Evidence(index)], - remediation="Add a row to the ADR index for each entry, in the same change set that adds the ADR.", - ), - ], - ) + evidence=[Evidence(rel, line, note="retired record") for rel, line in tombstones[:12]], + remediation="Delete each retired ADR, with its index row and every reference to it; git keeps its " + "history. When a decision changed, overwrite the current ADR in place and list the old choice under " + "Alternatives Considered.", + ) + ) + + if findings: + return _outcome(spec, Verdict.PARTIAL, "; ".join(summaries), findings) + return _outcome(spec, Verdict.PASS, f"{len(entries)} ADR(s) indexed in {index}") # =========================================================================== diff --git a/audit/engine/docs/reference/changelog.md b/audit/engine/docs/reference/changelog.md index dbbae71f..3da6596e 100644 --- a/audit/engine/docs/reference/changelog.md +++ b/audit/engine/docs/reference/changelog.md @@ -13,6 +13,7 @@ the record of how; git has the rest. | Revision | Date | Scoreable checks | What it is | |---|---|---|---| +| `2026-11-10.5` | 2026-11-10 | 60 | `DOC-04` (ADR directory and index, COSMETIC) reads each numbered ADR for a tombstone: a status line or field, or a `## Status` section, that says superseded, deprecated, rejected or obsolete, or a line that opens with "superseded by", "replaced by" or "deprecated by / in favour of". Any tombstone is `PARTIAL`, with the file and line, even when the index lists every entry. A plain `Status: Accepted` is not flagged (root ADR 0009). Moves `DOC-04` from `PASS` to `PARTIAL` on a repository that keeps retired ADRs. One-defect fixture `doc-04-adr-tombstone`. | | `2026-11-10.4` | 2026-11-10 | 60 | v0.3.5.1 item 4.5: `EXEC-01` (toolchain pinned, BLOCKER) reads every module manifest (`package.json`, `pyproject.toml`, `go.mod`) of a pinned ecosystem. A module is reached by a pin in its own manifest, or by a pin file (`.nvmrc`, `.python-version`, a root `.tool-versions` line) or, for node and python, a pinning manifest in its folder or a parent folder; a parent `go.mod` does not reach a nested Go module. A module no pin reaches ⇒ `PARTIAL`, with every such module in the evidence. A monorepo where one pinned module covered an unpinned sibling drops from `PASS` to `PARTIAL` | | `2026-11-10.3` | 2026-11-10 | 60 | v0.3.5.1 item 4.4: `TOOL-07` (local guard against commits to a protected branch, COSMETIC) reads whether a guard that reads the branch also exits non-zero: `exit 1` to `exit 255`, `process.exit(1)` or `sys.exit(1)`, or a bare `false`, in its lefthook command block or anywhere in its hook script, with comments skipped. `no-commit-to-branch` always stops the commit. No wired guard that exits ⇒ `PARTIAL`; the branch coverage is read only on the guards that exit. A repository whose only guard prints a warning drops from `PASS` to `PARTIAL` | | `2026-11-10.2` | 2026-11-10 | 60 | v0.3.4 T2: new check `DOC-05` (docs are readable, COSMETIC). It measures the prose of the Markdown pages under `docs/` with the three plain-language rules of the framework's prose check: the share of sentences over 25 words (20 in a numbered step), vague words per 1,000 words, and explanation or tutorial pages without a Mermaid block. Every pass level (10%, 1, none) ⇒ `PASS`; over 25% long sentences ⇒ `FAIL`; else `PARTIAL`; under 10 sentences, or prose that is not English ⇒ `UNKNOWN`. Every repository with English docs outside a pass level gains a `DOC-05` finding | diff --git a/audit/engine/tests/fixtures/README.md b/audit/engine/tests/fixtures/README.md index 72431e3f..0b4fb9d6 100644 --- a/audit/engine/tests/fixtures/README.md +++ b/audit/engine/tests/fixtures/README.md @@ -85,6 +85,7 @@ fixtures/ doc-03-tracked-artefacts/ artefacts/ ignored but force-added (needs git) -> DOC-03 FAIL ignore-ok-nested-gitignore/ `artefacts/.gitignore` holds `*` and re-includes itself and its README (needs git) -> DOC-03, CON-03 PASS (ADR-0009) doc-04-adr-unindexed/ an ADR with no index -> DOC-04 PARTIAL + doc-04-adr-tombstone/ an indexed ADR marked superseded -> DOC-04 PARTIAL doc-05-long-sentences/ two thirds of the docs' sentences over 25 words -> DOC-05 FAIL nav-01-no-manifest/ source but no build manifest -> NAV-01 UNKNOWN nav-02-no-entry/ node manifest with no entry point -> NAV-02 FAIL diff --git a/audit/engine/tests/fixtures/doc-04-adr-tombstone/docs/architecture/adrs/0001-use-postgres.md b/audit/engine/tests/fixtures/doc-04-adr-tombstone/docs/architecture/adrs/0001-use-postgres.md new file mode 100644 index 00000000..30771ba2 --- /dev/null +++ b/audit/engine/tests/fixtures/doc-04-adr-tombstone/docs/architecture/adrs/0001-use-postgres.md @@ -0,0 +1,9 @@ +# 0001. Use PostgreSQL + +## Context + +The service stores orders. + +## Decision + +Orders live in PostgreSQL. diff --git a/audit/engine/tests/fixtures/doc-04-adr-tombstone/docs/architecture/adrs/0002-use-mysql.md b/audit/engine/tests/fixtures/doc-04-adr-tombstone/docs/architecture/adrs/0002-use-mysql.md new file mode 100644 index 00000000..065d8a3b --- /dev/null +++ b/audit/engine/tests/fixtures/doc-04-adr-tombstone/docs/architecture/adrs/0002-use-mysql.md @@ -0,0 +1,11 @@ +# 0002. Use MySQL + +* **Status:** Superseded by [0001](0001-use-postgres.md) + +## Context + +The service stores orders. + +## Decision + +Orders live in MySQL. diff --git a/audit/engine/tests/fixtures/doc-04-adr-tombstone/docs/architecture/adrs/README.md b/audit/engine/tests/fixtures/doc-04-adr-tombstone/docs/architecture/adrs/README.md new file mode 100644 index 00000000..f2c3ba91 --- /dev/null +++ b/audit/engine/tests/fixtures/doc-04-adr-tombstone/docs/architecture/adrs/README.md @@ -0,0 +1,6 @@ +# Decisions + +| ADR | Title | +| --- | --- | +| [0001](0001-use-postgres.md) | Use PostgreSQL | +| [0002](0002-use-mysql.md) | Use MySQL | diff --git a/audit/engine/tests/packs/doc_nav/test_doc_nav_pack.py b/audit/engine/tests/packs/doc_nav/test_doc_nav_pack.py index 1403baac..e5380948 100644 --- a/audit/engine/tests/packs/doc_nav/test_doc_nav_pack.py +++ b/audit/engine/tests/packs/doc_nav/test_doc_nav_pack.py @@ -31,6 +31,8 @@ from audit.rules.checks.nav import _jvm_entry, _python_entry # noqa: E402 from audit.scan import build_inventory # noqa: E402 from audit.rules.checks import _prose # noqa: E402 +from audit.rules.checks.doc import _adr_tombstone # noqa: E402 +from audit.rules.checks.doc import check_doc04 as CHECK_DOC04 # noqa: E402 from audit.rules.checks.doc import check_doc05 as CHECK_DOC05 # noqa: E402 from audit.rules.registry import REGISTRY_BY_ID # noqa: E402 from audit.stack import detect_stack # noqa: E402 @@ -40,6 +42,7 @@ "doc-01-empty": ("DOC-01", "PARTIAL"), "doc-02-no-style-guide": ("DOC-02", "FAIL"), "doc-04-adr-unindexed": ("DOC-04", "PARTIAL"), + "doc-04-adr-tombstone": ("DOC-04", "PARTIAL"), "doc-05-long-sentences": ("DOC-05", "FAIL"), "nav-01-no-manifest": ("NAV-01", "UNKNOWN"), "nav-02-no-entry": ("NAV-02", "FAIL"), @@ -280,6 +283,79 @@ def test_a_directory_holding_only_a_template_has_no_numbered_adr(self): self.assertIn("holds no numbered ADR", check["summary"]) +class Doc04TombstoneTests(unittest.TestCase): + """An ADR marked superseded, deprecated or rejected is a tombstone: an agent loads it as a current rule.""" + + INDEX = "| [0001](0001-a.md) | A |\n| [0002](0002-b.md) | B |\n" + + def check(self, second: str) -> dict: + """Run DOC-04 in-process (so coverage traces it) on two indexed ADRs, the second with ``second``.""" + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + support.write(root, "docs/adr/0001-a.md", "# 0001. A\n\n## Decision\n\nUse A.\n") + support.write(root, "docs/adr/0002-b.md", second) + support.write(root, "docs/adr/README.md", self.INDEX) + inventory = build_inventory(root) + outcome = CHECK_DOC04( + spec=REGISTRY_BY_ID["DOC-04"], + target=None, + inventory=inventory, + stack=detect_stack(inventory), + components=None, + session=None, + ) + return { + "verdict": outcome.verdict.value, + "summary": outcome.summary, + "findings": [finding.to_dict() for finding in outcome.findings], + } + + def test_a_current_adr_set_passes(self): + check = self.check("# 0002. B\n\n## Decision\n\nUse B.\n") + self.assertEqual(check["verdict"], "PASS") + self.assertEqual(check["findings"], []) + + def test_a_superseded_status_field_is_partial_and_cites_the_line(self): + check = self.check("# 0002. B\n\n* **Status:** Superseded by 0001\n") + self.assertEqual(check["verdict"], "PARTIAL") + self.assertIn("1 ADR(s) marked superseded, deprecated or rejected", check["summary"]) + self.assertEqual(check["findings"][0]["evidence"][0]["path"], "docs/adr/0002-b.md") + self.assertEqual(check["findings"][0]["evidence"][0]["line"], 3) + + def test_an_unindexed_tombstone_reports_both_findings(self): + INDEX, self.INDEX = self.INDEX, "| [0001](0001-a.md) | A |\n" + self.addCleanup(setattr, self, "INDEX", INDEX) + check = self.check("# 0002. B\n\nStatus: Deprecated\n") + self.assertEqual(check["verdict"], "PARTIAL") + self.assertEqual(len(check["findings"]), 2) + self.assertIn("1/2 ADR(s) listed", check["summary"]) + + def test_each_retiring_form_is_found(self): + for text, line in ( + ("Status: Superseded\n", 1), + ("# T\n\n* **Status:** Deprecated\n", 3), + ("| Status | Rejected |\n", 1), + ("---\nstatus: superseded by ADR-0005\n---\n", 2), + ("# T\n\n## Status\n\nObsolete since the rewrite.\n", 3), + ("# T\n\n> Superseded by [ADR 0007](0007-x.md)\n", 3), + ("# T\n\nDeprecated in favour of 0009.\n", 3), + ("# T\n\nReplaced by 0004.\n", 3), + ): + with self.subTest(text=text): + self.assertEqual(_adr_tombstone(text), line) + + def test_a_current_record_that_discusses_retired_options_is_not_a_tombstone(self): + for text in ( + "# T\n\n* **Status:** Accepted\n", + "| Tombstone or superseded ADR stubs | Git is the archive. |\n", + "| A status lifecycle with superseded records kept | Agents read them as current. |\n", + "The cache entry is superseded by newer data on each write.\n", + "## Status\n\nAccepted.\n", + ): + with self.subTest(text=text): + self.assertIsNone(_adr_tombstone(text)) + + # -- DOC-05: docs are readable, in-process ------------------------------------------------------------ DOC05 = "DOC-05" diff --git a/audit/engine/tests/test_fixture_matrix.py b/audit/engine/tests/test_fixture_matrix.py index dc8f0673..9b7148c3 100644 --- a/audit/engine/tests/test_fixture_matrix.py +++ b/audit/engine/tests/test_fixture_matrix.py @@ -68,6 +68,7 @@ "doc-03-tracked-artefacts": {"DOC-03": "FAIL"}, "ignore-ok-nested-gitignore": {"DOC-03": "PASS", "CON-03": "PASS"}, "doc-04-adr-unindexed": {"DOC-04": "PARTIAL"}, + "doc-04-adr-tombstone": {"DOC-04": "PARTIAL"}, "doc-05-long-sentences": {"DOC-01": "PASS", "DOC-02": "PASS", "DOC-04": "PASS", "DOC-05": "FAIL"}, "doc-nav-ok": { "DOC-01": "PASS", diff --git a/dev/eval/expected/fixtures/doc-04-adr-tombstone.json b/dev/eval/expected/fixtures/doc-04-adr-tombstone.json new file mode 100644 index 00000000..d3a50e81 --- /dev/null +++ b/dev/eval/expected/fixtures/doc-04-adr-tombstone.json @@ -0,0 +1,14 @@ +{ + "expected_version": "0.1", + "repo_label": "doc-04-adr-tombstone", + "repo_sha": "d966939552db8242b649e9853977fc8b419f94af", + "notes": "Generated by dev/eval/generate_fixture_expected.py from test_fixture_matrix.py's MATRIX row for 'doc-04-adr-tombstone' (audit/engine/tests/fixtures/doc-04-adr-tombstone), a one-defect engine fixture committed into a throwaway git repository with a fixed commit so repo_sha is reproducible.", + "checks": [ + { + "id": "DOC-04", + "acceptable_verdicts": [ + "PARTIAL" + ] + } + ] +} diff --git a/docs/reference/audit-master.md b/docs/reference/audit-master.md index 8d853906..d6ebb641 100644 --- a/docs/reference/audit-master.md +++ b/docs/reference/audit-master.md @@ -218,7 +218,8 @@ Can an agent find the code, the rules and the documents it needs before it edits `COSMETIC` · scored · engine check · fixed by [`establish-docs-contract`](implement-master.md#establish-docs-contract) -- **Passes when:** a numbered-ADR directory exists, holds at least one entry, and an index lists every entry. +- **Passes when:** a numbered-ADR directory exists, holds at least one entry, an index lists every entry, and no entry + is a tombstone. - **Why it matters:** Architectural decisions need a location and a registry, or the rationale lives only in chat and pull requests, and agents re-litigate settled decisions ([greenfield/README.md § 5.2 Templates & Guides](../../greenfield/README.md#52-templates--guides)). diff --git a/spec/readiness-requirements.md b/spec/readiness-requirements.md index 9d1b4732..feb1674d 100644 --- a/spec/readiness-requirements.md +++ b/spec/readiness-requirements.md @@ -174,7 +174,8 @@ When `artefacts/`, `artifacts/` or a directory where an agent tool keeps its own - **Checks:** DOC-03, CON-03 ### REQ-SH-019 — ADR directory and index -The repository SHOULD have an ADR directory with numbered entries and an index that lists every entry. +The repository SHOULD have an ADR directory with numbered entries and an index that lists every entry. No entry +SHOULD be marked superseded, deprecated or rejected: a retired ADR is deleted, and git keeps its history. - **Phase:** 1 - **Verification:** automated From 58c53072ac41068885aae235d9191419ef792495 Mon Sep 17 00:00:00 2001 From: "K. C. Ramakrishna" Date: Tue, 10 Nov 2026 11:08:04 +0530 Subject: [PATCH 3/3] feat(dev): add adr-lint to make check dev/adr_lint.py fails when an ADR in any of the five ADR folders has a lifecycle field (Status, Date, Last reviewed, Deciders), a Lifecycle section, a retired-ADR marker (the audit's DOC-04 rule, loaded at run time so strict mypy stays on dev/), a missing or reordered template section, a title without its number, or no row in the root index. An index row to a missing ADR fails too. CI runs it in the static job. The policy page, ADR 0009 and the gate listings name it. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 4 +- AGENTS.md | 2 +- Makefile | 10 +- audit/engine/AGENTS.md | 2 +- dev/adr_lint.py | 136 +++++++++++++++++ dev/tests/test_adr_lint.py | 143 ++++++++++++++++++ docs/ADR_POLICY.md | 5 + docs/STYLE_GUIDE.md | 1 + ...009-adrs-hold-only-the-current-decision.md | 2 + docs/reference/branch-protection.md | 2 +- docs/reference/dev-tooling.md | 5 +- 11 files changed, 302 insertions(+), 10 deletions(-) create mode 100644 dev/adr_lint.py create mode 100644 dev/tests/test_adr_lint.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 265f9981..afeae623 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: # suite of `test` has no job here. The steps that are cheap share one job, because each job pays about 15 s of # setup. Python 3.11 runs every step; Python 3.14 runs the runtime code: the suites and the schema cross-check. static: - name: static (format, typecheck, rules, recipe lint, masters, workflow lint, spell) + name: static (format, typecheck, rules, recipe lint, ADR lint, masters, workflow lint, spell) runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -53,7 +53,7 @@ jobs: with: go-version-file: dev/tools/actionlint/go.mod cache-dependency-path: dev/tools/actionlint/go.sum - - run: make format-check typecheck verify-rules recipe-lint masters-check eval-expected-check workflow-lint spell + - run: make format-check typecheck verify-rules recipe-lint adr-lint masters-check eval-expected-check workflow-lint spell crosscheck: name: crosscheck (Python ${{ matrix.python }}) diff --git a/AGENTS.md b/AGENTS.md index 619b7fc4..c921bbf2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ make lint # read-only lint make typecheck # mypy, strict, on audit/tools, implement/tools and dev, and the engine's build.py make test # the engine's, the audit tools', the implement tools' and the dev checks' suites (at once on Linux) make check # THE gate: format:check, typecheck, test, the engine's --verify-rules, the schema - # cross-check, recipe-lint, masters-check, eval-expected-check, workflow-lint (actionlint, + # cross-check, recipe-lint, adr-lint, masters-check, eval-expected-check, workflow-lint (actionlint, # zizmor, check-jsonschema), spell (typos) make security # secrets scan, then the dependency audit (osv-scanner; needs Go and the network) make commit-lint # Conventional Commits subjects on this branch (RANGE=origin/dev..HEAD); CI runs it on each PR diff --git a/Makefile b/Makefile index 2620d757..9b4c87e5 100644 --- a/Makefile +++ b/Makefile @@ -107,13 +107,13 @@ endif ifndef TEST_SUITES_SUBMAKE .NOTPARALLEL: endif -.PHONY: help setup format format-check format\:check lint typecheck test $(TEST_SUITES) test-engine-shard test-audit-tools-shard test-dev-shard test-part coverage coverage-engine coverage-engine-shard coverage-audit-tools-shard coverage-part coverage-audit-tools coverage-implement-tools coverage-combine verify-rules crosscheck recipe-lint masters-check eval-expected-check update-masters check security security-secrets security-deps update-secrets-baseline self-audit update-audit-baseline validate-skills docs-lint docs-lint-all docs-prose docs-mermaid changelog-fix workflow-lint commit-lint spell spell-fix +.PHONY: help setup format format-check format\:check lint typecheck test $(TEST_SUITES) test-engine-shard test-audit-tools-shard test-dev-shard test-part coverage coverage-engine coverage-engine-shard coverage-audit-tools-shard coverage-part coverage-audit-tools coverage-implement-tools coverage-combine verify-rules crosscheck recipe-lint adr-lint masters-check eval-expected-check update-masters check security security-secrets security-deps update-secrets-baseline self-audit update-audit-baseline validate-skills docs-lint docs-lint-all docs-prose docs-mermaid changelog-fix workflow-lint commit-lint spell spell-fix help: @echo "verbs: format format:check (format-check) lint typecheck test check security" @echo "parts: test-engine test-audit-tools test-implement-tools test-dev coverage-engine coverage-audit-tools coverage-implement-tools coverage-combine" @echo "ci: test-part SHARD= coverage-part SHARD= coverage-combine SHARDED=1 (and test--shard, coverage--shard)" - @echo "extra: setup (checks prerequisites, installs nothing) coverage self-audit update-audit-baseline verify-rules crosscheck recipe-lint masters-check eval-expected-check update-masters workflow-lint commit-lint spell spell-fix update-secrets-baseline validate-skills" + @echo "extra: setup (checks prerequisites, installs nothing) coverage self-audit update-audit-baseline verify-rules crosscheck recipe-lint adr-lint masters-check eval-expected-check update-masters workflow-lint commit-lint spell spell-fix update-secrets-baseline validate-skills" @echo "docs: docs-lint docs-lint-all docs-prose [FILES=...] docs-mermaid FILES=... changelog-fix" # There is nothing to install into the project: the runtime code is standard-library Python and the dev tools run @@ -292,6 +292,10 @@ crosscheck: recipe-lint: $(PYTHON) dev/recipe_lint.py . +# Every ADR follows docs/ADR_POLICY.md: no lifecycle fields or tombstones, the four sections, a row in the root index. +adr-lint: + $(PYTHON) dev/adr_lint.py . + # The audit and implement master lists (docs/reference/*-master.md) are generated from the catalogue, the check files # and the playbooks. masters-check fails when either page is stale; update-masters rewrites them. masters-check: @@ -326,7 +330,7 @@ RANGE ?= origin/dev..HEAD commit-lint: $(PYTHON) dev/commit_lint.py --range $(RANGE) -check: format-check typecheck test verify-rules crosscheck recipe-lint masters-check eval-expected-check workflow-lint spell +check: format-check typecheck test verify-rules crosscheck recipe-lint adr-lint masters-check eval-expected-check workflow-lint spell # No third-party runtime dependencies exist to audit, so the gate is a secrets scan. The baseline # lists the deliberate key-shaped strings in the fixtures and tests; a new hit fails the scan. diff --git a/audit/engine/AGENTS.md b/audit/engine/AGENTS.md index 0fd5079e..95b58d56 100644 --- a/audit/engine/AGENTS.md +++ b/audit/engine/AGENTS.md @@ -106,7 +106,7 @@ The framework's verbs are the contract. The runner is the **root `Makefile`** of | `lint` | `make lint` | ruff lint rules over `audit`, `implement` and `dev`, read-only. | | `typecheck` | `make typecheck` | mypy strict, on `audit/tools`, `implement/tools`, `dev` and this directory's `build.py`. The rest of the engine joins later through a ratchet. | | `test` | `make test` | `python3 -m unittest discover -s tests` in this directory, then the audit tools' suite in `audit/tools/tests`, the implement tools' in `implement/tools/tests`, then the dev checks' in `dev/tests`. | -| `check` | `make check` | `format:check`, `typecheck`, `test`, `--verify-rules`, the schema cross-check (`crosscheck`) and `recipe-lint`. | +| `check` | `make check` | `format:check`, `typecheck`, `test`, `--verify-rules`, the schema cross-check (`crosscheck`), `recipe-lint` and `adr-lint`. | | `security` | `make security` | Secrets scan against the committed baseline, then osv-scanner on the dev tools' lockfiles. The engine itself has no dependencies to audit. | The dev tools (ruff, mypy, detect-secrets) are pinned in `dev/tools/pyproject.toml` and run through `uvx`. They diff --git a/dev/adr_lint.py b/dev/adr_lint.py new file mode 100644 index 00000000..6ff62e6c --- /dev/null +++ b/dev/adr_lint.py @@ -0,0 +1,136 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 K. C. Ramakrishna + +"""Check that every ADR in this repository follows ``docs/ADR_POLICY.md``. + + adr_lint.py [root] lint the ADR folders under ``root`` (default: the current directory) + +Each numbered ADR (``NNNN-slug.md``) in :data:`ADR_FOLDERS` must: + +* open with ``# NNNN. Title``, the number matching the file name; +* carry no lifecycle field (Status, Date, Last reviewed, Deciders) and no ``## Lifecycle`` footer; +* not be a tombstone: no superseded, deprecated, rejected or obsolete marker (the audit's DOC-04 rule); +* hold the four template sections, in order: Context, Decision, Alternatives Considered, Consequences; +* have a row in the root index, :data:`INDEX`, which links it by path. + +The index must not link a numbered ADR that does not exist. Exit 0 when all hold, 1 when one fails (each failure is +printed), 2 on a usage error. ``make adr-lint`` runs it inside ``make check``. Standard library only. +""" + +from __future__ import annotations + +import argparse +import importlib +import re +import sys +from pathlib import Path +from typing import Callable, Sequence + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "audit" / "engine")) + +#: The audit's DOC-04 tombstone rule, so this lint and the audit agree. Loaded at run time: a static import would make +#: strict mypy on ``dev/`` type-check the whole engine, which is outside the typecheck ratchet. +_adr_tombstone: Callable[[str], int | None] = importlib.import_module("audit.rules.checks.doc")._adr_tombstone + +ADR_FOLDERS = ( + "docs/architecture/adrs", + "spec/decisions", + "audit/docs/architecture/adrs", + "implement/docs/architecture/adrs", + "audit/engine/docs/architecture/adrs", +) +INDEX = "docs/architecture/adrs/README.md" +SECTIONS = ("Context", "Decision", "Alternatives Considered", "Consequences") + +ADR_FILE = re.compile(r"^(\d{4})-[a-z0-9-]+\.md$") +LIFECYCLE_FIELD = re.compile(r"^[ \t>*_-]*(status|date|last reviewed|deciders)[ \t*_]*:", re.IGNORECASE | re.MULTILINE) +LIFECYCLE_FOOTER = re.compile(r"^#{1,6}\s*lifecycle\b", re.IGNORECASE | re.MULTILINE) +LINK = re.compile(r"\]\(([^)#\s]+)(?:#[^)]*)?\)") + + +def adr_files(root: Path) -> list[Path]: + """Every numbered ADR in :data:`ADR_FOLDERS`, sorted by path.""" + found: list[Path] = [] + for folder in ADR_FOLDERS: + directory = root / folder + if directory.is_dir(): + found += [p for p in directory.iterdir() if p.is_file() and ADR_FILE.match(p.name)] + return sorted(found) + + +def line_of(text: str, offset: int) -> int: + return text.count("\n", 0, offset) + 1 + + +def lint_adr(path: Path, text: str) -> list[str]: + """The policy failures of one ADR, each as ``: ``.""" + problems: list[str] = [] + number = path.name[:4] + first = text.splitlines()[0] if text else "" + if not first.startswith(f"# {number}. "): + problems.append(f"1: the title must open with '# {number}. '") + for match in LIFECYCLE_FIELD.finditer(text): + problems.append(f"{line_of(text, match.start())}: lifecycle field '{match.group(1)}'; git holds it") + for match in LIFECYCLE_FOOTER.finditer(text): + problems.append(f"{line_of(text, match.start())}: lifecycle section; the policy page holds the rules") + tombstone = _adr_tombstone(text) + if tombstone is not None: + problems.append(f"{tombstone}: retired-ADR marker; delete the ADR or overwrite it in place") + headings = re.findall(r"^## (.+?)\s*$", text, re.MULTILINE) + present = [h for h in headings if h in SECTIONS] + if present != list(SECTIONS): + missing = [s for s in SECTIONS if s not in headings] + detail = f"missing {', '.join(missing)}" if missing else "out of order" + problems.append(f"1: sections must be {', '.join(SECTIONS)} ({detail})") + return problems + + +def index_links(root: Path) -> set[Path]: + """The files the root index links to, resolved.""" + index = root / INDEX + text = index.read_text(encoding="utf-8") + return {(index.parent / target).resolve() for target in LINK.findall(text) if "://" not in target} + + +def lint(root: Path) -> list[str]: + """Every failure under ``root``, each as ``:: ``.""" + failures: list[str] = [] + adrs = adr_files(root) + for path in adrs: + rel = path.relative_to(root).as_posix() + failures += [f"{rel}:{problem}" for problem in lint_adr(path, path.read_text(encoding="utf-8"))] + + if not (root / INDEX).is_file(): + return failures + [f"{INDEX}: the ADR index is missing"] + linked = index_links(root) + for path in adrs: + if path.resolve() not in linked: + failures.append(f"{path.relative_to(root).as_posix()}:1: no row in {INDEX}") + folders = {(root / folder).resolve() for folder in ADR_FOLDERS} + for target in sorted(linked): + if target.parent in folders and ADR_FILE.match(target.name) and not target.is_file(): + failures.append(f"{INDEX}: links {target.relative_to(root.resolve()).as_posix()}, which does not exist") + return failures + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("root", nargs="?", default=".", type=Path, help="the repository root") + args = parser.parse_args(argv) + root = args.root.resolve() + if not root.is_dir(): + print(f"adr_lint: not a directory: {args.root}", file=sys.stderr) + return 2 + + failures = lint(root) + for failure in failures: + print(f"adr_lint: {failure}", file=sys.stderr) + if failures: + print("adr_lint: the rules are in docs/ADR_POLICY.md", file=sys.stderr) + return 1 + print(f"adr_lint: {len(adr_files(root))} ADR(s) follow docs/ADR_POLICY.md and are indexed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/dev/tests/test_adr_lint.py b/dev/tests/test_adr_lint.py new file mode 100644 index 00000000..f0a8a6e8 --- /dev/null +++ b/dev/tests/test_adr_lint.py @@ -0,0 +1,143 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 K. C. Ramakrishna + +"""Unit tests for ``adr_lint.py``: each policy rule, the index match, and this repository's own ADRs.""" + +from __future__ import annotations + +import io +import tempfile +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path + +import _dev_paths # noqa: F401 (puts dev/ on sys.path) +import adr_lint + +REPO = Path(__file__).resolve().parents[2] + +GOOD = """# 0001. Use One Thing + +## Context + +Why. + +## Decision + +What. + +## Alternatives Considered + +| Alternative | Why it is rejected | +| --- | --- | +| Another thing | A superseded design would be loaded as current. | + +## Consequences + +- A result. +""" + + +class LintAdr(unittest.TestCase): + def problems(self, text: str, name: str = "0001-use-one-thing.md") -> list[str]: + return adr_lint.lint_adr(Path(name), text) + + def test_a_lean_adr_passes(self) -> None: + self.assertEqual(self.problems(GOOD), []) + + def test_the_title_must_carry_the_file_number(self) -> None: + self.assertEqual(self.problems(GOOD, "0002-use-one-thing.md"), ["1: the title must open with '# 0002. '"]) + + def test_each_lifecycle_field_fails_with_its_line(self) -> None: + for field in ("* **Status:** Accepted", "* **Date:** 2026-11-10", "Last reviewed: 2026-11-10", "- Deciders: x"): + with self.subTest(field=field): + text = GOOD.replace("## Context", f"{field}\n\n## Context", 1) + problems = self.problems(text) + self.assertEqual(len(problems), 1, problems) + self.assertTrue(problems[0].startswith("3: lifecycle field"), problems) + + def test_a_lifecycle_footer_fails(self) -> None: + problems = self.problems(GOOD + "\n## Lifecycle — Living, Current-State ADRs\n\n- Rules.\n") + self.assertEqual(len(problems), 1, problems) + self.assertIn("lifecycle section", problems[0]) + + def test_a_tombstone_fails(self) -> None: + problems = self.problems(GOOD.replace("## Context", "> Superseded by 0002.\n\n## Context", 1)) + self.assertEqual(problems, ["3: retired-ADR marker; delete the ADR or overwrite it in place"]) + + def test_a_missing_section_fails(self) -> None: + text = GOOD.replace("## Alternatives Considered", "## Options", 1) + self.assertEqual( + self.problems(text), + ["1: sections must be " + ", ".join(adr_lint.SECTIONS) + " (missing Alternatives Considered)"], + ) + + def test_sections_out_of_order_fail(self) -> None: + text = ( + GOOD.replace("## Context", "## Swap", 1) + .replace("## Decision", "## Context", 1) + .replace("## Swap", "## Decision", 1) + ) + self.assertEqual( + self.problems(text), ["1: sections must be " + ", ".join(adr_lint.SECTIONS) + " (out of order)"] + ) + + +class LintTree(unittest.TestCase): + def tree(self, files: dict[str, str]) -> Path: + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + root = Path(tmp.name) + for rel, text in files.items(): + path = root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return root + + def test_an_indexed_set_in_two_folders_passes(self) -> None: + root = self.tree( + { + "docs/architecture/adrs/0001-use-one-thing.md": GOOD, + "spec/decisions/0001-use-one-thing.md": GOOD, + adr_lint.INDEX: "[0001](0001-use-one-thing.md)\n[0001](../../../spec/decisions/0001-use-one-thing.md#context)\n", + } + ) + self.assertEqual(adr_lint.lint(root), []) + + def test_an_unindexed_adr_fails(self) -> None: + root = self.tree({"spec/decisions/0001-use-one-thing.md": GOOD, adr_lint.INDEX: "No rows.\n"}) + self.assertEqual(adr_lint.lint(root), [f"spec/decisions/0001-use-one-thing.md:1: no row in {adr_lint.INDEX}"]) + + def test_an_index_row_to_a_deleted_adr_fails(self) -> None: + root = self.tree({adr_lint.INDEX: "[0003](0003-gone.md) and [web](https://example.org/0004-x.md)\n"}) + self.assertEqual( + adr_lint.lint(root), [f"{adr_lint.INDEX}: links docs/architecture/adrs/0003-gone.md, which does not exist"] + ) + + def test_a_missing_index_fails(self) -> None: + root = self.tree({"spec/decisions/0001-use-one-thing.md": GOOD}) + self.assertEqual(adr_lint.lint(root), [f"{adr_lint.INDEX}: the ADR index is missing"]) + + def test_main_returns_one_and_prints_each_failure(self) -> None: + root = self.tree({adr_lint.INDEX: "", "spec/decisions/0001-use-one-thing.md": "# 0001. T\n"}) + err = io.StringIO() + with redirect_stderr(err): + self.assertEqual(adr_lint.main([str(root)]), 1) + self.assertIn("sections must be", err.getvalue()) + self.assertIn("no row in", err.getvalue()) + + def test_main_returns_two_for_a_missing_root(self) -> None: + with redirect_stderr(io.StringIO()): + self.assertEqual(adr_lint.main([str(REPO / "no-such-dir")]), 2) + + +class ThisRepository(unittest.TestCase): + def test_every_adr_here_follows_the_policy(self) -> None: + out = io.StringIO() + with redirect_stdout(out), redirect_stderr(io.StringIO()): + self.assertEqual(adr_lint.main([str(REPO)]), 0) + self.assertIn("follow docs/ADR_POLICY.md", out.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/ADR_POLICY.md b/docs/ADR_POLICY.md index 8b791f6a..2012c32d 100644 --- a/docs/ADR_POLICY.md +++ b/docs/ADR_POLICY.md @@ -36,8 +36,13 @@ Every ADR folder follows it: the root `docs/architecture/adrs/`, `spec/decisions recorded; owner to fill in". 11. **Read only the ADRs you need.** Use the index summaries to pick the ADRs that govern the folder you change. +`make adr-lint`, part of `make check`, checks the mechanical rules in every ADR folder. An ADR has no lifecycle +field or footer and no retired-ADR marker. It holds the four sections, and the root index has its row. + ## Where else this applies +- The audit flags a retired ADR (marked superseded, deprecated or rejected) in an audited repository: its DOC-04 + check gives `PARTIAL`. - The implement recipe writes ADRs into a target repository under the same rules. See the [`establish-docs-contract`](../implement/playbooks/establish-docs-contract.md) playbook. - The greenfield templates ship a copy of this policy for new repositories: diff --git a/docs/STYLE_GUIDE.md b/docs/STYLE_GUIDE.md index 54ede7e1..302225db 100644 --- a/docs/STYLE_GUIDE.md +++ b/docs/STYLE_GUIDE.md @@ -98,6 +98,7 @@ Paths are relative to `docs/`. | `dev/prose_check.py` | Sentence length, vague words, diagrams on explanation pages | Nowhere: advisory warnings and notices | | `dev/mermaid_check.py` | Each Mermaid block renders | The files you pass to `make docs-mermaid` | | `dev/recipe_lint.py` | Links resolve inside the repository | Recipes, `docs/` and the root pages | +| `dev/adr_lint.py` | Every ADR follows [`ADR_POLICY.md`](ADR_POLICY.md) and has a row in the root index | The five ADR folders | `DOCS_LINT_PATHS` is a ratchet: a page joins once it is clean, and the list only grows. Outside the list, the total count of Markdown issues may not go up (`.quality-baseline.json`). When you edit a page outside the list, fix diff --git a/docs/architecture/adrs/0009-adrs-hold-only-the-current-decision.md b/docs/architecture/adrs/0009-adrs-hold-only-the-current-decision.md index 4b1100e3..cac7aa0d 100644 --- a/docs/architecture/adrs/0009-adrs-hold-only-the-current-decision.md +++ b/docs/architecture/adrs/0009-adrs-hold-only-the-current-decision.md @@ -43,6 +43,8 @@ them too. - An agent that loads an ADR gets a rule that holds today. - ADRs stay short, so an agent can load the ones it needs without spending context on history. - A reader sees each rejected option and its current reason in one table. +- The rules are checked, not only written. Here `make adr-lint` fails on a lifecycle field or a retired ADR. + In an audited repository, the audit's DOC-04 check flags a retired ADR. ### Negative / Trade-offs diff --git a/docs/reference/branch-protection.md b/docs/reference/branch-protection.md index 1e6d4644..de46363e 100644 --- a/docs/reference/branch-protection.md +++ b/docs/reference/branch-protection.md @@ -85,7 +85,7 @@ fails when a matrix misses a shard or a part misses a suite: | Job | What it runs | | --- | --- | -| `static` | `make format-check typecheck verify-rules recipe-lint masters-check eval-expected-check workflow-lint spell` | +| `static` | `make format-check typecheck verify-rules recipe-lint adr-lint masters-check eval-expected-check workflow-lint spell` | | `crosscheck` | `make crosscheck` on Python 3.11 and 3.14 | | `test-coverage` | `make coverage-part`, one job per shard: a shard of the engine's and of the audit tools' suites, and the implement tools in part 1, under coverage.py on Python 3.11; each part runs its targets at once | | `test-dev` | `make test-dev` on Python 3.11 | diff --git a/docs/reference/dev-tooling.md b/docs/reference/dev-tooling.md index d20e7322..dfa38903 100644 --- a/docs/reference/dev-tooling.md +++ b/docs/reference/dev-tooling.md @@ -17,6 +17,7 @@ and where its version is pinned. The rules for the gates are in [`AGENTS.md`](.. | coverage.py | Test coverage below the floor in `.coveragerc` | `make coverage` (in CI: `coverage-part`, then `coverage-combine SHARDED=1`) | `test-coverage`, `coverage`; unsharded in `nightly.yml` | `dev/tools/pyproject.toml` | | jsonschema | The tools' schema validator disagrees with the reference package | `make crosscheck` | `crosscheck` | `dev/tools/pyproject.toml` | | `dev/recipe_lint.py` | Broken links, bad `SKILL.md` frontmatter, a recipe `SKILL.md` that does not link the shared user prompt, oversize files, unknown tools, check IDs and requirement IDs | `make recipe-lint` | `static` | This repository | +| `dev/adr_lint.py` | An ADR with a lifecycle field, a lifecycle footer or a retired-ADR marker, a missing template section, or no row in the [ADR index](../architecture/adrs/README.md); an index row to a missing ADR | `make adr-lint` | `static` | This repository | | `dev/build_masters.py` | An [audit master list](audit-master.md) or [implement master list](implement-master.md) that no longer matches the catalogue, the check files or the playbooks | `make masters-check`; `make update-masters` rewrites both pages | `static` | This repository | | `dev/eval/generate_fixture_expected.py` | An eval-expected file (`dev/eval/expected/fixtures/`) that no longer matches a fresh engine run on its fixture | `make eval-expected-check`; the script without `--check` rewrites the files | `static` | This repository | | actionlint | Workflow mistakes: syntax, expressions, action inputs, shellcheck on `run:` blocks | `make workflow-lint` | `static` | `dev/tools/actionlint/go.mod` | @@ -40,8 +41,8 @@ and where its version is pinned. The rules for the gates are in [`AGENTS.md`](.. | Go | Builds and runs actionlint and osv-scanner through `go tool` | — | `check`, `security` | the `go` line of each Go tool module | `make check` runs `format:check`, `typecheck`, `test`, the engine's rule check, `crosscheck`, `recipe-lint`, -`masters-check`, `eval-expected-check`, `workflow-lint` and `spell`, in sequence. CI's `CI Gate` passes only when -the `check`, `security`, `coverage`, `self-audit`, `docs` and `commits` jobs pass ([Branch protection](branch-protection.md#the-required-checks)). +`adr-lint`, `masters-check`, `eval-expected-check`, `workflow-lint` and `spell`, in sequence. CI's `CI Gate` +passes only when the `check`, `security`, `coverage`, `self-audit`, `docs` and `commits` jobs pass ([Branch protection](branch-protection.md#the-required-checks)). ## Spell check