diff --git a/.adr-toolkit.json b/.adr-toolkit.json new file mode 100644 index 0000000..4c14840 --- /dev/null +++ b/.adr-toolkit.json @@ -0,0 +1,4 @@ +{ + "schema_version": 1, + "locale": "en" +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index e645c6f..7597106 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,5 +1,5 @@ { "name": "adr-toolkit", - "version": "0.1.0", - "description": "Initialize, record, and check Architecture Decision Records by inspecting the repository before asking questions." + "version": "0.2.0", + "description": "Initialize, record, and check Architecture Decision Records by inspecting the repository and existing decisions before asking questions." } diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..2c4e92b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,39 @@ +--- +name: Bug report +about: Report a defect in ADR Toolkit +title: "" +labels: bug +--- + +## Summary + +## Steps to Reproduce + +1. +2. +3. + +## Expected Behavior + +## Actual Behavior + +## Environment + +- Harness: Claude Code / Codex CLI / Gemini CLI / Antigravity CLI / generic / other +- ADR Toolkit version (`skills/adr-toolkit/VERSION`): +- OS: + +## Relevant Output + +Paste the command and its output, including `--json` output where +applicable. + +``` +``` + +## Scope + +- [ ] This affects `adr.py validate`, `create`, `index`, `check`, `graph`, + or `search`. +- [ ] This affects a specific harness adapter (Codex, Gemini CLI, + Antigravity, or generic). diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..4f7f064 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,19 @@ +--- +name: Feature request +about: Propose new functionality for ADR Toolkit +title: "" +labels: enhancement +--- + +## Problem + +What are you trying to do that ADR Toolkit does not support today? + +## Proposed Solution + +## Alternatives Considered + +## Additional Context + +- [ ] I checked `project-roadmap.md` and `improvements.md` for existing + plans that already cover this. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..304542a --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,22 @@ +## Summary + +- + +## ADR Impact + +- [ ] I checked whether this changes an accepted architectural decision. +- [ ] I linked related ADRs, or this PR has no ADR impact. +- [ ] I ran `adr.py check` when the change touches governed paths. + +Related ADRs: + +## Verification + +- [ ] `python3 -m pytest -q` +- [ ] `python3 scripts/sync_version.py --check` +- [ ] `python3 skills/adr-toolkit/scripts/adr.py validate --dir docs/decisions --json` +- [ ] `python3 skills/adr-toolkit/scripts/adr.py index --dir docs/decisions --json` + +## Notes + +- diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9e4bd9d..5fb11b4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,5 +32,66 @@ jobs: run: pip install pytest - name: Run tests run: python -m pytest tests/unit tests/integration -v - - name: Check manifest versions are in sync + + version-drift: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Check manifest versions and descriptions are in sync run: python scripts/sync_version.py --check + + harness-parity: + # Installs the real Codex CLI and Gemini CLI and drives their own plugin + # commands against this repo, the same way a contributor would. Manifest + # JSON shape is already covered by tests/unit/test_*_adapter.py; this job + # instead proves discovery -> install -> list -> the installed skill + # package's script layer still runs, so an upstream CLI change or a + # manifest edit that breaks real installation fails CI instead of + # surfacing later as a user-reported install failure. Antigravity CLI + # (agy) has no public package registry distribution, so it stays a + # manually verified adapter only -- see adapters/antigravity/README.md. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install Codex CLI + run: npm install -g @openai/codex@0.151.0 + - name: Install Gemini CLI + run: npm install -g @google/gemini-cli@0.46.0 + - name: Verify Codex CLI adapter end to end + run: | + set -euo pipefail + export CODEX_HOME="$(mktemp -d)" + REPO_ROOT="$(pwd)" + codex plugin marketplace add "$REPO_ROOT" + INSTALLED_PATH="$(codex plugin add adr-toolkit@adr-toolkit-marketplace --json | jq -r '.installedPath')" + codex plugin list + SCRATCH="$(mktemp -d)" + cd "$SCRATCH" && git init -q + python3 "$INSTALLED_PATH/skills/adr-toolkit/scripts/adr.py" preflight --json | jq -e '.ok == true' + python3 "$INSTALLED_PATH/skills/adr-toolkit/scripts/adr.py" init --dir docs/decisions --json | jq -e '.ok == true' + python3 "$INSTALLED_PATH/skills/adr-toolkit/scripts/adr.py" validate --dir docs/decisions --json | jq -e '.ok == true' + - name: Verify Gemini CLI adapter end to end + run: | + set -euo pipefail + REPO_ROOT="$(pwd)" + mkdir -p adapters/gemini-cli/skills + ln -s "$REPO_ROOT/skills/adr-toolkit" adapters/gemini-cli/skills/adr-toolkit + export HOME="$(mktemp -d)" + gemini extensions validate "$REPO_ROOT/adapters/gemini-cli" + printf 'y\ny\n' | gemini extensions install "$REPO_ROOT/adapters/gemini-cli" --consent + gemini extensions list + INSTALLED_PATH="$HOME/.gemini/extensions/adr-toolkit" + SCRATCH="$(mktemp -d)" + cd "$SCRATCH" && git init -q + python3 "$INSTALLED_PATH/skills/adr-toolkit/scripts/adr.py" preflight --json | jq -e '.ok == true' + python3 "$INSTALLED_PATH/skills/adr-toolkit/scripts/adr.py" init --dir docs/decisions --json | jq -e '.ok == true' + python3 "$INSTALLED_PATH/skills/adr-toolkit/scripts/adr.py" validate --dir docs/decisions --json | jq -e '.ok == true' diff --git a/.gitignore b/.gitignore index 74738ed..3d12363 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,26 @@ __pycache__/ *.pyc .pytest_cache/ *.egg-info/ +.coverage +htmlcov/ +dist/ +build/ + +# Virtual environments +.venv/ +venv/ +env/ + +# Editor/IDE +.vscode/ +.idea/ + +# OS +.DS_Store + +# Local secrets/config — never commit real credentials +.env +.env.*.local # Adapter install-time symlinks. Every adapter README tells the user to # create these inside this working tree; committing a real symlink breaks @@ -9,3 +29,9 @@ __pycache__/ adapters/codex/skills/adr-toolkit adapters/gemini-cli/skills/adr-toolkit adapters/antigravity/skills/adr-toolkit + +# Task-by-task implementation plans (superpowers subagent-driven-development +# artifacts) — kept locally for reference, not part of the public tree. +# docs/superpowers/specs/ (the design docs) stay tracked; only plans/ is +# excluded. History from before this commit still has them if ever needed. +docs/superpowers/plans/ diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..f8fb524 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,44 @@ +# Code of Conduct + +## Our Standard + +Participation in this project — issues, pull requests, discussions, and +reviews — is expected to stay respectful and focused on the technical +problem. Disagreement about a decision or an approach is normal; personal +attacks, harassment, discriminatory language, or deliberately derailing a +discussion are not. + +## Expected Behavior + +- Assume good faith and give reviewers/contributors the benefit of the + doubt. +- Keep feedback specific to the code, design, or decision under + discussion. +- Accept that maintainers may decline a change and explain why, without + that being an invitation to escalate. + +## Unacceptable Behavior + +- Harassment, insults, or discriminatory remarks directed at a person + rather than their contribution. +- Publishing others' private information without consent. +- Sustained disruption of issues, pull requests, or reviews. + +## Reporting + +Report a violation privately to the repository owner rather than escalating +in a public issue or pull request thread. Include what happened, where +(link to the issue/PR/comment), and when. + +## Enforcement + +The repository owner reviews reports and may edit or remove content, warn a +participant, or restrict further participation in the project's spaces, +proportional to the violation. Enforcement decisions are not owed a public +explanation when doing so would expose reporter identity or private +details. + +## Scope + +This applies to all project spaces (issues, pull requests, discussions, +commits, and comments) and to representing the project in public spaces. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4445b47 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,48 @@ +# Contributing + +Thanks for helping improve ADR Toolkit. This project is intentionally small: +the agent can reason and draft, but deterministic scripts own file writes, +IDs, lifecycle transitions, validation, indexes, and machine-readable output. + +## Branches + +Follow the repository policy in `AGENTS.md`. + +- Branch from `develop` for normal work. +- Use `feature/*`, `fix/*`, or `docs/*` for short-lived work branches. +- Merge normal work back to `develop` through a pull request. +- Release branches merge to `master` and back to `develop`. +- Tags named `v*` are created only from `master`. + +## Local Checks + +Run the relevant checks before opening a pull request: + +```bash +python3 -m pytest -q +python3 scripts/sync_version.py --check +python3 skills/adr-toolkit/scripts/adr.py validate --dir docs/decisions --json +python3 skills/adr-toolkit/scripts/adr.py index --dir docs/decisions --json +``` + +For changes that may affect accepted architectural decisions, also run: + +```bash +python3 skills/adr-toolkit/scripts/adr.py check --uncommitted --dir docs/decisions --json +``` + +## ADR Changes + +- Keep ADR source files flat under `docs/decisions/`. +- Do not hand-edit generated indexes or lifecycle links to repair validation + errors. Change the source ADR or use the lifecycle command. +- Use `adr.py create`, `status`, `deprecate`, `supersede`, and `exception` for + deterministic mutations. +- Accepted ADRs are append-only in spirit: use supersession for changed + decisions instead of rewriting history. + +## Public Repository Hygiene + +Public repository branch/tag protection is expected to enforce PRs, required +checks, conversation resolution, and force-push/deletion restrictions on +`develop`, `master`, and `v*` tags once the repository is public. diff --git a/README.md b/README.md index 3718c9e..5db17d3 100644 --- a/README.md +++ b/README.md @@ -1 +1,261 @@ -# ADR-toolkit \ No newline at end of file +# ADR Toolkit + +An agent-native Architecture Decision Record toolkit: it inspects your +repository and existing decisions before asking questions, records new +decisions with a human-approved MADR, and checks a diff against Accepted +decisions for structural conflicts — all before anything gets written. + +Kafka got introduced into a production system once, and the person who +made that call left the company. Nobody could say why Kafka was chosen +over the alternatives, what problem it solved, or what was consciously +traded away. ADR Toolkit exists so that, going forward, the *reason* +behind a structural decision survives as long as the code does — written +by inspecting the repository first, not invented, and findable by a +successor (human or agent) in under a minute. + +## What it does + +| Operation | What it does | +|---|---| +| **INIT** | Scaffolds `docs/decisions/` in a repo that has none yet — directory, template, and ADR-0001. | +| **DISCOVER** | Mines existing conventions (dependency manifests, code, git history) for past decisions that were never written down, and drafts retrospective ADRs for ones you approve. | +| **RECORD** | Captures a new decision — before or after implementing it — investigating the code first and asking only what it can't answer (max 3 questions). | +| **CHECK** | Matches a diff against Accepted ADRs' structured `constraints:` rules and reports Related / Review required / Verified violation / No applicable constraint — never picks a fix for you. | + +Every file write goes through a deterministic script and is shown to a +human before it happens. Judgment (what's significant, what to ask, how to +draft) is the agent's job; file writes, ID assignment, and validation +never are. See [`examples/quickstart.md`](examples/quickstart.md) for a +full INIT → RECORD → CHECK walkthrough with real command output, or +[`docs/decisions/`](docs/decisions/) for this toolkit's own dogfooded ADRs. + +## Install + +`skills/adr-toolkit/` is one self-contained, harness-agnostic package — +copy or symlink it wherever your harness looks for skills, then point one +of these adapters at it: + +| Harness | Depth | Install | +|---|---|---| +| [Claude Code](.claude-plugin/) | Full plugin, auto-discovered | Add this repo via `.claude-plugin/marketplace.json` | +| [Codex CLI](adapters/codex/) | Agent Plugins 1.0.0 manifest | [`adapters/codex/README.md`](adapters/codex/README.md) | +| [Gemini CLI](adapters/gemini-cli/) | Extension manifest | [`adapters/gemini-cli/README.md`](adapters/gemini-cli/README.md) | +| [Antigravity CLI](adapters/antigravity/) | Plugin manifest | [`adapters/antigravity/README.md`](adapters/antigravity/README.md) | +| Anything else | Generic fallback | [`adapters/generic/README.md`](adapters/generic/README.md) — needs only markdown-reading and shell | + +**No AI harness at all?** `create --interactive` runs the same interview +directly in a terminal — no agent required: + +```bash +python skills/adr-toolkit/scripts/adr.py create --interactive --dir docs/decisions --json +``` + +## Language and repository config + +ADR Toolkit localizes deterministic, code-owned text in eight canonical +locales: `en`, `ko`, `ja`, `zh`, `fr`, `es`, `de`, and `pt-BR`. `zh` means +Simplified Chinese. User-authored prose is preserved as written, while JSON +keys, status values, error codes, IDs, and filenames remain machine-stable. + +Choose the repository default during INIT: + +```bash +python skills/adr-toolkit/scripts/adr.py init --locale ko --dir docs/decisions --json +``` + +This creates `.adr-toolkit.json` at the repository root: + +```json +{ + "schema_version": 1, + "locale": "ko" +} +``` + +Commands then use the repository default without repeating `--locale`: + +```bash +python skills/adr-toolkit/scripts/adr.py create --interactive --dir docs/decisions --json +python skills/adr-toolkit/scripts/adr.py index --dir docs/decisions --json +``` + +An explicit flag overrides the repository default for one operation. Input +draft locale overrides the repository only when the CLI flag is absent: + +```bash +python skills/adr-toolkit/scripts/adr.py create --locale ja --interactive --dir docs/decisions --json +python skills/adr-toolkit/scripts/adr.py index --locale fr --dir docs/decisions --json +``` + +The effective CLI order is explicit flag → approved input draft → repository +default → `en`. Unsupported locales and malformed config fail visibly. + +### Unicode titles and portable filenames + +Titles and bodies remain Unicode. Filenames remain ASCII for filesystem, URL, +and Git portability. For the title `결제 시스템 분리`, an agent can propose a +meaningful slug and show it for human approval: + +```bash +python skills/adr-toolkit/scripts/adr.py create --input draft.json \ + --slug separate-payment-system --dir docs/decisions --json +``` + +The deterministic core validates the slug and creates a filename such as +`0002-separate-payment-system.md`. It never translates or transliterates the +title itself. Without an approved slug or an ASCII fragment in the title, a +fresh repository safely falls back to `0001-decision.md`. + +## CHECK confidence + +CHECK does not certify the entire architecture. It evaluates only explicit, +structurally provable rules in the selected diff. Every finding carries a +`confidence` field with one of these four values directly — no need to +re-derive it from `kind`: + +| `confidence` | `kind` it comes from | Meaning | +|---|---|---| +| `VERIFIED` | `related` | Applicable explicit rules were evaluated and none fired. | +| `VIOLATED` | `verified_violation` | Structural evidence confirms a violation. | +| `UNVERIFIABLE` | `review_required` or `no_applicable_constraint` | No usable rule vocabulary could prove or disprove this. | +| `NOT_APPLICABLE` | (no finding at all) | No known ADR/rule applies to the selected change — an empty `findings` list, not proof of compliance. | + +Warnings mean some evidence could not be evaluated and must be reported. A +clean result never proves prose rationale, runtime behavior, or every +architecture invariant. + +### Exceptions + +`register_exception` — one of a Verified violation's five resolutions — is a +real, deterministic record, not just a label: + +```bash +python skills/adr-toolkit/scripts/adr.py exception --input exception.json \ + --dir docs/decisions --json +``` + +`exception.json` requires `adr_id`, `rule_id`, `owner`, `reason`, a `scope` +(path patterns the exception is narrowed to), and an `expiry` +(`YYYY-MM-DD`). The command assigns the next `EXC-NNNN` id and writes +`docs/decisions/exceptions/NNNN.json`. CHECK annotates a matching, non-expired +exception onto its finding's `exception` field — the finding's `kind` and +`confidence` stay exactly what the structural evidence says +(`verified_violation`/`VIOLATED`); an exception is visible, reviewable +evidence, never a silent pass. Once `expiry` passes, CHECK stops applying it +automatically. + +## Search + +Find an existing ADR by keyword (title **and** body), tags, status, or the +file path it governs — without opening every file: + +```bash +python skills/adr-toolkit/scripts/adr.py search --keyword architecture --dir docs/decisions --json +``` + +```json +{ + "ok": true, + "operation": "search", + "query": {"keyword": "architecture", "tags": null, "status": null, "path": null, "limit": null}, + "count": 1, + "total": 1, + "truncated": false, + "results": [ + { + "id": "ADR-0001", + "filename": "0001-record-architecture-decisions.md", + "path": "docs/decisions/0001-record-architecture-decisions.md", + "title": "Record architecture decisions", + "status": "accepted", + "tags": ["process"], + "matched_in": ["title", "body"] + } + ], + "warnings": [] +} +``` + +**Filter semantics:** filters across different fields (`--id`, `--keyword`, +`--tags`, `--status`, `--path`) are combined with AND. Multiple values within +`--tags` are combined with OR — `--tags postgres mysql` means "postgres or +mysql". No filters at all browses every ADR. `--id` looks up one ADR by its +exact id. `--path` matches a real file against an ADR's governed scope (the +same directory-boundary + glob logic CHECK uses), not an exact match against +the ADR's literal `affected_paths` list. `--limit N` truncates the +already-ranked (best-match-first) result list; `total` is always the +untruncated count and `truncated` is `total > count`. + +`search` is a general lookup command ("has this been decided before?"), distinct +from `related` (used during RECORD's DISCOVER stage to find precedent for a +*new* draft, with a broader OR-across-fields match). + +## Relationship graph + +`index` embeds a Mermaid relationship graph in `docs/decisions/README.md` +whenever ADRs have `related` or `supersedes` links, so GitHub can render the +navigation view directly. + +For a standalone graph artifact, export Mermaid and SVG files: + +```bash +python skills/adr-toolkit/scripts/adr.py graph --dir docs/decisions --format both --json +``` + +This writes `docs/decisions/relationships.mmd` and +`docs/decisions/relationships.svg`. SVG is the default image artifact because it +stays sharp when zoomed and does not require Mermaid CLI, Node, or a browser. +With `--format both`, `--output build/adr-relationships` is treated as a file +prefix and writes `build/adr-relationships.mmd` plus +`build/adr-relationships.svg`; relative output paths are resolved from +`--root`, matching the other repository-scoped commands. + +### Why storage stays flat + +ADRs live as `NNNN-slug.md` in one flat `docs/decisions/` directory — no +per-year, per-team, or per-status subfolders — no matter how many +accumulate. Two comparable real-world tools were checked before deciding +this: [`npryce/adr-tools`](https://github.com/npryce/adr-tools), the +original Nygard-style CLI, ships no search at all and expects `grep`; the +most-adopted actively maintained ADR tool, +[`log4brains`](https://github.com/thomvaill/log4brains) (1.5k+ GitHub +stars), also keeps ADRs as flat Markdown and instead layers a full-text +search and a relationship graph on top — it does not shard the source +files by count. A folder hierarchy also creates a real problem flat storage +avoids: a decision that spans two teams or domains has no unambiguous +folder to live in. + +The corollary is **retrieval, not storage, is where a growing ADR set gets +harder to use** — so that's where the effort went: `search`'s +title-and-body keyword/tag/status/path matching plus the generated index's +"By status" / "By tag" / "By affected path" / "Relationships" views. Both +already work identically for 10 ADRs or 500. Folder sharding, a rendered +relationship graph, and a real search index are deliberately not built yet +— they're tracked in [`project-roadmap.md`](project-roadmap.md), gated on +this repository (or an adopting team's) ADR count actually reaching a scale +where flat-directory substring search stops being fast enough. Building +that ahead of evidence would add real maintenance cost for a problem no one +has hit yet. + +## Scope + +- MADR 4.x format, no new standard. +- CHECK's MVP conflict detection is structural evidence only + (`constraints:` blocks) — no semantic/AST analysis. See + [ADR-0002](docs/decisions/0002-limit-check-s-conflict-detection-to-structural-evidence-only.md). +- Deterministic INIT, CREATE, and INDEX structure ships in eight locales; + agent-composed and user-authored prose is never machine-translated by the + core. See + [ADR-0006](docs/decisions/0006-localized-adr-generation.md). +- Everything explicitly deferred out of MVP is tracked in + [`project-roadmap.md`](project-roadmap.md), not silently dropped. + +## Contributing + +Read [`AGENTS.md`](AGENTS.md) first — it's the shared operating document +every harness (and every human) working in this repo follows, including +the branch policy. + +## License + +[MIT](LICENSE) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..84f604e --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,34 @@ +# Security Policy + +## Supported Versions + +Security fixes target the latest released version of ADR Toolkit. + +## Reporting a Vulnerability + +Please report security issues privately to the repository owner before opening a +public issue. Include: + +- affected version or commit +- reproduction steps +- expected impact +- whether the issue affects generated ADR content, command execution, path + handling, release automation, or plugin installation + +Do not include exploit details in a public issue until a fix or mitigation is +available. + +## Scope + +In scope: + +- command injection or unsafe path handling in deterministic scripts +- release workflow or version provenance problems +- plugin manifest behavior that can install or expose unintended files +- validation bugs that silently report clean results for invalid ADR state + +Out of scope: + +- disagreement with an ADR's architectural recommendation +- AI-generated prose quality +- unsupported third-party harness behavior outside this repository's adapters diff --git a/adapters/antigravity/README.md b/adapters/antigravity/README.md index fc9331b..205d586 100644 --- a/adapters/antigravity/README.md +++ b/adapters/antigravity/README.md @@ -3,6 +3,9 @@ Antigravity plugins are a `plugin.json` marker file plus optional sibling directories (`skills/`, `agents/`, `rules/`), per `antigravity.google/docs/cli/plugins/`. This manifest needs only `name`. +**Manually verified against Antigravity's `agy` CLI 1.1.13** (`agy +--version`): validate, install, and discovery all work — see "Verification +status" below. ## Install @@ -14,18 +17,62 @@ directories (`skills/`, `agents/`, `rules/`), per ln -s "$(pwd)/skills/adr-toolkit" adapters/antigravity/skills/adr-toolkit ``` -2. Install per Antigravity's own plugin-install documentation. -3. Confirm Antigravity lists `adr-toolkit`, then run +2. Install with Antigravity's own plugin CLI: + + ```bash + agy plugin install "$(pwd)/adapters/antigravity" + ``` + +3. Confirm Antigravity lists `adr-toolkit`: + + ```bash + agy plugin list + ``` + +4. Either way, run `python skills/adr-toolkit/scripts/adr.py preflight --json` from your target repository to confirm the script layer works standalone. -**Verification status:** this adapter's manifest and directory layout are -verified against Antigravity's published schema, but — unlike the Codex -and Gemini CLI adapters — no end-to-end install-and-run was performed, -because no `antigravity` CLI binary was available in the development -environment that built this adapter. Treat it as unverified until someone -with the actual CLI confirms it, and update this note when they do. - The symlink is created at install time, not committed to this repo — committing a real symlink breaks on Windows checkouts that don't have `core.symlinks` enabled, which this project's CI can't assume. + +## Verification status + +Manually verified against Antigravity's `agy` CLI 1.1.13 (`agy --version`) +in an isolated `HOME=$(mktemp -d)` so no state was written to the real +`~/.gemini` (Antigravity's plugin CLI stores state under `.gemini/config/` +in the active home directory). + +``` +$ agy plugin validate "$(pwd)/adapters/antigravity" + [ok] /adapters/antigravity + [check] skills : 1 processed + - agents : skipped (not found) + - commands : skipped (not found) + - mcpServers : skipped (not found) + - hooks : skipped (not found) + +$ agy plugin install "$(pwd)/adapters/antigravity" + [ok] adr-toolkit + [check] skills : 1 processed + +$ agy plugin list +{ + "imports": [ + { + "name": "adr-toolkit", + "source": "antigravity", + "importedAt": "2026-08-31T06:11:34Z", + "components": ["skills"] + } + ] +} +``` + +The installed snapshot lands under `/.gemini/config/plugins/adr-toolkit/` +and carries the whole `skills/adr-toolkit/` package. The script layer was +run directly out of it against a scratch git repository, producing correct +`"ok": true` output for `preflight`, `init --dir docs/decisions`, and +`validate --dir docs/decisions` — matching the Codex and Gemini CLI +adapters' verified depth. diff --git a/adapters/codex/README.md b/adapters/codex/README.md index 27c99c6..80d8ac4 100644 --- a/adapters/codex/README.md +++ b/adapters/codex/README.md @@ -16,15 +16,7 @@ from a bare plugin directory. This repository is already a valid marketplace root, because its `.claude-plugin/marketplace.json` is a manifest format Codex CLI reads, so no extra manifest is needed. -1. Symlink this repo's `skills/adr-toolkit/` package under this adapter's - directory, as a sibling of `.codex-plugin/` (design spec §17.2): - - ```bash - mkdir -p adapters/codex/skills - ln -s "$(pwd)/skills/adr-toolkit" adapters/codex/skills/adr-toolkit - ``` - -2. Register this repository as a Codex marketplace and install the plugin: +1. Register this repository as a Codex marketplace and install the plugin: ```bash codex plugin marketplace add "$(pwd)" @@ -35,21 +27,57 @@ Codex CLI reads, so no extra manifest is needed. `.claude-plugin/marketplace.json`; `codex plugin marketplace add` prints it on success, and `codex plugin marketplace list` shows it later. -3. Confirm Codex lists `adr-toolkit` as installed and enabled: +2. Confirm Codex lists `adr-toolkit` as installed and enabled: ```bash codex plugin list ``` -4. Optionally run +3. Optionally run `python skills/adr-toolkit/scripts/adr.py preflight --json` from your target repository to confirm the script layer works standalone. -The symlink is created at install time, not committed to this repo — -committing a real symlink breaks on Windows checkouts that don't have -`core.symlinks` enabled, which this project's CI can't assume. `.gitignore` -already ignores `adapters/codex/skills/adr-toolkit` so a `git add -A` after -step 1 cannot commit it by accident. +No symlink step is required. Registering the repository **root** as the +marketplace means Codex reads `.claude-plugin/plugin.json` as the plugin +manifest, and Agent Plugins' sibling-`skills/`-directory discovery finds the +real `skills/adr-toolkit/` package next to it directly — verified below by +installing with no `adapters/codex/skills/` symlink present at all. + +## Why `.codex-plugin/plugin.json` and an adapter-local symlink exist here too + +Design spec §17.2 originally planned every harness adapter as a +**self-contained** plugin directory: `adapters/codex/.codex-plugin/plugin.json` +as that harness's own manifest, with `adapters/codex/skills/adr-toolkit` +symlinked in as its required sibling `skills/` directory +(`ln -s ../../../skills/adr-toolkit adapters/codex/skills/adr-toolkit`), so +each adapter would work if pointed at directly. + +That plan does not hold for Codex specifically: `codex plugin marketplace add` +rejects a bare single-plugin directory — + +``` +$ codex plugin marketplace add "$(pwd)/adapters/codex" +Error: invalid marketplace file `/adapters/codex`: marketplace root does not contain a supported manifest +``` + +— because only the repository root carries a marketplace manifest +(`.claude-plugin/marketplace.json`). So `adapters/codex/.codex-plugin/plugin.json` +and its sibling symlink are never read by the verified install path above; +they exist only for structural consistency with the Gemini and Antigravity +adapters' self-contained layout, and in case a future Codex version accepts a +bare plugin directory as a marketplace root. If you want that manifest to +resolve correctly on its own regardless, create the symlink manually: + +```bash +mkdir -p adapters/codex/skills +ln -s "$(pwd)/skills/adr-toolkit" adapters/codex/skills/adr-toolkit +``` + +This is not committed to the repo — committing a real symlink breaks on +Windows checkouts that don't have `core.symlinks` enabled, which this +project's CI can't assume. `.gitignore` already ignores +`adapters/codex/skills/adr-toolkit` so a `git add -A` cannot commit it by +accident. ## Verification status @@ -67,8 +95,8 @@ $ codex plugin add adr-toolkit@adr-toolkit-marketplace --json "pluginId": "adr-toolkit@adr-toolkit-marketplace", "name": "adr-toolkit", "marketplaceName": "adr-toolkit-marketplace", - "version": "0.1.0", - "installedPath": "/plugins/cache/adr-toolkit-marketplace/adr-toolkit/0.1.0", + "version": "0.2.0", + "installedPath": "/plugins/cache/adr-toolkit-marketplace/adr-toolkit/0.2.0", "authPolicy": "ON_INSTALL" } @@ -77,7 +105,7 @@ Marketplace `adr-toolkit-marketplace` /.claude-plugin/marketplace.json PLUGIN STATUS VERSION PATH -adr-toolkit@adr-toolkit-marketplace installed, enabled 0.1.0 +adr-toolkit@adr-toolkit-marketplace installed, enabled 0.2.0 ``` The installed snapshot under `/plugins/cache/` carries the @@ -86,6 +114,12 @@ out of it against a scratch git repository, producing correct `"ok": true` output for `preflight`, `init --dir docs/decisions`, and `validate --dir docs/decisions`. +Re-verified with **no `adapters/codex/skills/` symlink present at all**: the +same three commands above still succeed and `preflight --json` still returns +`"ok": true` from the installed snapshot, confirming the symlink step this +README previously listed as install step 1 played no part in the working +flow. + Two details worth knowing: - **A marketplace root, not a plugin directory, is what you register.** @@ -95,9 +129,8 @@ Two details worth knowing: documented marketplace model, not a defect; the repo root is the correct thing to register, and it works. - **The version Codex reports comes from the plugin manifest it reads.** - Installing from this repo's root reports `0.1.0`, taken from - `.claude-plugin/plugin.json`. Installing a marketplace root whose plugin - manifest is only this adapter's `.codex-plugin/plugin.json` reports - `1.0.0` instead, because that manifest carries no `version` field and - Codex substitutes a default. Adding an explicit `version` to - `.codex-plugin/plugin.json` is tracked as a follow-up. + Installing from this repo's root reports the version in + `.claude-plugin/plugin.json`, which is kept in sync with + `skills/adr-toolkit/VERSION` by `scripts/sync_version.py --check`. The + adapter-local `.codex-plugin/plugin.json` intentionally has no `version` + field because the verified install path does not read it. diff --git a/adapters/gemini-cli/gemini-extension.json b/adapters/gemini-cli/gemini-extension.json index aa12830..7597106 100644 --- a/adapters/gemini-cli/gemini-extension.json +++ b/adapters/gemini-cli/gemini-extension.json @@ -1,5 +1,5 @@ { "name": "adr-toolkit", - "version": "0.1.0", + "version": "0.2.0", "description": "Initialize, record, and check Architecture Decision Records by inspecting the repository and existing decisions before asking questions." } diff --git a/changelog.md b/changelog.md index 8247122..05ae2be 100644 --- a/changelog.md +++ b/changelog.md @@ -4,6 +4,67 @@ Lightweight human-readable summary of meaningful repository changes. ## Unreleased +- Added ADR relationship graph navigation: `adr.py index` now embeds a Mermaid + graph in the generated decision log when relationships exist, and + `adr.py graph` exports standalone `relationships.mmd` plus a crisp SVG + navigation artifact without requiring Node, Mermaid CLI, or browser + rendering. +- Added public repository readiness docs: `CONTRIBUTING.md`, `SECURITY.md`, + and a pull request template covering ADR impact and local verification. +- Added `adr.py search`: finds an existing ADR by keyword (title **and** + body — `related`'s keyword match was title-only before), tags, status, + `--id`, or the real file path it governs, with deterministic best-match + ordering and `--limit`/`total`/`truncated` for bounding results. Added a + Relationships section (supersession chains + related lists, titles + alongside IDs) to the generated `docs/decisions/README.md`, localized + across all 8 catalogs, and `validate` checks for broken supersession + links, one-sided supersession edits, and supersession cycles. +- Added CHECK policy exceptions: `adr.py exception` validates and records a + schema-checked, owned, scoped, time-boxed exception; CHECK annotates a + matching finding with it but never suppresses or downgrades the finding — + an exception is visible evidence, not a silent pass. +- Promoted CHECK's violation classification to a stable `confidence` field + (`VERIFIED`/`VIOLATED`/`UNVERIFIABLE`) on every finding, instead of an + agent re-deriving it from documentation each run. +- Fixed a Windows-only bug where CHECK's git evidence gathering + (`diff.py`/`git_paths.py`) decoded subprocess output using the platform's + default locale encoding instead of UTF-8, garbling Unicode paths/content + on Windows CI while working correctly on macOS/Linux. +- Hardened `scripts/sync_version.py` against three silent-drift bugs + (a tracked manifest losing its version key, a manifest path outside the + repo root crashing instead of failing cleanly, non-ASCII manifest content + being escaped on write) and extended it to keep manifest `description` + fields in sync with `SKILL.md`'s frontmatter, the same way `version` + already was. +- Formalized `--json` as a documented no-op: every command has always + printed JSON regardless of the flag; `--help` now says so explicitly + instead of implying a choice that doesn't exist. +- Corrected repository ADR provenance and lifecycle: existing ADRs now name + an approved decision maker and locale, and ADR-0006 (repository-default + locale generation) supersedes ADR-0003 (index-only localization) + bidirectionally. This session's own design decisions were recorded as + ADR-0007..0010 (CHECK confidence field, exception mechanism, `--json` + contract, and a closed-as-not-applicable Codex tooling question). +- Bumped to v0.2.0. +- Added repository-configured deterministic ADR localization for eight locales + (`en`, `ko`, `ja`, `zh`, `fr`, `es`, `de`, `pt-BR`) across INIT, CREATE, + templates, prompts, and INDEX, with strict config/schema validation and + explicit override precedence. +- Added portable multilingual ADR creation: Unicode titles and bodies are + preserved, an approved semantic ASCII slug can be supplied, and non-ASCII + titles safely fall back to an ID-qualified `decision` filename. +- Hardened CHECK against false-clean results by failing closed on incomplete git + evidence, retaining both sides of renames and Unicode paths, detecting + working-tree deletions, exposing invalid ADRs, following Git ignore/path + semantics, and rejecting conflicting diff modes. +- Made relative ADR directories resolve consistently from `--root` for every + repository-scoped generation and validation command. +- Improved the repository's own ADR evidence and lifecycle history, including + the accepted ADR-0006 decision that supersedes the MVP index-only + localization decision ADR-0003. +- Expanded README, quickstart, and skill guidance with real multilingual + workflows and CHECK confidence boundaries, and added separate Korean v0.2.0 + readiness and enterprise-adoption reports. - Added the self-contained `skills/adr-toolkit/` package: deterministic scaffolding/discovery commands (`init`, `discover`, `preflight`, `validate`, `index`), ADR frontmatter/ID/lifecycle core logic, MADR @@ -37,3 +98,14 @@ Lightweight human-readable summary of meaningful repository changes. documentation, not guessed), a repo-root version-sync script, and a tag-triggered release workflow. This closes out Plan 4 of 4 — the full ADR Toolkit MVP is now complete on this branch. +- Released v0.1.0 and adopted a Git Flow branch policy + (`develop`/`master`/`feature`/`release`/`hotfix`, direct-tag release + automation), recorded in `AGENTS.md`. +- Dogfooded the toolkit on its own repo: initialized `docs/decisions/` + and recorded the four most significant architectural decisions made + while building it (CHECK's structural-only scope, i18n's index-only + scope, adapter packaging policy, the Git Flow adoption itself). +- Wrote the root `README.md` (previously a one-line stub) and + `examples/quickstart.md`, a full INIT → RECORD → CHECK walkthrough with + real command output, including CHECK catching a live rule violation and + clearing it after a fix. diff --git a/docs/adr-toolkit-v0.2.0-readiness-report.md b/docs/adr-toolkit-v0.2.0-readiness-report.md new file mode 100644 index 0000000..7fbcd4c --- /dev/null +++ b/docs/adr-toolkit-v0.2.0-readiness-report.md @@ -0,0 +1,193 @@ +# ADR Toolkit v0.2.0 준비도 보고서 + +작성일: 2026-08-30 + +평가 대상: `develop-2` 브랜치 + +릴리스 성격: 하위 호환을 유지하는 다국어 기능 추가이므로 patch가 아닌 minor release + +## 1. 결론 + +v0.2.0의 제품 구현 범위는 로컬 검증 기준으로 릴리스 준비가 끝났다. 저장소 기본 +언어 설정, 8개 언어의 결정론적 문서 생성, 비 ASCII 제목과 이식 가능한 파일명, +런타임과 JSON Schema의 locale 일치, 기존 ADR 품질 보강, CHECK의 false-clean 결함 +수정이 모두 테스트로 확인됐다. + +다만 **현재 판정은 `조건부 GO`**다. `develop-2`의 최종 변경이 PR의 필수 CI를 +통과하고, 소유자가 v0.2.0 버전 변경을 승인한 뒤 release 브랜치를 통해 +`master`에 병합하기 전에는 태그를 만들면 안 된다. 이는 기능 결함 때문이 아니라 +저장소가 채택한 릴리스 절차의 남은 통제 단계다. + +## 2. 평가 방법 + +### 2.1 성숙도 기준 + +| 수준 | 재현 가능한 의미 | +| --- | --- | +| 1 | 프로토타입 또는 수동 절차만 존재한다. | +| 2 | 기능은 존재하지만 중요한 정확성·운영 공백이 남아 있다. | +| 3 | 개인 또는 소규모 팀이 테스트와 문서에 따라 반복 사용할 수 있다. | +| 4 | CI와 저장소 거버넌스로 같은 보장을 반복 검증하고 강제할 수 있다. | +| 5 | 조직 단위 정책, 권한, 감사, 예외, 측정 체계가 운영된다. | + +점수는 정성적 소수점 대신 충족한 증거로 결정했다. 로컬 테스트가 충분하더라도 +현재 작업 브랜치의 PR CI와 보호 규칙으로 강제되지 않으면 수준 4로 올리지 않았다. + +### 2.2 근거 구분 + +- **저장소 사실**: 코드, 테스트, 명령 출력, GitHub API에서 직접 확인한 상태다. +- **외부 관행**: AWS, Microsoft, GitHub의 공식 문서에서 확인한 권고나 기능이다. +- **평가·추론**: 위 근거로부터 도출한 현재 수준과 권고이며 제품 사실과 구분한다. + +## 3. 객관적 기준선 + +| 영역 | 현재 수준 | 측정 신호 | 수준을 올리기 위한 조건 | +| --- | ---: | --- | --- | +| 결정론적 코어 | 3 | ID, lifecycle, schema, 관계, index, config를 테스트하며 전체 274개 테스트 통과 | 최종 PR CI를 필수 검사로 강제 | +| ADR 내용·lifecycle | 3 | ADR 6개 유효, 소유자·근거·locale 보강, ADR-0003과 ADR-0006 양방향 supersession 검증 | 독립 리뷰어와 CODEOWNERS 승인 강제 | +| 국제화 | 3 | 8개 locale catalog key parity와 INIT → CREATE → VALIDATE → INDEX E2E 통과 | PR CI 및 원어민 용어 검수 체계 | +| CHECK 신뢰성 | 3 | git 증거 수집 실패 시 fail-closed, rename 양 경로, ignore 및 diff-mode 회귀 테스트 | 실제 저장소 fixture 확대와 필수 CI 적용 | +| GitHub 거버넌스 | 2 | CI 5개 조합은 운영되지만 `master`/`develop` 보호와 ruleset, 필수 리뷰가 없음 | 공개 전환 후 ruleset을 API로 검증 | +| 엔터프라이즈 확장성 | 1 | 방향과 경계만 문서화됐고 조직 정책·감사·측정 구현은 없음 | 별도 도입 단계와 운영 데이터 필요 | + +국제화와 CHECK가 수준 3인 것은 기능 부족을 뜻하지 않는다. 기능 계약은 P0를 +충족하지만, 현재 브랜치에서 조직적 강제까지 확인하지 않았기 때문이다. + +## 4. v0.2.0 구현 검증 + +### 4.1 결정론적 다국어 생성 + +**저장소 사실** + +- 정식 locale은 `en`, `ko`, `ja`, `zh`, `fr`, `es`, `de`, `pt-BR` 8개다. +- `.adr-toolkit.json`이 저장소 기본 locale을 보유하며, 명령의 결정 순서는 + `CLI --locale` → 입력 draft의 locale → 저장소 기본값 → `en`이다. +- Agent는 사용자 명시 요청 → 요청 언어 → 저장소 기본값 → `en` 순으로 locale을 + 정하되, 결과는 동일한 결정론적 validator를 통과해야 한다. +- INIT, interactive CREATE, template, prompt, INDEX는 같은 catalog loader를 + 사용한다. input CREATE는 승인된 사용자 본문을 번역·재렌더링하지 않고 선택된 + locale metadata를 기록한다. 모든 catalog는 영어 key 집합과 정확히 일치한다. +- config의 알 수 없는 버전·키·locale은 묵시적으로 무시되지 않고 실패한다. +- locale 없는 기존 ADR은 계속 유효하며, locale이 있으면 런타임 validator와 + `adr.schema.json`이 같은 8개 값만 허용한다. + +### 4.2 파일명 정책 + +사용자의 Unicode 제목과 본문은 보존한다. 코어는 의미 번역이나 음역을 하지 않는다. +승인된 ASCII slug가 있으면 이를 검증해 사용하고, 안전한 ASCII slug를 만들 수 +없으면 `decision`으로 되돌아간다. ID가 앞에 붙으므로 충돌 없이 +`0006-decision.md` 같은 이식 가능한 이름을 만든다. + +Agent는 `separate-payment-system` 같은 의미 있는 slug를 제안할 수 있지만, 사용자의 +확인과 코어의 ASCII 규칙 검증을 모두 거쳐야 한다. 이 경계가 +**Deterministic Core / Agentic Edge** 원칙을 실제 동작으로 만든다. + +### 4.3 Dogfooding ADR 품질 + +**수정한 사실** + +- 기존 ADR에 승인된 decision maker와 `locale: en`을 기록했다. +- 사후 작성 ADR에는 확인된 증거, 추론한 근거, 알 수 없는 내용을 분리했다. +- ADR-0004를 Full MADR 구조로 보강하고 affected paths와 confirmation을 구체화했다. +- MVP의 index-only 국제화 결정인 ADR-0003을 유지한 채, 저장소 기본 locale과 + 다국어 생성을 채택한 ADR-0006이 이를 양방향으로 supersede하도록 lifecycle + 명령으로 변경했다. +- 생성된 ADR index를 두 번 생성해 동일한 해시가 유지되는 것을 확인했다. + +AWS는 중요한 결정에 context, decision, consequences와 명확한 소유자를 두고, +승인된 ADR은 직접 고치는 대신 새 ADR로 대체할 것을 권고한다. Microsoft 역시 ADR을 +append-only 기록으로 취급하고 status와 supersession을 명시하도록 설명한다. +현재 lifecycle 보강은 이 외부 관행과 일치한다. +([AWS ADR process](https://docs.aws.amazon.com/prescriptive-guidance/latest/architectural-decision-records/adr-process.html), +[Microsoft ADR guidance](https://learn.microsoft.com/en-us/azure/well-architected/architect-role/architecture-decision-record)) + +### 4.4 CHECK의 검증 한계 + +CHECK는 검증할 수 없는 정책까지 증명했다고 주장하지 않는다. + +| 결과 의미 | 해석 | +| --- | --- | +| `VERIFIED` | 구조화된 규칙과 git 증거로 정책 준수를 증명할 수 있다. | +| `VIOLATED` | 구조화된 규칙과 증거가 위반을 확인한다. | +| `NOT_APPLICABLE` | 현재 변경에 적용되는 구조화된 규칙이 없다. | +| `UNVERIFIABLE` | 산문·조직적 근거처럼 현재 rule vocabulary로 증명할 수 없다. | + +구현은 두 diff 호출과 tracked/untracked 파일 수집 실패를 모두 오류로 처리하고, +rename의 이전·새 경로와 Unicode 경로를 보존한다. 파일 존재 판단은 Git의 +tracked/untracked/ignore 및 working-tree 삭제 의미를 따르며 서로 충돌하는 diff +mode를 거부한다. schema-invalid ADR은 묵시적으로 제외하지 않고 warning으로 +노출한다. `--root`를 쓸 때 상대 `--dir`은 caller CWD가 아니라 repository root에서 +일관되게 해석한다. 따라서 현재까지 재현된 “증거를 못 읽었는데 clean으로 보이는” +경로는 닫혔다. 이 분류는 앞으로의 결과 모델이며, 현재 CLI의 세부 finding 이름과 +일대일로 같다는 뜻은 아니다. + +## 5. Release Gates + +### P0 — Release Blocking + +| Gate | 결과 | 증거 | +| --- | --- | --- | +| 비 ASCII 제목 생성 | PASS | 한국어 제목·본문 생성과 fallback/semantic slug 회귀 테스트 | +| locale catalog/loader 결정성 | PASS | 8개 catalog key parity 및 config 오류 테스트 | +| INIT / CREATE / INDEX의 정식 locale 지원 | PASS | 8-locale input E2E와 8-locale interactive prompt/render matrix | +| 런타임 validator / JSON Schema 일치 | PASS | locale parity 테스트, 기존 locale-less ADR 호환 | +| 기존 ADR의 RECORD 품질 계약 | PASS | 저장소 ADR 품질 테스트 4개 및 6개 ADR validation | +| CHECK false-confidence 정확성 | PASS | subprocess, rename, Unicode, deletion, schema warning, ignore, path, diff-mode 회귀 테스트 | +| 전체 테스트 | PASS | `python3 -m pytest -q` → `290 passed`, exit 0 | +| 버전 파일 드리프트 | PASS | `python3 scripts/sync_version.py --check` → exit 0 | + +### P1 — Strongly Recommended + +| 항목 | 결과 | +| --- | --- | +| 한국어 준비도 보고서 | PASS — 본 문서 | +| README 다국어 사용 예시 | PASS | +| 비라틴 quickstart | PASS — 한국어 기본 locale, semantic slug, 한국어 index의 실제 실행 결과 포함 | +| Dogfooding ADR 품질 개선 | PASS | +| work-tracking 문서 정합성 | PASS — 완료 항목은 `improvements.md`의 Done으로 이동 | + +### P2 — Post Release + +공개 저장소 ruleset, CODEOWNERS, 조직 ruleset, enterprise 도입 지표, +cross-repository discovery는 v0.2.0을 막지 않는다. 구체적인 단계와 통제는 +`docs/enterprise-adoption.md`에서 분리한다. + +## 6. 실제 GitHub 상태와 릴리스 판정 + +**2026-08-30에 확인한 저장소 사실** + +- 저장소는 private이고 기본 브랜치는 `master`다. +- `master`, `develop` branch protection과 repository ruleset이 없다. +- 현재 private 플랜에서 protection/ruleset API는 업그레이드 또는 public 전환을 + 요구하며 403을 반환한다. +- 첫 PR은 Ubuntu/macOS/Windows와 Python 3.9/3.12의 5개 CI 검사를 모두 통과했지만, + 별도 review 없이 병합됐다. +- CODEOWNERS와 PR template은 아직 없다. + +**평가** + +기능 구현에 대한 P0는 통과했다. 그러나 최종 변경이 원격 PR CI를 통과하지 않았고 +manifest 버전은 승인 없이 바꾸지 않았으므로, 지금 즉시 `v0.2.0` 태그를 만드는 +판정은 `NO-GO`다. 다음 세 조건을 만족하면 별도 설계 변경 없이 `GO`로 전환할 수 +있다. + +1. `develop-2` 변경을 PR로 올려 필수 CI가 모두 통과한다. +2. 소유자가 v0.2.0 version bump를 승인하고 `scripts/sync_version.py`로 전파한다. +3. release 브랜치를 `master`와 `develop`에 정책대로 반영한 뒤 tag가 `master`의 + 해당 커밋을 가리키는지 확인한다. + +공개 전환 후에는 GitHub ruleset으로 PR, required status checks, conversation +resolution, force-push/deletion 차단을 강제하고 API로 실제 적용을 확인해야 한다. +GitHub 공식 문서상 ruleset은 이 통제들을 제공하며 공개 저장소에서도 사용할 수 +있다. +([GitHub ruleset rules](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/available-rules-for-rulesets)) + +## 7. 남은 위험 + +- 8개 언어의 구조와 결정성은 검증했지만 전문 용어의 자연스러움은 각 언어 + 사용자의 검수를 대체하지 않는다. +- CHECK는 구조화된 경로·dependency 규칙만 증명한다. 사업적 타당성, 조직 효과, + 산문의 진실성은 `UNVERIFIABLE`로 남기는 것이 올바르다. +- 현재 저장소에서는 독립 리뷰와 브랜치 통제가 강제되지 않는다. public 전환 전에는 + 운영 절차가 사람의 준수에 의존한다. +- v0.2.0 버전 변경, release branch, push, tag는 이번 구현 범위에 포함되지 않았다. diff --git a/docs/decisions/0001-record-architecture-decisions.md b/docs/decisions/0001-record-architecture-decisions.md new file mode 100644 index 0000000..07fd39e --- /dev/null +++ b/docs/decisions/0001-record-architecture-decisions.md @@ -0,0 +1,48 @@ +--- +id: ADR-0001 +title: Record architecture decisions +status: accepted +date: 2026-08-30 +decision_makers: + - YangSeungHyun +locale: en +related: [] +affected_paths: + - docs/decisions/ +tags: + - process +retrospective: false +--- + +# Record architecture decisions + +## Context and Problem Statement + +We need a consistent way to capture and communicate significant +architectural decisions so future contributors (human or agent) can find +the reasoning behind them. + +## Considered Options + +* No formal record, rely on commit messages and memory +* Wiki or external documentation tool +* Architecture Decision Records stored alongside the code + +## Decision Outcome + +Chosen option: **Architecture Decision Records stored alongside the code**, because they version with the code, stay close to what they describe, and are readable by both humans and coding agents. + +## Consequences + +* Good: decisions and their rationale are discoverable in the repository itself. +* Bad: requires discipline to keep records up to date as decisions evolve. + +## Confirmation + +`python3 skills/adr-toolkit/scripts/adr.py validate --dir docs/decisions --json` +validates the records, and `index` regenerates `docs/decisions/README.md` from +the same directory. + +## Revisit Triggers + +* The team adopts a different documentation system project-wide. diff --git a/docs/decisions/0002-limit-check-s-conflict-detection-to-structural-evidence-only.md b/docs/decisions/0002-limit-check-s-conflict-detection-to-structural-evidence-only.md new file mode 100644 index 0000000..d600209 --- /dev/null +++ b/docs/decisions/0002-limit-check-s-conflict-detection-to-structural-evidence-only.md @@ -0,0 +1,69 @@ +--- +id: ADR-0002 +title: Limit CHECK's conflict detection to structural evidence only +status: accepted +date: 2026-08-30 +decision_makers: + - YangSeungHyun +locale: en +related: [] +affected_paths: + - skills/adr-toolkit/scripts/commands/check.py + - skills/adr-toolkit/scripts/commands/diff.py + - skills/adr-toolkit/scripts/core/constraints.py + - skills/adr-toolkit/scripts/rules/conflict.py + - skills/adr-toolkit/references/conflict-rules.md +tags: + - check + - mvp-scope + - architecture +retrospective: true +--- + +# Limit CHECK's conflict detection to structural evidence only + +## Context and Problem Statement + +CHECK needs to flag conflicts between a diff and existing Accepted ADRs. The original PRD's 8-type conflict taxonomy includes types (Direct violation via SDK-call detection, Pattern divergence) that require semantic understanding of code intent, which cannot be reliably detected without deep static analysis (AST or import-graph tooling). Shipping unverified semantic detection in a brand-new tool's MVP risks false positives on day one, which is the fastest way to lose adopter trust. + +## Considered Options + +* Full semantic/AST-based conflict detection matching the original 8-type taxonomy +* Structural-evidence-only detection via a fixed `constraints:` YAML rule vocabulary (`forbidden_import`, `required_path`, `forbidden_path`, `dependency_forbidden`, `file_must_exist`, `test_must_exist`) + +## Decision Outcome + +Chosen option: **structural-evidence-only detection**, because false positives are the fastest way to lose trust in a brand-new open-source tool, and a fixed rule vocabulary keeps CHECK's core deterministic, matching the project's own Deterministic Core / Agentic Edge principle. + +## Consequences + +* Good: CHECK never has to make an ambiguous semantic judgment call; every finding is traceable to an explicit, author-written rule. +* Bad: CHECK cannot catch a conflict that requires understanding code intent unless an explicit structural rule was already written for it — semantic drift can slip through undetected. + +## Confirmation + +`skills/adr-toolkit/scripts/rules/conflict.py` implements exactly four structural matching mechanisms and nothing else; no test in `tests/unit/test_conflict.py` attempts semantic or AST-based matching. + +## Confirmed Evidence + +The repository implements a fixed six-kind constraint vocabulary in +`core/constraints.py`, evaluates it through structural path and line matching +in `rules/conflict.py`, and collects Git diff evidence in `commands/diff.py`. +The unit and integration suites exercise those deterministic boundaries. This +ADR was written retrospectively from that implemented MVP state. + +## Inferred Rationale + +The trust argument in this record is a reconstruction from the implemented +scope and design material: limiting CHECK to rules it can prove appears to have +been preferred over heuristic semantic coverage because false confidence would +be more damaging than an explicit `UNVERIFIABLE` result. + +## Unknown + +The exact contemporaneous discussion, relative weighting of the alternatives, +and any rejected semantic-analysis prototypes were not preserved. + +## Revisit Triggers + +* Real usage produces enough false-negative reports that users want semantic detection — tracked as "Full semantic conflict taxonomy" in `project-roadmap.md`. diff --git a/docs/decisions/0003-localize-only-index-py-s-generated-strings-not-agent-composed-text.md b/docs/decisions/0003-localize-only-index-py-s-generated-strings-not-agent-composed-text.md new file mode 100644 index 0000000..3f41f1d --- /dev/null +++ b/docs/decisions/0003-localize-only-index-py-s-generated-strings-not-agent-composed-text.md @@ -0,0 +1,73 @@ +--- +id: ADR-0003 +title: Localize only index.py's generated strings, not agent-composed text +status: superseded +date: 2026-08-30 +decision_makers: + - YangSeungHyun +locale: en +related: [] +affected_paths: + - .adr-toolkit.json + - skills/adr-toolkit/SKILL.md + - skills/adr-toolkit/scripts/core/locale.py + - skills/adr-toolkit/scripts/core/config.py + - skills/adr-toolkit/scripts/core/rendering.py + - skills/adr-toolkit/scripts/i18n/ + - skills/adr-toolkit/scripts/commands/init.py + - skills/adr-toolkit/scripts/commands/create.py + - skills/adr-toolkit/scripts/commands/index.py +tags: + - i18n + - architecture +retrospective: true +superseded_by: ADR-0006 +--- + +# Localize only index.py's generated strings, not agent-composed text + +## Context and Problem Statement + +The product goal is five-language support (en/fr/ja/ko/zh). The original design's repo-structure diagram proposed a flat `scripts/i18n/{locale}.json` key-value file, but most of the toolkit's user-facing text — RECORD/DISCOVER/CHECK's interview questions and reports — is agent-composed natural language, not a fixed Python string. It is not obvious what a translation-key file would even contain for text that is never the same twice. + +## Considered Options + +* A comprehensive translation-key system covering every user-facing string, including agent-composed prose, requiring the agent to look up keys instead of composing text freely +* Localize only the deterministic, code-owned strings — `index.py`'s generated `README.md` headers and status labels — via a small JSON file, and instruct the agent (via `SKILL.md`) to detect the user's language and compose its own text in it + +## Decision Outcome + +Chosen option: **localize only index.py's generated strings**, because an LLM agent is already multilingual and needs no phrase-book to ask a question in French — building and maintaining a lookup table for text that was never a fixed string in the first place adds real translation-maintenance burden for no benefit. + +## Consequences + +* Good: only ten keys need translating and keeping in sync across five locales, not hundreds of agent-prose variants. +* Bad: the agent's exact wording is not guaranteed reproducible or testable per locale the way a translation table's output would be. + +## Confirmation + +`skills/adr-toolkit/scripts/i18n/*.json` has exactly ten keys per locale; `SKILL.md`'s Language section is the only agent-facing localization instruction, and it is prose, not a lookup table. + +## Confirmed Evidence + +The v0.1.0 implementation localized only INDEX-owned headings and status labels +through five JSON catalogs. INIT and CREATE still emitted English structure, +and there was no repository locale configuration. This ADR was reconstructed +after that implementation existed. + +## Inferred Rationale + +The motivation recorded above is inferred from the original narrow +implementation and design notes: avoid translating open-ended agent prose while +keeping the deterministic code surface small. + +## Unknown + +The original discussion did not preserve a complete comparison of +repository-level defaults, localized deterministic templates, or multilingual +filename behavior. Those omissions later became the reason to revisit this +decision for v0.2.0. + +## Revisit Triggers + +* The tool needs deterministic, exact-wording compliance per locale (for example, an accessibility or compliance audit) — revisit toward a full translation-key system at that point. diff --git a/docs/decisions/0004-adapter-packaging-manifest-only-directories-install-time-symlinks-verified-formats.md b/docs/decisions/0004-adapter-packaging-manifest-only-directories-install-time-symlinks-verified-formats.md new file mode 100644 index 0000000..bd902d1 --- /dev/null +++ b/docs/decisions/0004-adapter-packaging-manifest-only-directories-install-time-symlinks-verified-formats.md @@ -0,0 +1,92 @@ +--- +id: ADR-0004 +title: Adapter packaging: manifest-only directories, install-time symlinks, verified formats +status: accepted +date: 2026-08-30 +decision_makers: + - YangSeungHyun +locale: en +related: [] +affected_paths: + - adapters/ + - .gitignore + - .claude-plugin/marketplace.json + - .claude-plugin/plugin.json +tags: + - adapters + - harness-support +retrospective: true +--- + +# Adapter packaging: manifest-only directories, install-time symlinks, verified formats + +## Context and Problem Statement + +The toolkit ships as one self-contained `skills/adr-toolkit/` package usable by multiple harnesses (Claude Code, Codex CLI, Gemini CLI, Antigravity CLI). Every harness format actually investigated expects a sibling `skills/` directory next to its own manifest file, but duplicating the package once per harness would violate the single-source-of-truth goal that motivated the self-contained package in the first place. Separately, the exact manifest fields for three of the four harnesses were never verified against real documentation before Plan 4 — and guessing already produced a real bug once (Plan 1's Claude Code adapter guessed a `"skills"` key and a nested manifest path that turned out wrong, caught only by that plan's final review). + +## Decision Drivers + +* Keep one canonical `skills/adr-toolkit/` package across harnesses. +* Preserve Windows-compatible tracked repository contents. +* Require manifest claims to be backed by documentation or an executable install check. +* Keep adapter-specific setup visible instead of hiding it in generated copies. + +## Considered Options + +* Copy the full `skills/adr-toolkit/` package into each `adapters//` directory +* Symlink `skills/adr-toolkit/` into each adapter directory and commit the symlink to git +* Document the symlink as a manual install-time step (never committed), and verify each harness's real manifest format against its actual documentation before writing it + +## Decision Outcome + +Chosen option: **document the symlink as an install-time step, with formats verified not guessed**, because copying reintroduces drift between copies, committing a symlink breaks on a Windows checkout without `core.symlinks` enabled (this repo's own CI matrix includes `windows-latest`), and guessing manifest formats already produced two real bugs (Claude Code in Plan 1, Codex CLI in Plan 4) that only an actual install-and-run caught. + +## Consequences + +* Good: one canonical copy of the skill package exists; no manifest format ships without being checked against real documentation or a real install attempt where a harness CLI was available. +* Bad: a user must run one extra manual `ln -s` step before an adapter works — installation cannot be a true zero-step "clone and go." + +## Pros and Cons of the Options + +### Copy the package into every adapter + +* Good, because each adapter directory is self-contained after checkout. +* Bad, because duplicated skill code and documentation can drift between harnesses. + +### Commit adapter-local symlinks + +* Good, because every adapter references the canonical package without copying it. +* Bad, because Windows checkout behavior depends on Git and filesystem symlink support. + +### Create symlinks at install time and verify manifest formats + +* Good, because the repository retains one source of truth and each harness contract can be tested independently. +* Bad, because installation includes an explicit platform-sensitive setup step and its documentation must remain accurate. + +## Confirmation + +`git ls-tree` on any `adapters//` directory shows no `120000` (symlink) mode entries; every adapter's `README.md` documents the symlink as an install step; `.gitignore` ignores each adapter's `skills/` path. + +## Confirmed Evidence + +The tracked adapter directories contain manifests and README files but no +committed adapter-local `skills/` symlinks. The repository ignore rules exclude +those install-time paths, and the test suite checks the supported manifest +shapes. This record was reconstructed after the adapter packaging work. + +## Inferred Rationale + +The preference for install-time links is inferred from the observed +single-source package layout, the cross-platform CI matrix, and review notes +that corrected guessed manifest fields. + +## Unknown + +The original option weighting and the exact manual verification performed for +every harness version were not captured uniformly. Adapter claims must +therefore continue to distinguish executable verification from structural +layout checks. + +## Revisit Triggers + +* A harness's real plugin format later supports referencing an external path directly, removing the need for that harness's symlink step — revisit that harness's adapter specifically, not this policy as a whole. diff --git a/docs/decisions/0005-adopt-git-flow-with-direct-tag-release-automation.md b/docs/decisions/0005-adopt-git-flow-with-direct-tag-release-automation.md new file mode 100644 index 0000000..6d8250d --- /dev/null +++ b/docs/decisions/0005-adopt-git-flow-with-direct-tag-release-automation.md @@ -0,0 +1,79 @@ +--- +id: ADR-0005 +title: Adopt Git Flow with direct-tag release automation +status: accepted +date: 2026-08-30 +decision_makers: + - YangSeungHyun +locale: en +related: [] +affected_paths: + - AGENTS.md + - .github/workflows/ + - skills/adr-toolkit/VERSION + - scripts/sync_version.py +tags: + - process + - release + - git-flow +retrospective: false +--- + +# Adopt Git Flow with direct-tag release automation + +## Context and Problem Statement + +The 4-plan MVP was built entirely on one long-lived feature branch with no branch policy; `master` held only the initial commit throughout. Now that the MVP is complete and released as v0.1.0, the project needs an explicit branch and release policy for future work, including how a version gets tagged and published. + +## Decision Drivers + +* A predictable integration point for future feature/fix/docs work +* Low operational overhead appropriate for a solo/small-team repository +* Reuse the release automation already built and verified in Plan 4, rather than replacing it speculatively + +## Considered Options + +* Git Flow (`develop`/`master`/`feature`/`release`/`hotfix` branches) with direct-tag release automation +* Git Flow with release-pr automation (Release Please, auto-generating release PRs from Conventional Commits) +* Trunk-based development (small changes merged frequently, feature flags for incomplete work) +* GitHub Flow (one production branch, short-lived feature branches, continuous deployment) + +## Decision Outcome + +Chosen option: **Git Flow with direct-tag release automation**, because the project already has more than one long-lived integration point in practice (this MVP's own multi-plan history, and future feature batches), and an explicit `develop`/`master` split matches that; direct-tag was chosen over release-pr because Plan 4 already built, task-reviewed, and whole-branch-reviewed a working tag-triggered `release.yml` — replacing tested automation with an unverified new mechanism for a solo/small-team repo trades working automation for unverified automation with no clear benefit yet. + +### Consequences + +* Good: reuses release automation that is already tested and was independently re-verified during its own final review; explicit branch roles reduce ambiguity for future contributors. +* Bad: Git Flow's extra branch layer (`develop` plus `release/*`) is more ceremony than Trunk-based or GitHub Flow would need for a project this size; direct-tag requires a human to remember to bump `VERSION` and tag correctly, rather than deriving a version automatically from commit history. + +### Confirmation + +`AGENTS.md`'s "Git Flow Branch and Release Policy" section states the policy in full; `.github/workflows/release.yml` triggers only on `v*` tag pushes and never auto-bumps a version. + +## Pros and Cons of the Options + +### Git Flow with direct-tag release automation + +* Good, because it reuses release automation already built, tested, and verified in this repository +* Bad, because a human must remember every version bump and tag correctly; nothing derives the version from commit history + +### Git Flow with release-pr automation + +* Good, because changelog and version bumps are generated automatically from Conventional Commit history, reducing human error +* Bad, because it requires disciplined Conventional Commit usage across all contributors and replaces working, verified automation with an unverified new mechanism + +### Trunk-based development + +* Good, because it minimizes branch ceremony and integration lag for a small, fast-moving codebase +* Bad, because it assumes continuous feature-flag discipline this project has not adopted, and offers no natural checkpoint for a deliberate, reviewed release like v0.1.0 + +### GitHub Flow + +* Good, because it is simpler than Git Flow with only one long-lived branch to reason about +* Bad, because it has no dedicated integration branch separate from production, which matters once multiple features are in flight at once — a scenario this project has already lived through with its 4-plan MVP + +## Revisit Triggers + +* The team grows beyond solo/small-team and reliable Conventional Commit discipline becomes realistic — revisit toward release-pr automation. +* `develop` and `release/*` prove to be pure overhead with no multi-stream benefit in practice — revisit toward GitHub Flow. diff --git a/docs/decisions/0006-localized-adr-generation.md b/docs/decisions/0006-localized-adr-generation.md new file mode 100644 index 0000000..881c5b4 --- /dev/null +++ b/docs/decisions/0006-localized-adr-generation.md @@ -0,0 +1,102 @@ +--- +id: ADR-0006 +title: Localize deterministic ADR generation through repository configuration +status: accepted +date: 2026-08-30 +locale: en +decision_makers: + - YangSeungHyun +related: + - ADR-0003 +affected_paths: + - .adr-toolkit.json + - skills/adr-toolkit/SKILL.md + - skills/adr-toolkit/schemas/adr.schema.json + - skills/adr-toolkit/scripts/adr.py + - skills/adr-toolkit/scripts/core/config.py + - skills/adr-toolkit/scripts/core/locale.py + - skills/adr-toolkit/scripts/core/rendering.py + - skills/adr-toolkit/scripts/core/identifiers.py + - skills/adr-toolkit/scripts/core/schema.py + - skills/adr-toolkit/scripts/i18n/ + - skills/adr-toolkit/scripts/commands/init.py + - skills/adr-toolkit/scripts/commands/create.py + - skills/adr-toolkit/scripts/commands/index.py + - skills/adr-toolkit/scripts/commands/validate.py +tags: + - i18n + - configuration + - architecture + - v0.2.0 +retrospective: false +supersedes: + - ADR-0003 +--- + +# Localize deterministic ADR generation through repository configuration + +## Context and Problem Statement + +ADR-0003 limited localization to INDEX-owned strings. That MVP boundary left INIT and CREATE structures in English, required repeated locale flags, and rejected titles containing no ASCII characters. Teams need a repository-owned default language while retaining deterministic IDs, filenames, schema validation, lifecycle transitions, and CHECK behavior. + +## Decision Drivers + +* Keep repository state deterministic even when an agent assists with language inference or drafting. +* Let teams choose a default ADR language without repeating a flag for every operation. +* Preserve Unicode titles and bodies while keeping filenames portable across filesystems, URLs, and Git clients. +* Keep runtime validation and the published JSON Schema consistent. +* Avoid claiming that machine translation or transliteration is deterministic. + +## Considered Options + +* Retain ADR-0003 and localize only INDEX output. +* Delegate all localization, translation, and filename generation to an agent. +* Localize deterministic generation through a repository config, canonical catalogs, and validated agent inputs. + +## Decision Outcome + +Chosen option: **localize deterministic generation through a repository config, canonical catalogs, and validated agent inputs**, because it supports team language policy without handing repository governance to probabilistic output. + +The repository root may contain `.adr-toolkit.json` with `schema_version: 1` and a default `locale`. The canonical locale set is `en`, `ko`, `ja`, `zh`, `fr`, `es`, `de`, and `pt-BR`; `zh` means Simplified Chinese. CLI locale resolution is explicit CLI, then approved input draft, then repository default, then English. Agent workflows use explicit user language, then request language, then repository default, then English. + +The deterministic core localizes code-owned prompts, MADR structure, initial content, and index labels. It never translates user prose. ADR frontmatter may store an optional canonical `locale`. Unicode titles and bodies are preserved, while filenames remain ASCII. An agent may propose a meaningful ASCII slug for human approval; the core validates it and otherwise derives an ASCII title slug or falls back to `decision`. + +### Consequences + +* Good: INIT, CREATE, VALIDATE, and INDEX behave reproducibly across eight languages using one repository default. +* Good: the agent can improve filename discoverability without gaining authority to bypass deterministic validation. +* Good: existing ADRs without locale metadata remain valid. +* Bad: eight catalogs must keep exact key parity and require terminology review as the generated structure evolves. +* Bad: a non-ASCII title without an approved semantic slug falls back to a less descriptive `decision` filename. +* Bad: repository configuration introduces another versioned contract that must fail visibly when malformed or unsupported. + +### Confirmation + +* `tests/integration/test_localized_workflow.py` runs INIT, CREATE, VALIDATE, and INDEX for all eight canonical locales. +* `tests/unit/test_config.py` verifies config validation and locale precedence. +* `tests/unit/test_identifiers.py` verifies semantic slug validation and the deterministic fallback. +* `tests/unit/test_schema.py` verifies runtime and JSON Schema locale parity. + +## Pros and Cons of the Options + +### Retain INDEX-only localization + +* Good, because it keeps the translation surface small. +* Bad, because repository defaults, localized creation, and non-ASCII title workflows remain unsupported. + +### Delegate localization and filenames entirely to an agent + +* Good, because an agent can infer language and propose natural semantic filenames. +* Bad, because repository state would depend on non-deterministic translation and transliteration unless every output crossed a validation boundary. + +### Repository config, canonical catalogs, and validated agent inputs + +* Good, because deterministic code owns configuration, catalogs, schema, IDs, and filenames while an agent assists only at the edge. +* Bad, because catalog parity and config compatibility become maintained product contracts. + +## Revisit Triggers + +* A supported locale needs regional variants whose terminology cannot share the current catalog. +* Teams require bilingual rendering of one ADR without creating parallel sources of truth. +* Repeated `decision` fallbacks show that the semantic-slug confirmation flow is insufficient. +* A future config format needs additional repository-wide options beyond locale and requires a schema migration. diff --git a/docs/decisions/0007-check-confidence-field.md b/docs/decisions/0007-check-confidence-field.md new file mode 100644 index 0000000..0934a70 --- /dev/null +++ b/docs/decisions/0007-check-confidence-field.md @@ -0,0 +1,80 @@ +--- +id: ADR-0007 +title: Promote CHECK's kind-to-confidence mapping to a stable output field +status: accepted +date: 2026-08-30 +locale: en +decision_makers: + - YangSeungHyun +related: [] +affected_paths: + - skills/adr-toolkit/scripts/commands/check.py + - README.md + - skills/adr-toolkit/SKILL.md + - skills/adr-toolkit/references/conflict-rules.md + - examples/quickstart.md + - tests/unit/test_check.py +tags: + - check + - confidence + - v0.2.0 +retrospective: false +--- + +# Promote CHECK's kind-to-confidence mapping to a stable output field + +## Context and Problem Statement + +CHECK findings carried an ad-hoc `kind` value (`verified_violation`, `review_required`, `related`, `no_applicable_constraint`). README, SKILL.md, and `references/conflict-rules.md` already documented a mapping from each `kind` to a governance confidence meaning (`VERIFIED`, `VIOLATED`, `UNVERIFIABLE`, `NOT_APPLICABLE`), but that mapping existed only in prose. Every agent consuming CHECK output had to re-derive it from documentation on every run instead of reading it off the result. + +## Decision Drivers + +* Give CHECK a stable, machine-readable confidence contract instead of leaving classification to prose the agent must re-read every time. +* Avoid breaking existing consumers and tests that already assert on `kind`. +* Keep the change additive and low-risk given the size of the existing CHECK test suite. + +## Considered Options + +* Leave the mapping in documentation only; require agents to re-derive it each run. +* Replace `kind` entirely with the four-value `confidence` vocabulary. +* Add `confidence` as a new field computed from the existing `kind`, leaving `kind` unchanged. + +## Decision Outcome + +Chosen option: **add `confidence` as a new field computed from `kind`**, because it makes the already-documented contract machine-readable without touching `kind`'s sixteen existing test assertions or any consumer that already keys off it. + +Every finding of `kind: verified_violation` now also carries `confidence: VIOLATED`; `related` carries `VERIFIED`; `review_required` and `no_applicable_constraint` both carry `UNVERIFIABLE`. An empty `findings` list still means `NOT_APPLICABLE` — that value has no corresponding per-finding `kind`, since no finding is emitted when no ADR governs the changed paths at all. + +### Consequences + +* Good: agents (and any other consumer) read `confidence` directly instead of re-deriving it from prose on every run. +* Good: `kind` and every existing test assertion on it are untouched — zero regression risk to the CHECK test suite. +* Bad: two overlapping fields (`kind` and `confidence`) now exist on every finding; a future cleanup could consider whether `kind` should eventually be retired once all consumers move to `confidence`. + +### Confirmation + +* `tests/unit/test_check.py` asserts the exact `confidence` value for each of the four `kind` values, including the superseded-reference violation path. +* `python3 -m pytest -q` passes (327 tests) after the change. +* README's "CHECK confidence" table, SKILL.md's CLASSIFY step, and `references/conflict-rules.md`'s evidence-confidence table were updated to describe the field instead of a derivation the agent had to perform. + +## Pros and Cons of the Options + +### Leave the mapping in documentation only + +* Good, because it requires no code change. +* Bad, because every agent run re-derives the same fixed mapping from prose, which is exactly the kind of repeatable, low-judgment work a deterministic core should own. + +### Replace `kind` with `confidence` entirely + +* Good, because it removes the two-field redundancy. +* Bad, because it breaks sixteen existing test assertions and any external consumer already keyed off `kind`, for no functional gain over the additive approach. + +### Add `confidence` as a new field + +* Good, because it is purely additive: no existing behavior, test, or consumer changes. +* Bad, because the two fields overlap in meaning, which a future major version could choose to collapse. + +## Revisit Triggers + +* A future breaking release consolidates `kind` and `confidence` into a single field once external consumers no longer depend on `kind` alone. +* A fifth finding shape is added that doesn't map cleanly onto the four confidence values, requiring the mapping itself to be revisited. diff --git a/docs/decisions/0008-check-exceptions-annotate-only.md b/docs/decisions/0008-check-exceptions-annotate-only.md new file mode 100644 index 0000000..f058e1b --- /dev/null +++ b/docs/decisions/0008-check-exceptions-annotate-only.md @@ -0,0 +1,88 @@ +--- +id: ADR-0008 +title: Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress +status: accepted +date: 2026-08-30 +locale: en +decision_makers: + - YangSeungHyun +related: [] +affected_paths: + - skills/adr-toolkit/scripts/core/exceptions.py + - skills/adr-toolkit/scripts/commands/exception.py + - skills/adr-toolkit/scripts/commands/check.py + - skills/adr-toolkit/scripts/adr.py + - skills/adr-toolkit/schemas/exception.schema.json + - README.md + - skills/adr-toolkit/SKILL.md + - skills/adr-toolkit/references/conflict-rules.md +tags: + - check + - exceptions + - governance + - v0.2.0 +retrospective: false +--- + +# Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress + +## Context and Problem Statement + +`register_exception` has always been one of CHECK's five documented resolutions for a Verified violation, but it existed only as a string label. There was no schema for what an exception must record, nowhere to store one, and no way for a later CHECK run to recognize that a specific violation had already been reviewed and accepted. Anyone who picked `register_exception` had no deterministic next step. + +## Decision Drivers + +* Give `register_exception` a real, followable path instead of a dead-end label. +* Never let CHECK present a false "clean" result — this project's core principle is that a clean result must never be confused with proof of compliance. +* Keep exceptions scoped, owned, and time-boxed rather than a blanket, permanent rule bypass. +* Reuse the existing deterministic-core patterns (schema validation, sequential IDs, one JSON record per exception) instead of inventing a new mechanism. + +## Considered Options + +* Leave `register_exception` as a label only, with no backing schema or storage. +* Build a full exception system where a matching, active exception suppresses the violation from future CHECK runs. +* Build a schema-validated exception record (`owner`, `reason`, `scope`, `expiry`, `adr_id`, `rule_id`) that CHECK annotates onto a matching finding, but never uses to hide or downgrade it. + +## Decision Outcome + +Chosen option: **schema-validated exception records that CHECK annotates but never suppresses**, because any mechanism that makes an accepted violation silently disappear from CHECK's output would directly contradict this project's "no false governance confidence" principle — a human scanning CHECK results must still see every structurally confirmed violation, exception or not. + +`adr.py exception --input ` validates a draft against `schemas/exception.schema.json` (`owner`, `reason`, `scope`, `expiry`, `adr_id`, `rule_id` all required; `scope` must be a non-empty list of path patterns narrower than the rule itself) and writes `docs/decisions/exceptions/EXC-NNNN.json`. CHECK loads every active (schema-valid, non-expired) exception on each run and, for a `verified_violation` finding whose `adr_id`, `rule_id`, and file path all match, attaches an `exception` field carrying the exception's id, owner, reason, and expiry. The finding's `kind` and `confidence` stay exactly what the structural evidence says. A malformed exception file degrades to a `BAD_EXCEPTION` warning rather than aborting the run, matching the existing ADR/constraints degradation pattern. + +### Consequences + +* Good: `register_exception` is now a real, deterministic, auditable action instead of a dead-end label. +* Good: an exception can never cause CHECK to under-report a real violation — the worst case is a violation the reader already expects to see, now with context attached. +* Good: `scope` forces an exception to be narrower than the rule it excepts, and `expiry` forces it to be time-boxed rather than a permanent bypass. +* Bad: a violation with an active exception still appears in every CHECK run, which is by design but means CHECK output stays "noisy" for accepted, reviewed exceptions rather than going quiet. +* Bad: exceptions are matched by exact `adr_id`/`rule_id`/scoped-path equality only — no notion of exception review, renewal workflow, or expiry notification exists yet. + +### Confirmation + +* `tests/unit/test_exceptions.py` covers schema validation, expiry, and scope matching. +* `tests/unit/test_exception_command.py` covers the `exception` command's id assignment, dry-run, and error paths. +* `tests/unit/test_check.py` covers a matching active exception being annotated, an expired exception not applying, an out-of-scope exception not applying, and a malformed exception file degrading to a warning. +* The documented `adr.py exception` command was run end to end in a scratch repository and produced the exact output shown in the README. + +## Pros and Cons of the Options + +### Leave `register_exception` as a label only + +* Good, because it requires no new code, schema, or storage format. +* Bad, because it leaves one of CHECK's five documented resolutions with no actual mechanism behind it. + +### Suppress matching violations from future CHECK runs + +* Good, because accepted exceptions would stop generating repeat noise on every run. +* Bad, because it lets CHECK present a "clean" result over a structurally confirmed violation, which this project's own principles explicitly reject as false governance confidence. + +### Schema-validated, annotate-only exceptions + +* Good, because it makes `register_exception` real and auditable while keeping every violation visible. +* Bad, because CHECK output never goes fully quiet for a repository carrying accepted exceptions — a deliberate tradeoff, not an oversight. + +## Revisit Triggers + +* Real usage shows accepted-exception noise is a genuine workflow problem, motivating a filtered or grouped view rather than suppression itself. +* Multiple exceptions accumulate to the point that an `adr.py exception list` or expiry-reporting command becomes worth building. +* Organizations need exception approval workflows (multi-party sign-off) beyond a single `owner` field. diff --git a/docs/decisions/0009-json-flag-always-json-contract.md b/docs/decisions/0009-json-flag-always-json-contract.md new file mode 100644 index 0000000..31166ad --- /dev/null +++ b/docs/decisions/0009-json-flag-always-json-contract.md @@ -0,0 +1,80 @@ +--- +id: ADR-0009 +title: --json is a documented no-op; CLI output is always JSON +status: accepted +date: 2026-08-30 +locale: en +decision_makers: + - YangSeungHyun +related: [] +affected_paths: + - skills/adr-toolkit/scripts/adr.py + - README.md + - skills/adr-toolkit/SKILL.md + - tests/integration/test_cli.py +tags: + - cli + - output-contract + - v0.2.0 +retrospective: false +--- + +# --json is a documented no-op; CLI output is always JSON + +## Context and Problem Statement + +Every `adr.py` subcommand parsed a `--json` flag via argparse, but `main()` always printed `json.dumps(result, ...)` to stdout regardless of whether the flag was passed. The flag looked configurable in `--help` output but silently did nothing — a dead-flag bug that also left an unresolved question: should the tool grow a human-readable output mode, or should the flag be removed? + +## Decision Drivers + +* The tool's own code already carried a comment declaring a "JSON-only-stdout contract"; the flag contradicted that stated intent. +* Every real caller — every documented command in README/SKILL.md/quickstart, and every integration test — already passes `--json` explicitly. Only two SKILL.md steps and one README example omitted it. +* v0.2.0 is declared a non-breaking minor release; removing an accepted flag would be a breaking CLI change. +* Building a genuine human-readable mode means designing a format for fourteen different result shapes, an open-ended and unscoped effort not requested by any real usage. + +## Considered Options + +* Add a human-readable default output mode, with `--json` opting into the current JSON behavior (inverts today's actual default). +* Remove the `--json` flag entirely in a breaking CLI change. +* Keep current always-JSON behavior exactly as-is; formalize it as the deliberate, tested contract and document `--json` as an accepted no-op. + +## Decision Outcome + +Chosen option: **formalize always-JSON as the deliberate contract**, because it costs nothing — behavior does not change for a single existing caller — while resolving the standing ambiguity the dead flag created. This aligns with the tool's own "Deterministic Core" design: JSON is the one machine-readable contract agents and CI already rely on, and no request has ever surfaced a need for a second, human-readable output format. + +The fourteen duplicated `--json` argument definitions were centralized into one `_add_json_flag()` helper with a `--help` string stating plainly that the flag has no effect and is kept only for backward compatibility. A regression test proves output is identical JSON whether or not `--json` is passed. The three documented call sites that had omitted the flag were updated to include it, for consistency rather than necessity. + +### Consequences + +* Good: the standing ambiguity is resolved without any behavior change or breaking release. +* Good: `--help` now tells the truth about what the flag does, instead of implying a choice that doesn't exist. +* Bad: `--json` remains a slightly confusing vestigial flag that a new contributor may still wonder about, even with the `--help` text. +* Bad: a genuine human-readable mode is deferred indefinitely rather than designed now, if a future use case (e.g. a human running the CLI directly, without an agent) ever wants one. + +### Confirmation + +* `tests/integration/test_cli.py::test_preflight_output_is_json_even_without_the_json_flag` asserts identical JSON output with and without `--json`. +* `python3 -m pytest -q` passes (327 tests) after the change. +* `python3 skills/adr-toolkit/scripts/adr.py preflight --help` shows the updated, accurate help text. + +## Pros and Cons of the Options + +### Add a human-readable default mode + +* Good, because it would give `--json` genuine meaning again. +* Bad, because it inverts today's actual default behavior for the three call sites that currently omit the flag, and requires designing an unscoped, ad-hoc text format for fourteen different result shapes with no real usage evidence motivating it. + +### Remove the flag entirely + +* Good, because it eliminates the dead flag outright. +* Bad, because it is a breaking CLI change that would fail every existing documented and tested invocation, in a release explicitly declared non-breaking. + +### Formalize always-JSON as the contract + +* Good, because it changes zero behavior for zero existing callers while resolving the ambiguity. +* Bad, because the flag itself remains present and slightly confusing, rather than being cleanly resolved one way or the other. + +## Revisit Triggers + +* A real, evidenced need emerges for a human operator to run the CLI directly without an agent or script parsing its output. +* A future major/breaking release is already planned for unrelated reasons, making it a low-cost moment to drop the inert flag entirely. diff --git a/docs/decisions/0010-codex-quick-validate-not-applicable.md b/docs/decisions/0010-codex-quick-validate-not-applicable.md new file mode 100644 index 0000000..402d286 --- /dev/null +++ b/docs/decisions/0010-codex-quick-validate-not-applicable.md @@ -0,0 +1,74 @@ +--- +id: ADR-0010 +title: Codex skill-creator's quick_validate.py incompatibility is not this project's problem +status: accepted +date: 2026-08-30 +locale: en +decision_makers: + - YangSeungHyun +related: [] +affected_paths: + - skills/adr-toolkit/SKILL.md +tags: + - codex + - cross-harness + - documentation +retrospective: false +--- + +# Codex skill-creator's quick_validate.py incompatibility is not this project's problem + +## Context and Problem Statement + +Codex CLI ships a local, per-user "skill-creator" meta-skill (`~/.codex/skills/.system/skill-creator/`) with its own `quick_validate.py` lint script. That script only allows `name`, `description`, `license`, `allowed-tools`, and `metadata` in a `SKILL.md`'s frontmatter, and rejects `user-invocable` and `version` as unexpected keys. `skills/adr-toolkit/SKILL.md` carries both of those keys, because this project's own cross-harness contract and `tests/unit/test_skill_manifest.py` require them. Running that unrelated script against this repository's `SKILL.md` therefore fails. + +## Decision Drivers + +* Determine whether this represents a real compatibility gap this project must close, before spending effort on a fix. +* Verify the claim empirically against the actually-installed tooling rather than assuming. +* Avoid weakening this project's own cross-harness manifest contract to satisfy an unrelated tool's narrower one. + +## Considered Options + +* Drop `user-invocable` and `version` from `SKILL.md` to satisfy `quick_validate.py`. +* Maintain two parallel `SKILL.md` variants, one for Codex's skill-creator convention and one for the real cross-harness contract. +* Verify whether `quick_validate.py` sits on the actual plugin install/discovery path at all, and if not, take no action beyond documenting the finding. + +## Decision Outcome + +Chosen option: **verify, then take no action**, because `quick_validate.py` was confirmed to have no role in the actual Codex plugin install/discovery path. `codex plugin marketplace add` and `codex plugin add` (originally verified end to end for ADR-0004; re-verified in this session against Codex CLI 0.151.0, see `adapters/codex/README.md`) read only `.claude-plugin/plugin.json` and `.codex-plugin/plugin.json`; neither invokes `quick_validate.py`. That script belongs to a separate, local, per-user Codex feature for authoring brand-new, Codex-only skills from scratch with a deliberately narrower schema — it is not part of this project's distribution or install path, and this repository's `SKILL.md` was never built through that tool. + +Dropping `user-invocable`/`version` to satisfy an unrelated script would break this project's actual cross-harness contract and its own tests, in exchange for satisfying a tool this project's plugin never runs through. + +### Consequences + +* Good: no code or contract change was needed; nothing was broken to accommodate an unrelated tool. +* Good: the reasoning is now recorded, so the question doesn't have to be re-investigated from scratch if it resurfaces (it has already been asked twice in one session). +* Bad: a user who happens to run their local `quick_validate.py` against this repository's `SKILL.md` by hand will still see a failure, with no in-repo signal explaining why — the explanation lives only in this ADR and session history, not in `SKILL.md` itself. + +### Confirmation + +* `codex plugin marketplace add "$(pwd)"` and `codex plugin add adr-toolkit@adr-toolkit-marketplace` were re-run against Codex CLI 0.151.0 in an isolated `CODEX_HOME`; both succeeded and read `.claude-plugin/plugin.json`, never `quick_validate.py`. +* `python3 ~/.codex/skills/.system/skill-creator/scripts/quick_validate.py skills/adr-toolkit` was run directly and reproduced the reported failure, confirming the claim while also confirming it sits outside the install path. + +## Pros and Cons of the Options + +### Drop the two keys from SKILL.md + +* Good, because it would satisfy `quick_validate.py`. +* Bad, because it breaks this project's own cross-harness contract and `test_skill_manifest.py` for a tool that never actually reads this file during real plugin installation. + +### Maintain two SKILL.md variants + +* Good, because both conventions could theoretically be satisfied at once. +* Bad, because it reintroduces exactly the drift risk this project has repeatedly hardened against (see ADR-0004's single-canonical-package rationale), for a tool with no install-path role. + +### Verify, then take no action + +* Good, because it costs nothing and is backed by an actual reproduction against the real tooling, not assumption. +* Bad, because the explanation is not discoverable from `SKILL.md` itself if the question resurfaces outside this session. + +## Revisit Triggers + +* Codex's actual plugin install/discovery path changes to route through `quick_validate.py` or an equivalent check, making this a real compatibility gap. +* A future Agent Plugins spec revision standardizes `user-invocable`/`version`-equivalent keys in a way `quick_validate.py`'s allowed set would need updating for anyway. diff --git a/docs/decisions/0011-adr-relationship-graph-public-readiness.md b/docs/decisions/0011-adr-relationship-graph-public-readiness.md new file mode 100644 index 0000000..f849353 --- /dev/null +++ b/docs/decisions/0011-adr-relationship-graph-public-readiness.md @@ -0,0 +1,125 @@ +--- +id: ADR-0011 +title: Expose ADR relationships as Mermaid and SVG navigation artifacts +status: accepted +date: 2026-08-31 +locale: en +decision_makers: + - YangSeungHyun +related: + - ADR-0001 + - ADR-0006 + - ADR-0008 + - ADR-0009 +affected_paths: + - README.md + - CONTRIBUTING.md + - SECURITY.md + - .github/PULL_REQUEST_TEMPLATE.md + - project-roadmap.md + - docs/decisions/README.md + - docs/decisions/relationships.mmd + - docs/decisions/relationships.svg + - skills/adr-toolkit/SKILL.md + - skills/adr-toolkit/scripts/adr.py + - skills/adr-toolkit/scripts/commands/graph.py + - skills/adr-toolkit/scripts/commands/index.py + - skills/adr-toolkit/scripts/core/relationships.py + - tests/unit/test_graph_command.py + - tests/unit/test_relationships.py + - tests/unit/test_index.py + - tests/unit/test_adr_cli.py +tags: + - navigation + - graph + - documentation + - governance + - v0.2.0 +retrospective: false +--- + +# Expose ADR relationships as Mermaid and SVG navigation artifacts + +## Context and Problem Statement + +ADR Toolkit intentionally keeps ADR source files flat under `docs/decisions/`, but a growing decision log still needs navigable relationship context. The generated index already listed supersession and related links as text, but users also wanted a graph view and a crisp image artifact. Raster screenshots were not acceptable because relationship graphs rely on small text and lines that become blurry when exported as PNG. + +The same session also prepared the repository for public open-source use with contributor, security, and pull request guidance. That public surface should point contributors at the same deterministic ADR navigation and verification workflow rather than adding a separate documentation system. + +## Decision Drivers + +* Preserve the flat `docs/decisions/` storage model. +* Make relationship navigation visible in GitHub without requiring local tooling. +* Provide a sharp standalone image artifact for documents and reviews. +* Avoid making Node, Mermaid CLI, browser automation, or screenshot rendering required dependencies. +* Keep command output deterministic JSON and path handling consistent with the existing repository-scoped commands. +* Make public contribution guidance ask reviewers to consider ADR impact and verification. + +## Considered Options + +* Keep only the text relationship lists in the generated decision index. +* Embed Mermaid in the generated index and require Mermaid CLI or browser rendering for image export. +* Embed Mermaid in the generated index and generate standalone Mermaid plus Python-rendered SVG artifacts from the same relationship model. + +## Decision Outcome + +Chosen option: **embed Mermaid in the generated index and generate standalone Mermaid plus Python-rendered SVG artifacts**, because GitHub can render Mermaid directly while SVG gives a crisp vector artifact without adding external rendering dependencies. + +`adr.py index` now appends a Mermaid `flowchart LR` block when `related` or `supersedes` edges exist. `adr.py graph --format mermaid|svg|both` exports `relationships.mmd` and/or `relationships.svg`; with `--format both`, a custom `--output` is treated as a prefix so one path can produce both extensions. Relative output paths resolve from `--root`, matching the other repository-scoped commands. + +The public readiness docs added in the same decision are `CONTRIBUTING.md`, `SECURITY.md`, and `.github/PULL_REQUEST_TEMPLATE.md`. They make ADR impact, local verification, and security reporting explicit for future open-source contributors. + +### Consequences + +* Good: GitHub readers get an inline relationship graph without installing anything. +* Good: users who need an image get SVG, which remains sharp when zoomed or embedded in documents. +* Good: graph artifacts reuse the same deterministic relationship model as validation and index generation. +* Good: public contributors now see the expected branch, ADR, security, and verification workflow before opening a PR. +* Bad: the Python SVG renderer is a deliberately small navigation artifact, not a full Mermaid renderer or layout engine. +* Bad: generated `relationships.mmd` and `relationships.svg` are additional files that must be regenerated when ADR relationships change. + +### Confirmation + +* `tests/unit/test_relationships.py` covers Mermaid rendering, label escaping, no-edge behavior, and SVG output. +* `tests/unit/test_graph_command.py` covers default export, custom output prefix behavior for `--format both`, and `--root`-relative output paths. +* `tests/unit/test_index.py` covers Mermaid graph insertion in the generated decision index. +* `tests/unit/test_adr_cli.py` covers parser registration for `adr.py graph`. +* `python3 -m pytest -q` passes with 395 tests. +* `python3 scripts/sync_version.py --check`, `adr.py validate`, `adr.py index`, and `adr.py graph --format both` all pass. + +## Implementation Constraints + +```yaml +constraints: + - id: graph-export-no-external-renderer + kind: forbidden_import + paths: ["skills/adr-toolkit/scripts/commands/graph.py", "skills/adr-toolkit/scripts/core/relationships.py"] + pattern: ["^\\s*import\\s+subprocess\\b", "^\\s*from\\s+subprocess\\b", "^\\s*import\\s+playwright\\b", "^\\s*from\\s+playwright\\b", "^\\s*import\\s+selenium\\b", "^\\s*from\\s+selenium\\b"] + severity: major + message: "ADR graph export must stay deterministic and must not require external renderers or browser automation." +``` + +## Pros and Cons of the Options + +### Keep only text relationship lists + +* Good, because it requires no new command or generated artifact. +* Bad, because relationship navigation remains harder to scan as ADR count grows, and users still lack a sharp visual artifact. + +### Mermaid plus external renderer + +* Good, because Mermaid CLI can produce familiar graph output. +* Bad, because requiring Node, browser automation, or screenshot rendering expands the dependency surface and reintroduces blurry PNG output unless users tune scale settings. + +### Mermaid plus Python-rendered SVG + +* Good, because the README stays GitHub-native and the standalone image remains vector-sharp. +* Good, because it keeps graph export inside the existing deterministic Python package. +* Bad, because the SVG layout is intentionally simple and may need a richer layout strategy if real ADR graphs become dense. + +## Revisit Triggers + +* Real repositories produce dense relationship graphs that the simple SVG layout cannot make readable. +* Users need a PNG export despite SVG availability, and can define acceptable scale/DPI behavior. +* Mermaid syntax compatibility changes on GitHub and breaks the embedded graph. +* Multiple repositories need a combined cross-repository graph rather than one graph per ADR directory. diff --git a/docs/decisions/README.md b/docs/decisions/README.md new file mode 100644 index 0000000..864d41a --- /dev/null +++ b/docs/decisions/README.md @@ -0,0 +1,315 @@ +# Decision Log + +## By status + +### Accepted +- [ADR-0001 — Record architecture decisions](0001-record-architecture-decisions.md) +- [ADR-0002 — Limit CHECK's conflict detection to structural evidence only](0002-limit-check-s-conflict-detection-to-structural-evidence-only.md) +- [ADR-0004 — Adapter packaging: manifest-only directories, install-time symlinks, verified formats](0004-adapter-packaging-manifest-only-directories-install-time-symlinks-verified-formats.md) +- [ADR-0005 — Adopt Git Flow with direct-tag release automation](0005-adopt-git-flow-with-direct-tag-release-automation.md) +- [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) +- [ADR-0007 — Promote CHECK's kind-to-confidence mapping to a stable output field](0007-check-confidence-field.md) +- [ADR-0008 — Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress](0008-check-exceptions-annotate-only.md) +- [ADR-0009 — --json is a documented no-op; CLI output is always JSON](0009-json-flag-always-json-contract.md) +- [ADR-0010 — Codex skill-creator's quick_validate.py incompatibility is not this project's problem](0010-codex-quick-validate-not-applicable.md) +- [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.md) + +### Superseded +- [ADR-0003 — Localize only index.py's generated strings, not agent-composed text](0003-localize-only-index-py-s-generated-strings-not-agent-composed-text.md) + +## By tag + +### adapters +- [ADR-0004 — Adapter packaging: manifest-only directories, install-time symlinks, verified formats](0004-adapter-packaging-manifest-only-directories-install-time-symlinks-verified-formats.md) + +### architecture +- [ADR-0002 — Limit CHECK's conflict detection to structural evidence only](0002-limit-check-s-conflict-detection-to-structural-evidence-only.md) +- [ADR-0003 — Localize only index.py's generated strings, not agent-composed text](0003-localize-only-index-py-s-generated-strings-not-agent-composed-text.md) +- [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) + +### check +- [ADR-0002 — Limit CHECK's conflict detection to structural evidence only](0002-limit-check-s-conflict-detection-to-structural-evidence-only.md) +- [ADR-0007 — Promote CHECK's kind-to-confidence mapping to a stable output field](0007-check-confidence-field.md) +- [ADR-0008 — Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress](0008-check-exceptions-annotate-only.md) + +### cli +- [ADR-0009 — --json is a documented no-op; CLI output is always JSON](0009-json-flag-always-json-contract.md) + +### codex +- [ADR-0010 — Codex skill-creator's quick_validate.py incompatibility is not this project's problem](0010-codex-quick-validate-not-applicable.md) + +### confidence +- [ADR-0007 — Promote CHECK's kind-to-confidence mapping to a stable output field](0007-check-confidence-field.md) + +### configuration +- [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) + +### cross-harness +- [ADR-0010 — Codex skill-creator's quick_validate.py incompatibility is not this project's problem](0010-codex-quick-validate-not-applicable.md) + +### documentation +- [ADR-0010 — Codex skill-creator's quick_validate.py incompatibility is not this project's problem](0010-codex-quick-validate-not-applicable.md) +- [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.md) + +### exceptions +- [ADR-0008 — Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress](0008-check-exceptions-annotate-only.md) + +### git-flow +- [ADR-0005 — Adopt Git Flow with direct-tag release automation](0005-adopt-git-flow-with-direct-tag-release-automation.md) + +### governance +- [ADR-0008 — Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress](0008-check-exceptions-annotate-only.md) +- [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.md) + +### graph +- [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.md) + +### harness-support +- [ADR-0004 — Adapter packaging: manifest-only directories, install-time symlinks, verified formats](0004-adapter-packaging-manifest-only-directories-install-time-symlinks-verified-formats.md) + +### i18n +- [ADR-0003 — Localize only index.py's generated strings, not agent-composed text](0003-localize-only-index-py-s-generated-strings-not-agent-composed-text.md) +- [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) + +### mvp-scope +- [ADR-0002 — Limit CHECK's conflict detection to structural evidence only](0002-limit-check-s-conflict-detection-to-structural-evidence-only.md) + +### navigation +- [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.md) + +### output-contract +- [ADR-0009 — --json is a documented no-op; CLI output is always JSON](0009-json-flag-always-json-contract.md) + +### process +- [ADR-0001 — Record architecture decisions](0001-record-architecture-decisions.md) +- [ADR-0005 — Adopt Git Flow with direct-tag release automation](0005-adopt-git-flow-with-direct-tag-release-automation.md) + +### release +- [ADR-0005 — Adopt Git Flow with direct-tag release automation](0005-adopt-git-flow-with-direct-tag-release-automation.md) + +### v0.2.0 +- [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) +- [ADR-0007 — Promote CHECK's kind-to-confidence mapping to a stable output field](0007-check-confidence-field.md) +- [ADR-0008 — Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress](0008-check-exceptions-annotate-only.md) +- [ADR-0009 — --json is a documented no-op; CLI output is always JSON](0009-json-flag-always-json-contract.md) +- [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.md) + +## By affected path + +### `.adr-toolkit.json` +- [ADR-0003 — Localize only index.py's generated strings, not agent-composed text](0003-localize-only-index-py-s-generated-strings-not-agent-composed-text.md) +- [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) + +### `.claude-plugin/marketplace.json` +- [ADR-0004 — Adapter packaging: manifest-only directories, install-time symlinks, verified formats](0004-adapter-packaging-manifest-only-directories-install-time-symlinks-verified-formats.md) + +### `.claude-plugin/plugin.json` +- [ADR-0004 — Adapter packaging: manifest-only directories, install-time symlinks, verified formats](0004-adapter-packaging-manifest-only-directories-install-time-symlinks-verified-formats.md) + +### `.github/PULL_REQUEST_TEMPLATE.md` +- [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.md) + +### `.github/workflows/` +- [ADR-0005 — Adopt Git Flow with direct-tag release automation](0005-adopt-git-flow-with-direct-tag-release-automation.md) + +### `.gitignore` +- [ADR-0004 — Adapter packaging: manifest-only directories, install-time symlinks, verified formats](0004-adapter-packaging-manifest-only-directories-install-time-symlinks-verified-formats.md) + +### `AGENTS.md` +- [ADR-0005 — Adopt Git Flow with direct-tag release automation](0005-adopt-git-flow-with-direct-tag-release-automation.md) + +### `CONTRIBUTING.md` +- [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.md) + +### `README.md` +- [ADR-0007 — Promote CHECK's kind-to-confidence mapping to a stable output field](0007-check-confidence-field.md) +- [ADR-0008 — Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress](0008-check-exceptions-annotate-only.md) +- [ADR-0009 — --json is a documented no-op; CLI output is always JSON](0009-json-flag-always-json-contract.md) +- [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.md) + +### `SECURITY.md` +- [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.md) + +### `adapters/` +- [ADR-0004 — Adapter packaging: manifest-only directories, install-time symlinks, verified formats](0004-adapter-packaging-manifest-only-directories-install-time-symlinks-verified-formats.md) + +### `docs/decisions/` +- [ADR-0001 — Record architecture decisions](0001-record-architecture-decisions.md) + +### `docs/decisions/README.md` +- [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.md) + +### `docs/decisions/relationships.mmd` +- [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.md) + +### `docs/decisions/relationships.svg` +- [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.md) + +### `examples/quickstart.md` +- [ADR-0007 — Promote CHECK's kind-to-confidence mapping to a stable output field](0007-check-confidence-field.md) + +### `project-roadmap.md` +- [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.md) + +### `scripts/sync_version.py` +- [ADR-0005 — Adopt Git Flow with direct-tag release automation](0005-adopt-git-flow-with-direct-tag-release-automation.md) + +### `skills/adr-toolkit/SKILL.md` +- [ADR-0003 — Localize only index.py's generated strings, not agent-composed text](0003-localize-only-index-py-s-generated-strings-not-agent-composed-text.md) +- [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) +- [ADR-0007 — Promote CHECK's kind-to-confidence mapping to a stable output field](0007-check-confidence-field.md) +- [ADR-0008 — Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress](0008-check-exceptions-annotate-only.md) +- [ADR-0009 — --json is a documented no-op; CLI output is always JSON](0009-json-flag-always-json-contract.md) +- [ADR-0010 — Codex skill-creator's quick_validate.py incompatibility is not this project's problem](0010-codex-quick-validate-not-applicable.md) +- [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.md) + +### `skills/adr-toolkit/VERSION` +- [ADR-0005 — Adopt Git Flow with direct-tag release automation](0005-adopt-git-flow-with-direct-tag-release-automation.md) + +### `skills/adr-toolkit/references/conflict-rules.md` +- [ADR-0002 — Limit CHECK's conflict detection to structural evidence only](0002-limit-check-s-conflict-detection-to-structural-evidence-only.md) +- [ADR-0007 — Promote CHECK's kind-to-confidence mapping to a stable output field](0007-check-confidence-field.md) +- [ADR-0008 — Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress](0008-check-exceptions-annotate-only.md) + +### `skills/adr-toolkit/schemas/adr.schema.json` +- [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) + +### `skills/adr-toolkit/schemas/exception.schema.json` +- [ADR-0008 — Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress](0008-check-exceptions-annotate-only.md) + +### `skills/adr-toolkit/scripts/adr.py` +- [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) +- [ADR-0008 — Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress](0008-check-exceptions-annotate-only.md) +- [ADR-0009 — --json is a documented no-op; CLI output is always JSON](0009-json-flag-always-json-contract.md) +- [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.md) + +### `skills/adr-toolkit/scripts/commands/check.py` +- [ADR-0002 — Limit CHECK's conflict detection to structural evidence only](0002-limit-check-s-conflict-detection-to-structural-evidence-only.md) +- [ADR-0007 — Promote CHECK's kind-to-confidence mapping to a stable output field](0007-check-confidence-field.md) +- [ADR-0008 — Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress](0008-check-exceptions-annotate-only.md) + +### `skills/adr-toolkit/scripts/commands/create.py` +- [ADR-0003 — Localize only index.py's generated strings, not agent-composed text](0003-localize-only-index-py-s-generated-strings-not-agent-composed-text.md) +- [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) + +### `skills/adr-toolkit/scripts/commands/diff.py` +- [ADR-0002 — Limit CHECK's conflict detection to structural evidence only](0002-limit-check-s-conflict-detection-to-structural-evidence-only.md) + +### `skills/adr-toolkit/scripts/commands/exception.py` +- [ADR-0008 — Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress](0008-check-exceptions-annotate-only.md) + +### `skills/adr-toolkit/scripts/commands/graph.py` +- [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.md) + +### `skills/adr-toolkit/scripts/commands/index.py` +- [ADR-0003 — Localize only index.py's generated strings, not agent-composed text](0003-localize-only-index-py-s-generated-strings-not-agent-composed-text.md) +- [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) +- [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.md) + +### `skills/adr-toolkit/scripts/commands/init.py` +- [ADR-0003 — Localize only index.py's generated strings, not agent-composed text](0003-localize-only-index-py-s-generated-strings-not-agent-composed-text.md) +- [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) + +### `skills/adr-toolkit/scripts/commands/validate.py` +- [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) + +### `skills/adr-toolkit/scripts/core/config.py` +- [ADR-0003 — Localize only index.py's generated strings, not agent-composed text](0003-localize-only-index-py-s-generated-strings-not-agent-composed-text.md) +- [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) + +### `skills/adr-toolkit/scripts/core/constraints.py` +- [ADR-0002 — Limit CHECK's conflict detection to structural evidence only](0002-limit-check-s-conflict-detection-to-structural-evidence-only.md) + +### `skills/adr-toolkit/scripts/core/exceptions.py` +- [ADR-0008 — Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress](0008-check-exceptions-annotate-only.md) + +### `skills/adr-toolkit/scripts/core/identifiers.py` +- [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) + +### `skills/adr-toolkit/scripts/core/locale.py` +- [ADR-0003 — Localize only index.py's generated strings, not agent-composed text](0003-localize-only-index-py-s-generated-strings-not-agent-composed-text.md) +- [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) + +### `skills/adr-toolkit/scripts/core/relationships.py` +- [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.md) + +### `skills/adr-toolkit/scripts/core/rendering.py` +- [ADR-0003 — Localize only index.py's generated strings, not agent-composed text](0003-localize-only-index-py-s-generated-strings-not-agent-composed-text.md) +- [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) + +### `skills/adr-toolkit/scripts/core/schema.py` +- [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) + +### `skills/adr-toolkit/scripts/i18n/` +- [ADR-0003 — Localize only index.py's generated strings, not agent-composed text](0003-localize-only-index-py-s-generated-strings-not-agent-composed-text.md) +- [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) + +### `skills/adr-toolkit/scripts/rules/conflict.py` +- [ADR-0002 — Limit CHECK's conflict detection to structural evidence only](0002-limit-check-s-conflict-detection-to-structural-evidence-only.md) + +### `tests/integration/test_cli.py` +- [ADR-0009 — --json is a documented no-op; CLI output is always JSON](0009-json-flag-always-json-contract.md) + +### `tests/unit/test_adr_cli.py` +- [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.md) + +### `tests/unit/test_check.py` +- [ADR-0007 — Promote CHECK's kind-to-confidence mapping to a stable output field](0007-check-confidence-field.md) + +### `tests/unit/test_graph_command.py` +- [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.md) + +### `tests/unit/test_index.py` +- [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.md) + +### `tests/unit/test_relationships.py` +- [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.md) + +## Chronological (newest first) + +- 2026-08-31 — [ADR-0011 — Expose ADR relationships as Mermaid and SVG navigation artifacts](0011-adr-relationship-graph-public-readiness.md) +- 2026-08-30 — [ADR-0001 — Record architecture decisions](0001-record-architecture-decisions.md) +- 2026-08-30 — [ADR-0002 — Limit CHECK's conflict detection to structural evidence only](0002-limit-check-s-conflict-detection-to-structural-evidence-only.md) +- 2026-08-30 — [ADR-0003 — Localize only index.py's generated strings, not agent-composed text](0003-localize-only-index-py-s-generated-strings-not-agent-composed-text.md) +- 2026-08-30 — [ADR-0004 — Adapter packaging: manifest-only directories, install-time symlinks, verified formats](0004-adapter-packaging-manifest-only-directories-install-time-symlinks-verified-formats.md) +- 2026-08-30 — [ADR-0005 — Adopt Git Flow with direct-tag release automation](0005-adopt-git-flow-with-direct-tag-release-automation.md) +- 2026-08-30 — [ADR-0006 — Localize deterministic ADR generation through repository configuration](0006-localized-adr-generation.md) +- 2026-08-30 — [ADR-0007 — Promote CHECK's kind-to-confidence mapping to a stable output field](0007-check-confidence-field.md) +- 2026-08-30 — [ADR-0008 — Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress](0008-check-exceptions-annotate-only.md) +- 2026-08-30 — [ADR-0009 — --json is a documented no-op; CLI output is always JSON](0009-json-flag-always-json-contract.md) +- 2026-08-30 — [ADR-0010 — Codex skill-creator's quick_validate.py incompatibility is not this project's problem](0010-codex-quick-validate-not-applicable.md) + +## Relationships + +### Supersession chains + +- ADR-0003 "Localize only index.py's generated strings, not agent-composed text" → superseded by → ADR-0006 "Localize deterministic ADR generation through repository configuration" + +### Related + +- ADR-0006 "Localize deterministic ADR generation through repository configuration" related to: ADR-0003 "Localize only index.py's generated strings, not agent-composed text" +- ADR-0011 "Expose ADR relationships as Mermaid and SVG navigation artifacts" related to: ADR-0001 "Record architecture decisions" +- ADR-0011 "Expose ADR relationships as Mermaid and SVG navigation artifacts" related to: ADR-0006 "Localize deterministic ADR generation through repository configuration" +- ADR-0011 "Expose ADR relationships as Mermaid and SVG navigation artifacts" related to: ADR-0008 "Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress" +- ADR-0011 "Expose ADR relationships as Mermaid and SVG navigation artifacts" related to: ADR-0009 "--json is a documented no-op; CLI output is always JSON" + +```mermaid +flowchart LR + ADR_0001["ADR-0001
Record architecture decisions"] + ADR_0002["ADR-0002
Limit CHECK's conflict detection to structural evidence only"] + ADR_0003["ADR-0003
Localize only index.py's generated strings, not agent-composed text"] + ADR_0004["ADR-0004
Adapter packaging: manifest-only directories, install-time symlinks, verified formats"] + ADR_0005["ADR-0005
Adopt Git Flow with direct-tag release automation"] + ADR_0006["ADR-0006
Localize deterministic ADR generation through repository configuration"] + ADR_0007["ADR-0007
Promote CHECK's kind-to-confidence mapping to a stable output field"] + ADR_0008["ADR-0008
Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress"] + ADR_0009["ADR-0009
--json is a documented no-op; CLI output is always JSON"] + ADR_0010["ADR-0010
Codex skill-creator's quick_validate.py incompatibility is not this project's problem"] + ADR_0011["ADR-0011
Expose ADR relationships as Mermaid and SVG navigation artifacts"] + ADR_0006 -.->|related| ADR_0003 + ADR_0006 -->|supersedes| ADR_0003 + ADR_0011 -.->|related| ADR_0001 + ADR_0011 -.->|related| ADR_0006 + ADR_0011 -.->|related| ADR_0008 + ADR_0011 -.->|related| ADR_0009 +``` diff --git a/docs/decisions/adr-template.md b/docs/decisions/adr-template.md new file mode 100644 index 0000000..aaeb556 --- /dev/null +++ b/docs/decisions/adr-template.md @@ -0,0 +1,27 @@ +# {title} + +## Context and Problem Statement + +{problem and constraints} + +## Considered Options + +* {option one} +* {option two} + +## Decision Outcome + +Chosen option: **{chosen option}**, because {rationale}. + +## Consequences + +* Good: {positive consequence} +* Bad: {negative consequence} + +## Confirmation + +{how implementation will be verified} + +## Revisit Triggers + +* {condition that should reopen the decision} diff --git a/docs/decisions/relationships.mmd b/docs/decisions/relationships.mmd new file mode 100644 index 0000000..f36a1e0 --- /dev/null +++ b/docs/decisions/relationships.mmd @@ -0,0 +1,18 @@ +flowchart LR + ADR_0001["ADR-0001
Record architecture decisions"] + ADR_0002["ADR-0002
Limit CHECK's conflict detection to structural evidence only"] + ADR_0003["ADR-0003
Localize only index.py's generated strings, not agent-composed text"] + ADR_0004["ADR-0004
Adapter packaging: manifest-only directories, install-time symlinks, verified formats"] + ADR_0005["ADR-0005
Adopt Git Flow with direct-tag release automation"] + ADR_0006["ADR-0006
Localize deterministic ADR generation through repository configuration"] + ADR_0007["ADR-0007
Promote CHECK's kind-to-confidence mapping to a stable output field"] + ADR_0008["ADR-0008
Deterministic CHECK policy exceptions: schema-validated, annotate-only, never suppress"] + ADR_0009["ADR-0009
--json is a documented no-op; CLI output is always JSON"] + ADR_0010["ADR-0010
Codex skill-creator's quick_validate.py incompatibility is not this project's problem"] + ADR_0011["ADR-0011
Expose ADR relationships as Mermaid and SVG navigation artifacts"] + ADR_0006 -.->|related| ADR_0003 + ADR_0006 -->|supersedes| ADR_0003 + ADR_0011 -.->|related| ADR_0001 + ADR_0011 -.->|related| ADR_0006 + ADR_0011 -.->|related| ADR_0008 + ADR_0011 -.->|related| ADR_0009 diff --git a/docs/decisions/relationships.svg b/docs/decisions/relationships.svg new file mode 100644 index 0000000..7afc55f --- /dev/null +++ b/docs/decisions/relationships.svg @@ -0,0 +1,52 @@ + +ADR relationship graph +Vector export of ADR supersession and related-decision links. + + +ADR relationship graph + +ADR-0001 +Record architecture decisions + +ADR-0002 +Limit CHECK's conflict detection ... + +ADR-0003 +Localize only index.py's generate... + +ADR-0004 +Adapter packaging: manifest-only ... + +ADR-0005 +Adopt Git Flow with direct-tag re... + +ADR-0006 +Localize deterministic ADR genera... + +ADR-0007 +Promote CHECK's kind-to-confidenc... + +ADR-0008 +Deterministic CHECK policy except... + +ADR-0009 +--json is a documented no-op; CLI... + +ADR-0010 +Codex skill-creator's quick_valid... + +ADR-0011 +Expose ADR relationships as Merma... + +ADR-0006 related ADR-0003 + +ADR-0006 supersedes ADR-0003 + +ADR-0011 related ADR-0001 + +ADR-0011 related ADR-0006 + +ADR-0011 related ADR-0008 + +ADR-0011 related ADR-0009 + diff --git a/docs/enterprise-adoption.md b/docs/enterprise-adoption.md new file mode 100644 index 0000000..0086ac5 --- /dev/null +++ b/docs/enterprise-adoption.md @@ -0,0 +1,212 @@ +# ADR Toolkit 엔터프라이즈 도입 보고서 + +작성일: 2026-08-30 + +범위: v0.2.0 이후의 팀·공개 저장소·조직 단위 도입 + +## 1. 요약 + +ADR Toolkit의 강점은 AI가 초안과 언어 표현을 돕더라도 repository state의 진실은 +결정론적 코어가 소유한다는 점이다. 현재 구현은 개인·소규모 팀이 반복 사용할 수 +있는 수준이지만, 엔터프라이즈 도구라고 부르기에는 권한, 독립 승인, 감사, 예외, +조직 정책 배포, 채택 측정이 부족하다. + +가장 좋은 확장 순서는 기능을 한 번에 늘리는 것이 아니라 다음 네 단계다. + +1. private solo 저장소에서 v0.2.0의 정확성을 확정한다. +2. public 전환 시 GitHub ruleset과 기본 기여 통제를 실제로 강제한다. +3. 복수 maintainer가 생기면 CODEOWNERS와 독립 ADR review를 도입한다. +4. 여러 저장소가 같은 문제를 반복할 때 조직 ruleset, reusable workflow, audit, + metrics, cross-repository discovery를 추가한다. + +## 2. 현재 상태 + +### 확인된 사실 + +- 저장소는 private이며 `master`와 `develop` 보호 규칙 및 repository ruleset이 없다. +- 현재 플랜에서는 private 저장소 protection API가 활성화되지 않는다. +- CI는 Ubuntu, macOS, Windows와 지원 Python 조합에서 동작한다. +- 첫 PR은 모든 CI를 통과했지만 독립 review 없이 병합됐다. +- CODEOWNERS와 PR template이 없다. +- 코어는 locale, ID, lifecycle, schema, relationship, index, CHECK의 repository + state를 결정론적으로 검증한다. +- 중앙 서비스, 사용자 계정, 조직 RBAC, audit export, telemetry는 없다. + +### 객관적 판단 + +현재 상태는 “엔터프라이즈 준비 완료”가 아니라 **강한 로컬 코어를 가진 팀 도입 +전 단계**다. 중앙 서버와 telemetry가 없는 것은 개인 정보·오프라인 사용 측면에서는 +장점이지만, 조직은 누가 어떤 결정을 승인했고 정책 예외가 언제 만료되는지를 별도 +시스템 없이 집계할 수 없다. + +## 3. 유지해야 할 설계 원칙 + +### Deterministic Core, Agentic Edge + +코어가 소유해야 하는 범위는 ID, lifecycle transition, schema, relationship +invariant, index, filename, locale catalog, CHECK 규칙이다. Agent는 언어 추론, 초안, +근거 추출, 관련 ADR 탐색, semantic slug 제안을 도울 수 있다. Agent 출력은 저장소에 +반영되기 전에 항상 코어의 검증 경계를 통과해야 한다. + +### Human-localized, machine-stable + +사람이 읽는 제목·본문·prompt는 팀 언어를 따르되 ID, status, relationship, locale +code, 파일명 계약은 기계적으로 안정적이어야 한다. 코어가 제목을 번역해 semantic +slug를 만들어서는 안 된다. + +### No false governance confidence + +자동화할 수 없는 판단은 실패나 성공으로 꾸미지 않고 `UNVERIFIABLE`로 표시한다. +CI 통과는 구조화된 정책의 증거일 뿐, 결정의 사업적 타당성이나 조직 합의의 증거가 +아니다. + +## 4. 공개 저장소 전환 Gate + +사용자가 계획한 public 전환 직후 다음 repository ruleset을 적용하고 API로 실제 +상태를 재조회한다. + +| 대상 | 권장 통제 | 도입 이유 | +| --- | --- | --- | +| `master` | PR 필수, required CI, conversation resolution, force-push/deletion 차단 | 릴리스 이력과 tag 기준점 보호 | +| `develop` | PR 필수, required CI, conversation resolution, force-push/deletion 차단 | 통합 브랜치의 회귀 차단 | +| `v*` tag | 생성/수정/삭제 권한 제한 | 공개 릴리스 provenance 보호 | +| 예외 | 최소 인원 bypass, 사유 기록, 정기 검토 | 운영 복구 수단과 감사 가능성의 균형 | + +GitHub ruleset은 required pull request, status check, code-owner review, +conversation resolution, signed commit, force-push와 deletion 제한을 제공한다. +실제 조합은 maintainer 수와 자동화 계정 요구에 맞춰 최소 권한으로 정한다. +([GitHub ruleset rules](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/available-rules-for-rulesets)) + +public 전환과 동시에 추가할 최소 파일은 다음과 같다. + +- PR template: ADR 영향 여부, 관련 ADR ID, CHECK 결과, 문서 변경, 예외 사유를 묻는다. +- `CONTRIBUTING.md`: Git Flow, required checks, ADR lifecycle, release 절차를 설명한다. +- `SECURITY.md`: 취약점의 비공개 신고 경로와 지원 범위를 밝힌다. +- CODEOWNERS: 실제로 독립 승인할 수 있는 qualified maintainer가 2명 이상일 때 + 활성화한다. 한 사람만 있는 상태에서 필수 code-owner review를 켜면 운영을 막거나 + 형식적인 self-review만 만들 수 있다. + +## 5. 팀 단위 운영 모델 + +AWS는 ADR에 단일 owner를 두고 Proposed 상태에서 이해관계자 peer review를 거친 뒤 +Accepted로 전환하며, 승인된 기록은 새 ADR로 supersede할 것을 권고한다. Microsoft도 +status, considered options, tradeoffs, consequences를 포함하는 append-only 기록을 +권장한다. +([AWS ADR process](https://docs.aws.amazon.com/prescriptive-guidance/latest/architectural-decision-records/adr-process.html), +[Microsoft ADR guidance](https://learn.microsoft.com/en-us/azure/well-architected/architect-role/architecture-decision-record)) + +이를 Toolkit에 적용한 권장 역할은 다음과 같다. + +| 역할 | 책임 | +| --- | --- | +| Decision owner | 맥락·선택지·근거·consequence를 완성하고 review를 요청 | +| Required reviewer | 영역 전문성과 영향 범위를 확인하고 독립 승인 | +| Repository maintainer | lifecycle invariant, CI, merge, release 통제 유지 | +| Exception owner | 정책 예외의 사유, 범위, 만료일, 후속 조치 소유 | + +운영 절차는 Proposed → review → Accepted → implementation evidence → periodic review +순으로 둔다. Accepted ADR의 사실 오류를 몰래 고치지 않고, 결정 변경은 새 ADR과 +supersession으로 남긴다. 긴급 예외는 owner와 만료일이 없는 영구 bypass가 되지 않게 +한다. + +## 6. 조직 단위 확장 + +### 6.1 중앙 정책 배포 + +여러 저장소에서 같은 보호 규칙이 반복될 때 organization ruleset을 사용한다. +GitHub 조직 ruleset은 repository targeting, enforcement status, bypass actor를 중앙에서 +관리할 수 있다. GitHub Actions의 reusable workflow는 공통 검증 로직을 한 곳에 두고 +여러 workflow에서 호출할 수 있다. +([GitHub organization rulesets](https://docs.github.com/en/organizations/managing-organization-settings/managing-rulesets-for-repositories-in-your-organization), +[GitHub reusable workflows](https://docs.github.com/en/actions/how-tos/reuse-automations/share-with-your-organization)) + +권장 구성은 다음과 같다. + +- 조직 ruleset: protected branch/tag와 bypass 정책 +- reusable workflow: validate, index-drift, CHECK, schema/config parity +- repository-local config: 팀 locale과 ADR directory처럼 저장소가 소유할 값 +- 중앙 taxonomy: domain, risk, regulatory classification처럼 조직 간 집계할 값 +- exception registry: 규칙, owner, 승인자, scope, 생성일, 만료일, 해소 상태 + +중앙 정책은 저장소의 모든 결정을 대신하지 않는다. 조직 공통 invariant만 중앙화하고, +제품 맥락과 결정 본문은 해당 저장소가 소유해야 변경 속도와 책임 소재가 유지된다. + +### 6.2 RBAC와 감사 + +최소 권한 역할을 maintainer, decision owner, reviewer, auditor로 분리하고, bot에는 +검증에 필요한 read/check 권한만 준다. bypass는 소수 역할에 한정하고 사용 이벤트를 +감사한다. GitHub audit log는 조직의 actor, action, 대상, 시각을 조회·내보낼 수 있어 +ruleset 변경과 bypass 검토의 근거가 된다. +([GitHub audit log](https://docs.github.com/en/organizations/keeping-your-organization-secure/managing-security-settings-for-your-organization/reviewing-the-audit-log-for-your-organization)) + +Toolkit 자체에 계정 시스템을 서둘러 넣기보다 GitHub의 인증·권한·감사를 활용하고, +제품은 ADR 상태와 검증 결과를 기계 판독 가능한 산출물로 제공하는 편이 단순하고 +신뢰 경계가 명확하다. + +## 7. 측정 가능한 도입 지표 + +지표는 감시나 생산성 순위가 아니라 workflow 병목과 정책 부채를 찾는 용도로만 +사용한다. + +| 지표 | 정의 | 해석 시 주의점 | +| --- | --- | --- | +| Decision lead time | ADR 최초 Proposed 시각부터 Accepted/Rejected 시각까지의 중앙값 | 짧다고 결정 품질이 자동으로 높지 않음 | +| Review latency | review 요청부터 첫 qualified review까지의 중앙값 | 시간대·휴가·팀 규모를 함께 봐야 함 | +| Supersession rate | 기간 내 Accepted ADR 중 superseded된 비율 | 높은 값은 학습일 수도, 불안정일 수도 있음 | +| Unresolved violations | mainline에서 열린 `VIOLATED` 결과 수와 지속 일수 | rule coverage 밖의 문제는 포함하지 않음 | +| Exception age | 만료되지 않은 예외의 생성 후 경과 일수와 만료 초과 수 | 예외 개수보다 owner·만료·해소가 중요함 | +| Stale review coverage | review 주기를 넘긴 active ADR / review 대상 active ADR | 모든 ADR에 동일 주기를 강제하지 않음 | + +성숙도 수준 5는 이 지표를 수집한다는 사실만으로 달성되지 않는다. 정의가 버전 관리되고, +예외와 audit evidence를 재현할 수 있으며, 지표가 실제 운영 개선으로 연결돼야 한다. + +## 8. 서비스 개선 우선순위 + +### 다음 구현 후보 + +1. ~~ADR directory 로딩을 공통 iterator로 통합해~~ **완료 (2026-08-30).** + `core.adr_directory.iter_adr_files`로 validate/index/related/CHECK를 통합했고 + 각 명령의 warning 의미는 그대로 유지했다. +2. public 전환 시 PR template과 CONTRIBUTING/SECURITY 문서를 만들고 ruleset 적용을 + 자동 검증한다. **저장소가 아직 private이라 시작 조건이 충족되지 않았다.** +3. ~~CHECK 결과를 `VERIFIED`, `VIOLATED`, `NOT_APPLICABLE`, `UNVERIFIABLE`의 + 안정된 machine-readable contract로 승격한다.~~ **완료 (2026-08-30).** 모든 + finding이 기존 `kind` 값과 별개로 `confidence` 필드를 직접 갖는다 + (`related→VERIFIED`, `verified_violation→VIOLATED`, + `review_required`/`no_applicable_constraint→UNVERIFIABLE`; 관련 ADR/finding + 자체가 없으면 `NOT_APPLICABLE`, 이는 빈 `findings` 배열로 표현되고 별도 + finding을 만들지 않는다). README, SKILL.md, `conflict-rules.md`, quickstart + 예제 출력을 모두 갱신했다. +4. ~~예외에 owner, reason, scope, expiry를 요구하는 작은 schema부터 + 시작한다.~~ **완료 (2026-08-30).** `adr.py exception --input `이 + `owner`/`reason`/`scope`/`expiry`를 요구하는 schema로 검증한 뒤 + `docs/decisions/exceptions/NNNN.json`에 `EXC-NNNN`으로 기록한다. CHECK는 + 일치하는 만료 전 예외를 finding의 `exception` 필드로 주석만 달고, `kind`나 + `confidence`는 절대 낮추거나 숨기지 않는다 — "no false governance + confidence" 원칙 유지. +5. 두 개 이상의 저장소가 같은 운영 문제를 보일 때 reusable workflow와 조직 + taxonomy를 설계한다. **시작 조건(2개 이상 저장소) 미충족.** + +### 지금 구현하지 않을 것 + +- 중앙 포털, 벡터 검색, GitHub App은 flat-directory와 GitHub Action의 실제 한계가 + 측정되기 전에는 비용이 더 크다. +- 자동 번역과 자동 semantic slug 확정은 결정성을 약화하므로 코어에 넣지 않는다. +- 독립 reviewer가 없는 현재 저장소에서 mandatory CODEOWNERS 승인을 형식적으로 + 켜지 않는다. +- 모든 산문 ADR을 자동 검증하려 하지 않는다. 검증할 수 없는 판단은 사람의 review + 대상으로 남긴다. + +## 9. 단계별 완료 조건 + +| 단계 | 완료 조건 | +| --- | --- | +| v0.2.0 | P0 전체 통과, 최종 PR CI, 승인된 version bump와 release 절차 | +| Public | `master`/`develop`/`v*` ruleset API 검증, PR template, CONTRIBUTING, SECURITY | +| Team | 2명 이상 qualified maintainer, CODEOWNERS 독립 승인, 예외 owner/expiry | +| Enterprise | 조직 ruleset·reusable workflow, audit export, taxonomy, 정의된 adoption metrics | +| Multi-repo | 반복된 탐색 실패와 운영 요구를 근거로 registry/portal 도입 | + +이 순서라면 v0.2.0의 핵심 메시지인 “AI는 ADR 작성을 도울 수 있지만 architecture +governance의 진실은 결정론적 검증이 보장한다”를 유지하면서도, 실제 사용 증거가 +생길 때만 운영 복잡도를 추가할 수 있다. diff --git a/docs/superpowers/plans/2026-08-29-adr-toolkit-core-and-init.md b/docs/superpowers/plans/2026-08-29-adr-toolkit-core-and-init.md deleted file mode 100644 index 0b7420c..0000000 --- a/docs/superpowers/plans/2026-08-29-adr-toolkit-core-and-init.md +++ /dev/null @@ -1,2676 +0,0 @@ -# ADR Toolkit — Core Scripts + INIT (Plan 1 of 4) Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Ship a self-contained `skills/adr-toolkit/` package with the deterministic script layer (frontmatter, IDs, lifecycle, schema, discovery, index, validation) and two fully working operations — INIT (scaffolding) and DISCOVER (past-decision recovery, kept separate from INIT per user feedback) — usable three ways: through Claude Code, through any other harness via a generic fallback adapter, or directly from the terminal with no AI agent at all via an interactive CLI wizard. CI enforces all of it from the first commit. - -**Architecture:** Deterministic Python stdlib-only scripts under `skills/adr-toolkit/scripts/` (package name `scripts`, imported as `scripts.core.*` / `scripts.commands.*` / `scripts.evidence.*`) do all file I/O, ID assignment, and validation. `SKILL.md` carries the workflow contract the agent follows and never re-implements those deterministic steps in prose. A thin Claude Code adapter under `adapters/claude/` points at the skill folder; it carries no logic of its own. - -**Tech Stack:** Python 3.9+ standard library only (argparse, pathlib, json, re, shutil, datetime). pytest for tests. GitHub Actions for CI. - -**Spec:** `docs/superpowers/specs/2026-08-29-adr-toolkit-design.md` - -## Global Constraints - -- Python 3.9+, standard library only — no third-party pip packages anywhere under `skills/adr-toolkit/`. -- UTF-8 explicit on every file read/write: always pass `encoding="utf-8"`. -- No `shell=True` and no string-interpolated subprocess commands — pass argument lists. -- `init` and `create` (the two mutating commands in this plan) must support `--dry-run` and must refuse to overwrite an existing file or non-empty directory instead of silently overwriting it. -- Command modules never `print()` — they return a `dict`; only `scripts/adr.py`'s `main()` serializes to stdout as JSON. -- ADR IDs are 4-digit zero-padded, computed as `max(existing IDs) + 1` from filenames already on disk — never guessed, never reused. -- **Out of scope for this plan** (tracked in `project-roadmap.md` / later plans): RECORD workflow, CHECK workflow, i18n wiring, Codex/Gemini CLI/Antigravity adapters, release automation. `index.py`'s section headers are English-only in this plan; localizing them is Plan 4's job. -- Every task must leave `python -m pytest tests/unit tests/integration -v` green (from the repo root) before its commit step. -- Avoid `X | None` union-type syntax (Python 3.10+); use `typing.Optional` so the code runs on the Python 3.9 floor declared by `preflight`. - ---- - -### Task 1: Scaffold the self-contained skill package and test plumbing - -**Files:** -- Create: `skills/adr-toolkit/VERSION` -- Create: `skills/adr-toolkit/scripts/__init__.py` -- Create: `skills/adr-toolkit/scripts/core/__init__.py` -- Create: `skills/adr-toolkit/scripts/commands/__init__.py` -- Create: `skills/adr-toolkit/scripts/evidence/__init__.py` -- Create: `skills/adr-toolkit/templates/.gitkeep` -- Create: `skills/adr-toolkit/references/.gitkeep` -- Create: `skills/adr-toolkit/schemas/.gitkeep` -- Create: `tests/conftest.py` -- Create: `pytest.ini` -- Test: `tests/unit/test_scaffold.py` - -**Interfaces:** -- Consumes: nothing (first task). -- Produces: `sys.path` entry for `skills/adr-toolkit/` set up by `tests/conftest.py`, so every later test file can `import scripts.core.xxx` / `import scripts.commands.xxx` / `import scripts.evidence.xxx` without its own path hacking. - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_scaffold.py -from pathlib import Path - -SKILL_ROOT = Path(__file__).resolve().parent.parent.parent / "skills" / "adr-toolkit" - - -def test_skill_root_layout_exists(): - assert (SKILL_ROOT / "VERSION").is_file() - assert (SKILL_ROOT / "scripts" / "__init__.py").is_file() - assert (SKILL_ROOT / "scripts" / "core" / "__init__.py").is_file() - assert (SKILL_ROOT / "scripts" / "commands" / "__init__.py").is_file() - assert (SKILL_ROOT / "scripts" / "evidence" / "__init__.py").is_file() - assert (SKILL_ROOT / "templates").is_dir() - assert (SKILL_ROOT / "references").is_dir() - assert (SKILL_ROOT / "schemas").is_dir() - - -def test_version_file_has_semver(): - version = (SKILL_ROOT / "VERSION").read_text(encoding="utf-8").strip() - parts = version.split(".") - assert len(parts) == 3 - assert all(part.isdigit() for part in parts) - - -def test_scripts_importable_via_conftest_path(): - import scripts # noqa: F401 — only importable if conftest.py set sys.path correctly -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_scaffold.py -v` -Expected: FAIL / ERROR — files and directories don't exist yet, `tests/conftest.py` doesn't exist. - -- [ ] **Step 3: Create the scaffold** - -```python -# tests/conftest.py -import sys -from pathlib import Path - -SKILL_ROOT = Path(__file__).resolve().parent.parent / "skills" / "adr-toolkit" -sys.path.insert(0, str(SKILL_ROOT)) -``` - -```ini -# pytest.ini -[pytest] -testpaths = tests -``` - -Create the directories and empty `__init__.py` files listed under **Files** above (empty file contents), a `.gitkeep` in each of `templates/`, `references/`, `schemas/` (filled by later tasks), and: - -```text -skills/adr-toolkit/VERSION -0.1.0 -``` - -(no trailing content beyond the version string and a newline) - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_scaffold.py -v` -Expected: PASS (3 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/VERSION skills/adr-toolkit/scripts skills/adr-toolkit/templates \ - skills/adr-toolkit/references skills/adr-toolkit/schemas tests/conftest.py pytest.ini \ - tests/unit/test_scaffold.py -git commit -m "feat: scaffold self-contained adr-toolkit skill package" -``` - ---- - -### Task 2: `core/frontmatter.py` — parse and serialize ADR YAML frontmatter - -**Files:** -- Create: `skills/adr-toolkit/scripts/core/frontmatter.py` -- Test: `tests/unit/test_frontmatter.py` - -**Interfaces:** -- Consumes: nothing. -- Produces: `parse(text: str) -> tuple[dict, str]`, `serialize(data: dict, body: str) -> str`, `FrontmatterError(ValueError)`. Used by Tasks 10 (create), 11 (index), 12 (validate). - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_frontmatter.py -import pytest - -from scripts.core import frontmatter as fm - - -def test_parse_extracts_scalars_lists_and_body(): - text = ( - "---\n" - "id: ADR-0003\n" - "title: Use a provider port\n" - "status: accepted\n" - "date: 2026-08-29\n" - "decision_makers:\n" - " - Yangseunghyeon\n" - "related: []\n" - "affected_paths:\n" - " - src/providers/\n" - " - src/core/ports/\n" - "tags:\n" - " - architecture\n" - "retrospective: false\n" - "---\n" - "\n" - "# Use a provider port\n" - "\n" - "Body content.\n" - ) - data, body = fm.parse(text) - - assert data["id"] == "ADR-0003" - assert data["decision_makers"] == ["Yangseunghyeon"] - assert data["affected_paths"] == ["src/providers/", "src/core/ports/"] - assert data["retrospective"] is False - assert body.strip().startswith("# Use a provider port") - - -def test_parse_raises_without_frontmatter_block(): - with pytest.raises(fm.FrontmatterError): - fm.parse("# No frontmatter here\n") - - -def test_serialize_round_trips_through_parse(): - data = { - "id": "ADR-0001", - "title": "Record architecture decisions", - "status": "accepted", - "date": "2026-08-29", - "decision_makers": [], - "related": [], - "affected_paths": ["docs/decisions/"], - "tags": ["process"], - "retrospective": False, - } - text = fm.serialize(data, "# Record architecture decisions\n\nBody.\n") - parsed_data, parsed_body = fm.parse(text) - - assert parsed_data == data - assert parsed_body.strip() == "# Record architecture decisions\n\nBody." -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_frontmatter.py -v` -Expected: FAIL — `scripts.core.frontmatter` doesn't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -# skills/adr-toolkit/scripts/core/frontmatter.py -"""Parse and serialize ADR YAML frontmatter without a YAML library dependency. - -Supports exactly the subset ADR Toolkit frontmatter needs: string scalars, -booleans, and flat string lists. Not a general YAML parser. -""" -import re - -FRONTMATTER_RE = re.compile(r"\A---\n(.*?)\n---\n(.*)\Z", re.DOTALL) - - -class FrontmatterError(ValueError): - pass - - -def parse(text: str) -> tuple: - match = FRONTMATTER_RE.match(text) - if not match: - raise FrontmatterError("No YAML frontmatter block found") - raw_yaml, body = match.group(1), match.group(2) - return _parse_simple_yaml(raw_yaml), body - - -def serialize(data: dict, body: str) -> str: - lines = ["---"] - for key, value in data.items(): - lines.append(_format_field(key, value)) - lines.append("---") - return "\n".join(lines) + "\n\n" + body - - -def _parse_simple_yaml(raw: str) -> dict: - data: dict = {} - current_list_key = None - for line in raw.splitlines(): - if not line.strip(): - continue - if line.startswith(" - "): - if current_list_key is None: - raise FrontmatterError(f"List item with no preceding key: {line!r}") - data[current_list_key].append(line.strip()[2:].strip()) - continue - if ":" not in line: - raise FrontmatterError(f"Malformed frontmatter line: {line!r}") - key, _, value = line.partition(":") - key = key.strip() - value = value.strip() - if value == "" or value == "[]": - data[key] = [] - current_list_key = key if value == "" else None - else: - current_list_key = None - if value.lower() in ("true", "false"): - data[key] = value.lower() == "true" - else: - data[key] = value.strip('"').strip("'") - return data - - -def _format_field(key: str, value) -> str: - if isinstance(value, list): - if not value: - return f"{key}: []" - item_lines = "\n".join(f" - {item}" for item in value) - return f"{key}:\n{item_lines}" - if isinstance(value, bool): - return f"{key}: {'true' if value else 'false'}" - return f"{key}: {value}" -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_frontmatter.py -v` -Expected: PASS (3 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/core/frontmatter.py tests/unit/test_frontmatter.py -git commit -m "feat: add hand-rolled frontmatter parser/serializer" -``` - ---- - -### Task 3: `core/identifiers.py` — ADR ID calculation, filenames, slugs - -**Files:** -- Create: `skills/adr-toolkit/scripts/core/identifiers.py` -- Test: `tests/unit/test_identifiers.py` - -**Interfaces:** -- Consumes: nothing. -- Produces: `parse_filename(filename: str) -> Optional[tuple]`, `next_id(adr_dir: Path) -> int`, `format_filename(adr_id: int, slug: str) -> str`, `slugify(title: str) -> str`. Used by Tasks 10 (create), 11 (index), 12 (validate). - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_identifiers.py -from pathlib import Path - -from scripts.core import identifiers - - -def test_parse_filename_extracts_id_and_slug(): - assert identifiers.parse_filename("0003-use-provider-port.md") == (3, "use-provider-port") - - -def test_parse_filename_rejects_non_matching_names(): - assert identifiers.parse_filename("README.md") is None - assert identifiers.parse_filename("3-too-short.md") is None - - -def test_next_id_is_one_past_the_highest_existing(tmp_path): - (tmp_path / "0001-a.md").write_text("x", encoding="utf-8") - (tmp_path / "0003-b.md").write_text("x", encoding="utf-8") - assert identifiers.next_id(tmp_path) == 4 - - -def test_next_id_starts_at_one_for_empty_directory(tmp_path): - assert identifiers.next_id(tmp_path) == 1 - - -def test_format_filename_zero_pads_to_four_digits(): - assert identifiers.format_filename(7, "use-kafka") == "0007-use-kafka.md" - - -def test_slugify_lowercases_and_hyphenates(): - assert identifiers.slugify("Use Kafka for Domain Events!") == "use-kafka-for-domain-events" -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_identifiers.py -v` -Expected: FAIL — module doesn't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -# skills/adr-toolkit/scripts/core/identifiers.py -"""ADR ID calculation, filename parsing, and title slugification.""" -import re -from pathlib import Path -from typing import Optional - -ADR_FILENAME_RE = re.compile(r"^(\d{4})-([a-z0-9]+(?:-[a-z0-9]+)*)\.md$") - - -def parse_filename(filename: str) -> Optional[tuple]: - match = ADR_FILENAME_RE.match(filename) - if not match: - return None - return int(match.group(1)), match.group(2) - - -def next_id(adr_dir: Path) -> int: - existing_ids = [] - for entry in adr_dir.glob("*.md"): - parsed = parse_filename(entry.name) - if parsed: - existing_ids.append(parsed[0]) - return max(existing_ids, default=0) + 1 - - -def format_filename(adr_id: int, slug: str) -> str: - return f"{adr_id:04d}-{slug}.md" - - -def slugify(title: str) -> str: - slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") - return re.sub(r"-+", "-", slug) -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_identifiers.py -v` -Expected: PASS (6 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/core/identifiers.py tests/unit/test_identifiers.py -git commit -m "feat: add ADR id/filename/slug calculation" -``` - ---- - -### Task 4: `core/lifecycle.py` — status transition rules - -**Files:** -- Create: `skills/adr-toolkit/scripts/core/lifecycle.py` -- Test: `tests/unit/test_lifecycle.py` - -**Interfaces:** -- Consumes: nothing. -- Produces: `STATUSES: set`, `validate_transition(current: str, target: str) -> None`, `InvalidTransitionError(ValueError)`. `STATUSES` is consumed by Task 5 (`core/schema.py`). - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_lifecycle.py -import pytest - -from scripts.core import lifecycle - - -def test_proposed_can_become_accepted(): - lifecycle.validate_transition("proposed", "accepted") # must not raise - - -def test_proposed_can_become_rejected(): - lifecycle.validate_transition("proposed", "rejected") # must not raise - - -def test_accepted_cannot_go_back_to_proposed(): - with pytest.raises(lifecycle.InvalidTransitionError): - lifecycle.validate_transition("accepted", "proposed") - - -def test_rejected_is_terminal(): - with pytest.raises(lifecycle.InvalidTransitionError): - lifecycle.validate_transition("rejected", "accepted") - - -def test_unknown_status_is_rejected(): - with pytest.raises(lifecycle.InvalidTransitionError): - lifecycle.validate_transition("accepted", "archived") -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_lifecycle.py -v` -Expected: FAIL — module doesn't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -# skills/adr-toolkit/scripts/core/lifecycle.py -"""ADR status lifecycle rules.""" - -STATUSES = {"proposed", "accepted", "rejected", "deprecated", "superseded"} - -ALLOWED_TRANSITIONS = { - "proposed": {"accepted", "rejected"}, - "accepted": {"deprecated", "superseded"}, - "rejected": set(), - "deprecated": set(), - "superseded": set(), -} - - -class InvalidTransitionError(ValueError): - pass - - -def validate_transition(current: str, target: str) -> None: - if current not in STATUSES: - raise InvalidTransitionError(f"Unknown current status: {current!r}") - if target not in STATUSES: - raise InvalidTransitionError(f"Unknown target status: {target!r}") - if target not in ALLOWED_TRANSITIONS[current]: - raise InvalidTransitionError(f"Cannot transition from {current!r} to {target!r}") -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_lifecycle.py -v` -Expected: PASS (5 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/core/lifecycle.py tests/unit/test_lifecycle.py -git commit -m "feat: add ADR lifecycle transition rules" -``` - ---- - -### Task 5: `core/schema.py` — frontmatter structural validation - -**Files:** -- Create: `skills/adr-toolkit/scripts/core/schema.py` -- Create: `skills/adr-toolkit/schemas/adr.schema.json` (human/tool-readable reference; `core/schema.py` is the enforced source of truth, kept in sync by hand) -- Test: `tests/unit/test_schema.py` - -**Interfaces:** -- Consumes: `scripts.core.lifecycle.STATUSES` (Task 4). -- Produces: `validate_frontmatter(data: dict) -> list`. Used by Tasks 10 (create) and 12 (validate). - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_schema.py -from scripts.core.schema import validate_frontmatter - -VALID = { - "id": "ADR-0001", - "title": "Record architecture decisions", - "status": "accepted", - "date": "2026-08-29", - "decision_makers": [], - "related": [], - "affected_paths": ["docs/decisions/"], - "tags": ["process"], - "retrospective": False, -} - - -def test_valid_frontmatter_has_no_errors(): - assert validate_frontmatter(VALID) == [] - - -def test_missing_field_is_reported(): - data = dict(VALID) - del data["status"] - errors = validate_frontmatter(data) - assert any("status" in e for e in errors) - - -def test_bad_id_format_is_reported(): - data = dict(VALID, id="0001") - errors = validate_frontmatter(data) - assert any("id" in e for e in errors) - - -def test_unknown_status_is_reported(): - data = dict(VALID, status="archived") - errors = validate_frontmatter(data) - assert any("status" in e for e in errors) - - -def test_bad_date_format_is_reported(): - data = dict(VALID, date="29-08-2026") - errors = validate_frontmatter(data) - assert any("date" in e for e in errors) - - -def test_wrong_type_is_reported(): - data = dict(VALID, tags="process") # should be a list - errors = validate_frontmatter(data) - assert any("tags" in e for e in errors) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_schema.py -v` -Expected: FAIL — module doesn't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -# skills/adr-toolkit/scripts/core/schema.py -"""Structural validation for ADR frontmatter. - -schemas/adr.schema.json documents the same shape for external tools; this -module is the version actually enforced at runtime. -""" -from datetime import date as date_cls - -from scripts.core.lifecycle import STATUSES - -REQUIRED_FIELDS = { - "id": str, - "title": str, - "status": str, - "date": str, - "decision_makers": list, - "related": list, - "affected_paths": list, - "tags": list, - "retrospective": bool, -} - -import re - -ID_RE = re.compile(r"^ADR-\d{4}$") -DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") - - -def validate_frontmatter(data: dict) -> list: - errors = [] - for field, expected_type in REQUIRED_FIELDS.items(): - if field not in data: - errors.append(f"missing required field: {field}") - continue - if not isinstance(data[field], expected_type): - errors.append( - f"field {field!r} must be {expected_type.__name__}, " - f"got {type(data[field]).__name__}" - ) - - if isinstance(data.get("id"), str) and not ID_RE.match(data["id"]): - errors.append(f"id {data['id']!r} does not match ADR-NNNN") - - if "status" in data and data["status"] not in STATUSES: - errors.append(f"status {data['status']!r} is not one of {sorted(STATUSES)}") - - if isinstance(data.get("date"), str): - if not DATE_RE.match(data["date"]): - errors.append(f"date {data['date']!r} is not YYYY-MM-DD") - else: - year, month, day = (int(part) for part in data["date"].split("-")) - try: - date_cls(year, month, day) - except ValueError: - errors.append(f"date {data['date']!r} is not a real calendar date") - - return errors -``` - -```json -// skills/adr-toolkit/schemas/adr.schema.json -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "ADR Frontmatter", - "type": "object", - "required": ["id", "title", "status", "date", "decision_makers", "related", "affected_paths", "tags", "retrospective"], - "properties": { - "id": { "type": "string", "pattern": "^ADR-[0-9]{4}$" }, - "title": { "type": "string" }, - "status": { "type": "string", "enum": ["proposed", "accepted", "rejected", "deprecated", "superseded"] }, - "date": { "type": "string", "format": "date" }, - "decision_makers": { "type": "array", "items": { "type": "string" } }, - "related": { "type": "array", "items": { "type": "string" } }, - "affected_paths": { "type": "array", "items": { "type": "string" } }, - "tags": { "type": "array", "items": { "type": "string" } }, - "retrospective": { "type": "boolean" } - } -} -``` - -Delete `skills/adr-toolkit/schemas/.gitkeep` now that the directory has a real file. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_schema.py -v` -Expected: PASS (6 tests) - -- [ ] **Step 5: Commit** - -```bash -git rm skills/adr-toolkit/schemas/.gitkeep -git add skills/adr-toolkit/scripts/core/schema.py skills/adr-toolkit/schemas/adr.schema.json \ - tests/unit/test_schema.py -git commit -m "feat: add ADR frontmatter schema validation" -``` - ---- - -### Task 6: `commands/preflight.py` — environment and existing-convention check - -**Files:** -- Create: `skills/adr-toolkit/scripts/commands/preflight.py` -- Test: `tests/unit/test_preflight.py` - -**Interfaces:** -- Consumes: nothing. -- Produces: `run(args) -> dict` where `args` has an optional `.root` attribute (defaults to `"."`). Return shape: `{"ok": bool, "operation": "preflight", "python_version": str, "git_available": bool, "existing_adr_directory": Optional[str], "warnings": list, "errors": list}`. Used by Task 14 (CLI entrypoint) and Task 17 (fixture test). - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_preflight.py -from types import SimpleNamespace - -from scripts.commands import preflight - - -def test_reports_no_existing_adr_directory(tmp_path): - result = preflight.run(SimpleNamespace(root=str(tmp_path))) - assert result["ok"] is True - assert result["existing_adr_directory"] is None - - -def test_detects_existing_docs_decisions_directory(tmp_path): - (tmp_path / "docs" / "decisions").mkdir(parents=True) - result = preflight.run(SimpleNamespace(root=str(tmp_path))) - assert result["existing_adr_directory"] == "docs/decisions" - - -def test_reports_git_availability(tmp_path): - result = preflight.run(SimpleNamespace(root=str(tmp_path))) - assert isinstance(result["git_available"], bool) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_preflight.py -v` -Expected: FAIL — module doesn't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -# skills/adr-toolkit/scripts/commands/preflight.py -"""Check that the environment has what ADR Toolkit needs to run.""" -import shutil -import sys -from pathlib import Path - -CANDIDATE_ADR_DIRS = ("docs/decisions", "docs/adr", "adr", "decisions") - - -def run(args) -> dict: - errors = [] - - if sys.version_info < (3, 9): - errors.append({"code": "PYTHON_TOO_OLD", "detail": sys.version}) - - git_path = shutil.which("git") - - root = Path(getattr(args, "root", ".")).resolve() - existing_dir = None - for candidate in CANDIDATE_ADR_DIRS: - if (root / candidate).is_dir(): - existing_dir = candidate - break - - warnings = [] - if git_path is None: - warnings.append({"code": "GIT_NOT_FOUND"}) - - return { - "ok": not errors, - "operation": "preflight", - "python_version": sys.version.split()[0], - "git_available": git_path is not None, - "existing_adr_directory": existing_dir, - "warnings": warnings, - "errors": errors, - } -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_preflight.py -v` -Expected: PASS (3 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/commands/preflight.py tests/unit/test_preflight.py -git commit -m "feat: add preflight environment check" -``` - ---- - -### Task 7: `evidence/dependency_scanner.py` — dependency manifest detection - -**Files:** -- Create: `skills/adr-toolkit/scripts/evidence/dependency_scanner.py` -- Test: `tests/unit/test_dependency_scanner.py` - -**Interfaces:** -- Consumes: nothing. -- Produces: `scan(root: Path) -> list` where each item is `{"ecosystem": str, "path": str}`. Used by Task 8 (`commands/discover.py`). - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_dependency_scanner.py -from pathlib import Path - -from scripts.evidence import dependency_scanner - - -def test_detects_npm_manifest(tmp_path): - (tmp_path / "package.json").write_text("{}", encoding="utf-8") - findings = dependency_scanner.scan(tmp_path) - assert {"ecosystem": "npm", "path": "package.json"} in findings - - -def test_detects_multiple_manifests(tmp_path): - (tmp_path / "pyproject.toml").write_text("", encoding="utf-8") - (tmp_path / "go.mod").write_text("", encoding="utf-8") - findings = dependency_scanner.scan(tmp_path) - ecosystems = {f["ecosystem"] for f in findings} - assert ecosystems == {"python", "go"} - - -def test_no_manifests_returns_empty_list(tmp_path): - assert dependency_scanner.scan(tmp_path) == [] -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_dependency_scanner.py -v` -Expected: FAIL — module doesn't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -# skills/adr-toolkit/scripts/evidence/dependency_scanner.py -"""Detect dependency manifest files that hint at technology choices.""" -from pathlib import Path - -MANIFEST_FILES = { - "package.json": "npm", - "pyproject.toml": "python", - "requirements.txt": "python", - "pom.xml": "maven", - "build.gradle": "gradle", - "build.gradle.kts": "gradle", - "go.mod": "go", - "Cargo.toml": "cargo", -} - - -def scan(root: Path) -> list: - findings = [] - for filename, ecosystem in MANIFEST_FILES.items(): - path = root / filename - if path.is_file(): - findings.append({"ecosystem": ecosystem, "path": filename}) - return findings -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_dependency_scanner.py -v` -Expected: PASS (3 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/evidence/dependency_scanner.py tests/unit/test_dependency_scanner.py -git commit -m "feat: add dependency manifest evidence scanner" -``` - ---- - -### Task 8: `commands/discover.py` — repository evidence collection - -**Files:** -- Create: `skills/adr-toolkit/scripts/commands/discover.py` -- Test: `tests/unit/test_discover.py` - -**Interfaces:** -- Consumes: `scripts.evidence.dependency_scanner.scan` (Task 7). -- Produces: `run(args) -> dict` where `args.root` defaults to `"."`. Return shape: `{"ok": True, "operation": "discover", "root": str, "dependencies": list, "warnings": list}`. Used by Task 14 (CLI) and Task 17 (fixture test). - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_discover.py -from types import SimpleNamespace - -from scripts.commands import discover - - -def test_discover_reports_dependency_findings(tmp_path): - (tmp_path / "package.json").write_text("{}", encoding="utf-8") - result = discover.run(SimpleNamespace(root=str(tmp_path))) - assert result["ok"] is True - assert {"ecosystem": "npm", "path": "package.json"} in result["dependencies"] - - -def test_discover_on_empty_repo_reports_no_dependencies(tmp_path): - result = discover.run(SimpleNamespace(root=str(tmp_path))) - assert result["dependencies"] == [] -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_discover.py -v` -Expected: FAIL — module doesn't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -# skills/adr-toolkit/scripts/commands/discover.py -"""Scan the repository for evidence of existing conventions and past decisions.""" -from pathlib import Path - -from scripts.evidence import dependency_scanner - - -def run(args) -> dict: - root = Path(getattr(args, "root", ".")).resolve() - dependencies = dependency_scanner.scan(root) - - return { - "ok": True, - "operation": "discover", - "root": str(root), - "dependencies": dependencies, - "warnings": [], - } -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_discover.py -v` -Expected: PASS (2 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/commands/discover.py tests/unit/test_discover.py -git commit -m "feat: add discover command orchestrating evidence scanners" -``` - ---- - -### Task 9: MADR templates (minimal and full) - -**Files:** -- Create: `skills/adr-toolkit/templates/madr-minimal.md` -- Create: `skills/adr-toolkit/templates/madr-full.md` -- Test: `tests/unit/test_templates.py` - -**Interfaces:** -- Consumes: nothing. -- Produces: two template files referenced by `SKILL.md` (Task 15) when the agent drafts ADR bodies. Not imported by Python code. - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_templates.py -from pathlib import Path - -TEMPLATES = Path(__file__).resolve().parent.parent.parent / "skills" / "adr-toolkit" / "templates" - -REQUIRED_MINIMAL_SECTIONS = [ - "## Context and Problem Statement", - "## Considered Options", - "## Decision Outcome", - "## Consequences", - "## Confirmation", - "## Revisit Triggers", -] - -REQUIRED_FULL_SECTIONS = REQUIRED_MINIMAL_SECTIONS + [ - "## Decision Drivers", - "## Pros and Cons of the Options", -] - - -def test_minimal_template_has_required_sections(): - text = (TEMPLATES / "madr-minimal.md").read_text(encoding="utf-8") - for section in REQUIRED_MINIMAL_SECTIONS: - assert section in text, f"missing {section}" - - -def test_full_template_has_required_sections(): - text = (TEMPLATES / "madr-full.md").read_text(encoding="utf-8") - for section in REQUIRED_FULL_SECTIONS: - assert section in text, f"missing {section}" -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_templates.py -v` -Expected: FAIL — files don't exist. - -- [ ] **Step 3: Write the template files** - -```markdown - -# {title} - -## Context and Problem Statement - -{problem and constraints} - -## Considered Options - -* {option one} -* {option two} - -## Decision Outcome - -Chosen option: **{chosen option}**, because {rationale}. - -## Consequences - -* Good: {positive consequence} -* Bad: {negative consequence} - -## Confirmation - -{how implementation will be verified} - -## Revisit Triggers - -* {condition that should reopen the decision} -``` - -```markdown - -# {title} - -## Context and Problem Statement - -{problem and constraints} - -## Decision Drivers - -* {driver one} -* {driver two} - -## Considered Options - -* {option one} -* {option two} -* {option three} - -## Decision Outcome - -Chosen option: **{chosen option}**, because {rationale}. - -### Consequences - -* Good: {positive consequence} -* Bad: {negative consequence} - -### Confirmation - -{how implementation will be verified} - -## Pros and Cons of the Options - -### {option one} - -* Good, because {argument} -* Bad, because {argument} - -### {option two} - -* Good, because {argument} -* Bad, because {argument} - -## Revisit Triggers - -* {condition that should reopen the decision} -``` - -Delete `skills/adr-toolkit/templates/.gitkeep`. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_templates.py -v` -Expected: PASS (2 tests) - -- [ ] **Step 5: Commit** - -```bash -git rm skills/adr-toolkit/templates/.gitkeep -git add skills/adr-toolkit/templates/madr-minimal.md skills/adr-toolkit/templates/madr-full.md \ - tests/unit/test_templates.py -git commit -m "feat: add minimal and full MADR templates" -``` - ---- - -### Task 10: `commands/init.py` — scaffold a new ADR directory - -**Files:** -- Create: `skills/adr-toolkit/scripts/commands/init.py` -- Test: `tests/unit/test_init.py` - -**Interfaces:** -- Consumes: `skills/adr-toolkit/templates/madr-minimal.md` (Task 9, copied as-is into the new directory). -- Produces: `run(args) -> dict` where `args` has `.dir` (str) and optional `.dry_run` (bool). Return shape on success: `{"ok": True, "operation": "init", "dry_run": bool, "created"/"would_create": list}`; on conflict: `{"ok": False, "operation": "init", "errors": [{"code": "ADR_DIRECTORY_NOT_EMPTY", "path": str}]}`. Used by Task 14 (CLI) and Task 17 (fixture test). - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_init.py -from types import SimpleNamespace - -from scripts.commands import init - - -def test_dry_run_reports_would_create_without_writing(tmp_path): - adr_dir = tmp_path / "docs" / "decisions" - result = init.run(SimpleNamespace(dir=str(adr_dir), dry_run=True)) - assert result["ok"] is True - assert result["dry_run"] is True - assert not adr_dir.exists() - - -def test_creates_directory_template_and_first_adr(tmp_path): - adr_dir = tmp_path / "docs" / "decisions" - result = init.run(SimpleNamespace(dir=str(adr_dir), dry_run=False)) - assert result["ok"] is True - assert (adr_dir / "adr-template.md").is_file() - first_adr = adr_dir / "0001-record-architecture-decisions.md" - assert first_adr.is_file() - content = first_adr.read_text(encoding="utf-8") - assert "id: ADR-0001" in content - assert "status: accepted" in content - - -def test_refuses_to_run_on_non_empty_directory(tmp_path): - adr_dir = tmp_path / "docs" / "decisions" - adr_dir.mkdir(parents=True) - (adr_dir / "existing.md").write_text("x", encoding="utf-8") - - result = init.run(SimpleNamespace(dir=str(adr_dir), dry_run=False)) - assert result["ok"] is False - assert result["errors"][0]["code"] == "ADR_DIRECTORY_NOT_EMPTY" -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_init.py -v` -Expected: FAIL — module doesn't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -# skills/adr-toolkit/scripts/commands/init.py -"""Scaffold an ADR directory for a repository that has none yet.""" -import shutil -from datetime import date -from pathlib import Path - -TEMPLATE_SOURCE = Path(__file__).resolve().parent.parent.parent / "templates" / "madr-minimal.md" - -INITIAL_ADR_BODY = """# Record architecture decisions - -## Context and Problem Statement - -We need a consistent way to capture and communicate significant -architectural decisions so future contributors (human or agent) can find -the reasoning behind them. - -## Considered Options - -* No formal record, rely on commit messages and memory -* Wiki or external documentation tool -* Architecture Decision Records stored alongside the code - -## Decision Outcome - -Chosen option: **Architecture Decision Records stored alongside the code**, because they version with the code, stay close to what they describe, and are readable by both humans and coding agents. - -## Consequences - -* Good: decisions and their rationale are discoverable in the repository itself. -* Bad: requires discipline to keep records up to date as decisions evolve. - -## Confirmation - -* [ ] `docs/decisions/` exists with this file, a template, and an index. - -## Revisit Triggers - -* The team adopts a different documentation system project-wide. -""" - - -def run(args) -> dict: - adr_dir = Path(args.dir) - dry_run = getattr(args, "dry_run", False) - - if adr_dir.exists() and any(adr_dir.iterdir()): - return { - "ok": False, - "operation": "init", - "errors": [{"code": "ADR_DIRECTORY_NOT_EMPTY", "path": str(adr_dir)}], - } - - would_create = [ - str(adr_dir), - str(adr_dir / "adr-template.md"), - str(adr_dir / "0001-record-architecture-decisions.md"), - ] - - if dry_run: - return {"ok": True, "operation": "init", "dry_run": True, "would_create": would_create} - - adr_dir.mkdir(parents=True, exist_ok=True) - shutil.copyfile(TEMPLATE_SOURCE, adr_dir / "adr-template.md") - - frontmatter_block = ( - "---\n" - "id: ADR-0001\n" - "title: Record architecture decisions\n" - "status: accepted\n" - f"date: {date.today().isoformat()}\n" - "decision_makers: []\n" - "related: []\n" - "affected_paths:\n" - " - docs/decisions/\n" - "tags:\n" - " - process\n" - "retrospective: false\n" - "---\n\n" - ) - (adr_dir / "0001-record-architecture-decisions.md").write_text( - frontmatter_block + INITIAL_ADR_BODY, encoding="utf-8" - ) - - return {"ok": True, "operation": "init", "dry_run": False, "created": would_create} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_init.py -v` -Expected: PASS (3 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/commands/init.py tests/unit/test_init.py -git commit -m "feat: add init command to scaffold ADR directory" -``` - ---- - -### Task 11: `commands/create.py` — write a new ADR from an approved draft - -**Files:** -- Create: `skills/adr-toolkit/scripts/commands/create.py` -- Test: `tests/unit/test_create.py` - -**Interfaces:** -- Consumes: `scripts.core.frontmatter.serialize` (Task 2), `scripts.core.identifiers.{next_id, slugify, format_filename}` (Task 3), `scripts.core.schema.validate_frontmatter` (Task 5). -- Produces: `run(args) -> dict` where `args` has `.input` (path to draft JSON), `.dir` (ADR directory), optional `.dry_run`. The draft JSON must have `title`, `status`, `body` (pre-rendered markdown from the agent); optional `date`, `decision_makers`, `related`, `affected_paths`, `tags`, `retrospective`. Return shape on success: `{"ok": True, "operation": "create", "dry_run": bool, "created"/"would_create": str, "id": str}`. Used by Task 14 (CLI) and Task 17 (fixture test). - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_create.py -import json -from pathlib import Path -from types import SimpleNamespace - -from scripts.commands import create - - -def _write_draft(tmp_path, **overrides): - draft = { - "title": "Use Kafka for domain events", - "status": "accepted", - "body": "# Use Kafka for domain events\n\nBody text.\n", - } - draft.update(overrides) - draft_path = tmp_path / "draft.json" - draft_path.write_text(json.dumps(draft), encoding="utf-8") - return draft_path - - -def test_creates_file_with_next_id_and_valid_frontmatter(tmp_path): - adr_dir = tmp_path / "docs" / "decisions" - adr_dir.mkdir(parents=True) - draft_path = _write_draft(tmp_path) - - result = create.run(SimpleNamespace(input=str(draft_path), dir=str(adr_dir), dry_run=False)) - - assert result["ok"] is True - assert result["id"] == "ADR-0001" - created_file = Path(result["created"]) - assert created_file.name == "0001-use-kafka-for-domain-events.md" - assert "status: accepted" in created_file.read_text(encoding="utf-8") - - -def test_next_id_accounts_for_existing_adrs(tmp_path): - adr_dir = tmp_path / "docs" / "decisions" - adr_dir.mkdir(parents=True) - (adr_dir / "0001-existing.md").write_text("x", encoding="utf-8") - draft_path = _write_draft(tmp_path) - - result = create.run(SimpleNamespace(input=str(draft_path), dir=str(adr_dir), dry_run=False)) - - assert result["id"] == "ADR-0002" - - -def test_dry_run_does_not_write_file(tmp_path): - adr_dir = tmp_path / "docs" / "decisions" - adr_dir.mkdir(parents=True) - draft_path = _write_draft(tmp_path) - - result = create.run(SimpleNamespace(input=str(draft_path), dir=str(adr_dir), dry_run=True)) - - assert result["ok"] is True - assert result["dry_run"] is True - assert list(adr_dir.iterdir()) == [] - - -def test_missing_required_draft_field_is_an_error(tmp_path): - adr_dir = tmp_path / "docs" / "decisions" - adr_dir.mkdir(parents=True) - draft_path = tmp_path / "draft.json" - draft_path.write_text(json.dumps({"title": "Missing status and body"}), encoding="utf-8") - - result = create.run(SimpleNamespace(input=str(draft_path), dir=str(adr_dir), dry_run=False)) - - assert result["ok"] is False - assert result["errors"][0]["code"] == "MISSING_DRAFT_FIELD" -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_create.py -v` -Expected: FAIL — module doesn't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -# skills/adr-toolkit/scripts/commands/create.py -"""Assign the next ADR ID and write a new ADR file from an approved draft.""" -import json -from datetime import date -from pathlib import Path - -from scripts.core import frontmatter as fm -from scripts.core import identifiers -from scripts.core.schema import validate_frontmatter - -REQUIRED_DRAFT_FIELDS = {"title", "status", "body"} - - -def run(args) -> dict: - draft_path = Path(args.input) - adr_dir = Path(args.dir) - dry_run = getattr(args, "dry_run", False) - - draft = json.loads(draft_path.read_text(encoding="utf-8")) - missing = REQUIRED_DRAFT_FIELDS - draft.keys() - if missing: - return { - "ok": False, - "operation": "create", - "errors": [{"code": "MISSING_DRAFT_FIELD", "fields": sorted(missing)}], - } - - adr_dir.mkdir(parents=True, exist_ok=True) - next_num = identifiers.next_id(adr_dir) - slug = identifiers.slugify(draft["title"]) - filename = identifiers.format_filename(next_num, slug) - target = adr_dir / filename - - if target.exists(): - return { - "ok": False, - "operation": "create", - "errors": [{"code": "FILE_ALREADY_EXISTS", "path": str(target)}], - } - - frontmatter_data = { - "id": f"ADR-{next_num:04d}", - "title": draft["title"], - "status": draft["status"], - "date": draft.get("date") or date.today().isoformat(), - "decision_makers": draft.get("decision_makers", []), - "related": draft.get("related", []), - "affected_paths": draft.get("affected_paths", []), - "tags": draft.get("tags", []), - "retrospective": draft.get("retrospective", False), - } - - schema_errors = validate_frontmatter(frontmatter_data) - if schema_errors: - return { - "ok": False, - "operation": "create", - "errors": [{"code": "SCHEMA_ERROR", "detail": e} for e in schema_errors], - } - - content = fm.serialize(frontmatter_data, draft["body"].strip() + "\n") - - if dry_run: - return {"ok": True, "operation": "create", "dry_run": True, "would_create": str(target)} - - target.write_text(content, encoding="utf-8") - - return { - "ok": True, - "operation": "create", - "dry_run": False, - "created": str(target), - "id": frontmatter_data["id"], - } -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_create.py -v` -Expected: PASS (4 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/commands/create.py tests/unit/test_create.py -git commit -m "feat: add create command to write approved ADR drafts" -``` - ---- - -### Task 12: `commands/index.py` — multi-view decision log - -**Files:** -- Create: `skills/adr-toolkit/scripts/commands/index.py` -- Test: `tests/unit/test_index.py` - -**Interfaces:** -- Consumes: `scripts.core.frontmatter.parse` (Task 2), `scripts.core.identifiers.parse_filename` (Task 3). -- Produces: `run(args) -> dict` where `args.dir` is the ADR directory. Return shape: `{"ok": True, "operation": "index", "count": int, "path": str}`. Writes `README.md` inside the ADR directory with By-status / By-tag / By-affected-path / Chronological sections. Used by Task 14 (CLI) and Task 17 (fixture test). - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_index.py -from types import SimpleNamespace - -from scripts.commands import index - - -def _write_adr(adr_dir, filename, *, id_, title, status, date, tags, affected_paths): - tags_block = "\n".join(f" - {t}" for t in tags) or None - paths_block = "\n".join(f" - {p}" for p in affected_paths) or None - text = ( - "---\n" - f"id: {id_}\n" - f"title: {title}\n" - f"status: {status}\n" - f"date: {date}\n" - "decision_makers: []\n" - "related: []\n" - f"affected_paths:\n{paths_block}\n" - f"tags:\n{tags_block}\n" - "retrospective: false\n" - "---\n\n" - f"# {title}\n" - ) - (adr_dir / filename).write_text(text, encoding="utf-8") - - -def test_index_generates_readme_with_all_views(tmp_path): - adr_dir = tmp_path - _write_adr( - adr_dir, "0001-use-kafka.md", - id_="ADR-0001", title="Use Kafka", status="accepted", date="2026-08-01", - tags=["architecture"], affected_paths=["src/events/"], - ) - _write_adr( - adr_dir, "0002-use-postgres.md", - id_="ADR-0002", title="Use Postgres", status="proposed", date="2026-08-15", - tags=["architecture", "data"], affected_paths=["src/db/"], - ) - - result = index.run(SimpleNamespace(dir=str(adr_dir))) - - assert result["ok"] is True - assert result["count"] == 2 - readme = (adr_dir / "README.md").read_text(encoding="utf-8") - assert "## By status" in readme - assert "## By tag" in readme - assert "## By affected path" in readme - assert "## Chronological" in readme - assert "ADR-0001" in readme and "ADR-0002" in readme - assert "`src/events/`" in readme - - -def test_index_skips_readme_and_template_files(tmp_path): - (tmp_path / "adr-template.md").write_text("not an ADR", encoding="utf-8") - result = index.run(SimpleNamespace(dir=str(tmp_path))) - assert result["count"] == 0 -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_index.py -v` -Expected: FAIL — module doesn't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -# skills/adr-toolkit/scripts/commands/index.py -"""Regenerate the multi-view ADR index (README.md) for a decision directory.""" -from pathlib import Path - -from scripts.core import frontmatter as fm -from scripts.core import identifiers - -SKIP_FILES = {"README.md", "adr-template.md"} - - -def run(args) -> dict: - adr_dir = Path(args.dir) - entries = [] - - for entry in sorted(adr_dir.glob("*.md")): - if entry.name in SKIP_FILES: - continue - parsed = identifiers.parse_filename(entry.name) - if parsed is None: - continue - data, _ = fm.parse(entry.read_text(encoding="utf-8")) - entries.append({ - "id": data.get("id", f"ADR-{parsed[0]:04d}"), - "filename": entry.name, - "title": data.get("title", parsed[1]), - "status": data.get("status", "unknown"), - "date": data.get("date", ""), - "tags": data.get("tags", []), - "affected_paths": data.get("affected_paths", []), - }) - - (adr_dir / "README.md").write_text(_render(entries), encoding="utf-8") - - return {"ok": True, "operation": "index", "count": len(entries), "path": str(adr_dir / "README.md")} - - -def _render(entries: list) -> str: - lines = ["# Decision Log", ""] - - lines.append("## By status") - lines.append("") - by_status: dict = {} - for entry in entries: - by_status.setdefault(entry["status"], []).append(entry) - for status in sorted(by_status): - lines.append(f"### {status.capitalize()}") - for entry in sorted(by_status[status], key=lambda e: e["filename"]): - lines.append(f"- [{entry['id']} — {entry['title']}]({entry['filename']})") - lines.append("") - - lines.append("## By tag") - lines.append("") - by_tag: dict = {} - for entry in entries: - for tag in entry["tags"]: - by_tag.setdefault(tag, []).append(entry) - for tag in sorted(by_tag): - lines.append(f"### {tag}") - for entry in sorted(by_tag[tag], key=lambda e: e["filename"]): - lines.append(f"- [{entry['id']} — {entry['title']}]({entry['filename']})") - lines.append("") - - lines.append("## By affected path") - lines.append("") - by_path: dict = {} - for entry in entries: - for path in entry["affected_paths"]: - by_path.setdefault(path, []).append(entry) - for path in sorted(by_path): - lines.append(f"### `{path}`") - for entry in sorted(by_path[path], key=lambda e: e["filename"]): - lines.append(f"- [{entry['id']} — {entry['title']}]({entry['filename']})") - lines.append("") - - lines.append("## Chronological (newest first)") - lines.append("") - for entry in sorted(entries, key=lambda e: e["date"], reverse=True): - lines.append(f"- {entry['date']} — [{entry['id']} — {entry['title']}]({entry['filename']})") - - return "\n".join(lines) + "\n" -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_index.py -v` -Expected: PASS (2 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/commands/index.py tests/unit/test_index.py -git commit -m "feat: add multi-view decision log index command" -``` - ---- - -### Task 13: `commands/validate.py` — structural integrity checks - -**Files:** -- Create: `skills/adr-toolkit/scripts/commands/validate.py` -- Test: `tests/unit/test_validate.py` - -**Interfaces:** -- Consumes: `scripts.core.frontmatter.parse` (Task 2), `scripts.core.identifiers.parse_filename` (Task 3), `scripts.core.schema.validate_frontmatter` (Task 5). -- Produces: `run(args) -> dict` where `args.dir` is the ADR directory. Return shape: `{"ok": bool, "operation": "validate", "checked": int, "errors": list}`. Used by Task 14 (CLI) and Task 17 (fixture test). - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_validate.py -from types import SimpleNamespace - -from scripts.commands import validate - -VALID_ADR = ( - "---\n" - "id: ADR-0001\n" - "title: Record architecture decisions\n" - "status: accepted\n" - "date: 2026-08-29\n" - "decision_makers: []\n" - "related: []\n" - "affected_paths:\n" - " - docs/decisions/\n" - "tags:\n" - " - process\n" - "retrospective: false\n" - "---\n\n" - "# Record architecture decisions\n" -) - - -def test_valid_directory_passes(tmp_path): - (tmp_path / "0001-record-architecture-decisions.md").write_text(VALID_ADR, encoding="utf-8") - result = validate.run(SimpleNamespace(dir=str(tmp_path))) - assert result["ok"] is True - assert result["checked"] == 1 - assert result["errors"] == [] - - -def test_duplicate_ids_are_reported(tmp_path): - (tmp_path / "0001-a.md").write_text(VALID_ADR, encoding="utf-8") - duplicate = VALID_ADR.replace("title: Record architecture decisions", "title: A duplicate") - (tmp_path / "0002-b.md").write_text(duplicate, encoding="utf-8") - - result = validate.run(SimpleNamespace(dir=str(tmp_path))) - - assert result["ok"] is False - assert any(e["code"] == "DUPLICATE_ADR_ID" for e in result["errors"]) - - -def test_broken_related_link_is_reported(tmp_path): - broken = VALID_ADR.replace("related: []", "related:\n - ADR-0099") - (tmp_path / "0001-a.md").write_text(broken, encoding="utf-8") - - result = validate.run(SimpleNamespace(dir=str(tmp_path))) - - assert result["ok"] is False - assert any(e["code"] == "BROKEN_RELATED_LINK" for e in result["errors"]) - - -def test_bad_filename_is_reported(tmp_path): - (tmp_path / "not-a-valid-name.md").write_text(VALID_ADR, encoding="utf-8") - result = validate.run(SimpleNamespace(dir=str(tmp_path))) - assert any(e["code"] == "BAD_FILENAME" for e in result["errors"]) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_validate.py -v` -Expected: FAIL — module doesn't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -# skills/adr-toolkit/scripts/commands/validate.py -"""Validate ADR directory structural integrity: IDs, frontmatter, and links.""" -from pathlib import Path - -from scripts.core import frontmatter as fm -from scripts.core import identifiers -from scripts.core.schema import validate_frontmatter - -SKIP_FILES = {"README.md", "adr-template.md"} - - -def run(args) -> dict: - adr_dir = Path(args.dir) - errors = [] - - adr_files = sorted(p for p in adr_dir.glob("*.md") if p.name not in SKIP_FILES) - - parsed_entries = [] - seen_ids: dict = {} - - for path in adr_files: - if identifiers.parse_filename(path.name) is None: - errors.append({"code": "BAD_FILENAME", "file": path.name}) - continue - - try: - data, _ = fm.parse(path.read_text(encoding="utf-8")) - except fm.FrontmatterError as exc: - errors.append({"code": "BAD_FRONTMATTER", "file": path.name, "detail": str(exc)}) - continue - - for detail in validate_frontmatter(data): - errors.append({"code": "SCHEMA_ERROR", "file": path.name, "detail": detail}) - - adr_id = data.get("id") - if adr_id: - if adr_id in seen_ids: - errors.append({"code": "DUPLICATE_ADR_ID", "files": [seen_ids[adr_id], path.name]}) - else: - seen_ids[adr_id] = path.name - - parsed_entries.append((path.name, data)) - - known_ids = set(seen_ids.keys()) - for filename, data in parsed_entries: - for related_id in data.get("related", []): - if related_id not in known_ids: - errors.append({"code": "BROKEN_RELATED_LINK", "file": filename, "related_id": related_id}) - - return {"ok": not errors, "operation": "validate", "checked": len(adr_files), "errors": errors} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_validate.py -v` -Expected: PASS (4 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/commands/validate.py tests/unit/test_validate.py -git commit -m "feat: add validate command for ADR structural integrity" -``` - ---- - -### Task 14: `scripts/adr.py` — single CLI entrypoint - -**Files:** -- Create: `skills/adr-toolkit/scripts/adr.py` -- Test: `tests/integration/test_cli.py` - -**Interfaces:** -- Consumes: `scripts.commands.{preflight, discover, init, create, index, validate}.run` (Tasks 6, 8, 10, 11, 12, 13). -- Produces: an executable script (`python scripts/adr.py [--json] ...`) printing one JSON object to stdout and exiting 0 on `ok: true`, 1 otherwise. This is what `SKILL.md` (Task 15) and the fixture test (Task 17) actually invoke. - -- [ ] **Step 1: Write the failing test** - -```python -# tests/integration/test_cli.py -import json -import subprocess -import sys -from pathlib import Path - -ADR_PY = Path(__file__).resolve().parent.parent.parent / "skills" / "adr-toolkit" / "scripts" / "adr.py" - - -def _run(args, cwd): - return subprocess.run( - [sys.executable, str(ADR_PY), *args], cwd=cwd, capture_output=True, text=True, - ) - - -def test_preflight_returns_valid_json(tmp_path): - result = _run(["preflight", "--json"], cwd=tmp_path) - assert result.returncode == 0 - payload = json.loads(result.stdout) - assert payload["operation"] == "preflight" - assert payload["ok"] is True - - -def test_init_then_validate_round_trip(tmp_path): - init_result = _run(["init", "--dir", "docs/decisions", "--json"], cwd=tmp_path) - assert init_result.returncode == 0 - - validate_result = _run(["validate", "--dir", "docs/decisions", "--json"], cwd=tmp_path) - payload = json.loads(validate_result.stdout) - assert validate_result.returncode == 0 - assert payload["ok"] is True - assert payload["checked"] == 1 - - -def test_unknown_subcommand_exits_nonzero(tmp_path): - result = _run(["not-a-real-command"], cwd=tmp_path) - assert result.returncode != 0 -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/integration/test_cli.py -v` -Expected: FAIL — `scripts/adr.py` doesn't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -#!/usr/bin/env python3 -# skills/adr-toolkit/scripts/adr.py -"""Single entrypoint for all ADR Toolkit deterministic operations.""" -import argparse -import json -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from scripts.commands import preflight, discover, init, create, index, validate - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(prog="adr.py") - sub = parser.add_subparsers(dest="operation", required=True) - - p_preflight = sub.add_parser("preflight") - p_preflight.add_argument("--json", action="store_true") - p_preflight.add_argument("--root", default=".") - - p_discover = sub.add_parser("discover") - p_discover.add_argument("--json", action="store_true") - p_discover.add_argument("--root", default=".") - - p_init = sub.add_parser("init") - p_init.add_argument("--dir", default="docs/decisions") - p_init.add_argument("--dry-run", dest="dry_run", action="store_true") - p_init.add_argument("--json", action="store_true") - - p_create = sub.add_parser("create") - p_create.add_argument("--input", required=True) - p_create.add_argument("--dir", default="docs/decisions") - p_create.add_argument("--dry-run", dest="dry_run", action="store_true") - p_create.add_argument("--json", action="store_true") - - p_index = sub.add_parser("index") - p_index.add_argument("--dir", default="docs/decisions") - p_index.add_argument("--json", action="store_true") - - p_validate = sub.add_parser("validate") - p_validate.add_argument("--dir", default="docs/decisions") - p_validate.add_argument("--json", action="store_true") - - return parser - - -HANDLERS = { - "preflight": preflight.run, - "discover": discover.run, - "init": init.run, - "create": create.run, - "index": index.run, - "validate": validate.run, -} - - -def main(argv=None) -> int: - parser = build_parser() - args = parser.parse_args(argv) - - result = HANDLERS[args.operation](args) - print(json.dumps(result, indent=2, ensure_ascii=False)) - return 0 if result.get("ok") else 1 - - -if __name__ == "__main__": - sys.exit(main()) -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/integration/test_cli.py -v` -Expected: PASS (3 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/adr.py tests/integration/test_cli.py -git commit -m "feat: add single CLI entrypoint dispatching ADR subcommands" -``` - ---- - -### Task 15: `SKILL.md` and INIT-supporting references - -**Files:** -- Create: `skills/adr-toolkit/SKILL.md` -- Create: `skills/adr-toolkit/references/lifecycle.md` -- Create: `skills/adr-toolkit/references/madr-guide.md` -- Test: `tests/unit/test_skill_manifest.py` - -**Interfaces:** -- Consumes: `scripts.core.frontmatter.parse` (Task 2, reused by the test to check `SKILL.md`'s own frontmatter). -- Produces: the skill's user-facing contract. Consumed by Task 16 (Claude adapter) and by any harness that loads this skill. - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_skill_manifest.py -from pathlib import Path - -from scripts.core import frontmatter as fm - -SKILL_MD = Path(__file__).resolve().parent.parent.parent / "skills" / "adr-toolkit" / "SKILL.md" -REFERENCES = SKILL_MD.parent / "references" - - -def test_skill_md_frontmatter_has_required_fields(): - data, body = fm.parse(SKILL_MD.read_text(encoding="utf-8")) - assert data["name"] == "adr-toolkit" - assert data["user-invocable"] is True - assert "description" in data - assert "version" in data - - -def test_skill_md_documents_the_workflow_stages(): - _, body = fm.parse(SKILL_MD.read_text(encoding="utf-8")) - for stage in ["PREFLIGHT", "DISCOVER", "CLASSIFY", "ASK-IF-NEEDED", "PLAN", "CONFIRM", "MUTATE", "VALIDATE", "REPORT"]: - assert stage in body - - -def test_skill_md_separates_init_and_discover_operations(): - _, body = fm.parse(SKILL_MD.read_text(encoding="utf-8")) - assert "## INIT" in body - assert "## DISCOVER" in body - assert "## What belongs in an ADR" in body - - -def test_skill_md_requires_evidence_inference_separation_for_retrospective_adrs(): - _, body = fm.parse(SKILL_MD.read_text(encoding="utf-8")) - assert "Confirmed Evidence" in body - assert "Inferred Rationale" in body - assert "## Unknown" in body - - -def test_reference_files_exist(): - assert (REFERENCES / "lifecycle.md").is_file() - assert (REFERENCES / "madr-guide.md").is_file() -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_skill_manifest.py -v` -Expected: FAIL — files don't exist. - -- [ ] **Step 3: Write the content** - -```markdown - ---- -name: adr-toolkit -description: > - Initialize, record, and check Architecture Decision Records by inspecting - the repository and existing decisions before asking questions. -user-invocable: true -version: 0.1.0 ---- - -# ADR Toolkit - -## Workflow contract - -Every operation follows the same nine stages: - -```text -PREFLIGHT -→ DISCOVER -→ CLASSIFY -→ ASK-IF-NEEDED -→ PLAN -→ CONFIRM -→ MUTATE -→ VALIDATE -→ REPORT -``` - -## INIT (scaffolding only) - -Use INIT when a repository has no ADR directory yet. INIT does not mine -history — it only sets up the structure. Run DISCOVER afterward (or any -time later) to recover past decisions. - -1. **PREFLIGHT** — run `python scripts/adr.py preflight --json`. If - `existing_adr_directory` is set, stop and tell the user an ADR directory - already exists; do not scaffold a second one. -2. **CONFIRM** — show the user the exact directory that will be created, - before writing anything. -3. **MUTATE** — run `python scripts/adr.py init --dir docs/decisions` to - scaffold the directory, template, and ADR-0001. -4. **VALIDATE** — run `python scripts/adr.py validate --dir docs/decisions --json` - and `python scripts/adr.py index --dir docs/decisions --json`. -5. **REPORT** — tell the user INIT is done and that they can run DISCOVER - next if they want to recover past decisions from the repository's - history. - -## DISCOVER (past-decision recovery) - -Use DISCOVER on a repository that already has an ADR directory (run INIT -first if it doesn't). DISCOVER can be run once right after INIT, skipped -entirely, or re-run later to mine more of the history incrementally. - -1. **PREFLIGHT** — run `python scripts/adr.py preflight --json`. If - `existing_adr_directory` is `null`, stop and tell the user to run INIT - first. -2. **GATHER EVIDENCE** — run `python scripts/adr.py discover --json` from - the repository root. Read the `dependencies` list; each entry is - candidate evidence for a past architectural decision (e.g. `pom.xml` - suggests a JVM build-tool decision was made, even if undocumented). -3. **CLASSIFY** — for each finding, decide whether it looks structural (a - database driver, a message broker, a web framework) versus routine - tooling (a linter, a test runner), using the table below. Only - structural choices are candidates. -4. **ASK-IF-NEEDED** — for each candidate, ask the user at most one - question: why was this chosen, only if the reason is not evident from - comments, README, or commit history. Do not ask about anything - `discover` already reported as a fact. -5. **PLAN** — draft a `retrospective: true` MADR body for each ADR the user - wants recorded, following `templates/madr-minimal.md` (see - `references/madr-guide.md` for when to use the full template instead). - Every retrospective body MUST contain three separate subsections, never - merged into one narrative: - - ```markdown - ## Confirmed Evidence - - * {only facts `discover` or the user's own words actually established} - - ## Inferred Rationale - - * {the agent's best guess at *why*, explicitly labeled as a guess} - - ## Unknown - - * {anything about the original decision that cannot be recovered from this repository} - ``` - -6. **CONFIRM** — show the user each candidate's title, confirmed evidence, - and inferred rationale before writing anything; the user can drop any - candidate. -7. **MUTATE** — for each approved candidate, write a draft JSON file - (`title`, `status`, `body` — body includes the three-part structure - above — plus any of `date`/`decision_makers`/`related`/ - `affected_paths`/`tags`/`retrospective`) and run - `python scripts/adr.py create --input --dir docs/decisions`. -8. **VALIDATE** — same as INIT step 4. If validate reports errors, fix the - draft and re-run `create` — never hand-edit the generated file to patch - a validation error. -9. **REPORT** — tell the user what was created, in this order: facts - found, judgment, questions asked, files created, validation result, - remaining uncertainty. - -## What belongs in an ADR - -Not every structural-looking finding deserves a new file. Apply this table -during CLASSIFY, in both INIT/DISCOVER and (once built) RECORD: - -| Content | Where it belongs | -|---|---| -| Structural, long-lived decision | ADR | -| Feature implementation detail | Pull request | -| Routine code-change rationale | Commit message | -| Usage/behavior explanation | README or docs | -| Incident/outage response | Incident report / postmortem | -| A rule that must hold going forward | ADR's Implementation Constraints | - -## Prohibited - -- Creating `docs/decisions/` when `preflight` already found one. -- Running DISCOVER when `preflight` reports no ADR directory — tell the - user to run INIT first instead. -- Writing any ADR file before the user has seen and approved its title, - problem, and decision. -- Marking a retrospective ADR `status: accepted` without the user - confirming the reconstruction is accurate. -- Guessing a dependency's purpose instead of asking, when it's not evident - from the repository. -- Merging Confirmed Evidence, Inferred Rationale, and Unknown into a single - undifferentiated narrative for a retrospective ADR. - -## Script reference - -All mutating and validating operations are deterministic scripts under -`scripts/`; this skill never re-implements ID assignment, file writes, or -schema validation in prose — it only decides what to ask and what to draft. - -RECORD and CHECK workflows are not yet implemented (see `project-roadmap.md` -in the repository root and the design spec this skill is built from). -``` - -```markdown - -# ADR Lifecycle Reference - -## Statuses - -- `proposed` — drafted, not yet approved by a human. -- `accepted` — approved and currently in force. -- `rejected` — considered and explicitly declined. -- `deprecated` — no longer recommended, no direct replacement. -- `superseded` — replaced by a specific later ADR (`superseded_by` must be set). - -## Allowed transitions - -```text -proposed -> accepted | rejected -accepted -> deprecated | superseded -rejected -> (terminal) -deprecated -> (terminal) -superseded -> (terminal) -``` - -`scripts/core/lifecycle.py` enforces this table. If a user asks for a -transition that isn't in it, confirm they understand it's non-standard -before doing anything, and never write a status that violates the table. -``` - -```markdown - -# MADR Template Guide - -## When to use `madr-minimal.md` - -- The decision has two or fewer realistic alternatives. -- No conflicting quality attributes need a comparison table. -- The project is small enough that a short record is genuinely sufficient. - -## When to use `madr-full.md` - -- Three or more alternatives were seriously considered. -- Different options trade off against each other on quality attributes - (e.g. latency vs. operational complexity). -- The decision affects multiple teams, services, or systems. - -## Section meaning - -- **Context and Problem Statement** — the forces and constraints that made - a decision necessary; not a restatement of the chosen solution. -- **Considered Options** — every realistic option actually evaluated, not - a strawman list. -- **Decision Outcome** — the chosen option and the primary reason, stated - as a single sentence a newcomer could quote. -- **Consequences** — both the benefit and the accepted cost; a decision - with no listed downside has not been examined honestly. -- **Confirmation** — how someone (human or agent) can verify the decision - is actually being followed in the code today. -- **Revisit Triggers** — concrete conditions, not vague ones like "if - requirements change." -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_skill_manifest.py -v` -Expected: PASS (3 tests) - -- [ ] **Step 5: Commit** - -```bash -git rm skills/adr-toolkit/references/.gitkeep -git add skills/adr-toolkit/SKILL.md skills/adr-toolkit/references/lifecycle.md \ - skills/adr-toolkit/references/madr-guide.md tests/unit/test_skill_manifest.py -git commit -m "feat: add SKILL.md workflow contract and INIT reference docs" -``` - ---- - -### Task 16: Claude Code adapter manifest - -**Files:** -- Create: `adapters/claude/.claude-plugin/plugin.json` -- Create: `adapters/claude/marketplace.json` -- Test: `tests/unit/test_claude_adapter.py` - -**Interfaces:** -- Consumes: `skills/adr-toolkit/SKILL.md` (Task 15) — the manifest's `skills` path must resolve to that directory. -- Produces: an installable Claude Code plugin. No Python interface — this task is manifest content only. - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_claude_adapter.py -import json -from pathlib import Path - -ADAPTER_DIR = Path(__file__).resolve().parent.parent.parent / "adapters" / "claude" - - -def test_plugin_manifest_has_required_keys(): - manifest = json.loads((ADAPTER_DIR / ".claude-plugin" / "plugin.json").read_text(encoding="utf-8")) - assert manifest["name"] == "adr-toolkit" - assert "version" in manifest - assert manifest["skills"], "plugin.json must list at least one skill path" - - -def test_plugin_manifest_skill_path_resolves_to_real_skill(): - manifest = json.loads((ADAPTER_DIR / ".claude-plugin" / "plugin.json").read_text(encoding="utf-8")) - skill_path = (ADAPTER_DIR / ".claude-plugin" / manifest["skills"][0]).resolve() - assert skill_path.is_dir() - assert (skill_path / "SKILL.md").is_file() - - -def test_marketplace_manifest_lists_the_plugin(): - marketplace = json.loads((ADAPTER_DIR / "marketplace.json").read_text(encoding="utf-8")) - names = [p["name"] for p in marketplace["plugins"]] - assert "adr-toolkit" in names -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_claude_adapter.py -v` -Expected: FAIL — manifests don't exist. - -- [ ] **Step 3: Write the manifests** - -```json -// adapters/claude/.claude-plugin/plugin.json -{ - "name": "adr-toolkit", - "version": "0.1.0", - "description": "Initialize, record, and check Architecture Decision Records by inspecting the repository before asking questions.", - "skills": [ - "../../../skills/adr-toolkit" - ] -} -``` - -```json -// adapters/claude/marketplace.json -{ - "name": "adr-toolkit-marketplace", - "plugins": [ - { - "name": "adr-toolkit", - "source": "./", - "description": "Agent-native ADR toolkit: inspect the repo, ask only what code can't answer, record and check architecture decisions." - } - ] -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_claude_adapter.py -v` -Expected: PASS (3 tests) - -- [ ] **Step 5: Commit** - -```bash -git add adapters/claude/.claude-plugin/plugin.json adapters/claude/marketplace.json \ - tests/unit/test_claude_adapter.py -git commit -m "feat: add Claude Code adapter manifest for adr-toolkit skill" -``` - ---- - -### Task 17: End-to-end INIT fixture and golden test - -**Files:** -- Create: `tests/fixtures/init_no_adr_js_project/package.json` -- Create: `tests/integration/test_init_workflow.py` - -**Interfaces:** -- Consumes: `scripts/adr.py` (Task 14) via subprocess, exercising `preflight`, `discover`, `init`, `validate`, `index` end to end. -- Produces: the golden proof that the INIT workflow this plan builds actually works on a realistic repository, not just in isolated unit tests. - -- [ ] **Step 1: Write the failing test** - -```json -// tests/fixtures/init_no_adr_js_project/package.json -{ - "name": "sample-app", - "version": "1.0.0", - "dependencies": { - "express": "^4.18.0" - } -} -``` - -```python -# tests/integration/test_init_workflow.py -import json -import shutil -import subprocess -import sys -from pathlib import Path - -FIXTURE = Path(__file__).resolve().parent.parent / "fixtures" / "init_no_adr_js_project" -ADR_PY = Path(__file__).resolve().parent.parent.parent / "skills" / "adr-toolkit" / "scripts" / "adr.py" - - -def _run(args, cwd): - result = subprocess.run( - [sys.executable, str(ADR_PY), *args], cwd=cwd, capture_output=True, text=True, - ) - assert result.returncode in (0, 1), result.stderr - return json.loads(result.stdout) - - -def test_full_init_flow_on_js_fixture(tmp_path): - repo = tmp_path / "repo" - shutil.copytree(FIXTURE, repo) - - preflight = _run(["preflight", "--json"], cwd=repo) - assert preflight["ok"] is True - assert preflight["existing_adr_directory"] is None - - discovered = _run(["discover", "--json"], cwd=repo) - assert {"ecosystem": "npm", "path": "package.json"} in discovered["dependencies"] - - init_result = _run(["init", "--dir", "docs/decisions", "--json"], cwd=repo) - assert init_result["ok"] is True - - validate_result = _run(["validate", "--dir", "docs/decisions", "--json"], cwd=repo) - assert validate_result["ok"] is True - assert validate_result["checked"] == 1 - - index_result = _run(["index", "--dir", "docs/decisions", "--json"], cwd=repo) - assert index_result["count"] == 1 - - readme = (repo / "docs" / "decisions" / "README.md").read_text(encoding="utf-8") - assert "ADR-0001" in readme - assert "Accepted" in readme - - second_preflight = _run(["preflight", "--json"], cwd=repo) - assert second_preflight["existing_adr_directory"] == "docs/decisions" -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/integration/test_init_workflow.py -v` -Expected: FAIL — fixture doesn't exist yet (it's created in Step 1 above alongside the test; if you're following strict step order, running before creating `package.json` fails with a "fixture not found" style error since `discover` finds nothing). - -- [ ] **Step 3: Nothing further to implement** - -This task's "implementation" is the fixture file itself, created in Step 1. No production code changes — this task proves Tasks 1–16 already compose correctly end to end. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/integration/test_init_workflow.py -v` -Expected: PASS (1 test) - -- [ ] **Step 5: Commit** - -```bash -git add tests/fixtures/init_no_adr_js_project/package.json tests/integration/test_init_workflow.py -git commit -m "test: add end-to-end INIT fixture and golden test" -``` - ---- - -### Task 18: Generic harness fallback adapter - -**Files:** -- Create: `adapters/generic/README.md` -- Test: `tests/unit/test_generic_adapter.py` - -**Interfaces:** -- Consumes: nothing (documentation only). -- Produces: a manual-install path that works for any harness not covered by Task 16's Claude adapter — including harnesses that don't have a plugin/skill manifest system at all, as long as they read project instructions and can run shell commands. - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_generic_adapter.py -from pathlib import Path - -GENERIC_README = Path(__file__).resolve().parent.parent.parent / "adapters" / "generic" / "README.md" - - -def test_generic_adapter_readme_exists_and_documents_symlink_install(): - text = GENERIC_README.read_text(encoding="utf-8") - assert "skills/adr-toolkit" in text - assert "AGENTS.md" in text - assert "SKILL.md" in text -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_generic_adapter.py -v` -Expected: FAIL — file doesn't exist. - -- [ ] **Step 3: Write the fallback guide** - -```markdown - -# Generic harness adapter - -If your AI coding harness isn't Claude Code, Codex, Gemini CLI, or -Antigravity CLI, ADR Toolkit still works — `SKILL.md` only assumes your -harness can read project instructions and run shell commands, which -covers effectively every current coding agent, including ones this -project hasn't been tested against yet. - -## Install - -1. Copy or symlink `skills/adr-toolkit/` into your project, at whatever - path your harness scans for instructions or skills: - - ```bash - mkdir -p .agents/skills - ln -s ../../path/to/adr-toolkit/skills/adr-toolkit .agents/skills/adr-toolkit - ``` - -2. Add one line to your project's `AGENTS.md` (or whatever instruction - file your harness reads first): - - ```markdown - For architecture decisions (introducing, recording, or checking ADRs), - follow `.agents/skills/adr-toolkit/SKILL.md`. - ``` - -3. That's it. `SKILL.md` never assumes a plugin manifest, a hook, or any - harness-specific configuration — only that something can read markdown - and run `python scripts/adr.py ...`. - -## No agent at all? - -You don't need one. Every deterministic operation is a plain CLI command -you can run yourself: `python skills/adr-toolkit/scripts/adr.py init`, -`... validate`, `... index`, and `... create --interactive` (see Task 19) -for a guided prompt sequence that needs no AI harness whatsoever. -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_generic_adapter.py -v` -Expected: PASS (1 test) - -- [ ] **Step 5: Commit** - -```bash -git add adapters/generic/README.md tests/unit/test_generic_adapter.py -git commit -m "docs: add generic harness fallback adapter" -``` - ---- - -### Task 19: `create.py --interactive` — terminal wizard for humans with no agent - -**Files:** -- Modify: `skills/adr-toolkit/scripts/commands/create.py` (Task 11) -- Modify: `skills/adr-toolkit/scripts/adr.py` (Task 14) — `--input` becomes optional, `--interactive` added -- Test: `tests/unit/test_create_interactive.py` - -**Interfaces:** -- Consumes: nothing beyond what Task 11 already consumes. -- Produces: `gather_draft_interactively(input_fn=input) -> dict` (same draft shape Task 11's `run()` already expects: `title`, `status`, `body`). `run(args)` now accepts an `args.interactive` flag as an alternative to `args.input` — everything downstream (ID assignment, schema validation, file write) is unchanged. - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_create_interactive.py -from types import SimpleNamespace - -from scripts.commands import create - - -def test_gather_draft_interactively_builds_valid_minimal_body(): - answers = iter([ - "Use Kafka for domain events", - "Order processing and downstream work are tightly coupled", - "synchronous HTTP, RabbitMQ, Kafka", - "Kafka", - "it isolates failures and allows reprocessing", - "failures in one consumer don't block others", - "operational complexity increases", - "no direct SDK calls appear outside the events module", - "message volume exceeds what a single queue can handle", - ]) - draft = create.gather_draft_interactively(input_fn=lambda _prompt: next(answers)) - - assert draft["title"] == "Use Kafka for domain events" - assert draft["status"] == "proposed" - assert "## Context and Problem Statement" in draft["body"] - assert "Kafka" in draft["body"] - - -def test_create_run_supports_interactive_mode_end_to_end(tmp_path, monkeypatch): - adr_dir = tmp_path / "docs" / "decisions" - adr_dir.mkdir(parents=True) - - answers = iter([ - "Use Kafka for domain events", "Problem text", "HTTP, Kafka", "Kafka", - "reason", "good thing", "bad thing", "verification note", "revisit condition", - ]) - monkeypatch.setattr("builtins.input", lambda _prompt="": next(answers)) - - result = create.run(SimpleNamespace(interactive=True, input=None, dir=str(adr_dir), dry_run=False)) - - assert result["ok"] is True - assert result["id"] == "ADR-0001" - - -def test_create_run_without_input_or_interactive_is_an_error(tmp_path): - adr_dir = tmp_path / "docs" / "decisions" - adr_dir.mkdir(parents=True) - - result = create.run(SimpleNamespace(interactive=False, input=None, dir=str(adr_dir), dry_run=False)) - - assert result["ok"] is False - assert result["errors"][0]["code"] == "MISSING_INPUT_OR_INTERACTIVE" -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_create_interactive.py -v` -Expected: FAIL — `gather_draft_interactively` doesn't exist; `run()` doesn't accept `interactive`/`None` input. - -- [ ] **Step 3: Modify `create.py`** - -Add this function anywhere above `run`: - -```python -def _prompt(input_fn, question: str) -> str: - return input_fn(f"{question}\n> ").strip() - - -def gather_draft_interactively(input_fn=input) -> dict: - title = _prompt(input_fn, "Title of the decision?") - problem = _prompt(input_fn, "What problem or constraint made this decision necessary?") - options_raw = _prompt(input_fn, "Options considered (comma-separated)?") - options = [o.strip() for o in options_raw.split(",") if o.strip()] - decision = _prompt(input_fn, "Which option was chosen?") - rationale = _prompt(input_fn, "Why was it chosen?") - good = _prompt(input_fn, "One good consequence?") - bad = _prompt(input_fn, "One accepted downside?") - confirmation = _prompt(input_fn, "How will this be verified in the code?") - revisit = _prompt(input_fn, "What condition should reopen this decision?") - - options_block = "\n".join(f"* {o}" for o in options) if options else f"* {decision}" - - body = ( - f"# {title}\n\n" - "## Context and Problem Statement\n\n" - f"{problem}\n\n" - "## Considered Options\n\n" - f"{options_block}\n\n" - "## Decision Outcome\n\n" - f"Chosen option: **{decision}**, because {rationale}.\n\n" - "## Consequences\n\n" - f"* Good: {good}\n" - f"* Bad: {bad}\n\n" - "## Confirmation\n\n" - f"{confirmation}\n\n" - "## Revisit Triggers\n\n" - f"* {revisit}\n" - ) - - return {"title": title, "status": "proposed", "body": body} -``` - -Replace the start of `run(args)` (everything up to `adr_dir.mkdir`) with: - -```python -def run(args) -> dict: - dry_run = getattr(args, "dry_run", False) - - if getattr(args, "interactive", False): - draft = gather_draft_interactively() - else: - input_path = getattr(args, "input", None) - if not input_path: - return { - "ok": False, - "operation": "create", - "errors": [{"code": "MISSING_INPUT_OR_INTERACTIVE"}], - } - draft = json.loads(Path(input_path).read_text(encoding="utf-8")) - - adr_dir = Path(args.dir) - missing = REQUIRED_DRAFT_FIELDS - draft.keys() - if missing: - return { - "ok": False, - "operation": "create", - "errors": [{"code": "MISSING_DRAFT_FIELD", "fields": sorted(missing)}], - } -``` - -(the rest of `run` — ID assignment, schema validation, file write — is unchanged from Task 11) - -- [ ] **Step 4: Modify `adr.py`'s `create` subparser** - -In `build_parser()`, replace: - -```python - p_create.add_argument("--input", required=True) -``` - -with: - -```python - p_create.add_argument("--input") - p_create.add_argument("--interactive", action="store_true") -``` - -- [ ] **Step 5: Run tests to verify they pass** - -Run: `python -m pytest tests/unit/test_create.py tests/unit/test_create_interactive.py -v` -Expected: PASS (7 tests total — Task 11's 4 plus this task's 3). Task 11's tests must still pass unchanged, since `getattr(args, "interactive", False)` defaults to `False` for their `SimpleNamespace` instances. - -- [ ] **Step 6: Commit** - -```bash -git add skills/adr-toolkit/scripts/commands/create.py skills/adr-toolkit/scripts/adr.py \ - tests/unit/test_create_interactive.py -git commit -m "feat: add interactive create wizard for use without an AI agent" -``` - ---- - -### Task 20: CI workflow - -**Files:** -- Create: `.github/workflows/test.yml` - -**Interfaces:** -- Consumes: nothing new — runs the full test suite built by Tasks 1–19. -- Produces: CI enforcement on every push and PR. - -- [ ] **Step 1: Write the workflow file** - -```yaml -# .github/workflows/test.yml -name: test - -on: - push: - pull_request: - -jobs: - pytest: - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ["3.9", "3.12"] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - name: Run tests - run: python -m pytest tests/unit tests/integration -v -``` - -- [ ] **Step 2: Run the equivalent command locally to confirm it would pass in CI** - -Run: `python -m pytest tests/unit tests/integration -v` -Expected: PASS (every test added in Tasks 1–19) - -- [ ] **Step 3: Commit** - -```bash -git add .github/workflows/test.yml -git commit -m "ci: run pytest across unit and integration tests on every push and PR" -``` - ---- - -## Revision notes - -**Revision 1** — After this plan was first written, the user reviewed a -separate improvement proposal and asked to fold in several low-cost -refinements before execution began (no code had been written yet). -Applied: INIT and DISCOVER split into two SKILL.md operations instead of -one bundled flow (Task 15), retrospective ADRs now require a Confirmed -Evidence/Inferred Rationale/Unknown structure (Task 15), and a "what -belongs in an ADR" classification table was added (Task 15, and spec §6.1). -No script-level tasks changed — `discover.py` (Task 8) already existed as -its own command, so this was purely a SKILL.md/documentation restructuring. -Structured `constraints:` YAML blocks and CHECK's four-way finding -classification were accepted too, but recorded only in the design spec -(§6.1, §7) since CHECK isn't implemented until Plan 3. - -**Revision 2** — The user asked whether the design was actually natural to -use both for AI harnesses and for plain human developers. Two real gaps -surfaced: (1) the Codex/Gemini CLI/Antigravity adapter manifests planned -for Plan 4 rest on assumed, unverified schemas, and the original PRD's -harness-agnostic "Generic Agent Skills" fallback had been dropped when the -harness list was narrowed to four named ones; (2) `create.py` only accepted -a pre-built JSON draft, which is natural for an agent but not for a human -running the CLI directly with no AI harness at all — undermining the -stated goal of easy adoption by other developers. Added Task 18 (a -zero-maintenance generic fallback adapter — symlink + one `AGENTS.md` line -— that works for any harness, including ones not yet accounted for) and -Task 19 (`create --interactive`, a terminal wizard that needs no agent). -Both are documentation/CLI-only changes with no effect on Tasks 1–17. - -## Plan self-review notes - -- **Spec coverage:** §5 (repo structure) → Tasks 1, 16; §6 (ADR document format) → Tasks 2, 5, 9, 10, 11; §6.1 (what belongs in an ADR) → Task 15; §10 (multi-view index) → Task 12; §11 INIT/DISCOVER data flow → Tasks 6, 8, 10, 11, 13, 14, 15; §12 CI → Task 20; §8 Claude Code depth → Tasks 16, 17. Harness-naturalness and human-usability (raised in review, not in the original spec numbering) → Tasks 18 (generic adapter) and 19 (interactive wizard). RECORD (§11), CHECK (§7, §11), i18n (§9), and the Codex/Gemini CLI/Antigravity adapters (§8) are explicitly out of scope for this plan — see Plans 2–4. -- **Type consistency checked:** `identifiers.next_id`/`format_filename`/`slugify`/`parse_filename` signatures match across Tasks 3, 10, 11, 12, 13. `frontmatter.parse`/`serialize` signatures match across Tasks 2, 11, 12, 13, 15. `schema.validate_frontmatter` signature matches across Tasks 5, 11, 13. -- **No placeholders found** — every step has runnable code or literal file content; nothing deferred with "TODO" inside this plan's own tasks. - -## Open item for the user - -The design spec this plan implements (`docs/superpowers/specs/2026-08-29-adr-toolkit-design.md`) has not yet had a line-by-line review from the project owner — they approved the direction conversationally and asked me to keep moving while away. Two things in the spec worth their explicit attention when they're back, before this plan is executed: - -1. §13 of the spec proposes MIT as the license — decided in their absence, needs confirmation before any public release. -2. §7's conflict-detection scope (structural/path evidence only, semantic taxonomy deferred) — discussed and agreed to, but worth a final look now that the concrete rule table exists. diff --git a/docs/superpowers/plans/2026-08-30-adr-toolkit-check.md b/docs/superpowers/plans/2026-08-30-adr-toolkit-check.md deleted file mode 100644 index 69f432a..0000000 --- a/docs/superpowers/plans/2026-08-30-adr-toolkit-check.md +++ /dev/null @@ -1,1651 +0,0 @@ -# ADR Toolkit — CHECK (Plan 3 of 4) Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add the CHECK workflow — a `diff` command that wraps `git diff` for three range modes, and a `check` command that matches a diff against Accepted ADRs' structured `constraints:` rules and Superseded ADRs' `affected_paths`, producing a four-way classified finding report with five resolution options on every verified violation. - -**Architecture:** Four new deterministic layers, each independently testable: `core/globs.py` (a `**`-aware glob matcher, stdlib-only since `fnmatch`/`PurePath.match` don't handle `**`), `core/constraints.py` (extracts the fenced `constraints:` YAML block from an ADR body — a small fixed-shape parser, not a general YAML parser, mirroring `core/frontmatter.py`'s hand-rolled-subset precedent), `scripts/rules/conflict.py` (pure functions implementing the four matching mechanisms the six `constraints:` rule kinds collapse into, mirroring `scripts/rules/significance.py`'s no-I/O style), and `scripts/commands/{diff,check}.py` (the two new CLI commands — `diff` shells out to git, `check` orchestrates everything else). `SKILL.md` gains a CHECK section describing how the agent runs `check` and presents the five resolution options; no conflict-detection judgment is hardcoded in Python beyond the six structural rule kinds. - -**Tech Stack:** Same as Plans 1–2 — Python 3.9+ standard library only (including `subprocess` for git, `re`, `json`), pytest. - -**Spec:** `docs/superpowers/specs/2026-08-29-adr-toolkit-design.md` (§6 ADR document format, §7 CHECK scope, §11 CHECK workflow data flow, §16 Plan 3 implementation decisions) - -## Global Constraints - -- Same as Plans 1–2: Python 3.9+ stdlib only; UTF-8 explicit; no `shell=True`; command modules never `print()`; avoid `X | None` union syntax, use `typing.Optional`. -- `diff`/`check` never write any file — CHECK is read-only by design (§7); no `--dry-run` flag is needed because there is nothing to preview before writing. -- `git` is invoked only via `subprocess.run` with argument lists (`["git", "-C", str(root), ...]`), never `shell=True`, never a string-interpolated command. -- `constraints:` rules are evaluated only against ADRs with `status: accepted` (§16.4); superseded ADRs are handled by the separate superseded-reference pass, never by re-running their old `constraints:` block. -- Malformed ADR frontmatter or a malformed `constraints:` block is caught per-file/per-ADR and degrades to a `warnings` entry — `check` never aborts because one ADR is broken, matching the pattern the Plan 2 closeout review established in `related.py`/`status.py`/`supersede.py`. -- A `diff` failure (not a git repo, unknown ref) returns a specific error code (`NOT_A_GIT_REPO`, `INVALID_REF`, `GIT_DIFF_FAILED`) — never a raw `subprocess` traceback surfaced through `adr.py main()`'s `INTERNAL_ERROR` fallback. -- Out of scope for this plan (tracked in `project-roadmap.md` / Plan 4): i18n wiring, Codex/Gemini CLI/Antigravity CLI adapters, release automation, full semantic/AST conflict detection. -- Every task must leave `python -m pytest tests/unit tests/integration -v` green before its commit step. - ---- - -### Task 1: `core/globs.py` — a `**`-aware glob matcher - -**Files:** -- Create: `skills/adr-toolkit/scripts/core/globs.py` -- Test: `tests/unit/test_globs.py` - -**Interfaces:** -- Consumes: nothing. -- Produces: `match(pattern: str, path: str) -> bool`. Used by Task 4 (`rules/conflict.py`). - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_globs.py -from scripts.core import globs - - -def test_exact_match(): - assert globs.match("src/events/producer.py", "src/events/producer.py") - assert not globs.match("src/events/producer.py", "src/events/consumer.py") - - -def test_single_star_matches_within_one_segment(): - assert globs.match("src/events/*.py", "src/events/producer.py") - assert not globs.match("src/events/*.py", "src/events/sub/producer.py") - - -def test_double_star_matches_across_segments(): - assert globs.match("src/features/**", "src/features/x.py") - assert globs.match("src/features/**", "src/features/sub/y.py") - assert not globs.match("src/features/**", "src/other/x.py") - - -def test_double_star_prefix_matches_any_depth(): - assert globs.match("**/test_*.py", "test_foo.py") - assert globs.match("**/test_*.py", "src/deep/test_foo.py") - assert not globs.match("**/test_*.py", "src/deep/foo_test.py") - - -def test_question_mark_matches_single_character(): - assert globs.match("src/adr-?.md", "src/adr-1.md") - assert not globs.match("src/adr-?.md", "src/adr-10.md") -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_globs.py -v` -Expected: FAIL — module doesn't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -# skills/adr-toolkit/scripts/core/globs.py -"""A **-aware glob matcher, stdlib-only. - -Python's fnmatch/PurePath.match don't treat ** as "any number of path -segments" the way ArchUnit-style path rules need, so this implements just -that one extension on top of literal/*/? matching. -""" -import re - - -def match(pattern: str, path: str) -> bool: - return re.match(_translate(pattern), path) is not None - - -def _translate(pattern: str) -> str: - parts = [] - i, n = 0, len(pattern) - while i < n: - char = pattern[i] - if pattern[i:i + 2] == "**": - if i + 2 < n and pattern[i + 2] == "/": - parts.append(r"(?:.*/)?") - i += 3 - else: - parts.append(r".*") - i += 2 - elif char == "*": - parts.append(r"[^/]*") - i += 1 - elif char == "?": - parts.append(r"[^/]") - i += 1 - else: - parts.append(re.escape(char)) - i += 1 - return "^" + "".join(parts) + "$" -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_globs.py -v` -Expected: PASS (5 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/core/globs.py tests/unit/test_globs.py -git commit -m "feat: add **-aware glob matcher for constraint path patterns" -``` - ---- - -### Task 2: `core/constraints.py` — extract `constraints:` rules from an ADR body - -**Files:** -- Create: `skills/adr-toolkit/scripts/core/constraints.py` -- Test: `tests/unit/test_constraints.py` - -**Interfaces:** -- Consumes: nothing. -- Produces: `extract_constraints(body: str) -> list`, `ConstraintsError(ValueError)`. Used by Task 6 (`check.py`). - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_constraints.py -import pytest - -from scripts.core.constraints import ConstraintsError, extract_constraints - -BODY_WITH_CONSTRAINTS = """# Use a provider port - -## Implementation Constraints - -Feature modules must go through the LLM port, never call a provider SDK -directly. - -```yaml -constraints: - - id: no-provider-sdk-in-feature - kind: forbidden_import - paths: ["src/features/**"] - pattern: ["openai", "anthropic"] - severity: major - message: "Feature modules must use the LLM port." - - id: registry-required - kind: required_path - paths: ["src/events/**"] - pattern: ["src/events/registry.py"] - severity: minor - message: "New event types must be registered." -``` -""" - -BODY_WITHOUT_CONSTRAINTS = "# Use a provider port\n\nNo fenced block here.\n" - -BODY_WITH_MALFORMED_CONSTRAINTS = """# Bad ADR - -```yaml -constraints: - - id: broken - kind forbidden_import -``` -""" - - -def test_extracts_all_rules_with_correct_fields(): - rules = extract_constraints(BODY_WITH_CONSTRAINTS) - assert len(rules) == 2 - assert rules[0]["id"] == "no-provider-sdk-in-feature" - assert rules[0]["kind"] == "forbidden_import" - assert rules[0]["paths"] == ["src/features/**"] - assert rules[0]["pattern"] == ["openai", "anthropic"] - assert rules[0]["severity"] == "major" - assert rules[1]["id"] == "registry-required" - assert rules[1]["kind"] == "required_path" - - -def test_no_fenced_block_returns_empty_list(): - assert extract_constraints(BODY_WITHOUT_CONSTRAINTS) == [] - - -def test_malformed_line_raises_constraints_error(): - with pytest.raises(ConstraintsError): - extract_constraints(BODY_WITH_MALFORMED_CONSTRAINTS) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_constraints.py -v` -Expected: FAIL — module doesn't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -# skills/adr-toolkit/scripts/core/constraints.py -"""Extract structured `constraints:` rules from an ADR body's fenced block. - -Not a general YAML parser — supports exactly the fixed shape the design -spec defines (§7, §16.2): a top-level `constraints:` list of mappings with -a small set of known fields, list-valued fields written as a JSON-style -array on one line. Mirrors core/frontmatter.py's hand-rolled-subset -approach rather than adding a YAML dependency. -""" -import json -import re - -FENCE_RE = re.compile(r"```ya?ml\n(.*?)\n```", re.DOTALL) - -KNOWN_FIELDS = {"id", "kind", "paths", "pattern", "severity", "message"} -LIST_FIELDS = {"paths", "pattern"} - - -class ConstraintsError(ValueError): - pass - - -def extract_constraints(body: str) -> list: - rules = [] - for fence_match in FENCE_RE.finditer(body): - lines = fence_match.group(1).splitlines() - if not lines or not lines[0].strip().startswith("constraints:"): - continue - rules.extend(_parse_rules(lines[1:])) - return rules - - -def _parse_rules(lines) -> list: - rules = [] - current = None - for raw_line in lines: - if not raw_line.strip(): - continue - if raw_line.startswith(" - "): - if current is not None: - rules.append(current) - current = {} - - stripped = raw_line.strip() - if stripped.startswith("- "): - stripped = stripped[2:].strip() - - if ":" not in stripped: - raise ConstraintsError(f"Malformed constraints line: {raw_line!r}") - key, _, value = stripped.partition(":") - key, value = key.strip(), value.strip() - - if current is None: - raise ConstraintsError(f"Constraints field with no preceding '- ': {raw_line!r}") - if key not in KNOWN_FIELDS: - raise ConstraintsError(f"Unknown constraints field: {key!r}") - - if key in LIST_FIELDS: - try: - parsed_value = json.loads(value) - except json.JSONDecodeError as exc: - raise ConstraintsError(f"Field {key!r} must be a JSON-style list, got {value!r}") from exc - if not isinstance(parsed_value, list): - raise ConstraintsError(f"Field {key!r} must be a list, got {value!r}") - current[key] = parsed_value - else: - current[key] = value.strip('"').strip("'") - - if current is not None: - rules.append(current) - return rules -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_constraints.py -v` -Expected: PASS (3 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/core/constraints.py tests/unit/test_constraints.py -git commit -m "feat: extract constraints: rules from an ADR body's fenced block" -``` - ---- - -### Task 3: `commands/diff.py` — git diff wrapper + CLI wiring - -**Files:** -- Create: `skills/adr-toolkit/scripts/commands/diff.py` -- Modify: `skills/adr-toolkit/scripts/adr.py` -- Test: `tests/unit/test_diff.py` - -**Interfaces:** -- Consumes: `subprocess` (git binary on PATH). -- Produces: `run(args) -> dict` where `args` has optional `.staged` (bool), `.uncommitted` (bool), `.since` (str), `.root` (str, default `"."`). Success shape: `{"ok": True, "operation": "diff", "mode": "staged"|"uncommitted"|"since", "ref": Optional[str], "files": [{"path": str, "change_type": "added"|"modified"|"deleted", "added_lines": list, "removed_lines": list}]}`. Used by Task 6 (`check.py`). - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_diff.py -import subprocess -from types import SimpleNamespace - -from scripts.commands import diff - - -def _git(args, cwd): - subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True) - - -def _init_repo(tmp_path): - _git(["init", "-q", "-b", "master"], tmp_path) - _git(["config", "user.email", "test@example.com"], tmp_path) - _git(["config", "user.name", "Test"], tmp_path) - (tmp_path / "existing.py").write_text("x = 1\n", encoding="utf-8") - _git(["add", "existing.py"], tmp_path) - _git(["commit", "-q", "-m", "initial"], tmp_path) - - -def test_not_a_git_repo_returns_specific_error(tmp_path): - result = diff.run(SimpleNamespace(root=str(tmp_path), staged=False, uncommitted=False, since=None)) - assert result["ok"] is False - assert result["errors"][0]["code"] == "NOT_A_GIT_REPO" - - -def test_uncommitted_mode_reports_added_and_removed_lines(tmp_path): - _init_repo(tmp_path) - (tmp_path / "existing.py").write_text("x = 1\nimport openai\n", encoding="utf-8") - - result = diff.run(SimpleNamespace(root=str(tmp_path), staged=False, uncommitted=True, since=None)) - - assert result["ok"] is True - assert result["mode"] == "uncommitted" - entry = next(f for f in result["files"] if f["path"] == "existing.py") - assert entry["change_type"] == "modified" - assert "import openai" in entry["added_lines"] - - -def test_staged_mode_only_sees_staged_changes(tmp_path): - _init_repo(tmp_path) - (tmp_path / "new_file.py").write_text("y = 2\n", encoding="utf-8") - _git(["add", "new_file.py"], tmp_path) - (tmp_path / "existing.py").write_text("unstaged change\n", encoding="utf-8") - - result = diff.run(SimpleNamespace(root=str(tmp_path), staged=True, uncommitted=False, since=None)) - - paths = {f["path"] for f in result["files"]} - assert paths == {"new_file.py"} - assert result["files"][0]["change_type"] == "added" - - -def test_since_ref_diffs_against_head(tmp_path): - _init_repo(tmp_path) - _git(["checkout", "-q", "-b", "feature"], tmp_path) - (tmp_path / "existing.py").write_text("x = 1\nimport openai\n", encoding="utf-8") - _git(["commit", "-q", "-am", "add import"], tmp_path) - - result = diff.run(SimpleNamespace(root=str(tmp_path), staged=False, uncommitted=False, since="master")) - - assert result["ok"] is True - assert result["mode"] == "since" - assert result["ref"] == "master" - entry = next(f for f in result["files"] if f["path"] == "existing.py") - assert "import openai" in entry["added_lines"] - - -def test_invalid_ref_returns_specific_error(tmp_path): - _init_repo(tmp_path) - result = diff.run(SimpleNamespace(root=str(tmp_path), staged=False, uncommitted=False, since="no-such-ref")) - assert result["ok"] is False - assert result["errors"][0]["code"] == "INVALID_REF" - - -def test_deleted_file_reports_removed_lines(tmp_path): - _init_repo(tmp_path) - (tmp_path / "existing.py").unlink() - - result = diff.run(SimpleNamespace(root=str(tmp_path), staged=False, uncommitted=True, since=None)) - - entry = next(f for f in result["files"] if f["path"] == "existing.py") - assert entry["change_type"] == "deleted" - assert "x = 1" in entry["removed_lines"] - - -def test_uncommitted_mode_includes_untracked_new_files(tmp_path): - _init_repo(tmp_path) - (tmp_path / "brand_new.py").write_text("import openai\n", encoding="utf-8") - - result = diff.run(SimpleNamespace(root=str(tmp_path), staged=False, uncommitted=True, since=None)) - - entry = next(f for f in result["files"] if f["path"] == "brand_new.py") - assert entry["change_type"] == "added" - assert "import openai" in entry["added_lines"] -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_diff.py -v` -Expected: FAIL — module doesn't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -# skills/adr-toolkit/scripts/commands/diff.py -"""Wrap `git diff` for CHECK: staged, uncommitted, or since-a-ref changes.""" -import subprocess -from pathlib import Path - - -def run(args) -> dict: - root = Path(getattr(args, "root", ".")).resolve() - - repo_check = subprocess.run( - ["git", "-C", str(root), "rev-parse", "--is-inside-work-tree"], - capture_output=True, text=True, - ) - if repo_check.returncode != 0: - return { - "ok": False, - "operation": "diff", - "errors": [{"code": "NOT_A_GIT_REPO", "detail": repo_check.stderr.strip()}], - } - - since = getattr(args, "since", None) - if getattr(args, "staged", False): - mode, diff_args = "staged", ["--cached"] - elif since: - mode, diff_args = "since", [f"{since}..HEAD"] - else: - mode, diff_args = "uncommitted", [] - - name_status = subprocess.run( - ["git", "-C", str(root), "diff", "--name-status", *diff_args], - capture_output=True, text=True, - ) - if name_status.returncode != 0: - code = "INVALID_REF" if since else "GIT_DIFF_FAILED" - return {"ok": False, "operation": "diff", "errors": [{"code": code, "detail": name_status.stderr.strip()}]} - - patch = subprocess.run( - ["git", "-C", str(root), "diff", "--unified=0", *diff_args], - capture_output=True, text=True, - ) - - files = _parse_name_status(name_status.stdout) - _attach_line_content(files, patch.stdout) - - if mode == "uncommitted": - files.extend(_untracked_files(root)) - - return {"ok": True, "operation": "diff", "mode": mode, "ref": since, "files": files} - - -def _untracked_files(root: Path) -> list: - # `git diff` never shows untracked files, but a brand-new file is exactly - # the kind of change CHECK needs to see (e.g. a new module that violates - # a forbidden_import rule) — surface it as if it were entirely "added". - listing = subprocess.run( - ["git", "-C", str(root), "ls-files", "--others", "--exclude-standard"], - capture_output=True, text=True, - ) - entries = [] - for rel_path in listing.stdout.splitlines(): - if not rel_path.strip(): - continue - try: - content_lines = (root / rel_path).read_text(encoding="utf-8").splitlines() - except (UnicodeDecodeError, OSError): - content_lines = [] - entries.append({ - "path": rel_path, - "change_type": "added", - "added_lines": content_lines, - "removed_lines": [], - }) - return entries - - -def _parse_name_status(output: str) -> list: - status_map = {"A": "added", "M": "modified", "D": "deleted"} - files = [] - for line in output.splitlines(): - if not line.strip(): - continue - parts = line.split("\t") - status_char, path = parts[0][0], parts[-1] - files.append({ - "path": path, - "change_type": status_map.get(status_char, "modified"), - "added_lines": [], - "removed_lines": [], - }) - return files - - -def _attach_line_content(files: list, patch_output: str) -> None: - by_path = {f["path"]: f for f in files} - current = None - for line in patch_output.splitlines(): - if line.startswith("--- "): - src = line[6:] if line.startswith("--- a/") else None - current = by_path.get(src) if src else current - continue - if line.startswith("+++ "): - dst = line[6:] if line.startswith("+++ b/") else None - current = by_path.get(dst) if dst else current - continue - if current is None: - continue - if line.startswith("+"): - current["added_lines"].append(line[1:]) - elif line.startswith("-"): - current["removed_lines"].append(line[1:]) -``` - -In `scripts/adr.py`, add: - -```python -from scripts.commands import diff # alongside other imports -``` - -```python - p_diff = sub.add_parser("diff") - p_diff.add_argument("--staged", action="store_true") - p_diff.add_argument("--uncommitted", action="store_true") - p_diff.add_argument("--since") - p_diff.add_argument("--root", default=".") - p_diff.add_argument("--json", action="store_true") -``` - -Add `"diff": diff.run` to `HANDLERS`. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_diff.py -v` -Expected: PASS (7 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/commands/diff.py skills/adr-toolkit/scripts/adr.py \ - tests/unit/test_diff.py -git commit -m "feat: add diff command wrapping git diff for three range modes" -``` - ---- - -### Task 4: `rules/conflict.py` — the four constraint-matching mechanisms - -**Files:** -- Create: `skills/adr-toolkit/scripts/rules/conflict.py` -- Test: `tests/unit/test_conflict.py` - -**Interfaces:** -- Consumes: `scripts.core.globs.match` (Task 1). -- Produces: `evaluate_rule(rule: dict, diff_files: list, existing_paths: set) -> Optional[dict]`, `affected_paths_overlap(diff_files: list, affected_paths: list) -> bool`. `evaluate_rule` returns `None` for no violation, else `{"rule_id", "kind": "verified_violation", "severity", "message", "file", "evidence"}`. Used by Task 6 (`check.py`). - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_conflict.py -from scripts.rules import conflict - -DIFF_FILES = [ - {"path": "src/features/x.py", "change_type": "modified", - "added_lines": ["import openai", "def handler(): pass"], "removed_lines": []}, - {"path": "src/events/producer.py", "change_type": "added", - "added_lines": ["class Producer: pass"], "removed_lines": []}, -] - - -def test_content_pattern_fires_on_matching_added_line(): - rule = {"id": "no-sdk", "kind": "forbidden_import", "paths": ["src/features/**"], - "pattern": ["openai"], "severity": "major", "message": "no direct SDK"} - result = conflict.evaluate_rule(rule, DIFF_FILES, set()) - assert result["kind"] == "verified_violation" - assert result["rule_id"] == "no-sdk" - assert result["file"] == "src/features/x.py" - - -def test_content_pattern_does_not_fire_without_a_match(): - rule = {"id": "no-sdk", "kind": "forbidden_import", "paths": ["src/features/**"], - "pattern": ["anthropic"], "severity": "major", "message": "no direct SDK"} - assert conflict.evaluate_rule(rule, DIFF_FILES, set()) is None - - -def test_required_companion_path_fires_when_companion_missing(): - rule = {"id": "registry", "kind": "required_path", "paths": ["src/events/**"], - "pattern": ["src/events/registry.py"], "severity": "minor", "message": "register it"} - result = conflict.evaluate_rule(rule, DIFF_FILES, set()) - assert result["kind"] == "verified_violation" - - -def test_required_companion_path_satisfied_by_existing_paths(): - rule = {"id": "registry", "kind": "required_path", "paths": ["src/events/**"], - "pattern": ["src/events/registry.py"], "severity": "minor", "message": "register it"} - result = conflict.evaluate_rule(rule, DIFF_FILES, {"src/events/registry.py"}) - assert result is None - - -def test_forbidden_companion_path_fires_when_both_touched(): - rule = {"id": "no-db-in-features", "kind": "forbidden_path", "paths": ["src/features/**"], - "pattern": ["src/events/**"], "severity": "major", "message": "boundary violation"} - result = conflict.evaluate_rule(rule, DIFF_FILES, set()) - assert result["kind"] == "verified_violation" - - -def test_forbidden_companion_path_does_not_fire_when_companion_absent(): - rule = {"id": "no-db-in-features", "kind": "forbidden_path", "paths": ["src/features/**"], - "pattern": ["src/db/**"], "severity": "major", "message": "boundary violation"} - assert conflict.evaluate_rule(rule, DIFF_FILES, set()) is None - - -def test_existence_check_fires_when_path_missing(): - rule = {"id": "must-exist", "kind": "file_must_exist", "paths": ["src/events/registry.py"], - "severity": "major", "message": "registry must exist"} - assert conflict.evaluate_rule(rule, DIFF_FILES, set())["kind"] == "verified_violation" - - -def test_existence_check_satisfied_when_path_present(): - rule = {"id": "must-exist", "kind": "test_must_exist", "paths": ["tests/test_x.py"], - "severity": "major", "message": "tests must exist"} - assert conflict.evaluate_rule(rule, DIFF_FILES, {"tests/test_x.py"}) is None - - -def test_unknown_kind_produces_no_finding(): - rule = {"id": "mystery", "kind": "not_a_real_kind", "paths": ["src/**"]} - assert conflict.evaluate_rule(rule, DIFF_FILES, set()) is None - - -def test_affected_paths_overlap_matches_directory_prefix(): - assert conflict.affected_paths_overlap(DIFF_FILES, ["src/features/"]) - - -def test_affected_paths_overlap_matches_glob(): - assert conflict.affected_paths_overlap(DIFF_FILES, ["src/events/**"]) - - -def test_affected_paths_overlap_false_when_no_match(): - assert not conflict.affected_paths_overlap(DIFF_FILES, ["src/unrelated/"]) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_conflict.py -v` -Expected: FAIL — module doesn't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -# skills/adr-toolkit/scripts/rules/conflict.py -"""Pure structural constraint matching for CHECK (§16.2). - -No file or git I/O — callers pass in an already-parsed diff (Task 3's -diff.py `files` list) and the set of paths that exist in the working tree -after the diff. The six constraints: rule kinds collapse into four -mechanisms here; which kind maps to which mechanism is fixed by the design -spec, not configurable. -""" -import re - -from scripts.core import globs - -CONTENT_PATTERN_KINDS = {"forbidden_import", "dependency_forbidden"} -REQUIRED_PATH_KINDS = {"required_path"} -FORBIDDEN_PATH_KINDS = {"forbidden_path"} -EXISTENCE_KINDS = {"file_must_exist", "test_must_exist"} - - -def evaluate_rule(rule: dict, diff_files: list, existing_paths: set): - kind = rule.get("kind") - if kind in CONTENT_PATTERN_KINDS: - return _content_pattern(rule, diff_files) - if kind in REQUIRED_PATH_KINDS: - return _required_companion_path(rule, diff_files, existing_paths) - if kind in FORBIDDEN_PATH_KINDS: - return _forbidden_companion_path(rule, diff_files) - if kind in EXISTENCE_KINDS: - return _existence_check(rule, existing_paths) - return None - - -def affected_paths_overlap(diff_files: list, affected_paths: list) -> bool: - touched = _touched_paths(diff_files) - return any( - diff_path == ap or diff_path.startswith(ap) or globs.match(ap, diff_path) - for diff_path in touched - for ap in affected_paths - ) - - -def _touched_paths(diff_files: list) -> set: - return {f["path"] for f in diff_files} - - -def _files_matching(diff_files: list, path_patterns: list) -> list: - return [f for f in diff_files if any(globs.match(p, f["path"]) for p in path_patterns)] - - -def _content_pattern(rule: dict, diff_files: list): - regexes = [re.compile(p) for p in rule.get("pattern", [])] - for file_entry in _files_matching(diff_files, rule.get("paths", [])): - for line in file_entry.get("added_lines", []): - for regex in regexes: - if regex.search(line): - return _violation(rule, file=file_entry["path"], evidence={"line": line, "pattern": regex.pattern}) - return None - - -def _required_companion_path(rule: dict, diff_files: list, existing_paths: set): - trigger_files = _files_matching(diff_files, rule.get("paths", [])) - if not trigger_files: - return None - companion_patterns = rule.get("pattern", []) - candidates = _touched_paths(diff_files) | existing_paths - if any(globs.match(cp, path) for cp in companion_patterns for path in candidates): - return None - return _violation(rule, file=trigger_files[0]["path"], evidence={"missing_companion": companion_patterns}) - - -def _forbidden_companion_path(rule: dict, diff_files: list): - trigger_files = _files_matching(diff_files, rule.get("paths", [])) - if not trigger_files: - return None - companion_patterns = rule.get("pattern", []) - touched = _touched_paths(diff_files) - hits = sorted(p for p in touched if any(globs.match(cp, p) for cp in companion_patterns)) - if not hits: - return None - return _violation(rule, file=trigger_files[0]["path"], evidence={"forbidden_companion": hits}) - - -def _existence_check(rule: dict, existing_paths: set): - required_patterns = rule.get("paths", []) - if any(globs.match(p, path) for p in required_patterns for path in existing_paths): - return None - return _violation(rule, file=None, evidence={"missing_paths": required_patterns}) - - -def _violation(rule: dict, *, file, evidence) -> dict: - return { - "rule_id": rule.get("id"), - "kind": "verified_violation", - "severity": rule.get("severity", "major"), - "message": rule.get("message", ""), - "file": file, - "evidence": evidence, - } -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_conflict.py -v` -Expected: PASS (12 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/rules/conflict.py tests/unit/test_conflict.py -git commit -m "feat: add pure constraint-matching mechanisms for CHECK" -``` - ---- - -### Task 5: `commands/check.py` — core orchestration (Accepted-ADR constraint evaluation) + CLI wiring - -**Files:** -- Create: `skills/adr-toolkit/scripts/commands/check.py` -- Modify: `skills/adr-toolkit/scripts/adr.py` -- Test: `tests/unit/test_check.py` - -**Interfaces:** -- Consumes: `scripts.commands.diff.run` (Task 3), `scripts.core.constraints.{extract_constraints, ConstraintsError}` (Task 2), `scripts.rules.conflict.{evaluate_rule, affected_paths_overlap}` (Task 4), `scripts.core.frontmatter.parse`/`FrontmatterError`, `scripts.core.identifiers.parse_filename`. -- Produces: `run(args) -> dict` where `args` has `.dir` (ADR directory), plus `diff`'s own `.staged`/`.uncommitted`/`.since`/`.root`. Success shape: `{"ok": True, "operation": "check", "diff": {...}, "findings": [...], "warnings": [...]}`. This task covers `Verified violation` / `Related` / `No applicable constraint` for Accepted ADRs; Task 6 extends it with `Review required` and `Superseded reference`. - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_check.py -import subprocess -from types import SimpleNamespace - -from scripts.commands import check - -RESOLUTIONS = ["fix_code", "supersede_adr", "adjust_scope", "register_exception", "false_positive"] - - -def _git(args, cwd): - subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True) - - -def _init_repo(tmp_path): - _git(["init", "-q", "-b", "master"], tmp_path) - _git(["config", "user.email", "test@example.com"], tmp_path) - _git(["config", "user.name", "Test"], tmp_path) - - -ACCEPTED_ADR_WITH_RULE = """--- -id: ADR-0001 -title: Use a provider port -status: accepted -date: 2026-08-01 -decision_makers: [] -related: [] -affected_paths: - - src/features/ -tags: [] -retrospective: false ---- - -# Use a provider port - -## Implementation Constraints - -```yaml -constraints: - - id: no-provider-sdk-in-feature - kind: forbidden_import - paths: ["src/features/**"] - pattern: ["openai"] - severity: major - message: "Feature modules must use the LLM port." -``` -""" - -ACCEPTED_ADR_NO_CONSTRAINTS = """--- -id: ADR-0002 -title: Use flat decision directory -status: accepted -date: 2026-08-02 -decision_makers: [] -related: [] -affected_paths: - - docs/decisions/ -tags: [] -retrospective: false ---- - -# Use flat decision directory - -No constraints block here. -""" - - -def _args(tmp_path, adr_dir): - return SimpleNamespace(dir=str(adr_dir), staged=False, uncommitted=True, since=None, root=str(tmp_path)) - - -def test_verified_violation_when_rule_fires(tmp_path): - _init_repo(tmp_path) - adr_dir = tmp_path / "docs" / "decisions" - adr_dir.mkdir(parents=True) - (adr_dir / "0001-use-a-provider-port.md").write_text(ACCEPTED_ADR_WITH_RULE, encoding="utf-8") - _git(["add", "-A"], tmp_path) - _git(["commit", "-q", "-m", "init"], tmp_path) - - (tmp_path / "src" / "features").mkdir(parents=True) - (tmp_path / "src" / "features" / "x.py").write_text("import openai\n", encoding="utf-8") - - result = check.run(_args(tmp_path, adr_dir)) - - assert result["ok"] is True - violation = next(f for f in result["findings"] if f["kind"] == "verified_violation") - assert violation["adr_id"] == "ADR-0001" - assert violation["rule_id"] == "no-provider-sdk-in-feature" - assert violation["resolutions"] == RESOLUTIONS - - -def test_related_when_rule_present_but_does_not_fire(tmp_path): - _init_repo(tmp_path) - adr_dir = tmp_path / "docs" / "decisions" - adr_dir.mkdir(parents=True) - (adr_dir / "0001-use-a-provider-port.md").write_text(ACCEPTED_ADR_WITH_RULE, encoding="utf-8") - _git(["add", "-A"], tmp_path) - _git(["commit", "-q", "-m", "init"], tmp_path) - - (tmp_path / "src" / "features").mkdir(parents=True) - (tmp_path / "src" / "features" / "x.py").write_text("def handler(): pass\n", encoding="utf-8") - - result = check.run(_args(tmp_path, adr_dir)) - - finding = next(f for f in result["findings"] if f["adr_id"] == "ADR-0001") - assert finding["kind"] == "related" - - -def test_no_applicable_constraint_when_adr_has_no_constraints_block(tmp_path): - _init_repo(tmp_path) - adr_dir = tmp_path / "docs" / "decisions" - adr_dir.mkdir(parents=True) - (adr_dir / "0002-use-flat-decision-directory.md").write_text(ACCEPTED_ADR_NO_CONSTRAINTS, encoding="utf-8") - _git(["add", "-A"], tmp_path) - _git(["commit", "-q", "-m", "init"], tmp_path) - - (tmp_path / "docs" / "decisions" / "0003-new.md").write_text("placeholder\n", encoding="utf-8") - - result = check.run(_args(tmp_path, adr_dir)) - - finding = next(f for f in result["findings"] if f["adr_id"] == "ADR-0002") - assert finding["kind"] == "no_applicable_constraint" - - -def test_adr_with_no_affected_path_overlap_produces_no_finding(tmp_path): - _init_repo(tmp_path) - adr_dir = tmp_path / "docs" / "decisions" - adr_dir.mkdir(parents=True) - (adr_dir / "0001-use-a-provider-port.md").write_text(ACCEPTED_ADR_WITH_RULE, encoding="utf-8") - _git(["add", "-A"], tmp_path) - _git(["commit", "-q", "-m", "init"], tmp_path) - - (tmp_path / "unrelated.py").write_text("x = 1\n", encoding="utf-8") - - result = check.run(_args(tmp_path, adr_dir)) - - assert result["findings"] == [] - - -def test_malformed_adr_frontmatter_degrades_to_warning(tmp_path): - _init_repo(tmp_path) - adr_dir = tmp_path / "docs" / "decisions" - adr_dir.mkdir(parents=True) - (adr_dir / "0001-use-a-provider-port.md").write_text(ACCEPTED_ADR_WITH_RULE, encoding="utf-8") - (adr_dir / "0009-broken.md").write_text("not frontmatter at all", encoding="utf-8") - _git(["add", "-A"], tmp_path) - _git(["commit", "-q", "-m", "init"], tmp_path) - - (tmp_path / "unrelated.py").write_text("x = 1\n", encoding="utf-8") - - result = check.run(_args(tmp_path, adr_dir)) - - assert result["ok"] is True - assert any(w["code"] == "BAD_FRONTMATTER" and w["file"] == "0009-broken.md" for w in result["warnings"]) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_check.py -v` -Expected: FAIL — module doesn't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -# skills/adr-toolkit/scripts/commands/check.py -"""Match a git diff against Accepted ADRs' constraints: rules and -Superseded ADRs' affected_paths, per §7/§16 of the design spec.""" -from pathlib import Path - -from scripts.commands import diff as diff_command -from scripts.core import frontmatter as fm -from scripts.core import identifiers -from scripts.core.constraints import ConstraintsError, extract_constraints -from scripts.rules import conflict - -SKIP_FILES = {"README.md", "adr-template.md"} -RESOLUTIONS = ["fix_code", "supersede_adr", "adjust_scope", "register_exception", "false_positive"] - - -def run(args) -> dict: - diff_result = diff_command.run(args) - if not diff_result["ok"]: - return {"ok": False, "operation": "check", "errors": diff_result["errors"]} - diff_files = diff_result["files"] - - root = Path(getattr(args, "root", ".")).resolve() - existing_paths = _existing_paths(root) - - adr_dir = Path(args.dir) - entries, warnings = _load_adrs(adr_dir) - - findings = [] - for entry in entries: - data, body = entry["data"], entry["body"] - if data.get("status") != "accepted": - continue - if not conflict.affected_paths_overlap(diff_files, data.get("affected_paths", [])): - continue - - adr_id = data.get("id") - try: - rules = extract_constraints(body) - except ConstraintsError as exc: - warnings.append({"code": "BAD_CONSTRAINTS", "adr_id": adr_id, "detail": str(exc)}) - rules = [] - - produced_any = False - for rule in rules: - violation = conflict.evaluate_rule(rule, diff_files, existing_paths) - if violation: - findings.append({**violation, "adr_id": adr_id, "resolutions": RESOLUTIONS}) - produced_any = True - - if not produced_any: - findings.append({"adr_id": adr_id, "kind": "related" if rules else "no_applicable_constraint"}) - - return { - "ok": True, - "operation": "check", - "diff": {"mode": diff_result["mode"], "ref": diff_result.get("ref"), "files_changed": len(diff_files)}, - "findings": findings, - "warnings": warnings, - } - - -def _load_adrs(adr_dir: Path) -> tuple: - entries, warnings = [], [] - for entry in sorted(adr_dir.glob("*.md")): - if entry.name in SKIP_FILES or identifiers.parse_filename(entry.name) is None: - continue - try: - data, body = fm.parse(entry.read_text(encoding="utf-8")) - except fm.FrontmatterError as exc: - warnings.append({"code": "BAD_FRONTMATTER", "file": entry.name, "detail": str(exc)}) - continue - entries.append({"data": data, "body": body}) - return entries, warnings - - -def _existing_paths(root: Path) -> set: - paths = set() - for entry in root.rglob("*"): - if not entry.is_file(): - continue - relative = entry.relative_to(root) - if ".git" in relative.parts: - continue - paths.add(relative.as_posix()) - return paths -``` - -In `scripts/adr.py`, add: - -```python -from scripts.commands import check # alongside other imports -``` - -```python - p_check = sub.add_parser("check") - p_check.add_argument("--staged", action="store_true") - p_check.add_argument("--uncommitted", action="store_true") - p_check.add_argument("--since") - p_check.add_argument("--root", default=".") - p_check.add_argument("--dir", default="docs/decisions") - p_check.add_argument("--json", action="store_true") -``` - -Add `"check": check.run` to `HANDLERS`. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_check.py -v` -Expected: PASS (5 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/commands/check.py skills/adr-toolkit/scripts/adr.py \ - tests/unit/test_check.py -git commit -m "feat: add check command orchestrating diff against Accepted ADR constraints" -``` - ---- - -### Task 6: extend `check.py` — missing-realization heuristic + superseded-reference pass - -**Files:** -- Modify: `skills/adr-toolkit/scripts/commands/check.py` -- Test: `tests/unit/test_check.py` (add cases) - -**Interfaces:** -- Consumes: Task 5's `check.py` internals (`_load_adrs`, `_existing_paths`, `RESOLUTIONS`). -- Produces: two additional finding kinds on `run(args)`'s output: `review_required` (from a Verification-checklist scan, independent of whether a `constraints:` block exists) and `verified_violation` with `rule_id: "superseded_reference"` (from Superseded ADRs' `affected_paths` overlap). Both described in §16.3. - -- [ ] **Step 1: Write the failing test** - -Append to `tests/unit/test_check.py`: - -```python -ACCEPTED_ADR_WITH_VERIFICATION = """--- -id: ADR-0003 -title: Add event replay -status: accepted -date: 2026-08-03 -decision_makers: [] -related: [] -affected_paths: - - src/events/ -tags: [] -retrospective: false ---- - -# Add event replay - -## Verification - -* `src/events/replay.py` implements the replay handler. -""" - -SUPERSEDED_ADR = """--- -id: ADR-0004 -title: Use RabbitMQ -status: superseded -superseded_by: ADR-0005 -date: 2026-07-01 -decision_makers: [] -related: [] -affected_paths: - - src/queue/ -tags: [] -retrospective: false ---- - -# Use RabbitMQ - -Superseded. -""" - - -def test_review_required_when_verification_reference_is_removed(tmp_path): - _init_repo(tmp_path) - adr_dir = tmp_path / "docs" / "decisions" - adr_dir.mkdir(parents=True) - (adr_dir / "0003-add-event-replay.md").write_text(ACCEPTED_ADR_WITH_VERIFICATION, encoding="utf-8") - (tmp_path / "src" / "events").mkdir(parents=True) - (tmp_path / "src" / "events" / "replay.py").write_text("def replay(): pass\n", encoding="utf-8") - _git(["add", "-A"], tmp_path) - _git(["commit", "-q", "-m", "init"], tmp_path) - - (tmp_path / "src" / "events" / "replay.py").unlink() - - result = check.run(_args(tmp_path, adr_dir)) - - finding = next(f for f in result["findings"] if f["kind"] == "review_required") - assert finding["adr_id"] == "ADR-0003" - assert "src/events/replay.py" in finding["evidence"]["removed_paths"] - - -def test_superseded_reference_fires_on_affected_path_overlap(tmp_path): - _init_repo(tmp_path) - adr_dir = tmp_path / "docs" / "decisions" - adr_dir.mkdir(parents=True) - (adr_dir / "0004-use-rabbitmq.md").write_text(SUPERSEDED_ADR, encoding="utf-8") - _git(["add", "-A"], tmp_path) - _git(["commit", "-q", "-m", "init"], tmp_path) - - (tmp_path / "src" / "queue").mkdir(parents=True) - (tmp_path / "src" / "queue" / "client.py").write_text("x = 1\n", encoding="utf-8") - - result = check.run(_args(tmp_path, adr_dir)) - - finding = next(f for f in result["findings"] if f["rule_id"] == "superseded_reference") - assert finding["adr_id"] == "ADR-0004" - assert finding["kind"] == "verified_violation" - assert finding["evidence"]["superseded_by"] == "ADR-0005" - assert finding["resolutions"] == RESOLUTIONS -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_check.py -v` -Expected: FAIL — the two new finding kinds aren't produced yet. - -- [ ] **Step 3: Extend the implementation** - -In `skills/adr-toolkit/scripts/commands/check.py`, add near the top: - -```python -import re - -VERIFICATION_HEADING_RE = re.compile(r"^#+\s*Verification\s*$", re.IGNORECASE | re.MULTILINE) -NEXT_HEADING_RE = re.compile(r"^#+\s", re.MULTILINE) -PATH_TOKEN_RE = re.compile(r"`([^`]+\.[a-zA-Z0-9]+)`") -``` - -Replace the Accepted-ADR loop body's `if not produced_any:` block with (keeping everything above it unchanged): - -```python - review = _missing_realization(body, diff_files) - if review: - findings.append({"adr_id": adr_id, "kind": "review_required", **review}) - produced_any = True - - if not produced_any: - findings.append({"adr_id": adr_id, "kind": "related" if rules else "no_applicable_constraint"}) -``` - -After the Accepted-ADR loop, before the `return`, add the Superseded-ADR pass: - -```python - for entry in entries: - data = entry["data"] - if data.get("status") != "superseded": - continue - if not conflict.affected_paths_overlap(diff_files, data.get("affected_paths", [])): - continue - findings.append({ - "adr_id": data.get("id"), - "kind": "verified_violation", - "rule_id": "superseded_reference", - "severity": "major", - "message": ( - f"This path is governed by {data.get('id')}, which is superseded " - f"by {data.get('superseded_by')}. Review the replacement decision " - f"before proceeding." - ), - "file": None, - "evidence": {"superseded_by": data.get("superseded_by")}, - "resolutions": RESOLUTIONS, - }) -``` - -Add the missing-realization heuristic function: - -```python -def _missing_realization(body: str, diff_files: list): - heading = VERIFICATION_HEADING_RE.search(body) - if not heading: - return None - next_heading = NEXT_HEADING_RE.search(body, heading.end()) - section = body[heading.end():next_heading.start() if next_heading else len(body)] - - referenced_paths = PATH_TOKEN_RE.findall(section) - if not referenced_paths: - return None - - by_path = {f["path"]: f for f in diff_files} - removed = [p for p in referenced_paths if by_path.get(p, {}).get("change_type") == "deleted"] - if not removed: - return None - - return { - "message": f"Verification references {removed} which this diff removes.", - "evidence": {"removed_paths": removed}, - } -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_check.py -v` -Expected: PASS (7 tests — the 5 from Task 5 plus these 2) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/commands/check.py tests/unit/test_check.py -git commit -m "feat: add missing-realization and superseded-reference findings to check" -``` - ---- - -### Task 7: `references/conflict-rules.md` — constraint-authoring reference - -**Files:** -- Create: `skills/adr-toolkit/references/conflict-rules.md` -- Test: `tests/unit/test_conflict_rules_reference.py` - -**Interfaces:** -- Consumes: nothing (content only, read by the agent when drafting or explaining `constraints:` blocks and when presenting CHECK findings). -- Produces: the human/agent-readable explanation of the six rule kinds, the four-way classification, and the five resolution options. - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_conflict_rules_reference.py -from pathlib import Path - -REFERENCE = ( - Path(__file__).resolve().parent.parent.parent - / "skills" / "adr-toolkit" / "references" / "conflict-rules.md" -) - - -def test_reference_documents_all_six_rule_kinds(): - text = REFERENCE.read_text(encoding="utf-8") - for kind in [ - "forbidden_import", "required_path", "forbidden_path", - "dependency_forbidden", "file_must_exist", "test_must_exist", - ]: - assert kind in text - - -def test_reference_documents_the_four_classifications(): - text = REFERENCE.read_text(encoding="utf-8") - for label in ["Related", "Review required", "Verified violation", "No applicable constraint"]: - assert label in text - - -def test_reference_documents_the_five_resolutions(): - text = REFERENCE.read_text(encoding="utf-8") - for resolution in ["fix_code", "supersede_adr", "adjust_scope", "register_exception", "false_positive"]: - assert resolution in text -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_conflict_rules_reference.py -v` -Expected: FAIL — file doesn't exist. - -- [ ] **Step 3: Write the content** - -```markdown - -# Conflict Rules Reference - -CHECK matches a diff against Accepted ADRs' `constraints:` blocks and -Superseded ADRs' `affected_paths` — structural evidence only, never -semantic/AST analysis (see the design spec §7). - -## Writing a `constraints:` block - -Add a fenced YAML block to an ADR's `Implementation Constraints` section: - -```yaml -constraints: - - id: no-provider-sdk-in-feature - kind: forbidden_import - paths: ["src/features/**"] - pattern: ["openai\\.", "anthropic\\."] - severity: major - message: "Feature modules must use the LLM port." -``` - -Each rule needs `id`, `kind`, `paths` (glob list, `**` matches any depth), -`severity`, and `message`; `pattern` is required by every kind except the -existence-check kinds. `paths`/`pattern` values must be a JSON-style array -on one line — not YAML block-list syntax. - -## The six rule kinds - -- `forbidden_import` — fires if an added line in a file matching `paths` - matches any regex in `pattern`. Use for "this module must never import X." -- `dependency_forbidden` — mechanically identical to `forbidden_import`; - scope `paths` to a dependency manifest (`requirements.txt`, - `package.json`) instead of source files. -- `required_path` — fires if the diff touches a file matching `paths` but - no file matching `pattern` is touched by the diff or already exists in - the repository. Use for "touching X requires also touching/having Y." -- `forbidden_path` — fires if the diff touches a file matching `paths` AND - also touches a file matching `pattern`. Use for boundary violations - ("features must never touch db migrations directly"). -- `file_must_exist` / `test_must_exist` — fires if none of the paths - matching `paths` exist in the working tree after the diff. Identical - mechanism; `test_must_exist` is a naming convention for the ADR author, - not different logic. - -An ADR with no `constraints:` block has nothing CHECK can mechanically -enforce — that's a legitimate state, not an error. - -## Four-way classification - -- **Related** — the diff touches a path the ADR names, and its - `constraints:` block was evaluated but nothing fired. -- **Review required** — the ADR's `Verification` checklist references a - path or test the diff removes; this is prose-scanning, not a - `constraints:` rule, so it needs a human look rather than a mechanical - yes/no. -- **Verified violation** — a `constraints:` rule fired with direct - structural evidence, or the diff touches a path a Superseded ADR - governs (`rule_id: superseded_reference`). -- **No applicable constraint** — the diff touches a path the ADR names, - but the ADR has neither a `constraints:` block nor a matching - Verification reference. - -## Resolving a Verified violation - -Present all five options; never default to "revert the code": - -1. `fix_code` — change the diff to comply with the existing decision. -2. `supersede_adr` — the old decision no longer holds; record a new ADR - that supersedes it (`adr.py supersede`). -3. `adjust_scope` — the ADR's `affected_paths` or `constraints:` are too - broad or too narrow; edit them instead of the code. -4. `register_exception` — this specific case is a deliberate, documented - exception to an otherwise-still-valid rule. -5. `false_positive` — the rule fired but there is no real conflict; note - why so the rule can be tightened. -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_conflict_rules_reference.py -v` -Expected: PASS (3 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/references/conflict-rules.md tests/unit/test_conflict_rules_reference.py -git commit -m "docs: add conflict rules reference for CHECK" -``` - ---- - -### Task 8: `SKILL.md` — CHECK section - -**Files:** -- Modify: `skills/adr-toolkit/SKILL.md` -- Test: `tests/unit/test_skill_manifest.py` (add cases) - -**Interfaces:** -- Consumes: `diff`/`check` (Tasks 3, 5, 6), `references/conflict-rules.md` (Task 7). -- Produces: the CHECK operation contract an agent follows, replacing the "CHECK is not yet implemented" note in `## Script reference`. - -- [ ] **Step 1: Write the failing test** - -Append to `tests/unit/test_skill_manifest.py`: - -```python -def test_skill_md_documents_check(): - _, body = fm.parse(SKILL_MD.read_text(encoding="utf-8")) - assert "## CHECK" in body - assert "adr.py check" in body - assert "Verified violation" in body - assert "CHECK is not yet implemented" not in body -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_skill_manifest.py -v` -Expected: FAIL — section doesn't exist yet, and the stale note is still present. - -- [ ] **Step 3: Add the section** - -Insert into `skills/adr-toolkit/SKILL.md`, after `## Lifecycle operations` and before `## Prohibited`: - -```markdown -## CHECK - -Use CHECK to look for structural conflicts between a diff and existing -Accepted/Superseded ADRs before or during a change — never after merging, -since CHECK is read-only and never fixes anything itself. - -1. **PREFLIGHT** — run - `python skills/adr-toolkit/scripts/adr.py preflight --json`. If - `existing_adr_directory` is `null`, stop and tell the user to run INIT - first. -2. **GATHER EVIDENCE** — run - `python skills/adr-toolkit/scripts/adr.py check --uncommitted --dir docs/decisions --json` - (or `--staged`, or `--since ` for a branch/commit range — pick the - mode that matches what the user asked to check). Read - `references/conflict-rules.md` if you need to explain a finding or help - the user write a `constraints:` block. -3. **CLASSIFY** — each finding already carries its classification - (`related`, `review_required`, `verified_violation`, - `no_applicable_constraint`); do not re-judge it, report it as returned. -4. **REPORT** — group findings by classification. For every - `verified_violation`, present all five resolutions from - `references/conflict-rules.md` (`fix_code`, `supersede_adr`, - `adjust_scope`, `register_exception`, `false_positive`) — never assume - which one the user wants. -5. **ASK-IF-NEEDED / MUTATE** — CHECK itself never writes anything. If the - user picks `fix_code`, that's a normal code edit, not an ADR Toolkit - operation. If they pick `supersede_adr` or `adjust_scope`, follow the - RECORD or Lifecycle operations flow above — never hand-edit - `constraints:`, `affected_paths`, or `status`. -6. Any `check` `warnings` entries (e.g. `BAD_FRONTMATTER`, - `BAD_CONSTRAINTS`) mean one ADR was skipped, not that CHECK failed — - report them, but don't block on them. -``` - -Also, in `## Script reference`, replace: - -```markdown -CHECK is not yet implemented (see `project-roadmap.md` in the repository root -and the design spec this skill is built from). -``` - -with: - -```markdown -CHECK's conflict detection is deliberately limited to structural evidence -from `constraints:` blocks (see `references/conflict-rules.md`) — it never -attempts semantic or AST-level analysis; see `project-roadmap.md` for what -that fuller scope would look like. -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_skill_manifest.py -v` -Expected: PASS (all prior cases plus this one) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/SKILL.md tests/unit/test_skill_manifest.py -git commit -m "feat: document CHECK operation in SKILL.md" -``` - ---- - -### Task 9: End-to-end RECORD-then-CHECK fixture and golden test - -**Files:** -- Create: `tests/fixtures/check_provider_port/docs/decisions/0001-use-a-provider-port.md` -- Create: `tests/integration/test_check_workflow.py` - -**Interfaces:** -- Consumes: `check`, `diff` via subprocess (Tasks 3, 5, 6), following the `_run` diagnostic-assertion pattern established in `tests/integration/test_record_workflow.py`. -- Produces: the golden proof that CHECK's scripted steps compose correctly against a repo with a real git history and a constraint-bearing Accepted ADR. - -- [ ] **Step 1: Write the failing test** - -```markdown - ---- -id: ADR-0001 -title: Use a provider port for LLM calls -status: accepted -date: 2026-08-01 -decision_makers: [] -related: [] -affected_paths: - - src/features/ -tags: - - architecture -retrospective: false ---- - -# Use a provider port for LLM calls - -## Implementation Constraints - -Feature modules must go through the LLM port; they must never import a -provider SDK directly. - -```yaml -constraints: - - id: no-provider-sdk-in-feature - kind: forbidden_import - paths: ["src/features/**"] - pattern: ["openai", "anthropic"] - severity: major - message: "Feature modules must use the LLM port, not a provider SDK directly." -``` -``` - -```python -# tests/integration/test_check_workflow.py -import json -import shutil -import subprocess -import sys -from pathlib import Path - -FIXTURE = Path(__file__).resolve().parent.parent / "fixtures" / "check_provider_port" -ADR_PY = Path(__file__).resolve().parent.parent.parent / "skills" / "adr-toolkit" / "scripts" / "adr.py" - - -def _run(args, cwd): - command = [sys.executable, str(ADR_PY), *args] - result = subprocess.run(command, cwd=cwd, capture_output=True, text=True) - assert result.returncode == 0, ( - f"CLI command failed: {' '.join(command)}\n" - f"cwd: {cwd}\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}" - ) - try: - payload = json.loads(result.stdout) - except json.JSONDecodeError as exc: - raise AssertionError( - f"CLI command did not return JSON: {' '.join(command)}\n" - f"cwd: {cwd}\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}" - ) from exc - assert payload.get("ok") is True, ( - f"CLI command reported failure: {' '.join(command)}\ncwd: {cwd}\npayload: {payload!r}" - ) - return payload - - -def _git(args, cwd): - subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True) - - -def test_check_flags_a_violation_then_clears_after_the_fix(tmp_path): - repo = tmp_path / "repo" - shutil.copytree(FIXTURE, repo) - _git(["init", "-q", "-b", "master"], repo) - _git(["config", "user.email", "test@example.com"], repo) - _git(["config", "user.name", "Test"], repo) - _git(["add", "-A"], repo) - _git(["commit", "-q", "-m", "init"], repo) - - features_dir = repo / "src" / "features" - features_dir.mkdir(parents=True) - (features_dir / "chat.py").write_text("import openai\n\ndef handler():\n pass\n", encoding="utf-8") - - violating_result = _run( - ["check", "--uncommitted", "--dir", "docs/decisions", "--json"], cwd=repo, - ) - violation = next(f for f in violating_result["findings"] if f["kind"] == "verified_violation") - assert violation["adr_id"] == "ADR-0001" - assert violation["rule_id"] == "no-provider-sdk-in-feature" - assert set(violation["resolutions"]) == { - "fix_code", "supersede_adr", "adjust_scope", "register_exception", "false_positive", - } - - (features_dir / "chat.py").write_text( - "from src.core.ports.llm import LLMPort\n\ndef handler():\n pass\n", encoding="utf-8", - ) - - fixed_result = _run( - ["check", "--uncommitted", "--dir", "docs/decisions", "--json"], cwd=repo, - ) - assert all(f["kind"] != "verified_violation" for f in fixed_result["findings"]) - related = next(f for f in fixed_result["findings"] if f["adr_id"] == "ADR-0001") - assert related["kind"] == "related" -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/integration/test_check_workflow.py -v` -Expected: FAIL until Step 1's fixture file exists. - -- [ ] **Step 3: Nothing further to implement** - -This task's "implementation" is the fixture itself. No production code changes — it proves Tasks 1–8 already compose correctly end to end, including that fixing the violation clears it on a second `check` run. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/integration/test_check_workflow.py -v` -Expected: PASS (1 test) - -Then run the full suite: - -Run: `python -m pytest tests/unit tests/integration -v` -Expected: PASS (all prior tests plus this plan's new ones) - -- [ ] **Step 5: Commit** - -```bash -git add tests/fixtures/check_provider_port tests/integration/test_check_workflow.py -git commit -m "test: add end-to-end CHECK fixture and golden test" -``` - ---- - -## Plan self-review notes - -- **Spec coverage:** §7 (six rule kinds, four-way classification, five resolutions) → Tasks 2, 4, 5, 6, 7. §11 CHECK data flow → Tasks 3, 5, 6, 8. §16.1 module split → Tasks 1–6 (`globs.py`/`constraints.py` under `core/`, `conflict.py` under `rules/`, `diff.py`/`check.py` under `commands/`). §16.2 matching mechanisms → Task 4. §16.3 classification algorithm → Tasks 5–6. §16.4 constraints scope (Accepted only) → Task 5's `if data.get("status") != "accepted": continue`. §16.5 diff CLI shape → Task 3. §16.6 output/error handling → Tasks 3, 5 (specific error codes; `warnings` degrade path). §16.7 testing strategy → every task's own unit tests plus Task 9's golden test. -- **Type consistency checked:** `diff.run`'s `files` shape (`{path, change_type, added_lines, removed_lines}`, Task 3) matches exactly what `conflict.py` (Task 4) and `check.py` (Tasks 5–6) consume. `conflict.evaluate_rule`'s return shape (`rule_id`/`kind`/`severity`/`message`/`file`/`evidence`) matches how Task 5 merges it into a finding (`{**violation, "adr_id", "resolutions"}`). `extract_constraints`'s rule dicts (Task 2: `id`/`kind`/`paths`/`pattern`/`severity`/`message`) match the field names `conflict.py` reads. `RESOLUTIONS` is defined once in `check.py` and reused for both `constraints:`-sourced and `superseded_reference` violations, not duplicated. -- **No placeholders found.** diff --git a/docs/superpowers/plans/2026-08-30-adr-toolkit-i18n-adapters-release.md b/docs/superpowers/plans/2026-08-30-adr-toolkit-i18n-adapters-release.md deleted file mode 100644 index 95e9ee8..0000000 --- a/docs/superpowers/plans/2026-08-30-adr-toolkit-i18n-adapters-release.md +++ /dev/null @@ -1,1142 +0,0 @@ -# ADR Toolkit — i18n, Adapters, Release Automation (Plan 4 of 4) Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Close out the MVP — localize `index.py`'s generated `README.md` into 5 languages, add light adapters for Codex CLI, Gemini CLI, and Antigravity CLI (verified manifest formats, not guessed), and add version synchronization plus a tag-triggered release workflow. - -**Architecture:** Three independent layers, matching the plan's three sections. i18n: a small `core/locale.py` loader (English-fallback merge) plus five flat `scripts/i18n/{locale}.json` files, consumed only by `index.py`'s `--locale` flag — no other module needs translation, since everything else is agent-composed prose. Adapters: three new `adapters//` directories, each holding only a manifest file plus a README documenting a symlink-based install (mirroring `adapters/generic/README.md`) — no symlink is committed to the repo itself, since a real symlink checked into git breaks on Windows CI (`windows-latest` is in `test.yml`'s matrix) unless `core.symlinks` is enabled, which cannot be assumed. Release: a repo-root `scripts/sync_version.py` (tooling for the repo, not part of the distributable `skills/adr-toolkit/` package) plus a CI drift-check and a new tag-triggered `release.yml`. - -**Tech Stack:** Same as Plans 1–3 — Python 3.9+ standard library only, pytest, GitHub Actions. - -**Spec:** `docs/superpowers/specs/2026-08-29-adr-toolkit-design.md` (§17 Plan 4 implementation decisions — primary source; §8 harness support strategy; §9 internationalization scope) - -## Global Constraints - -- Same as Plans 1–3: Python 3.9+ stdlib only; UTF-8 explicit; no `shell=True`; command modules never `print()`; avoid `X | None` union syntax, use `typing.Optional`. -- i18n covers only `index.py`'s generated strings (§17.1) — never SKILL.md's own agent instructions, never RECORD/DISCOVER/CHECK's agent-composed questions/reports. A locale file or key that's missing falls back to English, never a crash and never a raw key name shown to a user. -- No adapter directory commits an actual symlink to git — every adapter's `skills/adr-toolkit` link is created at install time per that adapter's README, exactly like `adapters/generic/README.md` already documents. This avoids Windows CI (`test.yml`'s `windows-latest` matrix leg) breaking on an uncommitted-symlink-support assumption. -- `scripts/sync_version.py` lives at the repo root, not under `skills/adr-toolkit/` — it is tooling for this repository, not part of the distributable skill package. -- Manual end-to-end adapter verification (§17.2) only happens where a real harness CLI is present in this environment (`codex` 0.151.0, `gemini` 0.46.0 — confirmed installed). The Antigravity task is scoped to structural/schema verification only; its report must say so plainly, never claim an end-to-end run that didn't happen. -- Every task must leave `python -m pytest tests/unit tests/integration -v` green before its commit step. -- Out of scope for this plan: the CHECK follow-ups tracked in `project-roadmap.md`'s "CHECK follow-ups" section, and `adr.py`'s `--json`-always-emits-JSON standing risk (tracked in `handoff.md`, untouched here). - ---- - -### Task 1: `core/locale.py` + the five locale JSON files - -**Files:** -- Create: `skills/adr-toolkit/scripts/core/locale.py` -- Create: `skills/adr-toolkit/scripts/i18n/en.json` -- Create: `skills/adr-toolkit/scripts/i18n/fr.json` -- Create: `skills/adr-toolkit/scripts/i18n/ja.json` -- Create: `skills/adr-toolkit/scripts/i18n/ko.json` -- Create: `skills/adr-toolkit/scripts/i18n/zh.json` -- Test: `tests/unit/test_locale.py` - -**Interfaces:** -- Consumes: nothing. -- Produces: `load_locale(locale: str) -> dict`, `DEFAULT_LOCALE = "en"`. Used by Task 2 (`commands/index.py`). - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_locale.py -from scripts.core.locale import DEFAULT_LOCALE, load_locale - -REQUIRED_KEYS = { - "decision_log_title", "by_status", "by_tag", "by_affected_path", - "chronological", "status.proposed", "status.accepted", - "status.rejected", "status.deprecated", "status.superseded", -} - - -def test_default_locale_is_english(): - assert DEFAULT_LOCALE == "en" - - -def test_english_locale_has_all_required_keys(): - strings = load_locale("en") - assert REQUIRED_KEYS.issubset(strings.keys()) - - -def test_every_shipped_locale_has_all_required_keys(): - for locale in ("en", "fr", "ja", "ko", "zh"): - strings = load_locale(locale) - missing = REQUIRED_KEYS - strings.keys() - assert not missing, f"{locale} is missing keys: {missing}" - - -def test_french_translates_a_known_string(): - strings = load_locale("fr") - assert strings["by_status"] == "Par statut" - - -def test_missing_locale_falls_back_to_english(): - strings = load_locale("xx") - assert strings["by_status"] == "By status" - - -def test_missing_key_in_a_present_locale_falls_back_to_english(tmp_path, monkeypatch): - import scripts.core.locale as locale_module - partial_dir = tmp_path / "i18n" - partial_dir.mkdir() - (partial_dir / "en.json").write_text('{"by_status": "By status"}', encoding="utf-8") - (partial_dir / "xx.json").write_text('{}', encoding="utf-8") - monkeypatch.setattr(locale_module, "I18N_DIR", partial_dir) - - strings = locale_module.load_locale("xx") - - assert strings["by_status"] == "By status" -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_locale.py -v` -Expected: FAIL — `scripts.core.locale` doesn't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -# skills/adr-toolkit/scripts/core/locale.py -"""Load i18n locale files for index.py's generated strings. - -Every other user-facing string in this toolkit (RECORD/DISCOVER/CHECK's -interview questions and reports) is composed by the agent, not a fixed -Python string, so it needs no translation table — only index.py's -generated README.md content is a translation target (design spec §17.1). -""" -import json -from pathlib import Path - -I18N_DIR = Path(__file__).resolve().parent.parent / "i18n" -DEFAULT_LOCALE = "en" - - -def load_locale(locale: str) -> dict: - base = _load_json(DEFAULT_LOCALE) - if locale == DEFAULT_LOCALE: - return base - overlay = _load_json(locale) - return {**base, **overlay} - - -def _load_json(locale: str) -> dict: - path = I18N_DIR / f"{locale}.json" - if not path.is_file(): - return {} - return json.loads(path.read_text(encoding="utf-8")) -``` - -```json -// skills/adr-toolkit/scripts/i18n/en.json -{ - "decision_log_title": "Decision Log", - "by_status": "By status", - "by_tag": "By tag", - "by_affected_path": "By affected path", - "chronological": "Chronological (newest first)", - "status.proposed": "Proposed", - "status.accepted": "Accepted", - "status.rejected": "Rejected", - "status.deprecated": "Deprecated", - "status.superseded": "Superseded" -} -``` - -```json -// skills/adr-toolkit/scripts/i18n/fr.json -{ - "decision_log_title": "Journal des décisions", - "by_status": "Par statut", - "by_tag": "Par étiquette", - "by_affected_path": "Par chemin concerné", - "chronological": "Chronologique (plus récent d'abord)", - "status.proposed": "Proposé", - "status.accepted": "Accepté", - "status.rejected": "Rejeté", - "status.deprecated": "Déprécié", - "status.superseded": "Remplacé" -} -``` - -```json -// skills/adr-toolkit/scripts/i18n/ja.json -{ - "decision_log_title": "決定記録", - "by_status": "ステータス別", - "by_tag": "タグ別", - "by_affected_path": "影響パス別", - "chronological": "時系列(新しい順)", - "status.proposed": "提案中", - "status.accepted": "承認済み", - "status.rejected": "却下", - "status.deprecated": "非推奨", - "status.superseded": "置き換え済み" -} -``` - -```json -// skills/adr-toolkit/scripts/i18n/ko.json -{ - "decision_log_title": "결정 기록", - "by_status": "상태별", - "by_tag": "태그별", - "by_affected_path": "영향 경로별", - "chronological": "시간순 (최신순)", - "status.proposed": "제안됨", - "status.accepted": "승인됨", - "status.rejected": "거부됨", - "status.deprecated": "폐기됨", - "status.superseded": "대체됨" -} -``` - -```json -// skills/adr-toolkit/scripts/i18n/zh.json -{ - "decision_log_title": "决策日志", - "by_status": "按状态", - "by_tag": "按标签", - "by_affected_path": "按受影响路径", - "chronological": "按时间顺序(最新优先)", - "status.proposed": "已提议", - "status.accepted": "已接受", - "status.rejected": "已拒绝", - "status.deprecated": "已弃用", - "status.superseded": "已取代" -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_locale.py -v` -Expected: PASS (6 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/core/locale.py skills/adr-toolkit/scripts/i18n \ - tests/unit/test_locale.py -git commit -m "feat: add locale loader and five i18n string files" -``` - ---- - -### Task 2: `index.py --locale` — localized index generation - -**Files:** -- Modify: `skills/adr-toolkit/scripts/commands/index.py` -- Modify: `skills/adr-toolkit/scripts/adr.py` -- Test: `tests/unit/test_index.py` (add cases) - -**Interfaces:** -- Consumes: `scripts.core.locale.load_locale` (Task 1). -- Produces: `run(args) -> dict` where `args` gains an optional `.locale` attribute (default `"en"` when absent). `_render(entries, strings)` now takes the loaded locale dict as a second parameter. Used by Task 3 (`SKILL.md`, documentation only — no code dependency). - -- [ ] **Step 1: Write the failing test** - -Append to `tests/unit/test_index.py`: - -```python -def test_index_renders_french_headers_and_status_labels(tmp_path): - _write_adr( - tmp_path, "0001-use-kafka.md", - id_="ADR-0001", title="Use Kafka", status="accepted", date="2026-08-01", - tags=["architecture"], affected_paths=["src/events/"], - ) - - result = index.run(SimpleNamespace(dir=str(tmp_path), locale="fr")) - - assert result["ok"] is True - readme = (tmp_path / "README.md").read_text(encoding="utf-8") - assert "Journal des décisions" in readme - assert "Par statut" in readme - assert "Accepté" in readme - assert "Par étiquette" in readme - assert "Par chemin concerné" in readme - assert "Chronologique (plus récent d'abord)" in readme - - -def test_index_defaults_to_english_when_locale_omitted(tmp_path): - _write_adr( - tmp_path, "0001-use-kafka.md", - id_="ADR-0001", title="Use Kafka", status="accepted", date="2026-08-01", - tags=["architecture"], affected_paths=["src/events/"], - ) - - result = index.run(SimpleNamespace(dir=str(tmp_path))) - - assert result["ok"] is True - readme = (tmp_path / "README.md").read_text(encoding="utf-8") - assert "# Decision Log" in readme - assert "## By status" in readme - - -def test_index_unknown_status_falls_back_to_capitalized_label(tmp_path): - _write_adr( - tmp_path, "0001-use-kafka.md", - id_="ADR-0001", title="Use Kafka", status="unknown", date="2026-08-01", - tags=[], affected_paths=[], - ) - - result = index.run(SimpleNamespace(dir=str(tmp_path), locale="fr")) - - assert result["ok"] is True - readme = (tmp_path / "README.md").read_text(encoding="utf-8") - assert "### Unknown" in readme -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_index.py -v` -Expected: FAIL — `index.run` doesn't accept/use a locale yet, headers stay English-only. - -- [ ] **Step 3: Extend the implementation** - -In `skills/adr-toolkit/scripts/commands/index.py`, add the import: - -```python -from scripts.core.locale import load_locale -``` - -Replace `run`'s body up to the `README.md` write: - -```python -def run(args) -> dict: - adr_dir = Path(args.dir) - locale = getattr(args, "locale", None) or "en" - strings = load_locale(locale) - entries = [] - warnings = [] - - for entry in sorted(adr_dir.glob("*.md")): - if entry.name in SKIP_FILES: - continue - parsed = identifiers.parse_filename(entry.name) - if parsed is None: - continue - try: - data, _ = fm.parse(entry.read_text(encoding="utf-8")) - except fm.FrontmatterError as exc: - warnings.append({"code": "BAD_FRONTMATTER", "file": entry.name, "detail": str(exc)}) - continue - entries.append({ - "id": data.get("id", f"ADR-{parsed[0]:04d}"), - "filename": entry.name, - "title": data.get("title", parsed[1]), - "status": data.get("status", "unknown"), - "date": data.get("date", ""), - "tags": data.get("tags", []), - "affected_paths": data.get("affected_paths", []), - }) - - (adr_dir / "README.md").write_text(_render(entries, strings), encoding="utf-8") - - return { - "ok": True, - "operation": "index", - "count": len(entries), - "path": str(adr_dir / "README.md"), - "warnings": warnings, - } -``` - -Replace `_render` to take and use `strings`: - -```python -def _render(entries: list, strings: dict) -> str: - lines = [f"# {strings['decision_log_title']}", ""] - - lines.append(f"## {strings['by_status']}") - lines.append("") - by_status: dict = {} - for entry in entries: - by_status.setdefault(entry["status"], []).append(entry) - for status in sorted(by_status): - label = strings.get(f"status.{status}", status.capitalize()) - lines.append(f"### {label}") - for entry in sorted(by_status[status], key=lambda e: e["filename"]): - lines.append(f"- [{entry['id']} — {entry['title']}]({entry['filename']})") - lines.append("") - - lines.append(f"## {strings['by_tag']}") - lines.append("") - by_tag: dict = {} - for entry in entries: - for tag in entry["tags"]: - by_tag.setdefault(tag, []).append(entry) - for tag in sorted(by_tag): - lines.append(f"### {tag}") - for entry in sorted(by_tag[tag], key=lambda e: e["filename"]): - lines.append(f"- [{entry['id']} — {entry['title']}]({entry['filename']})") - lines.append("") - - lines.append(f"## {strings['by_affected_path']}") - lines.append("") - by_path: dict = {} - for entry in entries: - for path in entry["affected_paths"]: - by_path.setdefault(path, []).append(entry) - for path in sorted(by_path): - lines.append(f"### `{path}`") - for entry in sorted(by_path[path], key=lambda e: e["filename"]): - lines.append(f"- [{entry['id']} — {entry['title']}]({entry['filename']})") - lines.append("") - - lines.append(f"## {strings['chronological']}") - lines.append("") - for entry in sorted(entries, key=lambda e: e["date"], reverse=True): - lines.append(f"- {entry['date']} — [{entry['id']} — {entry['title']}]({entry['filename']})") - - return "\n".join(lines) + "\n" -``` - -In `skills/adr-toolkit/scripts/adr.py`, find the `p_index` subparser and add a `--locale` argument: - -```python - p_index = sub.add_parser("index") - p_index.add_argument("--dir", default="docs/decisions") - p_index.add_argument("--locale", default="en") - p_index.add_argument("--json", action="store_true") -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_index.py -v` -Expected: PASS (all prior cases plus these 3) - -Then run the full suite: - -Run: `python -m pytest tests/unit tests/integration -v` -Expected: PASS (no regressions — `test_check_workflow.py` and `test_record_workflow.py` both call `index` without `--locale`, which must still default to English) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/commands/index.py skills/adr-toolkit/scripts/adr.py \ - tests/unit/test_index.py -git commit -m "feat: add --locale to index for localized README generation" -``` - ---- - -### Task 3: `SKILL.md` — agent language-detection instruction - -**Files:** -- Modify: `skills/adr-toolkit/SKILL.md` -- Test: `tests/unit/test_skill_manifest.py` (add case) - -**Interfaces:** -- Consumes: `index --locale` (Task 2, referenced by name only — this is a documentation task, not a code dependency). -- Produces: the agent-facing instruction that closes the loop between "the agent detects a language" and "the agent tells `index` to use it." - -- [ ] **Step 1: Write the failing test** - -Append to `tests/unit/test_skill_manifest.py`: - -```python -def test_skill_md_documents_locale_detection(): - _, body = fm.parse(SKILL_MD.read_text(encoding="utf-8")) - assert "--locale" in body - assert "fr" in body and "ja" in body and "ko" in body and "zh" in body -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_skill_manifest.py -v` -Expected: FAIL — no locale instruction exists yet. - -- [ ] **Step 3: Add the instruction** - -In `skills/adr-toolkit/SKILL.md`, add a new subsection right after `## Workflow contract` and before `## INIT (scaffolding only)`: - -```markdown -## Language - -Detect the language of the user's own request (English, French, Japanese, -Korean, or Chinese) and compose every question and report you write in -that language — English is the default when the language can't be -determined. This applies only to what you write yourself; it does not -change any file content the user themselves wrote in a different -language, and it does not translate `SKILL.md`'s own instructions. - -When you run `index`, pass the same locale as a two-letter code so the -generated `docs/decisions/README.md` headers match -(`--locale en|fr|ja|ko|zh`, defaulting to `en` when omitted): - -```bash -python skills/adr-toolkit/scripts/adr.py index --dir docs/decisions --locale fr --json -``` -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_skill_manifest.py -v` -Expected: PASS (all prior cases plus this one) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/SKILL.md tests/unit/test_skill_manifest.py -git commit -m "docs: instruct the agent to detect language and pass index --locale" -``` - ---- - -### Task 4: Codex CLI adapter — manifest, README, structural test, manual verification - -**Files:** -- Create: `adapters/codex/.codex-plugin/plugin.json` -- Create: `adapters/codex/README.md` -- Test: `tests/unit/test_codex_adapter.py` - -**Interfaces:** -- Consumes: nothing (manifest is static JSON). -- Produces: nothing consumed by later tasks — adapters are independent leaves. - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_codex_adapter.py -import json -from pathlib import Path - -MANIFEST = ( - Path(__file__).resolve().parent.parent.parent - / "adapters" / "codex" / ".codex-plugin" / "plugin.json" -) - - -def test_manifest_is_valid_json_with_required_fields(): - data = json.loads(MANIFEST.read_text(encoding="utf-8")) - assert data["name"] == "adr-toolkit" - - -def test_manifest_has_no_extra_undocumented_top_level_keys(): - data = json.loads(MANIFEST.read_text(encoding="utf-8")) - assert set(data.keys()) <= {"$schema", "name", "description"} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_codex_adapter.py -v` -Expected: FAIL — manifest doesn't exist. - -- [ ] **Step 3: Write the manifest and README** - -```json -// adapters/codex/.codex-plugin/plugin.json -{ - "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", - "name": "adr-toolkit", - "description": "Initialize, record, and check Architecture Decision Records by inspecting the repository and existing decisions before asking questions." -} -``` - -```markdown - -# Codex CLI adapter - -Follows the cross-vendor Agent Plugins 1.0.0 standard -(`github.com/agentplugins/agent-plugins-spec`), which Codex CLI adopted -2026-08-07. This manifest needs only `name` — no `"skills"` key, since -Agent Plugins auto-discovers a sibling `skills/` directory. - -## Install - -1. Copy this repo's `skills/adr-toolkit/` package somewhere Codex can - reach it, then symlink it under this adapter's plugin directory as - `skills/adr-toolkit` (a sibling of `plugin.json`, inside - `.codex-plugin/`): - - ```bash - mkdir -p adapters/codex/.codex-plugin/skills - ln -s "$(pwd)/skills/adr-toolkit" adapters/codex/.codex-plugin/skills/adr-toolkit - ``` - -2. Point Codex at `adapters/codex/.codex-plugin/` per Codex's own plugin - install documentation (`codex --help` documents the exact - install/reload subcommand — it may change as the Agent Plugins - standard matures, since this adapter shipped within days of the - standard's own release). -3. Confirm Codex lists `adr-toolkit` as an available skill, then run - `python skills/adr-toolkit/scripts/adr.py preflight --json` from your - target repository to confirm the script layer works standalone. - -The symlink is created at install time, not committed to this repo — -committing a real symlink breaks on Windows checkouts that don't have -`core.symlinks` enabled, which this project's CI can't assume. -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_codex_adapter.py -v` -Expected: PASS (2 tests) - -- [ ] **Step 5: Manual end-to-end verification** - -`codex` CLI (0.151.0) is installed in this environment — actually perform this install and record the real transcript in your task report (not a hypothetical): - -```bash -mkdir -p /tmp/adr-toolkit-codex-verify && cd /tmp/adr-toolkit-codex-verify -git init -q -mkdir -p .codex-plugin/skills -ln -s "/skills/adr-toolkit" .codex-plugin/skills/adr-toolkit -cp "/adapters/codex/.codex-plugin/plugin.json" .codex-plugin/plugin.json -codex --help -# ^ read this output to find the actual plugin-list/plugin-install subcommand — -# do not guess a subcommand name; Codex's plugin CLI surface is new as of -# 2026-08-07 and this plan cannot assert its exact flags with confidence. -# Use whatever subcommand codex --help documents to confirm the adapter -# is discoverable, then run the toolkit directly to prove the symlinked -# skill actually works end to end: -python .codex-plugin/skills/adr-toolkit/scripts/adr.py preflight --json -python .codex-plugin/skills/adr-toolkit/scripts/adr.py init --dir docs/decisions --json -python .codex-plugin/skills/adr-toolkit/scripts/adr.py validate --dir docs/decisions --json -``` - -Record in the task report: the exact `codex --help` output relevant to plugin discovery, whether Codex actually lists `adr-toolkit`, and the `preflight`/`init`/`validate` JSON output proving the symlinked skill runs correctly from inside a Codex-style plugin layout. If Codex's plugin CLI doesn't expose a discovery command yet (the standard is very new), say so plainly and rely on the direct script execution as the verification instead — do not claim a Codex-side discovery check that didn't happen. - -- [ ] **Step 6: Commit** - -```bash -git add adapters/codex tests/unit/test_codex_adapter.py -git commit -m "feat: add Codex CLI adapter manifest and install docs" -``` - ---- - -### Task 5: Gemini CLI adapter — manifest, README, structural test, manual verification - -**Files:** -- Create: `adapters/gemini-cli/gemini-extension.json` -- Create: `adapters/gemini-cli/README.md` -- Test: `tests/unit/test_gemini_cli_adapter.py` - -**Interfaces:** -- Consumes: nothing. -- Produces: nothing consumed by later tasks. - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_gemini_cli_adapter.py -import json -from pathlib import Path - -MANIFEST = ( - Path(__file__).resolve().parent.parent.parent - / "adapters" / "gemini-cli" / "gemini-extension.json" -) - - -def test_manifest_is_valid_json_with_required_fields(): - data = json.loads(MANIFEST.read_text(encoding="utf-8")) - assert data["name"] == "adr-toolkit" - - -def test_manifest_name_uses_dashes_not_underscores_or_spaces(): - data = json.loads(MANIFEST.read_text(encoding="utf-8")) - assert " " not in data["name"] - assert "_" not in data["name"] -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_gemini_cli_adapter.py -v` -Expected: FAIL — manifest doesn't exist. - -- [ ] **Step 3: Write the manifest and README** - -```json -// adapters/gemini-cli/gemini-extension.json -{ - "name": "adr-toolkit", - "version": "0.1.0", - "description": "Initialize, record, and check Architecture Decision Records by inspecting the repository and existing decisions before asking questions." -} -``` - -```markdown - -# Gemini CLI adapter - -Gemini CLI extensions declare themselves via a root `gemini-extension.json` -(see `geminicli.com/docs/extensions/reference/`), expecting a sibling -`skills/` directory holding `SKILL.md` files. - -## Install - -1. Symlink this repo's `skills/adr-toolkit/` package under this adapter's - directory: - - ```bash - mkdir -p adapters/gemini-cli/skills - ln -s "$(pwd)/skills/adr-toolkit" adapters/gemini-cli/skills/adr-toolkit - ``` - -2. Install the extension per Gemini CLI's own extension-install command - (`gemini extensions install ` at the time - this adapter was verified — confirm against `gemini --help` / - `gemini extensions --help`, since command surfaces move faster than - this file). -3. Confirm Gemini CLI lists `adr-toolkit` as an installed extension, then - run `python skills/adr-toolkit/scripts/adr.py preflight --json` from - your target repository to confirm the script layer works standalone. - -The symlink is created at install time, not committed to this repo — -committing a real symlink breaks on Windows checkouts that don't have -`core.symlinks` enabled, which this project's CI can't assume. -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_gemini_cli_adapter.py -v` -Expected: PASS (2 tests) - -- [ ] **Step 5: Manual end-to-end verification** - -`gemini` CLI (0.46.0) is installed in this environment — actually perform this install and record the real transcript in your task report: - -```bash -mkdir -p /tmp/adr-toolkit-gemini-verify && cd /tmp/adr-toolkit-gemini-verify -git init -q -mkdir -p skills -ln -s "/skills/adr-toolkit" skills/adr-toolkit -cp "/adapters/gemini-cli/gemini-extension.json" gemini-extension.json -gemini extensions --help -# ^ read this output to find the actual install/list subcommand — do not -# guess; confirm the real syntax before running it. -# Then, whatever the real install command is, run it against this directory -# and confirm gemini lists adr-toolkit as an installed extension. Finally -# prove the symlinked skill runs correctly end to end: -python skills/adr-toolkit/scripts/adr.py preflight --json -python skills/adr-toolkit/scripts/adr.py init --dir docs/decisions --json -python skills/adr-toolkit/scripts/adr.py validate --dir docs/decisions --json -``` - -Record in the task report: the exact `gemini extensions --help` output relevant to install/discovery, whether Gemini CLI actually lists `adr-toolkit` after installing, and the `preflight`/`init`/`validate` JSON output. - -- [ ] **Step 6: Commit** - -```bash -git add adapters/gemini-cli tests/unit/test_gemini_cli_adapter.py -git commit -m "feat: add Gemini CLI adapter manifest and install docs" -``` - ---- - -### Task 6: Antigravity CLI adapter — manifest, README, structural test only - -**Files:** -- Create: `adapters/antigravity/plugin.json` -- Create: `adapters/antigravity/README.md` -- Test: `tests/unit/test_antigravity_adapter.py` - -**Interfaces:** -- Consumes: nothing. -- Produces: nothing consumed by later tasks. - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_antigravity_adapter.py -import json -import re -from pathlib import Path - -MANIFEST = ( - Path(__file__).resolve().parent.parent.parent - / "adapters" / "antigravity" / "plugin.json" -) - -NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$") - - -def test_manifest_is_valid_json_with_required_fields(): - data = json.loads(MANIFEST.read_text(encoding="utf-8")) - assert data["name"] == "adr-toolkit" - - -def test_manifest_name_matches_antigravity_naming_rule(): - data = json.loads(MANIFEST.read_text(encoding="utf-8")) - assert NAME_RE.match(data["name"]), "name must be alphanumeric, hyphens, or underscores only" - - -def test_manifest_schema_field_points_at_antigravity_schema(): - data = json.loads(MANIFEST.read_text(encoding="utf-8")) - assert data["$schema"] == "https://antigravity.google/schemas/v1/plugin.json" -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_antigravity_adapter.py -v` -Expected: FAIL — manifest doesn't exist. - -- [ ] **Step 3: Write the manifest and README** - -```json -// adapters/antigravity/plugin.json -{ - "$schema": "https://antigravity.google/schemas/v1/plugin.json", - "name": "adr-toolkit", - "description": "Initialize, record, and check Architecture Decision Records by inspecting the repository and existing decisions before asking questions." -} -``` - -```markdown - -# Antigravity CLI adapter - -Antigravity plugins are a `plugin.json` marker file plus optional sibling -directories (`skills/`, `agents/`, `rules/`), per -`antigravity.google/docs/cli/plugins/`. This manifest needs only `name`. - -## Install - -1. Symlink this repo's `skills/adr-toolkit/` package under this adapter's - directory: - - ```bash - mkdir -p adapters/antigravity/skills - ln -s "$(pwd)/skills/adr-toolkit" adapters/antigravity/skills/adr-toolkit - ``` - -2. Install per Antigravity's own plugin-install documentation. -3. Confirm Antigravity lists `adr-toolkit`, then run - `python skills/adr-toolkit/scripts/adr.py preflight --json` from your - target repository to confirm the script layer works standalone. - -**Verification status:** this adapter's manifest and directory layout are -verified against Antigravity's published schema, but — unlike the Codex -and Gemini CLI adapters — no end-to-end install-and-run was performed, -because no `antigravity` CLI binary was available in the development -environment that built this adapter. Treat it as unverified until someone -with the actual CLI confirms it, and update this note when they do. - -The symlink is created at install time, not committed to this repo — -committing a real symlink breaks on Windows checkouts that don't have -`core.symlinks` enabled, which this project's CI can't assume. -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_antigravity_adapter.py -v` -Expected: PASS (3 tests) - -- [ ] **Step 5: Commit** - -```bash -git add adapters/antigravity tests/unit/test_antigravity_adapter.py -git commit -m "feat: add Antigravity CLI adapter manifest (structural verification only)" -``` - ---- - -### Task 7: `scripts/sync_version.py` — repo-root version synchronization - -**Files:** -- Create: `scripts/sync_version.py` -- Test: `tests/unit/test_sync_version.py` - -**Interfaces:** -- Consumes: nothing (reads `VERSION` and manifest paths passed as arguments, for testability against fixtures rather than the real repo tree). -- Produces: `sync(version_file: Path, manifest_specs: list, check_only: bool) -> list` where `manifest_specs` is a list of `(path, json_key_path)` tuples, and the return value is the list of paths that were (or would be) changed; `sync_skill_md(version_file: Path, skill_md_path: Path, check_only: bool) -> bool` for the one non-JSON duplicate (`SKILL.md`'s YAML frontmatter `version:` line — `scripts/sync_version.py` lives at the repo root, outside `skills/adr-toolkit/`, so it cannot import that package's own `core.frontmatter` module without an ambiguous `scripts.*` import path collision between the two same-named `scripts/` directories; a small targeted regex substitution on just the `version:` line avoids that entirely and avoids re-serializing — and risking reformatting — the rest of the file). `main(argv=None) -> int` for CLI use. Used by Task 8 (CI wiring — invoked as a subprocess, not imported). - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_sync_version.py -import json - -from scripts.sync_version import sync - -MANIFEST_SPECS = [("plugin.json", ["version"]), ("nested.json", ["meta", "version"])] - - -def _write(tmp_path, name, data): - path = tmp_path / name - path.write_text(json.dumps(data), encoding="utf-8") - return path - - -def test_sync_writes_matching_version_into_every_manifest(tmp_path): - version_file = tmp_path / "VERSION" - version_file.write_text("1.2.3\n", encoding="utf-8") - plugin = _write(tmp_path, "plugin.json", {"name": "x", "version": "0.0.0"}) - nested = _write(tmp_path, "nested.json", {"meta": {"version": "0.0.0"}}) - - changed = sync(version_file, [(plugin, ["version"]), (nested, ["meta", "version"])], check_only=False) - - assert json.loads(plugin.read_text())["version"] == "1.2.3" - assert json.loads(nested.read_text())["meta"]["version"] == "1.2.3" - assert set(changed) == {plugin, nested} - - -def test_sync_is_idempotent_when_already_synced(tmp_path): - version_file = tmp_path / "VERSION" - version_file.write_text("1.2.3\n", encoding="utf-8") - plugin = _write(tmp_path, "plugin.json", {"name": "x", "version": "1.2.3"}) - - changed = sync(version_file, [(plugin, ["version"])], check_only=False) - - assert changed == [] - - -def test_check_only_mode_reports_drift_without_writing(tmp_path): - version_file = tmp_path / "VERSION" - version_file.write_text("1.2.3\n", encoding="utf-8") - plugin = _write(tmp_path, "plugin.json", {"name": "x", "version": "0.0.0"}) - - changed = sync(version_file, [(plugin, ["version"])], check_only=True) - - assert changed == [plugin] - assert json.loads(plugin.read_text())["version"] == "0.0.0" - - -def test_manifest_missing_a_version_key_is_skipped_not_added(tmp_path): - version_file = tmp_path / "VERSION" - version_file.write_text("1.2.3\n", encoding="utf-8") - frontmatter_like = _write(tmp_path, "nokey.json", {"name": "x"}) - - changed = sync(version_file, [(frontmatter_like, ["version"])], check_only=False) - - assert changed == [] - assert "version" not in json.loads(frontmatter_like.read_text()) - - -def test_sync_skill_md_updates_frontmatter_version_line(tmp_path): - from scripts.sync_version import sync_skill_md - - version_file = tmp_path / "VERSION" - version_file.write_text("1.2.3\n", encoding="utf-8") - skill_md = tmp_path / "SKILL.md" - skill_md.write_text( - "---\nname: adr-toolkit\nversion: 0.0.0\n---\n\n# ADR Toolkit\n", encoding="utf-8", - ) - - changed = sync_skill_md(version_file, skill_md, check_only=False) - - assert changed is True - updated = skill_md.read_text(encoding="utf-8") - assert "version: 1.2.3" in updated - assert "name: adr-toolkit" in updated # untouched fields survive unchanged - - -def test_sync_skill_md_check_only_does_not_write(tmp_path): - from scripts.sync_version import sync_skill_md - - version_file = tmp_path / "VERSION" - version_file.write_text("1.2.3\n", encoding="utf-8") - skill_md = tmp_path / "SKILL.md" - skill_md.write_text("---\nversion: 0.0.0\n---\n\nBody\n", encoding="utf-8") - - changed = sync_skill_md(version_file, skill_md, check_only=True) - - assert changed is True - assert "version: 0.0.0" in skill_md.read_text(encoding="utf-8") - - -def test_sync_skill_md_idempotent_when_already_synced(tmp_path): - from scripts.sync_version import sync_skill_md - - version_file = tmp_path / "VERSION" - version_file.write_text("1.2.3\n", encoding="utf-8") - skill_md = tmp_path / "SKILL.md" - skill_md.write_text("---\nversion: 1.2.3\n---\n\nBody\n", encoding="utf-8") - - changed = sync_skill_md(version_file, skill_md, check_only=False) - - assert changed is False -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_sync_version.py -v` -Expected: FAIL — module doesn't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -# scripts/sync_version.py -#!/usr/bin/env python3 -"""Sync skills/adr-toolkit/VERSION into every manifest that duplicates it. - -Repo tooling — not part of the distributable skills/adr-toolkit/ package. -""" -import argparse -import json -import re -import sys -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parent.parent -VERSION_FILE = REPO_ROOT / "skills" / "adr-toolkit" / "VERSION" -SKILL_MD_PATH = REPO_ROOT / "skills" / "adr-toolkit" / "SKILL.md" - -# (manifest path relative to REPO_ROOT, key path within its JSON) -# Only manifests confirmed to carry their own "version" field are listed — -# a manifest with no such field is never modified to add one. -MANIFEST_SPECS = [ - (REPO_ROOT / ".claude-plugin" / "plugin.json", ["version"]), - (REPO_ROOT / "adapters" / "gemini-cli" / "gemini-extension.json", ["version"]), -] - -VERSION_LINE_RE = re.compile(r"^version:\s*\S+$", re.MULTILINE) - - -def sync(version_file: Path, manifest_specs: list, check_only: bool) -> list: - version = version_file.read_text(encoding="utf-8").strip() - changed = [] - for path, key_path in manifest_specs: - if not path.is_file(): - continue - data = json.loads(path.read_text(encoding="utf-8")) - target = data - for key in key_path[:-1]: - if key not in target: - target = None - break - target = target[key] - if target is None or key_path[-1] not in target: - continue - if target[key_path[-1]] == version: - continue - changed.append(path) - if not check_only: - target[key_path[-1]] = version - path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") - return changed - - -def sync_skill_md(version_file: Path, skill_md_path: Path, check_only: bool) -> bool: - if not skill_md_path.is_file(): - return False - version = version_file.read_text(encoding="utf-8").strip() - text = skill_md_path.read_text(encoding="utf-8") - match = VERSION_LINE_RE.search(text) - if match is None or match.group() == f"version: {version}": - return False - if not check_only: - new_text = VERSION_LINE_RE.sub(f"version: {version}", text, count=1) - skill_md_path.write_text(new_text, encoding="utf-8") - return True - - -def main(argv=None) -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--check", action="store_true") - args = parser.parse_args(argv) - - changed = sync(VERSION_FILE, MANIFEST_SPECS, check_only=args.check) - if sync_skill_md(VERSION_FILE, SKILL_MD_PATH, check_only=args.check): - changed.append(SKILL_MD_PATH) - - if args.check and changed: - for path in changed: - print(f"drift: {path.relative_to(REPO_ROOT)}", file=sys.stderr) - return 1 - for path in changed: - print(f"synced: {path.relative_to(REPO_ROOT)}") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_sync_version.py -v` -Expected: PASS (7 tests) - -- [ ] **Step 5: Commit** - -```bash -git add scripts/sync_version.py tests/unit/test_sync_version.py -git commit -m "feat: add version sync script for duplicated manifest version fields" -``` - ---- - -### Task 8: CI drift check + `release.yml` - -**Files:** -- Modify: `.github/workflows/test.yml` -- Create: `.github/workflows/release.yml` -- Test: none (GitHub Actions workflow YAML isn't unit-testable in this stack; reviewed by reading it against `test.yml`'s existing conventions, per spec §17.4) - -**Interfaces:** -- Consumes: `scripts/sync_version.py --check` (Task 7), invoked as a subprocess by CI, not imported. -- Produces: nothing consumed by a later task — this is the plan's last task. - -- [ ] **Step 1: Add the drift-check step to `test.yml`** - -In `.github/workflows/test.yml`, add a step after `Run tests` in the existing `pytest` job: - -```yaml - - name: Run tests - run: python -m pytest tests/unit tests/integration -v - - name: Check manifest versions are in sync - run: python scripts/sync_version.py --check -``` - -- [ ] **Step 2: Verify the check step behaves correctly, locally** - -Run: `python scripts/sync_version.py --check` -Expected: exit code 0, no output — the repo's real manifests are already in sync with `VERSION` (`0.1.0`), since Task 7 only reads/checks and Task 8 doesn't change any version value. - -- [ ] **Step 3: Write `release.yml`** - -```yaml -# .github/workflows/release.yml -name: release - -on: - push: - tags: - - "v*" - -jobs: - release: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - name: Install dependencies - run: pip install pytest - - name: Run tests - run: python -m pytest tests/unit tests/integration -v - - name: Verify manifest versions match the tag - run: python scripts/sync_version.py --check - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - generate_release_notes: true -``` - -- [ ] **Step 4: Confirm the workflow is syntactically valid YAML** - -Run: `python -c "import yaml, sys; yaml.safe_load(open('.github/workflows/release.yml'))"` -Expected: no output, exit code 0 (`pyyaml` ships with most CI runner images; if it's not installed locally, `python -c "import json,sys; import yaml"` failing with `ModuleNotFoundError` is fine to note in the task report — the workflow's real validation happens when GitHub Actions parses it on the next tag push, which this task cannot trigger) - -- [ ] **Step 5: Run the full suite one last time and commit** - -Run: `python -m pytest tests/unit tests/integration -v` -Expected: PASS (all tests from Plans 1-4) - -```bash -git add .github/workflows/test.yml .github/workflows/release.yml -git commit -m "feat: add version-drift CI check and tag-triggered release workflow" -``` - ---- - -## Plan self-review notes - -- **Spec coverage:** §17.1 (i18n scope/mechanism) → Tasks 1, 2, 3. §17.2 (adapter formats, symlink pattern, manual verification split by CLI availability) → Tasks 4, 5, 6. §17.3 (version sync, release automation) → Tasks 7, 8. §17.4 (testing strategy) → each task's own test file; adapter manual-verification steps map to §17.4's "not an automated pytest case" note. §8/§9's original product framing is fully covered by §17's refinement, so no separate tasks needed beyond what §17 already resolves. -- **Type consistency checked:** `load_locale`'s return type (`dict`, Task 1) matches `index.py`'s `strings` parameter (Task 2) exactly, including the `status.` key convention used consistently in both the JSON files and `_render`'s lookup. `sync_version.sync`'s `(path, key_path)` tuple shape (Task 7) matches `MANIFEST_SPECS`'s own construction in the same file — no second, incompatible shape introduced elsewhere. No adapter task (4, 5, 6) is consumed by any other task, matching their "independent leaves" Interfaces note — verified none of Tasks 1-3 or 7-8 import anything from `adapters/`. -- **Self-caught during review:** the spec's §17.3 names three duplicate version locations (`VERSION`, `.claude-plugin/plugin.json`, `SKILL.md`'s frontmatter), but the plan's first draft of `MANIFEST_SPECS` covered only the two JSON manifests — `sync()`'s JSON-only design can't touch `SKILL.md`, which is markdown with YAML frontmatter, not JSON. Fixed by adding a separate `sync_skill_md()` using a targeted regex substitution on just the `version:` line, rather than importing `skills/adr-toolkit/`'s own `core.frontmatter` module (which would collide with `scripts/sync_version.py`'s own `scripts.*` import path — two same-named `scripts/` directories at different roots) or risking a full frontmatter re-serialize reformatting fields outside this task's scope. Also caught and fixed a regex subtlety in that same substitution: a trailing `\s*` before `$` combined with `\s`'s newline-matching under `re.MULTILINE` could have let the match creep across blank lines; the final pattern (`^version:\s*\S+$`, no trailing `\s*`) doesn't have that failure mode. -- **No placeholders found.** The adapter tasks' "read `codex --help`/`gemini extensions --help` before running the real subcommand" steps are deliberate — this plan cannot assert an unverified CLI flag as fact (that mistake already cost Plan 1 a fix round on the Claude Code adapter), so the concrete, runnable instruction is "investigate via `--help`, then use what it says," not a placeholder for missing code. diff --git a/docs/superpowers/plans/2026-08-30-adr-toolkit-record-and-lifecycle.md b/docs/superpowers/plans/2026-08-30-adr-toolkit-record-and-lifecycle.md deleted file mode 100644 index f93cb28..0000000 --- a/docs/superpowers/plans/2026-08-30-adr-toolkit-record-and-lifecycle.md +++ /dev/null @@ -1,1296 +0,0 @@ -# ADR Toolkit — RECORD + Lifecycle (Plan 2 of 4) Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add the RECORD workflow (significance scoring, related-ADR search, forward-looking and retrospective-in-branch decision capture) and lifecycle operations (`status`, `supersede`, `deprecate`) on top of Plan 1's deterministic core. - -**Architecture:** Two new deterministic layers — `scripts/rules/significance.py` (pure arithmetic classification the agent feeds its own per-criterion judgments into) and three new `scripts/commands/*.py` modules (`related`, `significance`, `status`, `supersede`) plus a `deprecate` subcommand that is a thin alias over `status`. `SKILL.md` gains RECORD and Lifecycle sections describing how the agent orchestrates these scripts; no new agent-side judgment logic is hardcoded in Python. - -**Tech Stack:** Same as Plan 1 — Python 3.9+ standard library only, pytest. - -**Spec:** `docs/superpowers/specs/2026-08-29-adr-toolkit-design.md` (§6.1, §11 RECORD, §11 Lifecycle operations) - -## Global Constraints - -- Same as Plan 1: Python 3.9+ stdlib only; UTF-8 explicit; no `shell=True`; mutating commands (`status`, `supersede`) support `--dry-run` and never silently overwrite; command modules never `print()`; avoid `X | None` union syntax, use `typing.Optional`. -- `status`/`supersede`/`deprecate` write ADR frontmatter only through `core.frontmatter.serialize` + `core.lifecycle.validate_transition` — never hand-format YAML, never skip the transition check. -- `deprecate ` is not a new command module — it is an `adr.py` subparser that calls `commands.status.run` with `to="deprecated"` pre-set via `set_defaults`, avoiding duplicated logic. -- Out of scope for this plan (tracked in `project-roadmap.md` / Plan 3/4): CHECK workflow, i18n wiring, Codex/Gemini CLI/Antigravity CLI adapters, release automation. -- Every task must leave `python -m pytest tests/unit tests/integration -v` green before its commit step. - ---- - -### Task 1: `core/identifiers.find_by_number` — locate an ADR file by its numeric ID - -**Files:** -- Modify: `skills/adr-toolkit/scripts/core/identifiers.py` -- Test: `tests/unit/test_identifiers.py` (add cases) - -**Interfaces:** -- Consumes: nothing new. -- Produces: `find_by_number(adr_dir: Path, number: int) -> Optional[Path]`. Used by Tasks 6 (status) and 7 (supersede). - -- [ ] **Step 1: Write the failing test** - -Append to `tests/unit/test_identifiers.py`: - -```python -def test_find_by_number_locates_matching_file(tmp_path): - (tmp_path / "0003-use-kafka.md").write_text("x", encoding="utf-8") - found = identifiers.find_by_number(tmp_path, 3) - assert found is not None - assert found.name == "0003-use-kafka.md" - - -def test_find_by_number_returns_none_when_missing(tmp_path): - assert identifiers.find_by_number(tmp_path, 7) is None -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_identifiers.py -v` -Expected: FAIL — `find_by_number` doesn't exist. - -- [ ] **Step 3: Add the function** - -Append to `skills/adr-toolkit/scripts/core/identifiers.py`: - -```python -def find_by_number(adr_dir: Path, number: int) -> Optional[Path]: - for entry in adr_dir.glob(f"{number:04d}-*.md"): - return entry - return None -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_identifiers.py -v` -Expected: PASS (8 tests — 6 from Plan 1 plus these 2) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/core/identifiers.py tests/unit/test_identifiers.py -git commit -m "feat: add find_by_number for looking up an ADR by its ID" -``` - ---- - -### Task 2: `rules/significance.py` — deterministic significance classification - -**Files:** -- Create: `skills/adr-toolkit/scripts/rules/__init__.py` (empty) -- Create: `skills/adr-toolkit/scripts/rules/significance.py` -- Test: `tests/unit/test_significance.py` - -**Interfaces:** -- Consumes: nothing. -- Produces: `CRITERIA: tuple`, `score(criteria_scores: dict) -> int`, `classify(total: int) -> str`. Used by Task 3 (`commands/significance.py`). - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_significance.py -import pytest - -from scripts.rules import significance - -FULL_SCORES = {c: 2 for c in significance.CRITERIA} -ZERO_SCORES = {c: 0 for c in significance.CRITERIA} - - -def test_score_sums_all_seven_criteria(): - assert significance.score(FULL_SCORES) == 14 - assert significance.score(ZERO_SCORES) == 0 - - -def test_score_defaults_missing_criteria_to_zero(): - assert significance.score({}) == 0 - - -def test_score_rejects_out_of_range_value(): - with pytest.raises(ValueError): - significance.score({"reversal_cost": 3}) - - -def test_classify_bands(): - assert significance.classify(0) == "not_needed" - assert significance.classify(3) == "not_needed" - assert significance.classify(4) == "optional" - assert significance.classify(6) == "optional" - assert significance.classify(7) == "recommended" - assert significance.classify(14) == "recommended" -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_significance.py -v` -Expected: FAIL — module doesn't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -# skills/adr-toolkit/scripts/rules/significance.py -"""Deterministic significance classification for RECORD. - -The agent scores each of the 7 criteria itself (0, 1, or 2) based on its own -reading of the code and conversation — that judgment is not scriptable. This -module only sums and bands the result deterministically, so the same 7 -scores always produce the same recommendation. -""" - -CRITERIA = ( - "reversal_cost", - "alternatives_considered", - "quality_attribute_impact", - "boundary_or_pattern_change", - "multi_developer_relevance", - "ops_security_data_impact", - "future_rationale_query_likelihood", -) - - -def score(criteria_scores: dict) -> int: - total = 0 - for criterion in CRITERIA: - value = criteria_scores.get(criterion, 0) - if value not in (0, 1, 2): - raise ValueError(f"{criterion} must be 0, 1, or 2, got {value!r}") - total += value - return total - - -def classify(total: int) -> str: - if total <= 3: - return "not_needed" - if total <= 6: - return "optional" - return "recommended" -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_significance.py -v` -Expected: PASS (4 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/rules tests/unit/test_significance.py -git commit -m "feat: add deterministic significance scoring/classification" -``` - ---- - -### Task 3: `commands/significance.py` — CLI wrapper + wire into `adr.py` - -**Files:** -- Create: `skills/adr-toolkit/scripts/commands/significance.py` -- Modify: `skills/adr-toolkit/scripts/adr.py` -- Test: `tests/unit/test_significance_command.py`, `tests/integration/test_cli.py` (add one case) - -**Interfaces:** -- Consumes: `scripts.rules.significance.{score, classify}` (Task 2). -- Produces: `run(args) -> dict` where `args.input` is a path to a JSON file of per-criterion scores. Used by `SKILL.md`'s RECORD flow (Task 10) and the CLI. - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_significance_command.py -import json -from types import SimpleNamespace - -from scripts.commands import significance - - -def test_significance_command_returns_total_and_classification(tmp_path): - input_path = tmp_path / "scores.json" - input_path.write_text(json.dumps({"reversal_cost": 2, "boundary_or_pattern_change": 2}), encoding="utf-8") - - result = significance.run(SimpleNamespace(input=str(input_path))) - - assert result["ok"] is True - assert result["total"] == 4 - assert result["classification"] == "optional" - - -def test_significance_command_rejects_bad_score(tmp_path): - input_path = tmp_path / "scores.json" - input_path.write_text(json.dumps({"reversal_cost": 9}), encoding="utf-8") - - result = significance.run(SimpleNamespace(input=str(input_path))) - - assert result["ok"] is False - assert result["errors"][0]["code"] == "INVALID_SCORE" -``` - -Append to `tests/integration/test_cli.py`: - -```python -def test_significance_via_cli(tmp_path): - scores_path = tmp_path / "scores.json" - scores_path.write_text('{"reversal_cost": 2, "quality_attribute_impact": 2}', encoding="utf-8") - result = _run(["significance", "--input", str(scores_path), "--json"], cwd=tmp_path) - payload = json.loads(result.stdout) - assert result.returncode == 0 - assert payload["total"] == 4 -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_significance_command.py tests/integration/test_cli.py -v` -Expected: FAIL — `commands.significance` and the `significance` subcommand don't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -# skills/adr-toolkit/scripts/commands/significance.py -"""Deterministic CLI wrapper around scripts.rules.significance.""" -import json -from pathlib import Path - -from scripts.rules import significance - - -def run(args) -> dict: - criteria_scores = json.loads(Path(args.input).read_text(encoding="utf-8")) - - try: - total = significance.score(criteria_scores) - except ValueError as exc: - return { - "ok": False, - "operation": "significance", - "errors": [{"code": "INVALID_SCORE", "detail": str(exc)}], - } - - return { - "ok": True, - "operation": "significance", - "total": total, - "classification": significance.classify(total), - } -``` - -In `skills/adr-toolkit/scripts/adr.py`, add the import and subparser: - -```python -from scripts.commands import significance # add alongside existing command imports -``` - -```python - p_significance = sub.add_parser("significance") - p_significance.add_argument("--input", required=True) - p_significance.add_argument("--json", action="store_true") -``` - -Add `"significance": significance.run` to the `HANDLERS` dict. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_significance_command.py tests/integration/test_cli.py -v` -Expected: PASS (2 new unit tests, 1 new integration test, all prior tests still green) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/commands/significance.py skills/adr-toolkit/scripts/adr.py \ - tests/unit/test_significance_command.py tests/integration/test_cli.py -git commit -m "feat: expose significance scoring as a CLI subcommand" -``` - ---- - -### Task 4: `commands/related.py` — related-ADR search + wire into `adr.py` - -**Files:** -- Create: `skills/adr-toolkit/scripts/commands/related.py` -- Modify: `skills/adr-toolkit/scripts/adr.py` -- Test: `tests/unit/test_related.py` - -**Interfaces:** -- Consumes: `scripts.core.frontmatter.parse` (Plan 1 Task 2), `scripts.core.identifiers.parse_filename` (Plan 1 Task 3). -- Produces: `run(args) -> dict` where `args` has optional `.paths` (list), `.tags` (list), `.keyword` (str), and `.dir`. Used by `SKILL.md`'s RECORD flow (Task 10). - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_related.py -from types import SimpleNamespace - -from scripts.commands import related - -ADR_WITH_PATH = ( - "---\n" - "id: ADR-0001\n" - "title: Use Kafka for domain events\n" - "status: accepted\n" - "date: 2026-08-01\n" - "decision_makers: []\n" - "related: []\n" - "affected_paths:\n" - " - src/events/\n" - "tags:\n" - " - architecture\n" - "retrospective: false\n" - "---\n\n" - "# Use Kafka for domain events\n" -) - -ADR_UNRELATED = ADR_WITH_PATH.replace("0001", "0002").replace( - "Use Kafka for domain events", "Use Postgres" -).replace("src/events/", "src/db/").replace("architecture", "data") - - -def test_finds_match_by_affected_path(tmp_path): - (tmp_path / "0001-use-kafka.md").write_text(ADR_WITH_PATH, encoding="utf-8") - (tmp_path / "0002-use-postgres.md").write_text(ADR_UNRELATED, encoding="utf-8") - - result = related.run(SimpleNamespace(dir=str(tmp_path), paths=["src/events/"], tags=None, keyword=None)) - - assert result["ok"] is True - assert result["count"] == 1 - assert result["matches"][0]["id"] == "ADR-0001" - assert "affected_paths overlap" in result["matches"][0]["reasons"][0] - - -def test_finds_match_by_tag(tmp_path): - (tmp_path / "0002-use-postgres.md").write_text(ADR_UNRELATED, encoding="utf-8") - - result = related.run(SimpleNamespace(dir=str(tmp_path), paths=None, tags=["data"], keyword=None)) - - assert result["count"] == 1 - assert result["matches"][0]["id"] == "ADR-0002" - - -def test_no_match_returns_empty_list(tmp_path): - (tmp_path / "0001-use-kafka.md").write_text(ADR_WITH_PATH, encoding="utf-8") - - result = related.run(SimpleNamespace(dir=str(tmp_path), paths=["src/unrelated/"], tags=None, keyword=None)) - - assert result["count"] == 0 - assert result["matches"] == [] -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_related.py -v` -Expected: FAIL — module doesn't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -# skills/adr-toolkit/scripts/commands/related.py -"""Find existing ADRs related to a set of paths, tags, or a keyword.""" -from pathlib import Path - -from scripts.core import frontmatter as fm -from scripts.core import identifiers - -SKIP_FILES = {"README.md", "adr-template.md"} - - -def run(args) -> dict: - adr_dir = Path(args.dir) - query_paths = set(getattr(args, "paths", None) or []) - query_tags = set(getattr(args, "tags", None) or []) - keyword = (getattr(args, "keyword", None) or "").lower() - - matches = [] - for entry in sorted(adr_dir.glob("*.md")): - if entry.name in SKIP_FILES or identifiers.parse_filename(entry.name) is None: - continue - - data, _ = fm.parse(entry.read_text(encoding="utf-8")) - reasons = [] - - path_overlap = query_paths & set(data.get("affected_paths", [])) - if path_overlap: - reasons.append(f"affected_paths overlap: {sorted(path_overlap)}") - - tag_overlap = query_tags & set(data.get("tags", [])) - if tag_overlap: - reasons.append(f"tag overlap: {sorted(tag_overlap)}") - - if keyword and keyword in data.get("title", "").lower(): - reasons.append("title keyword match") - - if reasons: - matches.append({ - "id": data.get("id"), - "filename": entry.name, - "title": data.get("title"), - "status": data.get("status"), - "reasons": reasons, - }) - - return {"ok": True, "operation": "related", "count": len(matches), "matches": matches} -``` - -In `scripts/adr.py`, add: - -```python -from scripts.commands import related # alongside other imports -``` - -```python - p_related = sub.add_parser("related") - p_related.add_argument("--paths", nargs="*") - p_related.add_argument("--tags", nargs="*") - p_related.add_argument("--keyword") - p_related.add_argument("--dir", default="docs/decisions") - p_related.add_argument("--json", action="store_true") -``` - -Add `"related": related.run` to `HANDLERS`. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_related.py -v` -Expected: PASS (3 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/commands/related.py skills/adr-toolkit/scripts/adr.py \ - tests/unit/test_related.py -git commit -m "feat: add related-ADR search by path/tag/keyword" -``` - ---- - -### Task 5: extend `core/schema.py` with optional `supersedes`/`superseded_by` - -**Files:** -- Modify: `skills/adr-toolkit/scripts/core/schema.py` -- Test: `tests/unit/test_schema.py` (add cases) - -**Interfaces:** -- Consumes: nothing new. -- Produces: `validate_frontmatter` now also accepts (but does not require) `supersedes: list` and `superseded_by: str`. Used by Task 7 (`commands/supersede.py`). - -- [ ] **Step 1: Write the failing test** - -Append to `tests/unit/test_schema.py`: - -```python -def test_optional_supersedes_field_is_allowed_when_a_list(): - data = dict(VALID, supersedes=["ADR-0002"]) - assert validate_frontmatter(data) == [] - - -def test_optional_supersedes_field_wrong_type_is_reported(): - data = dict(VALID, supersedes="ADR-0002") # should be a list - errors = validate_frontmatter(data) - assert any("supersedes" in e for e in errors) - - -def test_optional_superseded_by_field_is_allowed_when_a_string(): - data = dict(VALID, superseded_by="ADR-0009") - assert validate_frontmatter(data) == [] - - -def test_absence_of_optional_fields_is_not_an_error(): - assert validate_frontmatter(VALID) == [] # VALID has neither field, must still pass -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_schema.py -v` -Expected: FAIL on the wrong-type case (currently silently ignored since the field isn't in `REQUIRED_FIELDS` at all — the "wrong type is reported" test is the one that actually fails; the others already pass by accident, which is the point of this task: make the check explicit rather than absent). - -- [ ] **Step 3: Extend the implementation** - -In `skills/adr-toolkit/scripts/core/schema.py`, add alongside `REQUIRED_FIELDS`: - -```python -OPTIONAL_FIELDS = { - "supersedes": list, - "superseded_by": str, -} -``` - -In `validate_frontmatter`, after the `REQUIRED_FIELDS` loop, add: - -```python - for field, expected_type in OPTIONAL_FIELDS.items(): - if field in data and not isinstance(data[field], expected_type): - errors.append( - f"field {field!r} must be {expected_type.__name__}, " - f"got {type(data[field]).__name__}" - ) -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_schema.py -v` -Expected: PASS (10 tests — 6 from Plan 1 plus these 4) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/core/schema.py tests/unit/test_schema.py -git commit -m "feat: validate optional supersedes/superseded_by fields" -``` - ---- - -### Task 6: `commands/status.py` — validated status transition, plus `deprecate` alias - -**Files:** -- Create: `skills/adr-toolkit/scripts/commands/status.py` -- Modify: `skills/adr-toolkit/scripts/adr.py` -- Test: `tests/unit/test_status.py` - -**Interfaces:** -- Consumes: `scripts.core.identifiers.find_by_number` (Task 1), `scripts.core.frontmatter.{parse, serialize}` (Plan 1 Task 2), `scripts.core.lifecycle.{validate_transition, InvalidTransitionError}` (Plan 1 Task 4). -- Produces: `run(args) -> dict` where `args` has `.adr_number` (int), `.to` (str), `.dir`, optional `.dry_run`. Used by Task 7 (`supersede.py` reuses the same transition-checking pattern, not this module directly) and by `adr.py`'s `deprecate` subcommand (same function, `to="deprecated"` preset). - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_status.py -from types import SimpleNamespace - -from scripts.commands import status - -ACCEPTED_ADR = ( - "---\n" - "id: ADR-0001\n" - "title: Use Kafka\n" - "status: proposed\n" - "date: 2026-08-01\n" - "decision_makers: []\n" - "related: []\n" - "affected_paths: []\n" - "tags: []\n" - "retrospective: false\n" - "---\n\n" - "# Use Kafka\n" -) - - -def test_valid_transition_updates_status(tmp_path): - (tmp_path / "0001-use-kafka.md").write_text(ACCEPTED_ADR, encoding="utf-8") - - result = status.run(SimpleNamespace(adr_number=1, to="accepted", dir=str(tmp_path), dry_run=False)) - - assert result["ok"] is True - updated = (tmp_path / "0001-use-kafka.md").read_text(encoding="utf-8") - assert "status: accepted" in updated - - -def test_invalid_transition_is_rejected(tmp_path): - (tmp_path / "0001-use-kafka.md").write_text(ACCEPTED_ADR, encoding="utf-8") - - result = status.run(SimpleNamespace(adr_number=1, to="superseded", dir=str(tmp_path), dry_run=False)) - - assert result["ok"] is False - assert result["errors"][0]["code"] == "INVALID_TRANSITION" - - -def test_missing_adr_is_reported(tmp_path): - result = status.run(SimpleNamespace(adr_number=99, to="accepted", dir=str(tmp_path), dry_run=False)) - - assert result["ok"] is False - assert result["errors"][0]["code"] == "ADR_NOT_FOUND" - - -def test_dry_run_does_not_write(tmp_path): - (tmp_path / "0001-use-kafka.md").write_text(ACCEPTED_ADR, encoding="utf-8") - - result = status.run(SimpleNamespace(adr_number=1, to="accepted", dir=str(tmp_path), dry_run=True)) - - assert result["ok"] is True - assert result["dry_run"] is True - unchanged = (tmp_path / "0001-use-kafka.md").read_text(encoding="utf-8") - assert "status: proposed" in unchanged -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_status.py -v` -Expected: FAIL — module doesn't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -# skills/adr-toolkit/scripts/commands/status.py -"""Change an ADR's status through the deterministic lifecycle state machine.""" -from pathlib import Path - -from scripts.core import frontmatter as fm -from scripts.core import identifiers -from scripts.core.lifecycle import InvalidTransitionError, validate_transition - - -def run(args) -> dict: - adr_dir = Path(args.dir) - target_file = identifiers.find_by_number(adr_dir, args.adr_number) - - if target_file is None: - return { - "ok": False, - "operation": "status", - "errors": [{"code": "ADR_NOT_FOUND", "id": args.adr_number}], - } - - data, body = fm.parse(target_file.read_text(encoding="utf-8")) - - try: - validate_transition(data["status"], args.to) - except InvalidTransitionError as exc: - return { - "ok": False, - "operation": "status", - "errors": [{"code": "INVALID_TRANSITION", "detail": str(exc)}], - } - - if getattr(args, "dry_run", False): - return { - "ok": True, - "operation": "status", - "dry_run": True, - "would_update": str(target_file), - "to": args.to, - } - - data["status"] = args.to - target_file.write_text(fm.serialize(data, body.strip() + "\n"), encoding="utf-8") - - return {"ok": True, "operation": "status", "dry_run": False, "updated": str(target_file), "to": args.to} -``` - -In `scripts/adr.py`, add: - -```python -from scripts.commands import status # alongside other imports -``` - -```python - p_status = sub.add_parser("status") - p_status.add_argument("adr_number", type=int) - p_status.add_argument("--to", required=True) - p_status.add_argument("--dir", default="docs/decisions") - p_status.add_argument("--dry-run", dest="dry_run", action="store_true") - p_status.add_argument("--json", action="store_true") - - p_deprecate = sub.add_parser("deprecate") - p_deprecate.add_argument("adr_number", type=int) - p_deprecate.add_argument("--dir", default="docs/decisions") - p_deprecate.add_argument("--dry-run", dest="dry_run", action="store_true") - p_deprecate.add_argument("--json", action="store_true") - p_deprecate.set_defaults(to="deprecated") -``` - -Add both `"status": status.run` and `"deprecate": status.run` to `HANDLERS`. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_status.py -v` -Expected: PASS (4 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/commands/status.py skills/adr-toolkit/scripts/adr.py \ - tests/unit/test_status.py -git commit -m "feat: add status transition command with a deprecate alias" -``` - ---- - -### Task 7: `commands/supersede.py` — bidirectional supersede link - -**Files:** -- Create: `skills/adr-toolkit/scripts/commands/supersede.py` -- Modify: `skills/adr-toolkit/scripts/adr.py` -- Test: `tests/unit/test_supersede.py` - -**Interfaces:** -- Consumes: `scripts.core.identifiers.find_by_number` (Task 1), `scripts.core.frontmatter.{parse, serialize}` (Plan 1 Task 2), `scripts.core.lifecycle.{validate_transition, InvalidTransitionError}` (Plan 1 Task 4), the `supersedes`/`superseded_by` fields validated by Task 5. -- Produces: `run(args) -> dict` where `args` has `.adr_number` (the old ADR, int), `.by` (the new ADR, int), `.dir`, optional `.dry_run`. - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_supersede.py -from types import SimpleNamespace - -from scripts.commands import supersede - -OLD_ADR = ( - "---\n" - "id: ADR-0001\n" - "title: Use RabbitMQ\n" - "status: accepted\n" - "date: 2026-08-01\n" - "decision_makers: []\n" - "related: []\n" - "affected_paths: []\n" - "tags: []\n" - "retrospective: false\n" - "---\n\n" - "# Use RabbitMQ\n" -) - -NEW_ADR = ( - "---\n" - "id: ADR-0002\n" - "title: Use Kafka\n" - "status: accepted\n" - "date: 2026-08-15\n" - "decision_makers: []\n" - "related: []\n" - "affected_paths: []\n" - "tags: []\n" - "retrospective: false\n" - "---\n\n" - "# Use Kafka\n" -) - - -def test_supersede_updates_both_files(tmp_path): - (tmp_path / "0001-use-rabbitmq.md").write_text(OLD_ADR, encoding="utf-8") - (tmp_path / "0002-use-kafka.md").write_text(NEW_ADR, encoding="utf-8") - - result = supersede.run(SimpleNamespace(adr_number=1, by=2, dir=str(tmp_path), dry_run=False)) - - assert result["ok"] is True - old_text = (tmp_path / "0001-use-rabbitmq.md").read_text(encoding="utf-8") - new_text = (tmp_path / "0002-use-kafka.md").read_text(encoding="utf-8") - assert "status: superseded" in old_text - assert "superseded_by: ADR-0002" in old_text - assert "ADR-0001" in new_text # appears in the new ADR's supersedes list - - -def test_supersede_missing_old_adr_is_reported(tmp_path): - (tmp_path / "0002-use-kafka.md").write_text(NEW_ADR, encoding="utf-8") - - result = supersede.run(SimpleNamespace(adr_number=1, by=2, dir=str(tmp_path), dry_run=False)) - - assert result["ok"] is False - assert result["errors"][0]["code"] == "ADR_NOT_FOUND" - - -def test_supersede_dry_run_writes_nothing(tmp_path): - (tmp_path / "0001-use-rabbitmq.md").write_text(OLD_ADR, encoding="utf-8") - (tmp_path / "0002-use-kafka.md").write_text(NEW_ADR, encoding="utf-8") - - result = supersede.run(SimpleNamespace(adr_number=1, by=2, dir=str(tmp_path), dry_run=True)) - - assert result["dry_run"] is True - unchanged = (tmp_path / "0001-use-rabbitmq.md").read_text(encoding="utf-8") - assert "status: accepted" in unchanged -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_supersede.py -v` -Expected: FAIL — module doesn't exist. - -- [ ] **Step 3: Write minimal implementation** - -```python -# skills/adr-toolkit/scripts/commands/supersede.py -"""Mark one ADR as superseded by another, updating both files' frontmatter.""" -from pathlib import Path - -from scripts.core import frontmatter as fm -from scripts.core import identifiers -from scripts.core.lifecycle import InvalidTransitionError, validate_transition - - -def run(args) -> dict: - adr_dir = Path(args.dir) - old_file = identifiers.find_by_number(adr_dir, args.adr_number) - new_file = identifiers.find_by_number(adr_dir, args.by) - - if old_file is None: - return {"ok": False, "operation": "supersede", "errors": [{"code": "ADR_NOT_FOUND", "id": args.adr_number}]} - if new_file is None: - return {"ok": False, "operation": "supersede", "errors": [{"code": "ADR_NOT_FOUND", "id": args.by}]} - - old_data, old_body = fm.parse(old_file.read_text(encoding="utf-8")) - - try: - validate_transition(old_data["status"], "superseded") - except InvalidTransitionError as exc: - return {"ok": False, "operation": "supersede", "errors": [{"code": "INVALID_TRANSITION", "detail": str(exc)}]} - - if getattr(args, "dry_run", False): - return { - "ok": True, - "operation": "supersede", - "dry_run": True, - "would_update": [str(old_file), str(new_file)], - } - - new_data, new_body = fm.parse(new_file.read_text(encoding="utf-8")) - - old_data["status"] = "superseded" - old_data["superseded_by"] = new_data["id"] - old_file.write_text(fm.serialize(old_data, old_body.strip() + "\n"), encoding="utf-8") - - supersedes_list = new_data.get("supersedes", []) - if old_data["id"] not in supersedes_list: - supersedes_list.append(old_data["id"]) - new_data["supersedes"] = supersedes_list - new_file.write_text(fm.serialize(new_data, new_body.strip() + "\n"), encoding="utf-8") - - return { - "ok": True, - "operation": "supersede", - "dry_run": False, - "old": old_data["id"], - "new": new_data["id"], - } -``` - -In `scripts/adr.py`, add: - -```python -from scripts.commands import supersede # alongside other imports -``` - -```python - p_supersede = sub.add_parser("supersede") - p_supersede.add_argument("adr_number", type=int) - p_supersede.add_argument("--by", type=int, required=True) - p_supersede.add_argument("--dir", default="docs/decisions") - p_supersede.add_argument("--dry-run", dest="dry_run", action="store_true") - p_supersede.add_argument("--json", action="store_true") -``` - -Add `"supersede": supersede.run` to `HANDLERS`. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_supersede.py -v` -Expected: PASS (3 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/scripts/commands/supersede.py skills/adr-toolkit/scripts/adr.py \ - tests/unit/test_supersede.py -git commit -m "feat: add supersede command with bidirectional link update" -``` - ---- - -### Task 8: `references/significance-rules.md` - -**Files:** -- Create: `skills/adr-toolkit/references/significance-rules.md` -- Test: `tests/unit/test_significance_reference.py` - -**Interfaces:** -- Consumes: nothing (content only, read by the agent during RECORD). -- Produces: the human/agent-readable explanation of the 7 criteria `rules/significance.py` (Task 2) scores arithmetically. - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_significance_reference.py -from pathlib import Path - -REFERENCE = ( - Path(__file__).resolve().parent.parent.parent - / "skills" / "adr-toolkit" / "references" / "significance-rules.md" -) - - -def test_reference_documents_all_seven_criteria(): - text = REFERENCE.read_text(encoding="utf-8") - for criterion in [ - "reversal_cost", "alternatives_considered", "quality_attribute_impact", - "boundary_or_pattern_change", "multi_developer_relevance", - "ops_security_data_impact", "future_rationale_query_likelihood", - ]: - assert criterion in text - - -def test_reference_documents_the_three_bands(): - text = REFERENCE.read_text(encoding="utf-8") - assert "not_needed" in text - assert "optional" in text - assert "recommended" in text -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_significance_reference.py -v` -Expected: FAIL — file doesn't exist. - -- [ ] **Step 3: Write the content** - -```markdown - -# Significance Scoring Reference - -RECORD scores a candidate decision against 7 criteria, each 0/1/2, then -calls `python scripts/adr.py significance --input scores.json --json` to -get a deterministic band. The agent decides each score; the script only -sums and bands them — same inputs always produce the same recommendation. - -## The 7 criteria - -- `reversal_cost` — how expensive would it be to undo this later? -- `alternatives_considered` — were there multiple realistic options, or - effectively one obvious choice? -- `quality_attribute_impact` — does this meaningfully affect performance, - reliability, security, or another quality attribute? -- `boundary_or_pattern_change` — does this change a system boundary or a - pattern other code is expected to follow? -- `multi_developer_relevance` — will other developers (or agents) need to - follow this going forward? -- `ops_security_data_impact` — does this affect operations, security, or - data consistency? -- `future_rationale_query_likelihood` — is someone likely to ask "why did - we do it this way" months from now? - -Score each 0 (no), 1 (somewhat), or 2 (clearly yes) based on the actual -code and conversation — never guess a score to force a particular band. - -## Bands - -- 0–3: `not_needed` — recommend a commit message or code comment instead. -- 4–6: `optional` — mention the option to the user, let them decide. -- 7–14: `recommended` — proceed to drafting an ADR. - -The score is a decision aid, not a verdict the user can't override — if a -user insists on recording something scored `not_needed`, defer to them -rather than refusing. -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_significance_reference.py -v` -Expected: PASS (2 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/references/significance-rules.md tests/unit/test_significance_reference.py -git commit -m "docs: add significance scoring reference" -``` - ---- - -### Task 9: `references/interview-guide.md` - -**Files:** -- Create: `skills/adr-toolkit/references/interview-guide.md` -- Test: `tests/unit/test_interview_guide.py` - -**Interfaces:** -- Consumes: nothing (content only). -- Produces: the question-asking policy RECORD (and DISCOVER) follow. - -- [ ] **Step 1: Write the failing test** - -```python -# tests/unit/test_interview_guide.py -from pathlib import Path - -REFERENCE = ( - Path(__file__).resolve().parent.parent.parent - / "skills" / "adr-toolkit" / "references" / "interview-guide.md" -) - - -def test_guide_states_the_three_question_cap(): - text = REFERENCE.read_text(encoding="utf-8") - assert "3" in text and "question" in text.lower() - - -def test_guide_lists_things_not_to_ask(): - text = REFERENCE.read_text(encoding="utf-8") - assert "## What not to ask" in text -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_interview_guide.py -v` -Expected: FAIL — file doesn't exist. - -- [ ] **Step 3: Write the content** - -```markdown - -# Interview Guide - -RECORD and DISCOVER ask at most 3 questions per round. Fewer is better — -never ask a question `discover`/`related`/the repository already answered. - -## Priority order (ask the highest-priority unanswered question first) - -1. What problem or constraint made this decision necessary? -2. What realistic alternatives were considered? -3. Why was this option chosen over the others — what was the primary driver? -4. What negative consequence was knowingly accepted? -5. What condition should cause this decision to be revisited? - -## What not to ask - -- A library name or version already visible in a dependency manifest — - `discover`/`related` already reported it. -- A policy already stated in an existing ADR — cite it instead. -- A preference with no effect on the file that gets written (e.g. writing - style opinions). -- Anything the user already stated in their own request. - -## If the user's answer is ambiguous - -Ask one focused follow-up rather than guessing — but that follow-up still -counts against the 3-question cap for the round. -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_interview_guide.py -v` -Expected: PASS (2 tests) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/references/interview-guide.md tests/unit/test_interview_guide.py -git commit -m "docs: add interview question-asking guide" -``` - ---- - -### Task 10: `SKILL.md` — RECORD and Lifecycle sections - -**Files:** -- Modify: `skills/adr-toolkit/SKILL.md` -- Test: `tests/unit/test_skill_manifest.py` (add cases) - -**Interfaces:** -- Consumes: all commands built in Tasks 1–9, plus Plan 1's `create`/`validate`/`index`. -- Produces: the RECORD and Lifecycle operation contracts an agent follows. - -- [ ] **Step 1: Write the failing test** - -Append to `tests/unit/test_skill_manifest.py`: - -```python -def test_skill_md_documents_record_and_lifecycle(): - _, body = fm.parse(SKILL_MD.read_text(encoding="utf-8")) - assert "## RECORD" in body - assert "## Lifecycle operations" in body - assert "significance" in body - assert "adr.py supersede" in body - assert "adr.py deprecate" in body -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/unit/test_skill_manifest.py -v` -Expected: FAIL — sections don't exist yet. - -- [ ] **Step 3: Add the sections** - -Insert into `skills/adr-toolkit/SKILL.md`, after the `## What belongs in an ADR` section and before `## Prohibited`: - -```markdown -## RECORD - -Use RECORD both before implementing something (a forward-looking question) -and after (e.g. "find what in this branch should become an ADR" — same -flow, the trigger is a diff/branch range instead of a question). - -1. **PREFLIGHT** — confirm an ADR directory exists (`preflight --json`); if - not, tell the user to run INIT first. -2. **GATHER EVIDENCE** — run - `python scripts/adr.py related --paths --tags --json` - to find existing ADRs that might already cover this, or that this new - decision might conflict with or supersede. -3. **CLASSIFY** — use the `## What belongs in an ADR` table. Score the - candidate against the 7 criteria in `references/significance-rules.md`, - write the scores to a JSON file, and run - `python scripts/adr.py significance --input scores.json --json`. - - `not_needed` → recommend a commit message or code comment instead; - do not draft an ADR unless the user insists. - - `optional` → mention the option, let the user decide. - - `recommended` → continue. -4. **ASK-IF-NEEDED** — at most 3 questions, following - `references/interview-guide.md`'s priority order. Skip anything - `related`/`discover`/the user's own request already answered. -5. **PLAN** — draft a MADR body (minimal or full, per - `references/madr-guide.md`). If this decision replaces an existing - Accepted ADR, note which one — that becomes a `supersede` call after - approval, not a manual edit. -6. **CONFIRM** — show title, problem, options, decision, primary driver, - accepted downside, and affected paths before writing anything. -7. **MUTATE** — write the draft JSON and run - `python scripts/adr.py create --input --dir docs/decisions`. - If this decision supersedes an existing one, follow with - `python scripts/adr.py supersede --by ` only - after the user confirms that's intended. -8. **VALIDATE** — `validate --json` then `index --json`. -9. **REPORT** — facts found, judgment (including the significance score), - questions asked, files created/updated, validation result, remaining - uncertainty. - -## Lifecycle operations - -Status changes are explicit, user-triggered, and always go through the -deterministic transition check — never hand-edit a `status:` field. - -| User says | Run | -|---|---| -| "이 ADR을 승인 상태로 변경해줘" / "mark this ADR accepted" | `python scripts/adr.py status --to accepted` | -| "ADR-0012는 ADR-0021로 대체됐어" / "this supersedes ADR-0012" | `python scripts/adr.py supersede 12 --by 21` | -| "이 결정은 더 이상 적용되지 않아" / "deprecate this ADR" | `python scripts/adr.py deprecate ` | - -Show the user the change (old status → new status, or which two ADRs link) -before running the non-dry-run command. If the script returns -`INVALID_TRANSITION`, explain why (per `references/lifecycle.md`) rather -than retrying with a different status. -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/unit/test_skill_manifest.py -v` -Expected: PASS (all prior cases plus this one) - -- [ ] **Step 5: Commit** - -```bash -git add skills/adr-toolkit/SKILL.md tests/unit/test_skill_manifest.py -git commit -m "feat: document RECORD and Lifecycle operations in SKILL.md" -``` - ---- - -### Task 11: End-to-end RECORD fixture and golden test - -**Files:** -- Create: `tests/fixtures/record_existing_adr/docs/decisions/0001-use-rabbitmq.md` -- Create: `tests/integration/test_record_workflow.py` - -**Interfaces:** -- Consumes: `related`, `significance`, `create`, `validate`, `index` via subprocess (Tasks 3, 4, and Plan 1's `create`/`validate`/`index`). -- Produces: the golden proof that RECORD's scripted steps compose correctly against a repo with pre-existing ADRs. - -- [ ] **Step 1: Write the failing test** - -```markdown - ---- -id: ADR-0001 -title: Use RabbitMQ for async processing -status: accepted -date: 2026-07-01 -decision_makers: [] -related: [] -affected_paths: - - src/queue/ -tags: - - architecture -retrospective: false ---- - -# Use RabbitMQ for async processing - -Body. -``` - -```python -# tests/integration/test_record_workflow.py -import json -import shutil -import subprocess -import sys -from pathlib import Path - -FIXTURE = Path(__file__).resolve().parent.parent / "fixtures" / "record_existing_adr" -ADR_PY = Path(__file__).resolve().parent.parent.parent / "skills" / "adr-toolkit" / "scripts" / "adr.py" - - -def _run(args, cwd): - result = subprocess.run([sys.executable, str(ADR_PY), *args], cwd=cwd, capture_output=True, text=True) - assert result.returncode in (0, 1), result.stderr - return json.loads(result.stdout) - - -def test_record_finds_related_scores_and_creates_new_adr(tmp_path): - repo = tmp_path / "repo" - shutil.copytree(FIXTURE, repo) - - related_result = _run(["related", "--tags", "architecture", "--dir", "docs/decisions", "--json"], cwd=repo) - assert related_result["count"] == 1 - assert related_result["matches"][0]["id"] == "ADR-0001" - - scores_path = repo / "scores.json" - scores_path.write_text(json.dumps({ - "reversal_cost": 2, "boundary_or_pattern_change": 2, - "multi_developer_relevance": 2, "ops_security_data_impact": 1, - }), encoding="utf-8") - sig_result = _run(["significance", "--input", "scores.json", "--json"], cwd=repo) - assert sig_result["classification"] == "recommended" - - draft_path = repo / "draft.json" - draft_path.write_text(json.dumps({ - "title": "Use Kafka instead of RabbitMQ", - "status": "accepted", - "body": "# Use Kafka instead of RabbitMQ\n\nBody.\n", - "related": ["ADR-0001"], - }), encoding="utf-8") - create_result = _run(["create", "--input", "draft.json", "--dir", "docs/decisions", "--json"], cwd=repo) - assert create_result["id"] == "ADR-0002" - - validate_result = _run(["validate", "--dir", "docs/decisions", "--json"], cwd=repo) - assert validate_result["ok"] is True - assert validate_result["checked"] == 2 - - supersede_result = _run(["supersede", "1", "--by", "2", "--dir", "docs/decisions", "--json"], cwd=repo) - assert supersede_result["ok"] is True - - old_text = (repo / "docs" / "decisions" / "0001-use-rabbitmq.md").read_text(encoding="utf-8") - assert "status: superseded" in old_text - assert "superseded_by: ADR-0002" in old_text -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `python -m pytest tests/integration/test_record_workflow.py -v` -Expected: FAIL — fixture doesn't exist until Step 1's file is created. - -- [ ] **Step 3: Nothing further to implement** - -This task's "implementation" is the fixture itself. No production code changes — it proves Tasks 1–10 already compose correctly end to end. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `python -m pytest tests/integration/test_record_workflow.py -v` -Expected: PASS (1 test) - -- [ ] **Step 5: Commit** - -```bash -git add tests/fixtures/record_existing_adr tests/integration/test_record_workflow.py -git commit -m "test: add end-to-end RECORD/supersede fixture and golden test" -``` - ---- - -## Plan self-review notes - -- **Spec coverage:** §11 RECORD → Tasks 2, 3, 4, 8, 9, 10, 11; §11 Lifecycle operations → Tasks 1, 5, 6, 7, 10; §6.1 (what belongs in an ADR, reused by RECORD) → Task 10 (references it, no new implementation needed — already built in Plan 1). CHECK, i18n, and the remaining harness adapters are explicitly out of scope — Plans 3 and 4. -- **Type consistency checked:** `identifiers.find_by_number` signature (Task 1) matches its use in Tasks 6 and 7. `schema.validate_frontmatter`'s new optional-field handling (Task 5) doesn't change its existing signature or break any Plan 1 caller. `status.run` is reused unmodified as the `deprecate` handler via `set_defaults(to="deprecated")`, not a second copy of the transition logic. -- **No placeholders found.** - -## Execution status - -- Completed on 2026-08-30 via subagent-driven-development: all 11 tasks were - implemented and task-reviewed, including fix rounds for issues found during - review. The resulting unit and integration suite has 105 passing tests. -- The remaining user decisions are the public-release license and whether the - final MVP retains five languages and four named harnesses. Neither blocked - Plan 2. -- Codex `quick_validate.py` compatibility with the existing cross-harness - `user-invocable` and `version` skill frontmatter keys remains tracked in - `improvements.md` as a metadata/validator compatibility risk. diff --git a/docs/superpowers/specs/2026-08-30-adr-toolkit-localization-and-readiness-design.md b/docs/superpowers/specs/2026-08-30-adr-toolkit-localization-and-readiness-design.md new file mode 100644 index 0000000..e891ced --- /dev/null +++ b/docs/superpowers/specs/2026-08-30-adr-toolkit-localization-and-readiness-design.md @@ -0,0 +1,633 @@ +# ADR Toolkit v0.2.0 localization and readiness design + +## Status + +The initial direction was approved in conversation on 2026-08-30. This revision +incorporates review feedback and awaits final user approval before planning. +The design does not itself authorize a `v0.2.0` release. + +## Problem statement + +The MVP proves that ADR Toolkit can initialize, discover, record, validate, +index, and check Architecture Decision Records. The next release must prove +something stricter: the toolkit must produce useful ADRs in real repositories, +serve users in their chosen language, and have a credible path from a +single-maintainer GitHub repository to team and enterprise governance. + +The current repository exposes four gaps that should be addressed before +`v0.2.0`: + +1. Runtime localization covers only five locales and only the generated + decision-log index. INIT, interactive CREATE, and MADR section headings + remain English. +2. Non-ASCII-only titles cannot be created because filename slug generation + accepts only `[a-z0-9]` and returns an empty slug for titles such as + `결제 시스템 분리`. +3. The dogfooded ADRs pass structural validation but do not yet demonstrate + the full RECORD quality contract: decision ownership is empty, some + retrospective decisions are marked as prospective, and affected paths and + confirmations are narrower than the decisions they describe. +4. `handoff.md`, `improvements.md`, and `project-roadmap.md` contain stale or + overlapping state. Work selected for implementation needs to move into + `improvements.md`; it should no longer remain an unscheduled roadmap item. + +## Design principles + +### Deterministic Core, Agentic Edge + +ADR Toolkit allows an agent to help with judgment, but never makes repository +governance depend on an agent's unvalidated output. + +The deterministic core owns: + +- IDs and portable filenames. +- Lifecycle transitions and relationship invariants. +- Runtime and JSON Schema validation. +- Locale configuration and catalogs. +- Index generation and CHECK rule evaluation. +- Every mutation of repository ADR state. + +The agentic edge may assist with: + +- Inferring the user's requested language. +- Drafting ADR prose and extracting rationale from evidence. +- Suggesting a semantic ASCII filename slug. +- Finding related decisions and assessing significance. +- Explaining findings and presenting alternatives to a human. + +Agent output crosses a deterministic validation boundary before it becomes +repository state. + +### Human-localized, machine-stable + +Humans and agents author prose directly in the chosen language. Machine +contracts—IDs, statuses, JSON keys, error codes, rule kinds, and filenames—stay +stable across locales. ADR Toolkit does not maintain opaque automatic +translations as parallel sources of truth. + +### Repository-local ownership + +The repository remains the source of truth for its decisions, configuration, +owners, and history. Future central discovery may index repository-local ADRs; +it must not replace or silently mutate them. + +### No false governance confidence + +CHECK reports only what explicit evidence can prove. A passing structural rule +does not mean that an entire architecture is compliant. Policies that cannot be +evaluated deterministically remain visibly unverifiable and require human +review. + +## Goals + +- Generate ADRs in English, Korean, Japanese, Simplified Chinese, French, + Spanish, German, and Brazilian Portuguese. +- Let the user choose a locale explicitly and let an agent infer it from the + user's request when no explicit choice exists. +- Preserve user-authored text instead of applying opaque machine translation. +- Keep filenames portable across Git, macOS, Linux, Windows, URLs, and common + agent harnesses. +- Correct and strengthen this repository's unreleased dogfooded ADR set. +- Publish separate Korean readiness and enterprise-adoption reports. +- Give `handoff.md`, `improvements.md`, `project-roadmap.md`, and the new report + non-overlapping responsibilities. +- Define public-repository governance as a planned transition, not misclassify + its present absence as a defect in a deliberately private repository. + +## Non-goals + +- Maintaining eight translated copies of every ADR. +- Translating arbitrary user prose after it has been written. +- Translating the full project README and contributor documentation into eight + separate documents in this change. +- Building a GitHub App, central decision portal, vector database, or multi-repo + graph before the local workflow and GitHub Action integration are proven. +- Enabling GitHub branch protection while the private repository's current plan + does not expose that feature. + +## Objective baseline assessment + +Maturity uses reproducible integer levels rather than reviewer-selected +decimal scores: + +| Level | Meaning | +|---:|---| +| 1 | Prototype or manual experiment; core outcome is not repeatable. | +| 2 | Feature exists, but an important correctness, portability, or workflow gap remains. | +| 3 | Repeatable for an individual or small team with documented manual controls. | +| 4 | Repeatably verified by CI and enforceable repository governance. | +| 5 | Organization-wide policy, audit, exception handling, and measured outcomes exist. | + +Each dimension advances only when all of its listed signals are present. The +readiness report records the command, test, workflow, or repository setting +that proves each signal. + +| Dimension | Current | Evidence | v0.2.0 target signals | +|---|---:|---|---| +| Deterministic core | 3 | 212 tests pass; ID, lifecycle, validation, index, diff, and release paths are scripted | Level 4: all P0 commands and mutations have CI-covered success and failure paths | +| ADR content quality | 2 | Five ADRs validate, but all omit decision makers and retrospective reconstruction is inconsistent | Level 4: RECORD contract, ownership, evidence separation, index stability, and confirmations are verified | +| Internationalization | 2 | Five index locales exist; prompts, templates, config, and non-ASCII title creation are incomplete | Level 4: eight locale catalogs plus INIT/CREATE/INDEX E2E, fallback, schema parity, and Unicode-title tests | +| CHECK correctness | 3 | Structural rules work; rename handling, ignored paths, and subprocess failure handling remain open | Level 4: no known false-confidence defect and all four evidence outcomes have regression coverage | +| Developer experience | 3 | Root README and executable quickstart exist; locale and config behavior are not documented or consistent | Level 4: repository config, override examples, and executable README commands are verified | +| Distribution parity | 2 | Four harness paths are documented, but only some were exercised end to end | Level 3: limitations are exact and every manifest has structural verification | +| Repository governance | 2 | Git Flow and CI exist; the private repository intentionally lacks enforceable branch rules | Level 3 before public through documented controls; Level 4 only after public rules are API-verified | +| Enterprise scalability | 1 | No organization ruleset, cross-repo catalog, RBAC, audit export, or adoption metrics | Remains post-v0.2.0; do not inflate the release scope | + +The target is deliberately incremental. `v0.2.0` makes the local product and +its own ADRs trustworthy; it does not pretend to complete enterprise +governance in one release. + +## v0.2.0 release scope and gates + +The new locale set, repository configuration, and multilingual generation are +backward-compatible feature additions, so Semantic Versioning requires a minor +release (`v0.2.0`), not a patch release (`v0.1.1`). + +### P0 — release blocking + +- Non-ASCII-only and mixed-script titles create portable files reliably. +- The locale catalog, loader, and repository config resolution are + deterministic. +- INIT, interactive CREATE, input CREATE, and INDEX support the canonical + locale set. +- Runtime validation and JSON Schema remain consistent. +- Existing unreleased ADRs satisfy the minimum RECORD quality contract. +- CHECK has no known correctness defect that can produce false confidence. +- README documents repository defaults, per-command overrides, and executable + examples for the released locale behavior. +- The full test suite and version-sync check pass freshly. + +### P1 — strongly recommended before release + +- Publish the Korean v0.2.0 readiness report. +- Add extended Korean and non-Latin quickstart examples. +- Improve dogfooding ADR content beyond the minimum contract where evidence is + available. +- Reconcile `handoff.md`, `improvements.md`, and `project-roadmap.md`. + +P1 work may move after the release only through an explicit owner decision +recorded in the readiness report; it must not silently disappear. + +### P2 — post-release + +- Apply public-repository GitHub governance after the visibility change. +- Add CODEOWNERS and review policy when more than one independent reviewer can + satisfy it. +- Evaluate organization rulesets, enterprise adoption metrics, and + cross-repository discovery from actual adoption evidence. + +## Chosen approach + +Extend the existing deterministic scripts and locale catalog. Do not add a +separate translation program and do not create translated ADR replicas. + +The alternatives were: + +1. Add three index locale files only. This is cheap but leaves the actual ADR + creation workflow English-only and does not solve non-ASCII titles. +2. Localize the complete generation boundary while preserving user prose. + This is the selected approach because it covers both agent-mediated and + standalone CLI usage without introducing translation drift. +3. Generate and synchronize eight complete translations of every ADR. This is + rejected because approval, supersession, links, and content corrections + would diverge across replicas. + +## Locale model + +### Supported locales + +The canonical locale set is: + +```text +en, ko, ja, zh, fr, es, de, pt-BR +``` + +`zh` means Simplified Chinese. A future Traditional Chinese locale must use a +distinct code such as `zh-TW` rather than changing the meaning of `zh`. + +### Selection precedence + +For agent-mediated workflows: + +```text +explicit language requested by the user +→ language detected from the user's request +→ repository default +→ en +``` + +For standalone CLI workflows: + +```text +explicit CLI --locale +→ locale supplied by an approved input draft +→ repository default +→ en +``` + +The standalone CLI does not guess from the operating-system locale. That would +make automation depend on the machine running it and would make CI output less +reproducible. + +Argparse uses `None` for an omitted `--locale`; setting its parser default to +`en` would erase the distinction between an explicit CLI override and the +absence of one before repository configuration can be consulted. + +### Repository configuration + +The repository defines its normal ADR language once at its root: + +```json +{ + "schema_version": 1, + "locale": "ko" +} +``` + +The canonical filename is `.adr-toolkit.json`. JSON is selected instead of +YAML because the toolkit supports Python 3.9, has no YAML dependency, and must +not grow another incomplete hand-written YAML parser for two scalar settings. +TOML is not selected because `tomllib` is unavailable in Python 3.9's standard +library. + +Commands resolve the file from the repository working directory. The README +continues to require running commands from the repository root. A malformed +config, unsupported `schema_version`, unknown field, or unsupported locale is a +visible configuration error rather than a silent English fallback. + +INIT includes `.adr-toolkit.json` in its dry-run and creates it when absent, +using the explicit `--locale` or `en`. It never overwrites an existing config. +Existing repositories may add the small file directly; a dedicated config +mutation command is deferred until real usage shows that manual creation is a +problem. + +An unsupported locale is a user error. It must not silently become English. +A shipped locale file that is missing or malformed may degrade to the complete +English base at runtime, but the test suite and release checks must reject that +state before publishing. + +### What is localized + +- INIT's generated ADR body and repository ADR template. +- Interactive CREATE prompts and generated Minimal MADR headings. +- Minimal and Full MADR section labels used by agent guidance. +- Decision-log headings and status display labels. +- Human-readable README examples and locale documentation. + +Machine contracts remain stable: + +- CLI command names and flags stay English. +- JSON field names, operation names, error codes, status values, and rule kinds + stay locale-neutral English identifiers. +- File encoding remains UTF-8. + +### ADR metadata + +Newly generated ADRs carry an optional `locale` frontmatter field: + +```yaml +locale: ko +``` + +It is optional for backward compatibility with ADRs created by earlier +versions or by other tools. ADR Toolkit-generated files always include it. +Validation rejects an unsupported value when the field exists. The external +JSON Schema and runtime validator must remain in parity. + +The locale records the language selected for generated structure. It does not +claim that every code identifier, quotation, or linked document in the body is +written in that language. + +## Portable filename policy + +ADR titles and bodies support Unicode. Filenames remain ASCII because Unicode +normalization and tool behavior can differ across platforms and integrations. + +Creation follows this order: + +1. Use an explicitly supplied ASCII `slug` when present and valid. +2. Otherwise derive the existing ASCII slug from the title when non-empty. +3. Otherwise use the locale-neutral fallback `decision`. + +Examples: + +```text +title: Use Kafka for events → 0006-use-kafka-for-events.md +title: 결제 시스템 분리 → 0006-decision.md +title: 결제 시스템 분리, slug: separate-payment-system + → 0006-separate-payment-system.md +``` + +The numeric ADR ID guarantees uniqueness, so a repeated fallback slug is not a +collision. The parser continues to accept the established ASCII filename +contract. + +The fallback is a safety net, not the preferred agent experience. In an agent +workflow, the agentic edge proposes a short semantic ASCII slug, shows it with +the title during CONFIRM, and supplies it to CREATE. The deterministic core +does not translate or transliterate; it only validates the supplied slug and +uses `decision` when no valid semantic slug exists. + +```text +Korean title +→ agent suggests `separate-payment-system` +→ human approves title and slug +→ core validates `[a-z0-9-]+` +→ core writes `0006-separate-payment-system.md` +``` + +Standalone users may supply the same optional slug. This preserves useful file +navigation without introducing a nondeterministic translation library into the +core. + +## Component changes + +### Locale catalog + +`scripts/i18n/*.json` becomes the single catalog for deterministic text. The +English file defines the complete key set; each other locale must match it +exactly. Keys cover index labels, statuses, MADR headings, INIT boilerplate, +and interactive prompts. + +### Locale loader + +`scripts/core/locale.py` owns the canonical locale list, catalog loading, +fallback behavior, and completeness checks. CLI parsers import the same list +instead of duplicating choices. + +### Repository config loader + +A focused config module loads `.adr-toolkit.json`, rejects unknown or malformed +state, and resolves locale precedence without writing files. INIT owns initial +config creation so config reads and mutations remain separate and testable. + +### Template rendering + +A focused template-rendering module converts locale strings and user-provided +answers into Minimal or Full MADR Markdown. Interactive CREATE and INIT call +this renderer rather than maintaining English prose inline. Agent-authored +draft bodies remain accepted as opaque approved Markdown. + +### Command behavior + +- `init --locale ` localizes generated files and records locale metadata. +- `create --interactive --locale [--slug ]` localizes + prompts and structure and accepts an optional user-approved semantic slug. +- `create --input` accepts optional `locale` and `slug` draft fields; an + explicit CLI locale overrides the draft locale. +- `index --locale ` keeps its existing responsibility and gains the + three new locales. +- Omitted locale flags resolve through `.adr-toolkit.json` before falling back + to English. +- `validate` checks a present locale, config validity, and all existing + relationship invariants. + +## Dogfooding corrections + +The five current ADR files were added after the `v0.1.0` tag and have not yet +shipped from `master`. They may therefore be corrected before their first +release without rewriting a released decision log. + +The correction set is: + +- Record `YangSeungHyun` as the decision maker because the repository owner + explicitly approved the decisions before commit. +- Add `locale: en` to the existing English ADRs. +- Keep every status `accepted`. +- Mark ADR-0002 through ADR-0004 retrospective and separate confirmed + evidence, inferred rationale, and unknowns as required by the toolkit. +- Treat ADR-0005 as contemporaneous unless contrary evidence is supplied. +- Expand ADR-0004 to Full MADR because it compares three realistic options. +- Correct affected paths, relationships, confirmation evidence, and the INIT + ADR's visibly incomplete confirmation checkbox. +- Evaluate missing core decisions—Deterministic Core / Agentic Edge, no silent + mutation, and lifecycle/link invariants—using the significance rubric before + deciding whether to add ADRs. +- Add CHECK constraints only where the rule vocabulary can prove the policy + without misleading false confidence. + +After release, Accepted ADR content becomes append-only: a changed decision is +recorded as a new ADR and linked through supersession. Narrow factual metadata +corrections need an explicit documented policy rather than silent edits. + +## CHECK confidence contract + +CHECK validates explicit structural evidence; it never certifies an entire +architecture. The user-facing model distinguishes four meanings: + +| Meaning | Interpretation | +|---|---| +| VERIFIED | Applicable deterministic rules were evaluated and no violation was found for this diff. | +| VIOLATED | Direct structural evidence proves that an explicit rule was violated. | +| UNVERIFIABLE | The ADR is relevant, but prose, missing rules, or ambiguous evidence prevents deterministic verification. | +| NOT_APPLICABLE | The ADR does not govern the changed scope. | + +For backward compatibility, `v0.2.0` keeps the existing JSON finding kinds +(`related`, `review_required`, `verified_violation`, and +`no_applicable_constraint`). Documentation maps those kinds to the confidence +model and avoids calling an empty finding list “architecture verified.” A +future API normalization requires its own compatibility design. + +The release-blocking correctness work is narrower and more urgent: rename +handling, ignored-path discovery, and every git subprocess failure must avoid a +false clean result. CHECK constraints are added to dogfooded ADRs only when the +rule vocabulary can prove the policy. + +## Separate reports + +Create: + +```text +docs/adr-toolkit-v0.2.0-readiness-report.md +docs/enterprise-adoption.md +``` + +Both reports are Korean and distinguish four evidence classes: + +1. Repository fact, linked to a file, test, command result, commit, PR, or + workflow run. +2. External practice, linked to an official primary source. +3. Inference, explicitly labeled and tied to its evidence. +4. Recommendation, including priority, cost, dependency, and acceptance + signal. + +The readiness report stays release-focused: + +- Executive assessment and maturity table. +- Product architecture and dependency boundaries. +- ADR dogfooding quality audit. +- `improvements.md`, `handoff.md`, and roadmap reconciliation. +- Reliability, security, portability, and developer-experience gaps. +- P0/P1 release gate evidence. +- Risks, invalidation triggers, and measurable success criteria. + +The enterprise report owns the broader adoption path: + +- Team ownership, proposal, review, acceptance, and exception workflow. +- Public GitHub transition checklist. +- CODEOWNERS, rulesets, required checks, and auditability. +- Organization-level governance, RBAC, taxonomy, and metrics. +- Cross-repository discovery and integration sequencing. + +This split keeps enterprise analysis available without making every enterprise +capability appear to be part of `v0.2.0`. + +## Work-tracking document boundaries + +### `handoff.md` + +Contains only the active task, touched files, next step, and open risk. Remove +the stale statement that the MVP feature branch is not integrated. + +### `improvements.md` + +Contains concrete implementation work selected for execution. Add the +repository config, localization, dogfooding corrections, reports, and CHECK +correctness work that have actionable `v0.2.0` acceptance criteria. Replace +umbrella links back to the roadmap with explicit tasks, owners or prerequisites, +priority, and a testable completion signal. + +### `project-roadmap.md` + +Contains unscheduled product bets. Remove items promoted to +`improvements.md`, including localized MADR headings and the concrete CHECK and +i18n/adapter/release follow-up lists. Keep genuine later bets such as semantic +conflict analysis, relationship visualization, multi-repo graphs, +public/enterprise governance automation, central portals, and external-system +integrations. + +### Reports + +Capture the assessment snapshot and why the priorities were chosen. They do not +become second task trackers. + +## Post-v0.2.0 adoption boundary + +The repository is private. Its current plan returns HTTP 403 for branch +protection and repository ruleset APIs. This is a platform-plan constraint and +an intentional pre-public state, not evidence that the documented Git Flow +policy was forgotten. + +CI has run successfully on the first pull request and on `develop`, `master`, +and the release tag. The limitation is enforcement: GitHub cannot currently +prevent a direct push or an unreviewed merge. This does not block `v0.2.0`. + +When the owner makes the repository public, the enterprise-adoption report +requires the operational task to apply and verify: + +- Branch protection or repository rulesets for `master` and `develop`. +- Pull requests required before merge. +- Required ADR validation, test, and version-drift checks. +- Required conversation resolution. +- Force-push and deletion blocking. +- `CODEOWNERS` coverage for `docs/decisions/`, schemas, lifecycle code, and + release workflows. +- A pull-request template with an ADR impact declaration. +- Tag protection or a tag ruleset for `v*` releases. +- A documented bypass policy for emergencies. +- A deliberate decision on signed commits rather than enabling them without a + bot and contributor migration plan. + +GitHub settings must be verified through the API after configuration. A policy +written only in `AGENTS.md` is not an enforcement signal. Team workflow, RBAC, +audit export, organization rulesets, metrics, and central discovery remain in +the separate enterprise report and post-release roadmap until usage evidence +promotes them into `improvements.md`. + +## External evidence baseline + +- AWS Prescriptive Guidance defines ADR ownership, Proposed-state review, + stakeholder recording on acceptance, immutable accepted records, and + supersession instead of overwriting: + +- AWS recommends distributed ownership, preserved history, regular review, + central accessibility, and explicit handling of non-compliant legacy code: + +- Microsoft recommends an append-only decision log, retrospective recovery for + brownfield systems, confidence recording, consistent anatomy, and standalone + rationale: + +- GitHub documents required pull requests, CODEOWNER approval, required status + checks, code scanning, force-push controls, and tag/branch rulesets: + +- GitHub organization rulesets can apply governance across repositories and + expose evaluation insights: + + +## Verification plan + +### Localization + +- Every locale has exactly the English catalog's required keys. +- CLI accepts all eight locale codes and rejects unknown codes. +- Locale resolution proves CLI → draft → repository config → English + precedence, including the agent-mediated precedence documented in SKILL.md. +- Missing config defaults to English; malformed, unknown-version, unknown-key, + and unsupported-locale configs fail visibly. +- INIT dry-run reports config creation, INIT creates config when absent, and + never overwrites an existing file. +- Each locale passes INIT → interactive CREATE → VALIDATE → INDEX in a scratch + repository. +- Each generated body contains the expected localized headings. +- Non-ASCII-only, mixed-script, and explicit-slug titles create stable files. +- ADRs without locale remain valid; unsupported present locales fail. +- JSON Schema and runtime validation remain in parity. +- Agent guidance proposes an optional semantic ASCII slug and the core rejects + invalid supplied slugs without attempting translation. + +### Dogfooding and documentation + +- All repository ADRs validate. +- The decision index regenerates without a diff. +- Confirmation claims are checked against actual files and commands. +- CHECK runs against a representative conforming and violating change. +- CHECK regression tests prove that rename handling, ignored paths, and every + git subprocess failure cannot produce a false clean result. +- User documentation distinguishes verified rules from unverifiable policy and + never equates an empty finding list with full architecture compliance. +- README commands execute as written. +- `handoff.md`, `improvements.md`, and `project-roadmap.md` contain no duplicate + active items and no claims contradicted by the Git graph. + +### Repository + +- Full pytest suite passes freshly. +- Version sync check passes. +- Workflow YAML remains valid. +- Working tree contains only intended changes. +- Public GitHub protections are not claimed complete until API verification + succeeds after the repository is made public. + +## Delivery order + +1. Reconcile work-tracking documents and write the two assessment reports. +2. Implement repository config, locale resolution, and catalog completeness + with tests. +3. Implement localized rendering boundaries across INIT, CREATE, and INDEX. +4. Implement portable slug fallback, semantic-slug input, and locale metadata + with compatibility tests. +5. Update README, examples, agent guidance, and CHECK confidence language. +6. Correct the unreleased dogfooded ADRs and regenerate their index. +7. Fix release-blocking CHECK correctness defects selected in + `improvements.md`. +8. Run every P0 verification signal and perform a final pre-`v0.2.0` review. +9. Complete or explicitly defer each P1 item in the readiness report. +10. After the repository becomes public, configure and API-verify GitHub + protections as a separate operational task. + +## Decision and revisit triggers + +Adopt complete generation-boundary localization with one approved ADR per +decision, not translated replicas. Keep deterministic machine contracts in +English and portable filenames in ASCII. + +Revisit this design if: + +- Users require bilingual ADRs rather than one chosen language. +- A supported harness cannot read UTF-8 bodies reliably. +- Generic fallback filenames materially harm navigation at scale. +- Translation maintenance produces repeated correctness defects. +- Teams need repository config beyond locale and the minimal versioned JSON + shape no longer covers a proven use case. +- Public or enterprise GitHub settings materially change the available + enforcement model. diff --git a/docs/superpowers/specs/2026-08-31-adr-search-and-relationships-design.md b/docs/superpowers/specs/2026-08-31-adr-search-and-relationships-design.md new file mode 100644 index 0000000..455b800 --- /dev/null +++ b/docs/superpowers/specs/2026-08-31-adr-search-and-relationships-design.md @@ -0,0 +1,300 @@ +# ADR Search and Relationship Navigation Design + +## Context and Problem + +ADR Toolkit ships as an open-source, multi-harness skill meant for other developers and +teams to adopt, not just for this repository's own 10 ADRs. `project-roadmap.md`'s "ADR +navigation and scale" item flagged this as future work, gated on evidence. Research into +comparable real-world tools (`npryce/adr-tools`, the classic flat-file CLI with no search; +`thomvaill/log4brains`, 1.5k GitHub stars, the most-adopted actively maintained ADR tool) +shows the two features that actually matter for any team running an ADR practice over time +are **search** and **relationship visibility** — not folder restructuring, not semantic +search, not a web UI. Those two are proven table-stakes for any team, regardless of current +repository size; building them now is not premature relative to real adopters, even though +this repository itself only has 10 ADRs today. + +Guiding principle for this design: **keep ADR storage boring; make ADR retrieval +powerful.** Storage stays flat Markdown + frontmatter + Git — nothing here changes that. +Retrieval gets a real, shared, deterministic core that both a human at a terminal and an +agent consume identically. + +Three concrete gaps exist today: + +1. `related.py` — the only existing lookup command — matches a keyword against `title` + only, never `body`. A decision phrase that lives in the rationale/context, not the + title, cannot be found. +2. The generated `docs/decisions/README.md` shows *that* an ADR is `superseded` (under "By + status") but never shows *what superseded it*, and never shows `related` links at all, + without opening the file. +3. `validate.py` checks that `related` IDs point at real ADRs (`BROKEN_RELATED_LINK`), but + `supersedes`/`superseded_by` are never checked for existing or mutually consistent — + `adr.py supersede` writes both sides atomically today, but nothing catches it if a + frontmatter field is later hand-edited into an inconsistent state. + +## Goals + +- Let a human or an agent find an existing ADR by keyword (title **and** body), tag, + status, or the file path it governs — without opening every file. +- Return results in a deterministic, best-match-first order. +- Keep result size and shape safe for an agent's context window (`--limit`, `total`, + `truncated`). +- Make supersession and relatedness visible directly in the generated index, by title not + just ID. +- Catch broken or inconsistent relationship metadata as a structural validation error, the + same way a broken `related` link already is. +- Fix `related`'s title-only keyword gap as part of the same change, since it's the same + underlying defect — without changing its existing matching *policy*. +- Keep the CLI/agent-native shape this project already has: no web server, no static site, + no new storage format, no ranking model beyond simple deterministic tiers. + +## Non-goals (stay in `project-roadmap.md`, unchanged) + +- Rendered graph images / DOT / Mermaid visualization of relationships. +- Full-text indexing, ranking algorithms (BM25/TF-IDF), or semantic/vector search. +- Relationship cycle detection (the model below doesn't block adding it later; it's simply + not built now — no cycle has ever occurred in this repository's 10 ADRs). +- Directory sharding or an alternate storage layout. +- A standalone `adr.py relationships ` command or `--id` exact lookup — small, + plausible near-future additions, deliberately left out of this spec to keep it focused. + +These remain explicitly deferred until real usage (per the roadmap's own stated +threshold) demonstrates the deterministic, substring-based approach here is insufficient. + +## Components + +### 1. `scripts/core/query.py` — shared matching and ranking primitives + +```python +def matches_keyword(keyword: str, title: str, body: str) -> bool +def matches_tags_any(query_tags: set, entry_tags: list) -> set # overlap, used by related's OR policy +def matches_paths_exact(query_paths: set, affected_paths: list) -> set # overlap, used by related's OR policy +def path_governed_by(path: str, affected_paths: list) -> bool # prefix/glob overlap, used by search --path +def rank_key(entry: dict, keyword: str) -> tuple # deterministic best-match-first sort key +``` + +`matches_keyword` is the bug fix: case-insensitive substring match against `title` **or** +`body`, not title alone. + +`matches_tags_any`/`matches_paths_exact` are `related.py`'s existing set-intersection logic, +extracted unchanged — this is the matching policy a broad, "cast a wide net before +drafting" discovery command wants. + +`path_governed_by` is a **different, new** matching policy: does a single real file path +fall under any of an ADR's `affected_paths`, using the same directory-boundary prefix and +glob logic `scripts/rules/conflict.py`'s `affected_paths_overlap`/`_path_under` already use +for CHECK. This is deliberately not the same function as `matches_paths_exact` — "does this +ADR's frontmatter literally list this path" (what `related` needs when comparing a new +draft's exact `affected_paths`) and "does this real file fall under an ADR's governed +scope" (what `search --path` needs when an agent asks "does anything govern +`src/payment/x.py`?") are genuinely different questions. + +Layering note: `rules/conflict.py` already imports `core/globs.py` (nothing in `core/` +currently imports from `rules/`, and this spec doesn't introduce that direction). So +`_path_under` moves *into* `core/globs.py` as a new public `globs.path_under(path, prefix)` +— `conflict.py` is refactored to call it instead of owning a private copy, and +`query.path_governed_by` calls the same function. One implementation, no new dependency +direction between `core` and `rules`. + +`rank_key` orders results deterministically, best match first, using fixed tiers (highest to +lowest): exact ID match, exact title match, title substring match, tag exact match, body +substring match. No numeric score is exposed in the JSON output — the tiering is an +internal sort key, not a stable public contract, avoiding over-committing to arbitrary +weights. + +### 2. `scripts/core/relationships.py` — canonical relationship model + +```python +class Relationship(NamedTuple): + source: str # ADR id + type: str # "related" | "supersedes" | "superseded_by" + target: str + +def resolve(entries: list) -> list[Relationship] +def missing_targets(relationships: list, known_ids: set) -> list[Relationship] +def supersession_mismatches(relationships: list) -> list[tuple] # (supersedes_edge, expected_but_missing_or_wrong) +``` + +`resolve()` turns each ADR's `related`/`supersedes`/`superseded_by` frontmatter into a flat +list of edges. `index.py`, `validate.py`, and any future consumer (a graph renderer, an MCP +tool) read this same list instead of each re-deriving relationship data from raw frontmatter +independently — this is the one piece of real structural investment in this spec, and it's +what keeps `index.py`'s section from becoming ad-hoc string-building that a future feature +would have to reverse-engineer. + +### 3. `related.py` — fixed, not replaced, policy unchanged + +Updated to import `matches_keyword`, `matches_tags_any`, `matches_paths_exact` from +`core/query.py` instead of owning duplicate logic. Keyword now matches body too (the fix). +**Combination policy is unchanged**: a path OR a tag OR a keyword match is enough to +include an ADR ("related" wants a broad net before drafting) — this is `related`'s existing, +tested behavior and this spec does not change it. A regression test proves a body-only +keyword match now succeeds; all thirteen existing `related` tests must keep passing +unmodified. + +### 4. `scripts/commands/search.py` — new command + +``` +adr.py search [--keyword TEXT] [--tags TAG [TAG ...]] [--status STATUS] [--path PATH] + [--limit N] --dir docs/decisions --json +``` + +`--tags` matches `related.py`'s existing flag name and `nargs="*"` shape for CLI +consistency (space-separated values under one flag, not a repeatable `--tag`). + +Distinct purpose from `related`: general lookup ("what ADRs exist?"), not tied to drafting. +Two policy differences from `related`, both deliberate: + +- **Filters across different fields combine with AND; multiple values within the same + field combine with OR.** `--keyword database --tags postgres mysql --status + accepted` means: keyword matches AND (tag is postgres OR mysql) AND status is accepted. + This must be documented explicitly in `--help` and the README — an agent must never have + to guess implicit CLI semantics. +- **No filters given returns every non-skip ADR** (browse mode), the opposite of + `related`'s "no filters -> no matches." An empty query on a lookup command should list, + not return nothing. + +`--path` uses `query.path_governed_by`, matching CHECK's real-file-vs-governed-scope +semantics, not `related`'s exact frontmatter-list semantics. + +Result shape: + +```json +{ + "ok": true, + "operation": "search", + "query": {"keyword": "confidence", "tags": ["check"], "status": null, "path": null, "limit": null}, + "count": 2, + "total": 2, + "truncated": false, + "results": [ + { + "id": "ADR-0007", + "filename": "0007-check-confidence-field.md", + "path": "docs/decisions/0007-check-confidence-field.md", + "title": "Promote CHECK's kind-to-confidence mapping to a stable output field", + "status": "accepted", + "tags": ["check", "confidence", "v0.2.0"], + "matched_in": ["title", "tags"] + } + ], + "warnings": [] +} +``` + +`matched_in` lists which field(s) caused the match (`title`, `body`, `tags`, `status`, +`path`) — kept exactly as originally designed, for the same transparency reason CHECK +findings carry `reasons`: a consumer should never have to trust a match blindly. Detailed +per-field match evidence (e.g. which substring matched) is deferred — `matched_in` alone is +enough for MVP. + +`--limit N` truncates `results` to N entries (already rank-ordered, so truncation drops the +weakest matches, not arbitrary ones); `total` is always the untruncated match count; +`truncated` is `true` iff `total > count`. No default limit — unlimited unless requested, +matching every other command's current behavior; the fields exist so a harness *can* bound +its own consumption, not so the tool imposes one. + +Uses `core.adr_directory.iter_adr_files` for the walk and `frontmatter.parse` for +frontmatter, matching every other command's pattern. A malformed ADR degrades to a +`BAD_FRONTMATTER` warning, same as `related`/`check`/`validate`. + +### 5. `index.py` — Relationships section + +New generated section, after "Chronological", built from `relationships.resolve()` instead +of ad-hoc string building, localized like every other section header: + +``` +## Relationships + +### Supersession chains +- ADR-0003 "Localize only index.py's generated strings..." → superseded by → ADR-0006 "Localize deterministic ADR generation..." + +### Related +- ADR-0007 "Promote CHECK's kind-to-confidence..." related to: ADR-0002 "Limit CHECK's conflict detection..." +``` + +Titles are shown alongside IDs (not IDs alone) — a developer reading the generated README +should understand a relationship without opening either file. Only ADRs that actually carry +a `supersedes`/`superseded_by`/non-empty `related` are listed; an ADR with none of those is +omitted entirely (no noise). `entries` in `run()` gains `related`, `supersedes`, +`superseded_by` from frontmatter (currently not collected). + +New i18n keys (`relationships`, `supersession_chains`, `related`, `superseded_by`, +`related_to`), added to `FALLBACK_STRINGS` and all 8 locale catalogs +(`skills/adr-toolkit/scripts/i18n/{en,ko,ja,zh,fr,es,de,pt-BR}.json`), following the exact +pattern `by_status`/`by_tag` already established. + +### 6. `validate.py` — relationship integrity + +Extends the existing `known_ids`/`BROKEN_RELATED_LINK` check (which today only covers +`related`) using `relationships.resolve()` and `relationships.missing_targets()`/ +`supersession_mismatches()`: + +- `BROKEN_SUPERSESSION_LINK` — a `supersedes` or `superseded_by` value references an ADR ID + that doesn't exist among `known_ids`. Same severity and shape as the existing + `BROKEN_RELATED_LINK`. +- `SUPERSESSION_MISMATCH` — ADR A's `supersedes` lists B, but B's `superseded_by` isn't A + (missing or pointing elsewhere). Catches exactly the case `adr.py supersede` normally + prevents by writing both sides atomically, but a hand-edit could still produce. + +Both are **errors**, not warnings — `validate.py` has no warnings mechanism today (unlike +`check.py`/`index.py`), and introducing one for two checks would be disproportionate new +infrastructure. This matches the existing `BROKEN_RELATED_LINK`'s severity exactly, so no +new severity concept is introduced. + +Two codes, not three: `missing_targets()` alone would suggest a third +`MISSING_RELATIONSHIP_TARGET` code distinct from `BROKEN_SUPERSESSION_LINK`, but a missing +supersession target *is* a broken supersession link — one code covers it. + +## Testing + +- `tests/unit/test_globs.py`/`test_conflict.py` — extended for `globs.path_under()`; + existing `conflict.affected_paths_overlap` tests must pass unmodified after the refactor. +- `tests/unit/test_query.py` — `matches_keyword` (title-only, body-only, neither), + `matches_tags_any`, `matches_paths_exact`, `path_governed_by` (directory-boundary + prefix, glob, and non-match cases), `rank_key` ordering across all five tiers. +- `tests/unit/test_relationships.py` — `resolve()` produces the expected edge list; + `missing_targets()`/`supersession_mismatches()` against fixture data with a dangling + target and a one-sided supersession. +- `tests/unit/test_search_command.py` — keyword-in-title, keyword-in-body, tag filter + (OR within field), AND across different fields, status filter, `--path` using governed-by + semantics, no-filter-returns-all, `--limit`/`total`/`truncated`, `matched_in` correctness, + deterministic ordering, malformed-ADR warning. +- `tests/unit/test_related.py` — new regression test: keyword present only in body now + matches; all existing tests unmodified and still passing (policy unchanged). +- `tests/unit/test_index.py` — Relationships section renders a supersession chain and a + related list with titles; an ADR with no relationships is absent from the section. +- `tests/unit/test_validate.py` — `BROKEN_SUPERSESSION_LINK` for a dangling supersedes/ + superseded_by target; `SUPERSESSION_MISMATCH` for a one-sided edit; the real repository's + own 10 ADRs still validate clean (no existing supersession is broken). +- `tests/unit/test_locale.py` — extended to cover the five new keys in the existing + 8-catalog exact-key-set parity check. +- `tests/integration/test_localized_workflow.py` or equivalent — `search` and the new + index section produce valid output in at least one non-English locale. + +## Documentation + +- `README.md` — add `search` alongside the existing `related`/`check` usage examples, + including the explicit AND/OR filter-semantics statement. +- `skills/adr-toolkit/SKILL.md` — document when an agent should reach for `search` versus + `related` (ad-hoc lookup vs. DISCOVER-stage conflict check), framed as: `related` is a + discovery *policy* built on the same Query Engine `search` uses generically. +- Run every newly documented command in a scratch repository before treating the docs as + accurate, matching this project's existing practice. + +## Implementation sequencing + +1. Extract `_path_under` from `rules/conflict.py` into `core/globs.py` as + `globs.path_under()`; refactor `conflict.affected_paths_overlap` to call it. Full + `test_conflict.py`/`test_check.py` suites must still pass unmodified — pure refactor, + no behavior change. +2. `core/query.py` + tests (no command changes yet). +3. `core/relationships.py` + tests (no command changes yet). +4. `related.py` migrated onto `core/query.py`, body-search regression test added — verify + all existing tests still pass unmodified. +5. `search` command + CLI wiring + tests. +6. `index.py` Relationships section + 8-locale catalog updates + tests. +7. `validate.py` relationship-integrity checks + tests, run against this repository's real + 10 ADRs to confirm no existing supersession is inadvertently flagged. +8. Docs (README, SKILL.md) + a real scratch-repo run of every newly documented command. +9. Full suite + `sync_version.py --check` + `adr.py validate`/`index` against this + repository's real ADRs. diff --git a/docs/superpowers/specs/2026-08-31-public-readiness-and-adr-graph-design.md b/docs/superpowers/specs/2026-08-31-public-readiness-and-adr-graph-design.md new file mode 100644 index 0000000..14b0d33 --- /dev/null +++ b/docs/superpowers/specs/2026-08-31-public-readiness-and-adr-graph-design.md @@ -0,0 +1,27 @@ +# Public Readiness and ADR Graph Design + +## Scope + +Prepare ADR Toolkit for public open-source use and add a sharper ADR navigation +view without changing the canonical flat `docs/decisions/` storage model. + +## Decisions + +- Keep ADR Markdown files flat in `docs/decisions/`. +- Add a Mermaid relationship graph to the generated decision index so GitHub can + render it directly. +- Add a deterministic `adr.py graph` export command that writes + `relationships.mmd` and, when requested, a vector `relationships.svg`. +- Generate SVG directly from the same relationship model in Python. The SVG is a + navigation artifact, not a pixel screenshot, so it remains crisp when zoomed. +- Keep PNG out of the default workflow. Raster export can be revisited later if + users explicitly need it. +- Add public repository hygiene docs: contributing guidance, security reporting, + and a pull request template. + +## Validation + +- Unit tests must cover Mermaid block rendering, Mermaid file export, SVG file + export, and graph behavior when no relationships exist. +- Existing ADR validation, index generation, version drift, and full pytest must + pass before completion. diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..0ab1540 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,11 @@ +# Examples + +- [`quickstart.md`](quickstart.md) — INIT a repo, RECORD a decision with a + machine-checkable constraint, then watch CHECK catch a real violation of + it and clear once the code is fixed. Every command and its output in + this walkthrough was actually run, not written from memory — copy any + command and it will do exactly what's shown. + +For a second, larger example, this repository dogfoods itself: see +[`../docs/decisions/`](../docs/decisions/) for the real ADRs recording the +architectural decisions made while building this toolkit. diff --git a/examples/quickstart.md b/examples/quickstart.md new file mode 100644 index 0000000..c08d545 --- /dev/null +++ b/examples/quickstart.md @@ -0,0 +1,277 @@ +# Quickstart: INIT → RECORD → CHECK + +This walks through the three core operations against a small example +service, from an empty repo to CHECK catching a real rule violation. Every +command and JSON block below is real output from actually running these +commands — nothing here is hypothetical. + +All commands assume `skills/adr-toolkit/` is on your path somehow (see the +root [README](../README.md) for per-harness install, or just call the +script directly as shown here: `python skills/adr-toolkit/scripts/adr.py`). + +## Setup + +A tiny Node service with no ADRs yet: + +```text +example-service/ +├── package.json +└── src/db/connection.js +``` + +## 1. INIT — scaffold the ADR directory + +```bash +python skills/adr-toolkit/scripts/adr.py preflight --json +``` + +```json +{ + "ok": true, + "operation": "preflight", + "python_version": "3.9.6", + "git_available": true, + "existing_adr_directory": null, + "warnings": [], + "errors": [] +} +``` + +`existing_adr_directory` is `null`, so it's safe to scaffold: + +```bash +python skills/adr-toolkit/scripts/adr.py init --dir docs/decisions --json +``` + +```json +{ + "ok": true, + "operation": "init", + "dry_run": false, + "created": [ + ".adr-toolkit.json", + "docs/decisions", + "docs/decisions/adr-template.md", + "docs/decisions/0001-record-architecture-decisions.md" + ] +} +``` + +## 2. RECORD — capture a decision with a checkable rule + +Before drafting, check for anything already covering this area: + +```bash +python skills/adr-toolkit/scripts/adr.py related \ + --paths src/db/ --tags database persistence --dir docs/decisions --json +``` + +```json +{"ok": true, "operation": "related", "count": 0, "matches": [], "warnings": []} +``` + +Nothing exists yet. Score the decision's significance against the seven +criteria (`references/significance-rules.md` explains each one) and check +whether it clears the bar for recording at all: + +```bash +python skills/adr-toolkit/scripts/adr.py significance --input scores.json --json +``` + +```json +{"ok": true, "operation": "significance", "total": 12, "classification": "recommended"} +``` + +`recommended` — draft it. This decision also gets a structured +`constraints:` block in its Implementation Constraints section, so CHECK +can enforce it mechanically later, not just document it in prose: + +````markdown +## Implementation Constraints + +```yaml +constraints: + - id: no-mongodb-driver + kind: forbidden_import + paths: ["src/db/**"] + pattern: ["mongodb", "mongoose"] + severity: major + message: "This service uses PostgreSQL for persistence (see this ADR) — do not add a MongoDB driver." +``` +```` + +Write the full draft (title, body, tags, `affected_paths`) to a JSON file +and create it: + +```bash +python skills/adr-toolkit/scripts/adr.py create --input draft.json --dir docs/decisions --json +``` + +```json +{ + "ok": true, + "operation": "create", + "dry_run": false, + "created": "docs/decisions/0002-use-postgresql-for-persistence.md", + "id": "ADR-0002" +} +``` + +Then validate and regenerate the index: + +```bash +python skills/adr-toolkit/scripts/adr.py validate --dir docs/decisions --json +python skills/adr-toolkit/scripts/adr.py index --dir docs/decisions --json +``` + +Both report `"ok": true`, and `docs/decisions/README.md` now reads: + +```markdown +# Decision Log + +## By status + +### Accepted +- [ADR-0001 — Record architecture decisions](0001-record-architecture-decisions.md) +- [ADR-0002 — Use PostgreSQL for persistence](0002-use-postgresql-for-persistence.md) + +## By tag + +### database +- [ADR-0002 — Use PostgreSQL for persistence](0002-use-postgresql-for-persistence.md) +... +``` + +## 3. CHECK — catch a real violation + +Commit the ADR, then a developer (unaware of it) adds a MongoDB driver: + +```js +// src/db/connection.js +const mongodb = require("mongodb"); + +module.exports = { connect: () => mongodb.connect(process.env.DB_URL) }; +``` + +```bash +python skills/adr-toolkit/scripts/adr.py check --uncommitted --dir docs/decisions --json +``` + +```json +{ + "ok": true, + "operation": "check", + "diff": {"mode": "uncommitted", "ref": null, "files_changed": 1}, + "findings": [ + { + "rule_id": "no-mongodb-driver", + "kind": "verified_violation", + "severity": "major", + "message": "This service uses PostgreSQL for persistence (see this ADR) — do not add a MongoDB driver.", + "file": "src/db/connection.js", + "evidence": {"line": "const mongodb = require(\"mongodb\");", "pattern": "mongodb"}, + "adr_id": "ADR-0002", + "resolutions": ["fix_code", "supersede_adr", "adjust_scope", "register_exception", "false_positive"], + "confidence": "VIOLATED" + } + ], + "warnings": [] +} +``` + +CHECK never picks a resolution for you — it always hands back all five and +lets a human decide. Here the developer picks `fix_code`: + +```js +// src/db/connection.js +const { Pool } = require("pg"); + +module.exports = { pool: new Pool({ connectionString: process.env.DB_URL }) }; +``` + +```bash +python skills/adr-toolkit/scripts/adr.py check --uncommitted --dir docs/decisions --json +``` + +```json +{ + "ok": true, + "operation": "check", + "diff": {"mode": "uncommitted", "ref": null, "files_changed": 1}, + "findings": [{"adr_id": "ADR-0002", "kind": "related", "confidence": "VERIFIED"}], + "warnings": [] +} +``` + +The violation is gone — `related` means the diff still touches a path this +ADR governs, but the explicit structural rule that fired before no longer +does. It is `VERIFIED` only for that rule and selected diff, not proof of the +entire architecture. + +## Multilingual variation — Korean repository, portable filename + +In a fresh repository, choose Korean once during INIT: + +```bash +python skills/adr-toolkit/scripts/adr.py init --locale ko --dir docs/decisions --json +``` + +Real output: + +```json +{ + "ok": true, + "operation": "init", + "dry_run": false, + "created": [ + ".adr-toolkit.json", + "docs/decisions", + "docs/decisions/adr-template.md", + "docs/decisions/0001-record-architecture-decisions.md" + ] +} +``` + +The config now supplies `ko` to CREATE and INDEX. Given a reviewed draft whose +title is `결제 시스템 분리`, let the agent propose `separate-payment-system` +and approve that semantic ASCII slug before mutation: + +```bash +python skills/adr-toolkit/scripts/adr.py create --input draft.json \ + --slug separate-payment-system --dir docs/decisions --json +``` + +Real output: + +```json +{ + "ok": true, + "operation": "create", + "dry_run": false, + "created": "docs/decisions/0002-separate-payment-system.md", + "id": "ADR-0002" +} +``` + +The title/body remain Korean and the ADR records `locale: ko`; the core never +translates the title to obtain the filename. Omitting an approved slug for a +title with no ASCII characters safely uses `decision` instead. + +```bash +python skills/adr-toolkit/scripts/adr.py index --dir docs/decisions --json +``` + +The real command reports `"count": 2`, and the generated index begins with +`# 결정 기록` because it reads the repository default. + +## Next steps + +- `--since ` and `--staged` are the other two `check`/`diff` range + modes, for a branch/commit range or a staged-changes review — see + `SKILL.md`'s CHECK section. +- `references/conflict-rules.md` documents all six `constraints:` rule + kinds (`forbidden_import` was the only one used here) and the four-way + finding classification plus VERIFIED / VIOLATED / UNVERIFIABLE / + NOT_APPLICABLE confidence meaning in full. +- No AI harness at all? `create --interactive` runs the same interview in + a plain terminal — see the root [README](../README.md). diff --git a/handoff.md b/handoff.md index 3361371..4bdfde1 100644 --- a/handoff.md +++ b/handoff.md @@ -1,28 +1,110 @@ # handoff.md -## Current state (as of 2026-08-30) - -The full 4-plan ADR Toolkit MVP is complete and pushed to `origin` on -branch `feat/adr-toolkit-mvp-implement` (renamed from -`SHcommit/feat-plan-adr-toolkit`; the old name still exists as a stale -remote branch that was never deleted). Not yet merged to `master`, no PR -open — see `improvements.md`'s `## Open` list for what's pending. - -For what shipped, see `changelog.md`. For deferred/follow-up work, see -`improvements.md` and `project-roadmap.md` — this file no longer repeats -that history; keeping it in three places was how it drifted. - -## Standing risks - -- Codex `quick_validate.py` rejects the existing cross-harness - `user-invocable` and `version` skill frontmatter keys. The repository - tests require those keys today; resolve validator/metadata - compatibility deliberately rather than removing them silently. -- `scripts/adr.py` accepts `--json` on every subcommand but always emits - JSON; deliberately left untouched by Plan 4 (design spec §17.1). -- The Codex adapter's documented install path only exercises this repo's - root-level `.claude-plugin/marketplace.json` — the `.codex-plugin/` - manifest and its sibling `skills/` symlink (spec §17.2) are present and - correctly laid out but not exercised by Codex's own `plugin marketplace - add` in the verified flow. Revisit once Codex's Agent Plugins 1.0.0 - support matures. +## Current task (2026-08-31) + +Public-readiness, ADR navigation graph, and harness-parity work is on branch +`feature/project-roadmap-implements` in: + +`/Users/yangseunghyeon/orca/workspaces/ADR-toolkit/develop` + +Latest commits (newest first): + +- `2f2f604 ci: add harness-parity job for Codex and Gemini CLI end-to-end install` +- `e28eb5d docs: record Antigravity CLI adapter verification against agy 1.1.13` +- `b1bcb93 docs: add code of conduct and GitHub issue templates` +- `b8db211 docs: correct stale ADR count in project-roadmap.md` +- `35c81aa docs: record ADR-0011 for relationship graph and public readiness work` +- `8f0343e feat: add ADR relationship graph exports` + +Working tree is clean; every item below is already committed. + +Implemented this session: + +- `adr.py graph --format mermaid|svg|both` exports deterministic Mermaid and + Python-rendered SVG artifacts; `adr.py index` embeds the Mermaid graph in + the generated decision log. ADR-0011 records this decision. +- Public repository hygiene: `CONTRIBUTING.md`, `SECURITY.md`, + `.github/PULL_REQUEST_TEMPLATE.md`, `CODE_OF_CONDUCT.md`, and + `.github/ISSUE_TEMPLATE/{bug_report,feature_request}.md`. +- Harness parity, manually re-verified end-to-end against the real CLIs + installed on this machine (not just manifest-schema checks): + - Codex CLI 0.151.0 -- `codex plugin marketplace add/add/list`, then + `preflight`/`init`/`validate` from the installed snapshot, all + `"ok": true`. + - Gemini CLI 0.46.0 -- `gemini extensions validate/install/list`, then the + same three commands from the installed snapshot, all `"ok": true`. + - Antigravity CLI (`agy` 1.1.13) -- previously undocumented as available; + `agy plugin validate/install/list` plus the same three commands all + `"ok": true`. `adapters/antigravity/README.md` updated from "unverified" + to a recorded transcript, matching the Codex/Gemini READMEs' format. + - New `harness-parity` CI job (`.github/workflows/test.yml`) automates the + Codex and Gemini checks above on every push/PR, pinned to the exact + verified versions. Every step was dry-run locally against those same + CLI versions before landing. Antigravity stays manual-only: `agy` has no + npm/package-registry distribution, so a CI runner can't install it. + - `project-roadmap.md`'s "Harness parity" section narrowed to what's + actually still open: extending `harness-parity` past + preflight/init/validate to check/search/graph/create, and automating + Antigravity once it has an installable distribution. +- `project-roadmap.md` audited end-to-end against the codebase; confirmed no + other section describes already-shipped work. + +## Next step + +Open or update a PR for `feature/project-roadmap-implements` into `develop` +so the new `harness-parity` CI job actually runs on GitHub's runners at +least once before being trusted (everything above was dry-run locally with +the same pinned CLI versions, but never through the real Actions runner). + +The broader `v0.2.0` release gate still needs separate explicit owner approval +for each step: + +1. Merge the PR into `develop`. +2. Cut a `release/*` branch into `master` and back into `develop`, per + `AGENTS.md`'s Git Flow policy. +3. Push a `v0.2.0` tag on `master`; `.github/workflows/release.yml` then runs + the full suite, verifies manifest versions against the tag, and publishes + the GitHub Release. + +Do not perform merge, release branch, push, or tag operations without explicit +owner approval. The repository owner has said the switch to public is coming +"soon" but deliberately not yet -- don't start the "Public and enterprise +governance" roadmap items (they're gated on the repo actually being public). + +## Latest local verification + +After `2f2f604` (current HEAD): + +- `python3 -m pytest -q` -> `395 passed` +- `python3 scripts/sync_version.py --check` -> exit 0 +- `adr.py validate --dir docs/decisions --json` -> 11 ADRs, no errors +- `adr.py index --dir docs/decisions --json` -> 11 ADRs, no warnings +- `adr.py graph --dir docs/decisions --format both --json` -> 6 rendered graph + edges, no warnings +- `harness-parity` job's Codex and Gemini steps dry-run locally against + Codex CLI 0.151.0 and Gemini CLI 0.46.0 -- both passed, matching what the + workflow file runs. + +Run the full verification suite again before any further commit, and after +the PR's first real CI run, confirm `harness-parity` actually went green on +GitHub's runners (not just locally) before relying on it as a gate. + +## Open risks + +- `harness-parity`'s Codex/Gemini steps have only ever run locally on this + macOS dev machine, never on GitHub's `ubuntu-latest` runners. Watch the + first real CI run for environment differences (npm registry access, + network egress rules, `jq` availability -- present by default on + `ubuntu-latest` but unconfirmed here). +- `adr.py check --uncommitted` reports a `VIOLATED` finding for superseded + ADR-0003 when changes touch `index.py`; this branch intentionally follows + ADR-0006, which supersedes ADR-0003. Note that in PR review rather than + treating it as a code defect. +- CHECK deliberately cannot prove prose, business rationale, or organizational + claims; those remain human-review evidence. +- Search/relationship matching is deterministic substring/exact/prefix + matching, untested at real scale. Revisit roadmap scale items if an adopting + repository reaches hundreds of ADRs. +- GitHub branch/tag protection is intentionally unavailable on the current + private plan; configure and API-verify after the repository goes public + (owner has deferred this deliberately, not blocked on it). diff --git a/improvements.md b/improvements.md index 5f6223d..00eada5 100644 --- a/improvements.md +++ b/improvements.md @@ -1,47 +1,20 @@ # improvements.md -Backlog for follow-up improvements that are useful but not required to resume -the current session. +Concrete implementation backlog. Unscheduled product bets belong in +`project-roadmap.md`; current-session state belongs in `handoff.md`. ## Open -- [ ] **Decide branch integration.** `feat/adr-toolkit-mvp-implement` is - pushed to `origin` but not merged to `master`, and no PR is open yet. - Options: merge locally, open a PR, or leave as-is longer. -- [ ] **Clean up the stale remote branch** `origin/SHcommit/feat-plan-adr-toolkit` - — left over from before the local rename to - `feat/adr-toolkit-mvp-implement`; delete it on `origin` once confirmed - it's not needed (e.g. no open PR references it). -- [ ] **Write a real root `README.md`.** Still a one-line stub at - MVP-complete. Should point a new user at the four - `adapters/*/README.md` install guides and at `create --interactive`'s - no-agent-needed path. Do this before any public release, alongside the - license decision below. -- [ ] `scripts/adr.py`'s `--json` flag is parsed on every subcommand but never - read (output is always JSON regardless). Either implement a non-JSON - mode or drop the flag. -- [ ] Codex's `quick_validate.py` rejects the existing ADR Toolkit `SKILL.md` - frontmatter keys `user-invocable` and `version`, while the repository's - cross-harness contract and tests currently require them. Make a deliberate - metadata/validator compatibility decision before changing either side; - do not silently delete the keys to satisfy one harness. -- [ ] **CHECK follow-ups** — see `project-roadmap.md`'s "CHECK follow-ups" - section: a `git ls-files`-based existing-paths check (perf + - `.gitignore` correctness), collapsing the `SKIP_FILES`/ADR-loading loop - duplicated across 4 command modules, documenting that `pattern` syntax - differs by rule kind, `diff.py` rename handling, and two narrow - `diff.py` edge cases. None blocking. -- [ ] **i18n/adapters/release follow-ups** — see `project-roadmap.md`'s - "i18n, adapters, release follow-ups" section: the Codex adapter README - slightly overclaims which manifest its verification exercised (see the - standing risk in `handoff.md`), its install step 1 (the `skills/` - symlink) is currently orphaned relative to the documented working - install path, description text has drifted across 4 manifests with no - sync mechanism, and a few narrower `sync_version.py`/CI polish items. - None blocking. +### P0 — v0.2.0 release blocking + +- [ ] **Complete the remote v0.2.0 release gate.** Open the final PR, require all + CI jobs to pass, obtain approval for the v0.2.0 version bump, merge through a + release branch, and verify the tag points at the intended `master` commit. + **Done when:** the readiness report's conditional GO becomes GO. Creating the + release branch, pushing, version bumping, and tagging require owner approval. ## Done -Historical detail now lives in `changelog.md` (what shipped) and git log -(how) — this section stays empty going forward rather than duplicating -that record across three files. +Resolved items are recorded in `changelog.md` (what shipped) and git history +(exactly how) rather than kept here — this section stays empty between +sessions. diff --git a/project-roadmap.md b/project-roadmap.md index f86fa6a..d64bf98 100644 --- a/project-roadmap.md +++ b/project-roadmap.md @@ -1,155 +1,89 @@ # project-roadmap.md -Features and improvements that are valuable but deliberately excluded from -the MVP defined in `docs/superpowers/specs/2026-08-29-adr-toolkit-design.md`. -Nothing here is scheduled — it moves into a real design (brainstorming → -spec → plan) only once the MVP has proven the core loop with real usage. +Unscheduled product capabilities that need usage evidence or a separate design +before implementation. Concrete selected work belongs in `improvements.md`. ## Conflict detection depth -- Full semantic conflict taxonomy: Direct violation (e.g. SDK called - directly where a Provider Port was decided) and Pattern divergence - (diverges from a documented common pattern without touching a named - path). Needs AST/import-graph analysis beyond MVP's structural, - path/dependency-based rules. -- Confidence scoring calibrated against a larger golden fixture set once - real conflicts (and false positives) from actual usage are available. +- Full semantic conflict taxonomy: Direct violation (for example, an SDK called + directly where a Provider Port was decided) and Pattern divergence. This + requires AST/import-graph analysis beyond deterministic path/dependency rules. +- Confidence scoring calibrated against a larger golden fixture set after real + conflicts and false positives are available. +- Architecture fitness-function integrations that can verify richer policies + without presenting heuristic guesses as proof. ## Harness parity -- Full cross-harness fixture/golden test matrix for Codex, Gemini CLI, and - Antigravity CLI, matching the depth Claude Code gets in MVP. -- Harness-specific hook support beyond Claude Code's SessionStart, if the - other harnesses expose an equivalent trigger. +- ~~Automate the Codex CLI and Gemini CLI adapters' install-and-run + verification.~~ **Done (2026-08-31).** `.github/workflows/test.yml`'s + `harness-parity` job installs the real Codex CLI and Gemini CLI and runs + `preflight`/`init`/`validate` from each one's installed snapshot on every + push and pull request. +- Extend `harness-parity` coverage beyond `preflight`/`init`/`validate` to + `check`, `search`, `graph`, and `create` once a real regression in one of + those commands under a specific harness demonstrates the gap matters. +- Automate the Antigravity CLI (`agy`) adapter the same way once it has a + package-registry distribution a CI runner can install non-interactively; + today it has none, so `adapters/antigravity/README.md`'s manual + verification is the only signal. +- ~~Harness-specific hook support beyond Claude Code SessionStart when + equivalent stable extension points exist.~~ **Evaluated, not pursued + (2026-08-31).** The precondition is now true: Codex CLI has a config-driven + `SessionStart`/`UserPromptSubmit` hook system (`~/.codex/hooks.json`), and + Gemini CLI ships `gemini hooks migrate` specifically to port Claude Code + hooks over. But ADR Toolkit doesn't use a hook even on Claude Code today + (it relies entirely on skill auto-discovery), and a hook that fires on + every session regardless of relevance cuts against this project's own + restraint principle (max 3 questions, judge what's significant, minimize + interruption). The plausible use cases (nudge about an unfinished draft + ADR, warn about a governed path) are already covered by deliberately + invoking `discover` and `check` rather than an always-on hook. Revisit + only if real usage shows people miss something that `discover`/`check` + can't catch without a session-start nudge -- not just because the + extension points now exist. + +## ADR navigation and scale + +- Test whether 500+ decisions require sharding, alternate indexes, or a + real search index (this repo has 11 ADRs; substring/tag/path matching is + untested at that scale). +- Improve related-decision discovery beyond path/tag/keyword/body-substring + only after real misses demonstrate the need for semantic retrieval. -## ADR navigation and structure +## Internationalization -- ADR relationship graph (related/supersedes) rendered as a visual graph, - not just index list views. -- Investigate whether large decision sets (500+) need anything beyond the - flat-directory + multi-view-index model chosen for MVP. +- Translate complete project documentation such as README and CONTRIBUTING + after non-English contributors can review terminology and maintenance cost. +- Add Traditional Chinese as `zh-TW` only through a separate catalog and review; + keep `zh` defined as Simplified Chinese. +- Evaluate bilingual ADR presentation only if teams need one decision rendered + in more than one human language; do not create parallel sources of truth by + default. -## Internationalization +## Public and enterprise governance -- Localized MADR template section headers (currently English regardless of - locale). -- Localized project documentation — README, CONTRIBUTING — in the same 5 - languages as the skill's runtime text (en/fr/ja/ko/zh). Wait for real - non-English/Korean contributors before investing here. +- After the repository becomes public, apply and API-verify branch/tag + protections, required checks, conversation resolution, force-push/deletion + controls, and a documented bypass policy. +- Add CODEOWNERS and mandatory independent review when the contributor model can + actually satisfy it. +- Organization-level rulesets, reusable workflows, RBAC, audit export, + controlled taxonomy, exception governance, and adoption metrics. ## Ecosystem integration -- Pull request review integration / GitHub App / automated PR comments on - ADR conflicts. -- ArchUnit-style static enforcement tied to `Implementation Constraints`. +- Pull-request review integration, initially as a GitHub Action and only later + as a GitHub App if permissioned service behavior is justified. - C4 / arc42 Section 9 export. -- Multi-repo decision graph. -- Vector DB-backed semantic search over ADRs. -- Central decision portal / web viewer. -- Slack / Jira / Notion integrations. - -## Open items to revisit once MVP ships - -- Whether `retrospective` should become a first-class status instead of - metadata-only, once enough retrospective ADRs exist to see how they're - actually used. -- Whether related-ADR search needs more than keyword/tag/affected-path - matching (e.g. embedding-based similarity) — only worth it once keyword - search demonstrably misses real cases. - -## CHECK follow-ups (deferred from Plan 3's final review) - -Minor/deferred findings from Plan 3's closeout review, not fixed in the -fix wave since they're either narrow edge cases or refactors better done -deliberately rather than folded into a fix commit: - -- `check.py`'s `_existing_paths` walks the whole working tree via - `rglob("*")`, skipping only `.git`. `diff.py` already shells out to - `git ls-files`; switching to - `git ls-files --cached --others --exclude-standard` would be faster on - large repos and correctly honor `.gitignore` (currently a gitignored - build artifact could satisfy a `required_path`/`file_must_exist` rule). -- `SKIP_FILES = {"README.md", "adr-template.md"}` and the - glob/parse-filename/frontmatter-parse-with-per-file-warning loop are now - duplicated across `related.py`, `index.py`, `validate.py`, and - `check.py`. A shared `core/adr_dir.py::iter_adrs(adr_dir) -> (entries, - warnings)` would collapse all four call sites. -- `references/conflict-rules.md` doesn't warn that `pattern`'s syntax - differs by rule kind (regex for `forbidden_import`/`dependency_forbidden`, - glob for `required_path`/`forbidden_path`) — an author copying the - `paths` glob style into a `forbidden_import` rule's `pattern` field gets - a regex where `**` silently never matches as expected. -- `diff.py`'s `--name-status` parsing takes only the destination path for - a renamed file (git status char `R`), dropping the old path entirely — - a `constraints:` rule scoped to the old location won't fire on a rename, - and a Verification/Confirmation-referenced file that was renamed away - isn't recognized as removed. -- `diff.py`'s second subprocess call (the `--unified=0` patch content) - doesn't independently check its own returncode — if it fails while the - first `--name-status` call succeeded, files get correct `change_type` - but empty `added_lines`/`removed_lines`, silently passing every - content-pattern rule instead of surfacing an error. -- `diff.py`'s `INVALID_REF` vs `GIT_DIFF_FAILED` error-code selection - checks the raw `since` argument's truthiness rather than - `mode == "since"` — near-unreachable in practice (would need - `--staged --since ` together, which the CLI doesn't prevent), - but worth tightening to `mode == "since"` if `diff.py` is touched again. - -## i18n, adapters, release follow-ups (deferred from Plan 4's final review) +- Multi-repository decision discovery and graphing. +- Central decision portal or web viewer. +- Slack, Jira, and Notion integrations. +- Vector-backed semantic search after deterministic search has measured misses. -Minor/deferred findings from Plan 4's closeout review, not fixed in the -fix wave since they're narrow polish or genuinely need Codex's Agent -Plugins support to mature before a good fix exists: +## Lifecycle research -- `adapters/codex/README.md`'s opening line slightly overclaims which - manifest the verification exercised — it reads as endorsing - `.codex-plugin/plugin.json`'s sibling-`skills/` auto-discovery, but the - verified working path is Codex reading the repo-root - `.claude-plugin/marketplace.json` instead; `.codex-plugin/`'s own - auto-discovery was never actually exercised. Reword to scope the claim - to what was tested. -- Relatedly, the Codex README's install step 1 (create the - `adapters/codex/skills/adr-toolkit` symlink, per spec §17.2) is - currently orphaned — the documented working install (steps 2-4) never - touches it, since Codex reads the repo-root marketplace file instead. - Kept because the spec mandates the symlink exist, but the README should - explain why step 1 still matters once Codex's own Agent Plugins support - (shipped 2026-08-07, very new) starts reading `.codex-plugin/` the way - the standard describes. -- `codex plugin add` snapshots the entire repo root — including `.git/`, - `.superpowers/`, `.pytest_cache/` — into its plugin cache. Not a defect - in this repo's adapter, but worth knowing before ever publishing this as - a real, standalone marketplace listing (a dedicated distribution repo - might be warranted instead of pointing Codex at the full dev repo). -- `scripts/sync_version.py`'s `sync()` still silently skips a manifest - that exists but has lost its declared `version` key (only a missing - *file* is now a hard error via `require_known_paths()`) — covered by a - test asserting the real manifests currently have the key, but the - script itself doesn't enforce it going forward. -- `scripts/sync_version.py:104`'s `path.relative_to(REPO_ROOT)` in an - error-reporting path would raise `ValueError` instead of the intended - `SystemExit` if a future `MANIFEST_SPECS` entry were ever added outside - `REPO_ROOT` — only reachable via a deliberately malformed entry today, - not user-triggerable. -- `SKILL.md`'s line documenting the lifecycle `index --locale` invocation - is noticeably longer than the file's otherwise-consistent ~72-column - wrap. Cosmetic. -- Description text for the toolkit ("Initialize, record, and check - Architecture Decision Records...") is now duplicated across four - manifests (`.claude-plugin/plugin.json`, `SKILL.md`'s frontmatter, the - three adapter manifests, `.claude-plugin/marketplace.json`) with slight - wording drift between them and no sync mechanism — `sync_version.py` - only syncs the version field, not descriptions. -- `sync_version.py`'s `json.dumps(..., indent=2)` defaults to - `ensure_ascii=True`; harmless today since all synced values are ASCII, - but a future non-ASCII description would get mangled into `\uXXXX` - escapes on the next sync. Pass `ensure_ascii=False` if that becomes a - real concern. -- `.github/workflows/test.yml`'s version-drift check step runs redundantly - on all 5 matrix legs (3 OS × 2 Python versions minus one exclusion) — - it's a repo-invariant check, not a platform-specific one, so one leg - would suffice. Harmless, just slightly wasteful CI time. -- Root `README.md` is still a one-line stub at MVP-complete. Nothing - points a new user at the four `adapters/*/README.md` install guides or - at `create --interactive`'s no-agent-needed path. Worth writing a real - README before any public release, alongside the license decision. +- Revisit whether `retrospective` should become a first-class status rather than + metadata after enough retrospective ADRs exist to show a lifecycle need. +- Define a narrow factual-correction policy for Accepted ADR metadata without + weakening append-only decision history. diff --git a/scripts/sync_version.py b/scripts/sync_version.py index 7edb072..0affe5d 100755 --- a/scripts/sync_version.py +++ b/scripts/sync_version.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 -"""Sync skills/adr-toolkit/VERSION into every manifest that duplicates it. +"""Sync skills/adr-toolkit/VERSION and SKILL.md's description into every +manifest that duplicates them. Repo tooling — not part of the distributable skills/adr-toolkit/ package. """ @@ -21,8 +22,19 @@ (REPO_ROOT / "adapters" / "gemini-cli" / "gemini-extension.json", ["version"]), ] +# SKILL.md's frontmatter `description:` is the single canonical source; every +# manifest below duplicates it for its own harness's format and is synced +# from it the same way MANIFEST_SPECS entries are synced from VERSION. +DESCRIPTION_MANIFEST_SPECS = [ + (REPO_ROOT / ".claude-plugin" / "plugin.json", ["description"]), + (REPO_ROOT / "adapters" / "codex" / ".codex-plugin" / "plugin.json", ["description"]), + (REPO_ROOT / "adapters" / "gemini-cli" / "gemini-extension.json", ["description"]), + (REPO_ROOT / "adapters" / "antigravity" / "plugin.json", ["description"]), +] + VERSION_LINE_RE = re.compile(r"^version:\s*\S+$", re.MULTILINE) VERSION_FORMAT_RE = re.compile(r"\d+\.\d+\.\d+(-[\w.]+)?") +DESCRIPTION_LINE_RE = re.compile(r"^description:[ \t]*(.+)$", re.MULTILINE) def read_version(version_file: Path) -> str: @@ -49,8 +61,24 @@ def replace_version_line(text: str, version: str) -> str: return VERSION_LINE_RE.sub(lambda _: f"version: {version}", text, count=1) +def read_description(skill_md_path: Path) -> str: + """Read the canonical `description:` value from SKILL.md's frontmatter.""" + text = skill_md_path.read_text(encoding="utf-8") + match = DESCRIPTION_LINE_RE.search(text) + if match is None: + raise SystemExit(f"no description: line found in {skill_md_path}") + return match.group(1).strip() + + def sync(version_file: Path, manifest_specs: list, check_only: bool) -> list: - version = read_version(version_file) + return _sync_value(read_version(version_file), manifest_specs, check_only) + + +def sync_descriptions(skill_md_path: Path, manifest_specs: list, check_only: bool) -> list: + return _sync_value(read_description(skill_md_path), manifest_specs, check_only) + + +def _sync_value(value: str, manifest_specs: list, check_only: bool) -> list: changed = [] for path, key_path in manifest_specs: if not path.is_file(): @@ -64,12 +92,14 @@ def sync(version_file: Path, manifest_specs: list, check_only: bool) -> list: target = target[key] if target is None or key_path[-1] not in target: continue - if target[key_path[-1]] == version: + if target[key_path[-1]] == value: continue changed.append(path) if not check_only: - target[key_path[-1]] = version - path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + target[key_path[-1]] = value + path.write_text( + json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" + ) return changed @@ -95,15 +125,36 @@ def require_known_paths() -> None: MANIFEST_SPECS/SKILL_MD_PATH — a renamed or deleted manifest there must not silently drop out of the drift check and leave CI green forever. """ - missing = [p for p, _ in MANIFEST_SPECS if not p.is_file()] + all_specs = MANIFEST_SPECS + DESCRIPTION_MANIFEST_SPECS + tracked_paths = {p for p, _ in all_specs} + missing = [p for p in tracked_paths if not p.is_file()] if not VERSION_FILE.is_file(): missing.append(VERSION_FILE) if not SKILL_MD_PATH.is_file(): missing.append(SKILL_MD_PATH) if missing: - names = ", ".join(str(p.relative_to(REPO_ROOT)) for p in missing) + names = ", ".join(_display_path(p) for p in sorted(missing, key=str)) raise SystemExit(f"missing tracked file(s): {names}") + keyless = [] + for path, key_path in all_specs: + target = json.loads(path.read_text(encoding="utf-8")) + for key in key_path: + if not isinstance(target, dict) or key not in target: + keyless.append((path, key_path[-1])) + break + target = target[key] + if keyless: + names = ", ".join(f"{_display_path(p)} ({key})" for p, key in keyless) + raise SystemExit(f"tracked manifest(s) lost a tracked key: {names}") + + +def _display_path(path: Path) -> str: + try: + return str(path.relative_to(REPO_ROOT)) + except ValueError: + return str(path) + def main(argv=None) -> int: parser = argparse.ArgumentParser() @@ -112,6 +163,7 @@ def main(argv=None) -> int: require_known_paths() changed = sync(VERSION_FILE, MANIFEST_SPECS, check_only=args.check) + changed += sync_descriptions(SKILL_MD_PATH, DESCRIPTION_MANIFEST_SPECS, check_only=args.check) if sync_skill_md(VERSION_FILE, SKILL_MD_PATH, check_only=args.check): changed.append(SKILL_MD_PATH) diff --git a/skills/adr-toolkit/SKILL.md b/skills/adr-toolkit/SKILL.md index 49473f1..c2c3ac0 100644 --- a/skills/adr-toolkit/SKILL.md +++ b/skills/adr-toolkit/SKILL.md @@ -2,7 +2,7 @@ name: adr-toolkit description: Initialize, record, and check Architecture Decision Records by inspecting the repository and existing decisions before asking questions. user-invocable: true -version: 0.1.0 +version: 0.2.0 --- # ADR Toolkit @@ -27,23 +27,24 @@ PREFLIGHT ## Language -Detect the language of the user's own request (English, French, Japanese, -Korean, or Chinese) and compose every question and report you write in -that language — English is the default when the language can't be -determined. This applies only to what you write yourself; it does not -change any file content the user themselves wrote in a different -language, and it does not translate `SKILL.md`'s own instructions. +Use one of the canonical locales `en`, `ko`, `ja`, `zh`, `fr`, `es`, `de`, +or `pt-BR`; `zh` means Simplified Chinese. Compose agent-authored questions +and reports using this precedence: explicit user request → request language → +repository default in `.adr-toolkit.json` → `en`. Do not translate prose the +user already supplied. -When you run `index`, pass the same locale as a two-letter code so the -generated `docs/decisions/README.md` headers match -(`--locale en|fr|ja|ko|zh`, defaulting to `en` when omitted): +The deterministic CLI resolves locale as explicit `--locale` → approved input +draft `locale` → repository default → `en`. Normally omit repeated flags when +the repository default matches the request; pass `--locale` only for an +explicit per-operation override. For example: ```bash python skills/adr-toolkit/scripts/adr.py index --dir docs/decisions --locale fr --json ``` -Every `index` command shown elsewhere in this file writes `--locale `; -substitute the detected language's code, or `en` when it can't be determined. +INIT and CREATE localize deterministic prompts and MADR structure. INDEX +localizes its generated headings. IDs, status values, JSON keys, error codes, +and filenames remain English/ASCII machine contracts. ## INIT (scaffolding only) @@ -56,11 +57,10 @@ time later) to recover past decisions. already exists; do not scaffold a second one. 2. **CONFIRM** — show the user the exact directory that will be created, before writing anything. -3. **MUTATE** — run `python skills/adr-toolkit/scripts/adr.py init --dir docs/decisions` to - scaffold the directory, template, and ADR-0001. +3. **MUTATE** — run `python skills/adr-toolkit/scripts/adr.py init --locale --dir docs/decisions --json` + to create `.adr-toolkit.json`, the directory, template, and ADR-0001. 4. **VALIDATE** — run `python skills/adr-toolkit/scripts/adr.py validate --dir docs/decisions --json` - and `python skills/adr-toolkit/scripts/adr.py index --dir docs/decisions --locale --json` - (see Language for ``). + and `python skills/adr-toolkit/scripts/adr.py index --dir docs/decisions --json`. 5. **REPORT** — tell the user INIT is done and that they can run DISCOVER next if they want to recover past decisions from the repository's history. @@ -115,7 +115,7 @@ entirely, or re-run later to mine more of the history incrementally. (`title`, `status`, `body` — body includes the three-part structure above — plus any of `date`/`decision_makers`/`related`/ `affected_paths`/`tags`/`retrospective`) and run - `python skills/adr-toolkit/scripts/adr.py create --input --dir docs/decisions`. + `python skills/adr-toolkit/scripts/adr.py create --input --dir docs/decisions --json`. 8. **VALIDATE** — same as INIT step 4. If validate reports errors, fix the draft and re-run `create` — never hand-edit the generated file to patch a validation error. @@ -156,6 +156,14 @@ workflow does not. Omit `--paths`, `--tags`, or `--keyword` when that evidence is unknown; never invent a path, tag, or keyword. Use the results to find ADRs that already cover, conflict with, or may be replaced by the candidate. + `related`'s filters are OR'd together (any single overlap is enough) — it + deliberately casts a broad net before drafting. For a general lookup + unrelated to drafting (a user asks "has this been decided before?" or + "what ADRs govern `src/payment/`?"), use `search` instead — its filters + combine with AND for precise results, it supports `--status` and + `--path` (real-file-vs-governed-scope, not an exact list match), it + ranks results best-match-first, and an empty query browses every ADR. + See README's "Search" section for the full contract. 3. **CLASSIFY** — apply the table above, then read `references/significance-rules.md`. Score only evidence-supported 0/1/2 values; never guess or inflate a score to force a band. Write all seven @@ -181,19 +189,20 @@ workflow does not. can enforce it — see `references/conflict-rules.md` for the six rule kinds and the exact block syntax. 6. **CONFIRM** — before any `create`, show the title, problem, considered - options, decision, primary driver, accepted downside, and affected paths. + options, decision, primary driver, accepted downside, affected paths, and + the proposed semantic ASCII slug. For a non-ASCII title, suggest a useful + slug but never translate or transliterate inside the deterministic core. Get explicit approval of the draft. If it may replace an Accepted ADR, separately confirm that supersession is intended. 7. **MUTATE** — write the approved draft JSON and run - `python skills/adr-toolkit/scripts/adr.py create --input --dir docs/decisions --json`. + `python skills/adr-toolkit/scripts/adr.py create --input --slug --dir docs/decisions --json`. Only after explicit supersession intent and approval, follow the supersede preview and mutation sequence in Lifecycle operations; never hand-edit lifecycle fields or links. 8. **VALIDATE** — run `python skills/adr-toolkit/scripts/adr.py validate --dir docs/decisions --json` and - `python skills/adr-toolkit/scripts/adr.py index --dir docs/decisions --locale --json` - (see Language for ``). + `python skills/adr-toolkit/scripts/adr.py index --dir docs/decisions --json`. 9. **REPORT** — report facts found, judgment and significance result, questions asked, files created or updated, validation result, and remaining uncertainty. @@ -215,7 +224,8 @@ pair, and ask for confirmation. Only then run the same command without | Supersede an ADR with another | `python skills/adr-toolkit/scripts/adr.py supersede --by --dir docs/decisions --dry-run --json` | `python skills/adr-toolkit/scripts/adr.py supersede --by --dir docs/decisions --json` | After a successful mutation, run the same validate and index commands as -RECORD, including `index`'s `--locale `. If a preview or mutation returns `INVALID_TRANSITION`, stop and +RECORD, using the repository locale unless the user requested an explicit +override. If a preview or mutation returns `INVALID_TRANSITION`, stop and explain the rejected transition using `references/lifecycle.md`; do not retry with a different status or bypass the script. @@ -235,9 +245,13 @@ since CHECK is read-only and never fixes anything itself. mode that matches what the user asked to check). Read `references/conflict-rules.md` if you need to explain a finding or help the user write a `constraints:` block. -3. **CLASSIFY** — each finding already carries its classification - (`related`, `review_required`, `verified_violation`, - `no_applicable_constraint`); do not re-judge it, report it as returned. +3. **CLASSIFY** — each finding already carries both its `kind` (`related`, + `review_required`, `verified_violation`, `no_applicable_constraint`) and a + `confidence` field (`VERIFIED`, `VIOLATED`, or `UNVERIFIABLE`) computed by + the deterministic core — do not re-judge or recompute it, report it as + returned. See `references/conflict-rules.md` for what each value means. No + related finding at all is `NOT_APPLICABLE`, not proof that the repository + is compliant. 4. **REPORT** — group findings by classification. For every Verified violation finding, present all five resolutions from `references/conflict-rules.md` (`fix_code`, `supersede_adr`, @@ -247,10 +261,17 @@ since CHECK is read-only and never fixes anything itself. user picks `fix_code`, that's a normal code edit, not an ADR Toolkit operation. If they pick `supersede_adr` or `adjust_scope`, follow the RECORD or Lifecycle operations flow above — never hand-edit - `constraints:`, `affected_paths`, or `status`. -6. Any `check` `warnings` entries (e.g. `BAD_FRONTMATTER`, - `BAD_CONSTRAINTS`) mean one ADR was skipped, not that CHECK failed — - report them, but don't block on them. + `constraints:`, `affected_paths`, or `status`. If they pick + `register_exception`, gather `adr_id`, `rule_id`, `owner`, `reason`, a + narrow `scope` (never the whole rule), and an `expiry`, then run + `python skills/adr-toolkit/scripts/adr.py exception --input --dir docs/decisions --json` + — never hand-write a file under `docs/decisions/exceptions/`. A finding + already carrying an `exception` field has a matching, non-expired record; + report it, don't re-register it. +6. Any `check` `warnings` entries (e.g. `BAD_FRONTMATTER`, `SCHEMA_ERROR`, + `BAD_CONSTRAINTS`, `BAD_EXCEPTION`) mean one ADR, constraint, or exception + was skipped, not that CHECK failed — report them and never describe a + warning-bearing result as clean. ## Prohibited @@ -274,6 +295,20 @@ file writes, or schema validation in prose — it only decides what to ask and what to draft. CHECK's conflict detection is deliberately limited to structural evidence -from `constraints:` blocks (see `references/conflict-rules.md`) — it never -attempts semantic or AST-level analysis; see `project-roadmap.md` for what -that fuller scope would look like. +from `constraints:` blocks (see `references/conflict-rules.md`). It does not certify the entire architecture +and never attempts semantic or AST-level +analysis; see `project-roadmap.md` for what that fuller scope would look like. + +`graph` is a read-only navigation export: + +```bash +python skills/adr-toolkit/scripts/adr.py graph --dir docs/decisions --format both --json +``` + +It writes Mermaid (`relationships.mmd`) and SVG (`relationships.svg`) graph +artifacts from the same relationship model used by `index`. SVG is generated +directly by Python so the image stays sharp without requiring Mermaid CLI, +Node, or browser rendering. +When `--format both` and `--output` are used together, `--output` is a file +prefix; for example `--output build/adr-relationships` writes +`build/adr-relationships.mmd` and `build/adr-relationships.svg`. diff --git a/skills/adr-toolkit/VERSION b/skills/adr-toolkit/VERSION index 6e8bf73..0ea3a94 100644 --- a/skills/adr-toolkit/VERSION +++ b/skills/adr-toolkit/VERSION @@ -1 +1 @@ -0.1.0 +0.2.0 diff --git a/skills/adr-toolkit/references/conflict-rules.md b/skills/adr-toolkit/references/conflict-rules.md index 2ce8af2..5d8735b 100644 --- a/skills/adr-toolkit/references/conflict-rules.md +++ b/skills/adr-toolkit/references/conflict-rules.md @@ -23,6 +23,21 @@ Each rule needs `id`, `kind`, `paths` (glob list, `**` matches any depth), existence-check kinds. `paths`/`pattern` values must be a JSON-style array on one line — not YAML block-list syntax. +### Pattern syntax is kind-specific + +Every value in `paths` uses the toolkit's **glob** syntax. For +`forbidden_import` and `dependency_forbidden`, each `pattern` value is a +Python **regular expression** matched against added lines. For `required_path` +and `forbidden_path`, each `pattern` value is another glob matched against +repository paths. `file_must_exist` and `test_must_exist` use their `paths` +globs directly and do not require `pattern`. + +For example, the glob `src/**/*.py` matches Python files at any depth below +`src/`. Writing `src/**\\.py` as though it were a regular expression is +incorrect: glob `**` traversal and regex escaping are different languages. +Conversely, a `forbidden_import` content pattern such as `openai\\.` is a +regular expression, not a path glob. + ## The six rule kinds - `forbidden_import` — fires if an added line in a file matching `paths` @@ -61,6 +76,24 @@ enforce — that's a legitimate state, not an error. unknown `kind`, or an invalid regex in `pattern`) also lands here, with a `BAD_CONSTRAINTS` warning — it is never silently reported as Related. +## Evidence confidence + +Every finding carries this mapping directly as a `confidence` field — reading +it off `kind` yourself is only needed to explain *why* a confidence value +applies, not to compute it: + +| Existing `kind` | `confidence` field | Meaning | +|---|---|---| +| `related` | **VERIFIED** | Only for the applicable explicit structural rules in this diff. | +| `verified_violation` | **VIOLATED** | By direct structural evidence. | +| `review_required` | **UNVERIFIABLE** | Without human review. | +| `no_applicable_constraint` | **UNVERIFIABLE** | For the related ADR, because no usable rule proved the policy. | +| No related ADR or finding | **NOT_APPLICABLE** | To the known scoped rules — an empty `findings` list, not a global pass. | + +CHECK does not certify the entire architecture. A successful command means +the requested evidence was collected and evaluated; it does not prove prose, +organizational rationale, runtime behavior, or every architectural invariant. + ## Resolving a Verified violation Present all five options; never default to "revert the code": @@ -69,8 +102,26 @@ Present all five options; never default to "revert the code": 2. `supersede_adr` — the old decision no longer holds; record a new ADR that supersedes it (`adr.py supersede`). 3. `adjust_scope` — the ADR's `affected_paths` or `constraints:` are too - broad or too narrow; edit them instead of the code. + broad or too narrow. If the ADR is still Proposed, revise it through the + RECORD approval flow. If it is Accepted, record and approve a replacement + ADR, then supersede the old one; never weaken an accepted record in place + merely to clear CHECK. 4. `register_exception` — this specific case is a deliberate, documented - exception to an otherwise-still-valid rule. + exception to an otherwise-still-valid rule. Record it deterministically: + + ```bash + python skills/adr-toolkit/scripts/adr.py exception --input exception.json \ + --dir docs/decisions --json + ``` + + `exception.json` must provide `adr_id`, `rule_id`, `owner`, `reason`, + `scope` (path patterns the exception is narrowed to — never the whole + rule), and `expiry` (`YYYY-MM-DD`); the command assigns the next `EXC-NNNN` + id and writes `docs/decisions/exceptions/NNNN.json`. A future CHECK run on + a matching, non-expired exception annotates the finding with an + `exception` field — the finding's `kind` and `confidence` stay + `verified_violation`/`VIOLATED`; an exception is visible evidence, never a + silent pass. An expired exception stops applying automatically; register a + new one to extend it, never edit the `expiry` of an existing record. 5. `false_positive` — the rule fired but there is no real conflict; note why so the rule can be tightened. diff --git a/skills/adr-toolkit/schemas/adr.schema.json b/skills/adr-toolkit/schemas/adr.schema.json index 1d5f7a7..bc80fbf 100644 --- a/skills/adr-toolkit/schemas/adr.schema.json +++ b/skills/adr-toolkit/schemas/adr.schema.json @@ -13,6 +13,7 @@ "affected_paths": { "type": "array", "items": { "type": "string" } }, "tags": { "type": "array", "items": { "type": "string" } }, "retrospective": { "type": "boolean" }, + "locale": { "type": "string", "enum": ["en", "ko", "ja", "zh", "fr", "es", "de", "pt-BR"] }, "supersedes": { "type": "array", "items": { "type": "string" } }, "superseded_by": { "type": "string" } } diff --git a/skills/adr-toolkit/schemas/exception.schema.json b/skills/adr-toolkit/schemas/exception.schema.json new file mode 100644 index 0000000..c8209e4 --- /dev/null +++ b/skills/adr-toolkit/schemas/exception.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CHECK policy exception", + "type": "object", + "required": ["id", "adr_id", "rule_id", "owner", "reason", "scope", "expiry", "created"], + "properties": { + "id": { "type": "string", "pattern": "^EXC-[0-9]{4}$" }, + "adr_id": { "type": "string", "pattern": "^ADR-[0-9]{4}$" }, + "rule_id": { "type": "string", "minLength": 1 }, + "owner": { "type": "string", "minLength": 1 }, + "reason": { "type": "string", "minLength": 1 }, + "scope": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, + "expiry": { "type": "string", "format": "date" }, + "created": { "type": "string", "format": "date" } + } +} diff --git a/skills/adr-toolkit/scripts/adr.py b/skills/adr-toolkit/scripts/adr.py index c5d0c4f..9d139dc 100755 --- a/skills/adr-toolkit/scripts/adr.py +++ b/skills/adr-toolkit/scripts/adr.py @@ -12,15 +12,39 @@ create, diff, discover, + exception, + graph, index, init, preflight, related, + search, significance, status, supersede, validate, ) +from scripts.core.lifecycle import STATUSES +from scripts.core.locale import SUPPORTED_LOCALES + + +def _add_json_flag(parser: argparse.ArgumentParser) -> None: + # Every command always prints JSON to stdout (see main()) — there is no + # human-readable mode. This flag exists only for documentation clarity + # and backward compatibility with commands that already pass it; + # omitting it changes nothing. + parser.add_argument( + "--json", + action="store_true", + help="No effect: output is always JSON. Kept for backward compatibility.", + ) + + +def _add_diff_mode_arguments(parser: argparse.ArgumentParser) -> None: + modes = parser.add_mutually_exclusive_group() + modes.add_argument("--staged", action="store_true") + modes.add_argument("--uncommitted", action="store_true") + modes.add_argument("--since") def build_parser() -> argparse.ArgumentParser: @@ -28,59 +52,66 @@ def build_parser() -> argparse.ArgumentParser: sub = parser.add_subparsers(dest="operation", required=True) p_preflight = sub.add_parser("preflight") - p_preflight.add_argument("--json", action="store_true") + _add_json_flag(p_preflight) p_preflight.add_argument("--root", default=".") p_discover = sub.add_parser("discover") - p_discover.add_argument("--json", action="store_true") + _add_json_flag(p_discover) p_discover.add_argument("--root", default=".") p_init = sub.add_parser("init") p_init.add_argument("--dir", default="docs/decisions") + p_init.add_argument("--root", default=".") + p_init.add_argument("--locale", choices=SUPPORTED_LOCALES) p_init.add_argument("--dry-run", dest="dry_run", action="store_true") - p_init.add_argument("--json", action="store_true") + _add_json_flag(p_init) p_create = sub.add_parser("create") p_create.add_argument("--input") p_create.add_argument("--interactive", action="store_true") p_create.add_argument("--dir", default="docs/decisions") + p_create.add_argument("--root", default=".") + p_create.add_argument("--locale", choices=SUPPORTED_LOCALES) + p_create.add_argument("--slug") p_create.add_argument("--dry-run", dest="dry_run", action="store_true") - p_create.add_argument("--json", action="store_true") + _add_json_flag(p_create) p_index = sub.add_parser("index") p_index.add_argument("--dir", default="docs/decisions") + p_index.add_argument("--root", default=".") # Constrained so a typo from the agent's own language detection fails # visibly instead of silently producing English output. - p_index.add_argument("--locale", default="en", choices=["en", "fr", "ja", "ko", "zh"]) - p_index.add_argument("--json", action="store_true") + p_index.add_argument("--locale", choices=SUPPORTED_LOCALES) + _add_json_flag(p_index) p_related = sub.add_parser("related") p_related.add_argument("--paths", nargs="*") p_related.add_argument("--tags", nargs="*") p_related.add_argument("--keyword") p_related.add_argument("--dir", default="docs/decisions") - p_related.add_argument("--json", action="store_true") + _add_json_flag(p_related) p_significance = sub.add_parser("significance") p_significance.add_argument("--input", required=True) - p_significance.add_argument("--json", action="store_true") + _add_json_flag(p_significance) p_validate = sub.add_parser("validate") p_validate.add_argument("--dir", default="docs/decisions") - p_validate.add_argument("--json", action="store_true") + p_validate.add_argument("--root", default=".") + _add_json_flag(p_validate) p_status = sub.add_parser("status") p_status.add_argument("adr_number", type=int) p_status.add_argument("--to", required=True) p_status.add_argument("--dir", default="docs/decisions") p_status.add_argument("--dry-run", dest="dry_run", action="store_true") - p_status.add_argument("--json", action="store_true") + _add_json_flag(p_status) p_deprecate = sub.add_parser("deprecate") p_deprecate.add_argument("adr_number", type=int) p_deprecate.add_argument("--dir", default="docs/decisions") p_deprecate.add_argument("--dry-run", dest="dry_run", action="store_true") - p_deprecate.add_argument("--json", action="store_true") + _add_json_flag(p_deprecate) p_deprecate.set_defaults(to="deprecated") p_supersede = sub.add_parser("supersede") @@ -88,22 +119,42 @@ def build_parser() -> argparse.ArgumentParser: p_supersede.add_argument("--by", type=int, required=True) p_supersede.add_argument("--dir", default="docs/decisions") p_supersede.add_argument("--dry-run", dest="dry_run", action="store_true") - p_supersede.add_argument("--json", action="store_true") + _add_json_flag(p_supersede) p_diff = sub.add_parser("diff") - p_diff.add_argument("--staged", action="store_true") - p_diff.add_argument("--uncommitted", action="store_true") - p_diff.add_argument("--since") + _add_diff_mode_arguments(p_diff) p_diff.add_argument("--root", default=".") - p_diff.add_argument("--json", action="store_true") + _add_json_flag(p_diff) p_check = sub.add_parser("check") - p_check.add_argument("--staged", action="store_true") - p_check.add_argument("--uncommitted", action="store_true") - p_check.add_argument("--since") + _add_diff_mode_arguments(p_check) p_check.add_argument("--root", default=".") p_check.add_argument("--dir", default="docs/decisions") - p_check.add_argument("--json", action="store_true") + _add_json_flag(p_check) + + p_exception = sub.add_parser("exception") + p_exception.add_argument("--input", required=True) + p_exception.add_argument("--dir", default="docs/decisions") + p_exception.add_argument("--root", default=".") + p_exception.add_argument("--dry-run", dest="dry_run", action="store_true") + _add_json_flag(p_exception) + + p_graph = sub.add_parser("graph") + p_graph.add_argument("--dir", default="docs/decisions") + p_graph.add_argument("--root", default=".") + p_graph.add_argument("--format", choices=("mermaid", "svg", "both"), default="both") + p_graph.add_argument("--output") + _add_json_flag(p_graph) + + p_search = sub.add_parser("search") + p_search.add_argument("--id") + p_search.add_argument("--keyword") + p_search.add_argument("--tags", nargs="*") + p_search.add_argument("--status", choices=sorted(STATUSES)) + p_search.add_argument("--path") + p_search.add_argument("--limit", type=int) + p_search.add_argument("--dir", default="docs/decisions") + _add_json_flag(p_search) return parser @@ -122,6 +173,9 @@ def build_parser() -> argparse.ArgumentParser: "supersede": supersede.run, "diff": diff.run, "check": check.run, + "exception": exception.run, + "graph": graph.run, + "search": search.run, } diff --git a/skills/adr-toolkit/scripts/commands/check.py b/skills/adr-toolkit/scripts/commands/check.py index 2d90b41..3544426 100644 --- a/skills/adr-toolkit/scripts/commands/check.py +++ b/skills/adr-toolkit/scripts/commands/check.py @@ -1,17 +1,32 @@ """Match a git diff against Accepted ADRs' constraints: rules and Superseded ADRs' affected_paths, per §7/§16 of the design spec.""" +import json import re +from datetime import date from pathlib import Path from scripts.commands import diff as diff_command from scripts.core import frontmatter as fm -from scripts.core import identifiers +from scripts.core.adr_directory import iter_adr_files from scripts.core.constraints import ConstraintsError, extract_constraints +from scripts.core.exceptions import applies_to, is_expired, validate_exception +from scripts.core.git_paths import GitPathsError, list_existing_paths +from scripts.core.repository_paths import resolve_from_root +from scripts.core.schema import validate_frontmatter from scripts.rules import conflict -SKIP_FILES = {"README.md", "adr-template.md"} RESOLUTIONS = ["fix_code", "supersede_adr", "adjust_scope", "register_exception", "false_positive"] +# The stable, machine-readable confidence contract documented in README.md's +# "CHECK confidence" section and references/conflict-rules.md — promotes the +# ad-hoc `kind` values below into the four values agents are told to report. +CONFIDENCE_BY_KIND = { + "related": "VERIFIED", + "verified_violation": "VIOLATED", + "review_required": "UNVERIFIABLE", + "no_applicable_constraint": "UNVERIFIABLE", +} + # Every ADR this toolkit writes uses `## Confirmation` (templates/madr-*.md and # commands/create.py); `## Verification` is the design spec's name for the same # section. Accept both, plus any trailing words in the heading. @@ -29,13 +44,18 @@ def run(args) -> dict: diff_files = diff_result["files"] root = Path(getattr(args, "root", ".")).resolve() - existing_paths = _existing_paths(root) + try: + existing_paths = list_existing_paths(root) + except GitPathsError as exc: + return { + "ok": False, + "operation": "check", + "errors": [{"code": "GIT_LS_FILES_FAILED", "detail": str(exc)}], + } # `--dir` is relative to `--root`, so `check --root /repo --dir docs/decisions` # means the same directory no matter what the process CWD happens to be. - adr_dir = Path(args.dir) - if not adr_dir.is_absolute(): - adr_dir = root / adr_dir + adr_dir = resolve_from_root(root, args.dir) if not adr_dir.is_dir(): # Silently proceeding here would emit a confident "no conflicts" for # what is really a configuration error. @@ -46,6 +66,8 @@ def run(args) -> dict: } entries, warnings = _load_adrs(adr_dir) + active_exceptions, exception_warnings = _load_exceptions(adr_dir) + warnings += exception_warnings findings = [] for entry in entries: @@ -110,6 +132,26 @@ def run(args) -> dict: "resolutions": RESOLUTIONS, }) + for finding in findings: + finding["confidence"] = CONFIDENCE_BY_KIND[finding["kind"]] + if finding["kind"] == "verified_violation": + match = _matching_exception( + active_exceptions, + adr_id=finding.get("adr_id"), + rule_id=finding.get("rule_id"), + file_path=finding.get("file"), + ) + # Annotate only — an exception never hides or downgrades a + # violation. Confidence stays VIOLATED; a human/agent still sees + # and reports it, now with the approved exception attached. + if match is not None: + finding["exception"] = { + "id": match["id"], + "owner": match["owner"], + "reason": match["reason"], + "expiry": match["expiry"], + } + return { "ok": True, "operation": "check", @@ -119,16 +161,59 @@ def run(args) -> dict: } +def _load_exceptions(adr_dir: Path) -> tuple: + """Load active (non-expired, schema-valid) exceptions from adr_dir/exceptions. + + A malformed exception file degrades to a warning, same as a malformed ADR + — it must never silently vanish, and it must never abort the whole run. + """ + exceptions_dir = adr_dir / "exceptions" + active, warnings = [], [] + if not exceptions_dir.is_dir(): + return active, warnings + for path in sorted(exceptions_dir.glob("*.json")): + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError, UnicodeDecodeError) as exc: + warnings.append({"code": "BAD_EXCEPTION", "file": path.name, "detail": str(exc)}) + continue + schema_errors = validate_exception(data) if isinstance(data, dict) else ["not a JSON object"] + if schema_errors: + warnings.extend({ + "code": "BAD_EXCEPTION", "file": path.name, "detail": detail, + } for detail in schema_errors) + continue + if is_expired(data["expiry"], today=date.today()): + continue + active.append(data) + return active, warnings + + +def _matching_exception(exceptions: list, *, adr_id: str, rule_id: str, file_path): + for exception in exceptions: + if applies_to(exception, adr_id=adr_id, rule_id=rule_id, file_path=file_path): + return exception + return None + + def _load_adrs(adr_dir: Path) -> tuple: entries, warnings = [], [] - for entry in sorted(adr_dir.glob("*.md")): - if entry.name in SKIP_FILES or identifiers.parse_filename(entry.name) is None: + for entry, parsed in iter_adr_files(adr_dir): + if parsed is None: continue try: data, body = fm.parse(entry.read_text(encoding="utf-8")) except (fm.FrontmatterError, OSError, UnicodeDecodeError) as exc: warnings.append({"code": "BAD_FRONTMATTER", "file": entry.name, "detail": str(exc)}) continue + schema_errors = validate_frontmatter(data) + if schema_errors: + warnings.extend({ + "code": "SCHEMA_ERROR", + "file": entry.name, + "detail": detail, + } for detail in schema_errors) + continue entries.append({"data": data, "body": body}) return entries, warnings @@ -160,15 +245,3 @@ def _missing_realization(body: str, diff_files: list, existing_paths: set): ), "evidence": {"unrealized_paths": missing}, } - - -def _existing_paths(root: Path) -> set: - paths = set() - for entry in root.rglob("*"): - if not entry.is_file(): - continue - relative = entry.relative_to(root) - if ".git" in relative.parts: - continue - paths.add(relative.as_posix()) - return paths diff --git a/skills/adr-toolkit/scripts/commands/create.py b/skills/adr-toolkit/scripts/commands/create.py index c4a9ab9..1da3143 100644 --- a/skills/adr-toolkit/scripts/commands/create.py +++ b/skills/adr-toolkit/scripts/commands/create.py @@ -6,6 +6,10 @@ from scripts.core import frontmatter as fm from scripts.core import identifiers +from scripts.core.config import ConfigError, resolve_locale +from scripts.core.locale import DEFAULT_LOCALE +from scripts.core.rendering import interactive_prompts, render_minimal +from scripts.core.repository_paths import resolve_from_root from scripts.core.schema import validate_frontmatter REQUIRED_DRAFT_FIELDS = {"title", "status", "body"} @@ -17,47 +21,54 @@ def _prompt(input_fn, question: str) -> str: return input_fn("").strip() -def gather_draft_interactively(input_fn=None) -> dict: +def gather_draft_interactively(locale: str = DEFAULT_LOCALE, input_fn=None) -> dict: if input_fn is None: input_fn = input - title = _prompt(input_fn, "Title of the decision?") - problem = _prompt(input_fn, "What problem or constraint made this decision necessary?") - options_raw = _prompt(input_fn, "Options considered (comma-separated)?") + prompts = interactive_prompts(locale) + title = _prompt(input_fn, prompts[0]) + problem = _prompt(input_fn, prompts[1]) + options_raw = _prompt(input_fn, prompts[2]) options = [o.strip() for o in options_raw.split(",") if o.strip()] - decision = _prompt(input_fn, "Which option was chosen?") - rationale = _prompt(input_fn, "Why was it chosen?") - good = _prompt(input_fn, "One good consequence?") - bad = _prompt(input_fn, "One accepted downside?") - confirmation = _prompt(input_fn, "How will this be verified in the code?") - revisit = _prompt(input_fn, "What condition should reopen this decision?") - - options_block = "\n".join(f"* {o}" for o in options) if options else f"* {decision}" - - body = ( - f"# {title}\n\n" - "## Context and Problem Statement\n\n" - f"{problem}\n\n" - "## Considered Options\n\n" - f"{options_block}\n\n" - "## Decision Outcome\n\n" - f"Chosen option: **{decision}**, because {rationale}.\n\n" - "## Consequences\n\n" - f"* Good: {good}\n" - f"* Bad: {bad}\n\n" - "## Confirmation\n\n" - f"{confirmation}\n\n" - "## Revisit Triggers\n\n" - f"* {revisit}\n" - ) - - return {"title": title, "status": "proposed", "body": body} + decision = _prompt(input_fn, prompts[3]) + rationale = _prompt(input_fn, prompts[4]) + good = _prompt(input_fn, prompts[5]) + bad = _prompt(input_fn, prompts[6]) + confirmation = _prompt(input_fn, prompts[7]) + revisit = _prompt(input_fn, prompts[8]) + + body = render_minimal(locale, { + "title": title, + "problem": problem, + "options": options or [decision], + "decision": decision, + "rationale": rationale, + "good": good, + "bad": bad, + "confirmation": confirmation, + "revisit": revisit, + }) + + return {"title": title, "status": "proposed", "body": body, "locale": locale} def run(args) -> dict: dry_run = getattr(args, "dry_run", False) + root = Path(getattr(args, "root", ".")) if getattr(args, "interactive", False): - draft = gather_draft_interactively() + try: + locale = resolve_locale( + cli_locale=getattr(args, "locale", None), + draft_locale=None, + root=root, + ) + except ConfigError as exc: + return { + "ok": False, + "operation": "create", + "errors": [{"code": "CONFIG_ERROR", "detail": str(exc)}], + } + draft = gather_draft_interactively(locale) else: input_path = getattr(args, "input", None) if not input_path: @@ -83,7 +94,20 @@ def run(args) -> dict: "errors": [{"code": "DRAFT_FILE_INVALID_JSON", "path": input_path, "detail": str(exc)}], } - adr_dir = Path(args.dir) + try: + locale = resolve_locale( + cli_locale=getattr(args, "locale", None), + draft_locale=draft.get("locale"), + root=root, + ) + except ConfigError as exc: + return { + "ok": False, + "operation": "create", + "errors": [{"code": "CONFIG_ERROR", "detail": str(exc)}], + } + + adr_dir = resolve_from_root(root, args.dir) missing = REQUIRED_DRAFT_FIELDS - draft.keys() if missing: return { @@ -92,14 +116,24 @@ def run(args) -> dict: "errors": [{"code": "MISSING_DRAFT_FIELD", "fields": sorted(missing)}], } - next_num = identifiers.next_id(adr_dir) - slug = identifiers.slugify(draft["title"]) - if not slug: + cli_slug = getattr(args, "slug", None) + draft_slug = draft.get("slug") + if cli_slug is not None and draft_slug is not None and cli_slug != draft_slug: return { "ok": False, "operation": "create", - "errors": [{"code": "EMPTY_SLUG", "detail": "title must contain at least one ASCII letter or digit"}], + "errors": [{"code": "CONFLICTING_SLUG_INPUT"}], } + try: + slug = identifiers.slug_for_title(draft["title"], cli_slug or draft_slug) + except ValueError as exc: + return { + "ok": False, + "operation": "create", + "errors": [{"code": "INVALID_SLUG", "detail": str(exc)}], + } + + next_num = identifiers.next_id(adr_dir) filename = identifiers.format_filename(next_num, slug) target = adr_dir / filename @@ -115,6 +149,7 @@ def run(args) -> dict: "title": draft["title"], "status": draft["status"], "date": draft.get("date") or date.today().isoformat(), + "locale": locale, "decision_makers": draft.get("decision_makers", []), "related": draft.get("related", []), "affected_paths": draft.get("affected_paths", []), diff --git a/skills/adr-toolkit/scripts/commands/diff.py b/skills/adr-toolkit/scripts/commands/diff.py index bd63714..6a5780e 100644 --- a/skills/adr-toolkit/scripts/commands/diff.py +++ b/skills/adr-toolkit/scripts/commands/diff.py @@ -3,12 +3,19 @@ from pathlib import Path +class _GitCommandError(RuntimeError): + def __init__(self, code: str, detail: str): + super().__init__(detail) + self.code = code + self.detail = detail + + def run(args) -> dict: root = Path(getattr(args, "root", ".")).resolve() repo_check = subprocess.run( ["git", "-C", str(root), "rev-parse", "--is-inside-work-tree"], - capture_output=True, text=True, + capture_output=True, text=True, encoding="utf-8", ) if repo_check.returncode != 0: return { @@ -32,23 +39,43 @@ def run(args) -> dict: mode, diff_options, range_args = "uncommitted", [], [] name_status = subprocess.run( - ["git", "-C", str(root), "diff", "--name-status", *diff_options, "--end-of-options", *range_args], - capture_output=True, text=True, + [ + "git", "-C", str(root), "-c", "core.quotePath=false", "diff", + "--name-status", "-z", *diff_options, "--end-of-options", *range_args, + ], + capture_output=True, text=True, encoding="utf-8", ) if name_status.returncode != 0: - code = "INVALID_REF" if since else "GIT_DIFF_FAILED" + code = "INVALID_REF" if mode == "since" else "GIT_DIFF_FAILED" return {"ok": False, "operation": "diff", "errors": [{"code": code, "detail": name_status.stderr.strip()}]} patch = subprocess.run( - ["git", "-C", str(root), "diff", "--unified=0", *diff_options, "--end-of-options", *range_args], - capture_output=True, text=True, + [ + "git", "-C", str(root), "-c", "core.quotePath=false", "diff", + "--unified=0", *diff_options, "--end-of-options", *range_args, + ], + capture_output=True, text=True, encoding="utf-8", ) + if patch.returncode != 0: + code = "INVALID_REF" if mode == "since" else "GIT_DIFF_FAILED" + return { + "ok": False, + "operation": "diff", + "errors": [{"code": code, "detail": patch.stderr.strip()}], + } files = _parse_name_status(name_status.stdout) _attach_line_content(files, patch.stdout) if mode == "uncommitted": - files.extend(_untracked_files(root)) + try: + files.extend(_untracked_files(root)) + except _GitCommandError as exc: + return { + "ok": False, + "operation": "diff", + "errors": [{"code": exc.code, "detail": exc.detail}], + } return {"ok": True, "operation": "diff", "mode": mode, "ref": since, "files": files} @@ -58,11 +85,19 @@ def _untracked_files(root: Path) -> list: # the kind of change CHECK needs to see (e.g. a new module that violates # a forbidden_import rule) — surface it as if it were entirely "added". listing = subprocess.run( - ["git", "-C", str(root), "ls-files", "--others", "--exclude-standard"], - capture_output=True, text=True, + [ + "git", "-C", str(root), "ls-files", "--others", + "--exclude-standard", "-z", + ], + capture_output=True, text=True, encoding="utf-8", ) + if listing.returncode != 0: + raise _GitCommandError( + "GIT_LS_FILES_FAILED", + listing.stderr.strip() or "git ls-files failed", + ) entries = [] - for rel_path in listing.stdout.splitlines(): + for rel_path in listing.stdout.split("\0"): if not rel_path.strip(): continue try: @@ -81,35 +116,70 @@ def _untracked_files(root: Path) -> list: def _parse_name_status(output: str) -> list: status_map = {"A": "added", "M": "modified", "D": "deleted"} files = [] + if "\0" in output: + parts = output.split("\0") + if parts and parts[-1] == "": + parts.pop() + index = 0 + while index < len(parts): + status = parts[index] + index += 1 + status_char = status[0] + if status_char in {"R", "C"}: + old_path, new_path = parts[index], parts[index + 1] + index += 2 + files.extend([ + _file_entry(old_path, "deleted"), + _file_entry(new_path, "added"), + ]) + else: + path = parts[index] + index += 1 + files.append(_file_entry(path, status_map.get(status_char, "modified"))) + return files + for line in output.splitlines(): if not line.strip(): continue parts = line.split("\t") - status_char, path = parts[0][0], parts[-1] - files.append({ - "path": path, - "change_type": status_map.get(status_char, "modified"), - "added_lines": [], - "removed_lines": [], - }) + status_char = parts[0][0] + if status_char == "R" and len(parts) == 3: + old_path, new_path = parts[1], parts[2] + files.extend([ + _file_entry(old_path, "deleted"), + _file_entry(new_path, "added"), + ]) + continue + path = parts[-1] + files.append(_file_entry(path, status_map.get(status_char, "modified"))) return files +def _file_entry(path: str, change_type: str) -> dict: + return { + "path": path, + "change_type": change_type, + "added_lines": [], + "removed_lines": [], + } + + def _attach_line_content(files: list, patch_output: str) -> None: by_path = {f["path"]: f for f in files} - current = None + removed_target = None + added_target = None for line in patch_output.splitlines(): if line.startswith("--- "): src = line[6:] if line.startswith("--- a/") else None - current = by_path.get(src) if src else current + removed_target = by_path.get(src) if src else None continue if line.startswith("+++ "): dst = line[6:] if line.startswith("+++ b/") else None - current = by_path.get(dst) if dst else current - continue - if current is None: + added_target = by_path.get(dst) if dst else None continue if line.startswith("+"): - current["added_lines"].append(line[1:]) + if added_target is not None: + added_target["added_lines"].append(line[1:]) elif line.startswith("-"): - current["removed_lines"].append(line[1:]) + if removed_target is not None: + removed_target["removed_lines"].append(line[1:]) diff --git a/skills/adr-toolkit/scripts/commands/exception.py b/skills/adr-toolkit/scripts/commands/exception.py new file mode 100644 index 0000000..a9f2e0c --- /dev/null +++ b/skills/adr-toolkit/scripts/commands/exception.py @@ -0,0 +1,98 @@ +"""Validate and record a CHECK policy exception (owner/reason/scope/expiry).""" +import json +from datetime import date +from pathlib import Path + +from scripts.core.exceptions import validate_exception +from scripts.core.repository_paths import resolve_from_root + +REQUIRED_DRAFT_FIELDS = {"adr_id", "rule_id", "owner", "reason", "scope", "expiry"} + + +def _next_id(exceptions_dir: Path) -> int: + existing = [] + if exceptions_dir.is_dir(): + for entry in exceptions_dir.glob("*.json"): + if entry.stem.isdigit(): + existing.append(int(entry.stem)) + return max(existing, default=0) + 1 + + +def run(args) -> dict: + dry_run = getattr(args, "dry_run", False) + root = Path(getattr(args, "root", ".")) + input_path = getattr(args, "input", None) + + if not input_path: + return {"ok": False, "operation": "exception", "errors": [{"code": "MISSING_INPUT"}]} + + try: + raw_draft = Path(input_path).read_text(encoding="utf-8") + except FileNotFoundError: + return { + "ok": False, + "operation": "exception", + "errors": [{"code": "DRAFT_FILE_NOT_FOUND", "path": input_path}], + } + try: + draft = json.loads(raw_draft) + except json.JSONDecodeError as exc: + return { + "ok": False, + "operation": "exception", + "errors": [{"code": "DRAFT_FILE_INVALID_JSON", "path": input_path, "detail": str(exc)}], + } + + missing = REQUIRED_DRAFT_FIELDS - draft.keys() + if missing: + return { + "ok": False, + "operation": "exception", + "errors": [{"code": "MISSING_DRAFT_FIELD", "fields": sorted(missing)}], + } + + adr_dir = resolve_from_root(root, args.dir) + exceptions_dir = adr_dir / "exceptions" + next_num = _next_id(exceptions_dir) + exception_id = f"EXC-{next_num:04d}" + + data = { + "id": exception_id, + "adr_id": draft["adr_id"], + "rule_id": draft["rule_id"], + "owner": draft["owner"], + "reason": draft["reason"], + "scope": draft["scope"], + "expiry": draft["expiry"], + "created": draft.get("created") or date.today().isoformat(), + } + + schema_errors = validate_exception(data) + if schema_errors: + return { + "ok": False, + "operation": "exception", + "errors": [{"code": "SCHEMA_ERROR", "detail": e} for e in schema_errors], + } + + target = exceptions_dir / f"{next_num:04d}.json" + + if dry_run: + return { + "ok": True, + "operation": "exception", + "dry_run": True, + "would_create": str(target), + "id": exception_id, + } + + exceptions_dir.mkdir(parents=True, exist_ok=True) + target.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + + return { + "ok": True, + "operation": "exception", + "dry_run": False, + "created": str(target), + "id": exception_id, + } diff --git a/skills/adr-toolkit/scripts/commands/graph.py b/skills/adr-toolkit/scripts/commands/graph.py new file mode 100644 index 0000000..19c9ffb --- /dev/null +++ b/skills/adr-toolkit/scripts/commands/graph.py @@ -0,0 +1,65 @@ +"""Export ADR relationships as Mermaid and SVG graph artifacts.""" +from pathlib import Path + +from scripts.core import frontmatter as fm +from scripts.core.adr_directory import iter_adr_files +from scripts.core.relationships import render_mermaid, render_svg, resolve +from scripts.core.repository_paths import resolve_from_root + + +def run(args) -> dict: + root = Path(getattr(args, "root", ".")) + adr_dir = resolve_from_root(root, args.dir) + entries = [] + warnings = [] + + for entry, parsed in iter_adr_files(adr_dir): + if parsed is None: + continue + try: + data, _ = fm.parse(entry.read_text(encoding="utf-8")) + except fm.FrontmatterError as exc: + warnings.append({"code": "BAD_FRONTMATTER", "file": entry.name, "detail": str(exc)}) + continue + entries.append({ + "id": data.get("id", f"ADR-{parsed[0]:04d}"), + "filename": entry.name, + "title": data.get("title", parsed[1]), + "related": data.get("related", []), + "supersedes": data.get("supersedes", []), + "superseded_by": data.get("superseded_by"), + }) + + output = getattr(args, "output", None) + format_ = getattr(args, "format", "both") + outputs = [] + if format_ in {"mermaid", "both"}: + path = _output_path(root, adr_dir, output, format_, "mermaid") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(render_mermaid(entries), encoding="utf-8") + outputs.append(str(path)) + if format_ in {"svg", "both"}: + path = _output_path(root, adr_dir, output, format_, "svg") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(render_svg(entries), encoding="utf-8") + outputs.append(str(path)) + + rendered_edges = [edge for edge in resolve(entries) if edge.type in {"related", "supersedes"}] + return { + "ok": True, + "operation": "graph", + "count": len(rendered_edges), + "outputs": outputs, + "warnings": warnings, + } + + +def _output_path(root: Path, adr_dir: Path, output: str, requested_format: str, actual_format: str) -> Path: + suffix = f".{'mmd' if actual_format == 'mermaid' else 'svg'}" + if not output: + return adr_dir / f"relationships{suffix}" + + base = resolve_from_root(root, output) + if requested_format == "both": + return base.with_suffix(suffix) + return base diff --git a/skills/adr-toolkit/scripts/commands/index.py b/skills/adr-toolkit/scripts/commands/index.py index 3f5cf77..b9148df 100644 --- a/skills/adr-toolkit/scripts/commands/index.py +++ b/skills/adr-toolkit/scripts/commands/index.py @@ -2,10 +2,11 @@ from pathlib import Path from scripts.core import frontmatter as fm -from scripts.core import identifiers +from scripts.core.adr_directory import iter_adr_files +from scripts.core.config import ConfigError, resolve_locale from scripts.core.locale import load_locale - -SKIP_FILES = {"README.md", "adr-template.md"} +from scripts.core.relationships import render_mermaid, resolve +from scripts.core.repository_paths import resolve_from_root # Last-resort English headers, used when even the English locale file is # unavailable — e.g. a copy-based install (permitted by @@ -17,20 +18,34 @@ "by_tag": "By tag", "by_affected_path": "By affected path", "chronological": "Chronological (newest first)", + "relationships": "Relationships", + "supersession_chains": "Supersession chains", + "related": "Related", + "superseded_by": "superseded by", + "related_to": "related to", } def run(args) -> dict: - adr_dir = Path(args.dir) - locale = getattr(args, "locale", None) or "en" + root = Path(getattr(args, "root", ".")) + adr_dir = resolve_from_root(root, args.dir) + try: + locale = resolve_locale( + cli_locale=getattr(args, "locale", None), + draft_locale=None, + root=root, + ) + except ConfigError as exc: + return { + "ok": False, + "operation": "index", + "errors": [{"code": "CONFIG_ERROR", "detail": str(exc)}], + } strings = load_locale(locale) entries = [] warnings = [] - for entry in sorted(adr_dir.glob("*.md")): - if entry.name in SKIP_FILES: - continue - parsed = identifiers.parse_filename(entry.name) + for entry, parsed in iter_adr_files(adr_dir): if parsed is None: continue try: @@ -46,6 +61,9 @@ def run(args) -> dict: "date": data.get("date", ""), "tags": data.get("tags", []), "affected_paths": data.get("affected_paths", []), + "related": data.get("related", []), + "supersedes": data.get("supersedes", []), + "superseded_by": data.get("superseded_by"), }) (adr_dir / "README.md").write_text(_render(entries, strings), encoding="utf-8") @@ -108,4 +126,41 @@ def _render(entries: list, strings: dict) -> str: for entry in sorted(entries, key=lambda e: e["date"], reverse=True): lines.append(f"- {entry['date']} — [{entry['id']} — {entry['title']}]({entry['filename']})") + by_id = {entry["id"]: entry for entry in entries} + edges = resolve(entries) + + lines.append("") + lines.append(f"## {_s(strings, 'relationships')}") + lines.append("") + + lines.append(f"### {_s(strings, 'supersession_chains')}") + lines.append("") + for edge in sorted(e for e in edges if e.type == "supersedes"): + source = by_id.get(edge.source) + target = by_id.get(edge.target) + if source and target: + lines.append( + f"- {target['id']} \"{target['title']}\" → {_s(strings, 'superseded_by')} → " + f"{source['id']} \"{source['title']}\"" + ) + lines.append("") + + lines.append(f"### {_s(strings, 'related')}") + lines.append("") + for edge in sorted(e for e in edges if e.type == "related"): + source = by_id.get(edge.source) + target = by_id.get(edge.target) + if source and target: + lines.append( + f"- {source['id']} \"{source['title']}\" {_s(strings, 'related_to')}: " + f"{target['id']} \"{target['title']}\"" + ) + + visual_edges = [edge for edge in edges if edge.type in {"related", "supersedes"}] + if visual_edges: + lines.append("") + lines.append("```mermaid") + lines.extend(render_mermaid(entries).rstrip().splitlines()) + lines.append("```") + return "\n".join(lines) + "\n" diff --git a/skills/adr-toolkit/scripts/commands/init.py b/skills/adr-toolkit/scripts/commands/init.py index 416749a..644aa59 100644 --- a/skills/adr-toolkit/scripts/commands/init.py +++ b/skills/adr-toolkit/scripts/commands/init.py @@ -1,45 +1,21 @@ -"""Scaffold an ADR directory for a repository that has none yet.""" -import shutil +"""Scaffold a localized ADR directory for a repository that has none yet.""" +import json from datetime import date from pathlib import Path -TEMPLATE_SOURCE = Path(__file__).resolve().parent.parent.parent / "templates" / "madr-minimal.md" - -INITIAL_ADR_BODY = """# Record architecture decisions - -## Context and Problem Statement - -We need a consistent way to capture and communicate significant -architectural decisions so future contributors (human or agent) can find -the reasoning behind them. - -## Considered Options - -* No formal record, rely on commit messages and memory -* Wiki or external documentation tool -* Architecture Decision Records stored alongside the code - -## Decision Outcome - -Chosen option: **Architecture Decision Records stored alongside the code**, because they version with the code, stay close to what they describe, and are readable by both humans and coding agents. - -## Consequences - -* Good: decisions and their rationale are discoverable in the repository itself. -* Bad: requires discipline to keep records up to date as decisions evolve. - -## Confirmation - -* [ ] `{adr_dir}` exists with this file, a template, and an index. - -## Revisit Triggers - -* The team adopts a different documentation system project-wide. -""" +from scripts.core.config import ( + CONFIG_FILENAME, + CONFIG_SCHEMA_VERSION, + ConfigError, + resolve_locale, +) +from scripts.core.rendering import render_initial_adr, render_template +from scripts.core.repository_paths import resolve_from_root def run(args) -> dict: - adr_dir = Path(args.dir) + root = Path(getattr(args, "root", ".")) + adr_dir = resolve_from_root(root, args.dir) dry_run = getattr(args, "dry_run", False) if adr_dir.exists() and any(adr_dir.iterdir()): @@ -49,26 +25,58 @@ def run(args) -> dict: "errors": [{"code": "ADR_DIRECTORY_NOT_EMPTY", "path": str(adr_dir)}], } + config_path = root / CONFIG_FILENAME + config_exists = config_path.is_file() + try: + locale = resolve_locale( + cli_locale=getattr(args, "locale", None), + draft_locale=None, + root=root, + ) + except ConfigError as exc: + return { + "ok": False, + "operation": "init", + "errors": [{"code": "CONFIG_ERROR", "detail": str(exc)}], + } + would_create = [ str(adr_dir), str(adr_dir / "adr-template.md"), str(adr_dir / "0001-record-architecture-decisions.md"), ] + if not config_exists: + would_create.insert(0, str(config_path)) if dry_run: return {"ok": True, "operation": "init", "dry_run": True, "would_create": would_create} - adr_dir.mkdir(parents=True, exist_ok=True) - shutil.copyfile(TEMPLATE_SOURCE, adr_dir / "adr-template.md") - adr_dir_str = str(args.dir).rstrip("/") + "/" + title, body = render_initial_adr(locale, adr_dir_str) + + adr_dir.mkdir(parents=True, exist_ok=True) + (adr_dir / "adr-template.md").write_text( + render_template(locale, full=False), encoding="utf-8" + ) + if not config_exists: + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text( + json.dumps( + {"schema_version": CONFIG_SCHEMA_VERSION, "locale": locale}, + indent=2, + ensure_ascii=False, + ) + + "\n", + encoding="utf-8", + ) frontmatter_block = ( "---\n" "id: ADR-0001\n" - "title: Record architecture decisions\n" + f"title: {title}\n" "status: accepted\n" f"date: {date.today().isoformat()}\n" + f"locale: {locale}\n" "decision_makers: []\n" "related: []\n" "affected_paths:\n" @@ -79,7 +87,7 @@ def run(args) -> dict: "---\n\n" ) (adr_dir / "0001-record-architecture-decisions.md").write_text( - frontmatter_block + INITIAL_ADR_BODY.format(adr_dir=adr_dir_str), encoding="utf-8" + frontmatter_block + body, encoding="utf-8" ) return {"ok": True, "operation": "init", "dry_run": False, "created": would_create} diff --git a/skills/adr-toolkit/scripts/commands/related.py b/skills/adr-toolkit/scripts/commands/related.py index 56b12c4..d7144d1 100644 --- a/skills/adr-toolkit/scripts/commands/related.py +++ b/skills/adr-toolkit/scripts/commands/related.py @@ -2,13 +2,8 @@ from pathlib import Path from scripts.core import frontmatter as fm -from scripts.core import identifiers - -SKIP_FILES = {"README.md", "adr-template.md"} - - -def _as_list(value) -> list: - return value if isinstance(value, list) else [] +from scripts.core.adr_directory import iter_adr_files +from scripts.core.query import matches_keyword, matches_paths_exact, matches_tags_any def run(args) -> dict: @@ -19,28 +14,28 @@ def run(args) -> dict: matches = [] warnings = [] - for entry in sorted(adr_dir.glob("*.md")): - if entry.name in SKIP_FILES or identifiers.parse_filename(entry.name) is None: + for entry, parsed in iter_adr_files(adr_dir): + if parsed is None: continue try: - data, _ = fm.parse(entry.read_text(encoding="utf-8")) + data, body = fm.parse(entry.read_text(encoding="utf-8")) except fm.FrontmatterError as exc: warnings.append({"code": "BAD_FRONTMATTER", "file": entry.name, "detail": str(exc)}) continue reasons = [] - path_overlap = query_paths & set(_as_list(data.get("affected_paths"))) + path_overlap = matches_paths_exact(query_paths, data.get("affected_paths")) if path_overlap: reasons.append(f"affected_paths overlap: {sorted(path_overlap)}") - tag_overlap = query_tags & set(_as_list(data.get("tags"))) + tag_overlap = matches_tags_any(query_tags, data.get("tags")) if tag_overlap: reasons.append(f"tag overlap: {sorted(tag_overlap)}") - if keyword and keyword in data.get("title", "").lower(): - reasons.append("title keyword match") + if keyword and matches_keyword(keyword, data.get("title", ""), body): + reasons.append("title/body keyword match") if reasons: matches.append({ diff --git a/skills/adr-toolkit/scripts/commands/search.py b/skills/adr-toolkit/scripts/commands/search.py new file mode 100644 index 0000000..642ca6a --- /dev/null +++ b/skills/adr-toolkit/scripts/commands/search.py @@ -0,0 +1,106 @@ +"""General ADR lookup: keyword (title+body), tags, status, or governed path. + +Distinct from related.py's discovery policy (broad OR, no-query-means-no- +match): search.py combines different filter fields with AND, multiple values +within one field with OR, and an empty query browses every ADR. +""" +from pathlib import Path + +from scripts.core import frontmatter as fm +from scripts.core.adr_directory import iter_adr_files +from scripts.core.query import matches_tags_any, path_governed_by, rank_key + + +def run(args) -> dict: + adr_dir = Path(args.dir) + adr_id = getattr(args, "id", None) + keyword = (getattr(args, "keyword", None) or "").lower() + query_tags = set(getattr(args, "tags", None) or []) + status = getattr(args, "status", None) + path = getattr(args, "path", None) + limit = getattr(args, "limit", None) + + results = [] + warnings = [] + for entry, parsed in iter_adr_files(adr_dir): + if parsed is None: + continue + + try: + data, body = fm.parse(entry.read_text(encoding="utf-8")) + except fm.FrontmatterError as exc: + warnings.append({"code": "BAD_FRONTMATTER", "file": entry.name, "detail": str(exc)}) + continue + + matched_in = [] + + if adr_id: + if data.get("id") != adr_id: + continue + matched_in.append("id") + + if keyword: + title_hit = keyword in (data.get("title") or "").lower() + body_hit = keyword in (body or "").lower() + if not (title_hit or body_hit): + continue + if title_hit: + matched_in.append("title") + if body_hit: + matched_in.append("body") + + if query_tags: + tag_overlap = matches_tags_any(query_tags, data.get("tags")) + if not tag_overlap: + continue + matched_in.append("tags") + + if status: + if data.get("status") != status: + continue + matched_in.append("status") + + if path: + if not path_governed_by(path, data.get("affected_paths")): + continue + matched_in.append("path") + + results.append({ + "id": data.get("id"), + "filename": entry.name, + "path": str(Path(args.dir) / entry.name), + "title": data.get("title"), + "status": data.get("status"), + "tags": data.get("tags", []), + "matched_in": matched_in, + "_body": body, # used only for ranking below, stripped before returning + }) + + results.sort(key=lambda r: rank_key({"id": r["id"], "title": r["title"], "body": r["_body"]}, keyword)) + for r in results: + del r["_body"] + + total = len(results) + if limit is not None: + truncated = total > limit + results = results[:limit] + else: + truncated = False + + return { + "ok": True, + "operation": "search", + "query": { + "id": adr_id, + "keyword": getattr(args, "keyword", None), + "tags": getattr(args, "tags", None), + "status": status, + "path": path, + "limit": limit, + }, + "count": len(results), + "total": total, + "truncated": truncated, + "results": results, + "warnings": warnings, + } diff --git a/skills/adr-toolkit/scripts/commands/validate.py b/skills/adr-toolkit/scripts/commands/validate.py index 91699c0..afb614c 100644 --- a/skills/adr-toolkit/scripts/commands/validate.py +++ b/skills/adr-toolkit/scripts/commands/validate.py @@ -2,23 +2,34 @@ from pathlib import Path from scripts.core import frontmatter as fm -from scripts.core import identifiers +from scripts.core.adr_directory import iter_adr_files +from scripts.core.config import ConfigError, load_repository_config +from scripts.core.relationships import find_cycles, missing_targets, resolve, supersession_mismatches from scripts.core.schema import validate_frontmatter - -SKIP_FILES = {"README.md", "adr-template.md"} +from scripts.core.repository_paths import resolve_from_root def run(args) -> dict: - adr_dir = Path(args.dir) + root = Path(getattr(args, "root", ".")) + adr_dir = resolve_from_root(root, args.dir) errors = [] - adr_files = sorted(p for p in adr_dir.glob("*.md") if p.name not in SKIP_FILES) + try: + load_repository_config(root) + except ConfigError as exc: + return { + "ok": False, + "operation": "validate", + "checked": 0, + "errors": [{"code": "CONFIG_ERROR", "detail": str(exc)}], + } parsed_entries = [] seen_ids: dict = {} + checked = 0 - for path in adr_files: - parsed_filename = identifiers.parse_filename(path.name) + for path, parsed_filename in iter_adr_files(adr_dir): + checked += 1 if parsed_filename is None: errors.append({"code": "BAD_FILENAME", "file": path.name}) continue @@ -57,4 +68,24 @@ def run(args) -> dict: if related_id not in known_ids: errors.append({"code": "BROKEN_RELATED_LINK", "file": filename, "related_id": related_id}) - return {"ok": not errors, "operation": "validate", "checked": len(adr_files), "errors": errors} + edges = resolve([data for _, data in parsed_entries]) + + for edge in missing_targets(edges, known_ids): + if edge.type in ("supersedes", "superseded_by"): + errors.append({ + "code": "BROKEN_SUPERSESSION_LINK", + "adr_id": edge.source, + "target": edge.target, + }) + + for source, target in supersession_mismatches(edges): + errors.append({ + "code": "SUPERSESSION_MISMATCH", + "adr_id": source, + "expected_superseded_by_on": target, + }) + + for cycle in find_cycles(edges): + errors.append({"code": "SUPERSESSION_CYCLE", "cycle": list(cycle)}) + + return {"ok": not errors, "operation": "validate", "checked": checked, "errors": errors} diff --git a/skills/adr-toolkit/scripts/core/adr_directory.py b/skills/adr-toolkit/scripts/core/adr_directory.py new file mode 100644 index 0000000..9e55d51 --- /dev/null +++ b/skills/adr-toolkit/scripts/core/adr_directory.py @@ -0,0 +1,20 @@ +"""Shared ADR-candidate file iteration for related/index/validate/check.""" +from pathlib import Path + +from scripts.core import identifiers + +SKIP_FILES = {"README.md", "adr-template.md"} + + +def iter_adr_files(adr_dir: Path): + """Yield (path, parsed) for every candidate ADR file in `adr_dir`, sorted. + + Skips known non-ADR files (README.md, adr-template.md). `parsed` is the + `identifiers.parse_filename()` result — None when the filename doesn't + match the ADR-NNNN-slug pattern. Callers decide whether that is a silent + skip or a reportable error. + """ + for path in sorted(adr_dir.glob("*.md")): + if path.name in SKIP_FILES: + continue + yield path, identifiers.parse_filename(path.name) diff --git a/skills/adr-toolkit/scripts/core/config.py b/skills/adr-toolkit/scripts/core/config.py new file mode 100644 index 0000000..d2c77e2 --- /dev/null +++ b/skills/adr-toolkit/scripts/core/config.py @@ -0,0 +1,49 @@ +"""Load and validate repository-owned ADR Toolkit configuration.""" +import json +from pathlib import Path +from typing import Optional + +from scripts.core.locale import DEFAULT_LOCALE, SUPPORTED_LOCALES + +CONFIG_FILENAME = ".adr-toolkit.json" +CONFIG_SCHEMA_VERSION = 1 +ALLOWED_KEYS = {"schema_version", "locale"} + + +class ConfigError(ValueError): + """The repository configuration is malformed or unsupported.""" + + +def load_repository_config(root: Path) -> dict: + path = Path(root) / CONFIG_FILENAME + if not path.is_file(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ConfigError(f"Invalid {CONFIG_FILENAME}: {exc}") from exc + if not isinstance(data, dict): + raise ConfigError(f"{CONFIG_FILENAME} must contain a JSON object") + unknown = set(data) - ALLOWED_KEYS + if unknown: + raise ConfigError(f"Unknown config fields: {sorted(unknown)}") + if data.get("schema_version") != CONFIG_SCHEMA_VERSION: + raise ConfigError( + f"Unsupported schema_version: {data.get('schema_version')!r}" + ) + if data.get("locale") not in SUPPORTED_LOCALES: + raise ConfigError(f"Unsupported locale: {data.get('locale')!r}") + return data + + +def resolve_locale( + *, + cli_locale: Optional[str], + draft_locale: Optional[str], + root: Path, +) -> str: + config = load_repository_config(Path(root)) + locale = cli_locale or draft_locale or config.get("locale") or DEFAULT_LOCALE + if locale not in SUPPORTED_LOCALES: + raise ConfigError(f"Unsupported locale: {locale!r}") + return locale diff --git a/skills/adr-toolkit/scripts/core/exceptions.py b/skills/adr-toolkit/scripts/core/exceptions.py new file mode 100644 index 0000000..8d455e5 --- /dev/null +++ b/skills/adr-toolkit/scripts/core/exceptions.py @@ -0,0 +1,81 @@ +"""Structural validation and matching for CHECK policy exceptions. + +schemas/exception.schema.json documents the same shape for external tools; +this module is the version actually enforced at runtime. +""" +from datetime import date as date_cls +import re + +from scripts.core import globs + +REQUIRED_FIELDS = { + "id": str, + "adr_id": str, + "rule_id": str, + "owner": str, + "reason": str, + "scope": list, + "expiry": str, + "created": str, +} + +ID_RE = re.compile(r"^EXC-\d{4}$") +ADR_ID_RE = re.compile(r"^ADR-\d{4}$") +DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") + + +def _validate_date(field: str, value: str, errors: list) -> None: + if not DATE_RE.match(value): + errors.append(f"{field} {value!r} is not YYYY-MM-DD") + return + year, month, day = (int(part) for part in value.split("-")) + try: + date_cls(year, month, day) + except ValueError: + errors.append(f"{field} {value!r} is not a real calendar date") + + +def validate_exception(data: dict) -> list: + errors = [] + for field, expected_type in REQUIRED_FIELDS.items(): + if field not in data: + errors.append(f"missing required field: {field}") + continue + if not isinstance(data[field], expected_type): + errors.append( + f"field {field!r} must be {expected_type.__name__}, " + f"got {type(data[field]).__name__}" + ) + + if isinstance(data.get("id"), str) and not ID_RE.match(data["id"]): + errors.append(f"id {data['id']!r} does not match EXC-NNNN") + + if isinstance(data.get("adr_id"), str) and not ADR_ID_RE.match(data["adr_id"]): + errors.append(f"adr_id {data['adr_id']!r} does not match ADR-NNNN") + + for field in ("owner", "reason", "rule_id"): + if isinstance(data.get(field), str) and not data[field].strip(): + errors.append(f"{field} must not be empty") + + if isinstance(data.get("scope"), list) and not data["scope"]: + errors.append("scope must contain at least one path pattern") + + if isinstance(data.get("expiry"), str): + _validate_date("expiry", data["expiry"], errors) + if isinstance(data.get("created"), str): + _validate_date("created", data["created"], errors) + + return errors + + +def is_expired(expiry: str, today: date_cls) -> bool: + year, month, day = (int(part) for part in expiry.split("-")) + return today > date_cls(year, month, day) + + +def applies_to(exception: dict, *, adr_id: str, rule_id: str, file_path: str) -> bool: + if exception.get("adr_id") != adr_id or exception.get("rule_id") != rule_id: + return False + if file_path is None: + return False + return any(globs.match(pattern, file_path) for pattern in exception.get("scope", [])) diff --git a/skills/adr-toolkit/scripts/core/git_paths.py b/skills/adr-toolkit/scripts/core/git_paths.py new file mode 100644 index 0000000..9b8b028 --- /dev/null +++ b/skills/adr-toolkit/scripts/core/git_paths.py @@ -0,0 +1,25 @@ +"""List repository paths using Git's tracked and ignore semantics.""" +import subprocess +from pathlib import Path + + +class GitPathsError(RuntimeError): + """Git could not provide a trustworthy repository path inventory.""" + + +def list_existing_paths(root: Path) -> set: + result = subprocess.run( + [ + "git", "-C", str(root), "ls-files", "--cached", "--others", + "--exclude-standard", "-z", + ], + capture_output=True, + text=True, + encoding="utf-8", + ) + if result.returncode != 0: + raise GitPathsError(result.stderr.strip() or "git ls-files failed") + return { + path for path in result.stdout.split("\0") + if path and (root / path).exists() + } diff --git a/skills/adr-toolkit/scripts/core/globs.py b/skills/adr-toolkit/scripts/core/globs.py index 1b14479..4cb3ff5 100644 --- a/skills/adr-toolkit/scripts/core/globs.py +++ b/skills/adr-toolkit/scripts/core/globs.py @@ -11,6 +11,14 @@ def match(pattern: str, path: str) -> bool: return re.match(_translate(pattern), path) is not None +def path_under(path: str, prefix: str) -> bool: + """Directory-boundary prefix match: "src/db" governs "src/db/x.py" but + not "src/db2/file.py".""" + if path == prefix: + return True + return path.startswith(prefix.rstrip("/") + "/") + + def _translate(pattern: str) -> str: parts = [] i, n = 0, len(pattern) diff --git a/skills/adr-toolkit/scripts/core/identifiers.py b/skills/adr-toolkit/scripts/core/identifiers.py index 97b06b1..7c8b804 100644 --- a/skills/adr-toolkit/scripts/core/identifiers.py +++ b/skills/adr-toolkit/scripts/core/identifiers.py @@ -4,6 +4,7 @@ from typing import Optional ADR_FILENAME_RE = re.compile(r"^(\d{4})-([a-z0-9]+(?:-[a-z0-9]+)*)\.md$") +SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") def parse_filename(filename: str) -> Optional[tuple]: @@ -31,6 +32,18 @@ def slugify(title: str) -> str: return re.sub(r"-+", "-", slug) +def validate_slug(slug: str) -> str: + if not SLUG_RE.fullmatch(slug): + raise ValueError("slug must match [a-z0-9]+(?:-[a-z0-9]+)*") + return slug + + +def slug_for_title(title: str, explicit_slug: Optional[str] = None) -> str: + if explicit_slug is not None: + return validate_slug(explicit_slug) + return slugify(title) or "decision" + + def find_by_number(adr_dir: Path, number: int) -> Optional[Path]: for entry in adr_dir.glob(f"{number:04d}-*.md"): return entry diff --git a/skills/adr-toolkit/scripts/core/locale.py b/skills/adr-toolkit/scripts/core/locale.py index 9cd6492..4ea50d0 100644 --- a/skills/adr-toolkit/scripts/core/locale.py +++ b/skills/adr-toolkit/scripts/core/locale.py @@ -1,14 +1,9 @@ -"""Load i18n locale files for index.py's generated strings. - -Every other user-facing string in this toolkit (RECORD/DISCOVER/CHECK's -interview questions and reports) is composed by the agent, not a fixed -Python string, so it needs no translation table — only index.py's -generated README.md content is a translation target (design spec §17.1). -""" +"""Load deterministic strings for ADR scaffolds, prompts, and indexes.""" import json from pathlib import Path I18N_DIR = Path(__file__).resolve().parent.parent / "i18n" +SUPPORTED_LOCALES = ("en", "ko", "ja", "zh", "fr", "es", "de", "pt-BR") DEFAULT_LOCALE = "en" diff --git a/skills/adr-toolkit/scripts/core/query.py b/skills/adr-toolkit/scripts/core/query.py new file mode 100644 index 0000000..538e9d5 --- /dev/null +++ b/skills/adr-toolkit/scripts/core/query.py @@ -0,0 +1,57 @@ +"""Shared ADR matching primitives for related.py and search.py. + +related.py wants a broad net (any single field overlap is enough); search.py +wants precise filtering (all given fields must match). Both call the same +functions here; the combination policy lives in each command, not here. +""" +from scripts.core import globs + + +def _as_list(value) -> list: + # A malformed ADR can carry a non-list value for a field this project's + # schema declares as a list (e.g. "affected_paths: src/events/" instead + # of a YAML list) -- treating it as iterable would silently decompose a + # string into individual characters and produce nonsense single-char + # matches. Anything that isn't already a list is treated as empty. + return value if isinstance(value, list) else [] + + +def matches_keyword(keyword: str, title: str, body: str) -> bool: + keyword = keyword.lower() + return keyword in (title or "").lower() or keyword in (body or "").lower() + + +def matches_tags_any(query_tags: set, entry_tags: list) -> set: + return query_tags & set(_as_list(entry_tags)) + + +def matches_paths_exact(query_paths: set, affected_paths: list) -> set: + return query_paths & set(_as_list(affected_paths)) + + +def path_governed_by(path: str, affected_paths: list) -> bool: + return any( + globs.path_under(path, ap) or globs.match(ap, path) + for ap in _as_list(affected_paths) + if isinstance(ap, str) + ) + + +def rank_key(entry: dict, keyword: str) -> tuple: + """Lower tuples sort first -- tier 0 (best) through tier 4 (worst), then + id as a stable, deterministic tie-break.""" + keyword_lower = (keyword or "").lower() + title_lower = (entry.get("title") or "").lower() + body_lower = (entry.get("body") or "").lower() + + if keyword_lower and entry.get("id", "").lower() == keyword_lower: + tier = 0 + elif keyword_lower and title_lower == keyword_lower: + tier = 1 + elif keyword_lower and keyword_lower in title_lower: + tier = 2 + elif keyword_lower and keyword_lower in body_lower: + tier = 4 + else: + tier = 3 # tag-only or no-keyword matches land between title and body + return (tier, entry.get("id", "")) diff --git a/skills/adr-toolkit/scripts/core/relationships.py b/skills/adr-toolkit/scripts/core/relationships.py new file mode 100644 index 0000000..d9f8bd0 --- /dev/null +++ b/skills/adr-toolkit/scripts/core/relationships.py @@ -0,0 +1,185 @@ +"""Canonical ADR relationship model, shared by index.py and validate.py.""" +from html import escape +from typing import NamedTuple + + +class Relationship(NamedTuple): + source: str + type: str # "related" | "supersedes" | "superseded_by" + target: str + + +def resolve(entries: list) -> list: + edges = [] + for entry in entries: + source = entry.get("id") + for target in entry.get("related") or []: + edges.append(Relationship(source=source, type="related", target=target)) + for target in entry.get("supersedes") or []: + edges.append(Relationship(source=source, type="supersedes", target=target)) + superseded_by = entry.get("superseded_by") + if superseded_by: + edges.append(Relationship(source=source, type="superseded_by", target=superseded_by)) + return edges + + +def missing_targets(relationships: list, known_ids: set) -> list: + return [r for r in relationships if r.target not in known_ids] + + +def supersession_mismatches(relationships: list) -> list: + supersedes_edges = {(r.source, r.target) for r in relationships if r.type == "supersedes"} + superseded_by_edges = {(r.target, r.source) for r in relationships if r.type == "superseded_by"} + return sorted(supersedes_edges - superseded_by_edges) + + +def find_cycles(relationships: list) -> list: + """Detect cycles among "supersedes" edges only -- "related" is symmetric + in meaning, so an A<->B related pair is not a logical error the way a + supersession cycle (A supersedes B supersedes A) is. + + Returns a list of cycle paths, each a tuple of ADR ids in the order the + depth-first walk encountered them. + """ + graph: dict = {} + for r in relationships: + if r.type == "supersedes": + graph.setdefault(r.source, []).append(r.target) + + cycles = [] + visited = set() + + def dfs(node, path, path_set): + if node in path_set: + cycle_start = path.index(node) + cycle = tuple(path[cycle_start:]) + if cycle not in cycles: + cycles.append(cycle) + return + if node in visited: + return + visited.add(node) + path.append(node) + path_set.add(node) + for neighbor in graph.get(node, []): + dfs(neighbor, path, path_set) + path.pop() + path_set.discard(node) + + for node in sorted(graph): + dfs(node, [], set()) + + return cycles + + +def render_mermaid(entries: list) -> str: + """Render ADR relationships as GitHub-compatible Mermaid flowchart text.""" + by_id = _entries_by_id(entries) + relationships = resolve(entries) + visual_edges = _visual_edges(relationships) + lines = ["flowchart LR"] + + for adr_id in sorted(by_id): + entry = by_id[adr_id] + label = _mermaid_label(adr_id, entry.get("title", adr_id)) + lines.append(f" {_node_id(adr_id)}[\"{label}\"]") + + if not visual_edges: + lines.append(' EMPTY["No relationships recorded"]') + + for relationship in sorted(visual_edges): + source = _node_id(relationship.source) + target = _node_id(relationship.target) + if relationship.type == "related": + lines.append(f" {source} -.->|related| {target}") + else: + lines.append(f" {source} -->|supersedes| {target}") + + return "\n".join(lines) + "\n" + + +def render_svg(entries: list) -> str: + """Render a small deterministic SVG navigation artifact. + + This is intentionally not a full Mermaid renderer. It uses the same graph + model and writes vector text/lines directly so the exported artifact stays + sharp without requiring Node or browser automation. + """ + by_id = _entries_by_id(entries) + relationships = _visual_edges(resolve(entries)) + ordered_ids = sorted(by_id) + width = 960 + row_height = 82 + edge_height = 28 + node_height = max(1, len(ordered_ids)) * row_height + edge_start = 72 + node_height + height = edge_start + max(1, len(relationships)) * edge_height + 48 + y_by_id = {adr_id: 72 + index * row_height for index, adr_id in enumerate(ordered_ids)} + + lines = [ + f'', + "ADR relationship graph", + "Vector export of ADR supersession and related-decision links.", + '', + '', + 'ADR relationship graph', + ] + + for adr_id in ordered_ids: + y = y_by_id[adr_id] + title = str(by_id[adr_id].get("title", adr_id)) + lines.extend([ + f'', + f'{escape(adr_id)}', + f'{escape(_truncate(title, 36))}', + ]) + + if relationships: + pair_totals = {} + for relationship in relationships: + pair = (relationship.source, relationship.target) + pair_totals[pair] = pair_totals.get(pair, 0) + 1 + pair_seen = {} + for index, relationship in enumerate(sorted(relationships)): + pair = (relationship.source, relationship.target) + pair_seen[pair] = pair_seen.get(pair, 0) + 1 + offset = (pair_seen[pair] - (pair_totals[pair] + 1) / 2) * 14 + source_y = y_by_id.get(relationship.source, 72) + 26 + offset + target_y = y_by_id.get(relationship.target, 72) + 26 + offset + edge_y = edge_start + index * edge_height + label = f"{relationship.source} {relationship.type} {relationship.target}" + dash = ' stroke-dasharray="5 5"' if relationship.type == "related" else "" + lines.extend([ + f'', + f'{escape(label)}', + ]) + else: + lines.append('No relationships recorded') + + lines.append("") + return "\n".join(lines) + "\n" + + +def _entries_by_id(entries: list) -> dict: + return {entry["id"]: entry for entry in entries if entry.get("id")} + + +def _visual_edges(relationships: list) -> list: + return [r for r in relationships if r.type in {"related", "supersedes"}] + + +def _node_id(adr_id: str) -> str: + return adr_id.replace("-", "_") + + +def _mermaid_label(adr_id: str, title: str) -> str: + safe_title = " ".join(str(title).split()) + safe_title = escape(safe_title, quote=False) + safe_title = safe_title.replace('"', "'").replace("[", "(").replace("]", ")") + return f"{adr_id}
{safe_title}" + + +def _truncate(value: str, limit: int) -> str: + if len(value) <= limit: + return value + return value[: limit - 3] + "..." diff --git a/skills/adr-toolkit/scripts/core/rendering.py b/skills/adr-toolkit/scripts/core/rendering.py new file mode 100644 index 0000000..7673dfa --- /dev/null +++ b/skills/adr-toolkit/scripts/core/rendering.py @@ -0,0 +1,81 @@ +"""Render deterministic ADR Markdown from localized structural strings.""" +from scripts.core.locale import load_locale + +PROMPT_KEYS = ( + "prompt.title", "prompt.problem", "prompt.options", "prompt.decision", + "prompt.rationale", "prompt.good", "prompt.bad", "prompt.confirmation", + "prompt.revisit", +) + + +def render_minimal(locale: str, values: dict) -> str: + strings = load_locale(locale) + options = "\n".join(f"* {option}" for option in values["options"]) + return ( + f"# {values['title']}\n\n" + f"## {strings['heading.context']}\n\n{values['problem']}\n\n" + f"## {strings['heading.considered_options']}\n\n{options}\n\n" + f"## {strings['heading.decision_outcome']}\n\n" + f"{strings['label.chosen_option']}: **{values['decision']}**, " + f"{strings['label.because']} {values['rationale']}.\n\n" + f"## {strings['heading.consequences']}\n\n" + f"* {strings['label.good']}: {values['good']}\n" + f"* {strings['label.bad']}: {values['bad']}\n\n" + f"## {strings['heading.confirmation']}\n\n{values['confirmation']}\n\n" + f"## {strings['heading.revisit_triggers']}\n\n* {values['revisit']}\n" + ) + + +def interactive_prompts(locale: str) -> tuple: + strings = load_locale(locale) + return tuple(strings[key] for key in PROMPT_KEYS) + + +def render_initial_adr(locale: str, adr_dir: str) -> tuple: + strings = load_locale(locale) + values = { + "title": strings["init.title"], + "problem": strings["init.problem"], + "options": [strings["init.option.none"], strings["init.option.wiki"], strings["init.option.adr"]], + "decision": strings["init.decision"], + "rationale": strings["init.rationale"], + "good": strings["init.good"], + "bad": strings["init.bad"], + "confirmation": strings["init.confirmation"].format(adr_dir=adr_dir), + "revisit": strings["init.revisit"], + } + return values["title"], render_minimal(locale, values) + + +def render_template(locale: str, *, full: bool) -> str: + if full: + return _render_full_template(load_locale(locale)) + return render_minimal(locale, { + "title": "{title}", "problem": "{problem and constraints}", + "options": ["{option one}", "{option two}"], + "decision": "{chosen option}", "rationale": "{rationale}", + "good": "{positive consequence}", "bad": "{negative consequence}", + "confirmation": "{how implementation will be verified}", + "revisit": "{condition that should reopen the decision}", + }) + + +def _render_full_template(strings: dict) -> str: + return ( + "# {title}\n\n" + f"## {strings['heading.context']}\n\n{{problem and constraints}}\n\n" + f"## {strings['heading.decision_drivers']}\n\n* {{driver one}}\n* {{driver two}}\n\n" + f"## {strings['heading.considered_options']}\n\n* {{option one}}\n* {{option two}}\n* {{option three}}\n\n" + f"## {strings['heading.decision_outcome']}\n\n" + f"{strings['label.chosen_option']}: **{{chosen option}}**, {strings['label.because']} {{rationale}}.\n\n" + f"### {strings['heading.consequences']}\n\n" + f"* {strings['label.good']}: {{positive consequence}}\n* {strings['label.bad']}: {{negative consequence}}\n\n" + f"### {strings['heading.confirmation']}\n\n{{how implementation will be verified}}\n\n" + f"## {strings['heading.pros_cons']}\n\n### {{option one}}\n\n" + f"* {strings['label.good']}, {strings['label.because']} {{argument}}\n" + f"* {strings['label.bad']}, {strings['label.because']} {{argument}}\n\n" + "### {option two}\n\n" + f"* {strings['label.good']}, {strings['label.because']} {{argument}}\n" + f"* {strings['label.bad']}, {strings['label.because']} {{argument}}\n\n" + f"## {strings['heading.revisit_triggers']}\n\n* {{condition that should reopen the decision}}\n" + ) diff --git a/skills/adr-toolkit/scripts/core/repository_paths.py b/skills/adr-toolkit/scripts/core/repository_paths.py new file mode 100644 index 0000000..8e887f0 --- /dev/null +++ b/skills/adr-toolkit/scripts/core/repository_paths.py @@ -0,0 +1,9 @@ +"""Resolve repository-owned paths independently of the caller's CWD.""" +from pathlib import Path + + +def resolve_from_root(root, path) -> Path: + candidate = Path(path) + if candidate.is_absolute(): + return candidate + return Path(root) / candidate diff --git a/skills/adr-toolkit/scripts/core/schema.py b/skills/adr-toolkit/scripts/core/schema.py index 332dcf2..54f5da9 100644 --- a/skills/adr-toolkit/scripts/core/schema.py +++ b/skills/adr-toolkit/scripts/core/schema.py @@ -7,6 +7,7 @@ import re from scripts.core.lifecycle import STATUSES +from scripts.core.locale import SUPPORTED_LOCALES REQUIRED_FIELDS = { "id": str, @@ -21,6 +22,7 @@ } OPTIONAL_FIELDS = { + "locale": str, "supersedes": list, "superseded_by": str, } @@ -54,6 +56,11 @@ def validate_frontmatter(data: dict) -> list: if "status" in data and data["status"] not in STATUSES: errors.append(f"status {data['status']!r} is not one of {sorted(STATUSES)}") + if "locale" in data and data["locale"] not in SUPPORTED_LOCALES: + errors.append( + f"locale {data['locale']!r} is not one of {list(SUPPORTED_LOCALES)}" + ) + if isinstance(data.get("date"), str): if not DATE_RE.match(data["date"]): errors.append(f"date {data['date']!r} is not YYYY-MM-DD") diff --git a/skills/adr-toolkit/scripts/i18n/de.json b/skills/adr-toolkit/scripts/i18n/de.json new file mode 100644 index 0000000..baf5d8f --- /dev/null +++ b/skills/adr-toolkit/scripts/i18n/de.json @@ -0,0 +1,49 @@ +{ + "decision_log_title": "Entscheidungsprotokoll", + "by_status": "Nach Status", + "by_tag": "Nach Tag", + "by_affected_path": "Nach betroffenem Pfad", + "chronological": "Chronologisch (neueste zuerst)", + "relationships": "Beziehungen", + "supersession_chains": "Ablöseketten", + "related": "Verwandt", + "superseded_by": "abgelöst durch", + "related_to": "verwandt mit", + "status.proposed": "Vorgeschlagen", + "status.accepted": "Akzeptiert", + "status.rejected": "Abgelehnt", + "status.deprecated": "Veraltet", + "status.superseded": "Ersetzt", + "heading.context": "Kontext und Problemstellung", + "heading.decision_drivers": "Entscheidungstreiber", + "heading.considered_options": "Betrachtete Optionen", + "heading.decision_outcome": "Entscheidung", + "heading.consequences": "Konsequenzen", + "heading.confirmation": "Bestätigung", + "heading.pros_cons": "Vor- und Nachteile", + "heading.revisit_triggers": "Auslöser für Neubewertung", + "label.chosen_option": "Gewählte Option", + "label.because": "weil", + "label.good": "Vorteil", + "label.bad": "Nachteil", + "prompt.title": "Wie lautet der Titel der Entscheidung?", + "prompt.problem": "Welches Problem oder welche Einschränkung machte diese Entscheidung erforderlich?", + "prompt.options": "Welche Optionen wurden betrachtet? (durch Kommas getrennt)", + "prompt.decision": "Welche Option wurde gewählt?", + "prompt.rationale": "Warum wurde sie gewählt?", + "prompt.good": "Was ist eine positive Konsequenz?", + "prompt.bad": "Welcher Nachteil wird akzeptiert?", + "prompt.confirmation": "Wie wird diese Entscheidung im Code überprüft?", + "prompt.revisit": "Welche Bedingung soll diese Entscheidung wieder öffnen?", + "init.title": "Architekturentscheidungen dokumentieren", + "init.problem": "Wir benötigen eine einheitliche Methode, um wichtige Architekturentscheidungen festzuhalten und zu kommunizieren, damit spätere Mitwirkende (Menschen oder Agenten) deren Begründung nachvollziehen können.", + "init.option.none": "Keine formale Dokumentation und stattdessen auf Commit-Nachrichten und Erinnerung vertrauen", + "init.option.wiki": "Ein Wiki oder externes Dokumentationswerkzeug verwenden", + "init.option.adr": "Architekturentscheidungen zusammen mit dem Code speichern", + "init.decision": "Architekturentscheidungen zusammen mit dem Code speichern", + "init.rationale": "sie mit dem Code versioniert werden, nahe am beschriebenen Gegenstand bleiben und sowohl für Menschen als auch für Coding-Agenten lesbar sind", + "init.good": "Entscheidungen und ihre Begründungen sind im Repository auffindbar", + "init.bad": "die Dokumentation muss bei Änderungen der Entscheidungen diszipliniert aktuell gehalten werden", + "init.confirmation": "* [ ] `{adr_dir}` enthält diese Datei, eine Vorlage und einen Index.", + "init.revisit": "Das Team führt projektweit ein anderes Dokumentationssystem ein." +} diff --git a/skills/adr-toolkit/scripts/i18n/en.json b/skills/adr-toolkit/scripts/i18n/en.json index 71376ef..141f830 100644 --- a/skills/adr-toolkit/scripts/i18n/en.json +++ b/skills/adr-toolkit/scripts/i18n/en.json @@ -4,9 +4,46 @@ "by_tag": "By tag", "by_affected_path": "By affected path", "chronological": "Chronological (newest first)", + "relationships": "Relationships", + "supersession_chains": "Supersession chains", + "related": "Related", + "superseded_by": "superseded by", + "related_to": "related to", "status.proposed": "Proposed", "status.accepted": "Accepted", "status.rejected": "Rejected", "status.deprecated": "Deprecated", - "status.superseded": "Superseded" + "status.superseded": "Superseded", + "heading.context": "Context and Problem Statement", + "heading.decision_drivers": "Decision Drivers", + "heading.considered_options": "Considered Options", + "heading.decision_outcome": "Decision Outcome", + "heading.consequences": "Consequences", + "heading.confirmation": "Confirmation", + "heading.pros_cons": "Pros and Cons of the Options", + "heading.revisit_triggers": "Revisit Triggers", + "label.chosen_option": "Chosen option", + "label.because": "because", + "label.good": "Good", + "label.bad": "Bad", + "prompt.title": "Title of the decision?", + "prompt.problem": "What problem or constraint made this decision necessary?", + "prompt.options": "Options considered (comma-separated)?", + "prompt.decision": "Which option was chosen?", + "prompt.rationale": "Why was it chosen?", + "prompt.good": "One good consequence?", + "prompt.bad": "One accepted downside?", + "prompt.confirmation": "How will this be verified in the code?", + "prompt.revisit": "What condition should reopen this decision?", + "init.title": "Record architecture decisions", + "init.problem": "We need a consistent way to capture and communicate significant architectural decisions so future contributors (human or agent) can find the reasoning behind them.", + "init.option.none": "No formal record, rely on commit messages and memory", + "init.option.wiki": "Wiki or external documentation tool", + "init.option.adr": "Architecture Decision Records stored alongside the code", + "init.decision": "Architecture Decision Records stored alongside the code", + "init.rationale": "they version with the code, stay close to what they describe, and are readable by both humans and coding agents", + "init.good": "decisions and their rationale are discoverable in the repository itself", + "init.bad": "requires discipline to keep records up to date as decisions evolve", + "init.confirmation": "* [ ] `{adr_dir}` exists with this file, a template, and an index.", + "init.revisit": "The team adopts a different documentation system project-wide." } diff --git a/skills/adr-toolkit/scripts/i18n/es.json b/skills/adr-toolkit/scripts/i18n/es.json new file mode 100644 index 0000000..a80f7cb --- /dev/null +++ b/skills/adr-toolkit/scripts/i18n/es.json @@ -0,0 +1,49 @@ +{ + "decision_log_title": "Registro de decisiones", + "by_status": "Por estado", + "by_tag": "Por etiqueta", + "by_affected_path": "Por ruta afectada", + "chronological": "Cronológico (más reciente primero)", + "relationships": "Relaciones", + "supersession_chains": "Cadenas de sustitución", + "related": "Relacionadas", + "superseded_by": "reemplazado por", + "related_to": "relacionada con", + "status.proposed": "Propuesta", + "status.accepted": "Aceptada", + "status.rejected": "Rechazada", + "status.deprecated": "Obsoleta", + "status.superseded": "Sustituida", + "heading.context": "Contexto y problema", + "heading.decision_drivers": "Impulsores de la decisión", + "heading.considered_options": "Opciones consideradas", + "heading.decision_outcome": "Resultado de la decisión", + "heading.consequences": "Consecuencias", + "heading.confirmation": "Confirmación", + "heading.pros_cons": "Ventajas y desventajas", + "heading.revisit_triggers": "Condiciones de revisión", + "label.chosen_option": "Opción elegida", + "label.because": "porque", + "label.good": "Ventaja", + "label.bad": "Desventaja", + "prompt.title": "¿Cuál es el título de la decisión?", + "prompt.problem": "¿Qué problema o restricción hizo necesaria esta decisión?", + "prompt.options": "¿Qué opciones se consideraron? (separadas por comas)", + "prompt.decision": "¿Qué opción se eligió?", + "prompt.rationale": "¿Por qué se eligió?", + "prompt.good": "¿Cuál es una consecuencia positiva?", + "prompt.bad": "¿Cuál es una desventaja aceptada?", + "prompt.confirmation": "¿Cómo se verificará esta decisión en el código?", + "prompt.revisit": "¿Qué condición debería reabrir esta decisión?", + "init.title": "Registrar las decisiones de arquitectura", + "init.problem": "Necesitamos una forma coherente de registrar y comunicar las decisiones de arquitectura importantes para que futuros colaboradores (personas o agentes) puedan encontrar sus fundamentos.", + "init.option.none": "No mantener un registro formal y depender de los mensajes de commit y la memoria", + "init.option.wiki": "Usar una wiki o una herramienta de documentación externa", + "init.option.adr": "Guardar los registros de decisiones de arquitectura junto al código", + "init.decision": "Guardar los registros de decisiones de arquitectura junto al código", + "init.rationale": "se versionan con el código, permanecen cerca de lo que describen y pueden leerlos tanto personas como agentes de programación", + "init.good": "las decisiones y sus fundamentos se pueden encontrar en el propio repositorio", + "init.bad": "se requiere disciplina para mantener los registros actualizados cuando evolucionan las decisiones", + "init.confirmation": "* [ ] `{adr_dir}` contiene este archivo, una plantilla y un índice.", + "init.revisit": "El equipo adopta otro sistema de documentación para todo el proyecto." +} diff --git a/skills/adr-toolkit/scripts/i18n/fr.json b/skills/adr-toolkit/scripts/i18n/fr.json index baad8e9..20b1303 100644 --- a/skills/adr-toolkit/scripts/i18n/fr.json +++ b/skills/adr-toolkit/scripts/i18n/fr.json @@ -4,9 +4,46 @@ "by_tag": "Par étiquette", "by_affected_path": "Par chemin concerné", "chronological": "Chronologique (plus récent d'abord)", + "relationships": "Relations", + "supersession_chains": "Chaînes de remplacement", + "related": "Liées", + "superseded_by": "remplacé par", + "related_to": "liée à", "status.proposed": "Proposé", "status.accepted": "Accepté", "status.rejected": "Rejeté", "status.deprecated": "Déprécié", - "status.superseded": "Remplacé" + "status.superseded": "Remplacé", + "heading.context": "Contexte et problème", + "heading.decision_drivers": "Facteurs de décision", + "heading.considered_options": "Options envisagées", + "heading.decision_outcome": "Décision", + "heading.consequences": "Conséquences", + "heading.confirmation": "Confirmation", + "heading.pros_cons": "Avantages et inconvénients", + "heading.revisit_triggers": "Conditions de réexamen", + "label.chosen_option": "Option retenue", + "label.because": "car", + "label.good": "Avantage", + "label.bad": "Inconvénient", + "prompt.title": "Quel est le titre de la décision ?", + "prompt.problem": "Quel problème ou quelle contrainte a rendu cette décision nécessaire ?", + "prompt.options": "Quelles options ont été envisagées ? (séparées par des virgules)", + "prompt.decision": "Quelle option a été retenue ?", + "prompt.rationale": "Pourquoi a-t-elle été retenue ?", + "prompt.good": "Quelle est une conséquence positive ?", + "prompt.bad": "Quel inconvénient est accepté ?", + "prompt.confirmation": "Comment cette décision sera-t-elle vérifiée dans le code ?", + "prompt.revisit": "Quelle condition doit conduire à réexaminer cette décision ?", + "init.title": "Consigner les décisions d’architecture", + "init.problem": "Nous avons besoin d’une méthode cohérente pour consigner et communiquer les décisions d’architecture importantes afin que les futurs contributeurs (humains ou agents) puissent en retrouver la justification.", + "init.option.none": "Ne tenir aucun registre formel et s’appuyer sur les messages de commit et la mémoire", + "init.option.wiki": "Utiliser un wiki ou un outil de documentation externe", + "init.option.adr": "Stocker les décisions d’architecture avec le code", + "init.decision": "Stocker les décisions d’architecture avec le code", + "init.rationale": "elles sont versionnées avec le code, restent proches de ce qu’elles décrivent et sont lisibles par les humains comme par les agents de développement", + "init.good": "les décisions et leur justification sont accessibles dans le dépôt", + "init.bad": "il faut maintenir les enregistrements à jour lorsque les décisions évoluent", + "init.confirmation": "* [ ] `{adr_dir}` contient ce fichier, un modèle et un index.", + "init.revisit": "L’équipe adopte un autre système de documentation pour l’ensemble du projet." } diff --git a/skills/adr-toolkit/scripts/i18n/ja.json b/skills/adr-toolkit/scripts/i18n/ja.json index a8f0ffc..dca7dac 100644 --- a/skills/adr-toolkit/scripts/i18n/ja.json +++ b/skills/adr-toolkit/scripts/i18n/ja.json @@ -4,9 +4,46 @@ "by_tag": "タグ別", "by_affected_path": "影響パス別", "chronological": "時系列(新しい順)", + "relationships": "関係", + "supersession_chains": "置き換えの履歴", + "related": "関連", + "superseded_by": "置き換え先", + "related_to": "関連", "status.proposed": "提案中", "status.accepted": "承認済み", "status.rejected": "却下", "status.deprecated": "非推奨", - "status.superseded": "置き換え済み" + "status.superseded": "置き換え済み", + "heading.context": "コンテキストと問題", + "heading.decision_drivers": "決定要因", + "heading.considered_options": "検討した選択肢", + "heading.decision_outcome": "決定結果", + "heading.consequences": "影響", + "heading.confirmation": "確認方法", + "heading.pros_cons": "選択肢の長所と短所", + "heading.revisit_triggers": "再検討条件", + "label.chosen_option": "選択した案", + "label.because": "理由", + "label.good": "長所", + "label.bad": "短所", + "prompt.title": "決定のタイトルは何ですか?", + "prompt.problem": "どのような問題または制約により、この決定が必要になりましたか?", + "prompt.options": "検討した選択肢は何ですか?(カンマ区切り)", + "prompt.decision": "どの選択肢を採用しましたか?", + "prompt.rationale": "なぜその選択肢を採用しましたか?", + "prompt.good": "期待される長所を一つ挙げてください。", + "prompt.bad": "受け入れる短所を一つ挙げてください。", + "prompt.confirmation": "コード上でこの決定をどのように確認しますか?", + "prompt.revisit": "どの条件でこの決定を再検討しますか?", + "init.title": "アーキテクチャ上の決定を記録する", + "init.problem": "重要なアーキテクチャ上の決定を一貫して記録・共有し、将来の貢献者(人間またはエージェント)がその根拠を確認できるようにする必要がある。", + "init.option.none": "正式な記録を残さず、コミットメッセージと記憶に頼る", + "init.option.wiki": "Wikiまたは外部ドキュメントツールを使う", + "init.option.adr": "コードと共にアーキテクチャ決定記録を保存する", + "init.decision": "コードと共にアーキテクチャ決定記録を保存する", + "init.rationale": "コードと一緒にバージョン管理され、対象の近くに置かれ、人間とコーディングエージェントの双方が読めるため", + "init.good": "リポジトリ内で決定と根拠を確認できる", + "init.bad": "決定の変更に合わせて記録を最新に保つ規律が必要になる", + "init.confirmation": "* [ ] `{adr_dir}`にこのファイル、テンプレート、インデックスが存在する。", + "init.revisit": "チームがプロジェクト全体で別のドキュメントシステムを採用する。" } diff --git a/skills/adr-toolkit/scripts/i18n/ko.json b/skills/adr-toolkit/scripts/i18n/ko.json index e26020b..54ad7fa 100644 --- a/skills/adr-toolkit/scripts/i18n/ko.json +++ b/skills/adr-toolkit/scripts/i18n/ko.json @@ -4,9 +4,46 @@ "by_tag": "태그별", "by_affected_path": "영향 경로별", "chronological": "시간순 (최신순)", + "relationships": "관계", + "supersession_chains": "대체 이력", + "related": "관련", + "superseded_by": "대체됨", + "related_to": "관련", "status.proposed": "제안됨", "status.accepted": "승인됨", "status.rejected": "거부됨", "status.deprecated": "폐기됨", - "status.superseded": "대체됨" + "status.superseded": "대체됨", + "heading.context": "맥락 및 문제 설명", + "heading.decision_drivers": "의사결정 동인", + "heading.considered_options": "검토한 대안", + "heading.decision_outcome": "결정 결과", + "heading.consequences": "결과 및 영향", + "heading.confirmation": "확인 방법", + "heading.pros_cons": "대안별 장단점", + "heading.revisit_triggers": "재검토 조건", + "label.chosen_option": "선택한 대안", + "label.because": "그 이유는", + "label.good": "장점", + "label.bad": "단점", + "prompt.title": "결정의 제목은 무엇인가요?", + "prompt.problem": "어떤 문제나 제약 때문에 이 결정이 필요한가요?", + "prompt.options": "검토한 대안은 무엇인가요? (쉼표로 구분)", + "prompt.decision": "어떤 대안을 선택했나요?", + "prompt.rationale": "왜 이 대안을 선택했나요?", + "prompt.good": "기대하는 장점 한 가지는 무엇인가요?", + "prompt.bad": "감수하기로 한 단점 한 가지는 무엇인가요?", + "prompt.confirmation": "코드에서 이 결정을 어떻게 확인할 수 있나요?", + "prompt.revisit": "어떤 조건에서 이 결정을 재검토해야 하나요?", + "init.title": "아키텍처 결정을 기록한다", + "init.problem": "중요한 아키텍처 결정을 일관되게 기록하고 공유하여, 이후 기여자(사람 또는 에이전트)가 그 근거를 찾을 수 있어야 한다.", + "init.option.none": "공식 기록 없이 커밋 메시지와 기억에 의존한다", + "init.option.wiki": "위키 또는 외부 문서 도구를 사용한다", + "init.option.adr": "코드와 함께 아키텍처 결정 기록을 저장한다", + "init.decision": "코드와 함께 아키텍처 결정 기록을 저장한다", + "init.rationale": "코드와 함께 버전 관리되고 설명 대상 가까이에 있으며 사람과 코딩 에이전트 모두 읽을 수 있기 때문이다", + "init.good": "저장소에서 결정과 근거를 직접 찾을 수 있다", + "init.bad": "결정이 바뀔 때 기록도 최신 상태로 유지하는 규율이 필요하다", + "init.confirmation": "* [ ] `{adr_dir}`에 이 파일, 템플릿, 인덱스가 존재한다.", + "init.revisit": "팀이 프로젝트 전체에 다른 문서 시스템을 도입한다." } diff --git a/skills/adr-toolkit/scripts/i18n/pt-BR.json b/skills/adr-toolkit/scripts/i18n/pt-BR.json new file mode 100644 index 0000000..46fd0af --- /dev/null +++ b/skills/adr-toolkit/scripts/i18n/pt-BR.json @@ -0,0 +1,49 @@ +{ + "decision_log_title": "Registro de decisões", + "by_status": "Por status", + "by_tag": "Por etiqueta", + "by_affected_path": "Por caminho afetado", + "chronological": "Cronológico (mais recente primeiro)", + "relationships": "Relacionamentos", + "supersession_chains": "Cadeias de substituição", + "related": "Relacionadas", + "superseded_by": "substituído por", + "related_to": "relacionada a", + "status.proposed": "Proposta", + "status.accepted": "Aceita", + "status.rejected": "Rejeitada", + "status.deprecated": "Obsoleta", + "status.superseded": "Substituída", + "heading.context": "Contexto e problema", + "heading.decision_drivers": "Direcionadores da decisão", + "heading.considered_options": "Opções consideradas", + "heading.decision_outcome": "Resultado da decisão", + "heading.consequences": "Consequências", + "heading.confirmation": "Confirmação", + "heading.pros_cons": "Prós e contras", + "heading.revisit_triggers": "Condições para reavaliação", + "label.chosen_option": "Opção escolhida", + "label.because": "porque", + "label.good": "Vantagem", + "label.bad": "Desvantagem", + "prompt.title": "Qual é o título da decisão?", + "prompt.problem": "Qual problema ou restrição tornou esta decisão necessária?", + "prompt.options": "Quais opções foram consideradas? (separadas por vírgulas)", + "prompt.decision": "Qual opção foi escolhida?", + "prompt.rationale": "Por que ela foi escolhida?", + "prompt.good": "Qual é uma consequência positiva?", + "prompt.bad": "Qual é uma desvantagem aceita?", + "prompt.confirmation": "Como esta decisão será verificada no código?", + "prompt.revisit": "Qual condição deve reabrir esta decisão?", + "init.title": "Registrar decisões de arquitetura", + "init.problem": "Precisamos de uma forma consistente de registrar e comunicar decisões de arquitetura importantes para que futuros colaboradores (pessoas ou agentes) encontrem seus fundamentos.", + "init.option.none": "Não manter registro formal e depender de mensagens de commit e da memória", + "init.option.wiki": "Usar uma wiki ou ferramenta externa de documentação", + "init.option.adr": "Armazenar registros de decisões de arquitetura junto ao código", + "init.decision": "Armazenar registros de decisões de arquitetura junto ao código", + "init.rationale": "eles são versionados com o código, permanecem próximos do que descrevem e podem ser lidos por pessoas e agentes de programação", + "init.good": "as decisões e seus fundamentos podem ser encontrados no próprio repositório", + "init.bad": "é preciso disciplina para manter os registros atualizados conforme as decisões evoluem", + "init.confirmation": "* [ ] `{adr_dir}` contém este arquivo, um modelo e um índice.", + "init.revisit": "A equipe adota outro sistema de documentação em todo o projeto." +} diff --git a/skills/adr-toolkit/scripts/i18n/zh.json b/skills/adr-toolkit/scripts/i18n/zh.json index 3ae60c6..5edf659 100644 --- a/skills/adr-toolkit/scripts/i18n/zh.json +++ b/skills/adr-toolkit/scripts/i18n/zh.json @@ -4,9 +4,46 @@ "by_tag": "按标签", "by_affected_path": "按受影响路径", "chronological": "按时间顺序(最新优先)", + "relationships": "关系", + "supersession_chains": "替代链", + "related": "相关", + "superseded_by": "被替代为", + "related_to": "相关", "status.proposed": "已提议", "status.accepted": "已接受", "status.rejected": "已拒绝", "status.deprecated": "已弃用", - "status.superseded": "已取代" + "status.superseded": "已取代", + "heading.context": "背景与问题陈述", + "heading.decision_drivers": "决策驱动因素", + "heading.considered_options": "考虑的选项", + "heading.decision_outcome": "决策结果", + "heading.consequences": "后果", + "heading.confirmation": "确认方式", + "heading.pros_cons": "各选项的优缺点", + "heading.revisit_triggers": "重新评估条件", + "label.chosen_option": "选择的方案", + "label.because": "原因是", + "label.good": "优点", + "label.bad": "缺点", + "prompt.title": "决策标题是什么?", + "prompt.problem": "什么问题或约束使这项决策成为必要?", + "prompt.options": "考虑了哪些选项?(用逗号分隔)", + "prompt.decision": "选择了哪个选项?", + "prompt.rationale": "为什么选择它?", + "prompt.good": "一项正面影响是什么?", + "prompt.bad": "一项可接受的负面影响是什么?", + "prompt.confirmation": "如何在代码中验证这项决策?", + "prompt.revisit": "什么条件应触发重新评估?", + "init.title": "记录架构决策", + "init.problem": "我们需要以一致的方式记录和传达重要的架构决策,使未来的贡献者(人或智能体)能够找到决策依据。", + "init.option.none": "不做正式记录,依赖提交信息和记忆", + "init.option.wiki": "使用 Wiki 或外部文档工具", + "init.option.adr": "将架构决策记录与代码一起保存", + "init.decision": "将架构决策记录与代码一起保存", + "init.rationale": "它们与代码共同进行版本控制,靠近所描述的内容,并且人和编码智能体都可以阅读", + "init.good": "可直接在仓库中找到决策及其依据", + "init.bad": "决策演进时需要保持记录更新的纪律", + "init.confirmation": "* [ ] `{adr_dir}`中存在此文件、模板和索引。", + "init.revisit": "团队在整个项目中采用其他文档系统。" } diff --git a/skills/adr-toolkit/scripts/rules/conflict.py b/skills/adr-toolkit/scripts/rules/conflict.py index 89af3c6..d6640f2 100644 --- a/skills/adr-toolkit/scripts/rules/conflict.py +++ b/skills/adr-toolkit/scripts/rules/conflict.py @@ -41,21 +41,13 @@ def _as_list(value) -> list: def affected_paths_overlap(diff_files: list, affected_paths) -> bool: touched = _touched_paths(diff_files) return any( - _path_under(diff_path, ap) or globs.match(ap, diff_path) + globs.path_under(diff_path, ap) or globs.match(ap, diff_path) for diff_path in touched for ap in _as_list(affected_paths) if isinstance(ap, str) ) -def _path_under(diff_path: str, affected_path: str) -> bool: - # Prefix matching must respect directory boundaries: "src/db" governs - # "src/db/x.py" but not "src/db2/file.py". - if diff_path == affected_path: - return True - return diff_path.startswith(affected_path.rstrip("/") + "/") - - def _touched_paths(diff_files: list) -> set: return {f["path"] for f in diff_files} diff --git a/tests/integration/test_cli.py b/tests/integration/test_cli.py index d6c5380..f2ed635 100644 --- a/tests/integration/test_cli.py +++ b/tests/integration/test_cli.py @@ -36,6 +36,31 @@ def test_unknown_subcommand_exits_nonzero(tmp_path): assert result.returncode != 0 +def test_preflight_output_is_json_even_without_the_json_flag(tmp_path): + """The CLI has one output contract — JSON to stdout — regardless of --json. + + --json is accepted for documentation clarity and backward compatibility, + but every command has always printed JSON whether or not it's passed. + """ + result = _run(["preflight"], cwd=tmp_path) + assert result.returncode == 0 + payload = json.loads(result.stdout) + assert payload["operation"] == "preflight" + assert payload["ok"] is True + + +def test_search_via_cli(tmp_path): + init_result = _run(["init", "--dir", "docs/decisions", "--json"], cwd=tmp_path) + assert init_result.returncode == 0 + + result = _run(["search", "--keyword", "architecture", "--dir", "docs/decisions", "--json"], cwd=tmp_path) + + payload = json.loads(result.stdout) + assert result.returncode == 0 + assert payload["ok"] is True + assert payload["count"] >= 1 + + def test_significance_via_cli(tmp_path): scores_path = tmp_path / "scores.json" scores_path.write_text( diff --git a/tests/integration/test_init_workflow.py b/tests/integration/test_init_workflow.py index 4a92df2..d7de91a 100644 --- a/tests/integration/test_init_workflow.py +++ b/tests/integration/test_init_workflow.py @@ -29,6 +29,10 @@ def test_full_init_flow_on_js_fixture(tmp_path): init_result = _run(["init", "--dir", "docs/decisions", "--json"], cwd=repo) assert init_result["ok"] is True + assert json.loads((repo / ".adr-toolkit.json").read_text(encoding="utf-8")) == { + "schema_version": 1, + "locale": "en", + } validate_result = _run(["validate", "--dir", "docs/decisions", "--json"], cwd=repo) assert validate_result["ok"] is True diff --git a/tests/integration/test_localized_workflow.py b/tests/integration/test_localized_workflow.py new file mode 100644 index 0000000..0faa717 --- /dev/null +++ b/tests/integration/test_localized_workflow.py @@ -0,0 +1,76 @@ +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +from scripts.core.locale import SUPPORTED_LOCALES + +ADR_PY = Path(__file__).resolve().parents[2] / "skills/adr-toolkit/scripts/adr.py" + +CASES = { + "en": ("Separate payment system", "Decision Log"), + "ko": ("결제 시스템 분리", "결정 기록"), + "ja": ("決済システムを分離する", "決定記録"), + "zh": ("拆分支付系统", "决策日志"), + "fr": ("Séparer le système de paiement", "Journal des décisions"), + "es": ("Separar el sistema de pagos", "Registro de decisiones"), + "de": ("Zahlungssystem trennen", "Entscheidungsprotokoll"), + "pt-BR": ("Separar o sistema de pagamentos", "Registro de decisões"), +} + + +def _run(repo, *args): + completed = subprocess.run( + [sys.executable, str(ADR_PY), *args], + cwd=repo, + capture_output=True, + text=True, + ) + assert completed.stderr == "" + payload = json.loads(completed.stdout) + assert completed.returncode == 0, payload + return payload + + +@pytest.mark.parametrize("locale", SUPPORTED_LOCALES) +def test_complete_localized_workflow(locale, tmp_path): + title, index_title = CASES[locale] + repo = tmp_path / locale + repo.mkdir() + + initialized = _run(repo, "init", "--locale", locale, "--json") + assert initialized["ok"] is True + + draft = { + "title": title, + "status": "proposed", + "body": f"# {title}\n\nUnicode body: {title}\n", + } + draft_path = repo / "draft.json" + draft_path.write_text( + json.dumps(draft, ensure_ascii=False), encoding="utf-8" + ) + created = _run( + repo, + "create", + "--input", + str(draft_path), + "--slug", + "separate-payment-system", + "--json", + ) + created_path = repo / created["created"] + assert created_path.name == "0002-separate-payment-system.md" + created_text = created_path.read_text(encoding="utf-8") + assert f"locale: {locale}" in created_text + assert title in created_text + + validated = _run(repo, "validate", "--json") + assert validated["checked"] == 2 + + indexed = _run(repo, "index", "--json") + assert indexed["count"] == 2 + readme = (repo / "docs/decisions/README.md").read_text(encoding="utf-8") + assert readme.startswith(f"# {index_title}") diff --git a/tests/integration/test_repository_adrs.py b/tests/integration/test_repository_adrs.py new file mode 100644 index 0000000..5144ff1 --- /dev/null +++ b/tests/integration/test_repository_adrs.py @@ -0,0 +1,64 @@ +from pathlib import Path + +from scripts.core import frontmatter as fm +from scripts.core.identifiers import parse_filename + +REPO_ROOT = Path(__file__).resolve().parents[2] +ADR_DIR = REPO_ROOT / "docs/decisions" + + +def _repository_adrs(): + for path in sorted(ADR_DIR.glob("*.md")): + if parse_filename(path.name) is None: + continue + data, body = fm.parse(path.read_text(encoding="utf-8")) + yield path, data, body + + +def test_accepted_repository_adrs_name_a_decision_maker_and_locale(): + for path, data, _body in _repository_adrs(): + if data["status"] == "accepted": + assert data["decision_makers"] == ["YangSeungHyun"], path.name + assert data.get("locale") == "en", path.name + + +def test_retrospective_repository_adrs_separate_evidence_from_inference(): + entries = list(_repository_adrs()) + retrospective_ids = { + data["id"] for _path, data, _body in entries if data["retrospective"] + } + assert retrospective_ids == {"ADR-0002", "ADR-0003", "ADR-0004"} + + for path, data, body in entries: + if data["retrospective"]: + assert "## Confirmed Evidence" in body, path.name + assert "## Inferred Rationale" in body, path.name + assert "## Unknown" in body, path.name + + +def test_repository_adrs_cover_their_governing_paths(): + required_paths = { + "ADR-0002": { + "skills/adr-toolkit/scripts/core/constraints.py", + "skills/adr-toolkit/scripts/commands/diff.py", + "skills/adr-toolkit/references/conflict-rules.md", + }, + "ADR-0003": { + ".adr-toolkit.json", + "skills/adr-toolkit/SKILL.md", + "skills/adr-toolkit/scripts/core/rendering.py", + }, + "ADR-0004": {"adapters/", ".gitignore", ".claude-plugin/marketplace.json"}, + "ADR-0005": {"skills/adr-toolkit/VERSION", "scripts/sync_version.py"}, + } + for path, data, _body in _repository_adrs(): + expected = required_paths.get(data["id"], set()) + assert expected <= set(data["affected_paths"]), path.name + + +def test_repository_adr_structure_matches_its_evidence_contract(): + entries = {data["id"]: (path, body) for path, data, body in _repository_adrs()} + assert "* [ ]" not in entries["ADR-0001"][1] + adr4_body = entries["ADR-0004"][1] + assert "## Decision Drivers" in adr4_body + assert "## Pros and Cons of the Options" in adr4_body diff --git a/tests/integration/test_skill_md_documented_invocation.py b/tests/integration/test_skill_md_documented_invocation.py index 3bfb5f2..3987717 100644 --- a/tests/integration/test_skill_md_documented_invocation.py +++ b/tests/integration/test_skill_md_documented_invocation.py @@ -32,11 +32,15 @@ def test_documented_relative_invocation_resolves_from_repo_root(tmp_path): assert preflight.returncode == 0, preflight.stderr assert json.loads(preflight.stdout)["ok"] is True - init_result = _run(["init", "--dir", str(adr_dir), "--json"]) + init_result = _run([ + "init", "--dir", str(adr_dir), "--root", str(tmp_path), "--json", + ]) assert init_result.returncode == 0, init_result.stderr assert json.loads(init_result.stdout)["ok"] is True - validate_result = _run(["validate", "--dir", str(adr_dir), "--json"]) + validate_result = _run([ + "validate", "--dir", str(adr_dir), "--root", str(tmp_path), "--json", + ]) assert validate_result.returncode == 0, validate_result.stderr payload = json.loads(validate_result.stdout) assert payload["ok"] is True diff --git a/tests/unit/test_adr_cli.py b/tests/unit/test_adr_cli.py index 9642efa..b8378cc 100644 --- a/tests/unit/test_adr_cli.py +++ b/tests/unit/test_adr_cli.py @@ -47,5 +47,55 @@ def test_index_locale_rejects_an_unrecognized_code_instead_of_silently_using_eng adr.build_parser().parse_args(["index", "--locale", "zz"]) -def test_index_locale_defaults_to_english_when_omitted(): - assert adr.build_parser().parse_args(["index"]).locale == "en" +def test_index_locale_is_unset_when_omitted_so_repository_default_can_win(): + args = adr.build_parser().parse_args(["index"]) + assert args.locale is None + assert args.root == "." + + +def test_graph_command_accepts_format_and_output_options(): + args = adr.build_parser().parse_args([ + "graph", + "--dir", "docs/decisions", + "--format", "svg", + "--output", "docs/decisions/relationships.svg", + ]) + + assert args.operation == "graph" + assert args.format == "svg" + assert args.output == "docs/decisions/relationships.svg" + assert adr.HANDLERS["graph"].__module__ == "scripts.commands.graph" + + +def test_init_locale_is_optional_and_constrained(): + parser = adr.build_parser() + + assert parser.parse_args(["init"]).locale is None + assert parser.parse_args(["init", "--locale", "pt-BR"]).locale == "pt-BR" + + with pytest.raises(SystemExit): + parser.parse_args(["init", "--locale", "xx"]) + + +def test_create_locale_and_slug_are_optional_and_constrained(): + parser = adr.build_parser() + + args = parser.parse_args(["create", "--input", "draft.json"]) + assert args.locale is None + assert args.slug is None + assert args.root == "." + + args = parser.parse_args([ + "create", "--input", "draft.json", "--locale", "de", + "--slug", "separate-payment-system", + ]) + assert args.locale == "de" + assert args.slug == "separate-payment-system" + + +@pytest.mark.parametrize("operation", ["diff", "check"]) +def test_diff_modes_are_mutually_exclusive(operation): + with pytest.raises(SystemExit): + adr.build_parser().parse_args([ + operation, "--staged", "--since", "main", + ]) diff --git a/tests/unit/test_adr_directory.py b/tests/unit/test_adr_directory.py new file mode 100644 index 0000000..0c997ad --- /dev/null +++ b/tests/unit/test_adr_directory.py @@ -0,0 +1,46 @@ +from scripts.core.adr_directory import SKIP_FILES, iter_adr_files + + +def _touch(path, text="content"): + path.write_text(text, encoding="utf-8") + + +def test_iter_adr_files_skips_readme_and_template(tmp_path): + _touch(tmp_path / "README.md") + _touch(tmp_path / "adr-template.md") + _touch(tmp_path / "0001-decision.md") + + results = list(iter_adr_files(tmp_path)) + + assert [path.name for path, _ in results] == ["0001-decision.md"] + + +def test_iter_adr_files_yields_parsed_id_and_slug(tmp_path): + _touch(tmp_path / "0002-use-a-cache.md") + + ((path, parsed),) = list(iter_adr_files(tmp_path)) + + assert path.name == "0002-use-a-cache.md" + assert parsed == (2, "use-a-cache") + + +def test_iter_adr_files_yields_none_for_unparseable_filename(tmp_path): + _touch(tmp_path / "not-an-adr.md") + + ((path, parsed),) = list(iter_adr_files(tmp_path)) + + assert path.name == "not-an-adr.md" + assert parsed is None + + +def test_iter_adr_files_sorted_by_filename(tmp_path): + _touch(tmp_path / "0002-second.md") + _touch(tmp_path / "0001-first.md") + + results = list(iter_adr_files(tmp_path)) + + assert [path.name for path, _ in results] == ["0001-first.md", "0002-second.md"] + + +def test_skip_files_constant_matches_known_non_adr_filenames(): + assert SKIP_FILES == {"README.md", "adr-template.md"} diff --git a/tests/unit/test_check.py b/tests/unit/test_check.py index 0c0c98d..4a1138b 100644 --- a/tests/unit/test_check.py +++ b/tests/unit/test_check.py @@ -86,6 +86,107 @@ def test_verified_violation_when_rule_fires(tmp_path): assert violation["adr_id"] == "ADR-0001" assert violation["rule_id"] == "no-provider-sdk-in-feature" assert violation["resolutions"] == RESOLUTIONS + assert violation["confidence"] == "VIOLATED" + assert "exception" not in violation + + +def _write_exception(adr_dir, **overrides): + import json as _json + exceptions_dir = adr_dir / "exceptions" + exceptions_dir.mkdir(parents=True, exist_ok=True) + data = { + "id": "EXC-0001", + "adr_id": "ADR-0001", + "rule_id": "no-provider-sdk-in-feature", + "owner": "YangSeungHyun", + "reason": "Vendor migration is in progress.", + "scope": ["src/features/**"], + "expiry": "2999-12-31", + "created": "2026-08-30", + } + data.update(overrides) + (exceptions_dir / "0001.json").write_text(_json.dumps(data), encoding="utf-8") + return data + + +def test_verified_violation_is_annotated_with_a_matching_active_exception(tmp_path): + _init_repo(tmp_path) + adr_dir = tmp_path / "docs" / "decisions" + adr_dir.mkdir(parents=True) + (adr_dir / "0001-use-a-provider-port.md").write_text(ACCEPTED_ADR_WITH_RULE, encoding="utf-8") + _git(["add", "-A"], tmp_path) + _git(["commit", "-q", "-m", "init"], tmp_path) + _write_exception(adr_dir) + + (tmp_path / "src" / "features").mkdir(parents=True) + (tmp_path / "src" / "features" / "x.py").write_text("import openai\n", encoding="utf-8") + + result = check.run(_args(tmp_path, adr_dir)) + + violation = next(f for f in result["findings"] if f["kind"] == "verified_violation") + # An active exception annotates the finding; it never hides or downgrades it. + assert violation["confidence"] == "VIOLATED" + assert violation["exception"]["id"] == "EXC-0001" + assert violation["exception"]["owner"] == "YangSeungHyun" + assert violation["exception"]["expiry"] == "2999-12-31" + + +def test_expired_exception_is_not_applied(tmp_path): + _init_repo(tmp_path) + adr_dir = tmp_path / "docs" / "decisions" + adr_dir.mkdir(parents=True) + (adr_dir / "0001-use-a-provider-port.md").write_text(ACCEPTED_ADR_WITH_RULE, encoding="utf-8") + _git(["add", "-A"], tmp_path) + _git(["commit", "-q", "-m", "init"], tmp_path) + _write_exception(adr_dir, expiry="2020-01-01") + + (tmp_path / "src" / "features").mkdir(parents=True) + (tmp_path / "src" / "features" / "x.py").write_text("import openai\n", encoding="utf-8") + + result = check.run(_args(tmp_path, adr_dir)) + + violation = next(f for f in result["findings"] if f["kind"] == "verified_violation") + assert "exception" not in violation + + +def test_exception_outside_scope_is_not_applied(tmp_path): + _init_repo(tmp_path) + adr_dir = tmp_path / "docs" / "decisions" + adr_dir.mkdir(parents=True) + (adr_dir / "0001-use-a-provider-port.md").write_text(ACCEPTED_ADR_WITH_RULE, encoding="utf-8") + _git(["add", "-A"], tmp_path) + _git(["commit", "-q", "-m", "init"], tmp_path) + _write_exception(adr_dir, scope=["src/features/legacy/**"]) + + (tmp_path / "src" / "features").mkdir(parents=True) + (tmp_path / "src" / "features" / "x.py").write_text("import openai\n", encoding="utf-8") + + result = check.run(_args(tmp_path, adr_dir)) + + violation = next(f for f in result["findings"] if f["kind"] == "verified_violation") + assert "exception" not in violation + + +def test_malformed_exception_file_degrades_to_warning(tmp_path): + _init_repo(tmp_path) + adr_dir = tmp_path / "docs" / "decisions" + adr_dir.mkdir(parents=True) + (adr_dir / "0001-use-a-provider-port.md").write_text(ACCEPTED_ADR_WITH_RULE, encoding="utf-8") + _git(["add", "-A"], tmp_path) + _git(["commit", "-q", "-m", "init"], tmp_path) + exceptions_dir = adr_dir / "exceptions" + exceptions_dir.mkdir(parents=True) + (exceptions_dir / "0001.json").write_text("{not valid json", encoding="utf-8") + + (tmp_path / "src" / "features").mkdir(parents=True) + (tmp_path / "src" / "features" / "x.py").write_text("import openai\n", encoding="utf-8") + + result = check.run(_args(tmp_path, adr_dir)) + + assert result["ok"] is True + assert any(w["code"] == "BAD_EXCEPTION" for w in result["warnings"]) + violation = next(f for f in result["findings"] if f["kind"] == "verified_violation") + assert "exception" not in violation def test_related_when_rule_present_but_does_not_fire(tmp_path): @@ -103,6 +204,7 @@ def test_related_when_rule_present_but_does_not_fire(tmp_path): finding = next(f for f in result["findings"] if f["adr_id"] == "ADR-0001") assert finding["kind"] == "related" + assert finding["confidence"] == "VERIFIED" def test_no_applicable_constraint_when_adr_has_no_constraints_block(tmp_path): @@ -119,6 +221,7 @@ def test_no_applicable_constraint_when_adr_has_no_constraints_block(tmp_path): finding = next(f for f in result["findings"] if f["adr_id"] == "ADR-0002") assert finding["kind"] == "no_applicable_constraint" + assert finding["confidence"] == "UNVERIFIABLE" def test_adr_with_no_affected_path_overlap_produces_no_finding(tmp_path): @@ -173,6 +276,59 @@ def test_unreadable_adr_file_degrades_to_warning_and_keeps_other_findings(tmp_pa assert any(w["code"] == "BAD_FRONTMATTER" and w["file"] == "0010-invalid-utf8.md" for w in result["warnings"]) +def test_schema_invalid_adr_is_visible_as_a_warning_instead_of_silent_skip(tmp_path): + _init_repo(tmp_path) + adr_dir = tmp_path / "docs" / "decisions" + adr_dir.mkdir(parents=True) + invalid = ACCEPTED_ADR_WITH_RULE.replace( + "affected_paths:\n - src/features/", "affected_paths: src/features/" + ) + (adr_dir / "0001-use-a-provider-port.md").write_text(invalid, encoding="utf-8") + _git(["add", "-A"], tmp_path) + _git(["commit", "-q", "-m", "init"], tmp_path) + (tmp_path / "src" / "features").mkdir(parents=True) + (tmp_path / "src" / "features" / "결제.py").write_text( + "import openai\n", encoding="utf-8" + ) + + result = check.run(_args(tmp_path, adr_dir)) + + assert result["ok"] is True + assert result["findings"] == [] + warning = next(w for w in result["warnings"] if w["code"] == "SCHEMA_ERROR") + assert warning["file"] == "0001-use-a-provider-port.md" + assert "affected_paths" in warning["detail"] + + +def test_deleted_tracked_file_does_not_satisfy_file_must_exist(tmp_path): + _init_repo(tmp_path) + adr_dir = tmp_path / "docs" / "decisions" + adr_dir.mkdir(parents=True) + adr = ACCEPTED_ADR_WITH_RULE.replace( + " - id: no-provider-sdk-in-feature\n" + " kind: forbidden_import\n" + " paths: [\"src/features/**\"]\n" + " pattern: [\"openai\"]\n", + " - id: registry-must-exist\n" + " kind: file_must_exist\n" + " paths: [\"src/features/registry.py\"]\n" + " pattern: []\n", + ) + (adr_dir / "0001-use-a-provider-port.md").write_text(adr, encoding="utf-8") + (tmp_path / "src" / "features").mkdir(parents=True) + registry = tmp_path / "src" / "features" / "registry.py" + registry.write_text("REGISTRY = {}\n", encoding="utf-8") + _git(["add", "-A"], tmp_path) + _git(["commit", "-q", "-m", "init"], tmp_path) + registry.unlink() + + result = check.run(_args(tmp_path, adr_dir)) + + violation = next(f for f in result["findings"] if f.get("rule_id") == "registry-must-exist") + assert violation["kind"] == "verified_violation" + assert violation["evidence"] == {"missing_paths": ["src/features/registry.py"]} + + ACCEPTED_ADR_WITH_VERIFICATION = """--- id: ADR-0003 title: Add event replay @@ -230,6 +386,31 @@ def test_review_required_when_verification_reference_is_removed(tmp_path): finding = next(f for f in result["findings"] if f["kind"] == "review_required") assert finding["adr_id"] == "ADR-0003" assert "src/events/replay.py" in finding["evidence"]["unrealized_paths"] + assert finding["confidence"] == "UNVERIFIABLE" + + +def test_review_required_when_confirmed_path_is_renamed(tmp_path): + _init_repo(tmp_path) + adr_dir = tmp_path / "docs/decisions" + adr_dir.mkdir(parents=True) + adr = ACCEPTED_ADR_WITH_VERIFICATION.replace( + "src/events/replay.py", "src/old.py" + ).replace("src/events/", "src/old.py") + (adr_dir / "0003-add-event-replay.md").write_text(adr, encoding="utf-8") + (tmp_path / "src").mkdir() + (tmp_path / "src/old.py").write_text("def replay(): pass\n", encoding="utf-8") + _git(["add", "-A"], tmp_path) + _git(["commit", "-q", "-m", "init"], tmp_path) + _git(["mv", "src/old.py", "src/new.py"], tmp_path) + + args = _args(tmp_path, adr_dir) + args.staged = True + args.uncommitted = False + result = check.run(args) + + finding = next(f for f in result["findings"] if f["kind"] == "review_required") + assert finding["adr_id"] == "ADR-0003" + assert finding["evidence"]["unrealized_paths"] == ["src/old.py"] def test_superseded_reference_fires_on_affected_path_overlap(tmp_path): @@ -250,6 +431,7 @@ def test_superseded_reference_fires_on_affected_path_overlap(tmp_path): assert finding["kind"] == "verified_violation" assert finding["evidence"]["superseded_by"] == "ADR-0005" assert finding["resolutions"] == RESOLUTIONS + assert finding["confidence"] == "VIOLATED" # --- Regression tests for the final whole-branch review of Plan 3 (CHECK) --- @@ -314,6 +496,7 @@ def test_review_required_fires_on_a_confirmation_section_written_by_create(tmp_p finding = next(f for f in result["findings"] if f["kind"] == "review_required") assert finding["adr_id"] == "ADR-0003" assert "src/events/replay.py" in finding["evidence"]["unrealized_paths"] + assert finding["confidence"] == "UNVERIFIABLE" def test_review_required_fires_when_a_confirmation_path_was_never_created(tmp_path): @@ -343,6 +526,57 @@ def test_review_required_fires_when_a_confirmation_path_was_never_created(tmp_pa assert finding["evidence"]["unrealized_paths"] == ["tests/test_replay.py"] +def test_ignored_artifact_does_not_satisfy_file_must_exist(tmp_path): + _init_repo(tmp_path) + adr_dir = tmp_path / "docs/decisions" + adr_dir.mkdir(parents=True) + adr = """--- +id: ADR-0011 +title: Require generated policy manifest +status: accepted +date: 2026-08-03 +decision_makers: [] +related: [] +affected_paths: + - src/ +tags: [] +retrospective: false +--- + +# Require generated policy manifest + +## Implementation Constraints + +```yaml +constraints: + - id: generated-policy-must-be-versioned + kind: file_must_exist + paths: ["build/generated-policy.json"] + pattern: [] + severity: major + message: "The generated policy manifest must be versioned." +``` +""" + (adr_dir / "0011-require-generated-policy-manifest.md").write_text( + adr, encoding="utf-8" + ) + (tmp_path / ".gitignore").write_text("build/\n", encoding="utf-8") + (tmp_path / "src").mkdir() + (tmp_path / "src/app.py").write_text("value = 1\n", encoding="utf-8") + _git(["add", "-A"], tmp_path) + _git(["commit", "-q", "-m", "init"], tmp_path) + + (tmp_path / "build").mkdir() + (tmp_path / "build/generated-policy.json").write_text("{}\n", encoding="utf-8") + (tmp_path / "src/app.py").write_text("value = 2\n", encoding="utf-8") + + result = check.run(_args(tmp_path, adr_dir)) + + violation = next(f for f in result["findings"] if f["kind"] == "verified_violation") + assert violation["rule_id"] == "generated-policy-must-be-versioned" + assert violation["evidence"]["missing_paths"] == ["build/generated-policy.json"] + + STRING_AFFECTED_PATHS_ADR = """--- id: ADR-0020 title: Scalar affected paths diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py new file mode 100644 index 0000000..de15b7b --- /dev/null +++ b/tests/unit/test_config.py @@ -0,0 +1,48 @@ +import json + +import pytest + +from scripts.core.config import ConfigError, load_repository_config, resolve_locale + + +def test_missing_config_is_empty(tmp_path): + assert load_repository_config(tmp_path) == {} + + +def test_loads_versioned_repository_locale(tmp_path): + (tmp_path / ".adr-toolkit.json").write_text( + json.dumps({"schema_version": 1, "locale": "ko"}), encoding="utf-8" + ) + + assert load_repository_config(tmp_path)["locale"] == "ko" + + +@pytest.mark.parametrize( + "payload", + [ + {"schema_version": 2, "locale": "ko"}, + {"schema_version": 1, "locale": "xx"}, + {"schema_version": 1, "locale": "ko", "extra": True}, + ], +) +def test_invalid_config_fails_visibly(tmp_path, payload): + (tmp_path / ".adr-toolkit.json").write_text( + json.dumps(payload), encoding="utf-8" + ) + + with pytest.raises(ConfigError): + load_repository_config(tmp_path) + + +def test_locale_precedence_is_cli_then_draft_then_repo_then_english(tmp_path): + config_path = tmp_path / ".adr-toolkit.json" + config_path.write_text( + json.dumps({"schema_version": 1, "locale": "ko"}), encoding="utf-8" + ) + + assert resolve_locale(cli_locale="ja", draft_locale="fr", root=tmp_path) == "ja" + assert resolve_locale(cli_locale=None, draft_locale="fr", root=tmp_path) == "fr" + assert resolve_locale(cli_locale=None, draft_locale=None, root=tmp_path) == "ko" + + config_path.unlink() + assert resolve_locale(cli_locale=None, draft_locale=None, root=tmp_path) == "en" diff --git a/tests/unit/test_conflict_rules_reference.py b/tests/unit/test_conflict_rules_reference.py index 1280c1b..ad88d7b 100644 --- a/tests/unit/test_conflict_rules_reference.py +++ b/tests/unit/test_conflict_rules_reference.py @@ -25,3 +25,17 @@ def test_reference_documents_the_five_resolutions(): text = REFERENCE.read_text(encoding="utf-8") for resolution in ["fix_code", "supersede_adr", "adjust_scope", "register_exception", "false_positive"]: assert resolution in text + + +def test_reference_distinguishes_regex_and_glob_patterns(): + text = REFERENCE.read_text(encoding="utf-8") + assert "regular expression" in text + assert "glob" in text + assert "forbidden_import" in text and "required_path" in text + + +def test_reference_never_claims_full_architecture_verification(): + text = REFERENCE.read_text(encoding="utf-8") + for label in ["VERIFIED", "VIOLATED", "UNVERIFIABLE", "NOT_APPLICABLE"]: + assert label in text + assert "does not certify the entire architecture" in text diff --git a/tests/unit/test_create.py b/tests/unit/test_create.py index 1c72a37..0469cb0 100644 --- a/tests/unit/test_create.py +++ b/tests/unit/test_create.py @@ -4,6 +4,7 @@ from types import SimpleNamespace from scripts.commands import create +from scripts.core import frontmatter as fm def _write_draft(tmp_path, **overrides): @@ -18,12 +19,24 @@ def _write_draft(tmp_path, **overrides): return draft_path +def _args(tmp_path, draft_path, adr_dir, *, dry_run=False, locale=None, slug=None): + return SimpleNamespace( + interactive=False, + input=str(draft_path), + dir=str(adr_dir), + root=str(tmp_path), + locale=locale, + slug=slug, + dry_run=dry_run, + ) + + def test_creates_file_with_next_id_and_valid_frontmatter(tmp_path): adr_dir = tmp_path / "docs" / "decisions" adr_dir.mkdir(parents=True) draft_path = _write_draft(tmp_path) - result = create.run(SimpleNamespace(input=str(draft_path), dir=str(adr_dir), dry_run=False)) + result = create.run(_args(tmp_path, draft_path, adr_dir)) assert result["ok"] is True assert result["id"] == "ADR-0001" @@ -38,7 +51,7 @@ def test_next_id_accounts_for_existing_adrs(tmp_path): (adr_dir / "0001-existing.md").write_text("x", encoding="utf-8") draft_path = _write_draft(tmp_path) - result = create.run(SimpleNamespace(input=str(draft_path), dir=str(adr_dir), dry_run=False)) + result = create.run(_args(tmp_path, draft_path, adr_dir)) assert result["id"] == "ADR-0002" @@ -48,7 +61,7 @@ def test_dry_run_does_not_write_file(tmp_path): adr_dir.mkdir(parents=True) draft_path = _write_draft(tmp_path) - result = create.run(SimpleNamespace(input=str(draft_path), dir=str(adr_dir), dry_run=True)) + result = create.run(_args(tmp_path, draft_path, adr_dir, dry_run=True)) assert result["ok"] is True assert result["dry_run"] is True @@ -61,7 +74,7 @@ def test_missing_required_draft_field_is_an_error(tmp_path): draft_path = tmp_path / "draft.json" draft_path.write_text(json.dumps({"title": "Missing status and body"}), encoding="utf-8") - result = create.run(SimpleNamespace(input=str(draft_path), dir=str(adr_dir), dry_run=False)) + result = create.run(_args(tmp_path, draft_path, adr_dir)) assert result["ok"] is False assert result["errors"][0]["code"] == "MISSING_DRAFT_FIELD" @@ -72,7 +85,7 @@ def test_dry_run_against_nonexistent_dir_does_not_create_dir(tmp_path): # Note: adr_dir is NOT created beforehand draft_path = _write_draft(tmp_path) - result = create.run(SimpleNamespace(input=str(draft_path), dir=str(adr_dir), dry_run=True)) + result = create.run(_args(tmp_path, draft_path, adr_dir, dry_run=True)) assert result["ok"] is True assert result["dry_run"] is True @@ -84,7 +97,7 @@ def test_dry_run_response_includes_id(tmp_path): adr_dir.mkdir(parents=True) draft_path = _write_draft(tmp_path) - result = create.run(SimpleNamespace(input=str(draft_path), dir=str(adr_dir), dry_run=True)) + result = create.run(_args(tmp_path, draft_path, adr_dir, dry_run=True)) assert result["ok"] is True assert result["dry_run"] is True @@ -92,27 +105,26 @@ def test_dry_run_response_includes_id(tmp_path): assert result["id"] == "ADR-0001" -def test_empty_slug_title_is_an_error_and_does_not_write_file(tmp_path): +def test_non_ascii_title_uses_portable_fallback_filename(tmp_path): adr_dir = tmp_path / "docs" / "decisions" adr_dir.mkdir(parents=True) draft_path = _write_draft(tmp_path, title="아키텍처 결정 기록") - result = create.run(SimpleNamespace(input=str(draft_path), dir=str(adr_dir), dry_run=False)) + result = create.run(_args(tmp_path, draft_path, adr_dir)) - assert result["ok"] is False - assert result["errors"][0]["code"] == "EMPTY_SLUG" - assert list(adr_dir.iterdir()) == [] + assert result["ok"] is True + assert Path(result["created"]).name == "0001-decision.md" -def test_empty_slug_title_dry_run_is_also_an_error(tmp_path): +def test_non_ascii_title_dry_run_reports_portable_fallback(tmp_path): adr_dir = tmp_path / "docs" / "decisions" adr_dir.mkdir(parents=True) draft_path = _write_draft(tmp_path, title="아키텍처 결정 기록") - result = create.run(SimpleNamespace(input=str(draft_path), dir=str(adr_dir), dry_run=True)) + result = create.run(_args(tmp_path, draft_path, adr_dir, dry_run=True)) - assert result["ok"] is False - assert result["errors"][0]["code"] == "EMPTY_SLUG" + assert result["ok"] is True + assert result["would_create"].endswith("0001-decision.md") assert list(adr_dir.iterdir()) == [] @@ -121,7 +133,7 @@ def test_missing_draft_file_returns_json_error(tmp_path): adr_dir.mkdir(parents=True) missing_path = tmp_path / "does-not-exist.json" - result = create.run(SimpleNamespace(input=str(missing_path), dir=str(adr_dir), dry_run=False)) + result = create.run(_args(tmp_path, missing_path, adr_dir)) assert result["ok"] is False assert result["errors"][0]["code"] == "DRAFT_FILE_NOT_FOUND" @@ -133,7 +145,81 @@ def test_malformed_json_draft_file_returns_json_error(tmp_path): bad_json_path = tmp_path / "draft.json" bad_json_path.write_text("{not valid json", encoding="utf-8") - result = create.run(SimpleNamespace(input=str(bad_json_path), dir=str(adr_dir), dry_run=False)) + result = create.run(_args(tmp_path, bad_json_path, adr_dir)) assert result["ok"] is False assert result["errors"][0]["code"] == "DRAFT_FILE_INVALID_JSON" + + +def test_input_create_uses_repo_locale_and_semantic_slug(tmp_path): + (tmp_path / ".adr-toolkit.json").write_text( + json.dumps({"schema_version": 1, "locale": "ko"}), encoding="utf-8" + ) + adr_dir = tmp_path / "docs/decisions" + draft_path = _write_draft( + tmp_path, + title="결제 시스템 분리", + slug="separate-payment-system", + body="# 결제 시스템 분리\n", + ) + + result = create.run(_args(tmp_path, draft_path, adr_dir)) + + assert result["created"].endswith("0001-separate-payment-system.md") + assert "locale: ko" in Path(result["created"]).read_text(encoding="utf-8") + + +def test_relative_adr_directory_is_resolved_against_root(tmp_path, monkeypatch): + caller = tmp_path / "caller" + repo = tmp_path / "repo" + caller.mkdir() + repo.mkdir() + draft_path = _write_draft(repo) + monkeypatch.chdir(caller) + + result = create.run(_args(repo, draft_path, Path("docs/decisions"))) + + assert result["ok"] is True + assert Path(result["created"]).parent == repo / "docs/decisions" + assert not (caller / "docs/decisions").exists() + + +def test_cli_locale_overrides_draft_and_repo(tmp_path): + result = _run_with_locales(tmp_path, repo="ko", draft="fr", cli="ja") + data, _ = fm.parse(Path(result["created"]).read_text(encoding="utf-8")) + assert data["locale"] == "ja" + + +def test_draft_locale_overrides_repo(tmp_path): + result = _run_with_locales(tmp_path, repo="ko", draft="fr", cli=None) + data, _ = fm.parse(Path(result["created"]).read_text(encoding="utf-8")) + assert data["locale"] == "fr" + + +def _run_with_locales(tmp_path, *, repo, draft, cli): + (tmp_path / ".adr-toolkit.json").write_text( + json.dumps({"schema_version": 1, "locale": repo}), encoding="utf-8" + ) + adr_dir = tmp_path / "docs/decisions" + draft_path = _write_draft(tmp_path, locale=draft) + return create.run(_args(tmp_path, draft_path, adr_dir, locale=cli)) + + +def test_invalid_explicit_slug_returns_structured_error(tmp_path): + adr_dir = tmp_path / "docs/decisions" + draft_path = _write_draft(tmp_path, title="한국어 제목", slug="Bad Slug") + + result = create.run(_args(tmp_path, draft_path, adr_dir)) + + assert result["ok"] is False + assert result["errors"][0]["code"] == "INVALID_SLUG" + + +def test_conflicting_cli_and_draft_slugs_return_structured_error(tmp_path): + adr_dir = tmp_path / "docs/decisions" + draft_path = _write_draft(tmp_path, slug="draft-slug") + + result = create.run(_args(tmp_path, draft_path, adr_dir, slug="cli-slug")) + + assert result["ok"] is False + assert result["errors"][0]["code"] == "CONFLICTING_SLUG_INPUT" diff --git a/tests/unit/test_create_interactive.py b/tests/unit/test_create_interactive.py index 1cb7931..6981a86 100644 --- a/tests/unit/test_create_interactive.py +++ b/tests/unit/test_create_interactive.py @@ -1,8 +1,22 @@ from types import SimpleNamespace +import pytest + from scripts.commands import create +INTERACTIVE_LOCALE_CASES = { + "en": ("Title of the decision?", "Context and Problem Statement"), + "ko": ("결정의 제목은 무엇인가요?", "맥락 및 문제 설명"), + "ja": ("決定のタイトルは何ですか?", "コンテキストと問題"), + "zh": ("决策标题是什么?", "背景与问题陈述"), + "fr": ("Quel est le titre de la décision ?", "Contexte et problème"), + "es": ("¿Cuál es el título de la decisión?", "Contexto y problema"), + "de": ("Wie lautet der Titel der Entscheidung?", "Kontext und Problemstellung"), + "pt-BR": ("Qual é o título da decisão?", "Contexto e problema"), +} + + def test_gather_draft_interactively_builds_valid_minimal_body(): answers = iter([ "Use Kafka for domain events", @@ -15,7 +29,7 @@ def test_gather_draft_interactively_builds_valid_minimal_body(): "no direct SDK calls appear outside the events module", "message volume exceeds what a single queue can handle", ]) - draft = create.gather_draft_interactively(input_fn=lambda _prompt: next(answers)) + draft = create.gather_draft_interactively("en", input_fn=lambda _prompt: next(answers)) assert draft["title"] == "Use Kafka for domain events" assert draft["status"] == "proposed" @@ -23,6 +37,21 @@ def test_gather_draft_interactively_builds_valid_minimal_body(): assert "Kafka" in draft["body"] +def test_gather_draft_interactively_keeps_english_default_for_existing_callers(): + answers = iter([ + "Use Postgres", "Persistence is required", "SQLite, Postgres", + "Postgres", "it scales", "durability", "operations", "integration test", + "requirements change", + ]) + + draft = create.gather_draft_interactively( + input_fn=lambda _prompt: next(answers) + ) + + assert draft["locale"] == "en" + assert "## Context and Problem Statement" in draft["body"] + + def test_create_run_supports_interactive_mode_end_to_end(tmp_path, monkeypatch): adr_dir = tmp_path / "docs" / "decisions" adr_dir.mkdir(parents=True) @@ -33,7 +62,10 @@ def test_create_run_supports_interactive_mode_end_to_end(tmp_path, monkeypatch): ]) monkeypatch.setattr("builtins.input", lambda _prompt="": next(answers)) - result = create.run(SimpleNamespace(interactive=True, input=None, dir=str(adr_dir), dry_run=False)) + result = create.run(SimpleNamespace( + interactive=True, input=None, dir=str(adr_dir), root=str(tmp_path), + locale=None, slug=None, dry_run=False, + )) assert result["ok"] is True assert result["id"] == "ADR-0001" @@ -43,7 +75,43 @@ def test_create_run_without_input_or_interactive_is_an_error(tmp_path): adr_dir = tmp_path / "docs" / "decisions" adr_dir.mkdir(parents=True) - result = create.run(SimpleNamespace(interactive=False, input=None, dir=str(adr_dir), dry_run=False)) + result = create.run(SimpleNamespace( + interactive=False, input=None, dir=str(adr_dir), root=str(tmp_path), + locale=None, slug=None, dry_run=False, + )) assert result["ok"] is False assert result["errors"][0]["code"] == "MISSING_INPUT_OR_INTERACTIVE" + + +def test_gather_draft_interactively_localizes_korean_structure(): + answers = iter([ + "결제 시스템 분리", "장애 격리가 필요하다", "유지, 분리", "분리", + "장애를 격리한다", "독립 배포", "운영 복잡성", "CI 경계 검사", + "운영 비용이 편익을 초과할 때", + ]) + + draft = create.gather_draft_interactively( + "ko", input_fn=lambda _prompt: next(answers) + ) + + assert "## 맥락 및 문제 설명" in draft["body"] + assert "장애 격리가 필요하다" in draft["body"] + assert draft["locale"] == "ko" + + +@pytest.mark.parametrize("locale", INTERACTIVE_LOCALE_CASES) +def test_interactive_create_prompts_and_renders_every_supported_locale(locale, capsys): + expected_prompt, expected_heading = INTERACTIVE_LOCALE_CASES[locale] + answers = iter([ + "Decision title", "Problem", "Option A, Option B", "Option A", + "Rationale", "Benefit", "Cost", "CI evidence", "Trigger", + ]) + + draft = create.gather_draft_interactively( + locale, input_fn=lambda _prompt: next(answers) + ) + + assert expected_prompt in capsys.readouterr().err + assert f"## {expected_heading}" in draft["body"] + assert draft["locale"] == locale diff --git a/tests/unit/test_diff.py b/tests/unit/test_diff.py index bc26d2f..a2b0825 100644 --- a/tests/unit/test_diff.py +++ b/tests/unit/test_diff.py @@ -94,6 +94,21 @@ def test_uncommitted_mode_includes_untracked_new_files(tmp_path): assert "import openai" in entry["added_lines"] +def test_uncommitted_mode_preserves_unicode_untracked_path_and_content(tmp_path): + _init_repo(tmp_path) + unicode_path = tmp_path / "src" / "결제.py" + unicode_path.parent.mkdir() + unicode_path.write_text("import openai\n", encoding="utf-8") + + result = diff.run(SimpleNamespace( + root=str(tmp_path), staged=False, uncommitted=True, since=None, + )) + + entry = next(f for f in result["files"] if f["path"] == "src/결제.py") + assert entry["change_type"] == "added" + assert entry["added_lines"] == ["import openai"] + + def test_since_value_cannot_smuggle_a_git_option_that_writes_a_file(tmp_path): """CHECK is read-only: a `--since` starting with `-` must not reach git as an option.""" _init_repo(tmp_path) @@ -109,3 +124,91 @@ def test_since_value_cannot_smuggle_a_git_option_that_writes_a_file(tmp_path): # git's `--output` writes to the literal argument, i.e. "..HEAD". assert not target.exists() assert not (tmp_path / f"{target.name}..HEAD").exists() + + +def test_patch_subprocess_failure_is_not_reported_as_clean(tmp_path, monkeypatch): + _init_repo(tmp_path) + real_run = diff.subprocess.run + diff_calls = 0 + + def fake_run(command, **kwargs): + nonlocal diff_calls + if "diff" in command: + diff_calls += 1 + if diff_calls == 2: + return subprocess.CompletedProcess(command, 2, "", "patch failed") + return real_run(command, **kwargs) + + monkeypatch.setattr(diff.subprocess, "run", fake_run) + + result = diff.run(SimpleNamespace( + root=str(tmp_path), staged=False, uncommitted=True, since=None, + )) + + assert result["ok"] is False + assert result["errors"][0]["code"] == "GIT_DIFF_FAILED" + assert "patch failed" in result["errors"][0]["detail"] + + +def test_untracked_listing_failure_is_not_reported_as_clean(tmp_path, monkeypatch): + _init_repo(tmp_path) + real_run = diff.subprocess.run + + def fake_run(command, **kwargs): + if "ls-files" in command: + return subprocess.CompletedProcess(command, 2, "", "listing failed") + return real_run(command, **kwargs) + + monkeypatch.setattr(diff.subprocess, "run", fake_run) + + result = diff.run(SimpleNamespace( + root=str(tmp_path), staged=False, uncommitted=True, since=None, + )) + + assert result["ok"] is False + assert result["errors"][0]["code"] == "GIT_LS_FILES_FAILED" + assert "listing failed" in result["errors"][0]["detail"] + + +def test_parse_rename_preserves_old_and_new_paths(): + files = diff._parse_name_status("R100\tsrc/old.py\tsrc/new.py\n") + + assert [(item["path"], item["change_type"]) for item in files] == [ + ("src/old.py", "deleted"), + ("src/new.py", "added"), + ] + + +def test_staged_rename_reports_deletion_and_addition(tmp_path): + _init_repo(tmp_path) + (tmp_path / "src").mkdir() + (tmp_path / "src/old.py").write_text("one\ntwo\nthree\n", encoding="utf-8") + _git(["add", "src/old.py"], tmp_path) + _git(["commit", "-q", "-m", "add old path"], tmp_path) + _git(["mv", "src/old.py", "src/new.py"], tmp_path) + + result = diff.run(SimpleNamespace( + root=str(tmp_path), staged=True, uncommitted=False, since=None, + )) + + changes = {(item["path"], item["change_type"]) for item in result["files"]} + assert ("src/old.py", "deleted") in changes + assert ("src/new.py", "added") in changes + + +def test_rename_patch_lines_are_attached_to_the_corresponding_path(): + files = diff._parse_name_status("R090\tsrc/old.py\tsrc/new.py\n") + patch = ( + "diff --git a/src/old.py b/src/new.py\n" + "--- a/src/old.py\n" + "+++ b/src/new.py\n" + "@@ -1 +1 @@\n" + "-old value\n" + "+new value\n" + ) + + diff._attach_line_content(files, patch) + + by_path = {item["path"]: item for item in files} + assert by_path["src/old.py"]["removed_lines"] == ["old value"] + assert by_path["src/new.py"]["added_lines"] == ["new value"] diff --git a/tests/unit/test_exception_command.py b/tests/unit/test_exception_command.py new file mode 100644 index 0000000..6edaa82 --- /dev/null +++ b/tests/unit/test_exception_command.py @@ -0,0 +1,87 @@ +import json +from types import SimpleNamespace + +from scripts.commands import exception + +VALID_DRAFT = { + "adr_id": "ADR-0001", + "rule_id": "no-provider-sdk-in-feature", + "owner": "YangSeungHyun", + "reason": "Vendor migration is in progress; tracked in issue #42.", + "scope": ["src/features/legacy/**"], + "expiry": "2026-12-31", +} + + +def _args(tmp_path, draft, **overrides): + draft_path = tmp_path / "draft.json" + draft_path.write_text(json.dumps(draft), encoding="utf-8") + defaults = dict( + input=str(draft_path), dir="docs/decisions", root=str(tmp_path), dry_run=False, + ) + defaults.update(overrides) + return SimpleNamespace(**defaults) + + +def test_creates_first_exception_as_exc_0001(tmp_path): + result = exception.run(_args(tmp_path, VALID_DRAFT)) + + assert result["ok"] is True + assert result["id"] == "EXC-0001" + created = tmp_path / "docs" / "decisions" / "exceptions" / "0001.json" + assert created.is_file() + data = json.loads(created.read_text(encoding="utf-8")) + assert data["id"] == "EXC-0001" + assert data["adr_id"] == "ADR-0001" + assert data["owner"] == "YangSeungHyun" + assert data["created"] # auto-stamped + + +def test_second_exception_gets_next_sequential_id(tmp_path): + exception.run(_args(tmp_path, VALID_DRAFT)) + second_draft = {**VALID_DRAFT, "rule_id": "some-other-rule"} + + result = exception.run(_args(tmp_path, second_draft)) + + assert result["id"] == "EXC-0002" + assert (tmp_path / "docs" / "decisions" / "exceptions" / "0002.json").is_file() + + +def test_missing_required_field_fails_before_writing(tmp_path): + bad_draft = {k: v for k, v in VALID_DRAFT.items() if k != "owner"} + + result = exception.run(_args(tmp_path, bad_draft)) + + assert result["ok"] is False + assert result["errors"][0]["code"] == "MISSING_DRAFT_FIELD" + assert not (tmp_path / "docs" / "decisions" / "exceptions").exists() + + +def test_present_but_invalid_field_fails_with_schema_error(tmp_path): + bad_draft = {**VALID_DRAFT, "expiry": "not-a-date"} + + result = exception.run(_args(tmp_path, bad_draft)) + + assert result["ok"] is False + assert result["errors"][0]["code"] == "SCHEMA_ERROR" + assert not (tmp_path / "docs" / "decisions" / "exceptions").exists() + + +def test_dry_run_validates_without_writing(tmp_path): + result = exception.run(_args(tmp_path, VALID_DRAFT, dry_run=True)) + + assert result["ok"] is True + assert result["dry_run"] is True + assert not (tmp_path / "docs" / "decisions" / "exceptions").exists() + + +def test_missing_input_file_is_an_explicit_error(tmp_path): + args = SimpleNamespace( + input=str(tmp_path / "nope.json"), dir="docs/decisions", root=str(tmp_path), + dry_run=False, + ) + + result = exception.run(args) + + assert result["ok"] is False + assert result["errors"][0]["code"] == "DRAFT_FILE_NOT_FOUND" diff --git a/tests/unit/test_exceptions.py b/tests/unit/test_exceptions.py new file mode 100644 index 0000000..dea7acb --- /dev/null +++ b/tests/unit/test_exceptions.py @@ -0,0 +1,94 @@ +from datetime import date + +from scripts.core.exceptions import applies_to, is_expired, validate_exception + +VALID = { + "id": "EXC-0001", + "adr_id": "ADR-0001", + "rule_id": "no-provider-sdk-in-feature", + "owner": "YangSeungHyun", + "reason": "Vendor migration is in progress; tracked in issue #42.", + "scope": ["src/features/legacy/**"], + "expiry": "2026-12-31", + "created": "2026-08-30", +} + + +def test_valid_exception_has_no_errors(): + assert validate_exception(VALID) == [] + + +def test_missing_required_field_is_reported(): + data = {k: v for k, v in VALID.items() if k != "owner"} + errors = validate_exception(data) + assert any("owner" in e for e in errors) + + +def test_empty_owner_is_rejected(): + data = {**VALID, "owner": " "} + errors = validate_exception(data) + assert any("owner" in e for e in errors) + + +def test_empty_scope_list_is_rejected(): + data = {**VALID, "scope": []} + errors = validate_exception(data) + assert any("scope" in e for e in errors) + + +def test_malformed_expiry_is_rejected(): + data = {**VALID, "expiry": "12/31/2026"} + errors = validate_exception(data) + assert any("expiry" in e for e in errors) + + +def test_malformed_id_is_rejected(): + data = {**VALID, "id": "exception-1"} + errors = validate_exception(data) + assert any("id" in e for e in errors) + + +def test_malformed_adr_id_is_rejected(): + data = {**VALID, "adr_id": "1"} + errors = validate_exception(data) + assert any("adr_id" in e for e in errors) + + +def test_is_expired_true_after_expiry_date(): + assert is_expired("2026-01-01", today=date(2026, 1, 2)) is True + + +def test_is_expired_false_on_expiry_date(): + assert is_expired("2026-01-01", today=date(2026, 1, 1)) is False + + +def test_is_expired_false_before_expiry_date(): + assert is_expired("2026-01-01", today=date(2025, 12, 31)) is False + + +def test_applies_to_matches_adr_rule_and_scope(): + assert applies_to( + VALID, adr_id="ADR-0001", rule_id="no-provider-sdk-in-feature", + file_path="src/features/legacy/old.py", + ) is True + + +def test_applies_to_false_when_adr_id_differs(): + assert applies_to( + VALID, adr_id="ADR-0002", rule_id="no-provider-sdk-in-feature", + file_path="src/features/legacy/old.py", + ) is False + + +def test_applies_to_false_when_rule_id_differs(): + assert applies_to( + VALID, adr_id="ADR-0001", rule_id="some-other-rule", + file_path="src/features/legacy/old.py", + ) is False + + +def test_applies_to_false_when_file_outside_scope(): + assert applies_to( + VALID, adr_id="ADR-0001", rule_id="no-provider-sdk-in-feature", + file_path="src/features/current/new.py", + ) is False diff --git a/tests/unit/test_git_paths.py b/tests/unit/test_git_paths.py new file mode 100644 index 0000000..36273dd --- /dev/null +++ b/tests/unit/test_git_paths.py @@ -0,0 +1,51 @@ +import subprocess + +import pytest + +from scripts.core import git_paths + + +def _git(args, cwd): + subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True) + + +def _init_repo(tmp_path): + _git(["init", "-q", "-b", "master"], tmp_path) + _git(["config", "user.email", "test@example.com"], tmp_path) + _git(["config", "user.name", "Test"], tmp_path) + (tmp_path / "existing.py").write_text("x = 1\n", encoding="utf-8") + _git(["add", "existing.py"], tmp_path) + _git(["commit", "-q", "-m", "initial"], tmp_path) + + +def test_existing_paths_include_tracked_and_untracked_but_not_ignored(tmp_path): + _init_repo(tmp_path) + (tmp_path / ".gitignore").write_text("build/\n", encoding="utf-8") + (tmp_path / "untracked.py").write_text("x = 1\n", encoding="utf-8") + (tmp_path / "build").mkdir() + (tmp_path / "build/generated.py").write_text("x = 1\n", encoding="utf-8") + + paths = git_paths.list_existing_paths(tmp_path) + + assert "existing.py" in paths + assert "untracked.py" in paths + assert "build/generated.py" not in paths + + +def test_existing_paths_excludes_a_tracked_file_deleted_from_worktree(tmp_path): + _init_repo(tmp_path) + (tmp_path / "existing.py").unlink() + + paths = git_paths.list_existing_paths(tmp_path) + + assert "existing.py" not in paths + + +def test_git_path_listing_failure_includes_stderr(tmp_path, monkeypatch): + def fail(command, **kwargs): + return subprocess.CompletedProcess(command, 128, "", "not a repository") + + monkeypatch.setattr(git_paths.subprocess, "run", fail) + + with pytest.raises(git_paths.GitPathsError, match="not a repository"): + git_paths.list_existing_paths(tmp_path) diff --git a/tests/unit/test_globs.py b/tests/unit/test_globs.py index f5464b9..688bab7 100644 --- a/tests/unit/test_globs.py +++ b/tests/unit/test_globs.py @@ -26,3 +26,23 @@ def test_double_star_prefix_matches_any_depth(): def test_question_mark_matches_single_character(): assert globs.match("src/adr-?.md", "src/adr-1.md") assert not globs.match("src/adr-?.md", "src/adr-10.md") + + +def test_path_under_matches_exact_path(): + assert globs.path_under("src/db", "src/db") is True + + +def test_path_under_matches_nested_path(): + assert globs.path_under("src/db/x.py", "src/db") is True + + +def test_path_under_matches_with_trailing_slash_prefix(): + assert globs.path_under("src/db/x.py", "src/db/") is True + + +def test_path_under_respects_directory_boundaries(): + assert globs.path_under("src/db2/file.py", "src/db") is False + + +def test_path_under_false_for_unrelated_path(): + assert globs.path_under("src/other/x.py", "src/db") is False diff --git a/tests/unit/test_graph_command.py b/tests/unit/test_graph_command.py new file mode 100644 index 0000000..c03c378 --- /dev/null +++ b/tests/unit/test_graph_command.py @@ -0,0 +1,120 @@ +from types import SimpleNamespace + +from scripts.commands import graph + + +def _write_adr(adr_dir, filename, *, id_, title, related=None, supersedes=None, superseded_by=None): + lines = [ + "---", + f"id: {id_}", + f"title: {title}", + "status: accepted", + "date: 2026-08-31", + "decision_makers: []", + ] + if related: + lines.append("related:") + lines.extend(f" - {value}" for value in related) + else: + lines.append("related: []") + lines.append("affected_paths: []") + lines.append("tags: []") + lines.append("retrospective: false") + if supersedes: + lines.append("supersedes:") + lines.extend(f" - {value}" for value in supersedes) + if superseded_by: + lines.append(f"superseded_by: {superseded_by}") + lines.extend(["---", "", f"# {title}", ""]) + (adr_dir / filename).write_text("\n".join(lines), encoding="utf-8") + + +def test_graph_command_writes_mermaid_and_svg_exports(tmp_path): + adr_dir = tmp_path / "docs" / "decisions" + adr_dir.mkdir(parents=True) + _write_adr(adr_dir, "0001-old.md", id_="ADR-0001", title="Old", superseded_by="ADR-0002") + _write_adr(adr_dir, "0002-new.md", id_="ADR-0002", title="New", supersedes=["ADR-0001"]) + + result = graph.run(SimpleNamespace( + dir="docs/decisions", + root=str(tmp_path), + output=None, + format="both", + )) + + assert result["ok"] is True + assert result["count"] == 1 + assert result["outputs"] == [ + str(adr_dir / "relationships.mmd"), + str(adr_dir / "relationships.svg"), + ] + assert "ADR_0002 -->|supersedes| ADR_0001" in ( + adr_dir / "relationships.mmd" + ).read_text(encoding="utf-8") + assert "ADR-0002 supersedes ADR-0001" in ( + adr_dir / "relationships.svg" + ).read_text(encoding="utf-8") + + +def test_graph_command_can_write_only_mermaid_to_custom_output(tmp_path): + adr_dir = tmp_path / "docs" / "decisions" + adr_dir.mkdir(parents=True) + _write_adr(adr_dir, "0001-a.md", id_="ADR-0001", title="A", related=["ADR-0002"]) + _write_adr(adr_dir, "0002-b.md", id_="ADR-0002", title="B") + output = tmp_path / "graph.mmd" + + result = graph.run(SimpleNamespace( + dir=str(adr_dir), + root=str(tmp_path), + output=str(output), + format="mermaid", + )) + + assert result["ok"] is True + assert result["outputs"] == [str(output)] + assert "ADR_0001 -.->|related| ADR_0002" in output.read_text(encoding="utf-8") + + +def test_graph_command_uses_custom_output_as_prefix_for_both_formats(tmp_path): + adr_dir = tmp_path / "docs" / "decisions" + adr_dir.mkdir(parents=True) + _write_adr(adr_dir, "0001-a.md", id_="ADR-0001", title="A", related=["ADR-0002"]) + _write_adr(adr_dir, "0002-b.md", id_="ADR-0002", title="B") + + result = graph.run(SimpleNamespace( + dir="docs/decisions", + root=str(tmp_path), + output="build/adr-relationships", + format="both", + )) + + assert result["ok"] is True + assert result["outputs"] == [ + str(tmp_path / "build" / "adr-relationships.mmd"), + str(tmp_path / "build" / "adr-relationships.svg"), + ] + assert (tmp_path / "build" / "adr-relationships.mmd").is_file() + assert (tmp_path / "build" / "adr-relationships.svg").is_file() + + +def test_graph_command_resolves_relative_single_output_against_root(tmp_path, monkeypatch): + caller = tmp_path / "caller" + repo = tmp_path / "repo" + adr_dir = repo / "docs" / "decisions" + caller.mkdir() + adr_dir.mkdir(parents=True) + _write_adr(adr_dir, "0001-a.md", id_="ADR-0001", title="A", related=["ADR-0002"]) + _write_adr(adr_dir, "0002-b.md", id_="ADR-0002", title="B") + monkeypatch.chdir(caller) + + result = graph.run(SimpleNamespace( + dir="docs/decisions", + root=str(repo), + output="docs/decisions/custom.svg", + format="svg", + )) + + assert result["ok"] is True + assert result["outputs"] == [str(repo / "docs" / "decisions" / "custom.svg")] + assert (repo / "docs" / "decisions" / "custom.svg").is_file() + assert not (caller / "docs" / "decisions" / "custom.svg").exists() diff --git a/tests/unit/test_identifiers.py b/tests/unit/test_identifiers.py index 596bc47..f105801 100644 --- a/tests/unit/test_identifiers.py +++ b/tests/unit/test_identifiers.py @@ -1,5 +1,7 @@ from pathlib import Path +import pytest + from scripts.core import identifiers @@ -30,6 +32,22 @@ def test_slugify_lowercases_and_hyphenates(): assert identifiers.slugify("Use Kafka for Domain Events!") == "use-kafka-for-domain-events" +def test_non_ascii_title_uses_deterministic_fallback(): + assert identifiers.slug_for_title("결제 시스템 분리", None) == "decision" + + +def test_agent_semantic_slug_wins_for_non_ascii_title(): + assert identifiers.slug_for_title( + "결제 시스템 분리", "separate-payment-system" + ) == "separate-payment-system" + + +@pytest.mark.parametrize("slug", ["Bad Slug", "한글", "../escape", "two--hyphens"]) +def test_invalid_explicit_slug_is_rejected(slug): + with pytest.raises(ValueError): + identifiers.validate_slug(slug) + + def test_find_by_number_locates_matching_file(tmp_path): (tmp_path / "0003-use-kafka.md").write_text("x", encoding="utf-8") found = identifiers.find_by_number(tmp_path, 3) diff --git a/tests/unit/test_index.py b/tests/unit/test_index.py index 252c202..a62b60e 100644 --- a/tests/unit/test_index.py +++ b/tests/unit/test_index.py @@ -97,7 +97,7 @@ def test_index_defaults_to_english_when_locale_omitted(tmp_path): tags=["architecture"], affected_paths=["src/events/"], ) - result = index.run(SimpleNamespace(dir=str(tmp_path))) + result = index.run(SimpleNamespace(dir=str(tmp_path), root=str(tmp_path), locale=None)) assert result["ok"] is True readme = (tmp_path / "README.md").read_text(encoding="utf-8") @@ -105,6 +105,38 @@ def test_index_defaults_to_english_when_locale_omitted(tmp_path): assert "## By status" in readme +def test_index_uses_repository_default_when_locale_omitted(tmp_path): + (tmp_path / ".adr-toolkit.json").write_text( + '{"schema_version": 1, "locale": "ko"}', encoding="utf-8" + ) + adr_dir = tmp_path / "docs/decisions" + adr_dir.mkdir(parents=True) + + result = index.run(SimpleNamespace( + dir=str(adr_dir), root=str(tmp_path), locale=None, + )) + + assert result["ok"] is True + assert (adr_dir / "README.md").read_text(encoding="utf-8").startswith("# 결정 기록") + + +def test_relative_adr_directory_is_resolved_against_root(tmp_path, monkeypatch): + caller = tmp_path / "caller" + repo = tmp_path / "repo" + adr_dir = repo / "docs/decisions" + caller.mkdir() + adr_dir.mkdir(parents=True) + monkeypatch.chdir(caller) + + result = index.run(SimpleNamespace( + dir="docs/decisions", root=str(repo), locale="en", + )) + + assert result["ok"] is True + assert (adr_dir / "README.md").is_file() + assert not (caller / "docs/decisions/README.md").exists() + + def test_index_unknown_status_falls_back_to_capitalized_label(tmp_path): _write_adr( tmp_path, "0001-use-kafka.md", @@ -173,3 +205,46 @@ def test_index_survives_a_completely_absent_i18n_directory(tmp_path, monkeypatch assert "## Chronological (newest first)" in readme assert "### Accepted" in readme assert "ADR-0001" in readme + + +def test_index_relationships_section_shows_supersession_with_titles(tmp_path): + adr_dir = tmp_path / "docs" / "decisions" + adr_dir.mkdir(parents=True) + (adr_dir / "0003-old.md").write_text( + "---\nid: ADR-0003\ntitle: Old decision\nstatus: superseded\n" + "date: 2026-08-31\ndecision_makers: []\nrelated: []\naffected_paths: []\n" + "tags: []\nretrospective: false\nsuperseded_by: ADR-0006\n---\n\nBody.\n", + encoding="utf-8", + ) + (adr_dir / "0006-new.md").write_text( + "---\nid: ADR-0006\ntitle: New decision\nstatus: accepted\n" + "date: 2026-08-31\ndecision_makers: []\nrelated: []\naffected_paths: []\n" + "tags: []\nretrospective: false\nsupersedes:\n - ADR-0003\n---\n\nBody.\n", + encoding="utf-8", + ) + + index.run(SimpleNamespace(dir=str(adr_dir), root=str(tmp_path), locale=None)) + + readme = (adr_dir / "README.md").read_text(encoding="utf-8") + assert "## Relationships" in readme + assert "ADR-0003" in readme and "Old decision" in readme + assert "ADR-0006" in readme and "New decision" in readme + assert "```mermaid" in readme + assert "ADR_0006 -->|supersedes| ADR_0003" in readme + + +def test_index_relationships_section_omits_adr_with_no_relationships(tmp_path): + adr_dir = tmp_path / "docs" / "decisions" + adr_dir.mkdir(parents=True) + (adr_dir / "0001-lonely.md").write_text( + "---\nid: ADR-0001\ntitle: Lonely decision\nstatus: accepted\n" + "date: 2026-08-31\ndecision_makers: []\nrelated: []\naffected_paths: []\n" + "tags: []\nretrospective: false\n---\n\nBody.\n", + encoding="utf-8", + ) + + index.run(SimpleNamespace(dir=str(adr_dir), root=str(tmp_path), locale=None)) + + readme = (adr_dir / "README.md").read_text(encoding="utf-8") + assert "Lonely decision" not in readme.split("## Relationships")[1] + assert "```mermaid" not in readme diff --git a/tests/unit/test_init.py b/tests/unit/test_init.py index b5d19a9..5f40e34 100644 --- a/tests/unit/test_init.py +++ b/tests/unit/test_init.py @@ -1,11 +1,14 @@ from types import SimpleNamespace +import json from scripts.commands import init def test_dry_run_reports_would_create_without_writing(tmp_path): adr_dir = tmp_path / "docs" / "decisions" - result = init.run(SimpleNamespace(dir=str(adr_dir), dry_run=True)) + result = init.run(SimpleNamespace( + dir=str(adr_dir), root=str(tmp_path), locale=None, dry_run=True, + )) assert result["ok"] is True assert result["dry_run"] is True assert not adr_dir.exists() @@ -13,7 +16,9 @@ def test_dry_run_reports_would_create_without_writing(tmp_path): def test_creates_directory_template_and_first_adr(tmp_path): adr_dir = tmp_path / "docs" / "decisions" - result = init.run(SimpleNamespace(dir=str(adr_dir), dry_run=False)) + result = init.run(SimpleNamespace( + dir=str(adr_dir), root=str(tmp_path), locale=None, dry_run=False, + )) assert result["ok"] is True assert (adr_dir / "adr-template.md").is_file() first_adr = adr_dir / "0001-record-architecture-decisions.md" @@ -25,7 +30,9 @@ def test_creates_directory_template_and_first_adr(tmp_path): def test_affected_paths_and_confirmation_reflect_custom_dir(tmp_path): adr_dir = tmp_path / "custom" / "adr-location" - result = init.run(SimpleNamespace(dir=str(adr_dir), dry_run=False)) + result = init.run(SimpleNamespace( + dir=str(adr_dir), root=str(tmp_path), locale=None, dry_run=False, + )) assert result["ok"] is True content = (adr_dir / "0001-record-architecture-decisions.md").read_text(encoding="utf-8") @@ -40,6 +47,76 @@ def test_refuses_to_run_on_non_empty_directory(tmp_path): adr_dir.mkdir(parents=True) (adr_dir / "existing.md").write_text("x", encoding="utf-8") - result = init.run(SimpleNamespace(dir=str(adr_dir), dry_run=False)) + result = init.run(SimpleNamespace( + dir=str(adr_dir), root=str(tmp_path), locale=None, dry_run=False, + )) assert result["ok"] is False assert result["errors"][0]["code"] == "ADR_DIRECTORY_NOT_EMPTY" + assert not (tmp_path / ".adr-toolkit.json").exists() + + +def test_init_creates_config_and_korean_scaffold(tmp_path): + adr_dir = tmp_path / "docs" / "decisions" + + result = init.run(SimpleNamespace( + dir=str(adr_dir), root=str(tmp_path), locale="ko", dry_run=False, + )) + + assert result["ok"] is True + config = json.loads((tmp_path / ".adr-toolkit.json").read_text(encoding="utf-8")) + assert config == {"schema_version": 1, "locale": "ko"} + adr = (adr_dir / "0001-record-architecture-decisions.md").read_text(encoding="utf-8") + assert "locale: ko" in adr + assert "## 맥락 및 문제 설명" in adr + assert "# 아키텍처 결정을 기록한다" in adr + + +def test_init_dry_run_lists_config_without_writing(tmp_path): + config_path = tmp_path / ".adr-toolkit.json" + + result = init.run(SimpleNamespace( + dir=str(tmp_path / "docs/decisions"), + root=str(tmp_path), + locale="fr", + dry_run=True, + )) + + assert str(config_path) in result["would_create"] + assert not config_path.exists() + + +def test_init_never_overwrites_existing_config(tmp_path): + config = tmp_path / ".adr-toolkit.json" + config.write_text( + json.dumps({"schema_version": 1, "locale": "ja"}), encoding="utf-8" + ) + + result = init.run(SimpleNamespace( + dir=str(tmp_path / "docs/decisions"), + root=str(tmp_path), + locale=None, + dry_run=False, + )) + + assert result["ok"] is True + assert json.loads(config.read_text(encoding="utf-8"))["locale"] == "ja" + adr = (tmp_path / "docs/decisions/0001-record-architecture-decisions.md").read_text(encoding="utf-8") + assert "locale: ja" in adr + assert "## コンテキストと問題" in adr + + +def test_relative_adr_directory_is_resolved_against_root(tmp_path, monkeypatch): + caller = tmp_path / "caller" + repo = tmp_path / "repo" + caller.mkdir() + repo.mkdir() + monkeypatch.chdir(caller) + + result = init.run(SimpleNamespace( + dir="docs/decisions", root=str(repo), locale="ko", dry_run=False, + )) + + assert result["ok"] is True + assert (repo / ".adr-toolkit.json").is_file() + assert (repo / "docs/decisions/0001-record-architecture-decisions.md").is_file() + assert not (caller / "docs/decisions").exists() diff --git a/tests/unit/test_locale.py b/tests/unit/test_locale.py index 1295542..17f5446 100644 --- a/tests/unit/test_locale.py +++ b/tests/unit/test_locale.py @@ -1,9 +1,26 @@ -from scripts.core.locale import DEFAULT_LOCALE, load_locale +import json + +from scripts.core.locale import DEFAULT_LOCALE, I18N_DIR, SUPPORTED_LOCALES, load_locale REQUIRED_KEYS = { "decision_log_title", "by_status", "by_tag", "by_affected_path", "chronological", "status.proposed", "status.accepted", "status.rejected", "status.deprecated", "status.superseded", + "relationships", "supersession_chains", "related", "superseded_by", + "related_to", +} + +REQUIRED_GENERATION_KEYS = { + "heading.context", "heading.decision_drivers", + "heading.considered_options", "heading.decision_outcome", + "heading.consequences", "heading.confirmation", "heading.pros_cons", + "heading.revisit_triggers", "label.chosen_option", "label.because", + "label.good", "label.bad", "prompt.title", "prompt.problem", + "prompt.options", "prompt.decision", "prompt.rationale", "prompt.good", + "prompt.bad", "prompt.confirmation", "prompt.revisit", "init.title", + "init.problem", "init.option.none", "init.option.wiki", "init.option.adr", + "init.decision", "init.rationale", "init.good", "init.bad", + "init.confirmation", "init.revisit", } @@ -11,6 +28,12 @@ def test_default_locale_is_english(): assert DEFAULT_LOCALE == "en" +def test_supported_locales_are_canonical_and_ordered(): + assert SUPPORTED_LOCALES == ( + "en", "ko", "ja", "zh", "fr", "es", "de", "pt-BR", + ) + + def test_english_locale_has_all_required_keys(): strings = load_locale("en") assert REQUIRED_KEYS.issubset(strings.keys()) @@ -23,6 +46,15 @@ def test_every_shipped_locale_has_all_required_keys(): assert not missing, f"{locale} is missing keys: {missing}" +def test_every_catalog_exactly_matches_english_generation_keys(): + english = json.loads((I18N_DIR / "en.json").read_text(encoding="utf-8")) + assert REQUIRED_GENERATION_KEYS <= set(english) + + for code in SUPPORTED_LOCALES: + catalog = json.loads((I18N_DIR / f"{code}.json").read_text(encoding="utf-8")) + assert set(catalog) == set(english), code + + def test_french_translates_a_known_string(): strings = load_locale("fr") assert strings["by_status"] == "Par statut" diff --git a/tests/unit/test_query.py b/tests/unit/test_query.py new file mode 100644 index 0000000..0aa92ea --- /dev/null +++ b/tests/unit/test_query.py @@ -0,0 +1,98 @@ +from scripts.core.query import ( + matches_keyword, + matches_paths_exact, + matches_tags_any, + path_governed_by, + rank_key, +) + + +def test_matches_keyword_in_title(): + assert matches_keyword("cache", "Cache policy for reads", "irrelevant body") is True + + +def test_matches_keyword_in_body_only(): + assert matches_keyword("redis", "Cache policy", "We chose Redis for shared caching.") is True + + +def test_matches_keyword_case_insensitive(): + assert matches_keyword("REDIS", "Cache policy", "we chose redis") is True + + +def test_matches_keyword_absent_returns_false(): + assert matches_keyword("postgres", "Cache policy", "We chose Redis.") is False + + +def test_matches_tags_any_returns_overlap(): + assert matches_tags_any({"check", "cli"}, ["cli", "v0.2.0"]) == {"cli"} + + +def test_matches_tags_any_empty_when_no_overlap(): + assert matches_tags_any({"check"}, ["cli"]) == set() + + +def test_matches_paths_exact_returns_overlap(): + assert matches_paths_exact({"src/db/"}, ["src/db/", "src/api/"]) == {"src/db/"} + + +def test_matches_paths_exact_does_not_prefix_match(): + assert matches_paths_exact({"src/db/x.py"}, ["src/db/"]) == set() + + +def test_path_governed_by_true_for_nested_file(): + assert path_governed_by("src/db/x.py", ["src/db/"]) is True + + +def test_path_governed_by_false_when_no_affected_path_covers_it(): + assert path_governed_by("src/other/x.py", ["src/db/"]) is False + + +def test_path_governed_by_ignores_non_string_affected_paths(): + assert path_governed_by("src/db/x.py", ["src/db/", 42, None]) is True + + +def test_path_governed_by_does_not_iterate_a_non_list_affected_paths_by_character(): + # A malformed ADR could carry "affected_paths: src/db/" (a plain string, + # not a YAML list). Iterating it would decompose into characters and + # produce nonsense single-character path_under/glob matches. + assert path_governed_by("s", "src/db/") is False + + +def test_matches_tags_any_ignores_non_list_entry_tags(): + assert matches_tags_any({"c"}, "check") == set() + + +def test_matches_paths_exact_ignores_non_list_affected_paths(): + assert matches_paths_exact({"s"}, "src/events/") == set() + + +def _entry(id_="ADR-0001", title="Cache policy", body="body text"): + return {"id": id_, "title": title, "body": body} + + +def test_rank_key_exact_id_match_ranks_first(): + exact_id = _entry(id_="ADR-0007") + title_match = _entry(id_="ADR-0001", title="ADR-0007 discussion") + ranked = sorted([title_match, exact_id], key=lambda e: rank_key(e, "ADR-0007")) + assert ranked[0]["id"] == "ADR-0007" + + +def test_rank_key_exact_title_ranks_above_title_substring(): + exact_title = _entry(id_="ADR-0001", title="cache") + substring_title = _entry(id_="ADR-0002", title="cache policy for reads") + ranked = sorted([substring_title, exact_title], key=lambda e: rank_key(e, "cache")) + assert ranked[0]["id"] == "ADR-0001" + + +def test_rank_key_title_substring_ranks_above_body_only_match(): + title_match = _entry(id_="ADR-0001", title="redis caching", body="unrelated") + body_match = _entry(id_="ADR-0002", title="unrelated title", body="uses redis internally") + ranked = sorted([body_match, title_match], key=lambda e: rank_key(e, "redis")) + assert ranked[0]["id"] == "ADR-0001" + + +def test_rank_key_ties_break_by_filename_for_determinism(): + a = _entry(id_="ADR-0002", title="cache", body="") + b = _entry(id_="ADR-0001", title="cache", body="") + ranked = sorted([a, b], key=lambda e: rank_key(e, "cache")) + assert [e["id"] for e in ranked] == ["ADR-0001", "ADR-0002"] diff --git a/tests/unit/test_readme.py b/tests/unit/test_readme.py new file mode 100644 index 0000000..0be3ee8 --- /dev/null +++ b/tests/unit/test_readme.py @@ -0,0 +1,28 @@ +from pathlib import Path + +README = Path(__file__).resolve().parents[2] / "README.md" + + +def test_readme_documents_repository_locale_configuration(): + text = README.read_text(encoding="utf-8") + assert ".adr-toolkit.json" in text + for locale in ["en", "ko", "ja", "zh", "fr", "es", "de", "pt-BR"]: + assert f"`{locale}`" in text + assert "init --locale ko" in text + assert "create --interactive" in text + assert "create --locale ja" in text + assert "index --locale fr" in text + + +def test_readme_documents_non_ascii_titles_and_semantic_slugs(): + text = README.read_text(encoding="utf-8") + assert "결제 시스템 분리" in text + assert "--slug separate-payment-system" in text + assert "0001-decision.md" in text + + +def test_readme_scopes_check_confidence(): + text = README.read_text(encoding="utf-8") + assert "CHECK does not certify the entire architecture" in text + for label in ["VERIFIED", "VIOLATED", "UNVERIFIABLE", "NOT_APPLICABLE"]: + assert label in text diff --git a/tests/unit/test_related.py b/tests/unit/test_related.py index 4dd8479..0f8c29c 100644 --- a/tests/unit/test_related.py +++ b/tests/unit/test_related.py @@ -80,3 +80,28 @@ def test_non_list_affected_paths_does_not_match_by_character(tmp_path): assert result["count"] == 0 assert result["matches"] == [] + + +def test_keyword_matches_when_present_only_in_body(tmp_path): + (tmp_path / "0001-cache-policy.md").write_text( + "---\n" + "id: ADR-0001\n" + "title: Cache policy\n" + "status: accepted\n" + "date: 2026-08-31\n" + "decision_makers: []\n" + "related: []\n" + "affected_paths: []\n" + "tags: []\n" + "retrospective: false\n" + "---\n\n" + "# Cache policy\n\nWe chose Redis for the shared cache layer.\n", + encoding="utf-8", + ) + + result = related.run(SimpleNamespace( + dir=str(tmp_path), paths=None, tags=None, keyword="redis", + )) + + assert result["count"] == 1 + assert result["matches"][0]["id"] == "ADR-0001" diff --git a/tests/unit/test_relationships.py b/tests/unit/test_relationships.py new file mode 100644 index 0000000..0717478 --- /dev/null +++ b/tests/unit/test_relationships.py @@ -0,0 +1,143 @@ +from scripts.core.relationships import ( + Relationship, + find_cycles, + missing_targets, + render_mermaid, + render_svg, + resolve, + supersession_mismatches, +) + +ENTRIES = [ + {"id": "ADR-0003", "title": "Old decision", "related": [], "supersedes": [], "superseded_by": "ADR-0006"}, + {"id": "ADR-0006", "title": "New decision", "related": [], "supersedes": ["ADR-0003"], "superseded_by": None}, + {"id": "ADR-0007", "title": "Confidence field", "related": ["ADR-0002"], "supersedes": [], "superseded_by": None}, + {"id": "ADR-0002", "title": "Structural checks", "related": [], "supersedes": [], "superseded_by": None}, +] + + +def test_resolve_produces_supersedes_edge(): + edges = resolve(ENTRIES) + assert Relationship(source="ADR-0006", type="supersedes", target="ADR-0003") in edges + + +def test_resolve_produces_superseded_by_edge(): + edges = resolve(ENTRIES) + assert Relationship(source="ADR-0003", type="superseded_by", target="ADR-0006") in edges + + +def test_resolve_produces_related_edge(): + edges = resolve(ENTRIES) + assert Relationship(source="ADR-0007", type="related", target="ADR-0002") in edges + + +def test_resolve_produces_no_edges_for_entry_with_no_relationships(): + edges = resolve(ENTRIES) + assert not any(e.source == "ADR-0002" for e in edges) + + +def test_resolve_handles_missing_fields_gracefully(): + edges = resolve([{"id": "ADR-0001"}]) + assert edges == [] + + +def test_missing_targets_finds_dangling_reference(): + edges = resolve([{"id": "ADR-0001", "related": ["ADR-9999"]}]) + missing = missing_targets(edges, known_ids={"ADR-0001"}) + assert Relationship(source="ADR-0001", type="related", target="ADR-9999") in missing + + +def test_missing_targets_empty_when_all_targets_known(): + edges = resolve(ENTRIES) + known_ids = {e["id"] for e in ENTRIES} + assert missing_targets(edges, known_ids) == [] + + +def test_supersession_mismatches_empty_when_bidirectional(): + edges = resolve(ENTRIES) + assert supersession_mismatches(edges) == [] + + +def test_supersession_mismatches_finds_one_sided_edge(): + one_sided = [ + {"id": "ADR-0010", "related": [], "supersedes": ["ADR-0009"], "superseded_by": None}, + {"id": "ADR-0009", "related": [], "supersedes": [], "superseded_by": None}, + ] + edges = resolve(one_sided) + mismatches = supersession_mismatches(edges) + assert ("ADR-0010", "ADR-0009") in mismatches + + +def test_find_cycles_empty_for_a_linear_chain(): + linear = [ + {"id": "ADR-0001", "supersedes": [], "superseded_by": "ADR-0002"}, + {"id": "ADR-0002", "supersedes": ["ADR-0001"], "superseded_by": None}, + ] + assert find_cycles(resolve(linear)) == [] + + +def test_find_cycles_detects_a_direct_two_node_cycle(): + mutual = [ + {"id": "ADR-0001", "supersedes": ["ADR-0002"], "superseded_by": None}, + {"id": "ADR-0002", "supersedes": ["ADR-0001"], "superseded_by": None}, + ] + cycles = find_cycles(resolve(mutual)) + assert len(cycles) == 1 + assert set(cycles[0]) == {"ADR-0001", "ADR-0002"} + + +def test_find_cycles_detects_a_longer_cycle(): + triangle = [ + {"id": "ADR-0001", "supersedes": ["ADR-0002"], "superseded_by": None}, + {"id": "ADR-0002", "supersedes": ["ADR-0003"], "superseded_by": None}, + {"id": "ADR-0003", "supersedes": ["ADR-0001"], "superseded_by": None}, + ] + cycles = find_cycles(resolve(triangle)) + assert len(cycles) == 1 + assert set(cycles[0]) == {"ADR-0001", "ADR-0002", "ADR-0003"} + + +def test_find_cycles_ignores_related_edges(): + # related is symmetric/non-directional in meaning; A<->B related is not + # a logical error the way a supersession cycle is. + mutually_related = [ + {"id": "ADR-0001", "related": ["ADR-0002"]}, + {"id": "ADR-0002", "related": ["ADR-0001"]}, + ] + assert find_cycles(resolve(mutually_related)) == [] + + +def test_render_mermaid_includes_supersession_and_related_edges_with_labels(): + mermaid = render_mermaid(ENTRIES) + + assert mermaid.startswith("flowchart LR\n") + assert "ADR_0006[\"ADR-0006
New decision\"]" in mermaid + assert "ADR_0006 -->|supersedes| ADR_0003" in mermaid + assert "ADR_0007 -.->|related| ADR_0002" in mermaid + assert "ADR_0003 -->|superseded by| ADR_0006" not in mermaid + + +def test_render_mermaid_reports_no_relationships_when_graph_has_no_edges(): + mermaid = render_mermaid([{"id": "ADR-0001", "title": "Lonely decision"}]) + + assert "ADR_0001[\"ADR-0001
Lonely decision\"]" in mermaid + assert "No relationships recorded" in mermaid + + +def test_render_mermaid_escapes_title_text_for_label_safety(): + mermaid = render_mermaid([ + {"id": "ADR-0001", "title": "Use \"ports\" [core]\nnow"}, + ]) + + assert "Use <A&B> 'ports' (core) now" in mermaid + assert "\nnow" not in mermaid + + +def test_render_svg_returns_crisp_vector_navigation_artifact(): + svg = render_svg(ENTRIES) + + assert svg.startswith("ADR relationship graph" in svg + assert "ADR-0006" in svg and "New decision" in svg + assert "ADR-0006 supersedes ADR-0003" in svg + assert "ADR-0007 related ADR-0002" in svg diff --git a/tests/unit/test_rendering.py b/tests/unit/test_rendering.py new file mode 100644 index 0000000..af6aa3b --- /dev/null +++ b/tests/unit/test_rendering.py @@ -0,0 +1,49 @@ +from scripts.core.rendering import ( + interactive_prompts, + render_initial_adr, + render_minimal, + render_template, +) + + +def test_korean_minimal_uses_korean_structure_and_preserves_answers(): + body = render_minimal( + "ko", + { + "title": "결제 시스템 분리", + "problem": "장애 격리가 필요하다.", + "options": ["유지", "분리"], + "decision": "분리", + "rationale": "격리할 수 있다", + "good": "독립 배포", + "bad": "운영 복잡성", + "confirmation": "CI에서 경계를 검사한다.", + "revisit": "운영 비용이 편익을 초과할 때", + }, + ) + + assert "## 맥락 및 문제 설명" in body + assert "장애 격리가 필요하다." in body + assert "## 재검토 조건" in body + + +def test_japanese_full_template_has_full_sections(): + template = render_template("ja", full=True) + + assert "## 決定要因" in template + assert "## 選択肢の長所と短所" in template + + +def test_korean_prompts_are_localized_in_stable_order(): + prompts = interactive_prompts("ko") + + assert prompts[0] == "결정의 제목은 무엇인가요?" + assert prompts[-1] == "어떤 조건에서 이 결정을 재검토해야 하나요?" + + +def test_initial_adr_uses_locale_boilerplate_and_path(): + title, body = render_initial_adr("fr", "docs/decisions/") + + assert title == "Consigner les décisions d’architecture" + assert "`docs/decisions/`" in body + assert "## Contexte et problème" in body diff --git a/tests/unit/test_schema.py b/tests/unit/test_schema.py index 8ee661d..2d3a3b1 100644 --- a/tests/unit/test_schema.py +++ b/tests/unit/test_schema.py @@ -2,6 +2,7 @@ from pathlib import Path from scripts.core import schema +from scripts.core.locale import SUPPORTED_LOCALES from scripts.core.schema import validate_frontmatter VALID = { @@ -72,6 +73,12 @@ def test_absence_of_optional_fields_is_not_an_error(): assert validate_frontmatter(VALID) == [] +def test_optional_locale_is_valid_and_unknown_locale_is_rejected(): + assert validate_frontmatter({**VALID, "locale": "ko"}) == [] + errors = validate_frontmatter({**VALID, "locale": "xx"}) + assert any("locale" in error for error in errors) + + def test_json_schema_matches_runtime_frontmatter_fields(): schema_path = Path(__file__).parents[2] / "skills/adr-toolkit/schemas/adr.schema.json" with schema_path.open() as schema_file: @@ -87,3 +94,6 @@ def test_json_schema_matches_runtime_frontmatter_fields(): for field, python_type in runtime_fields.items(): assert field in json_schema["properties"] assert json_schema["properties"][field]["type"] == expected_json_types[python_type] + + assert "locale" not in json_schema["required"] + assert tuple(json_schema["properties"]["locale"]["enum"]) == SUPPORTED_LOCALES diff --git a/tests/unit/test_search_command.py b/tests/unit/test_search_command.py new file mode 100644 index 0000000..3bddbf1 --- /dev/null +++ b/tests/unit/test_search_command.py @@ -0,0 +1,198 @@ +from types import SimpleNamespace + +from scripts.commands import search + + +def _yaml_list_field(key, values): + if not values: + return f"{key}: []" + return f"{key}:\n" + "\n".join(f" - {v}" for v in values) + + +def _write_adr(adr_dir, filename, id, title, status="accepted", affected_paths=None, tags=None, body="Body text."): + adr_dir.mkdir(parents=True, exist_ok=True) + text = ( + "---\n" + f"id: {id}\n" + f"title: {title}\n" + f"status: {status}\n" + "date: 2026-08-31\n" + "decision_makers: []\n" + "related: []\n" + f"{_yaml_list_field('affected_paths', affected_paths)}\n" + f"{_yaml_list_field('tags', tags)}\n" + "retrospective: false\n" + "---\n\n" + f"# {title}\n\n" + f"{body}\n" + ) + (adr_dir / filename).write_text(text, encoding="utf-8") + + +def _args(adr_dir, **overrides): + defaults = dict(dir=str(adr_dir), keyword=None, tags=None, status=None, path=None, limit=None, id=None) + defaults.update(overrides) + return SimpleNamespace(**defaults) + + +def test_search_with_no_filters_returns_every_adr(tmp_path): + adr_dir = tmp_path / "docs" / "decisions" + _write_adr(adr_dir, "0001-a.md", id="ADR-0001", title="First") + _write_adr(adr_dir, "0002-b.md", id="ADR-0002", title="Second") + + result = search.run(_args(adr_dir)) + + assert result["count"] == 2 + + +def test_search_keyword_matches_title(tmp_path): + adr_dir = tmp_path / "docs" / "decisions" + _write_adr(adr_dir, "0001-a.md", id="ADR-0001", title="Cache policy") + _write_adr(adr_dir, "0002-b.md", id="ADR-0002", title="Unrelated") + + result = search.run(_args(adr_dir, keyword="cache")) + + assert result["count"] == 1 + assert result["results"][0]["id"] == "ADR-0001" + + +def test_search_keyword_matches_body(tmp_path): + adr_dir = tmp_path / "docs" / "decisions" + _write_adr(adr_dir, "0001-a.md", id="ADR-0001", title="Cache policy", body="We chose Redis.") + + result = search.run(_args(adr_dir, keyword="redis")) + + assert result["count"] == 1 + + +def test_search_tag_filter(tmp_path): + adr_dir = tmp_path / "docs" / "decisions" + _write_adr(adr_dir, "0001-a.md", id="ADR-0001", title="A", tags=["database"]) + _write_adr(adr_dir, "0002-b.md", id="ADR-0002", title="B", tags=["cli"]) + + result = search.run(_args(adr_dir, tags=["database"])) + + assert result["count"] == 1 + assert result["results"][0]["id"] == "ADR-0001" + + +def test_search_status_filter(tmp_path): + adr_dir = tmp_path / "docs" / "decisions" + _write_adr(adr_dir, "0001-a.md", id="ADR-0001", title="A", status="accepted") + _write_adr(adr_dir, "0002-b.md", id="ADR-0002", title="B", status="proposed") + + result = search.run(_args(adr_dir, status="proposed")) + + assert result["count"] == 1 + assert result["results"][0]["id"] == "ADR-0002" + + +def test_search_path_filter_uses_governed_by_semantics(tmp_path): + adr_dir = tmp_path / "docs" / "decisions" + _write_adr(adr_dir, "0001-a.md", id="ADR-0001", title="A", affected_paths=["src/db/"]) + _write_adr(adr_dir, "0002-b.md", id="ADR-0002", title="B", affected_paths=["src/api/"]) + + result = search.run(_args(adr_dir, path="src/db/connection.py")) + + assert result["count"] == 1 + assert result["results"][0]["id"] == "ADR-0001" + + +def test_search_combines_different_fields_with_and(tmp_path): + adr_dir = tmp_path / "docs" / "decisions" + _write_adr(adr_dir, "0001-a.md", id="ADR-0001", title="Cache policy", tags=["database"]) + _write_adr(adr_dir, "0002-b.md", id="ADR-0002", title="Cache policy", tags=["cli"]) + + result = search.run(_args(adr_dir, keyword="cache", tags=["database"])) + + assert result["count"] == 1 + assert result["results"][0]["id"] == "ADR-0001" + + +def test_search_combines_same_field_values_with_or(tmp_path): + adr_dir = tmp_path / "docs" / "decisions" + _write_adr(adr_dir, "0001-a.md", id="ADR-0001", title="A", tags=["postgres"]) + _write_adr(adr_dir, "0002-b.md", id="ADR-0002", title="B", tags=["mysql"]) + _write_adr(adr_dir, "0003-c.md", id="ADR-0003", title="C", tags=["cli"]) + + result = search.run(_args(adr_dir, tags=["postgres", "mysql"])) + + assert result["count"] == 2 + + +def test_search_orders_exact_title_match_before_substring_match(tmp_path): + adr_dir = tmp_path / "docs" / "decisions" + _write_adr(adr_dir, "0001-a.md", id="ADR-0001", title="cache policy extended") + _write_adr(adr_dir, "0002-b.md", id="ADR-0002", title="cache") + + result = search.run(_args(adr_dir, keyword="cache")) + + assert [r["id"] for r in result["results"]] == ["ADR-0002", "ADR-0001"] + + +def test_search_limit_truncates_and_reports_total(tmp_path): + adr_dir = tmp_path / "docs" / "decisions" + _write_adr(adr_dir, "0001-a.md", id="ADR-0001", title="A") + _write_adr(adr_dir, "0002-b.md", id="ADR-0002", title="B") + _write_adr(adr_dir, "0003-c.md", id="ADR-0003", title="C") + + result = search.run(_args(adr_dir, limit=2)) + + assert result["count"] == 2 + assert result["total"] == 3 + assert result["truncated"] is True + assert len(result["results"]) == 2 + + +def test_search_no_limit_is_not_truncated(tmp_path): + adr_dir = tmp_path / "docs" / "decisions" + _write_adr(adr_dir, "0001-a.md", id="ADR-0001", title="A") + + result = search.run(_args(adr_dir)) + + assert result["total"] == 1 + assert result["truncated"] is False + + +def test_search_echoes_the_query(tmp_path): + adr_dir = tmp_path / "docs" / "decisions" + _write_adr(adr_dir, "0001-a.md", id="ADR-0001", title="A", tags=["cli"]) + + result = search.run(_args(adr_dir, keyword="a", tags=["cli"], status="accepted")) + + assert result["query"] == { + "id": None, "keyword": "a", "tags": ["cli"], "status": "accepted", "path": None, "limit": None, + } + + +def test_search_id_filter_returns_exact_match_only(tmp_path): + adr_dir = tmp_path / "docs" / "decisions" + _write_adr(adr_dir, "0001-a.md", id="ADR-0001", title="A") + _write_adr(adr_dir, "0002-b.md", id="ADR-0002", title="B") + + result = search.run(_args(adr_dir, id="ADR-0002")) + + assert result["count"] == 1 + assert result["results"][0]["id"] == "ADR-0002" + assert result["results"][0]["matched_in"] == ["id"] + + +def test_search_id_filter_no_match_returns_empty(tmp_path): + adr_dir = tmp_path / "docs" / "decisions" + _write_adr(adr_dir, "0001-a.md", id="ADR-0001", title="A") + + result = search.run(_args(adr_dir, id="ADR-9999")) + + assert result["count"] == 0 + + +def test_search_malformed_adr_degrades_to_warning(tmp_path): + adr_dir = tmp_path / "docs" / "decisions" + adr_dir.mkdir(parents=True) + (adr_dir / "0001-bad.md").write_text("not valid frontmatter", encoding="utf-8") + + result = search.run(_args(adr_dir)) + + assert result["ok"] is True + assert result["count"] == 0 + assert any(w["code"] == "BAD_FRONTMATTER" for w in result["warnings"]) diff --git a/tests/unit/test_skill_manifest.py b/tests/unit/test_skill_manifest.py index bb42708..4dd9f8e 100644 --- a/tests/unit/test_skill_manifest.py +++ b/tests/unit/test_skill_manifest.py @@ -45,6 +45,9 @@ def test_skill_md_documents_check(): assert "adr.py check" in body assert "Verified violation" in body assert "CHECK is not yet implemented" not in body + assert "does not certify the entire architecture" in body + for label in ["VERIFIED", "VIOLATED", "UNVERIFIABLE", "NOT_APPLICABLE"]: + assert label in body def test_record_points_at_the_conflict_rules_reference_for_constraints_blocks(): @@ -56,4 +59,15 @@ def test_record_points_at_the_conflict_rules_reference_for_constraints_blocks(): def test_skill_md_documents_locale_detection(): _, body = fm.parse(SKILL_MD.read_text(encoding="utf-8")) assert "--locale" in body - assert "fr" in body and "ja" in body and "ko" in body and "zh" in body + for locale in ["en", "ko", "ja", "zh", "fr", "es", "de", "pt-BR"]: + assert locale in body + assert "explicit user request" in body + assert "request language" in body + assert "repository default" in body + + +def test_skill_md_requires_semantic_slug_confirmation(): + _, body = fm.parse(SKILL_MD.read_text(encoding="utf-8")) + assert "semantic ASCII slug" in body + assert "--slug" in body + assert "show" in body and "CONFIRM" in body diff --git a/tests/unit/test_sync_version.py b/tests/unit/test_sync_version.py index 57d3f15..d037d08 100644 --- a/tests/unit/test_sync_version.py +++ b/tests/unit/test_sync_version.py @@ -215,3 +215,92 @@ def test_sync_still_tolerates_a_missing_fixture_path_for_testability(tmp_path): version_file.write_text("1.2.3\n", encoding="utf-8") assert sync(version_file, [(tmp_path / "absent.json", ["version"])], check_only=True) == [] + + +def test_require_known_paths_raises_when_a_tracked_manifest_loses_its_version_key( + tmp_path, monkeypatch +): + manifest = tmp_path / "plugin.json" + manifest.write_text(json.dumps({"name": "x"}), encoding="utf-8") + monkeypatch.setattr(_sync_version, "MANIFEST_SPECS", [(manifest, ["version"])]) + + with pytest.raises(SystemExit) as excinfo: + require_known_paths() + + assert "plugin.json" in str(excinfo.value) + + +def test_require_known_paths_raises_a_clean_error_for_a_manifest_outside_repo_root( + tmp_path, monkeypatch +): + outside = tmp_path / "definitely-missing.json" + monkeypatch.setattr(_sync_version, "MANIFEST_SPECS", [(outside, ["version"])]) + + with pytest.raises(SystemExit) as excinfo: + require_known_paths() + + assert "definitely-missing.json" in str(excinfo.value) + + +def test_sync_preserves_non_ascii_content_in_manifest_writes(tmp_path): + version_file = tmp_path / "VERSION" + version_file.write_text("1.2.3\n", encoding="utf-8") + manifest = _write( + tmp_path, "plugin.json", {"name": "x", "description": "한국어 설명", "version": "0.0.0"} + ) + + sync(version_file, [(manifest, ["version"])], check_only=False) + + assert "한국어 설명" in manifest.read_text(encoding="utf-8") + + +# --- Manifest description ownership ----------------------------------------- + +read_description = _sync_version.read_description +sync_descriptions = _sync_version.sync_descriptions +REAL_DESCRIPTION_MANIFEST_SPECS = _sync_version.DESCRIPTION_MANIFEST_SPECS + + +def test_read_description_extracts_frontmatter_description(tmp_path): + skill_md = tmp_path / "SKILL.md" + skill_md.write_text( + "---\nname: x\ndescription: Do the thing.\nversion: 0.0.0\n---\n\nBody\n", + encoding="utf-8", + ) + + assert read_description(skill_md) == "Do the thing." + + +def test_sync_descriptions_writes_matching_description_into_every_manifest(tmp_path): + skill_md = tmp_path / "SKILL.md" + skill_md.write_text( + "---\nname: x\ndescription: Canonical description.\n---\n", encoding="utf-8" + ) + plugin = _write(tmp_path, "plugin.json", {"name": "x", "description": "stale"}) + + changed = sync_descriptions(skill_md, [(plugin, ["description"])], check_only=False) + + assert json.loads(plugin.read_text())["description"] == "Canonical description." + assert changed == [plugin] + + +def test_sync_descriptions_check_only_reports_drift_without_writing(tmp_path): + skill_md = tmp_path / "SKILL.md" + skill_md.write_text( + "---\nname: x\ndescription: Canonical description.\n---\n", encoding="utf-8" + ) + plugin = _write(tmp_path, "plugin.json", {"name": "x", "description": "stale"}) + + changed = sync_descriptions(skill_md, [(plugin, ["description"])], check_only=True) + + assert changed == [plugin] + assert json.loads(plugin.read_text())["description"] == "stale" + + +def test_real_manifests_have_a_description_matching_skill_md(): + canonical = read_description(REAL_SKILL_MD_PATH) + for path, key_path in REAL_DESCRIPTION_MANIFEST_SPECS: + target = json.loads(path.read_text(encoding="utf-8")) + for key in key_path: + target = target[key] + assert target == canonical, f"{path} description has drifted from SKILL.md" diff --git a/tests/unit/test_validate.py b/tests/unit/test_validate.py index 097adcf..b9ead3a 100644 --- a/tests/unit/test_validate.py +++ b/tests/unit/test_validate.py @@ -66,3 +66,111 @@ def test_filename_id_mismatch_is_reported(tmp_path): assert mismatches[0]["file"] == "0002-something.md" assert mismatches[0]["expected_id"] == "ADR-0002" assert mismatches[0]["found_id"] == "ADR-0001" + + +def test_invalid_repository_config_fails_before_reporting_adr_success(tmp_path): + (tmp_path / ".adr-toolkit.json").write_text( + '{"schema_version": 1, "locale": "xx"}', encoding="utf-8" + ) + adr_dir = tmp_path / "docs/decisions" + adr_dir.mkdir(parents=True) + (adr_dir / "0001-record-architecture-decisions.md").write_text( + VALID_ADR, encoding="utf-8" + ) + + result = validate.run(SimpleNamespace(dir=str(adr_dir), root=str(tmp_path))) + + assert result["ok"] is False + assert result["checked"] == 0 + assert result["errors"][0]["code"] == "CONFIG_ERROR" + + +def test_relative_adr_directory_is_resolved_against_root(tmp_path, monkeypatch): + caller = tmp_path / "caller" + repo = tmp_path / "repo" + adr_dir = repo / "docs/decisions" + caller.mkdir() + adr_dir.mkdir(parents=True) + (adr_dir / "0001-record-architecture-decisions.md").write_text( + VALID_ADR, encoding="utf-8" + ) + monkeypatch.chdir(caller) + + result = validate.run(SimpleNamespace(dir="docs/decisions", root=str(repo))) + + assert result["ok"] is True + assert result["checked"] == 1 + + +def test_broken_supersession_link_is_an_error(tmp_path): + (tmp_path / "0001-a.md").write_text( + "---\nid: ADR-0001\ntitle: A\nstatus: accepted\ndate: 2026-08-31\n" + "decision_makers: []\nrelated: []\naffected_paths: []\ntags: []\n" + "retrospective: false\nsupersedes:\n - ADR-9999\n---\n\nBody.\n", + encoding="utf-8", + ) + + result = validate.run(SimpleNamespace(dir=str(tmp_path))) + + assert result["ok"] is False + assert any(e["code"] == "BROKEN_SUPERSESSION_LINK" for e in result["errors"]) + + +def test_supersession_mismatch_is_an_error(tmp_path): + (tmp_path / "0001-old.md").write_text( + "---\nid: ADR-0001\ntitle: Old\nstatus: superseded\ndate: 2026-08-31\n" + "decision_makers: []\nrelated: []\naffected_paths: []\ntags: []\n" + "retrospective: false\n---\n\nBody.\n", + encoding="utf-8", + ) + (tmp_path / "0002-new.md").write_text( + "---\nid: ADR-0002\ntitle: New\nstatus: accepted\ndate: 2026-08-31\n" + "decision_makers: []\nrelated: []\naffected_paths: []\ntags: []\n" + "retrospective: false\nsupersedes:\n - ADR-0001\n---\n\nBody.\n", + encoding="utf-8", + ) + # ADR-0001 has no superseded_by: ADR-0002 -- one-sided edit. + + result = validate.run(SimpleNamespace(dir=str(tmp_path))) + + assert result["ok"] is False + assert any(e["code"] == "SUPERSESSION_MISMATCH" for e in result["errors"]) + + +def test_bidirectional_supersession_has_no_relationship_errors(tmp_path): + (tmp_path / "0001-old.md").write_text( + "---\nid: ADR-0001\ntitle: Old\nstatus: superseded\ndate: 2026-08-31\n" + "decision_makers: []\nrelated: []\naffected_paths: []\ntags: []\n" + "retrospective: false\nsuperseded_by: ADR-0002\n---\n\nBody.\n", + encoding="utf-8", + ) + (tmp_path / "0002-new.md").write_text( + "---\nid: ADR-0002\ntitle: New\nstatus: accepted\ndate: 2026-08-31\n" + "decision_makers: []\nrelated: []\naffected_paths: []\ntags: []\n" + "retrospective: false\nsupersedes:\n - ADR-0001\n---\n\nBody.\n", + encoding="utf-8", + ) + + result = validate.run(SimpleNamespace(dir=str(tmp_path))) + + assert result["ok"] is True + + +def test_supersession_cycle_is_an_error(tmp_path): + (tmp_path / "0001-a.md").write_text( + "---\nid: ADR-0001\ntitle: A\nstatus: accepted\ndate: 2026-08-31\n" + "decision_makers: []\nrelated: []\naffected_paths: []\ntags: []\n" + "retrospective: false\nsupersedes:\n - ADR-0002\n---\n\nBody.\n", + encoding="utf-8", + ) + (tmp_path / "0002-b.md").write_text( + "---\nid: ADR-0002\ntitle: B\nstatus: accepted\ndate: 2026-08-31\n" + "decision_makers: []\nrelated: []\naffected_paths: []\ntags: []\n" + "retrospective: false\nsupersedes:\n - ADR-0001\n---\n\nBody.\n", + encoding="utf-8", + ) + + result = validate.run(SimpleNamespace(dir=str(tmp_path))) + + assert result["ok"] is False + assert any(e["code"] == "SUPERSESSION_CYCLE" for e in result["errors"])