adr-ops
Author, index, and lint Architecture Decision Records — append-only memory that recovers the WHY behind a system's shape. Triggers on: adr, architecture decision record, decision log, record this decision, supersede an adr, why was this decided, adr template, next adr number.
Install
npx skills add https://github.com/0xDarkMatter/claude-mods/tree/main/skills/adr-ops
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install 0xdarkmatter-claude-mods@llmmart
git clone https://github.com/0xDarkMatter/claude-mods.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole 0xdarkmatter/claude-mods collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
ADR Ops
An Architecture Decision Record (ADR) captures one architectural decision: what was decided, why, what was rejected, and what it costs. ADRs are append-only project memory — they exist so a future maintainer touching a subsystem can recover the reasoning behind its shape without archaeology through git history or chat logs.
This skill encapsulates a battle-tested ADR protocol and generalizes it to any repo.
The default location is docs/adr/, but every script takes --dir so a repo can keep
records anywhere (docs/decisions/, architecture/adr/, …).
When to write an ADR
Write one when a change has any of these properties:
- It constrains future options — a boundary, an invariant, a "we will always / never do X" rule that later work must respect.
- Multiple alternatives were seriously evaluated and the choice is not obvious in hindsight.
- The rationale is non-obvious from the code — the code shows what, the ADR preserves why.
Write one decision per ADR. If a change bundles two separable decisions, write two.
When NOT to write one
- A bug fix, refactor, or feature that follows existing architecture without changing it — that is a commit message.
- A reversible, low-stakes choice — that is a code comment.
- A point-in-time event with no forward constraint (a benchmark run, an incident write-up) — that is an audit/log entry, not an ADR.
Rule of thumb: if someone could plausibly undo this next month without re-litigating a trade-off, it is not an ADR.
Naming, location, numbering
- Path:
<adr-dir>/ADR-NNN-slug.md(default<adr-dir>=docs/adr). NNNis zero-padded three digits, assigned sequentially: the next number ishighest existing + 1. Numbers are never reused, never reordered — a superseded ADR keeps its number forever.slugis short kebab-case naming the subject (oauth-only-auth,per-trial-container).- A protocol/how-to file (e.g.
00_*) sorts above the numbered records and is not part of the sequence.
The directory IS the index
Do not maintain a hand-curated numbered list as the source of truth — it drifts from
the filesystem. The authoritative list is the directory itself; adr-index.sh is just a
clean parse of it. Any prose list elsewhere (README, AGENTS.md) is a convenience pointer
that may lag and must say so.
Canonical format (compact view)
Full template in assets/ADR-template.md; full rules in
references/canonical-format.md. The shape:
---
status: accepted
date: YYYY-MM-DD
supersedes: []
superseded-by: []
touches:
- "path/one.py"
---
# ADR-NNN: Title in Title Case
## Decision (one sentence)
<BLUF — one present-tense sentence stating the standing rule; greppable, stands alone.>
## Context
## Alternatives considered
## Consequences
### Positive / ### Negative / ### Non-goals
## See also
Fixed section order: Decision → Context → Alternatives considered → Consequences →
See also. Extra sections (Migration path, Enforcement, Implementation summary) go after
Consequences. For a multi-part decision, keep the one-sentence BLUF and add a
## Decision (detail) section lower down.
Frontmatter fields
| Field | Required | Rule |
|---|---|---|
status |
yes | proposed / accepted / superseded / deprecated (lowercase). |
date |
yes | Decision date, YYYY-MM-DD. |
supersedes |
yes | YAML list of ADR ids this replaces ([] if none). |
superseded-by |
yes | YAML list of ADR ids that replace this ([] until superseded). |
touches |
yes | YAML list of paths / globs / config keys this governs. Quote each. The grep discovery surface — grep touches: answers "is there an ADR about the thing I'm changing?" |
extends |
optional | ADR ids this builds on without replacing. |
related |
optional | Companion ADR ids (not parents). |
deciders |
optional | Who made the call. |
A new field is a protocol change — record it in a new ADR, don't invent per-record keys.
Status lifecycle & immutability
proposed ──► accepted ──► superseded (superseded-by: [ADR-NNN])
│
└────────► deprecated (withdrawn; nothing replaces it)
ADRs are append-only. Once accepted, you do not rewrite the Decision or Context.
Three change modes (full detail in references/lifecycle-and-supersession.md):
- Supersede — the rule itself changes. Write a new ADR with
supersedes: [ADR-OLD]; flip the old record's frontmatter tostatus: superseded+superseded-by: [ADR-NEW]in the same commit. Body stays intact (obsolete reasoning is itself a record). Supersession is bidirectional — a one-sided link is a lint error. - Addendum — new facts that refine an in-force decision. A dated
## Addendum — YYYY-MM-DD: <topic>at the end of the body. Never use it to quietly reverse the decision. - In-place edit — typos, dead links, a renamed path in
touches:. Preserves meaning; never rewrites rationale.
End-to-end workflow
- Scaffold the next record:
bash scripts/adr-new.sh --dir docs/adr --title "Your decision title"(computesNNN = highest+1, derives the slug, fills frontmatter). - Fill it in — BLUF first, then Context, Alternatives, Consequences, See also.
Keep
touches:accurate; it is the discovery surface. - Cross-check the number against the directory to avoid a collision with a parallel
session:
ls docs/adr/ADR-*.md(orbash scripts/adr-index.sh). - If superseding, flip the old record's frontmatter in the same commit — either
by hand or with
adr-new.sh --supersedes ADR-OLD --apply-supersede. - Lint before committing:
python scripts/adr-lint.py --dir docs/adr. - Commit with a
docs(adr):conventional-commit subject, e.g.docs(adr): ADR-020 — <subject>.
Adopting ADRs in a fresh repo? Run bash scripts/adr-init.sh --first-title "…" once to
bootstrap the directory + a lint-clean ADR-001. Before changing an existing subsystem, run
python scripts/adr-touching.py <path> to surface any decision already governing it.
Tools
All scripts take --dir (default docs/adr), --help, and follow semantic exit codes
(0 ok, 2 usage, 3 not-found, 5 precondition, 10 findings/domain-signal). Pair
with the git-ops skill for the commit/PR step. The three read tools form the legs of a
stool: lint = integrity, index = overview, touching = "what governs this file
before I change it".
scripts/adr-init.sh — bootstrap a repo adopting ADRs cold
# Create docs/adr/, scaffold a lint-clean ADR-001, write a generated README:
bash scripts/adr-init.sh --first-title "Adopt ADRs"
# Custom dir + preview without writing:
bash scripts/adr-init.sh --dir docs/decisions --first-title "OAuth-only auth" --dry-run
Refuses to run in a directory that already holds ADR-*.md (exit 5) unless --force. The
ADR-001 it scaffolds is rendered by adr-new.sh, so it lints clean immediately. The
generated <dir>/README.md is self-labeled "generated — do not hand-edit; the directory
is the index" and says to run adr-index to regenerate. --dry-run writes nothing.
scripts/adr-new.sh — scaffold the next ADR
# Next number, slug derived from the title, frontmatter pre-filled:
bash scripts/adr-new.sh --title "OAuth-only auth"
# Custom dir + explicit slug + proposed status, preview without writing:
bash scripts/adr-new.sh --dir docs/decisions --title "Per-trial container" \
--slug per-trial-container --status proposed --dry-run
# Supersede an old record and flip its frontmatter automatically:
bash scripts/adr-new.sh --title "Replace router" --supersedes ADR-002 --apply-supersede
Refuses to overwrite an existing file (exit 5). Atomic write. --dry-run prints the path
- rendered content and writes nothing.
--number Nforces a specific number (backfilling or coordination) — use sparingly; sequentialhighest+1is the discipline.
scripts/adr-index.sh — the directory as a table (read-only)
bash scripts/adr-index.sh # number | status | date | title
bash scripts/adr-index.sh --json | jq '.data[] | select(.status=="accepted")'
Prefers yq; degrades to a built-in parser when yq is absent (announced on stderr).
Pass --output FILE to write a generated Markdown index (heading + a do not hand-edit marker + the | # | Status | Date | Title | table) atomically to a file
instead of stdout — for a README pointer that you regenerate rather than hand-curate.
scripts/adr-touching.py — what governs this file? (the discovery surface)
The touches: frontmatter is the grep target answering "is there an ADR about the thing
I'm changing?". This tool is that grep, done properly — match a path, glob, or config
key against every ADR's touches: list.
# Before editing src/auth.py, ask what decisions constrain it:
python scripts/adr-touching.py src/auth.py # exit 10 if an ADR governs it
python scripts/adr-touching.py 'src/**' # glob query
python scripts/adr-touching.py --json src/ | jq '.data[].number'
Matching is bidirectional and pragmatic: exact equality; fnmatch glob either direction
(touches src/** matches query src/auth.py; query src/* matches touches
src/auth.py); path-prefix containment (query src/ governs touches src/auth.py, and
vice-versa); config keys (file.yaml:key) by exact-or-prefix.
Guard contract (the load-bearing bit): exit 0 = no governing ADR found, exit
10 = at least one ADR governs the query. A pre-edit hook or CI step branches on it —
"heads up, ADR-010 governs this path; read it before changing." Exit 3 dir not found,
2 usage.
Batched queries: pass several positionals and the ADR set is parsed once —
one spawn for N paths, which is what makes it cheap to call from a lint loop
(fleetflow's ff-plan lint went from 225 spawns to 35 on a 35-packet plan). The
exit code is any-governed (10 if at least one query is governed, 0 only when
none is); the per-query split is in the --json envelope's queries list, each
entry {query, governing, rc}. data stays the deduped union so .data[].number
keeps working, and a single-query call's envelope is unchanged.
python scripts/adr-touching.py --json src/a.py src/b.py lib/ \
| jq -r '.queries[] | select(.rc==10) | .query' # which of these are governed
scripts/adr-lint.py — conformance validator
python scripts/adr-lint.py --dir docs/adr # exit 0 clean, 10 if findings
python scripts/adr-lint.py --strict --json | jq '.data[] | select(.severity=="error")'
Checks required + well-typed frontmatter, the # ADR-NNN: title matching the filename,
the BLUF placement, core section order, no duplicate numbers (gaps are a warning),
and supersession bidirectionality (the high-value cross-file check). Plus:
- Lifecycle consistency (errors):
supersededwith an emptysuperseded-by;deprecatedwith a non-emptysuperseded-by; an in-force (accepted/proposed) ADR carrying asuperseded-by. These complement the bidirectionality check without double-reporting. - Stale
touches(warning): atouches:entry that is a literal filesystem path (not a glob, not a config key) which no longer resolves under--repo-root(default: git toplevel, else cwd) — the discovery surface may have drifted. Warning-tier only; counts toward exit 10 under--strict.
--strict makes warnings count toward exit 10. Exit 4 if a file's frontmatter is
unparseable.
CI integration
ADRs only stay trustworthy if the integrity contract is machine-enforced. Gate the lint
in CI; --strict turns the stale-touches drift warning into a hard signal.
# .github/workflows/adr-lint.yml
name: adr-lint
on: [pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Lint ADRs
run: python skills/adr-ops/scripts/adr-lint.py --strict --dir docs/adr
# exit 10 (findings, incl. stale-touches under --strict) fails the build
Local pre-commit gate: add python scripts/adr-lint.py --strict --dir docs/adr to a
pre-commit hook so a one-sided supersession or a stale discovery surface is caught before
the commit lands. A pre-edit hook can additionally call adr-touching.py <changed-path>
and surface the governing ADR (exit 10) before a subsystem is modified.
See also
references/canonical-format.md— the full template, field table, and body rules.references/lifecycle-and-supersession.md— status lifecycle + the three change modes.assets/ADR-template.md— copy-ready canonical template.
Files (claude-mods)
-
assets
-
ADR-template.md 1.2 KB
--- status: accepted date: YYYY-MM-DD supersedes: [] superseded-by: [] touches: - "path/one.py" - "path/two.py" - "config.yaml:some.key" --- # ADR-NNN: Title in Title Case ## Decision (one sentence) <One present-tense sentence stating the rule. This is the BLUF — it must stand alone and be greppable. A reader who reads only this line knows what was decided.> ## Context <The forces in play: what problem, what constraints, what made this a decision rather than an obvious default. Enough that the decision reads as inevitable given the context — not a coin flip.> ## Alternatives considered <Each serious option, and the specific reason it lost. "We didn't consider any" is a signal the change may not warrant an ADR. Omit this section only for a forced decision with no real alternative — and say so explicitly if you omit it.> ## Consequences ### Positive - <What this buys us.> ### Negative - <The costs and risks we accept.> ### Non-goals - <What this ADR deliberately does NOT decide or constrain — fences against scope-creep readings.> ## See also - <Links to the enforcing code, tests, related ADRs, audits. An invariant that is enforced by a test should link that test — the protocol is the contract is the test.>
-
-
references
-
canonical-format.md 4.8 KB
# ADR Canonical Format The exact shape every ADR takes. Copy `assets/ADR-template.md` verbatim and fill it in, or copy an existing accepted ADR. `adr-lint.py` enforces what is on this page. --- ## The template ```markdown --- status: accepted date: YYYY-MM-DD supersedes: [] superseded-by: [] touches: - "path/one.py" - "path/two.py" - "config.yaml:some.key" --- # ADR-NNN: Title in Title Case ## Decision (one sentence) <One present-tense sentence stating the rule. This is the BLUF — it must stand alone and be greppable. A reader who reads only this line knows what was decided.> ## Context <The forces in play: what problem, what constraints, what made this a decision rather than an obvious default. Enough that the decision reads as inevitable given the context — not a coin flip.> ## Alternatives considered <Each serious option, and the specific reason it lost. "We didn't consider any" is a signal the change may not warrant an ADR. Omit this section only for a forced decision with no real alternative — and say so explicitly if you omit it.> ## Consequences ### Positive - <What this buys us.> ### Negative - <The costs and risks we accept.> ### Non-goals - <What this ADR deliberately does NOT decide or constrain — fences against scope-creep readings.> ## See also - <Links to the enforcing code, tests, related ADRs, audits. An invariant that is enforced by a test should link that test — the protocol is the contract is the test.> ``` --- ## Frontmatter fields | Field | Required | Rule | |---|---|---| | `status` | yes | One of `proposed` / `accepted` / `superseded` / `deprecated` (lowercase). | | `date` | yes | Decision date, `YYYY-MM-DD`. | | `supersedes` | yes | YAML list of ADR ids this record replaces (`[]` if none, else `[ADR-002]`). | | `superseded-by` | yes | YAML list of ADR ids that replace this one (`[]` until superseded). | | `touches` | yes | YAML list of the paths / globs / config keys this decision governs. **Quote every entry.** This is the discovery surface — a future editor greps `touches:` to find "is there an ADR about the thing I'm changing?" Keep it accurate. | | `extends` | optional | YAML list of ADR ids this record builds on without replacing. | | `related` | optional | YAML list of ADR ids that are companions, not parents. | | `deciders` | optional | YAML list of who made the call. | Keep the frontmatter to these fields. A new field is a protocol change — propose it in a new ADR rather than inventing per-record keys. --- ## Body rules - **Title.** `# ADR-NNN: Title` — **colon separator**, Title Case, immediately after the closing `---` of the frontmatter (one blank line between them). Backticks for code identifiers in the title are fine. The `NNN` in the title MUST match the filename. - **Decision-first (BLUF).** `## Decision (one sentence)` comes immediately after the title, before `## Context`. A reader skims the decision, then reads context only if they need the why. - The one-sentence decision is **literally one sentence**, present tense, stating the standing rule (not "we decided to…" but "X routes through Y by default…"). - **Fixed section order:** Decision → Context → Alternatives considered → Consequences → See also. Add extra sections (e.g. `## Migration path`, `## Enforcement`, `## Implementation summary`) **after** Consequences when useful; never reorder the core five. - **Consequences** carries three sub-headings: `### Positive`, `### Negative`, `### Non-goals`. Non-goals fence the record against scope-creep readings. - **Multi-part decisions.** When the decision needs more than one sentence to specify (enumerated rules, a comparison table), keep the one-sentence BLUF at the top and put the full statement in a `## Decision (detail)` section lower down. Do not drop the one-sentence BLUF. --- ## Naming, location, numbering - Path: `<adr-dir>/ADR-NNN-slug.md` (default `<adr-dir>` is `docs/adr`, configurable). - `NNN` is zero-padded three digits, assigned sequentially. **The next number is `highest existing + 1`. Numbers are never reused, never reordered** — a superseded ADR keeps its number forever. - `slug` is short kebab-case naming the subject (`oauth-only-auth`, `per-trial-container`). - A protocol/how-to file (e.g. `00_*`) sorts above the numbered records and is **not** part of the sequence. ### The directory IS the index Do **not** maintain a hand-curated numbered list of ADRs as a source of truth — that list drifts from the filesystem. The authoritative list is the directory itself: ```bash ls <adr-dir>/ADR-*.md # or: adr-index.sh --dir <adr-dir> ``` Any prose list of ADRs elsewhere (a README, an AGENTS.md) is a **convenience pointer that may lag** and must say so. Because metadata lives in YAML frontmatter, a fresh index is a clean parse — `adr-index.sh` does exactly this. -
lifecycle-and-supersession.md 3 KB
# ADR Lifecycle and Supersession ADRs are **append-only project memory**. Once a record is `accepted`, its Decision and Context are not rewritten to reflect a new reality — that erases the record. Change comes through supersession, addenda, or narrow in-place fixes, never by editing the decision. --- ## Status lifecycle ``` proposed ──► accepted ──► superseded (superseded-by: [ADR-NNN]) │ └─────────► deprecated (withdrawn; nothing replaces it) ``` | `status` | Meaning | |---|---| | `proposed` | Drafted, under discussion, not yet in force. Rare — most ADRs land accepted. | | `accepted` | In force. The default landing state. | | `superseded` | Replaced by a newer decision; `superseded-by` names it. The record stays. | | `deprecated` | The decision no longer applies and nothing replaces it. | --- ## The three change modes When something about a decision needs to change, pick exactly one mode. ### 1. Supersede — the decision itself changes A new decision replaces an old one. This is a **record, not a deletion** — `git log` should never be the only place a reversal lives. 1. Write a **new** ADR with the next number. Set its frontmatter `supersedes: [ADR-OLD]`. 2. In the old ADR, edit **frontmatter only**: set `status: superseded` and `superseded-by: [ADR-NEW]`. Leave its body intact — the obsolete reasoning is itself a record of how thinking changed. 3. Do both in the **same commit**. Supersession is **bidirectional** and `adr-lint.py` enforces it: if A lists `supersedes: [B]`, then B must have `superseded-by: [A]` AND `status: superseded`, and vice versa. A one-sided link is a lint error. `adr-new.sh --supersedes ADR-OLD --apply-supersede` performs the flip for you. ### 2. Addendum — new facts, same decision A dated `## Addendum — YYYY-MM-DD: <topic>` section at the **end** of the body is for new facts that *refine* an in-force decision (an implementation note, a discovered constraint). It does not change the decision. Do **not** use an addendum to quietly reverse the decision — that is a supersession. ### 3. In-place edit — typos, dead links, renamed paths In-place edits for typos, broken links, or a renamed path in `touches:` are fine — they preserve the record's meaning. Use judgement: correcting a path is maintenance; rewriting the rationale is not. Normalising format across the whole corpus is maintenance — it changes presentation, not decisions. --- ## Decision tree ``` Does the standing rule change? ├── YES → Supersede (new ADR + flip the old record's frontmatter, same commit) └── NO ├── New fact refining the decision? → Addendum (dated section at the end) └── Typo / dead link / renamed path? → In-place edit (frontmatter or body) ``` > Rule of thumb for whether it is even an ADR at all: if someone could plausibly undo > the change next month without re-litigating a trade-off, it is not an ADR — it is a > commit message or a code comment.
-
-
scripts
-
adr-index.sh 8.2 KB
#!/usr/bin/env bash # Emit the ADR index — one row per record, in number order. Read-only. # # Usage: adr-index.sh [--dir DIR] [--json] [--output FILE] # Input: argv flags only (no stdin). # Output: stdout = the index. Plain: "number | status | date | title" rows. # --json: {"data":[...],"meta":{...,"schema":"claude-mods.adr-ops.index/v1"}} # --output FILE: write a generated Markdown index (heading + marker + # table) to FILE atomically instead of stdout. Data only — the directory # IS the index; this is just a parse of it. # Stderr: headers, warnings (e.g. yq absent -> fallback parser), errors. # Exit: 0 ok, 2 usage, 3 dir not found # # Prefers `yq --front-matter=extract` for frontmatter parsing; degrades to a # sed/grep parser when yq is absent (announced on stderr). # # Examples: # adr-index.sh # adr-index.sh --dir docs/decisions # adr-index.sh --json | jq '.data[] | select(.status=="accepted")' # adr-index.sh --output docs/adr/INDEX.md set -uo pipefail readonly EX_OK=0 EX_USAGE=2 EX_NOTFOUND=3 # Terminal design system (skills/_lib/term.sh). The index IS this tool's stdout # data product (pipeable rows / --json / --output), so framing rides fd 1 and is # only rendered as a full panel when stdout is a TTY (or FORCE_COLOR is set for a # render check). Piped or --json/--output stays plain. Degrade if the lib is gone. __lib="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../_lib" 2>/dev/null && pwd || true)" if [ -n "${__lib:-}" ] && [ -f "$__lib/term.sh" ]; then . "$__lib/term.sh"; term_init else term_init() { :; }; term_color() { shift; printf '%s' "$*"; } term_panel_open() { :; }; term_panel_close() { :; }; term_panel_vert() { :; } term_section() { :; }; term_summary_line() { :; }; term_leaf_line() { :; } term_health() { shift; printf '%s' "$*"; }; TERM_DOT="|" TERM_TREE_BRANCH="+-"; TERM_TREE_LAST="\`-" fi DIR="docs/adr" JSON=0 OUTPUT="" usage() { cat <<'EOF' adr-index.sh — emit the ADR index (number | status | date | title), in order. Usage: adr-index.sh [--dir DIR] [--json] Options: --dir DIR ADR directory (default: docs/adr) --json Emit a JSON envelope (schema claude-mods.adr-ops.index/v1) --output FILE Write a generated Markdown index to FILE (atomic) instead of stdout -h, --help Show this help and exit 0. Exit codes: 0 ok 2 usage 3 dir not found Examples: adr-index.sh adr-index.sh --dir docs/decisions adr-index.sh --json | jq '.data[] | select(.status=="accepted")' adr-index.sh --output docs/adr/INDEX.md EOF } die_usage() { printf 'error: %s\n' "$1" >&2; echo >&2; usage >&2; exit "$EX_USAGE"; } while [[ $# -gt 0 ]]; do case "$1" in --dir) [[ $# -ge 2 ]] || die_usage "--dir needs a value"; DIR="$2"; shift 2 ;; --json) JSON=1; shift ;; --output) [[ $# -ge 2 ]] || die_usage "--output needs a value"; OUTPUT="$2"; shift 2 ;; -h|--help) usage; exit "$EX_OK" ;; -*) die_usage "unknown flag: $1" ;; *) die_usage "unexpected positional argument: $1" ;; esac done [[ "$JSON" -eq 1 && -n "$OUTPUT" ]] && die_usage "--json and --output are mutually exclusive" [[ -d "$DIR" ]] || { printf 'error: ADR directory not found: %s\n' "$DIR" >&2; exit "$EX_NOTFOUND"; } HAVE_YQ=0 if command -v yq >/dev/null 2>&1; then HAVE_YQ=1; else printf 'note: yq not found — using built-in frontmatter parser.\n' >&2 fi # Extract a scalar frontmatter field from a file. # field_of <file> <field> field_of() { local file="$1" field="$2" if [[ "$HAVE_YQ" -eq 1 ]]; then local v v="$(yq --front-matter=extract ".$field" "$file" 2>/dev/null)" [[ "$v" == "null" ]] && v="" printf '%s' "$v" else # Read only the first frontmatter block (between the first two --- lines). awk -v f="$field" ' NR==1 && $0=="---" { infm=1; next } infm && $0=="---" { exit } infm { if ($0 ~ "^" f ":[[:space:]]*") { sub("^" f ":[[:space:]]*", "") gsub(/^["'"'"']|["'"'"']$/, "") print exit } } ' "$file" fi } title_of() { # First "# ADR-NNN: Title" line, with the prefix stripped. sed -n 's/^# ADR-[0-9]*:[[:space:]]*//p' "$1" | head -1 } # Collect ADR files in number order. rows_num=(); rows_status=(); rows_date=(); rows_title=() shopt -s nullglob mapfile -t files < <( for f in "$DIR"/ADR-*.md; do base="$(basename "$f")" [[ "$base" =~ ^ADR-([0-9]+) ]] || continue printf '%010d\t%s\n' "$((10#${BASH_REMATCH[1]}))" "$f" done | sort | cut -f2- ) shopt -u nullglob for f in "${files[@]}"; do base="$(basename "$f")" [[ "$base" =~ ^(ADR-[0-9]+) ]] || continue num="${BASH_REMATCH[1]}" rows_num+=("$num") rows_status+=("$(field_of "$f" status)") rows_date+=("$(field_of "$f" date)") rows_title+=("$(title_of "$f")") done count="${#rows_num[@]}" # Render the index as a full panel (grouped by lifecycle status) for a human at a # TTY. Strictly a display layer over the same rows — never the data product. render_panel() { local indicator indicator="$count $([ "$count" -eq 1 ] && echo record || echo records)" term_panel_open adr "adr" "$indicator" term_panel_vert term_summary_line "$DIR" term_panel_vert local st state i j idxs last conn nm # Canonical lifecycle order; a trailing pass catches any off-spec status so no # row is ever silently dropped from the view. for st in proposed accepted superseded deprecated __other__; do idxs=() for ((i=0; i<count; i++)); do case "$st" in __other__) case "${rows_status[$i]}" in proposed|accepted|superseded|deprecated) ;; *) idxs+=("$i") ;; esac ;; *) [[ "${rows_status[$i]}" == "$st" ]] && idxs+=("$i") ;; esac done [[ ${#idxs[@]} -eq 0 ]] && continue case "$st" in accepted) state=OK ;; # green — in force proposed) state=PENDING ;; # yellow — under consideration *) state=RETIRED ;; # default fg — superseded / deprecated / off-spec esac local label="$st"; [[ "$st" == "__other__" ]] && label="other" term_section "$state" "$label" "${#idxs[@]}" last=$(( ${#idxs[@]} - 1 )) for j in "${!idxs[@]}"; do i="${idxs[$j]}" conn="$TERM_TREE_BRANCH"; [[ "$j" -eq "$last" ]] && conn="$TERM_TREE_LAST" nm="${rows_num[$i]} ${rows_title[$i]}" term_leaf_line "$conn" "$nm" "" "" "${rows_date[$i]}" done term_panel_vert done term_panel_close "lint ${TERM_DOT} touching ${TERM_DOT} new" "$(term_health healthy "$indicator")" } # Decide whether the human panel applies: never for --json/--output; only when # stdout is a terminal (or FORCE_COLOR forces a render for verification). PANEL=0 if [[ "$JSON" -eq 0 && -z "$OUTPUT" ]] && { [ -t 1 ] || [ -n "${FORCE_COLOR:-}" ]; }; then PANEL=1; fi if [[ "$JSON" -eq 1 ]]; then # Build JSON without external deps (escape backslash + double-quote). esc() { local s="${1//\\/\\\\}"; s="${s//\"/\\\"}"; printf '%s' "$s"; } printf '{"data":[' for ((i=0; i<count; i++)); do [[ $i -gt 0 ]] && printf ',' printf '{"number":"%s","status":"%s","date":"%s","title":"%s"}' \ "$(esc "${rows_num[$i]}")" "$(esc "${rows_status[$i]}")" \ "$(esc "${rows_date[$i]}")" "$(esc "${rows_title[$i]}")" done printf '],"meta":{"count":%d,"dir":"%s","schema":"claude-mods.adr-ops.index/v1"}}\n' \ "$count" "$(esc "$DIR")" elif [[ -n "$OUTPUT" ]]; then # Generated Markdown index, written atomically to $OUTPUT. tmp="$OUTPUT.tmp.$$" { printf '# ADR Index\n\n' printf '<!-- generated by adr-index.sh — do not hand-edit; the directory is the index -->\n\n' printf '| # | Status | Date | Title |\n' printf '|---|---|---|---|\n' for ((i=0; i<count; i++)); do printf '| %s | %s | %s | %s |\n' \ "${rows_num[$i]}" "${rows_status[$i]}" "${rows_date[$i]}" "${rows_title[$i]}" done } > "$tmp" || { rm -f "$tmp"; printf 'error: failed to write %s\n' "$tmp" >&2; exit 1; } mv -f "$tmp" "$OUTPUT" || { rm -f "$tmp"; printf 'error: failed to move into place: %s\n' "$OUTPUT" >&2; exit 1; } printf 'wrote %d-row index to %s\n' "$count" "$OUTPUT" >&2 elif [[ "$PANEL" -eq 1 ]]; then render_panel else for ((i=0; i<count; i++)); do printf '%s | %s | %s | %s\n' \ "${rows_num[$i]}" "${rows_status[$i]}" "${rows_date[$i]}" "${rows_title[$i]}" done fi exit "$EX_OK" -
adr-init.sh 7.6 KB
#!/usr/bin/env bash # Bootstrap an ADR directory in a repo adopting Architecture Decision Records cold. # # Usage: adr-init.sh [--dir DIR] [--first-title "TEXT"] [--dry-run] [--force] # Input: argv flags only (no stdin). # Output: stdout = paths created (or, under --dry-run, the actions it would take). # Data only. # Stderr: headers, reminders, warnings, errors. # Exit: 0 created (or dry-run rendered), 2 usage, 5 precondition # (dir already contains ADR-*.md and --force not given) # # Creates <dir> if missing, scaffolds a lint-clean ADR-001 (by invoking adr-new.sh # so it shares the canonical template), and writes a short generated <dir>/README.md # pointing at the directory-as-index discipline. Refuses to run in a directory that # already holds ADRs unless --force. Atomic writes; never clobbers existing files. # # Examples: # adr-init.sh # adr-init.sh --dir docs/decisions --first-title "Adopt ADRs" # adr-init.sh --first-title "OAuth-only auth" --dry-run set -uo pipefail readonly EX_OK=0 EX_USAGE=2 EX_PRECOND=5 # Terminal design system (skills/_lib/term.sh). stdout = paths created (data); the # bootstrap summary frames on stderr, so detect color on fd 2. Degrade to plain # stderr lines if the shared lib is unreachable. __lib="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../_lib" 2>/dev/null && pwd || true)" if [ -n "${__lib:-}" ] && [ -f "$__lib/term.sh" ]; then . "$__lib/term.sh"; term_init 2 else term_panel_open() { :; }; term_panel_close() { :; }; term_panel_vert() { :; } term_status_row() { shift; printf ' - %s %s\n' "$1" "${2:-}"; } term_color() { shift; printf '%s' "$*"; }; TERM_DOT="|" fi DIR="docs/adr" FIRST_TITLE="Record architecture decisions" DRY_RUN=0 FORCE=0 HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ADR_NEW="$HERE/adr-new.sh" usage() { cat <<'EOF' adr-init.sh — bootstrap an ADR directory in a repo adopting ADRs cold. Usage: adr-init.sh [--dir DIR] [--first-title "TEXT"] [--dry-run] [--force] Options: --dir DIR ADR directory to create/populate (default: docs/adr) --first-title TEXT Title for the scaffolded ADR-001 (default: "Record architecture decisions") --dry-run Print what would happen; write nothing. --force Proceed even if DIR already contains ADR-*.md. -h, --help Show this help and exit 0. Exit codes: 0 created (or dry-run) 2 usage 5 dir already has ADRs (without --force) Examples: adr-init.sh adr-init.sh --dir docs/decisions --first-title "Adopt ADRs" adr-init.sh --first-title "OAuth-only auth" --dry-run EOF } die_usage() { printf 'error: %s\n' "$1" >&2; echo >&2; usage >&2; exit "$EX_USAGE"; } while [[ $# -gt 0 ]]; do case "$1" in --dir) [[ $# -ge 2 ]] || die_usage "--dir needs a value"; DIR="$2"; shift 2 ;; --first-title) [[ $# -ge 2 ]] || die_usage "--first-title needs a value"; FIRST_TITLE="$2"; shift 2 ;; --dry-run) DRY_RUN=1; shift ;; --force) FORCE=1; shift ;; -h|--help) usage; exit "$EX_OK" ;; -*) die_usage "unknown flag: $1" ;; *) die_usage "unexpected positional argument: $1" ;; esac done [[ -f "$ADR_NEW" ]] || { printf 'error: adr-new.sh not found beside this script: %s\n' "$ADR_NEW" >&2; exit 1; } # ── precondition: refuse a populated dir unless --force ───────────────────── if [[ -d "$DIR" ]]; then shopt -s nullglob existing=("$DIR"/ADR-*.md) shopt -u nullglob if [[ ${#existing[@]} -gt 0 && "$FORCE" -ne 1 ]]; then printf 'error: %s already contains %d ADR file(s); refusing to bootstrap (use --force to override)\n' \ "$DIR" "${#existing[@]}" >&2 exit "$EX_PRECOND" fi fi README_PATH="$DIR/README.md" readme_content() { cat <<EOF <!-- generated by adr-init.sh — do not hand-edit; the directory is the index --> # Architecture Decision Records This directory holds **Architecture Decision Records (ADRs)** — append-only project memory recording *why* the system is shaped the way it is. Each \`ADR-NNN-slug.md\` captures one decision: what was decided, the alternatives, and the consequences. ## When to write one Write an ADR when a change **constrains future options**, **seriously weighed alternatives**, or has **rationale the code can't show**. A reversible, low-stakes choice is a code comment, not an ADR. ## Format Numbered sequentially (\`highest + 1\`), never reused or reordered. Frontmatter carries \`status\`, \`date\`, \`supersedes\`, \`superseded-by\`, and \`touches:\` (the discovery surface). Body: Decision (one sentence) → Context → Alternatives considered → Consequences → See also. ## The directory IS the index Do not hand-maintain a numbered list here — it drifts. The authoritative list is the filesystem; run \`adr-index\` to regenerate a table view. EOF } # ── dry-run: describe, write nothing ──────────────────────────────────────── if [[ "$DRY_RUN" -eq 1 ]]; then [[ -d "$DIR" ]] || printf 'would create directory: %s\n' "$DIR" printf 'would scaffold: %s\n' "$DIR/ADR-001-<slug-from-title>.md" printf 'would write: %s\n' "$README_PATH" { term_panel_open adr "adr ${TERM_DOT} init (dry-run)" "$DIR" term_panel_vert [[ -d "$DIR" ]] || term_status_row skip "would create dir" "$DIR" term_status_row skip "would scaffold ADR-001" "title: $FIRST_TITLE" term_status_row skip "would write README.md" "" term_panel_vert term_panel_close "nothing written" "" } >&2 exit "$EX_OK" fi # Bootstrap outcome rows, collected then framed as one status panel on stderr. init_rows=() # "state\tlabel\tvalue" # ── create the directory ──────────────────────────────────────────────────── if [[ ! -d "$DIR" ]]; then mkdir -p "$DIR" || { printf 'error: failed to create %s\n' "$DIR" >&2; exit 1; } printf '%s\n' "$DIR" init_rows+=("ok"$'\t'"created dir"$'\t'"$DIR") fi # ── scaffold ADR-001 via adr-new.sh (shares the canonical template) ───────── # Force number 001 so a --force re-run into a dir with higher numbers still # bootstraps the first record; adr-new's own guard refuses to clobber. new_out="$(bash "$ADR_NEW" --dir "$DIR" --number 001 --title "$FIRST_TITLE" 2>/dev/null)" new_rc=$? if [[ "$new_rc" -eq 0 ]]; then printf '%s\n' "$new_out" init_rows+=("ok"$'\t'"scaffolded $(basename "$new_out")"$'\t'"title: $FIRST_TITLE") elif [[ "$new_rc" -eq 5 ]]; then init_rows+=("skip"$'\t'"ADR-001 already present"$'\t'"left as-is") else printf 'error: adr-new.sh failed (exit %d) scaffolding ADR-001\n' "$new_rc" >&2 exit 1 fi # ── write the generated README (atomic; never clobber) ────────────────────── if [[ -e "$README_PATH" ]]; then init_rows+=("skip"$'\t'"README.md already present"$'\t'"left as-is") else tmp="$README_PATH.tmp.$$" readme_content > "$tmp" || { rm -f "$tmp"; printf 'error: failed to write %s\n' "$tmp" >&2; exit 1; } mv -f "$tmp" "$README_PATH" || { rm -f "$tmp"; printf 'error: failed to move into place: %s\n' "$README_PATH" >&2; exit 1; } printf '%s\n' "$README_PATH" init_rows+=("ok"$'\t'"wrote README.md"$'\t'"$README_PATH") fi { term_panel_open adr "adr ${TERM_DOT} init" "$DIR" term_panel_vert for row in "${init_rows[@]}"; do IFS=$'\t' read -r st lbl val <<<"$row" term_status_row "$st" "$lbl" "$val" done term_panel_vert term_panel_close "fill in ADR-001 ${TERM_DOT} then adr-lint before committing" "" } >&2 exit "$EX_OK" -
adr-lint.py 21.8 KB
#!/usr/bin/env python3 """Conformance linter for Architecture Decision Records. Validates every ADR-*.md in --dir against the canonical protocol: required frontmatter (present + well-typed), the `# ADR-NNN: Title` line matching the filename, the BLUF `## Decision (one sentence)` right after the title, the fixed core section order, no duplicate numbers, and — the high-value cross-file check — supersession bidirectionality. Usage: adr-lint.py [--dir DIR] [--repo-root DIR] [--strict] [--json] Input: argv flags only (no stdin). Output: stdout = findings (plain table, or --json envelope). Data only. Both streams are pinned to UTF-8 at import so a finding quoting a non-ASCII title, path, or field cannot break the exit contract. Stderr: headers, the yq/PyYAML fallback notice, errors. Exit: 0 conformant, 2 usage, 3 dir not found, 4 a file's frontmatter unparseable, 10 findings present (errors; or warnings too under --strict) Beyond format/order/duplicate/supersession-bidirectionality, also checks: lifecycle consistency (status vs superseded-by), and — when a touches: entry is a literal filesystem path — whether it still resolves under --repo-root (a stale discovery surface), reported as a warning. Prefers PyYAML for frontmatter; falls back to a minimal parser when it is absent (announced on stderr). The supersession cross-check is the one most worth running. Examples: adr-lint.py adr-lint.py --dir docs/decisions --strict adr-lint.py --json | jq '.data[] | select(.severity=="error")' """ from __future__ import annotations import argparse import json import os import re import sys from pathlib import Path # Windows consoles default to cp1252; force UTF-8 so em-dashes/arrows in findings # don't raise UnicodeEncodeError or print mojibake (matches the repo's standard # fix). Load-bearing here, not cosmetic: findings quote ADR titles, touches: # entries, filenames, and raw field values, and the crash fired mid-list AFTER # linting — truncating the findings and exiting 1, so a caller branching on the # documented 0/10 misread a dirty repo as clean. for _stream in (sys.stdout, sys.stderr): try: _stream.reconfigure(encoding="utf-8") # type: ignore[attr-defined] except (AttributeError, ValueError): pass class Term: """Tiny ANSI helper mirroring skills/_lib/term.sh (term.sh is bash-only; per TERMINAL-DESIGN.md §9 the Python port is inline with matching keys/glyphs). Honors FORCE_COLOR / NO_COLOR / TERM_ASCII (+ legacy FLEET_ASCII). Color tracks the bound stream's TTY so piped data stays plain; ASCII mode swaps every glyph for its registered proxy (✓✗▲—? -> +x!-?).""" _C = { "green": "\033[32m", "yellow": "\033[33m", "orange": "\033[38;5;208m", "red": "\033[31m", "cyan": "\033[36m", "dim": "\033[2m", "off": "\033[0m", } _GLYPH = {"ok": "✓", "bad": "✗", "warn": "▲", "skip": "—", "na": "—", "unknown": "?"} _ASCII = {"ok": "+", "bad": "x", "warn": "!", "skip": "-", "na": "-", "unknown": "?"} _MARK_COLOR = {"ok": "green", "bad": "red", "warn": "orange", "skip": "dim", "na": "dim", "unknown": "yellow"} def __init__(self, stream=sys.stdout): # ASCII fallback: explicit env, OR the bound stream can't encode UTF — # mirrors term.sh's non-UTF-locale rule and prevents a UnicodeEncodeError # when a glyph hits a legacy codec. The module-level reconfigure above # runs first, so the encoding arm no longer fires on a Windows cp1252 pipe # (the stream really is UTF-8 by then); it still covers a caller that binds # Term to its own stream. TERM_ASCII stays the operator control for # "give me ASCII anyway". enc = (getattr(stream, "encoding", "") or "").lower() self.ascii = ( os.environ.get("TERM_ASCII") == "1" or os.environ.get("FLEET_ASCII") == "1" or "utf" not in enc ) if os.environ.get("FORCE_COLOR"): self.color = True elif (os.environ.get("NO_COLOR") is not None or os.environ.get("TERM") == "dumb" or not getattr(stream, "isatty", lambda: False)()): self.color = False else: self.color = True def c(self, name, text): if not self.color: return text return f"{self._C.get(name, '')}{text}{self._C['off']}" def mark(self, state): glyph = (self._ASCII if self.ascii else self._GLYPH).get(state, "." ) return self.c(self._MARK_COLOR.get(state, ""), glyph) EX_OK = 0 EX_USAGE = 2 EX_NOTFOUND = 3 EX_UNPARSEABLE = 4 EX_FINDINGS = 10 VALID_STATUS = {"proposed", "accepted", "superseded", "deprecated"} IN_FORCE_STATUS = {"proposed", "accepted"} LIST_FIELDS = ("supersedes", "superseded-by") REQUIRED_FIELDS = ("status", "date", "supersedes", "superseded-by", "touches") DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") ADR_ID_RE = re.compile(r"^ADR-\d+$") FILENAME_RE = re.compile(r"^ADR-(\d+)-.+\.md$") TITLE_RE = re.compile(r"^# ADR-(\d+):\s+\S") GLOB_CHARS_RE = re.compile(r"[*?\[]") EXT_RE = re.compile(r"\.[A-Za-z0-9]{1,8}$") CORE_SECTIONS = [ "## Decision", # may be "## Decision (one sentence)" "## Context", "## Alternatives considered", "## Consequences", "## See also", ] try: import yaml # type: ignore _HAVE_YAML = True except Exception: # pragma: no cover - environment dependent yaml = None # type: ignore _HAVE_YAML = False class FrontmatterError(Exception): """Frontmatter block is absent or structurally unparseable.""" def split_frontmatter(text: str) -> tuple[str, str]: """Return (frontmatter_text, body_text). Raises FrontmatterError if absent.""" lines = text.splitlines() if not lines or lines[0].strip() != "---": raise FrontmatterError("no opening '---' frontmatter fence") for i in range(1, len(lines)): if lines[i].strip() == "---": return "\n".join(lines[1:i]), "\n".join(lines[i + 1 :]) raise FrontmatterError("no closing '---' frontmatter fence") def parse_frontmatter(fm_text: str) -> dict: """Parse the frontmatter block to a dict. PyYAML if present, else minimal.""" _yaml = yaml # local alias narrows cleanly (module global won't) if _yaml is not None: try: data = _yaml.safe_load(fm_text) except Exception as exc: # malformed YAML raise FrontmatterError(f"YAML parse error: {exc}") from exc if data is None: return {} if not isinstance(data, dict): raise FrontmatterError("frontmatter is not a mapping") return data return _minimal_parse(fm_text) def _minimal_parse(fm_text: str) -> dict: """Tiny frontmatter parser for `key: scalar` and `key: [a, b]` / block lists.""" out: dict = {} lines = fm_text.splitlines() i = 0 while i < len(lines): raw = lines[i] if not raw.strip() or raw.lstrip().startswith("#"): i += 1 continue m = re.match(r"^(\S[^:]*):\s*(.*)$", raw) if not m: i += 1 continue key, val = m.group(1).strip(), m.group(2).strip() if val == "": # Possible block list: subsequent " - item" lines. items = [] j = i + 1 while j < len(lines) and re.match(r"^\s*-\s+", lines[j]): item = re.sub(r"^\s*-\s+", "", lines[j]).strip() item = item.strip("\"'") items.append(item) j += 1 if items: out[key] = items i = j continue out[key] = "" i += 1 continue if val.startswith("[") and val.endswith("]"): inner = val[1:-1].strip() out[key] = ( [x.strip().strip("\"'") for x in inner.split(",") if x.strip()] if inner else [] ) else: out[key] = val.strip("\"'") i += 1 return out def as_list(value) -> list | None: """Return value as a list, or None if it is not list-typed.""" if isinstance(value, list): return value return None def is_literal_path(entry: str) -> bool: """True if a touches: entry is a literal filesystem path we can check on disk. A literal path contains a '/' or a file extension; is NOT a glob (no * ? [); and is NOT a config-key (no `file:key` colon segment — but a Windows drive letter `C:` at position 1 doesn't count as a marker). """ s = entry.strip() if not s: return False if GLOB_CHARS_RE.search(s): return False if s.find(":") > 1: # config-key marker (drive letters live at index 1) return False return ("/" in s) or bool(EXT_RE.search(s)) def find_title(body: str): """Return (line_number_in_body, match) for the first ADR title, or (None, None).""" for idx, line in enumerate(body.splitlines()): m = TITLE_RE.match(line.strip()) if m: return idx, m return None, None def section_sequence(body: str) -> list[str]: """Ordered list of the core `## ` headings that appear (normalised).""" seen = [] for line in body.splitlines(): s = line.strip() if not s.startswith("## "): continue for canon in CORE_SECTIONS: if s == canon or s.startswith(canon + " "): seen.append(canon) break return seen def lint_dir(adr_dir: Path, repo_root: Path | None = None) -> tuple[list[dict], bool]: """Return (findings, any_unparseable).""" findings: list[dict] = [] any_unparseable = False files = sorted( p for p in adr_dir.glob("ADR-*.md") if FILENAME_RE.match(p.name) ) # number -> list of filenames (duplicate detection) by_number: dict[str, list[str]] = {} # adr-id -> parsed record (for supersession cross-check) records: dict[str, dict] = {} def add(file: str, severity: str, message: str) -> None: findings.append({"file": file, "severity": severity, "message": message}) for path in files: name = path.name fn_match = FILENAME_RE.match(name) if fn_match is None: continue # files are pre-filtered to ADR-NNN-*.md; defensive guard fm_num = fn_match.group(1) adr_id = f"ADR-{fm_num}" by_number.setdefault(fm_num, []).append(name) try: text = path.read_text(encoding="utf-8") except Exception as exc: add(name, "error", f"could not read file: {exc}") any_unparseable = True continue try: fm_text, body = split_frontmatter(text) fm = parse_frontmatter(fm_text) except FrontmatterError as exc: add(name, "error", f"unparseable frontmatter: {exc}") any_unparseable = True continue records[adr_id] = {"file": name, "fm": fm, "number": fm_num} # ── required frontmatter present + typed ── for field in REQUIRED_FIELDS: if field not in fm: add(name, "error", f"missing required frontmatter field: {field}") status = fm.get("status") if status is not None and status not in VALID_STATUS: add( name, "error", f"status '{status}' not in {sorted(VALID_STATUS)}", ) date = fm.get("date") if date is not None and not DATE_RE.match(str(date)): add(name, "error", f"date '{date}' is not YYYY-MM-DD") for field in LIST_FIELDS: if field in fm and as_list(fm[field]) is None: add(name, "error", f"{field} must be a YAML list (got {type(fm[field]).__name__})") if "touches" in fm and as_list(fm["touches"]) is None: add(name, "warning", "touches should be a YAML list of paths/globs/keys") # ── title line + filename agreement ── t_idx, t_match = find_title(body) if t_match is None: add(name, "error", "missing '# ADR-NNN: Title' line after frontmatter") else: title_num = t_match.group(1) if title_num != fm_num: add( name, "error", f"title number ADR-{title_num} != filename number ADR-{fm_num}", ) # ── BLUF: '## Decision (one sentence)' right after the title ── if t_match is not None and t_idx is not None: body_lines = body.splitlines() nxt = None for line in body_lines[t_idx + 1 :]: if line.strip(): nxt = line.strip() break if nxt != "## Decision (one sentence)": add( name, "error", "first section after title must be '## Decision (one sentence)' (BLUF)", ) # ── core section order ── seq = section_sequence(body) present = [s for s in CORE_SECTIONS if s in seq] # Filter the observed sequence down to core headings only, dedup-first-occurrence. observed = [] for s in seq: if s in present and s not in observed: observed.append(s) expected_order = [s for s in CORE_SECTIONS if s in observed] if observed != expected_order: add( name, "error", f"core sections out of order: {observed} (expected {expected_order})", ) # ── lifecycle consistency (status vs superseded-by) ── # Complements the bidirectionality cross-check below: these are local, # single-record contradictions and never double-report with it. superseded_by_here = as_list(fm.get("superseded-by")) or [] has_successor = len(superseded_by_here) > 0 if status == "superseded" and not has_successor: add( name, "error", "status is 'superseded' but superseded-by is empty " "(a superseded ADR must name its successor in superseded-by)", ) elif status == "deprecated" and has_successor: add( name, "error", "status is 'deprecated' but superseded-by is non-empty " "(deprecated means nothing replaces it; if something does, use 'superseded')", ) elif status in IN_FORCE_STATUS and has_successor: add( name, "error", f"status is '{status}' (in force) but superseded-by is non-empty " "(an in-force ADR cannot list a superseded-by)", ) # ── stale touches: a literal path that no longer exists (warning) ── if repo_root is not None: touches_here = as_list(fm.get("touches")) or [] for entry in touches_here: if not isinstance(entry, str) or not is_literal_path(entry): continue target = (repo_root / entry).resolve() if not target.exists(): add( name, "warning", f"touches path no longer exists: {entry} " "(discovery surface may be stale)", ) # ── duplicate numbers (error) / gaps (warning) ── for num, names in sorted(by_number.items()): if len(names) > 1: for n in names: add(n, "error", f"duplicate ADR number {num} (also: {[x for x in names if x != n]})") if by_number: nums = sorted(int(n) for n in by_number) full = set(range(min(nums), max(nums) + 1)) missing = sorted(full - set(nums)) for gap in missing: add( f"ADR-{gap:03d}", "warning", f"number {gap:03d} is missing — a gap in the sequence (numbers are normally contiguous)", ) # ── supersession bidirectionality (the high-value cross-file check) ── for adr_id, rec in records.items(): fm = rec["fm"] name = rec["file"] supersedes = as_list(fm.get("supersedes")) or [] for target in supersedes: if not isinstance(target, str) or not ADR_ID_RE.match(target): add(name, "error", f"supersedes entry '{target}' is not a valid ADR-NNN id") continue other = records.get(target) if other is None: add(name, "error", f"supersedes {target}, but no such record exists") continue o_fm = other["fm"] o_by = as_list(o_fm.get("superseded-by")) or [] if adr_id not in o_by: add( other["file"], "error", f"{target} is superseded by {adr_id} but its superseded-by does not list {adr_id}", ) if o_fm.get("status") != "superseded": add( other["file"], "error", f"{target} is superseded by {adr_id} but its status is '{o_fm.get('status')}', not 'superseded'", ) superseded_by = as_list(fm.get("superseded-by")) or [] for target in superseded_by: if not isinstance(target, str) or not ADR_ID_RE.match(target): add(name, "error", f"superseded-by entry '{target}' is not a valid ADR-NNN id") continue other = records.get(target) if other is None: add(name, "error", f"superseded-by {target}, but no such record exists") continue o_sup = as_list(other["fm"].get("supersedes")) or [] if adr_id not in o_sup: add( other["file"], "error", f"{target} claims to supersede nothing back to {adr_id} (its supersedes omits {adr_id})", ) return findings, any_unparseable def resolve_repo_root(explicit: str | None) -> Path | None: """Resolve the repo root for touches-path checks. Explicit --repo-root wins (must be a directory). Otherwise try `git rev-parse --show-toplevel`; fall back to cwd. Returns None only if an explicit path was given but is not a directory (caller treats as usage). """ if explicit is not None: p = Path(explicit) return p if p.is_dir() else None import subprocess # local: only needed when no explicit root try: out = subprocess.run( ["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True, timeout=5, ) if out.returncode == 0 and out.stdout.strip(): return Path(out.stdout.strip()) except (OSError, subprocess.SubprocessError): pass return Path.cwd() def main(argv: list[str]) -> int: parser = argparse.ArgumentParser( prog="adr-lint.py", description="Conformance linter for Architecture Decision Records.", add_help=True, ) parser.add_argument("--dir", default="docs/adr", help="ADR directory (default: docs/adr)") parser.add_argument( "--repo-root", default=None, help="repo root for resolving literal touches: paths " "(default: git toplevel if in a git repo, else cwd)", ) parser.add_argument( "--strict", action="store_true", help="count warnings toward the exit-10 signal" ) parser.add_argument("--json", action="store_true", help="emit a JSON envelope") try: args = parser.parse_args(argv) except SystemExit as exc: # argparse exits 2 on usage error already; normalise non-zero to EX_USAGE. return EX_USAGE if exc.code not in (0, None) else (exc.code or EX_OK) if not _HAVE_YAML: print("note: PyYAML not found — using built-in minimal frontmatter parser.", file=sys.stderr) adr_dir = Path(args.dir) if not adr_dir.is_dir(): print(f"error: ADR directory not found: {adr_dir}", file=sys.stderr) return EX_NOTFOUND repo_root = resolve_repo_root(args.repo_root) if args.repo_root is not None and repo_root is None: print(f"error: --repo-root is not a directory: {args.repo_root}", file=sys.stderr) return EX_USAGE findings, any_unparseable = lint_dir(adr_dir, repo_root) errors = [f for f in findings if f["severity"] == "error"] warnings = [f for f in findings if f["severity"] == "warning"] if args.json: envelope = { "data": findings, "meta": { "count": len(findings), "errors": len(errors), "warnings": len(warnings), "dir": str(adr_dir), "schema": "claude-mods.adr-ops.lint/v1", }, } print(json.dumps(envelope, indent=2)) else: tout = Term(sys.stdout) terr = Term(sys.stderr) for f in findings: sev = f["severity"] if tout.color: state = "bad" if sev == "error" else "warn" col = "red" if sev == "error" else "orange" print(f"{tout.mark(state)} {tout.c(col, f'{sev.upper():7}')} {f['file']}: {f['message']}") else: # Plain stream stays byte-identical to the legacy data format. print(f"{sev.upper():7} {f['file']}: {f['message']}") print( f"--- {terr.c('red', str(len(errors)))} error(s), " f"{terr.c('orange', str(len(warnings)))} warning(s) across {args.dir}", file=sys.stderr, ) if any_unparseable: return EX_UNPARSEABLE if errors or (args.strict and warnings): return EX_FINDINGS return EX_OK if __name__ == "__main__": sys.exit(main(sys.argv[1:])) -
adr-new.sh 12 KB
#!/usr/bin/env bash # Scaffold the next Architecture Decision Record from the canonical template. # # Usage: adr-new.sh --title "Title Text" [OPTIONS] # Input: argv flags only (no stdin). # Output: stdout = the created file path (or, under --dry-run, the path then the # full rendered content). Data only. # Stderr: headers, reminders (e.g. supersession flip), warnings, errors. # Exit: 0 created (or dry-run rendered), 2 usage, 3 dir not found, # 5 precondition (target already exists) # # Computes the next number as (highest existing ADR-NNN in --dir) + 1, zero-padded # to three digits, and writes ADR-NNN-slug.md with frontmatter filled in. Never # overwrites an existing file. Atomic write (tmp + mv). # # Examples: # adr-new.sh --title "OAuth-only auth" # adr-new.sh --dir docs/decisions --title "Per-trial container" --slug per-trial-container # adr-new.sh --title "Replace router" --supersedes ADR-002 --apply-supersede # adr-new.sh --title "Draft idea" --status proposed --dry-run set -uo pipefail # ── exit-code constants ──────────────────────────────────────────────────── readonly EX_OK=0 EX_USAGE=2 EX_NOTFOUND=3 EX_PRECOND=5 # Terminal design system (skills/_lib/term.sh). stdout = the created file path # (data); the creation summary + supersession reminders frame on stderr, so detect # color on fd 2. Degrade to plain stderr lines if the shared lib is unreachable. __lib="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../_lib" 2>/dev/null && pwd || true)" if [ -n "${__lib:-}" ] && [ -f "$__lib/term.sh" ]; then . "$__lib/term.sh"; term_init 2 else term_panel_open() { :; }; term_panel_close() { :; }; term_panel_vert() { :; } term_status_row() { shift; printf ' - %s %s\n' "$1" "${2:-}"; } term_alert() { shift; printf ' ! %s\n' "$*"; } term_color() { shift; printf '%s' "$*"; }; TERM_DOT="|" fi # ── defaults ─────────────────────────────────────────────────────────────── DIR="docs/adr" TITLE="" SLUG="" STATUS="accepted" SUPERSEDES="" APPLY_SUPERSEDE=0 DATE="" NUMBER="" # explicit override; default is highest+1 DRY_RUN=0 # Resolve the bundled template relative to this script (works in repo + installed). HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TEMPLATE="$HERE/../assets/ADR-template.md" usage() { cat <<'EOF' adr-new.sh — scaffold the next ADR from the canonical template. Usage: adr-new.sh --title "Title Text" [OPTIONS] Options: --dir DIR ADR directory (default: docs/adr) --title TEXT ADR title (required). Used in the `# ADR-NNN: Title` line. --slug SLUG kebab-case slug for the filename. Derived from --title if omitted. --status STATUS proposed|accepted|superseded|deprecated (default: accepted) --number N Force the ADR number instead of computing highest+1 (for backfilling or coordination). Sequential discipline is the default; use sparingly. The overwrite guard still applies. --supersedes ADR-NNN Mark this ADR as superseding ADR-NNN. Prints a reminder to flip the old record. Repeatable. --apply-supersede With --supersedes: also flip the OLD file's frontmatter (status: superseded, superseded-by: [this ADR]) in place. --date YYYY-MM-DD Decision date (default: today via `date +%F`). --dry-run Print the target path and full content; write nothing. -h, --help Show this help and exit 0. Exit codes: 0 created (or dry-run rendered) 2 usage 3 dir not found 5 target exists Examples: adr-new.sh --title "OAuth-only auth" adr-new.sh --dir docs/decisions --title "Per-trial container" --slug per-trial-container adr-new.sh --title "Replace router" --supersedes ADR-002 --apply-supersede adr-new.sh --title "Draft idea" --status proposed --dry-run EOF } die_usage() { printf 'error: %s\n' "$1" >&2; echo >&2; usage >&2; exit "$EX_USAGE"; } # ── parse args ───────────────────────────────────────────────────────────── SUPERSEDES_LIST=() while [[ $# -gt 0 ]]; do case "$1" in --dir) [[ $# -ge 2 ]] || die_usage "--dir needs a value"; DIR="$2"; shift 2 ;; --title) [[ $# -ge 2 ]] || die_usage "--title needs a value"; TITLE="$2"; shift 2 ;; --slug) [[ $# -ge 2 ]] || die_usage "--slug needs a value"; SLUG="$2"; shift 2 ;; --status) [[ $# -ge 2 ]] || die_usage "--status needs a value"; STATUS="$2"; shift 2 ;; --number) [[ $# -ge 2 ]] || die_usage "--number needs a value"; NUMBER="$2"; shift 2 ;; --supersedes) [[ $# -ge 2 ]] || die_usage "--supersedes needs a value"; SUPERSEDES_LIST+=("$2"); shift 2 ;; --apply-supersede) APPLY_SUPERSEDE=1; shift ;; --date) [[ $# -ge 2 ]] || die_usage "--date needs a value"; DATE="$2"; shift 2 ;; --dry-run) DRY_RUN=1; shift ;; -h|--help) usage; exit "$EX_OK" ;; -*) die_usage "unknown flag: $1" ;; *) die_usage "unexpected positional argument: $1" ;; esac done # ── validate ─────────────────────────────────────────────────────────────── [[ -n "$TITLE" ]] || die_usage "--title is required" case "$STATUS" in proposed|accepted|superseded|deprecated) ;; *) die_usage "--status must be one of proposed|accepted|superseded|deprecated (got '$STATUS')" ;; esac if [[ -n "$DATE" ]]; then [[ "$DATE" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || die_usage "--date must be YYYY-MM-DD (got '$DATE')" else DATE="$(date +%F)" fi [[ -f "$TEMPLATE" ]] || { printf 'error: template not found at %s\n' "$TEMPLATE" >&2; exit "$EX_NOTFOUND"; } [[ -d "$DIR" ]] || { printf 'error: ADR directory not found: %s\n' "$DIR" >&2; exit "$EX_NOTFOUND"; } # Derive slug from title if not supplied: lowercase, non-alnum -> '-', squeeze, trim. if [[ -z "$SLUG" ]]; then SLUG="$(printf '%s' "$TITLE" \ | tr '[:upper:]' '[:lower:]' \ | sed -E 's/[^a-z0-9]+/-/g; s/-+/-/g; s/^-//; s/-$//')" fi [[ -n "$SLUG" ]] || die_usage "could not derive a slug from --title; pass --slug explicitly" [[ "$SLUG" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]] || die_usage "--slug must be kebab-case (got '$SLUG')" # ── compute next number ──────────────────────────────────────────────────── # Default: highest existing ADR-NNN in --dir, +1. Sequential, never reused/reordered. # --number N overrides this (for backfilling or cross-session coordination); the # overwrite guard below still protects against clobbering an existing record. if [[ -n "$NUMBER" ]]; then [[ "$NUMBER" =~ ^[0-9]+$ ]] || die_usage "--number must be a non-negative integer (got '$NUMBER')" next=$((10#$NUMBER)) else highest=0 shopt -s nullglob for f in "$DIR"/ADR-*.md; do base="$(basename "$f")" if [[ "$base" =~ ^ADR-([0-9]+) ]]; then n=$((10#${BASH_REMATCH[1]})) (( n > highest )) && highest=$n fi done shopt -u nullglob next=$((highest + 1)) fi NNN="$(printf '%03d' "$next")" TARGET="$DIR/ADR-$NNN-$SLUG.md" # Never overwrite (precondition). if [[ -e "$TARGET" ]]; then printf 'error: target already exists: %s (refusing to overwrite)\n' "$TARGET" >&2 exit "$EX_PRECOND" fi # ── render content from template ─────────────────────────────────────────── # Build the supersedes YAML list value. supersedes_yaml="[]" if [[ ${#SUPERSEDES_LIST[@]} -gt 0 ]]; then joined="" for s in "${SUPERSEDES_LIST[@]}"; do [[ "$s" =~ ^ADR-[0-9]+$ ]] || die_usage "--supersedes value must look like ADR-NNN (got '$s')" joined+="${joined:+, }$s" done supersedes_yaml="[$joined]" fi # Render from template via line-exact awk substitution (avoids bash ${//} # footguns: a leading '#' anchors the match, and a title with regex metachars # would otherwise need escaping). Each placeholder line is matched whole. content="$(awk \ -v status="status: $STATUS" \ -v date="date: $DATE" \ -v supersedes="supersedes: $supersedes_yaml" \ -v title="# ADR-$NNN: $TITLE" ' $0 == "status: accepted" { print status; next } $0 == "date: YYYY-MM-DD" { print date; next } $0 == "supersedes: []" { print supersedes; next } $0 == "# ADR-NNN: Title in Title Case" { print title; next } { print } ' "$TEMPLATE")" # ── dry-run: print and stop ──────────────────────────────────────────────── if [[ "$DRY_RUN" -eq 1 ]]; then printf '%s\n' "$TARGET" { term_panel_open adr "adr ${TERM_DOT} new (dry-run)" "ADR-$NNN" term_panel_vert term_status_row skip "would write $(basename "$TARGET")" "status: $STATUS ${TERM_DOT} $DATE" if [[ ${#SUPERSEDES_LIST[@]} -gt 0 ]]; then term_alert warning "supersedes ${SUPERSEDES_LIST[*]} — flip status: superseded + superseded-by: [ADR-$NNN] in the same commit" fi term_panel_vert term_panel_close "nothing written" "" } >&2 printf '%s\n' "$content" exit "$EX_OK" fi # ── atomic write ─────────────────────────────────────────────────────────── tmp="$TARGET.tmp.$$" printf '%s\n' "$content" > "$tmp" || { printf 'error: failed to write %s\n' "$tmp" >&2; exit 1; } mv -f "$tmp" "$TARGET" || { rm -f "$tmp"; printf 'error: failed to move into place: %s\n' "$TARGET" >&2; exit 1; } printf '%s\n' "$TARGET" # ── supersession handling ────────────────────────────────────────────────── # Apply the flips first (collecting outcome rows), then frame the whole event as a # single status panel on stderr. stdout already carries the created path (data). sup_rows=() # "state\tlabel\tvalue" — rendered as term_status_row sup_alerts=() # plain text — rendered as term_alert warning if [[ ${#SUPERSEDES_LIST[@]} -gt 0 ]]; then for old in "${SUPERSEDES_LIST[@]}"; do oldfile="" shopt -s nullglob for f in "$DIR/$old"-*.md; do oldfile="$f"; break; done shopt -u nullglob if [[ "$APPLY_SUPERSEDE" -eq 1 ]]; then if [[ -z "$oldfile" || ! -f "$oldfile" ]]; then sup_alerts+=("--apply-supersede: could not find $old in $DIR — flip it by hand") continue fi # Flip frontmatter ONLY: status -> superseded, superseded-by -> [ADR-NNN]. otmp="$oldfile.tmp.$$" if sed -E \ -e "0,/^status: .*/s//status: superseded/" \ -e "0,/^superseded-by: .*/s//superseded-by: [ADR-$NNN]/" \ "$oldfile" > "$otmp" && mv -f "$otmp" "$oldfile"; then sup_rows+=("ok"$'\t'"flipped $(basename "$oldfile")"$'\t'"-> superseded, superseded-by: [ADR-$NNN]") else rm -f "$otmp" sup_alerts+=("failed to flip $oldfile — do it by hand") fi else sup_alerts+=("supersedes $old — in the SAME commit flip ${oldfile:-$old} to status: superseded + superseded-by: [ADR-$NNN] (or re-run with --apply-supersede)") fi done fi { term_panel_open adr "adr ${TERM_DOT} new" "ADR-$NNN" term_panel_vert term_status_row ok "created $(basename "$TARGET")" "status: $STATUS ${TERM_DOT} $DATE" for row in "${sup_rows[@]:-}"; do [[ -z "$row" ]] && continue IFS=$'\t' read -r st lbl val <<<"$row" term_status_row "$st" "$lbl" "$val" done for a in "${sup_alerts[@]:-}"; do [[ -z "$a" ]] && continue term_alert warning "$a" done term_panel_vert term_panel_close "then: adr-lint before committing" "" } >&2 exit "$EX_OK" -
adr-touching.py 16.6 KB
#!/usr/bin/env python3 """Find which ADRs govern a given path, glob, or config key via `touches:`. The third leg of the toolkit: adr-lint checks integrity, adr-index gives an overview, and adr-touching answers the pre-edit question — "is there a decision record governing the thing I'm about to change?". It reads every ADR's `touches:` list and reports the records whose discovery surface matches the query. A query matches a `touches:` entry by any of: exact string equality; fnmatch glob in EITHER direction (touches `src/**` matches query `src/auth.py`; query `src/*` matches touches `src/auth.py`); or path-prefix containment (touches `src/auth.py` is governed by query `src/`; touches `src/` governs query `src/auth.py`). Config-key entries (`file.yaml:db.host`) match by exact-or-prefix on the whole string. Pragmatic, not exhaustive. Usage: adr-touching.py [--dir DIR] [--json] <path-or-glob-or-key>... Input: one OR MORE positional queries + argv flags (no stdin). The ADR set is parsed once and every query is matched against it, so a caller with N paths pays one process spawn instead of N (the 2026-09-05 batching: fleetflow's plan lint went from 225 spawns to 35 on a 35-packet run). Output: stdout = matching ADRs, data only. Single query: "number | status | title | matched-entry" rows — the legacy format, byte-identical to before batching existed. Multiple queries: "query | number | status | title | matched-entry" rows (the query column is prepended so rows stay attributable). --json: {"data":[...],"queries":[{"query":Q,"governing":[...], "rc":0|10},...],"meta":{...,"schema":"claude-mods.adr-ops.touching/v1"}}. "data" is the union of governing ADRs across all queries (deduped by number, ordered by first appearance) so `.data[].number` keeps working; "queries" carries the per-query verdict a batching caller needs. meta.query (string) is present for a single query — unchanged; a multi-query call carries meta.queries (list) instead. Both streams are pinned to UTF-8 at import so an ADR title carrying an em dash or an arrow cannot break the exit contract. Stderr: headers, the PyYAML fallback notice, errors. Exit: 0 NO governing ADR found for ANY query, 2 usage (no query, or a blank one), 3 dir not found, 10 at least one governing ADR found for AT LEAST ONE query (domain signal — a pre-edit hook or CI can branch on it: "heads up, ADR-NNN governs this path"). Multi-query exit is deliberately any-governed so a caller that only reads the exit code gets the conservative answer; the per-query split lives in the --json "queries" list. Prefers PyYAML for frontmatter; falls back to a minimal parser when absent (announced on stderr). Examples: adr-touching.py src/auth.py adr-touching.py 'src/**' adr-touching.py --dir docs/decisions config.yaml:db.host adr-touching.py --json src/ | jq '.data[].number' adr-touching.py --json src/a.py src/b.py lib/ | jq '.queries[] | select(.rc==10) | .query' """ from __future__ import annotations import argparse import fnmatch import json import os import re import sys from pathlib import Path # Windows consoles default to cp1252; force UTF-8 so em-dashes/arrows in ADR # titles don't raise UnicodeEncodeError or print mojibake (matches the repo's # standard fix). Load-bearing here, not cosmetic: the crash fired AFTER matching, # so the traceback replaced the rows and the process exited 1 — a caller branching # on the documented `10` read "no ADR governs this" while several did. A guard that # dies encoding its own answer is a false negative on a safety check. for _stream in (sys.stdout, sys.stderr): try: _stream.reconfigure(encoding="utf-8") # type: ignore[attr-defined] except (AttributeError, ValueError): pass class Term: """Tiny ANSI helper mirroring skills/_lib/term.sh (term.sh is bash-only; per TERMINAL-DESIGN.md §9 the Python port is inline with matching keys/glyphs). Honors FORCE_COLOR / NO_COLOR / TERM_ASCII (+ legacy FLEET_ASCII). Color tracks the bound stream's TTY so piped data stays plain; ASCII mode swaps every glyph for its registered proxy (✓✗▲—? -> +x!-?).""" _C = { "green": "\033[32m", "yellow": "\033[33m", "orange": "\033[38;5;208m", "red": "\033[31m", "cyan": "\033[36m", "dim": "\033[2m", "off": "\033[0m", } _GLYPH = {"ok": "✓", "bad": "✗", "warn": "▲", "skip": "—", "na": "—", "unknown": "?"} _ASCII = {"ok": "+", "bad": "x", "warn": "!", "skip": "-", "na": "-", "unknown": "?"} _MARK_COLOR = {"ok": "green", "bad": "red", "warn": "orange", "skip": "dim", "na": "dim", "unknown": "yellow"} def __init__(self, stream=sys.stdout): # ASCII fallback: explicit env, OR the bound stream can't encode UTF — # mirrors term.sh's non-UTF-locale rule and prevents a UnicodeEncodeError # when a glyph hits a legacy codec. The module-level reconfigure above # runs first, so the encoding arm no longer fires on a Windows cp1252 pipe # (the stream really is UTF-8 by then); it still covers a caller that binds # Term to its own stream. TERM_ASCII stays the operator control for # "give me ASCII anyway". enc = (getattr(stream, "encoding", "") or "").lower() self.ascii = ( os.environ.get("TERM_ASCII") == "1" or os.environ.get("FLEET_ASCII") == "1" or "utf" not in enc ) if os.environ.get("FORCE_COLOR"): self.color = True elif (os.environ.get("NO_COLOR") is not None or os.environ.get("TERM") == "dumb" or not getattr(stream, "isatty", lambda: False)()): self.color = False else: self.color = True def c(self, name, text): if not self.color: return text return f"{self._C.get(name, '')}{text}{self._C['off']}" def mark(self, state): glyph = (self._ASCII if self.ascii else self._GLYPH).get(state, ".") return self.c(self._MARK_COLOR.get(state, ""), glyph) EX_OK = 0 EX_USAGE = 2 EX_NOTFOUND = 3 EX_FOUND = 10 FILENAME_RE = re.compile(r"^ADR-(\d+)-.+\.md$") TITLE_RE = re.compile(r"^# ADR-(\d+):\s+(\S.*)$") GLOB_CHARS_RE = re.compile(r"[*?\[]") try: import yaml # type: ignore _HAVE_YAML = True except Exception: # pragma: no cover - environment dependent yaml = None # type: ignore _HAVE_YAML = False class FrontmatterError(Exception): """Frontmatter block is absent or structurally unparseable.""" def split_frontmatter(text: str) -> tuple[str, str]: """Return (frontmatter_text, body_text). Raises FrontmatterError if absent.""" lines = text.splitlines() if not lines or lines[0].strip() != "---": raise FrontmatterError("no opening '---' frontmatter fence") for i in range(1, len(lines)): if lines[i].strip() == "---": return "\n".join(lines[1:i]), "\n".join(lines[i + 1 :]) raise FrontmatterError("no closing '---' frontmatter fence") def parse_frontmatter(fm_text: str) -> dict: """Parse the frontmatter block to a dict. PyYAML if present, else minimal.""" _yaml = yaml # local alias narrows cleanly (module global won't) if _yaml is not None: try: data = _yaml.safe_load(fm_text) except Exception as exc: # malformed YAML raise FrontmatterError(f"YAML parse error: {exc}") from exc if data is None: return {} if not isinstance(data, dict): raise FrontmatterError("frontmatter is not a mapping") return data return _minimal_parse(fm_text) def _minimal_parse(fm_text: str) -> dict: """Tiny frontmatter parser for `key: scalar` and `key: [a, b]` / block lists.""" out: dict = {} lines = fm_text.splitlines() i = 0 while i < len(lines): raw = lines[i] if not raw.strip() or raw.lstrip().startswith("#"): i += 1 continue m = re.match(r"^(\S[^:]*):\s*(.*)$", raw) if not m: i += 1 continue key, val = m.group(1).strip(), m.group(2).strip() if val == "": items = [] j = i + 1 while j < len(lines) and re.match(r"^\s*-\s+", lines[j]): item = re.sub(r"^\s*-\s+", "", lines[j]).strip() item = item.strip("\"'") items.append(item) j += 1 if items: out[key] = items i = j continue out[key] = "" i += 1 continue if val.startswith("[") and val.endswith("]"): inner = val[1:-1].strip() out[key] = ( [x.strip().strip("\"'") for x in inner.split(",") if x.strip()] if inner else [] ) else: out[key] = val.strip("\"'") i += 1 return out def as_list(value) -> list: """Return value coerced to a list of strings (best-effort).""" if isinstance(value, list): return [str(x) for x in value] if value is None or value == "": return [] return [str(value)] def _norm(p: str) -> str: """Normalise a path-ish string for comparison: backslashes -> /, no trailing /.""" s = p.strip().replace("\\", "/") while len(s) > 1 and s.endswith("/"): s = s[:-1] return s def _is_glob(s: str) -> bool: return bool(GLOB_CHARS_RE.search(s)) def _is_config_key(s: str) -> bool: """A `file.ext:dotted.key` entry — a colon segment that isn't a drive letter.""" # Treat any ':' not at position 1 (Windows drive like C:) as a config-key marker. idx = s.find(":") return idx > 1 def _prefix_governs(prefix: str, child: str) -> bool: """True if `prefix` is a directory-prefix of `child` (or equal).""" prefix = _norm(prefix) child = _norm(child) if prefix == child: return True return child.startswith(prefix + "/") def matches(query: str, entry: str) -> bool: """Does `query` select the ADR carrying `touches:` entry `entry`?""" q = _norm(query) e = _norm(entry) if q == e: return True # Config-key entries: match by exact-or-prefix on the whole string only. if _is_config_key(entry) or _is_config_key(query): # exact handled above; allow prefix containment either direction if e.startswith(q) or q.startswith(e): return True return False # Glob in either direction. if _is_glob(entry) and fnmatch.fnmatch(q, e): return True if _is_glob(query) and fnmatch.fnmatch(e, q): return True # Recursive-glob convenience: fnmatch treats ** like * (no path awareness), # which already lets `src/**` match `src/auth.py`. Nothing more needed. # Path-prefix containment in either direction. if not _is_glob(entry) and not _is_glob(query): if _prefix_governs(query, entry) or _prefix_governs(entry, query): return True return False def find_title(body: str) -> str: for line in body.splitlines(): m = TITLE_RE.match(line.strip()) if m: return m.group(2).strip() return "" def load_adrs(adr_dir: Path) -> list[dict]: """Parse every ADR-*.md once. Returns records sorted by number, each carrying the parsed `touches:` list; a file that fails to parse is skipped with a stderr warning (not fatal — one broken record must not blind the guard).""" adrs: list[dict] = [] files = sorted(p for p in adr_dir.glob("ADR-*.md") if FILENAME_RE.match(p.name)) for path in files: fn = FILENAME_RE.match(path.name) if fn is None: continue try: text = path.read_text(encoding="utf-8") fm_text, body = split_frontmatter(text) fm = parse_frontmatter(fm_text) except (OSError, FrontmatterError) as exc: print(f"warning: skipping {path.name}: {exc}", file=sys.stderr) continue adrs.append( { "number": f"ADR-{fn.group(1)}", "status": str(fm.get("status", "")), "title": find_title(body), "file": path.name, "touches": as_list(fm.get("touches")), } ) return adrs def scan(adrs: list[dict], query: str) -> list[dict]: """Return the ADR records (from load_adrs) whose touches: govern `query`. Separated from parsing so a batched call matches N queries against one parsed set — the whole point of accepting several positionals.""" results: list[dict] = [] for adr in adrs: matched = next((t for t in adr["touches"] if matches(query, t)), None) if matched is not None: results.append( { "number": adr["number"], "status": adr["status"], "title": adr["title"], "matched": matched, "file": adr["file"], } ) return results def main(argv: list[str]) -> int: parser = argparse.ArgumentParser( prog="adr-touching.py", description="Find which ADRs govern a path/glob/config-key via touches:.", add_help=True, ) parser.add_argument("--dir", default="docs/adr", help="ADR directory (default: docs/adr)") parser.add_argument("--json", action="store_true", help="emit a JSON envelope") parser.add_argument( "query", nargs="*", help="path(s), glob(s), or config key(s) to look up" ) try: args = parser.parse_args(argv) except SystemExit as exc: return EX_USAGE if exc.code not in (0, None) else (exc.code or EX_OK) # A blank query anywhere in the list is a usage error for the WHOLE call, # not a silent skip: a caller that built its argv from a bad split would # otherwise get a confident "ungoverned" verdict for a path it never asked # about. queries: list[str] = list(args.query) if not queries or any(q.strip() == "" for q in queries): print("error: at least one non-blank path/glob/config-key query is required", file=sys.stderr) return EX_USAGE if not _HAVE_YAML: print("note: PyYAML not found — using built-in minimal frontmatter parser.", file=sys.stderr) adr_dir = Path(args.dir) if not adr_dir.is_dir(): print(f"error: ADR directory not found: {adr_dir}", file=sys.stderr) return EX_NOTFOUND adrs = load_adrs(adr_dir) per_query: list[dict] = [] for q in queries: res = scan(adrs, q) per_query.append({"query": q, "governing": res, "rc": EX_FOUND if res else EX_OK}) # Union across queries, deduped by ADR number, first appearance wins (so a # single-query call's "data" is exactly that query's result list — the # pre-batching envelope, unchanged). seen: set[str] = set() union: list[dict] = [] for pq in per_query: for r in pq["governing"]: if r["number"] not in seen: seen.add(r["number"]) union.append(r) single = len(queries) == 1 any_found = any(pq["rc"] == EX_FOUND for pq in per_query) if args.json: meta: dict = {"count": len(union)} if single: meta["query"] = queries[0] else: meta["queries"] = queries meta["dir"] = str(adr_dir) meta["schema"] = "claude-mods.adr-ops.touching/v1" envelope = {"data": union, "queries": per_query, "meta": meta} print(json.dumps(envelope, indent=2)) else: tout = Term(sys.stdout) terr = Term(sys.stderr) status_color = {"accepted": "green", "proposed": "yellow"} for pq in per_query: # Multi-query rows carry the query as a leading column; single-query # rows do not, so the legacy plain format stays byte-identical. lead = "" if single else f"{pq['query']} | " for r in pq["governing"]: if tout.color: num = tout.c("cyan", r["number"]) st = tout.c(status_color.get(r["status"], "dim"), r["status"]) print(f"{tout.mark('warn')} {lead}{num} | {st} | {r['title']} | {r['matched']}") else: print(f"{lead}{r['number']} | {r['status']} | {r['title']} | {r['matched']}") n = len(pq["governing"]) if n: print(f"--- {terr.c('orange', str(n))} ADR(s) govern '{pq['query']}'", file=sys.stderr) else: print(f"--- no ADR governs '{pq['query']}'", file=sys.stderr) return EX_FOUND if any_found else EX_OK if __name__ == "__main__": sys.exit(main(sys.argv[1:]))
-
-
tests
-
run.sh 24.2 KB
#!/usr/bin/env bash # Self-test for adr-ops scripts (adr-new.sh, adr-index.sh, adr-lint.py). # # Offline-deterministic (no network). Builds throwaway ADR fixtures, asserts the # documented exit codes and key output of each script, then cleans up. Resolves # paths relative to itself so it works both in the repo and once installed to # ~/.claude/skills/adr-ops/. # # Usage: bash tests/run.sh # Exit: 0 all pass, 1 one or more failures set -uo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SKILL="$(dirname "$HERE")" SCRIPTS="$SKILL/scripts" NEW="$SCRIPTS/adr-new.sh" INDEX="$SCRIPTS/adr-index.sh" LINT="$SCRIPTS/adr-lint.py" TOUCHING="$SCRIPTS/adr-touching.py" INIT="$SCRIPTS/adr-init.sh" # Pick a python that actually executes — skips the Windows Store `python3` stub. PYTHON="" for c in python python3 py; do if command -v "$c" >/dev/null 2>&1 && "$c" -c "" >/dev/null 2>&1; then PYTHON="$c"; break; fi done [[ -z "$PYTHON" ]] && { echo "no working python found" >&2; exit 1; } SB="$(mktemp -d)"; trap 'rm -rf "$SB"' EXIT PASS=0; FAIL=0 ok() { PASS=$((PASS+1)); printf ' PASS %s\n' "$1"; } no() { FAIL=$((FAIL+1)); printf ' FAIL %s\n' "$1"; } expect_exit() { [[ "$2" == "$3" ]] && ok "$1 (exit $3)" || no "$1 (want $2 got $3)"; } expect_has() { case "$3" in *"$2"*) ok "$1";; *) no "$1 (missing '$2')";; esac; } # Write a minimal conformant ADR. # make_adr <dir> <NNN> <slug> <status> <date> <supersedes-yaml> <superseded-by-yaml> make_adr() { local dir="$1" nnn="$2" slug="$3" status="$4" date="$5" sup="$6" supby="$7" cat > "$dir/ADR-$nnn-$slug.md" <<EOF --- status: $status date: $date supersedes: $sup superseded-by: $supby touches: - "src/x.py" --- # ADR-$nnn: $slug Title ## Decision (one sentence) The system does the $slug thing by default. ## Context Some forces were in play. ## Alternatives considered We considered nothing and it lost. ## Consequences ### Positive - Good. ### Negative - Cost. ### Non-goals - Not that. ## See also - src/x.py EOF } echo "=== adr-ops self-test (python: $PYTHON) ===" # ── --help contracts (exit 0) ────────────────────────────────────────────── echo "-- --help --" bash "$NEW" --help >/dev/null 2>&1; expect_exit "adr-new --help" 0 $? bash "$INDEX" --help >/dev/null 2>&1; expect_exit "adr-index --help" 0 $? "$PYTHON" "$LINT" --help >/dev/null 2>&1; expect_exit "adr-lint --help" 0 $? "$PYTHON" "$TOUCHING" --help >/dev/null 2>&1; expect_exit "adr-touching --help" 0 $? bash "$INIT" --help >/dev/null 2>&1; expect_exit "adr-init --help" 0 $? # ── adr-lint.py: clean conformant pair -> 0 ──────────────────────────────── echo "-- adr-lint: clean --" CLEAN="$SB/clean"; mkdir -p "$CLEAN" make_adr "$CLEAN" 001 alpha accepted 2026-01-01 "[]" "[]" make_adr "$CLEAN" 002 beta accepted 2026-01-02 "[]" "[]" "$PYTHON" "$LINT" --dir "$CLEAN" >/dev/null 2>&1; expect_exit "clean pair -> 0" 0 $? # ── adr-lint.py: missing dir -> 3 ────────────────────────────────────────── "$PYTHON" "$LINT" --dir "$SB/no-such-dir" >/dev/null 2>&1; expect_exit "missing dir -> 3" 3 $? # ── adr-lint.py: missing required field -> 10 ────────────────────────────── echo "-- adr-lint: findings --" MISS="$SB/missing"; mkdir -p "$MISS" cat > "$MISS/ADR-001-x.md" <<'EOF' --- status: accepted date: 2026-01-01 superseded-by: [] touches: - "a" --- # ADR-001: X ## Decision (one sentence) Rule. ## Context ## Alternatives considered ## Consequences ## See also EOF out="$("$PYTHON" "$LINT" --dir "$MISS" 2>&1)"; rc=$? expect_exit "missing 'supersedes' field -> 10" 10 "$rc" expect_has "names missing field" "supersedes" "$out" # ── adr-lint.py: bad status -> 10 ────────────────────────────────────────── BADS="$SB/badstatus"; mkdir -p "$BADS" make_adr "$BADS" 001 x bogus 2026-01-01 "[]" "[]" out="$("$PYTHON" "$LINT" --dir "$BADS" 2>&1)"; rc=$? expect_exit "bad status -> 10" 10 "$rc" expect_has "flags bad status" "not in" "$out" # ── adr-lint.py: broken supersession (one-sided) -> 10 ───────────────────── BROKE="$SB/broken-sup"; mkdir -p "$BROKE" # 002 supersedes 001, but 001 was NOT flipped (still accepted, empty superseded-by). make_adr "$BROKE" 001 old accepted 2026-01-01 "[]" "[]" make_adr "$BROKE" 002 new accepted 2026-01-02 "[ADR-001]" "[]" out="$("$PYTHON" "$LINT" --dir "$BROKE" 2>&1)"; rc=$? expect_exit "broken supersession -> 10" 10 "$rc" expect_has "flags one-sided supersession" "superseded-by" "$out" # ── adr-lint.py: a properly-flipped supersession pair is clean -> 0 ───────── GOODSUP="$SB/good-sup"; mkdir -p "$GOODSUP" make_adr "$GOODSUP" 001 old superseded 2026-01-01 "[]" "[ADR-002]" make_adr "$GOODSUP" 002 new accepted 2026-01-02 "[ADR-001]" "[]" "$PYTHON" "$LINT" --dir "$GOODSUP" >/dev/null 2>&1; expect_exit "valid supersession pair -> 0" 0 $? # ── adr-lint.py: duplicate number -> 10 ──────────────────────────────────── DUP="$SB/dup"; mkdir -p "$DUP" make_adr "$DUP" 001 a accepted 2026-01-01 "[]" "[]" make_adr "$DUP" 001 b accepted 2026-01-02 "[]" "[]" out="$("$PYTHON" "$LINT" --dir "$DUP" 2>&1)"; rc=$? expect_exit "duplicate number -> 10" 10 "$rc" expect_has "flags duplicate" "duplicate ADR number" "$out" # ── adr-lint.py: unparseable frontmatter -> 4 ────────────────────────────── BADFM="$SB/badfm"; mkdir -p "$BADFM" printf '# ADR-001: No Frontmatter\n\n## Decision (one sentence)\nRule.\n' > "$BADFM/ADR-001-x.md" "$PYTHON" "$LINT" --dir "$BADFM" >/dev/null 2>&1; expect_exit "no frontmatter fence -> 4" 4 $? # ── adr-lint.py: --json envelope ─────────────────────────────────────────── out="$("$PYTHON" "$LINT" --dir "$DUP" --json 2>/dev/null)" expect_has "json envelope schema" "claude-mods.adr-ops.lint/v1" "$out" # ── adr-new.sh: computes next number, derives slug ───────────────────────── echo "-- adr-new --" NEWDIR="$SB/newdir"; mkdir -p "$NEWDIR" make_adr "$NEWDIR" 001 alpha accepted 2026-01-01 "[]" "[]" make_adr "$NEWDIR" 007 gamma accepted 2026-01-02 "[]" "[]" out="$(bash "$NEW" --dir "$NEWDIR" --title "Cache The Things" --date 2026-02-02 2>/dev/null)"; rc=$? expect_exit "adr-new -> 0" 0 "$rc" expect_has "next number is highest+1 (008)" "ADR-008-cache-the-things.md" "$out" [[ -f "$NEWDIR/ADR-008-cache-the-things.md" ]] && ok "file written" || no "file not written" # the written file passes the linter "$PYTHON" "$LINT" --dir "$NEWDIR" >/dev/null 2>&1; expect_exit "scaffolded file lints clean -> 0" 0 $? # ── adr-new.sh: refuses to overwrite -> 5 ────────────────────────────────── # A pre-existing target must never be clobbered. Create one (ADR-004 already on # disk), then ask adr-new to write that exact slot via --number — the precondition # guard must refuse. (--number also exercises the explicit-number override path.) OWDIR="$SB/overwrite"; mkdir -p "$OWDIR" make_adr "$OWDIR" 004 collide accepted 2026-01-01 "[]" "[]" bash "$NEW" --dir "$OWDIR" --title "Collide" --slug collide --number 4 --date 2026-02-02 >/dev/null 2>&1 expect_exit "refuse overwrite -> 5" 5 $? # --number on a free slot writes there (and lints clean). bash "$NEW" --dir "$OWDIR" --title "Backfill Two" --slug backfill-two --number 2 --date 2026-02-02 >/dev/null 2>&1 expect_exit "--number free slot writes -> 0" 0 $? [[ -f "$OWDIR/ADR-002-backfill-two.md" ]] && ok "--number wrote ADR-002" || no "--number did not write ADR-002" # ── adr-new.sh: --dry-run writes nothing ─────────────────────────────────── DRY="$SB/dry"; mkdir -p "$DRY" before="$(ls "$DRY" | wc -l)" out="$(bash "$NEW" --dir "$DRY" --title "Dry Run Test" --dry-run 2>/dev/null)"; rc=$? expect_exit "dry-run -> 0" 0 "$rc" after="$(ls "$DRY" | wc -l)" [[ "$before" == "$after" ]] && ok "dry-run wrote nothing" || no "dry-run wrote a file" expect_has "dry-run prints target path" "ADR-001-dry-run-test.md" "$out" # ── adr-new.sh: bad status -> 2, missing title -> 2 ──────────────────────── bash "$NEW" --dir "$NEWDIR" --title "X" --status nonsense >/dev/null 2>&1; expect_exit "bad status -> 2" 2 $? bash "$NEW" --dir "$NEWDIR" >/dev/null 2>&1; expect_exit "missing title -> 2" 2 $? bash "$NEW" --dir "$SB/no-such" --title "X" >/dev/null 2>&1; expect_exit "missing dir -> 3" 3 $? # ── adr-new.sh: --apply-supersede flips the old record ───────────────────── SUPDIR="$SB/supdir"; mkdir -p "$SUPDIR" make_adr "$SUPDIR" 001 router accepted 2026-01-01 "[]" "[]" bash "$NEW" --dir "$SUPDIR" --title "New Router" --supersedes ADR-001 --apply-supersede --date 2026-03-03 >/dev/null 2>&1 expect_exit "apply-supersede -> 0" 0 $? "$PYTHON" "$LINT" --dir "$SUPDIR" >/dev/null 2>&1 expect_exit "auto-flipped pair lints clean -> 0" 0 $? # ── adr-index.sh: one row per ADR, in order ──────────────────────────────── echo "-- adr-index --" out="$(bash "$INDEX" --dir "$CLEAN" 2>/dev/null)"; rc=$? expect_exit "adr-index -> 0" 0 "$rc" lines="$(printf '%s\n' "$out" | grep -c '^ADR-')" [[ "$lines" == 2 ]] && ok "two ADRs -> two rows" || no "expected 2 rows, got $lines" expect_has "row carries status" "accepted" "$out" bash "$INDEX" --dir "$SB/no-such-dir" >/dev/null 2>&1; expect_exit "missing dir -> 3" 3 $? out="$(bash "$INDEX" --dir "$CLEAN" --json 2>/dev/null)" expect_has "json envelope schema" "claude-mods.adr-ops.index/v1" "$out" # ── adr-lint.py: lifecycle consistency checks ────────────────────────────── echo "-- adr-lint: lifecycle --" # superseded with empty superseded-by -> error -> 10 LCS="$SB/lc-sup-empty"; mkdir -p "$LCS" make_adr "$LCS" 001 a superseded 2026-01-01 "[]" "[]" out="$("$PYTHON" "$LINT" --dir "$LCS" 2>&1)"; rc=$? expect_exit "superseded w/ empty superseded-by -> 10" 10 "$rc" expect_has "names the lifecycle error" "must name its successor" "$out" # deprecated with non-empty superseded-by -> error -> 10 LCD="$SB/lc-dep"; mkdir -p "$LCD" make_adr "$LCD" 001 a deprecated 2026-01-01 "[]" "[ADR-002]" make_adr "$LCD" 002 b accepted 2026-01-02 "[]" "[]" out="$("$PYTHON" "$LINT" --dir "$LCD" 2>&1)"; rc=$? expect_exit "deprecated w/ superseded-by -> 10" 10 "$rc" expect_has "names the deprecated error" "nothing replaces it" "$out" # accepted (in force) with superseded-by -> error -> 10. Pair it with a valid # back-reference so ONLY the lifecycle error fires (no bidirectionality noise), # proving the two checks don't double-report. LCA="$SB/lc-accepted"; mkdir -p "$LCA" make_adr "$LCA" 001 a accepted 2026-01-01 "[]" "[ADR-002]" make_adr "$LCA" 002 b accepted 2026-01-02 "[ADR-001]" "[]" out="$("$PYTHON" "$LINT" --dir "$LCA" 2>&1)"; rc=$? expect_exit "accepted w/ superseded-by -> 10" 10 "$rc" expect_has "names the in-force error" "in force" "$out" # ── adr-lint.py: stale touches: warning ──────────────────────────────────── echo "-- adr-lint: stale touches --" # An ADR whose touches: lists a literal path absent under --repo-root. Warning # tier: exit 0 normally, exit 10 under --strict. (make_adr writes touches src/x.py; # the sandbox repo-root has no such file, so it's stale by construction.) STALE="$SB/stale"; mkdir -p "$STALE" make_adr "$STALE" 001 a accepted 2026-01-01 "[]" "[]" out="$("$PYTHON" "$LINT" --dir "$STALE" --repo-root "$SB" 2>&1)"; rc=$? expect_exit "stale touches normally -> 0" 0 "$rc" expect_has "warns on stale touches path" "no longer exists" "$out" "$PYTHON" "$LINT" --dir "$STALE" --repo-root "$SB" --strict >/dev/null 2>&1 expect_exit "stale touches --strict -> 10" 10 $? # When the path DOES exist under repo-root, no stale warning. mkdir -p "$STALE/repo/src"; : > "$STALE/repo/src/x.py" out="$("$PYTHON" "$LINT" --dir "$STALE" --repo-root "$STALE/repo" 2>&1)" case "$out" in *"no longer exists"*) no "existing touches path still flagged";; *) ok "existing touches path not flagged";; esac # ── adr-touching.py: exact / prefix / glob / config-key / no-match ────────── echo "-- adr-touching --" TCH="$SB/touching"; mkdir -p "$TCH" cat > "$TCH/ADR-001-auth.md" <<'EOF' --- status: accepted date: 2026-01-01 supersedes: [] superseded-by: [] touches: - "src/auth.py" - "lib/**" - "config.yaml:db.host" --- # ADR-001: Auth Title ## Decision (one sentence) Rule. ## Context C. ## Alternatives considered A. ## Consequences ### Positive - G. ## See also - x EOF # exact match -> 10, names the ADR out="$("$PYTHON" "$TOUCHING" --dir "$TCH" src/auth.py 2>/dev/null)"; rc=$? expect_exit "touching exact match -> 10" 10 "$rc" expect_has "touching names the ADR" "ADR-001" "$out" # prefix query (dir governs file) -> 10 "$PYTHON" "$TOUCHING" --dir "$TCH" src/ >/dev/null 2>&1; expect_exit "touching prefix query -> 10" 10 $? # glob query matches a literal touches entry -> 10 "$PYTHON" "$TOUCHING" --dir "$TCH" 'src/*.py' >/dev/null 2>&1; expect_exit "touching glob query -> 10" 10 $? # touches glob matches a concrete query path -> 10 "$PYTHON" "$TOUCHING" --dir "$TCH" lib/deep/thing.go >/dev/null 2>&1; expect_exit "touching matched by touches-glob -> 10" 10 $? # config-key exact -> 10 "$PYTHON" "$TOUCHING" --dir "$TCH" config.yaml:db.host >/dev/null 2>&1; expect_exit "touching config-key -> 10" 10 $? # no governing ADR -> 0 "$PYTHON" "$TOUCHING" --dir "$TCH" other/unrelated.txt >/dev/null 2>&1; expect_exit "touching no match -> 0" 0 $? # dir not found -> 3 "$PYTHON" "$TOUCHING" --dir "$SB/no-such-dir" src/auth.py >/dev/null 2>&1; expect_exit "touching missing dir -> 3" 3 $? # missing query -> 2 "$PYTHON" "$TOUCHING" --dir "$TCH" >/dev/null 2>&1; expect_exit "touching missing query -> 2" 2 $? # --json envelope schema out="$("$PYTHON" "$TOUCHING" --dir "$TCH" --json src/auth.py 2>/dev/null)" expect_has "touching json envelope schema" "claude-mods.adr-ops.touching/v1" "$out" # single-query envelope keeps its pre-batching shape: meta.query is the string printf '%s' "$out" | jq -e '.meta.query=="src/auth.py" and (.meta|has("queries")|not) and (.queries|length)==1' >/dev/null 2>&1 \ && ok "touching single-query envelope unchanged (meta.query string, one queries[] entry)" \ || no "touching single-query envelope drifted" # ── adr-touching.py: batched queries (2026-09-05) ─────────────────────────── # One spawn, N queries, per-query verdicts. Exit is any-governed: 10 if at least # one query is governed, 0 only when none is. fleetflow's plan lint depends on # this to make one call per packet instead of one per owned path. "$PYTHON" "$TOUCHING" --dir "$TCH" src/auth.py other/unrelated.txt >/dev/null 2>&1 expect_exit "touching multi-query any-governed -> 10" 10 $? "$PYTHON" "$TOUCHING" --dir "$TCH" other/unrelated.txt nope/x.txt >/dev/null 2>&1 expect_exit "touching multi-query none-governed -> 0" 0 $? "$PYTHON" "$TOUCHING" --dir "$TCH" src/auth.py "" >/dev/null 2>&1 expect_exit "touching multi-query with a blank entry -> 2" 2 $? out="$("$PYTHON" "$TOUCHING" --dir "$TCH" --json src/auth.py other/unrelated.txt lib/deep/thing.go 2>/dev/null)" printf '%s' "$out" | jq -e ' (.queries|length)==3 and (.queries[0]|.query=="src/auth.py" and .rc==10 and (.governing|length)==1) and (.queries[1]|.query=="other/unrelated.txt" and .rc==0 and (.governing|length)==0) and (.queries[2]|.query=="lib/deep/thing.go" and .rc==10) and (.data|length)==1 and .data[0].number=="ADR-001" and (.meta.queries|length)==3 and (.meta|has("query")|not)' >/dev/null 2>&1 \ && ok "touching multi-query json: per-query verdicts, deduped data union, meta.queries" \ || no "touching multi-query json envelope wrong: $out" out="$("$PYTHON" "$TOUCHING" --dir "$TCH" src/auth.py other/unrelated.txt 2>/dev/null)" expect_has "touching multi-query text rows carry the query column" "src/auth.py | ADR-001 |" "$out" # ── non-Latin-1 text must not break the exit contract (regression) ────────── # Both Python tools print ADR titles / touches: entries / field values verbatim. # On Windows those streams default to cp1252 whenever redirected, so a title # carrying an em dash or an arrow raised UnicodeEncodeError *after* the work was # done: the traceback replaced the findings and the process exited 1, making a # governed path look ungoverned and a dirty repo look clean. PYTHONIOENCODING is # pinned to cp1252 here so the regression is reproduced deterministically on every # platform, not just on a Windows box — the import-time reconfigure must win over # it. Assert the documented codes AND that the text actually survives. echo "-- non-Latin-1 titles --" U8="$SB/utf8"; mkdir -p "$U8/src"; : > "$U8/src/auth.py" cat > "$U8/ADR-001-write-path.md" <<'EOF' --- status: accepted date: 2026-01-01 supersedes: [] superseded-by: [] touches: - "src/auth.py" --- # ADR-001: Propose→Approve Is The Only Chat→Profile Write Path ## Decision (one sentence) Writes reach the profile only via propose→approve — never a direct chat write. ## Context C — an em dash lives here too. ## Alternatives considered A. ## Consequences ### Positive - G. ## See also - x EOF out="$(PYTHONIOENCODING=cp1252 "$PYTHON" "$TOUCHING" --dir "$U8" src/auth.py 2>/dev/null)"; rc=$? expect_exit "touching: non-Latin-1 title still exits 10" 10 "$rc" expect_has "touching: non-Latin-1 title survives to stdout" "Chat→Profile" "$out" out="$(PYTHONIOENCODING=cp1252 "$PYTHON" "$TOUCHING" --dir "$U8" --json src/auth.py 2>/dev/null)"; rc=$? expect_exit "touching --json: non-Latin-1 title still exits 10" 10 "$rc" expect_has "touching --json: envelope intact" "claude-mods.adr-ops.touching/v1" "$out" # A conformant record with a non-Latin-1 title lints clean -> 0. PYTHONIOENCODING=cp1252 "$PYTHON" "$LINT" --dir "$U8" --repo-root "$U8" >/dev/null 2>&1 expect_exit "lint: non-Latin-1 title lints clean -> 0" 0 $? # A finding that QUOTES non-Latin-1 text must still reach stdout, and still exit 10. cat > "$U8/ADR-002-boundary.md" <<'EOF' --- status: accepted date: 2026-01-01 supersedes: ["ADR-001 → replaced"] superseded-by: [] touches: - "src/auth.py" --- # ADR-002: Chat→Profile Boundary ## Decision (one sentence) The boundary is one-way. ## Context C. ## Alternatives considered A. ## Consequences ### Positive - G. ## See also - x EOF out="$(PYTHONIOENCODING=cp1252 "$PYTHON" "$LINT" --dir "$U8" --repo-root "$U8" 2>/dev/null)"; rc=$? expect_exit "lint: finding quoting non-Latin-1 text still exits 10" 10 "$rc" expect_has "lint: non-Latin-1 finding survives to stdout" "ADR-001 → replaced" "$out" # ── adr-init.sh: bootstrap, refuse populated, dry-run ────────────────────── echo "-- adr-init --" INITD="$SB/init" bash "$INIT" --dir "$INITD/docs/adr" --first-title "Adopt ADRs" >/dev/null 2>&1 expect_exit "adr-init -> 0" 0 $? [[ -f "$INITD/docs/adr/ADR-001-adopt-adrs.md" ]] && ok "init scaffolded ADR-001" || no "init did not scaffold ADR-001" [[ -f "$INITD/docs/adr/README.md" ]] && ok "init wrote README.md" || no "init did not write README.md" case "$(cat "$INITD/docs/adr/README.md" 2>/dev/null)" in *"generated by adr-init.sh"*) ok "init README carries generated marker";; *) no "init README missing generated marker";; esac # the scaffolded ADR-001 lints clean (repo-root = init root; touches paths are template placeholders -> warnings only, exit 0) "$PYTHON" "$LINT" --dir "$INITD/docs/adr" --repo-root "$INITD" >/dev/null 2>&1 expect_exit "init ADR-001 lints clean -> 0" 0 $? # refuses a populated dir -> 5 bash "$INIT" --dir "$INITD/docs/adr" >/dev/null 2>&1; expect_exit "init refuses populated dir -> 5" 5 $? # --dry-run writes nothing into a fresh location DRYI="$SB/init-dry" bash "$INIT" --dir "$DRYI/docs/adr" --dry-run >/dev/null 2>&1; expect_exit "init dry-run -> 0" 0 $? [[ -e "$DRYI" ]] && no "init dry-run created files" || ok "init dry-run wrote nothing" # ── adr-index.sh: --output generated file ────────────────────────────────── echo "-- adr-index --output --" OUTF="$SB/index-out.md" bash "$INDEX" --dir "$CLEAN" --output "$OUTF" >/dev/null 2>&1; expect_exit "adr-index --output -> 0" 0 $? [[ -f "$OUTF" ]] && ok "--output wrote a file" || no "--output wrote no file" outc="$(cat "$OUTF" 2>/dev/null)" expect_has "--output has the table header" "| # | Status | Date | Title |" "$outc" expect_has "--output carries the generated marker" "do not hand-edit" "$outc" expect_has "--output lists a row" "ADR-001" "$outc" # --json + --output is a usage error -> 2 bash "$INDEX" --dir "$CLEAN" --json --output "$SB/x.md" >/dev/null 2>&1; expect_exit "index --json+--output -> 2" 2 $? # ── terminal design system (term.sh adoption + ASCII fallback) ───────────── echo "-- terminal design system --" # Bash scripts adopt the shared toolkit (full panel grammar), not hand-rolled ANSI. for s in "$INDEX" "$NEW" "$INIT"; do b="$(basename "$s")" if grep -q '_lib/term.sh' "$s"; then ok "$b sources _lib/term.sh"; else no "$b does not source _lib/term.sh"; fi done # Python scripts carry the inline Term helper (term.sh is bash-only; spec §9). for s in "$LINT" "$TOUCHING"; do b="$(basename "$s")" if grep -q 'class Term' "$s"; then ok "$b carries inline Term helper"; else no "$b missing inline Term helper"; fi done # term.sh primitives are pure ASCII under TERM_ASCII=1 (design principle #3). LIBTERM="$SKILL/../_lib/term.sh" if [[ -f "$LIBTERM" ]]; then ok "term.sh present" prims="$(TERM_ASCII=1 LT="$LIBTERM" bash -c '. "$LT"; term_init; printf "%s%s%s%s%s" \ "$(term_mark ok)" "$(term_mark bad)" "$(term_status_row ok lbl val)" \ "$TERM_DOT" "$(term_panel_open adr adr x)"')" if printf '%s' "$prims" | LC_ALL=C grep -q '[^[:print:][:cntrl:]]'; then no "term.sh TERM_ASCII=1 primitives still emit non-ASCII bytes" else ok "term.sh TERM_ASCII=1 primitives are pure ASCII"; fi else no "term.sh missing at $LIBTERM" fi # adr-index panel: full bordered frame under FORCE_COLOR, pure ASCII under TERM_ASCII=1. PB="$SB/panel"; mkdir -p "$PB" make_adr "$PB" 001 alpha accepted 2026-01-01 "[]" "[]" pout="$(TERM_ASCII=1 FORCE_COLOR=1 bash "$INDEX" --dir "$PB" 2>/dev/null)" if printf '%s' "$pout" | LC_ALL=C grep -q '[^[:print:][:cntrl:]]'; then no "adr-index panel emits non-ASCII under TERM_ASCII=1" else ok "adr-index panel is pure ASCII under TERM_ASCII=1"; fi case "$pout" in *"+-- "*) ok "adr-index renders the full panel frame";; *) no "adr-index panel frame missing";; esac # Piped (non-TTY, no FORCE_COLOR) stays plain data rows — the stdout contract. pp="$(bash "$INDEX" --dir "$PB" 2>/dev/null)" case "$pp" in *$'\033'*) no "piped adr-index leaked ANSI into the data stream";; *"ADR-001 | accepted"*) ok "piped adr-index stays plain data rows";; *) no "piped adr-index lost its data row";; esac # Python inline Term: colorizes under FORCE_COLOR, pure ASCII under TERM_ASCII=1, # byte-plain when piped (the data contract). pylint="$(TERM_ASCII=1 FORCE_COLOR=1 "$PYTHON" "$LINT" --dir "$DUP" 2>/dev/null)" if printf '%s' "$pylint" | LC_ALL=C grep -q '[^[:print:][:cntrl:]]'; then no "adr-lint colored output emits non-ASCII under TERM_ASCII=1" else ok "adr-lint colored output is pure ASCII under TERM_ASCII=1"; fi case "$pylint" in *$'\033'*) ok "adr-lint colorizes under FORCE_COLOR";; *) no "adr-lint did not colorize under FORCE_COLOR";; esac plain_lint="$("$PYTHON" "$LINT" --dir "$DUP" 2>/dev/null)" case "$plain_lint" in *$'\033'*) no "piped adr-lint leaked ANSI";; *) ok "piped adr-lint stays plain data";; esac # ── summary ──────────────────────────────────────────────────────────────── echo "=== $PASS passed, $FAIL failed ===" [[ "$FAIL" -eq 0 ]] || exit 1
-
-
SKILL.md 13.1 KB
--- name: adr-ops description: "Author, index, and lint Architecture Decision Records — append-only memory that recovers the WHY behind a system's shape. Triggers on: adr, architecture decision record, decision log, record this decision, supersede an adr, why was this decided, adr template, next adr number." license: MIT allowed-tools: "Read Write Edit Bash Glob Grep" metadata: author: claude-mods related-skills: "git-ops, doc-scanner" --- # ADR Ops An **Architecture Decision Record (ADR)** captures one architectural decision: what was decided, why, what was rejected, and what it costs. ADRs are **append-only project memory** — they exist so a future maintainer touching a subsystem can recover the *reasoning* behind its shape without archaeology through git history or chat logs. This skill encapsulates a battle-tested ADR protocol and generalizes it to **any repo**. The default location is `docs/adr/`, but every script takes `--dir` so a repo can keep records anywhere (`docs/decisions/`, `architecture/adr/`, …). --- ## When to write an ADR Write one when a change has **any** of these properties: - It **constrains future options** — a boundary, an invariant, a "we will always / never do X" rule that later work must respect. - **Multiple alternatives were seriously evaluated** and the choice is not obvious in hindsight. - The **rationale is non-obvious from the code** — the code shows *what*, the ADR preserves *why*. Write one decision per ADR. If a change bundles two separable decisions, write two. ### When NOT to write one - A bug fix, refactor, or feature that follows existing architecture without changing it — that is a commit message. - A reversible, low-stakes choice — that is a code comment. - A point-in-time event with no forward constraint (a benchmark run, an incident write-up) — that is an audit/log entry, not an ADR. > **Rule of thumb:** if someone could plausibly undo this next month without > re-litigating a trade-off, it is **not** an ADR. --- ## Naming, location, numbering - Path: `<adr-dir>/ADR-NNN-slug.md` (default `<adr-dir>` = `docs/adr`). - `NNN` is zero-padded three digits, assigned **sequentially**: the next number is `highest existing + 1`. **Numbers are never reused, never reordered** — a superseded ADR keeps its number forever. - `slug` is short kebab-case naming the subject (`oauth-only-auth`, `per-trial-container`). - A protocol/how-to file (e.g. `00_*`) sorts above the numbered records and is **not** part of the sequence. ### The directory IS the index Do **not** maintain a hand-curated numbered list as the source of truth — it drifts from the filesystem. The authoritative list is the directory itself; `adr-index.sh` is just a clean parse of it. Any prose list elsewhere (README, AGENTS.md) is a convenience pointer that **may lag** and must say so. --- ## Canonical format (compact view) Full template in `assets/ADR-template.md`; full rules in `references/canonical-format.md`. The shape: ```markdown --- status: accepted date: YYYY-MM-DD supersedes: [] superseded-by: [] touches: - "path/one.py" --- # ADR-NNN: Title in Title Case ## Decision (one sentence) <BLUF — one present-tense sentence stating the standing rule; greppable, stands alone.> ## Context ## Alternatives considered ## Consequences ### Positive / ### Negative / ### Non-goals ## See also ``` **Fixed section order:** Decision → Context → Alternatives considered → Consequences → See also. Extra sections (Migration path, Enforcement, Implementation summary) go *after* Consequences. For a multi-part decision, keep the one-sentence BLUF and add a `## Decision (detail)` section lower down. ### Frontmatter fields | Field | Required | Rule | |---|---|---| | `status` | yes | `proposed` / `accepted` / `superseded` / `deprecated` (lowercase). | | `date` | yes | Decision date, `YYYY-MM-DD`. | | `supersedes` | yes | YAML list of ADR ids this replaces (`[]` if none). | | `superseded-by` | yes | YAML list of ADR ids that replace this (`[]` until superseded). | | `touches` | yes | YAML list of paths / globs / config keys this governs. **Quote each.** The grep discovery surface — `grep touches:` answers "is there an ADR about the thing I'm changing?" | | `extends` | optional | ADR ids this builds on without replacing. | | `related` | optional | Companion ADR ids (not parents). | | `deciders` | optional | Who made the call. | A new field is a protocol change — record it in a new ADR, don't invent per-record keys. --- ## Status lifecycle & immutability ``` proposed ──► accepted ──► superseded (superseded-by: [ADR-NNN]) │ └────────► deprecated (withdrawn; nothing replaces it) ``` ADRs are **append-only**. Once `accepted`, you do not rewrite the Decision or Context. Three change modes (full detail in `references/lifecycle-and-supersession.md`): 1. **Supersede** — the rule itself changes. Write a new ADR with `supersedes: [ADR-OLD]`; flip the old record's frontmatter to `status: superseded` + `superseded-by: [ADR-NEW]` in the **same commit**. Body stays intact (obsolete reasoning is itself a record). Supersession is **bidirectional** — a one-sided link is a lint error. 2. **Addendum** — new facts that refine an in-force decision. A dated `## Addendum — YYYY-MM-DD: <topic>` at the **end** of the body. Never use it to quietly reverse the decision. 3. **In-place edit** — typos, dead links, a renamed path in `touches:`. Preserves meaning; never rewrites rationale. --- ## End-to-end workflow 1. **Scaffold** the next record: `bash scripts/adr-new.sh --dir docs/adr --title "Your decision title"` (computes `NNN = highest+1`, derives the slug, fills frontmatter). 2. **Fill it in** — BLUF first, then Context, Alternatives, Consequences, See also. Keep `touches:` accurate; it is the discovery surface. 3. **Cross-check the number** against the directory to avoid a collision with a parallel session: `ls docs/adr/ADR-*.md` (or `bash scripts/adr-index.sh`). 4. **If superseding**, flip the old record's frontmatter in the **same commit** — either by hand or with `adr-new.sh --supersedes ADR-OLD --apply-supersede`. 5. **Lint** before committing: `python scripts/adr-lint.py --dir docs/adr`. 6. **Commit** with a `docs(adr):` conventional-commit subject, e.g. `docs(adr): ADR-020 — <subject>`. Adopting ADRs in a fresh repo? Run `bash scripts/adr-init.sh --first-title "…"` once to bootstrap the directory + a lint-clean ADR-001. Before changing an existing subsystem, run `python scripts/adr-touching.py <path>` to surface any decision already governing it. --- ## Tools All scripts take `--dir` (default `docs/adr`), `--help`, and follow semantic exit codes (`0` ok, `2` usage, `3` not-found, `5` precondition, `10` findings/domain-signal). Pair with the `git-ops` skill for the commit/PR step. The three read tools form the legs of a stool: **lint** = integrity, **index** = overview, **touching** = "what governs this file before I change it". ### `scripts/adr-init.sh` — bootstrap a repo adopting ADRs cold ```bash # Create docs/adr/, scaffold a lint-clean ADR-001, write a generated README: bash scripts/adr-init.sh --first-title "Adopt ADRs" # Custom dir + preview without writing: bash scripts/adr-init.sh --dir docs/decisions --first-title "OAuth-only auth" --dry-run ``` Refuses to run in a directory that already holds `ADR-*.md` (exit 5) unless `--force`. The ADR-001 it scaffolds is rendered by `adr-new.sh`, so it lints clean immediately. The generated `<dir>/README.md` is self-labeled "generated — do not hand-edit; the directory is the index" and says to run `adr-index` to regenerate. `--dry-run` writes nothing. ### `scripts/adr-new.sh` — scaffold the next ADR ```bash # Next number, slug derived from the title, frontmatter pre-filled: bash scripts/adr-new.sh --title "OAuth-only auth" # Custom dir + explicit slug + proposed status, preview without writing: bash scripts/adr-new.sh --dir docs/decisions --title "Per-trial container" \ --slug per-trial-container --status proposed --dry-run # Supersede an old record and flip its frontmatter automatically: bash scripts/adr-new.sh --title "Replace router" --supersedes ADR-002 --apply-supersede ``` Refuses to overwrite an existing file (exit 5). Atomic write. `--dry-run` prints the path + rendered content and writes nothing. `--number N` forces a specific number (backfilling or coordination) — use sparingly; sequential `highest+1` is the discipline. ### `scripts/adr-index.sh` — the directory as a table (read-only) ```bash bash scripts/adr-index.sh # number | status | date | title bash scripts/adr-index.sh --json | jq '.data[] | select(.status=="accepted")' ``` Prefers `yq`; degrades to a built-in parser when yq is absent (announced on stderr). Pass `--output FILE` to write a **generated Markdown index** (heading + a `do not hand-edit` marker + the `| # | Status | Date | Title |` table) atomically to a file instead of stdout — for a README pointer that you regenerate rather than hand-curate. ### `scripts/adr-touching.py` — what governs this file? (the discovery surface) The `touches:` frontmatter is the grep target answering "is there an ADR about the thing I'm changing?". This tool *is* that grep, done properly — match a path, glob, or config key against every ADR's `touches:` list. ```bash # Before editing src/auth.py, ask what decisions constrain it: python scripts/adr-touching.py src/auth.py # exit 10 if an ADR governs it python scripts/adr-touching.py 'src/**' # glob query python scripts/adr-touching.py --json src/ | jq '.data[].number' ``` Matching is bidirectional and pragmatic: exact equality; fnmatch glob either direction (touches `src/**` matches query `src/auth.py`; query `src/*` matches touches `src/auth.py`); path-prefix containment (query `src/` governs touches `src/auth.py`, and vice-versa); config keys (`file.yaml:key`) by exact-or-prefix. **Guard contract (the load-bearing bit):** exit **0 = no governing ADR found**, exit **10 = at least one ADR governs the query**. A pre-edit hook or CI step branches on it — "heads up, ADR-010 governs this path; read it before changing." Exit `3` dir not found, `2` usage. **Batched queries:** pass several positionals and the ADR set is parsed once — one spawn for N paths, which is what makes it cheap to call from a lint loop (fleetflow's `ff-plan lint` went from 225 spawns to 35 on a 35-packet plan). The exit code is any-governed (`10` if at least one query is governed, `0` only when none is); the per-query split is in the `--json` envelope's `queries` list, each entry `{query, governing, rc}`. `data` stays the deduped union so `.data[].number` keeps working, and a single-query call's envelope is unchanged. ```bash python scripts/adr-touching.py --json src/a.py src/b.py lib/ \ | jq -r '.queries[] | select(.rc==10) | .query' # which of these are governed ``` ### `scripts/adr-lint.py` — conformance validator ```bash python scripts/adr-lint.py --dir docs/adr # exit 0 clean, 10 if findings python scripts/adr-lint.py --strict --json | jq '.data[] | select(.severity=="error")' ``` Checks required + well-typed frontmatter, the `# ADR-NNN:` title matching the filename, the BLUF placement, core section order, **no duplicate numbers** (gaps are a warning), and **supersession bidirectionality** (the high-value cross-file check). Plus: - **Lifecycle consistency** (errors): `superseded` with an empty `superseded-by`; `deprecated` with a non-empty `superseded-by`; an in-force (`accepted`/`proposed`) ADR carrying a `superseded-by`. These complement the bidirectionality check without double-reporting. - **Stale `touches`** (warning): a `touches:` entry that is a literal filesystem path (not a glob, not a config key) which no longer resolves under `--repo-root` (default: git toplevel, else cwd) — the discovery surface may have drifted. Warning-tier only; counts toward exit 10 under `--strict`. `--strict` makes warnings count toward exit 10. Exit 4 if a file's frontmatter is unparseable. --- ## CI integration ADRs only stay trustworthy if the integrity contract is machine-enforced. Gate the lint in CI; `--strict` turns the stale-`touches` drift warning into a hard signal. ```yaml # .github/workflows/adr-lint.yml name: adr-lint on: [pull_request] jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Lint ADRs run: python skills/adr-ops/scripts/adr-lint.py --strict --dir docs/adr # exit 10 (findings, incl. stale-touches under --strict) fails the build ``` **Local pre-commit gate:** add `python scripts/adr-lint.py --strict --dir docs/adr` to a pre-commit hook so a one-sided supersession or a stale discovery surface is caught before the commit lands. A pre-edit hook can additionally call `adr-touching.py <changed-path>` and surface the governing ADR (exit 10) before a subsystem is modified. --- ## See also - `references/canonical-format.md` — the full template, field table, and body rules. - `references/lifecycle-and-supersession.md` — status lifecycle + the three change modes. - `assets/ADR-template.md` — copy-ready canonical template.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.