From 361d826dc391ac31442e2f59e4b89a8c0122a3a5 Mon Sep 17 00:00:00 2001 From: Chris Pezza <153260989+chrispezza@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:03:08 -0400 Subject: [PATCH] feat(checks): generate the ADR index and check amendment back-links Every hand-kept summary of the ADRs had drifted from the records. The index is now derived, and a check fails when it is stale. - Add checks/adr_index.py: renders docs/adrs/README.md from ADR titles and status lines, the enforcement config, and declared amendments - Add adr-index (ADR 0012 TC-3) and adr-amendment-link (TC-4) at warn - Restrict adr-status to numbered ADR files so the index is not read as one - Replace the README ADR table with a pointer to the index - Narrow the guard's inline-script rule after two false positives; add a regression test - Record all of it as an appended amendment to ADR 0012 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 + CONTRIBUTING.md | 4 +- README.md | 24 +-- checks/README.md | 1 + checks/adr_index.py | 175 ++++++++++++++++++ checks/enforcement.config.json | 8 +- checks/hooks/pretooluse_guard.py | 18 +- checks/run_checks.py | 31 +++- checks/tests/test_adr_index.py | 97 ++++++++++ checks/tests/test_pretooluse_guard.py | 13 ++ .../adrs/0012-adr-enforcement-architecture.md | 49 +++++ docs/adrs/README.md | 49 +++++ 12 files changed, 441 insertions(+), 31 deletions(-) create mode 100644 checks/adr_index.py create mode 100644 checks/tests/test_adr_index.py create mode 100644 docs/adrs/README.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a16cf7..8fc181c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,9 +12,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `checks/` — warn-only enforcement suite (14 checks: frontmatter, body structure, placeholders, dependency graph, word limits, manifest via `claude plugin validate --strict`, component census, legacy guards, self-containment, spec fixture, ADR status, filename convention); config `checks/enforcement.config.json`, CI job in `validate.yml` - Enforcement sections appended to all 11 prior ADRs: testable consequences, check mappings, honest not-machine-checkable lines, empty graduation logs - Two blocking hooks in checked-in `.claude/settings.json`: Stop-gate (suite BLOCKERs bounce the turn) and PreToolUse guard (existing ADRs append-only, Pattern Guide requires an ADR); kill-switches `PRODUCT_DEV_SKIP_STOP_GATE=1` / `PRODUCT_DEV_SKIP_ADR_GUARD=1` +- ADR index, generated: `checks/adr_index.py` renders the index of records (status, enforcing checks, amended-by links) from the ADR files and the enforcement config. Two new warn-level checks (ADR 0012 TC-3, TC-4): `adr-index` fails when the committed index is stale, `adr-amendment-link` fails when an ADR says it amends another and the amended one never names it - `skills-claude-ai/` — Claude.ai Agent Skill ports of product-ideation and product-flow (issue #12): bundled prompts with skill-relative paths, conversation-scoped artifact ledger with `product-dev-artifacts.md` export replacing the `.product-dev/` registry ### Changed +- README: the hand-kept ADR table is replaced by a pointer to the generated index +- PreToolUse guard: the Bash rule for inline scripts now requires an existing protected file or iteration of the ADR folder, after it blocked two commands that only mentioned the folder in text - Enforcement suite: first graduation (ADR 0012 amendment, 2026-10-12). Twelve checks moved warn → block after 7+ clean days: `frontmatter-v2`, `name-uniqueness`, `body-structure`, `placeholder-syntax`, `placeholder-resolvability`, `dependency-graph`, `component-census`, `no-legacy`, `spec-fixture`, `adr-status`, `filename-convention`, `objective-parity`. Findings from these now fail CI and bounce the Stop-gate. `manifest`, `word-limit`, and `self-containment` stay at warn, with reasons in the amendment - ADRs 0003, 0004, 0008, 0013: appended amendments recording what later ADRs changed (the 6-command / 5-skill / 2-subagent census, registry extensions, the reverse-pass checks that had landed) - PR and bug-report templates, README, `scripts/README.md`, CONTRIBUTING: removed references to the deleted MCP server and to script paths that moved into `plugin/scripts/`; corrected component counts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 28e41c9..348eff7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -161,9 +161,9 @@ When making architectural decisions: 3. Set status to "Proposed" 4. The repo owner updates status to "Accepted" after review -Existing ADRs are append-only. To change one, append a dated `## Amendment (YYYY-MM-DD)` section at the end of the file, or write a new ADR that supersedes it. When a new ADR amends an older one, append a note to the older one saying so. In Claude Code sessions a hook (`checks/hooks/pretooluse_guard.py`) blocks rewrites and allows appends. +Existing ADRs are append-only. To change one, append a dated `## Amendment (YYYY-MM-DD)` section at the end of the file, or write a new ADR that supersedes it. When a new ADR amends an older one, say so in the new one with the words "amends ADR NNNN" and append a note to the older one naming the new one (the `adr-amendment-link` check verifies the pair). In Claude Code sessions a hook (`checks/hooks/pretooluse_guard.py`) blocks rewrites and allows appends. -Run `python checks/run_checks.py --ci` before pushing. Checks at `block` fail CI; see `checks/README.md`. +After adding or amending an ADR, regenerate the index with `python checks/adr_index.py --write` (never edit the index by hand). Run `python checks/run_checks.py --ci` before pushing. Checks at `block` fail CI; see `checks/README.md`. ## Code Style diff --git a/README.md b/README.md index 3297129..f8d9482 100644 --- a/README.md +++ b/README.md @@ -135,27 +135,9 @@ All IDs are cross-referenced. The validation pipeline checks 20 rules across ref ## Architecture Decisions -Key decisions are documented as ADRs in `docs/adrs/`: - -| ADR | Decision | -|-----|----------| -| [0001](docs/adrs/0001-prompt-frontmatter-schema.md) | YAML frontmatter as canonical prompt metadata | -| [0002](docs/adrs/0002-mcp-prompt-packaging.md) | MCP prompt packaging (superseded by ADR 0008; server removed) | -| [0003](docs/adrs/0003-context-registry-state-management.md) | File-based context registry at `.product-dev/` | -| [0004](docs/adrs/0004-skill-subagent-decomposition.md) | Skill / command / subagent decomposition (amended by ADR 0013) | -| [0005](docs/adrs/0005-prompt-enhancement-schema.md) | Prompt enhancement schema | -| [0006](docs/adrs/0006-progressive-disclosure-tiered-engagement.md) | Progressive disclosure and tiered engagement | -| [0007](docs/adrs/0007-json-index-deprecation.md) | JSON index deprecation | -| [0008](docs/adrs/0008-plugin-architecture.md) | Plugin as delivery vehicle | -| [0009](docs/adrs/0009-prompt-enhancement-pattern.md) | Prompt Enhancement Pattern v2 | -| [0010](docs/adrs/0010-spec-package-compilation-target.md) | Spec package as compilation target | -| [0011](docs/adrs/0011-plugin-self-containment.md) | Plugin self-containment via `${CLAUDE_PLUGIN_ROOT}` | -| [0012](docs/adrs/0012-adr-enforcement-architecture.md) | ADR enforcement architecture | -| [0013](docs/adrs/0013-ux-optimization-reverse-pass.md) | UX optimization reverse pass (`/optimize`) | -| [0014](docs/adrs/0014-live-site-audit-and-objective.md) | Live-site audit mode + objective metrics | -| [0015](docs/adrs/0015-runtime-audit-mode.md) | Runtime audit mode for locally runnable products | -| [0016](docs/adrs/0016-gstack-derived-patterns.md) | gstack patterns: interrogation, candidate directions, provenance | -| [0017](docs/adrs/0017-gstack-derived-patterns-second-wave.md) | gstack patterns: quality loops, decision classification, scope walk, learnings | +Decisions are recorded as ADRs. The index — one row per record with its status, +the checks that enforce it, and what amended it — is generated from the +records themselves: [docs/adrs/README.md](docs/adrs/README.md). ## ADR Enforcement diff --git a/checks/README.md b/checks/README.md index 21df406..5d5e1e9 100644 --- a/checks/README.md +++ b/checks/README.md @@ -13,6 +13,7 @@ python checks/run_checks.py --json report.json # + machine-readable report python checks/run_checks.py --check word-limit # single check python checks/run_checks.py --list # checks, statuses, graduations python checks/run_checks.py --ci # exit 1 iff BLOCKERs (CI mode) +python checks/adr_index.py --write # regenerate the ADR index ``` ## Read the report diff --git a/checks/adr_index.py b/checks/adr_index.py new file mode 100644 index 0000000..45a0be8 --- /dev/null +++ b/checks/adr_index.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""Generate and verify the ADR index (docs/adrs/README.md). See ADR 0012, +amendment 2026-10-12. + +The index is derived, never hand-edited: titles and statuses come from the ADR +files, the enforcing checks from checks/enforcement.config.json, and the +"amended by" links from the ADR texts themselves. The `adr-index` check fails +when the committed file differs from what this module renders, so the index +cannot drift the way hand-kept tables did. + +Usage: + python checks/adr_index.py # print the index + python checks/adr_index.py --write # regenerate docs/adrs/README.md + python checks/adr_index.py --check # exit 1 if the file is stale +""" +import json +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +ADRS = ROOT / "docs" / "adrs" +CONFIG = ROOT / "checks" / "enforcement.config.json" +INDEX_NAME = "README.md" + +ADR_FILE = re.compile(r"^(\d{4})-.+\.md$") +STATUS_WORD = re.compile(r"^(Proposed|Accepted|Deferred|Superseded)\b") +# Phrases by which one ADR declares that it changes another. Deliberately +# narrow: "would amend ADR 0010" or "see ADR 0004" are not declarations. +AMEND_PATTERNS = [ + re.compile(r"\bamends\s+ADR\s+(\d{4})", re.IGNORECASE), + re.compile(r"\bADR\s+(\d{4})\s+\([^)]*—\s*amended\b", re.IGNORECASE), + re.compile(r"\bsupersedes\s+ADR\s+(\d{4})", re.IGNORECASE), + re.compile(r"\bSupersedes:\*{0,2}[^.\n]*?\bADR\s+(\d{4})"), +] +IN_FILE_AMENDMENT = re.compile(r"^#{2,3} Amendment \((\d{4}-\d{2}-\d{2})\)", re.MULTILINE) + + +def adr_files(adr_dir=ADRS): + """Numbered ADR files only, as [(number, path)] in order.""" + out = [] + for f in sorted(adr_dir.glob("*.md")): + m = ADR_FILE.match(f.name) + if m: + out.append((m.group(1), f)) + return out + + +def parse_adr(number, path): + text = path.read_text() + title = re.search(r"^#\s+ADR\s+\d{4}:\s*(.+?)\s*$", text, re.MULTILINE) + status_line = "" + m = re.search(r"^## Status\s*\n+([^\n]+)", text, re.MULTILINE) + if m: + status_line = m.group(1).strip().lstrip("> ").strip() + word = STATUS_WORD.match(status_line) + status = word.group(1) if word else "Unknown" + if status == "Superseded": + by = re.search(r"ADR (\d{4})", status_line) + if by: + status = f"Superseded by {by.group(1)}" + return { + "number": number, + "file": path.name, + "title": title.group(1) if title else path.stem, + "status": status, + "text": text, + "amendment_dates": sorted(IN_FILE_AMENDMENT.findall(text)), + } + + +def declared_amendments(adrs): + """{target_number: sorted([amender_number, ...])} from the ADR texts.""" + known = {a["number"] for a in adrs} + out = {} + for a in adrs: + for pat in AMEND_PATTERNS: + for target in pat.findall(a["text"]): + if target != a["number"] and target in known: + out.setdefault(target, set()).add(a["number"]) + return {k: sorted(v) for k, v in out.items()} + + +def missing_backlinks(adrs): + """[(amender, target)] where the amender declares it amends the target + but the target never mentions the amender.""" + by_number = {a["number"]: a for a in adrs} + out = [] + for target, amenders in sorted(declared_amendments(adrs).items()): + for amender in amenders: + if not re.search(rf"\bADR[ -]?{amender}\b", by_number[target]["text"]): + out.append((amender, target)) + return out + + +def checks_by_adr(config): + out = {} + for c in config.get("checks", []): + for number in re.findall(r"\b(\d{4})\b", c.get("adr", "")): + out.setdefault(number, []).append((c["id"], c["status"])) + return out + + +def render(adr_dir=ADRS, config_path=CONFIG): + adrs = [parse_adr(n, p) for n, p in adr_files(adr_dir)] + amended_by = declared_amendments(adrs) + checks = checks_by_adr(json.loads(config_path.read_text())) + lines = [ + "", + "", + "# Architecture Decision Records", + "", + "One row per record. This table tells you which record to open; it does not", + "replace reading it. A record's later `## Amendment` sections and the ADRs", + "listed under \"Amended by\" override its earlier text.", + "", + "| ADR | Decision | Status | Enforced by | Amended by | In-file amendments |", + "|-----|----------|--------|-------------|------------|--------------------|", + ] + for a in adrs: + enforced = ", ".join(f"`{cid}` ({status})" for cid, status in checks.get(a["number"], [])) + amenders = ", ".join(amended_by.get(a["number"], [])) + dates = a["amendment_dates"] + in_file = f"{len(dates)}, latest {dates[-1]}" if dates else "" + lines.append( + f"| [{a['number']}]({a['file']}) | {a['title']} | {a['status']} | " + f"{enforced or 'no dedicated check'} | {amenders or '—'} | {in_file or '—'} |" + ) + lines += [ + "", + "## Reading the columns", + "", + "- **Status** — `Accepted` records are in force. `Superseded` and `Deferred`", + " records are history: do not build on them.", + "- **Enforced by** — checks in `checks/run_checks.py` whose config entry names", + " the record. `block` fails CI and stops a Claude Code session from finishing;", + " `warn` only reports. \"No dedicated check\" means the record's own", + " `## Enforcement` section says what, if anything, covers it.", + "- **Amended by** — later ADRs that state they amend or supersede part of the", + " record. The `adr-amendment-link` check requires the amended record to name", + " each of them.", + "", + "## Changing a record", + "", + "Existing records are append-only (ADR 0012). Append a dated", + "`## Amendment (YYYY-MM-DD)` section to the end of the file, or write a new", + "numbered ADR that says which record it amends. Then run", + "`python checks/adr_index.py --write`.", + "", + ] + return "\n".join(lines) + + +def main(argv): + index_path = ADRS / INDEX_NAME + content = render() + if "--write" in argv: + index_path.write_text(content) + print(f"wrote {index_path.relative_to(ROOT)}") + return 0 + if "--check" in argv: + current = index_path.read_text() if index_path.exists() else None + if current != content: + print(f"{index_path.relative_to(ROOT)} is missing or stale — run: python checks/adr_index.py --write", + file=sys.stderr) + return 1 + return 0 + sys.stdout.write(content) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/checks/enforcement.config.json b/checks/enforcement.config.json index 7f97d5e..04956b8 100644 --- a/checks/enforcement.config.json +++ b/checks/enforcement.config.json @@ -10,12 +10,14 @@ { "id": "dependency-graph", "adr": "0003", "tc": "0003:TC-4", "status": "block", "added": "2026-07-12", "graduated": "2026-10-12" }, { "id": "word-limit", "adr": "0009", "tc": "0009:TC-5", "status": "warn", "added": "2026-07-12", "graduated": null }, { "id": "manifest", "adr": "0008", "tc": "0008:TC-1,TC-3", "status": "warn", "added": "2026-07-12", "graduated": null }, - { "id": "component-census", "adr": "0004, 0008", "tc": "0004:TC-1,TC-2,TC-3,TC-4; 0008:TC-2", "status": "block", "added": "2026-07-12", "graduated": "2026-10-12" }, + { "id": "component-census", "adr": "0004, 0008, 0013", "tc": "0004:TC-1,TC-2,TC-3,TC-4; 0008:TC-2", "status": "block", "added": "2026-07-12", "graduated": "2026-10-12" }, { "id": "no-legacy", "adr": "0001, 0002, 0005, 0007", "tc": "0001:TC-2; 0002:TC-1; 0005:TC-1; 0007:TC-1,TC-2", "status": "block", "added": "2026-07-12", "graduated": "2026-10-12" }, { "id": "self-containment", "adr": "0011", "tc": "0011:TC-1,TC-2", "status": "warn", "added": "2026-07-12", "graduated": null }, { "id": "spec-fixture", "adr": "0010", "tc": "0010:TC-1,TC-2", "status": "block", "added": "2026-07-12", "graduated": "2026-10-12" }, - { "id": "adr-status", "adr": "0002, process", "tc": "0002:TC-2; process:adr-status", "status": "block", "added": "2026-07-12", "graduated": "2026-10-12" }, + { "id": "adr-status", "adr": "0002, 0012, process", "tc": "0002:TC-2; process:adr-status", "status": "block", "added": "2026-07-12", "graduated": "2026-10-12" }, { "id": "filename-convention", "adr": "CONTRIBUTING", "tc": "CONTRIBUTING:naming-convention", "status": "block", "added": "2026-07-12", "graduated": "2026-10-12" }, - { "id": "objective-parity", "adr": "0014", "tc": "0014:TC-parity", "status": "block", "added": "2026-07-25", "graduated": "2026-10-12" } + { "id": "objective-parity", "adr": "0014", "tc": "0014:TC-parity", "status": "block", "added": "2026-07-25", "graduated": "2026-10-12" }, + { "id": "adr-index", "adr": "0012", "tc": "0012:TC-3", "status": "warn", "added": "2026-10-12", "graduated": null }, + { "id": "adr-amendment-link", "adr": "0012", "tc": "0012:TC-4", "status": "warn", "added": "2026-10-12", "graduated": null } ] } diff --git a/checks/hooks/pretooluse_guard.py b/checks/hooks/pretooluse_guard.py index eb99ca7..f42a2f1 100644 --- a/checks/hooks/pretooluse_guard.py +++ b/checks/hooks/pretooluse_guard.py @@ -132,6 +132,11 @@ def guard_file_tool(tool_name, tool_input, project_dir): r"|File\.write|os\.remove|os\.rename|os\.replace|shutil\.|\.unlink\(" ) PROTECTED_MENTION = re.compile(r"docs/adrs|00_ENHANCEMENT_PATTERN") +EMBEDDED_PATH = re.compile(r"[\w./~-]*(?:docs/adrs/[\w.*?\[\]-]+|docs/updates/00_ENHANCEMENT_PATTERN\.md)") +ADR_DIR_ITERATION = re.compile( + r"docs/adrs/?[\"'][^\n]{0,80}?(?:glob|iterdir|listdir|scandir|walk|readdir)" + r"|(?:glob|iterdir|listdir|scandir|walk|readdir)[^\n]{0,80}?docs/adrs" +) def strip_quotes(token): @@ -179,12 +184,17 @@ def bash_violations(command, bases, project_dir): for t in tokens: if t.startswith("of="): hits += existing_protected(t[3:], bases, project_dir) - # An inline interpreter script (python -c, a heredoc) that names a - # protected path and contains a write call. The path sits inside code, so - # it cannot be resolved token by token. + # An inline interpreter script (python -c, a heredoc) that contains a + # write call and either names an existing protected file or iterates the + # ADR directory. The path sits inside code, so it is pulled out by pattern + # rather than token by token. Merely mentioning docs/adrs in text the + # script writes elsewhere is not enough — that was a false positive. words = {os.path.basename(strip_quotes(t)) for t in command.split()} if not hits and words & INTERPRETERS and INLINE_WRITE.search(command): - hits.append(Path("an inline script that names docs/adrs or the Pattern Guide and writes files")) + for m in EMBEDDED_PATH.finditer(command): + hits += existing_protected(m.group(0), bases, project_dir) + if not hits and ADR_DIR_ITERATION.search(command): + hits.append(Path("an inline script that iterates docs/adrs and writes files")) return sorted(set(hits), key=str) diff --git a/checks/run_checks.py b/checks/run_checks.py index b3799cf..1537144 100644 --- a/checks/run_checks.py +++ b/checks/run_checks.py @@ -21,6 +21,8 @@ import yaml +import adr_index + ROOT = Path(__file__).resolve().parent.parent CHECKS_DIR = Path(__file__).resolve().parent PROMPTS = ROOT / "plugin" / "prompts" @@ -359,7 +361,8 @@ def check_spec_fixture(ctx): def check_adr_status(ctx): out = [] valid = re.compile(r"^(Proposed|Accepted|Deferred|Superseded)\b") - for f in sorted(ADRS.glob("*.md")): + # Numbered ADR files only: the generated index (README.md) is not an ADR. + for _, f in adr_index.adr_files(ADRS): rel = str(f.relative_to(ROOT)) text = f.read_text() m = re.search(r"^## Status\s*\n+([^\n]+)", text, re.MULTILINE) @@ -376,6 +379,30 @@ def check_adr_status(ctx): return out +def check_adr_index(ctx): + """ADR 0012 TC-3: the ADR index is generated, so it must equal what the + generator renders from the ADR files and the enforcement config.""" + out = [] + index = ADRS / adr_index.INDEX_NAME + rel = str(index.relative_to(ROOT)) + if not index.exists(): + out.append(finding("0012", "TC-3", "ADR index missing — generate it with `python checks/adr_index.py --write`", rel)) + elif index.read_text() != adr_index.render(ADRS, CHECKS_DIR / "enforcement.config.json"): + out.append(finding("0012", "TC-3", "ADR index is stale (an ADR title, status, amendment, or check status changed) — regenerate with `python checks/adr_index.py --write`; never edit it by hand", rel)) + return out + + +def check_adr_amendment_link(ctx): + """ADR 0012 TC-4: when ADR X says it amends ADR Y, Y must name X, so a + reader of the amended record learns it was changed.""" + out = [] + adrs = [adr_index.parse_adr(n, p) for n, p in adr_index.adr_files(ADRS)] + for amender, target in adr_index.missing_backlinks(adrs): + target_file = next(a["file"] for a in adrs if a["number"] == target) + out.append(finding("0012", "TC-4", f"ADR {amender} says it amends ADR {target}, but ADR {target} never mentions ADR {amender} — append a dated `## Amendment` note to ADR {target} naming ADR {amender} (appends pass the guard)", str((ADRS / target_file).relative_to(ROOT)))) + return out + + def check_objective_parity(ctx): """ADR 0014: forward (02_objectives) and reverse (07_objective_metrics) passes both produce `core_objective`. Downstream consumers must be able to substitute @@ -435,6 +462,8 @@ def check_filename_convention(ctx): "adr-status": check_adr_status, "filename-convention": check_filename_convention, "objective-parity": check_objective_parity, + "adr-index": check_adr_index, + "adr-amendment-link": check_adr_amendment_link, } diff --git a/checks/tests/test_adr_index.py b/checks/tests/test_adr_index.py new file mode 100644 index 0000000..6f90058 --- /dev/null +++ b/checks/tests/test_adr_index.py @@ -0,0 +1,97 @@ +"""Tests for the ADR index generator and the amendment back-link rule +(ADR 0012 TC-3, TC-4). Run: python -m unittest discover -s checks/tests +""" +import json +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import adr_index # noqa: E402 + + +def adr(number, title, status, body=""): + return f"# ADR {number}: {title}\n\n## Status\n\n{status}\n\n## Decision\n\n{body}\n" + + +class AdrIndexCase(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.dir = Path(self._tmp.name) / "adrs" + self.dir.mkdir() + self.config = Path(self._tmp.name) / "config.json" + self.config.write_text(json.dumps({"checks": [ + {"id": "alpha", "adr": "0001, 0002", "status": "block"}, + {"id": "beta", "adr": "0002, process", "status": "warn"}, + ]})) + + def tearDown(self): + self._tmp.cleanup() + + def write(self, name, text): + (self.dir / name).write_text(text) + + def parsed(self): + return [adr_index.parse_adr(n, p) for n, p in adr_index.adr_files(self.dir)] + + def test_only_numbered_files_are_adrs(self): + self.write("0001-first.md", adr("0001", "First", "Accepted")) + self.write("README.md", "# index\n") + self.write("notes.md", "scratch\n") + self.assertEqual([n for n, _ in adr_index.adr_files(self.dir)], ["0001"]) + + def test_status_is_normalised_and_supersession_names_the_successor(self): + self.write("0001-first.md", adr("0001", "First", "Accepted (Amended 2026-04-04)")) + self.write("0002-second.md", adr("0002", "Second", "Superseded by ADR 0003 (2026-07-11)")) + self.write("0003-third.md", adr("0003", "Third", "> Deferred — later")) + self.assertEqual([a["status"] for a in self.parsed()], + ["Accepted", "Superseded by 0003", "Deferred"]) + + def test_render_lists_checks_with_status_and_counts_in_file_amendments(self): + self.write("0001-first.md", adr("0001", "First", "Accepted") + + "\n## Amendment (2026-07-25): a\n\n## Amendment (2026-10-12): b\n\n## Amendment (2026-10-12): c\n") + self.write("0002-second.md", adr("0002", "Second", "Accepted")) + self.write("0003-third.md", adr("0003", "Third", "Accepted")) + out = adr_index.render(self.dir, self.config) + self.assertIn("| [0001](0001-first.md) | First | Accepted | `alpha` (block) | — | 3, latest 2026-10-12 |", out) + self.assertIn("`alpha` (block), `beta` (warn)", out) + self.assertIn("| [0003](0003-third.md) | Third | Accepted | no dedicated check | — | — |", out) + + def test_render_is_deterministic(self): + self.write("0001-first.md", adr("0001", "First", "Accepted")) + self.assertEqual(adr_index.render(self.dir, self.config), adr_index.render(self.dir, self.config)) + + def test_declared_amendments_use_narrow_phrases(self): + self.write("0001-first.md", adr("0001", "First", "Accepted")) + self.write("0002-second.md", adr("0002", "Second", "Accepted")) + self.write("0003-third.md", adr("0003", "Third", "Accepted", + "This amends ADR 0001's counts. It would amend ADR 0002 later. See ADR 0002.")) + self.write("0004-fourth.md", adr("0004", "Fourth", "Accepted", + "- ADR 0002 (registry — amended by the new field)\n**Supersedes:** the path approach in ADR 0001.")) + self.assertEqual(adr_index.declared_amendments(self.parsed()), + {"0001": ["0003", "0004"], "0002": ["0004"]}) + + def test_missing_backlink_is_reported_until_the_target_names_the_amender(self): + self.write("0001-first.md", adr("0001", "First", "Accepted")) + self.write("0002-second.md", adr("0002", "Second", "Accepted", "This amends ADR 0001.")) + self.assertEqual(adr_index.missing_backlinks(self.parsed()), [("0002", "0001")]) + with (self.dir / "0001-first.md").open("a") as f: + f.write("\n## Amendment (2026-10-12)\n\nExtended by ADR 0002.\n") + self.assertEqual(adr_index.missing_backlinks(self.parsed()), []) + + def test_an_adr_cannot_amend_itself_or_an_unknown_record(self): + self.write("0001-first.md", adr("0001", "First", "Accepted", "This amends ADR 0001 and amends ADR 0099.")) + self.assertEqual(adr_index.declared_amendments(self.parsed()), {}) + + +class RepoIndexCase(unittest.TestCase): + def test_committed_index_matches_the_generator(self): + index = adr_index.ADRS / adr_index.INDEX_NAME + self.assertTrue(index.exists(), "run: python checks/adr_index.py --write") + self.assertEqual(index.read_text(), adr_index.render(), + "docs/adrs/README.md is stale — run: python checks/adr_index.py --write") + + +if __name__ == "__main__": + unittest.main() diff --git a/checks/tests/test_pretooluse_guard.py b/checks/tests/test_pretooluse_guard.py index 3e02301..ea8ec5f 100644 --- a/checks/tests/test_pretooluse_guard.py +++ b/checks/tests/test_pretooluse_guard.py @@ -144,6 +144,19 @@ def test_bash_reads_and_unrelated_writes_are_allowed(self): with self.subTest(command=command): self.assertAllowed("Bash", {"command": command}) + def test_inline_script_that_iterates_the_adr_directory_and_writes_is_blocked(self): + command = ("python3 - <<'EOF'\nfrom pathlib import Path\n" + "for f in Path('docs/adrs').glob('*.md'):\n f.write_text(f.read_text().replace('a', 'b'))\nEOF") + self.assertBlocked("Bash", {"command": command}) + + def test_inline_script_that_only_mentions_the_adr_folder_in_text_is_allowed(self): + # Regression: editing CONTRIBUTING.md with a heredoc script whose + # replacement text talks about docs/adrs was blocked on day one. + command = ("python3 - <<'EOF'\nfrom pathlib import Path\n" + "p = Path('notes.md')\n" + "p.write_text(p.read_text() + 'Create docs/adrs/NNNN-short-title.md; see ls docs/adrs/ and docs/adrs/{name}')\nEOF") + self.assertAllowed("Bash", {"command": command}) + def test_bash_may_create_a_new_adr_file(self): self.assertAllowed("Bash", {"command": "cat > docs/adrs/0002-new.md <<'EOF'\n# ADR 0002\nEOF"}) diff --git a/docs/adrs/0012-adr-enforcement-architecture.md b/docs/adrs/0012-adr-enforcement-architecture.md index 27f4bf6..5dd2c9b 100644 --- a/docs/adrs/0012-adr-enforcement-architecture.md +++ b/docs/adrs/0012-adr-enforcement-architecture.md @@ -255,3 +255,52 @@ all have declared word limits, so the "77 uncovered" in §7 is resolved). **Graduation log entry (2026-10-12):** `adr-status` (TC-1) and `filename-convention`: warn → block, basis as above. + +--- + +## Amendment (2026-10-12): Generated ADR index and amendment back-links + +The same review found that every hand-kept summary of the ADRs had drifted: +the README table called ADR 0002 "deferred" after it was superseded, ADR 0004 +still stated counts ADR 0013 had amended, and CLAUDE.md repeated them. An +index is worth having — an agent needs to know which record to open — but +only one that cannot go stale. + +**Decision:** + +- **`docs/adrs/README.md` is the ADR index, and it is generated.** + `checks/adr_index.py` renders it from each ADR's title and Status line, from + `checks/enforcement.config.json` (which checks name the ADR, and at what + status), and from the ADR texts (which later ADRs amend it; how many in-file + amendments it carries). It is never edited by hand. Regenerate with + `python checks/adr_index.py --write`. README.md and CLAUDE.md point at it + instead of keeping their own tables. +- **An amended ADR names its amender.** When an ADR states that it amends or + supersedes another ("amends ADR NNNN", "ADR NNNN (… — amended …)", + "supersedes ADR NNNN"), the amended ADR must mention the amending ADR's + number — normally through an appended, dated amendment note. The trigger + phrases are deliberately narrow so that "see ADR NNNN" or "would amend + ADR NNNN" are not read as declarations. + +**Testable consequences added to this ADR:** + +- TC-3: `docs/adrs/README.md` equals the generator's output → + `checks/run_checks.py :: adr-index` (status: **warn**, added 2026-10-12). +- TC-4: for every declared amendment, the amended ADR mentions the amender → + `checks/run_checks.py :: adr-amendment-link` (status: **warn**, added + 2026-10-12). + +Both start at warn and graduate under §5. The suite now has 17 checks. Both +are covered by `checks/tests/test_adr_index.py`, which also fails when the +committed index is stale. + +**Guard refinement, same day.** The Bash rule for inline scripts first blocked +any script that mentioned `docs/adrs` and contained a write call. Within an +hour it blocked two legitimate commands that only named the folder in text +written to other files. It now blocks an inline script only when it names an +existing protected file or iterates the ADR directory, and contains a write +call. A regression test covers the false positive. + +**Not machine-checkable:** an amendment that changes another ADR without using +one of the trigger phrases is invisible to TC-4; and the index says which +record to open, not what it means. diff --git a/docs/adrs/README.md b/docs/adrs/README.md new file mode 100644 index 0000000..a762a1c --- /dev/null +++ b/docs/adrs/README.md @@ -0,0 +1,49 @@ + + +# Architecture Decision Records + +One row per record. This table tells you which record to open; it does not +replace reading it. A record's later `## Amendment` sections and the ADRs +listed under "Amended by" override its earlier text. + +| ADR | Decision | Status | Enforced by | Amended by | In-file amendments | +|-----|----------|--------|-------------|------------|--------------------| +| [0001](0001-prompt-frontmatter-schema.md) | Prompt Frontmatter Schema & Source of Truth | Accepted | `frontmatter-v2` (block), `no-legacy` (block) | — | 1, latest 2026-07-25 | +| [0002](0002-mcp-prompt-packaging.md) | MCP Packaging for Prompt Library | Superseded by 0008 | `no-legacy` (block), `adr-status` (block) | — | — | +| [0003](0003-context-registry-state-management.md) | Context Registry and State Management | Accepted | `frontmatter-v2` (block), `placeholder-syntax` (block), `placeholder-resolvability` (block), `dependency-graph` (block) | 0013, 0016 | 2, latest 2026-10-12 | +| [0004](0004-skill-subagent-decomposition.md) | Skill and Subagent Decomposition | Accepted | `component-census` (block) | 0013 | 1, latest 2026-10-12 | +| [0005](0005-prompt-enhancement-schema.md) | Prompt Enhancement Schema | Deferred | `no-legacy` (block) | — | — | +| [0006](0006-progressive-disclosure-tiered-engagement.md) | Progressive Disclosure and Tiered Engagement | Accepted | `frontmatter-v2` (block) | — | 1, latest 2026-07-25 | +| [0007](0007-json-index-deprecation.md) | Deprecation of JSON Index Files and Stream Deck Document | Accepted | `no-legacy` (block) | — | — | +| [0008](0008-plugin-architecture.md) | Plugin Architecture | Accepted | `manifest` (warn), `component-census` (block) | 0011 | 1, latest 2026-10-12 | +| [0009](0009-prompt-enhancement-pattern.md) | Prompt Enhancement Pattern v2 | Accepted | `frontmatter-v2` (block), `name-uniqueness` (block), `body-structure` (block), `placeholder-syntax` (block), `word-limit` (warn) | — | 1, latest 2026-07-25 | +| [0010](0010-spec-package-compilation-target.md) | Spec Package as Compilation Target | Accepted | `spec-fixture` (block) | — | — | +| [0011](0011-plugin-self-containment.md) | Plugin Self-Containment via Bundled Assets | Accepted | `self-containment` (warn) | — | 1, latest 2026-07-25 | +| [0012](0012-adr-enforcement-architecture.md) | ADR Enforcement Architecture | Accepted | `adr-status` (block), `adr-index` (warn), `adr-amendment-link` (warn) | — | 3, latest 2026-10-12 | +| [0013](0013-ux-optimization-reverse-pass.md) | UX Optimization Reverse Pass (ux-optimization skill) | Accepted | `component-census` (block) | — | 1, latest 2026-10-12 | +| [0014](0014-live-site-audit-and-objective.md) | Live-Site Audit and Core Objective in the Reverse Pass | Accepted | `objective-parity` (block) | — | 1, latest 2026-07-25 | +| [0015](0015-runtime-audit-mode.md) | Runtime Audit Mode for Locally Runnable Products | Accepted | no dedicated check | — | — | +| [0016](0016-gstack-derived-patterns.md) | gstack-Derived Patterns — Interrogation, Premise Challenge, Input Provenance | Accepted | no dedicated check | — | — | +| [0017](0017-gstack-derived-patterns-second-wave.md) | gstack-Derived Patterns, Second Wave — Quality Loops, Decision Classification, Scope Walk, Learnings | Accepted | no dedicated check | — | — | +| [0018](0018-ops-dashboard-audit-signal.md) | Report the Enforcement Suite's Finding Count to the Ops Dashboard | Accepted | no dedicated check | — | — | + +## Reading the columns + +- **Status** — `Accepted` records are in force. `Superseded` and `Deferred` + records are history: do not build on them. +- **Enforced by** — checks in `checks/run_checks.py` whose config entry names + the record. `block` fails CI and stops a Claude Code session from finishing; + `warn` only reports. "No dedicated check" means the record's own + `## Enforcement` section says what, if anything, covers it. +- **Amended by** — later ADRs that state they amend or supersede part of the + record. The `adr-amendment-link` check requires the amended record to name + each of them. + +## Changing a record + +Existing records are append-only (ADR 0012). Append a dated +`## Amendment (YYYY-MM-DD)` section to the end of the file, or write a new +numbered ADR that says which record it amends. Then run +`python checks/adr_index.py --write`.