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/workflows/test.yml b/.github/workflows/test.yml index 9e4bd9d..f20f9ec 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,5 +32,13 @@ 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 diff --git a/README.md b/README.md index c2b2255..3840378 100644 --- a/README.md +++ b/README.md @@ -47,19 +47,186 @@ of these adapters at it: directly in a terminal — no agent required: ```bash -python skills/adr-toolkit/scripts/adr.py create --interactive --dir docs/decisions +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). + +### 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). -- Runtime text ships in English, French, Japanese, Korean, and Chinese — - scoped to `index`'s generated output only, since agent-composed prose - needs no lookup table. See - [ADR-0003](docs/decisions/0003-localize-only-index-py-s-generated-strings-not-agent-composed-text.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. diff --git a/adapters/codex/README.md b/adapters/codex/README.md index 27c99c6..2abb0ca 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 @@ -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.** 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 480f840..dab90e5 100644 --- a/changelog.md +++ b/changelog.md @@ -4,6 +4,60 @@ Lightweight human-readable summary of meaningful repository changes. ## Unreleased +- 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 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 index d4ee0ca..07fd39e 100644 --- a/docs/decisions/0001-record-architecture-decisions.md +++ b/docs/decisions/0001-record-architecture-decisions.md @@ -3,7 +3,9 @@ id: ADR-0001 title: Record architecture decisions status: accepted date: 2026-08-30 -decision_makers: [] +decision_makers: + - YangSeungHyun +locale: en related: [] affected_paths: - docs/decisions/ @@ -37,7 +39,9 @@ Chosen option: **Architecture Decision Records stored alongside the code**, beca ## Confirmation -* [ ] `docs/decisions/` exists with this file, a template, and an index. +`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 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 index 776491a..d600209 100644 --- 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 @@ -3,16 +3,21 @@ id: ADR-0002 title: Limit CHECK's conflict detection to structural evidence only status: accepted date: 2026-08-30 -decision_makers: [] +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: false +retrospective: true --- # Limit CHECK's conflict detection to structural evidence only @@ -39,6 +44,26 @@ Chosen option: **structural-evidence-only detection**, because false positives a `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 index 3c428ce..3f41f1d 100644 --- 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 @@ -1,18 +1,27 @@ --- id: ADR-0003 title: Localize only index.py's generated strings, not agent-composed text -status: accepted +status: superseded date: 2026-08-30 -decision_makers: [] +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: false +retrospective: true +superseded_by: ADR-0006 --- # Localize only index.py's generated strings, not agent-composed text @@ -39,6 +48,26 @@ Chosen option: **localize only index.py's generated strings**, because an LLM ag `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 index 9fb3d6c..bd902d1 100644 --- 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 @@ -3,14 +3,19 @@ id: ADR-0004 title: Adapter packaging: manifest-only directories, install-time symlinks, verified formats status: accepted date: 2026-08-30 -decision_makers: [] +decision_makers: + - YangSeungHyun +locale: en related: [] affected_paths: - adapters/ + - .gitignore + - .claude-plugin/marketplace.json + - .claude-plugin/plugin.json tags: - adapters - harness-support -retrospective: false +retrospective: true --- # Adapter packaging: manifest-only directories, install-time symlinks, verified formats @@ -19,6 +24,13 @@ retrospective: false 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 @@ -34,10 +46,47 @@ Chosen option: **document the symlink as an install-time step, with formats veri * 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 index 0084b8a..6d8250d 100644 --- 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 @@ -3,11 +3,15 @@ id: ADR-0005 title: Adopt Git Flow with direct-tag release automation status: accepted date: 2026-08-30 -decision_makers: [] +decision_makers: + - YangSeungHyun +locale: en related: [] affected_paths: - AGENTS.md - .github/workflows/ + - skills/adr-toolkit/VERSION + - scripts/sync_version.py tags: - process - release 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/README.md b/docs/decisions/README.md index 370fca0..955800a 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -5,9 +5,16 @@ ### 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-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-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) + +### 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 @@ -17,22 +24,53 @@ ### 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) + +### 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) + ### 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) +### 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) @@ -40,35 +78,140 @@ ### 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) + ## 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/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) +### `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) + ### `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) +### `examples/quickstart.md` +- [ADR-0007 — Promote CHECK's kind-to-confidence mapping to a stable output field](0007-check-confidence-field.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) + +### `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) + ### `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/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) + +### `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/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_check.py` +- [ADR-0007 — Promote CHECK's kind-to-confidence mapping to a stable output field](0007-check-confidence-field.md) + ## Chronological (newest first) - 2026-08-30 — [ADR-0001 — Record architecture decisions](0001-record-architecture-decisions.md) @@ -76,3 +219,18 @@ - 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" 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/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/examples/quickstart.md b/examples/quickstart.md index f5e3cbf..c08d545 100644 --- a/examples/quickstart.md +++ b/examples/quickstart.md @@ -49,6 +49,7 @@ python skills/adr-toolkit/scripts/adr.py init --dir docs/decisions --json "operation": "init", "dry_run": false, "created": [ + ".adr-toolkit.json", "docs/decisions", "docs/decisions/adr-template.md", "docs/decisions/0001-record-architecture-decisions.md" @@ -120,7 +121,7 @@ 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 --locale en --json +python skills/adr-toolkit/scripts/adr.py index --dir docs/decisions --json ``` Both report `"ok": true`, and `docs/decisions/README.md` now reads: @@ -170,7 +171,8 @@ python skills/adr-toolkit/scripts/adr.py check --uncommitted --dir docs/decision "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"] + "resolutions": ["fix_code", "supersede_adr", "adjust_scope", "register_exception", "false_positive"], + "confidence": "VIOLATED" } ], "warnings": [] @@ -196,13 +198,71 @@ python skills/adr-toolkit/scripts/adr.py check --uncommitted --dir docs/decision "ok": true, "operation": "check", "diff": {"mode": "uncommitted", "ref": null, "files_changed": 1}, - "findings": [{"adr_id": "ADR-0002", "kind": "related"}], + "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 rule that fired before no longer does. +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 @@ -211,6 +271,7 @@ ADR governs, but the rule that fired before no longer does. `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 in full. + 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..a942d74 100644 --- a/handoff.md +++ b/handoff.md @@ -1,28 +1,56 @@ # 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) + +`v0.2.0` release gate is in progress — the only remaining work +(`improvements.md`'s single open item). Everything else (multilingual ADR +generation, CHECK hardening, exception mechanism, ADR search and +relationship navigation) is implemented and merged into this branch; see +`changelog.md` for what shipped and `git log` for exactly how. + +Work runs in the linked worktree at +`/Users/yangseunghyeon/orca/workspaces/ADR-toolkit/develop-2`, branch +`feature/v0.2.0-multilingual-and-check-confidence`, pushed to `origin`. + +## Next step + +**PR #2 open against `develop`, CI green (6/6), `mergeable`**: +https://github.com/SHcommit/ADR-toolkit/pull/2 + +Release gate steps, each needing separate explicit owner approval — do not +perform any of the remaining ones without asking first, "CI is green" does +not imply approval for the next step: + +1. ✅ **Done** — v0.2.0 version bump (`skills/adr-toolkit/VERSION` → + `0.2.0`, propagated via `scripts/sync_version.py`); CI re-ran green. +2. Merge PR #2 into `develop` (normal merge or squash — ask which). +3. Cut a `release/*` branch into `master` and back into `develop`, per + `AGENTS.md`'s Git Flow policy. +4. 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. + +Latest local verification (2026-08-31): `python3 -m pytest -q` → `386 +passed`; `sync_version.py --check` → exit 0; `adr.py validate` → 10 ADRs, +no errors; `git status --short` → clean. + +An open, undecided idea from this session: measuring how much LLM-token +input/output the deterministic core saves an agent versus doing the same +work by hand (e.g. CHECK returning a structured finding instead of the +agent scanning a full diff). Deliberately deferred — no scope or +destination decided. + +## Open risks + +- CHECK deliberately cannot prove prose, business rationale, or + organizational claims; those remain human-review evidence. The exception + mechanism extends this — an exception is recorded and annotated, never a + silent pass. +- Search/relationship matching is deterministic substring/exact/prefix + matching, untested at real scale (this repo has 10 ADRs); revisit + `project-roadmap.md`'s navigation-and-scale items if that changes. +- GitHub branch/tag protection is intentionally unavailable on the current + private plan; configure and API-verify after the repository goes public + (owner's own follow-up, not scheduled here). +- Version bump is done; release branch, push, and tag remain unauthorized + until the owner approves each step above. diff --git a/improvements.md b/improvements.md index 59254f8..00eada5 100644 --- a/improvements.md +++ b/improvements.md @@ -1,42 +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). -- [ ] `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..25e1a1b 100644 --- a/project-roadmap.md +++ b/project-roadmap.md @@ -1,155 +1,73 @@ # 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. +- Full cross-harness fixture/golden matrix for Codex, Gemini CLI, and + Antigravity CLI at the depth currently available for Claude Code. +- Harness-specific hook support beyond Claude Code SessionStart when equivalent + stable extension points exist. + +## ADR navigation and scale + +- ~~Add a general search command and relationship visibility.~~ **Done + (2026-08-31).** `adr.py search` (keyword now matches body, not just + title; tag/status/governed-path filters; deterministic ranking) and a + Relationships section in the generated index. See `improvements.md`'s + Done entry and `docs/superpowers/specs/2026-08-31-adr-search-and-relationships-design.md`. +- Render `related`/`supersedes` relationships as a rendered graph image (DOT/ + Mermaid), not just the text-based index lists search/index now provide. +- Test whether 500+ decisions require sharding, alternate indexes, or a + real search index (this repo has 10 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..e9ff071 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,6 @@ 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. 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..cb93296 100755 --- a/skills/adr-toolkit/scripts/adr.py +++ b/skills/adr-toolkit/scripts/adr.py @@ -12,15 +12,38 @@ create, diff, discover, + exception, 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 +51,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 +118,35 @@ 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_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 +165,8 @@ def build_parser() -> argparse.ArgumentParser: "supersede": supersede.run, "diff": diff.run, "check": check.run, + "exception": exception.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/index.py b/skills/adr-toolkit/scripts/commands/index.py index 3f5cf77..526f020 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 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,34 @@ 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']}\"" + ) + 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..865ccbd --- /dev/null +++ b/skills/adr-toolkit/scripts/core/relationships.py @@ -0,0 +1,71 @@ +"""Canonical ADR relationship model, shared by index.py and validate.py.""" +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 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..a122cc0 100644 --- a/tests/unit/test_adr_cli.py +++ b/tests/unit/test_adr_cli.py @@ -47,5 +47,41 @@ 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_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_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..f917870 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,43 @@ 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 + + +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] 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..97b9dde --- /dev/null +++ b/tests/unit/test_relationships.py @@ -0,0 +1,105 @@ +from scripts.core.relationships import ( + Relationship, + find_cycles, + missing_targets, + resolve, + supersession_mismatches, +) + +ENTRIES = [ + {"id": "ADR-0003", "related": [], "supersedes": [], "superseded_by": "ADR-0006"}, + {"id": "ADR-0006", "related": [], "supersedes": ["ADR-0003"], "superseded_by": None}, + {"id": "ADR-0007", "related": ["ADR-0002"], "supersedes": [], "superseded_by": None}, + {"id": "ADR-0002", "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)) == [] 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"])