From b4fab4113589103cb524de198bdbb45b7c3be1bf Mon Sep 17 00:00:00 2001 From: Elijah Duffy Date: Fri, 23 Oct 2026 13:56:50 -0700 Subject: [PATCH 01/13] docs: add docs format specification Co-Authored-By: Claude Sonnet 5.5 --- docs/docs-format.md | 236 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 docs/docs-format.md diff --git a/docs/docs-format.md b/docs/docs-format.md new file mode 100644 index 0000000..d43465c --- /dev/null +++ b/docs/docs-format.md @@ -0,0 +1,236 @@ +# Docs format + +This is the complete specification for architecture decision records (ADRs) and implementation plans checked by `docs check`. It is standalone: a file that follows this document is valid without running the tool. The words MUST, MUST NOT and MAY are used in their RFC 2119 sense. + +The ADR body follows the core sections of [MADR 4](https://adr.github.io/madr/). The frontmatter, naming and cross-reference rules are specific to this format. + +## 1. Files and directories + +- ADRs live in `docs/adr/`, plans in `docs/plans/`, relative to the repository root. Both are flat: subdirectories are not scanned and are errors. +- A missing directory is treated as empty. +- `README.md` in either directory is skipped. Every other entry MUST be a regular file whose name matches section 2 and ends in `.md`. Any other file, directory or name is an error. There are no other exceptions: no templates and no companion files; every `.md` file is a record. +- Files are UTF-8 without a byte order mark. LF and CRLF line endings are both accepted. + +## 2. Names and IDs + +A record file is named `YYYYMMDDTHHMMSSmmmZ-slug.md`, for example `20261023T204512345Z-use-example-queue.md`. + +- The timestamp is UTC to the millisecond and MUST be a real date and time. +- The slug is lowercase `a-z0-9` words joined by single hyphens: no leading, trailing or doubled hyphen, 1 to 80 characters. +- The filename without `.md` (the stem) is the record's permanent ID. Never rename a record, even to fix its slug; change the title instead. +- IDs MUST be unique across `docs/adr` and `docs/plans` (compared by the timestamp-and-slug stem). +- Tools create files exclusively (`O_EXCL`). If a file with the same millisecond exists, the creator advances by 1 ms until it is free. + +## 3. Frontmatter + +Every record starts with a YAML frontmatter block: the first line is exactly `---`, and the block ends at the next line that is exactly `---`. The block MUST be a mapping containing exactly the keys listed for the record type, no more, no fewer, each once. Key order is free. Empty lists are written `[]`. A key with no value (null) is an error. + +### 3.1 ADR keys + +| Key | Type | Rule | +| --- | --- | --- | +| `title` | string | Non-empty, single line. | +| `date` | `YYYY-MM-DD` | Valid date. Equals the date in the filename (UTC). | +| `status` | enum | `proposed`, `accepted`, `rejected`, `deprecated`, `superseded`. | +| `implementation` | enum | `not-started`, `in-progress`, `implemented`, `deferred`, `not-applicable`. | +| `summary` | string | Non-empty, single line. | +| `tags` | list of strings | Non-empty list. Each tag is lowercase `a-z0-9` words joined by single hyphens; no duplicates. | +| `supersedes` | list of references | May be empty. | +| `superseded_by` | list of references | May be empty. | +| `related` | list of references | May be empty. | +| `tracking` | list of references | May be empty. | +| `implemented_by` | list of references | May be empty. | + +### 3.2 Plan keys + +| Key | Type | Rule | +| --- | --- | --- | +| `status` | enum | `draft`, `active`, `completed`, `cancelled`. | +| `tracking` | list of references | May be empty unless `active`. | +| `adrs` | list of references | May be empty. | +| `completion` | list of references | May be empty unless `completed` or `cancelled`. | + +A plan has no `title` key; its title is its H1. + +### 3.3 References in frontmatter + +Every list item is a string. No list may contain duplicates. Which kinds of reference each key accepts: + +| Key | Accepts | +| --- | --- | +| `supersedes`, `superseded_by`, plan `adrs` | Repo-root paths to ADRs (`/docs/adr/.md`). | +| `related` | Repo-root paths to any record (`/docs/adr/...` or `/docs/plans/...`). | +| `tracking` (ADR and plan) | `http` or `https` URLs. | +| `implemented_by`, `completion` | `http` or `https` URLs (pull request, commit, release), or repo-root paths to existing files. | + +See section 7 for what a valid path or URL is. A record MUST NOT reference itself. + +Metadata values MUST be durable: a bare issue number such as `#123` and the placeholders "this PR", "this pull request" or "this commit" (any case) are rejected. Use a full URL. + +## 4. Status + +`status` is the decision; `implementation` is delivery of that decision. They are independent except where stated. + +ADR `status`: + +- `proposed`: under discussion; not binding. +- `accepted`: the decision stands. Accepting is the owner's act. +- `rejected`: considered and declined. Requires `implementation: not-applicable`. +- `deprecated`: once accepted, no longer recommended, with no replacement. +- `superseded`: replaced by another ADR. Requires non-empty `superseded_by`. + +ADR `implementation`: + +- `not-started`, `in-progress`, `deferred`: delivery state. +- `implemented`: requires non-empty `implemented_by`. +- `not-applicable`: nothing to deliver (required for `rejected`; allowed for any other status). + +Per-status requirements, summarized: + +| Condition | Requirement | +| --- | --- | +| `status: superseded` | `superseded_by` non-empty. | +| `superseded_by` non-empty | `status: superseded`. | +| `status: rejected` | `implementation: not-applicable`. | +| `implementation: implemented` | `implemented_by` non-empty. | + +Plan `status`: + +| Status | Meaning | `tracking` | `completion` | +| --- | --- | --- | --- | +| `draft` | Being written. | any | MUST be empty | +| `active` | Work under way. | MUST be non-empty | MUST be empty | +| `completed` | All steps done. | any | MUST be non-empty | +| `cancelled` | Abandoned. | any | MUST be non-empty | + +## 5. Supersession + +- If ADR A lists B in `supersedes`, then B MUST list A in `superseded_by`, and the reverse. Both records exist and are ADRs. +- Supersession MUST NOT form a cycle. +- The superseding record (A, the one whose `supersedes` lists the other) MUST have status `accepted`, or `superseded` or `deprecated` (it was accepted once and has since moved on). It MUST NOT be `proposed` or `rejected`. +- A superseded record keeps its original `date` and filename; only its `status` and `superseded_by` change. + +## 6. Body + +The body is Markdown (CommonMark) after the frontmatter. Headings inside fenced code blocks or HTML comments do not count. Required section headings are matched case-sensitively and exactly, at level 2 (`##`). A required section MUST appear exactly once and contain non-empty prose: at least one paragraph, list item or table cell of text. HTML comments and code (fenced, indented or inline) do not count as prose. Extra sections are allowed anywhere. + +### 6.1 ADR + +- The first heading is an H1 whose text equals `title` exactly. There is one H1. +- Required H2 sections: `Context and Problem Statement`, `Considered Options`, `Decision Outcome`, `Consequences`. +- Optional MADR sections (for example `Decision Drivers`, `Pros and Cons of the Options`, `More Information`) MAY be added. + +### 6.2 Plan + +- The first heading is a non-empty H1: the plan title. There is one H1. +- Required H2 sections: `Scope`, `Steps`, `Validation`. +- `Outcome` is required when `status` is `completed` or `cancelled` and MUST NOT appear otherwise. + +### 6.3 Placeholders + +A record is invalid if it still contains unfinished template text: `{{` or `}}` anywhere outside code, or the whole words `TODO` or `TBD` in prose outside code. + +## 7. References and links + +A reference is either a repo-root path or a URL. This applies to frontmatter reference lists and to every Markdown link and image destination in the body (inline, reference-style and autolinks; bare URLs in text are not checked). + +Repo-root path: + +- Starts with a single `/` and is relative to the repository root, for example `/docs/adr/20261023T204512345Z-use-example-queue.md`. +- The target MUST exist as a regular file inside the repository. Symlinks that resolve outside the repository are rejected. +- In body links only, a `#fragment` suffix is allowed; the fragment is not verified. In frontmatter, a fragment is an error. +- Rejected: relative paths (`foo.md`, `./foo.md`), any `..` segment, backslashes, and machine paths (`/Users/...`, `/home/...`, `C:\...`, `file:` URLs). + +URL: + +- Scheme `http` or `https` with a host, for example `https://example.com/org/repo/pull/42`. Credentials in the URL (`user:pass@`) are rejected. URLs are not fetched. + +Always allowed in body prose only: in-page anchors (`#section`) and `mailto:` links. Any other scheme is rejected. + +## 8. What `docs check` reports + +`docs check [--root DIR]` checks every record under `DIR` (default: the working directory) and prints one line per problem: + +``` +path:line: message +``` + +`path` is relative to the root using `/` separators. `line` is the best location of the problem (the key, heading or link), or `1` for file-level problems. Output is sorted by path, then line, then message. The exit status is `1` if any problem is reported, `2` for usage errors, and `0` otherwise, in which case a one-line summary of the records checked is printed. + +It reports violations of everything above: + +- unexpected files, directories or names; bad timestamps or slugs; duplicate IDs; +- missing, malformed or unterminated frontmatter; unknown, missing, duplicate or null keys; wrong types or values; `date` not matching the filename; +- status/implementation/field combinations from section 4; +- supersession problems from section 5, and self-references; +- missing, repeated or empty required sections; H1 problems; placeholders; +- invalid, unresolvable or non-durable references and links. + +It does not judge whether a decision is good, whether a record should have been written, or whether a link's fragment exists. + +## 9. Minimal valid examples + +`docs/adr/20261023T204512345Z-use-example-queue.md`: + +```markdown +--- +title: Use Example Queue for background jobs +date: 2026-10-23 +status: proposed +implementation: not-started +summary: Run background jobs on Example Queue. +tags: + - jobs +supersedes: [] +superseded_by: [] +related: [] +tracking: [] +implemented_by: [] +--- + +# Use Example Queue for background jobs + +## Context and Problem Statement + +Jobs currently run inline and block requests. + +## Considered Options + +* Example Queue +* Keep running jobs inline + +## Decision Outcome + +Chosen option: Example Queue, because it decouples jobs from requests. + +## Consequences + +* We operate one more service. +``` + +`docs/plans/20261023T210000000Z-adopt-example-queue.md`: + +```markdown +--- +status: draft +tracking: [] +adrs: + - /docs/adr/20261023T204512345Z-use-example-queue.md +completion: [] +--- + +# Adopt Example Queue + +## Scope + +Move the email job to Example Queue. + +## Steps + +1. Add the queue client. +2. Move the email job. + +## Validation + +The email job runs from the queue in staging. +``` -- 2.54.0 From 4cdc29bf3fd991495148e40f2a7de9c8a8cf70a2 Mon Sep 17 00:00:00 2001 From: Elijah Duffy Date: Fri, 23 Oct 2026 14:36:06 -0700 Subject: [PATCH 02/13] docs: revise format spec after review Co-Authored-By: Claude Sonnet 5.5 --- docs/docs-format.md | 41 +++++++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/docs/docs-format.md b/docs/docs-format.md index d43465c..2deeee5 100644 --- a/docs/docs-format.md +++ b/docs/docs-format.md @@ -2,13 +2,13 @@ This is the complete specification for architecture decision records (ADRs) and implementation plans checked by `docs check`. It is standalone: a file that follows this document is valid without running the tool. The words MUST, MUST NOT and MAY are used in their RFC 2119 sense. -The ADR body follows the core sections of [MADR 4](https://adr.github.io/madr/). The frontmatter, naming and cross-reference rules are specific to this format. +The ADR body uses the [MADR 4](https://adr.github.io/madr/) layout; `Consequences` is mandatory. The frontmatter, naming and cross-reference rules are specific to this format. ## 1. Files and directories - ADRs live in `docs/adr/`, plans in `docs/plans/`, relative to the repository root. Both are flat: subdirectories are not scanned and are errors. - A missing directory is treated as empty. -- `README.md` in either directory is skipped. Every other entry MUST be a regular file whose name matches section 2 and ends in `.md`. Any other file, directory or name is an error. There are no other exceptions: no templates and no companion files; every `.md` file is a record. +- Entries whose name starts with `.` (such as `.DS_Store` or editor swap files) are ignored. `README.md` in either directory is skipped. Every other entry MUST be a regular file whose name matches section 2 and ends in `.md`. Any other file, directory or name is an error. There are no other exceptions: no templates and no companion files; every `.md` file is a record. - Files are UTF-8 without a byte order mark. LF and CRLF line endings are both accepted. ## 2. Names and IDs @@ -19,12 +19,14 @@ A record file is named `YYYYMMDDTHHMMSSmmmZ-slug.md`, for example `20261023T2045 - The slug is lowercase `a-z0-9` words joined by single hyphens: no leading, trailing or doubled hyphen, 1 to 80 characters. - The filename without `.md` (the stem) is the record's permanent ID. Never rename a record, even to fix its slug; change the title instead. - IDs MUST be unique across `docs/adr` and `docs/plans` (compared by the timestamp-and-slug stem). -- Tools create files exclusively (`O_EXCL`). If a file with the same millisecond exists, the creator advances by 1 ms until it is free. +- Tools create `docs//.md` exclusively (`O_EXCL`) and advance the timestamp by 1 ms on `EEXIST`, or while any record in either directory has the same stem. This is tool behavior and does not affect validity. ## 3. Frontmatter Every record starts with a YAML frontmatter block: the first line is exactly `---`, and the block ends at the next line that is exactly `---`. The block MUST be a mapping containing exactly the keys listed for the record type, no more, no fewer, each once. Key order is free. Empty lists are written `[]`. A key with no value (null) is an error. +Scalar values are read as their literal text, whether quoted or not; for example, `date` may be quoted or unquoted, and `title: 2026` is the string `2026`. Anchors, aliases, explicit tags and multiple YAML documents are errors. "Single line" means the parsed value contains no line break. + ### 3.1 ADR keys | Key | Type | Rule | @@ -60,12 +62,13 @@ Every list item is a string. No list may contain duplicates. Which kinds of refe | --- | --- | | `supersedes`, `superseded_by`, plan `adrs` | Repo-root paths to ADRs (`/docs/adr/.md`). | | `related` | Repo-root paths to any record (`/docs/adr/...` or `/docs/plans/...`). | -| `tracking` (ADR and plan) | `http` or `https` URLs. | +| ADR `tracking` | `http` or `https` URLs, or repo-root paths to plans (`/docs/plans/.md`). | +| Plan `tracking` | `http` or `https` URLs. | | `implemented_by`, `completion` | `http` or `https` URLs (pull request, commit, release), or repo-root paths to existing files. | -See section 7 for what a valid path or URL is. A record MUST NOT reference itself. +See section 7 for what a valid path or URL is. A record MUST NOT reference itself. An ADR whose delivery is tracked by a plan lists the plan in `tracking`; other record-to-record links go in `related`. -Metadata values MUST be durable: a bare issue number such as `#123` and the placeholders "this PR", "this pull request" or "this commit" (any case) are rejected. Use a full URL. +Reference lists MUST be durable: an item that is a bare issue number such as `#123` or `owner/repo#123`, or one of the placeholders "this PR", "this pull request" or "this commit" (any case), is rejected. Use a full URL. This applies to whole list items in frontmatter reference lists only; `title`, `summary` and body text are not inspected for it. ## 4. Status @@ -108,38 +111,44 @@ Plan `status`: - If ADR A lists B in `supersedes`, then B MUST list A in `superseded_by`, and the reverse. Both records exist and are ADRs. - Supersession MUST NOT form a cycle. - The superseding record (A, the one whose `supersedes` lists the other) MUST have status `accepted`, or `superseded` or `deprecated` (it was accepted once and has since moved on). It MUST NOT be `proposed` or `rejected`. +- While a replacement is `proposed`, list the ADR it would replace in `related`. On acceptance, in the same change, move it to `supersedes` and set the target's `status: superseded` and `superseded_by`. - A superseded record keeps its original `date` and filename; only its `status` and `superseded_by` change. ## 6. Body -The body is Markdown (CommonMark) after the frontmatter. Headings inside fenced code blocks or HTML comments do not count. Required section headings are matched case-sensitively and exactly, at level 2 (`##`). A required section MUST appear exactly once and contain non-empty prose: at least one paragraph, list item or table cell of text. HTML comments and code (fenced, indented or inline) do not count as prose. Extra sections are allowed anywhere. +The body is Markdown (CommonMark) after the frontmatter. Headings inside fenced code blocks or HTML comments do not count. Required section headings are matched case-sensitively and exactly, at level 2 (`##`). A section runs until the next heading of level 2 or lower, so its subsections belong to it. A required section MUST appear exactly once and contain non-empty prose: at least one paragraph (including paragraphs inside blockquotes), list item or table cell of text. HTML comments and code (fenced, indented or inline) do not count as prose. Extra sections are allowed anywhere. + +Heading text is the raw source after the `#` markers, trimmed, with any closing `#` run removed; it is compared byte for byte, with no inline-markup rendering. Setext headings are read as the equivalent ATX heading. ### 6.1 ADR -- The first heading is an H1 whose text equals `title` exactly. There is one H1. -- Required H2 sections: `Context and Problem Statement`, `Considered Options`, `Decision Outcome`, `Consequences`. +- The first heading is an H1 whose heading text equals `title`. There is one H1. +- Required H2 sections: `Context and Problem Statement`, `Considered Options`, `Decision Outcome`. +- `Decision Outcome` MUST contain exactly one `### Consequences` (H3) subsection with non-empty prose, and prose of its own outside that subsection. A level-2 `## Consequences` is an error. - Optional MADR sections (for example `Decision Drivers`, `Pros and Cons of the Options`, `More Information`) MAY be added. ### 6.2 Plan - The first heading is a non-empty H1: the plan title. There is one H1. - Required H2 sections: `Scope`, `Steps`, `Validation`. -- `Outcome` is required when `status` is `completed` or `cancelled` and MUST NOT appear otherwise. +- `## Outcome` is optional for every status. Closed plans still require `completion` (section 4). ### 6.3 Placeholders -A record is invalid if it still contains unfinished template text: `{{` or `}}` anywhere outside code, or the whole words `TODO` or `TBD` in prose outside code. +A record is invalid if it still contains the exact marker `{{docs:todo}}` anywhere outside code. `docs new` writes this marker into each section it leaves for you to fill; replace it with content. No other text is treated as a placeholder, so words like TODO or TBD are fine. To mention the marker itself, put it in inline or fenced code. ## 7. References and links -A reference is either a repo-root path or a URL. This applies to frontmatter reference lists and to every Markdown link and image destination in the body (inline, reference-style and autolinks; bare URLs in text are not checked). +A reference is either a repo-root path or a URL. This applies to frontmatter reference lists and to every Markdown link and image destination in the body (inline, reference-style and autolinks; bare URLs in text are not checked). Link rules apply only to Markdown link destinations: forge autolink shortcuts in plain text (`#123`, `owner/repo#123`, `@user`, commit SHAs) and raw HTML (``, ``) are not inspected. Every link reference definition is checked, whether or not it is used. Repo-root path: -- Starts with a single `/` and is relative to the repository root, for example `/docs/adr/20261023T204512345Z-use-example-queue.md`. +- Starts with a single `/` and is relative to the repository root, for example `/docs/adr/20261023T204512345Z-use-example-queue.md`. A leading `//` (protocol-relative `//host/...`) is rejected. +- Repo-root links render correctly on Git forges (GitHub, Gitea) but not in every Markdown viewer. +- The destination is percent-decoded before the existence check (`%20` is a space). A `?query` is an error. - The target MUST exist as a regular file inside the repository. Symlinks that resolve outside the repository are rejected. - In body links only, a `#fragment` suffix is allowed; the fragment is not verified. In frontmatter, a fragment is an error. -- Rejected: relative paths (`foo.md`, `./foo.md`), any `..` segment, backslashes, and machine paths (`/Users/...`, `/home/...`, `C:\...`, `file:` URLs). +- Rejected: relative paths (`foo.md`, `./foo.md`), any `..` segment and backslashes. Other non-existent targets, including machine paths such as `/Users/...`, `/home/...` or `C:\...`, fail the existence rule; `file:` URLs fail the scheme rule. URL: @@ -163,7 +172,7 @@ It reports violations of everything above: - missing, malformed or unterminated frontmatter; unknown, missing, duplicate or null keys; wrong types or values; `date` not matching the filename; - status/implementation/field combinations from section 4; - supersession problems from section 5, and self-references; -- missing, repeated or empty required sections; H1 problems; placeholders; +- missing, repeated or empty required sections; H1 problems; `{{docs:todo}}` markers; - invalid, unresolvable or non-durable references and links. It does not judge whether a decision is good, whether a record should have been written, or whether a link's fragment exists. @@ -203,7 +212,7 @@ Jobs currently run inline and block requests. Chosen option: Example Queue, because it decouples jobs from requests. -## Consequences +### Consequences * We operate one more service. ``` -- 2.54.0 From f8a76a9ab0eb8763e03974cbfbf7352e4541752c Mon Sep 17 00:00:00 2001 From: Elijah Duffy Date: Fri, 23 Oct 2026 14:39:25 -0700 Subject: [PATCH 03/13] docs: apply architecture review pass 2 fixes Clarify null scalars, marker handling in frontmatter, the Decision Outcome prose rule, section extent wording and link rule scope. Co-Authored-By: Claude Sonnet 5.5 --- docs/docs-format.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/docs-format.md b/docs/docs-format.md index 2deeee5..6ab9954 100644 --- a/docs/docs-format.md +++ b/docs/docs-format.md @@ -25,7 +25,7 @@ A record file is named `YYYYMMDDTHHMMSSmmmZ-slug.md`, for example `20261023T2045 Every record starts with a YAML frontmatter block: the first line is exactly `---`, and the block ends at the next line that is exactly `---`. The block MUST be a mapping containing exactly the keys listed for the record type, no more, no fewer, each once. Key order is free. Empty lists are written `[]`. A key with no value (null) is an error. -Scalar values are read as their literal text, whether quoted or not; for example, `date` may be quoted or unquoted, and `title: 2026` is the string `2026`. Anchors, aliases, explicit tags and multiple YAML documents are errors. "Single line" means the parsed value contains no line break. +A plain `~`, `null`, `Null` or `NULL` is null, the same as an empty value, and is an error. Every other scalar is a string with no type resolution: its parsed text, whether quoted or not. For example, `date` may be quoted or unquoted, `title: 2026` is the string `2026`, and `title: "null"` is the string `null`. A block scalar (`|`, `>`) is its parsed text. Anchors, aliases, explicit tags and multiple YAML documents are errors. "Single line" means the parsed value contains no line break. ### 3.1 ADR keys @@ -116,7 +116,7 @@ Plan `status`: ## 6. Body -The body is Markdown (CommonMark) after the frontmatter. Headings inside fenced code blocks or HTML comments do not count. Required section headings are matched case-sensitively and exactly, at level 2 (`##`). A section runs until the next heading of level 2 or lower, so its subsections belong to it. A required section MUST appear exactly once and contain non-empty prose: at least one paragraph (including paragraphs inside blockquotes), list item or table cell of text. HTML comments and code (fenced, indented or inline) do not count as prose. Extra sections are allowed anywhere. +The body is Markdown (CommonMark) after the frontmatter. Headings inside fenced code blocks or HTML comments do not count. Required section headings are matched case-sensitively and exactly, at level 2 (`##`). A section runs until the next heading of level 1 or 2, so its subsections belong to it. A required section MUST appear exactly once and contain non-empty prose: at least one paragraph (including paragraphs inside blockquotes), list item or table cell of text. HTML comments and code (fenced, indented or inline) do not count as prose. Extra sections are allowed anywhere. Heading text is the raw source after the `#` markers, trimmed, with any closing `#` run removed; it is compared byte for byte, with no inline-markup rendering. Setext headings are read as the equivalent ATX heading. @@ -124,7 +124,7 @@ Heading text is the raw source after the `#` markers, trimmed, with any closing - The first heading is an H1 whose heading text equals `title`. There is one H1. - Required H2 sections: `Context and Problem Statement`, `Considered Options`, `Decision Outcome`. -- `Decision Outcome` MUST contain exactly one `### Consequences` (H3) subsection with non-empty prose, and prose of its own outside that subsection. A level-2 `## Consequences` is an error. +- `Decision Outcome` MUST contain exactly one `### Consequences` (H3) subsection with non-empty prose, and at least one prose block before its first H3 subsection. A level-2 `## Consequences` is an error. - Optional MADR sections (for example `Decision Drivers`, `Pros and Cons of the Options`, `More Information`) MAY be added. ### 6.2 Plan @@ -135,11 +135,11 @@ Heading text is the raw source after the `#` markers, trimmed, with any closing ### 6.3 Placeholders -A record is invalid if it still contains the exact marker `{{docs:todo}}` anywhere outside code. `docs new` writes this marker into each section it leaves for you to fill; replace it with content. No other text is treated as a placeholder, so words like TODO or TBD are fine. To mention the marker itself, put it in inline or fenced code. +A record is invalid if it still contains the exact marker `{{docs:todo}}` anywhere outside code, including inside a frontmatter string value or list item (write it quoted there: `summary: "{{docs:todo}}"`). A list item equal to the marker reports only the marker error, not the format error for its key. `docs new` writes this marker into each field and section it leaves for you to fill; replace it with content. No other text is treated as a placeholder, so words like TODO or TBD are fine. To mention the marker itself, put it in inline or fenced code. ## 7. References and links -A reference is either a repo-root path or a URL. This applies to frontmatter reference lists and to every Markdown link and image destination in the body (inline, reference-style and autolinks; bare URLs in text are not checked). Link rules apply only to Markdown link destinations: forge autolink shortcuts in plain text (`#123`, `owner/repo#123`, `@user`, commit SHAs) and raw HTML (``, ``) are not inspected. Every link reference definition is checked, whether or not it is used. +A reference is either a repo-root path or a URL. This applies to frontmatter reference lists and to every Markdown link and image destination in the body (inline, reference-style and autolinks; bare URLs in text are not checked). In the body, link rules apply only to Markdown link destinations: forge autolink shortcuts in plain text (`#123`, `owner/repo#123`, `@user`, commit SHAs) and raw HTML (``, ``) are not inspected. Every link reference definition is checked, whether or not it is used. Repo-root path: @@ -148,7 +148,7 @@ Repo-root path: - The destination is percent-decoded before the existence check (`%20` is a space). A `?query` is an error. - The target MUST exist as a regular file inside the repository. Symlinks that resolve outside the repository are rejected. - In body links only, a `#fragment` suffix is allowed; the fragment is not verified. In frontmatter, a fragment is an error. -- Rejected: relative paths (`foo.md`, `./foo.md`), any `..` segment and backslashes. Other non-existent targets, including machine paths such as `/Users/...`, `/home/...` or `C:\...`, fail the existence rule; `file:` URLs fail the scheme rule. +- Rejected: relative paths (`foo.md`, `./foo.md`), any `..` segment and backslashes. Other non-existent targets, including machine paths such as `/Users/...` or `/home/...`, fail the existence rule; `file:` URLs fail the scheme rule. URL: -- 2.54.0 From b0428256bd0067540f22b19f6b897583e6341e00 Mon Sep 17 00:00:00 2001 From: Elijah Duffy Date: Fri, 23 Oct 2026 14:46:56 -0700 Subject: [PATCH 04/13] chore: add module, license and repository guides Co-Authored-By: Claude Sonnet 5.5 --- AGENTS.md | 9 +++++++++ CLAUDE.md | 1 + LICENSE | 28 ++++++++++++++++++++++++++++ README.md | 28 ++++++++++++++++++++++++++++ go.mod | 8 ++++++++ go.sum | 4 ++++ 6 files changed, 78 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 LICENSE create mode 100644 go.mod create mode 100644 go.sum diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ce6ee12 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,9 @@ +# AGENTS.md + +Go module `go.auvem.com/corestack`. Reading map: + +- Build and test: `go build ./...`, `go vet ./...`, `go test -race ./...`; format with `gofmt -w .`. +- Docs format (the spec `docs check` implements): `docs/docs-format.md`. +- Check records: `go run ./cmd/docs check`. Create one: `go run ./cmd/docs new adr|plan "Title"`. +- Decisions live in `docs/adr/`, plans in `docs/plans/`. Records are permanent; never rename them. +- Dependencies are limited to `go.yaml.in/yaml/v3` and `github.com/yuin/goldmark`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8b7cbf4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +See [AGENTS.md](AGENTS.md). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..61c60c3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2026, Elijah Duffy + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md index a9ecfc1..a8fa370 100644 --- a/README.md +++ b/README.md @@ -1 +1,29 @@ # corestack go + +Shared Go building blocks. The first one is `docs`, a checker for architecture decision records (ADRs) and implementation plans. + +## docs + +Install: + +```sh +go install go.auvem.com/corestack/cmd/docs@latest +``` + +Use, from a repository root: + +```sh +docs new adr "Use Example Queue for background jobs" # creates docs/adr/.md +docs new plan "Adopt Example Queue" # creates docs/plans/.md +docs check [--root DIR] # prints path:line: message; exit 1 on problems +``` + +The format is specified in [docs/docs-format.md](docs/docs-format.md); that document is the complete rule set. Exit status is `0` when every record is valid, `1` when problems are reported and `2` for usage errors. + +## Status + +No support is guaranteed. Breaking changes, including to the format rules, may happen before 1.0 and are listed in the release notes. + +## License + +BSD-3-Clause, see [LICENSE](LICENSE). diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0218f6a --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module go.auvem.com/corestack + +go 1.27.1 + +require ( + github.com/yuin/goldmark v1.8.6 + go.yaml.in/yaml/v3 v3.0.5 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1b18895 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4= +github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= -- 2.54.0 From 750b7449b0a6d59acb1a8dd41d1a1c816763843a Mon Sep 17 00:00:00 2001 From: Elijah Duffy Date: Fri, 23 Oct 2026 14:46:56 -0700 Subject: [PATCH 05/13] feat: add docs checker and record templates docs new creates ADRs and plans from embedded templates; docs check reports path:line: message problems per docs/docs-format.md. Co-Authored-By: Claude Sonnet 5.5 --- cmd/docs/main.go | 66 ++++++ cmd/docs/main_test.go | 80 ++++++++ internal/docs/adr.go | 45 ++++ internal/docs/body.go | 285 +++++++++++++++++++++++++ internal/docs/check.go | 128 ++++++++++++ internal/docs/check_test.go | 354 ++++++++++++++++++++++++++++++++ internal/docs/frontmatter.go | 229 +++++++++++++++++++++ internal/docs/graph.go | 71 +++++++ internal/docs/id.go | 126 ++++++++++++ internal/docs/links_test.go | 117 +++++++++++ internal/docs/new_test.go | 104 ++++++++++ internal/docs/plan.go | 23 +++ internal/docs/refs.go | 176 ++++++++++++++++ internal/docs/templates/adr.md | 32 +++ internal/docs/templates/plan.md | 20 ++ 15 files changed, 1856 insertions(+) create mode 100644 cmd/docs/main.go create mode 100644 cmd/docs/main_test.go create mode 100644 internal/docs/adr.go create mode 100644 internal/docs/body.go create mode 100644 internal/docs/check.go create mode 100644 internal/docs/check_test.go create mode 100644 internal/docs/frontmatter.go create mode 100644 internal/docs/graph.go create mode 100644 internal/docs/id.go create mode 100644 internal/docs/links_test.go create mode 100644 internal/docs/new_test.go create mode 100644 internal/docs/plan.go create mode 100644 internal/docs/refs.go create mode 100644 internal/docs/templates/adr.md create mode 100644 internal/docs/templates/plan.md diff --git a/cmd/docs/main.go b/cmd/docs/main.go new file mode 100644 index 0000000..871355a --- /dev/null +++ b/cmd/docs/main.go @@ -0,0 +1,66 @@ +// Command docs creates and checks ADR and plan records. See docs/docs-format.md. +package main + +import ( + "flag" + "fmt" + "io" + "os" + "time" + + "go.auvem.com/corestack/internal/docs" +) + +const usage = `usage: + docs new adr|plan "Title" + docs check [--root DIR] +` + +func main() { os.Exit(run(os.Args[1:], os.Stdout, os.Stderr, time.Now())) } + +// run returns the exit status: 0 success, 1 problems or failure, 2 usage error. +func run(args []string, stdout, stderr io.Writer, now time.Time) int { + if len(args) == 0 { + fmt.Fprint(stderr, usage) + return 2 + } + switch args[0] { + case "new": + if len(args) != 3 || (args[1] != "adr" && args[1] != "plan") { + fmt.Fprint(stderr, usage) + return 2 + } + path, err := docs.New(".", args[1], args[2], now) + if err != nil { + fmt.Fprintf(stderr, "docs: %v\n", err) + return 1 + } + fmt.Fprintln(stdout, path) + return 0 + case "check": + fs := flag.NewFlagSet("docs check", flag.ContinueOnError) + fs.SetOutput(stderr) + root := fs.String("root", ".", "repository root") + if err := fs.Parse(args[1:]); err != nil || fs.NArg() != 0 { + if fs.NArg() != 0 { + fmt.Fprint(stderr, usage) + } + return 2 + } + res, err := docs.Check(*root) + if err != nil { + fmt.Fprintf(stderr, "docs: %v\n", err) + return 1 + } + for _, p := range res.Problems { + fmt.Fprintln(stdout, p) + } + if len(res.Problems) > 0 { + return 1 + } + fmt.Fprintf(stdout, "ok: %d records checked\n", res.Records) + return 0 + } + fmt.Fprint(stderr, usage) + return 2 +} diff --git a/cmd/docs/main_test.go b/cmd/docs/main_test.go new file mode 100644 index 0000000..0530516 --- /dev/null +++ b/cmd/docs/main_test.go @@ -0,0 +1,80 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +var now = time.Date(2026, 10, 23, 20, 45, 12, 345_000_000, time.UTC) + +func do(t *testing.T, args ...string) (code int, stdout, stderr string) { + t.Helper() + var out, errw bytes.Buffer + code = run(args, &out, &errw, now) + return code, out.String(), errw.String() +} + +func TestCLI(t *testing.T) { + root := t.TempDir() + t.Chdir(root) + + if code, out, _ := do(t, "check"); code != 0 || out != "ok: 0 records checked\n" { + t.Fatalf("empty check: %d %q", code, out) + } + code, out, _ := do(t, "new", "adr", "Use X") + if code != 0 || out != "docs/adr/20261023T204512345Z-use-x.md\n" { + t.Fatalf("new: %d %q", code, out) + } + code, out, _ = do(t, "check") + var want strings.Builder + for _, line := range []string{"6", "8", "20", "24", "28", "32"} { // numeric, not lexical, order + want.WriteString("docs/adr/20261023T204512345Z-use-x.md:" + line + ": unfinished placeholder {{docs:todo}}\n") + } + if code != 1 || out != want.String() { + t.Fatalf("check with markers: %d %q", code, out) + } + // --root checks another directory; the working directory is not used. + t.Chdir(t.TempDir()) + if code, _, _ := do(t, "check", "--root", root); code != 1 { + t.Errorf("--root: %d", code) + } + if code, out, _ := do(t, "check"); code != 0 || !strings.HasPrefix(out, "ok: ") { + t.Errorf("cwd root: %d %q", code, out) + } +} + +func TestCLIExitCodes(t *testing.T) { + t.Chdir(t.TempDir()) + must := func(err error) { + t.Helper() + if err != nil { + t.Fatal(err) + } + } + must(os.MkdirAll("docs/adr", 0o755)) + must(os.WriteFile(filepath.Join("docs", "adr", "bad.md"), nil, 0o644)) + for _, tt := range []struct { + args []string + code int + }{ + {nil, 2}, + {[]string{"bogus"}, 2}, + {[]string{"new"}, 2}, + {[]string{"new", "adr"}, 2}, + {[]string{"new", "note", "T"}, 2}, + {[]string{"new", "adr", "T", "extra"}, 2}, + {[]string{"new", "adr", "!!!"}, 1}, + {[]string{"check", "--bogus"}, 2}, + {[]string{"check", "extra"}, 2}, + {[]string{"check", "--root", "missing-dir"}, 1}, + {[]string{"check"}, 1}, // bad.md is not a valid name + } { + if code, _, stderr := do(t, tt.args...); code != tt.code { + t.Errorf("docs %v = %d, want %d (stderr %q)", tt.args, code, tt.code, stderr) + } + } +} diff --git a/internal/docs/adr.go b/internal/docs/adr.go new file mode 100644 index 0000000..a2ea2c4 --- /dev/null +++ b/internal/docs/adr.go @@ -0,0 +1,45 @@ +package docs + +import "time" + +var ( + adrStatuses = []string{"proposed", "accepted", "rejected", "deprecated", "superseded"} + adrImpls = []string{"not-started", "in-progress", "implemented", "deferred", "not-applicable"} + adrRefs = []refSpec{ + {"supersedes", kAdr}, {"superseded_by", kAdr}, {"related", kAdr | kPlan}, + {"tracking", kPlan | kURL}, {"implemented_by", kURL | kFile}, + } +) + +// adr checks the ADR frontmatter and returns its title for the body check. +func (c *checker) adr(r *record, m *fm) (string, bool) { + m.keys("title", "date", "status", "implementation", "summary", "tags", "supersedes", "superseded_by", "related", "tracking", "implemented_by") + title, titleOK := m.text("title") + if d, line, ok := m.scalar("date"); ok { + if _, err := time.Parse(time.DateOnly, d); err != nil || len(d) != len(time.DateOnly) { + c.add(r.path, line, "date must be a valid YYYY-MM-DD date") + } else if d != r.date { + c.add(r.path, line, "date %s does not match the filename date %s", d, r.date) + } + } + status, statusOK := m.enum("status", adrStatuses...) + impl, implOK := m.enum("implementation", adrImpls...) + m.text("summary") + m.tags() + m.refs(adrRefs...) + r.status = status + if statusOK { + if n := len(r.lists["superseded_by"]); status == "superseded" && n == 0 { + m.problemf("status", "status superseded requires superseded_by") + } else if status != "superseded" && n > 0 { + m.problemf("superseded_by", "superseded_by requires status superseded") + } + if status == "rejected" && implOK && impl != "not-applicable" { + m.problemf("implementation", "status rejected requires implementation not-applicable") + } + } + if implOK && impl == "implemented" && len(r.lists["implemented_by"]) == 0 { + m.problemf("implementation", "implementation implemented requires implemented_by") + } + return title, titleOK +} diff --git a/internal/docs/body.go b/internal/docs/body.go new file mode 100644 index 0000000..2fd20e5 --- /dev/null +++ b/internal/docs/body.go @@ -0,0 +1,285 @@ +package docs + +import ( + "bytes" + "regexp" + "strings" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/text" +) + +var ( + adrSections = []string{"Context and Problem Statement", "Considered Options", "Decision Outcome"} + planSections = []string{"Scope", "Steps", "Validation"} +) + +type heading struct { + level int + text string + line int + at int // index among the document's top-level nodes +} + +type doc struct { + c *checker + r *record + src []byte + startLine int + kids []ast.Node + heads []heading +} + +func (d *doc) lineAt(off int) int { + return d.startLine + bytes.Count(d.src[:min(off, len(d.src))], []byte("\n")) +} + +// lineOf is the line of the first text under n, or of the nearest block that has lines. +func (d *doc) lineOf(n ast.Node) int { + var off = -1 + _ = ast.Walk(n, func(c ast.Node, enter bool) (ast.WalkStatus, error) { + if t, ok := c.(*ast.Text); ok && enter { + off = t.Segment.Start + return ast.WalkStop, nil + } + return ast.WalkContinue, nil + }) + for p := n; off < 0 && p != nil; p = p.Parent() { + if p.Type() == ast.TypeBlock && p.Lines().Len() > 0 { + off = p.Lines().At(0).Start + } + } + if off < 0 { + return d.startLine + } + return d.lineAt(off) +} + +func (c *checker) body(r *record, src string, startLine int, title string, titleOK bool) { + b := []byte(src) + ctx := parser.NewContext() + root := goldmark.DefaultParser().Parse(text.NewReader(b), parser.WithContext(ctx)) + d := &doc{c: c, r: r, src: b, startLine: startLine} + for n := root.FirstChild(); n != nil; n = n.NextSibling() { + if h, ok := n.(*ast.Heading); ok { + line := d.startLine + if h.Lines().Len() > 0 { + line = d.lineAt(h.Lines().At(0).Start) + } + d.heads = append(d.heads, heading{h.Level, strings.TrimSpace(string(h.Lines().Value(b))), line, len(d.kids)}) + } + d.kids = append(d.kids, n) + } + d.placeholders(root) + d.links(root, ctx) + d.title(title, titleOK) + if r.kind == "adr" { + for _, name := range adrSections { + d.section(name) + } + d.decisionOutcome() + } else { + for _, name := range planSections { + d.section(name) + } + } +} + +// title checks the single leading H1. +func (d *doc) title(title string, titleOK bool) { + h1s := 0 + for _, h := range d.heads { + if h.level == 1 { + if h1s++; h1s > 1 { + d.c.add(d.r.path, h.line, "more than one H1") + } + } + } + switch first := d.heads; { + case len(first) == 0 || first[0].level != 1: + d.c.add(d.r.path, d.startLine, "the first heading must be an H1") + case d.r.kind == "plan" && first[0].text == "": + d.c.add(d.r.path, first[0].line, "the H1 (plan title) must not be empty") + case d.r.kind == "adr" && titleOK && first[0].text != title: + d.c.add(d.r.path, first[0].line, "H1 %q must equal title %q", first[0].text, title) + } +} + +// extent returns the top-level nodes after heading h up to the next heading of level <= upTo. +func (d *doc) extent(h heading, upTo int) []ast.Node { + end := len(d.kids) + for _, o := range d.heads { + if o.at > h.at && o.level <= upTo { + end = o.at + break + } + } + return d.kids[h.at+1 : end] +} + +// section finds the one H2 called name and checks that it has prose. +func (d *doc) section(name string) (heading, bool) { + var found []heading + for _, h := range d.heads { + if h.level == 2 && h.text == name { + found = append(found, h) + } + } + switch { + case len(found) == 0: + d.c.add(d.r.path, 1, "missing required section %q", name) + case len(found) > 1: + d.c.add(d.r.path, found[1].line, "section %q appears more than once", name) + case !d.hasProse(d.extent(found[0], 2)): + d.c.add(d.r.path, found[0].line, "section %q has no prose", name) + return found[0], true + default: + return found[0], true + } + return heading{}, false +} + +func (d *doc) decisionOutcome() { + for _, h := range d.heads { + if h.level == 2 && h.text == "Consequences" { + d.c.add(d.r.path, h.line, "Consequences must be a ### subsection of Decision Outcome, not a ## section") + } + } + var outcome []heading + for _, h := range d.heads { + if h.level == 2 && h.text == "Decision Outcome" { + outcome = append(outcome, h) + } + } + if len(outcome) != 1 { + return + } + h := outcome[0] + nodes := d.extent(h, 2) + // Prose before the first subsection is the decision itself. + pre := nodes + for i, n := range nodes { + if _, ok := n.(*ast.Heading); ok { + pre = nodes[:i] + break + } + } + if !d.hasProse(pre) { + d.c.add(d.r.path, h.line, "Decision Outcome needs prose before its first subsection") + } + var cons []heading + for _, s := range d.heads { + if s.level == 3 && s.text == "Consequences" && s.at > h.at && s.at < h.at+len(nodes)+1 { + cons = append(cons, s) + } + } + switch { + case len(cons) == 0: + d.c.add(d.r.path, h.line, "Decision Outcome must contain a ### Consequences subsection") + case len(cons) > 1: + d.c.add(d.r.path, cons[1].line, "### Consequences appears more than once") + case !d.hasProse(d.extent(cons[0], 3)): + d.c.add(d.r.path, cons[0].line, "### Consequences has no prose") + } +} + +// hasProse reports whether nodes hold a paragraph with text outside code, images and raw HTML. +func (d *doc) hasProse(nodes []ast.Node) bool { + for _, n := range nodes { + found := false + _ = ast.Walk(n, func(c ast.Node, enter bool) (ast.WalkStatus, error) { + switch c.(type) { + case *ast.Paragraph, *ast.TextBlock: + if enter && strings.TrimSpace(d.inlineText(c)) != "" { + found = true + return ast.WalkStop, nil + } + return ast.WalkSkipChildren, nil + } + return ast.WalkContinue, nil + }) + if found { + return true + } + } + return false +} + +func (d *doc) inlineText(n ast.Node) string { + var s strings.Builder + for c := n.FirstChild(); c != nil; c = c.NextSibling() { + switch t := c.(type) { + case *ast.Text: + s.Write(t.Segment.Value(d.src)) + case *ast.String: + s.Write(t.Value) + case *ast.CodeSpan, *ast.RawHTML, *ast.Image: + default: + s.WriteString(d.inlineText(c)) + } + } + return s.String() +} + +// placeholders reports every marker outside code. +func (d *doc) placeholders(root ast.Node) { + var code [][2]int + _ = ast.Walk(root, func(n ast.Node, enter bool) (ast.WalkStatus, error) { + switch n.(type) { + case *ast.FencedCodeBlock, *ast.CodeBlock: + for i := range n.Lines().Len() { + seg := n.Lines().At(i) + code = append(code, [2]int{seg.Start, seg.Stop}) + } + case *ast.CodeSpan: + for c := n.FirstChild(); c != nil; c = c.NextSibling() { + if t, ok := c.(*ast.Text); ok { + code = append(code, [2]int{t.Segment.Start, t.Segment.Stop}) + } + } + } + return ast.WalkContinue, nil + }) + for off := 0; ; { + i := strings.Index(string(d.src[off:]), marker) + if i < 0 { + return + } + at := off + i + off = at + len(marker) + inCode := false + for _, r := range code { + inCode = inCode || at >= r[0] && at < r[1] + } + if !inCode { + d.c.add(d.r.path, d.lineAt(at), "unfinished placeholder %s", marker) + } + } +} + +// links checks every link, image and autolink destination and every reference definition. +func (d *doc) links(root ast.Node, ctx parser.Context) { + _ = ast.Walk(root, func(n ast.Node, enter bool) (ast.WalkStatus, error) { + if !enter { + return ast.WalkContinue, nil + } + switch t := n.(type) { + case *ast.Link: + d.c.checkLink(d.r, d.lineOf(n), string(t.Destination)) + case *ast.Image: + d.c.checkLink(d.r, d.lineOf(n), string(t.Destination)) + case *ast.AutoLink: + d.c.checkLink(d.r, d.lineOf(n), string(t.URL(d.src))) + } + return ast.WalkContinue, nil + }) + for _, ref := range ctx.References() { + line := d.startLine + if m := regexp.MustCompile(`(?im)^ {0,3}\[` + regexp.QuoteMeta(string(ref.Label())) + `\]:`).FindIndex(d.src); m != nil { + line = d.lineAt(m[0]) + } + d.c.checkLink(d.r, line, string(ref.Destination())) + } +} diff --git a/internal/docs/check.go b/internal/docs/check.go new file mode 100644 index 0000000..097618b --- /dev/null +++ b/internal/docs/check.go @@ -0,0 +1,128 @@ +// Package docs creates and checks the ADR and plan records described in docs/docs-format.md. +package docs + +import ( + "bytes" + "embed" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "unicode/utf8" +) + +//go:embed templates/*.md +var templates embed.FS + +var dirs = []struct{ kind, dir string }{{"adr", "docs/adr"}, {"plan", "docs/plans"}} + +// Problem is one violation of the format. Line is 1 for file-level problems. +type Problem struct { + Path string + Line int + Msg string +} + +func (p Problem) String() string { return fmt.Sprintf("%s:%d: %s", p.Path, p.Line, p.Msg) } + +// Result is the outcome of Check: the number of records read and the sorted problems. +type Result struct { + Records int + Problems []Problem +} + +type record struct { + path, kind, stem, date string + status string + lists map[string][]item + fm *fm +} + +type checker struct { + root, realRoot string + problems []Problem +} + +func (c *checker) add(path string, line int, format string, args ...any) { + c.problems = append(c.problems, Problem{path, line, fmt.Sprintf(format, args...)}) +} + +// Check validates every record under root. The error is for I/O failures only. +func Check(root string) (Result, error) { + real, err := filepath.EvalSymlinks(root) + if err != nil { + return Result{}, err + } + c := &checker{root: root, realRoot: real} + var recs []*record + for _, d := range dirs { + entries, err := os.ReadDir(filepath.Join(root, d.dir)) + if os.IsNotExist(err) { + continue + } else if err != nil { + return Result{}, err + } + for _, e := range entries { + name := e.Name() + if strings.HasPrefix(name, ".") || name == "README.md" { + continue + } + rel := d.dir + "/" + name + if !e.Type().IsRegular() { + c.add(rel, 1, "must be a regular file (subdirectories, symlinks and other entries are not allowed)") + continue + } + stem, at, msg := parseName(name) + if msg != "" { + c.add(rel, 1, "%s", msg) + continue + } + r := &record{path: rel, kind: d.kind, stem: stem, date: at.Format("2006-01-02"), lists: map[string][]item{}} + if err := c.record(r); err != nil { + return Result{}, err + } + recs = append(recs, r) + } + } + c.graph(recs) + slices.SortFunc(c.problems, func(a, b Problem) int { + return strings.Compare(fmt.Sprintf("%s\x00%09d\x00%s", a.Path, a.Line, a.Msg), fmt.Sprintf("%s\x00%09d\x00%s", b.Path, b.Line, b.Msg)) + }) + return Result{len(recs), c.problems}, nil +} + +func (c *checker) record(r *record) error { + data, err := os.ReadFile(filepath.Join(c.root, filepath.FromSlash(r.path))) + if err != nil { + return err + } + if bytes.HasPrefix(data, []byte("\xef\xbb\xbf")) { + c.add(r.path, 1, "file must not start with a byte order mark") + return nil + } + if !utf8.Valid(data) { + c.add(r.path, 1, "file must be valid UTF-8") + return nil + } + src := strings.ReplaceAll(string(data), "\r\n", "\n") + front, body, bodyLine, msg := splitFrontmatter(src) + if msg != "" { + c.add(r.path, 1, "%s", msg) + return nil + } + m, ok := c.parseFrontmatter(r, front) + if !ok { + return nil + } + r.fm = m + var title string + var titleOK bool + if r.kind == "adr" { + title, titleOK = c.adr(r, m) + } else { + c.plan(r, m) + } + c.body(r, body, bodyLine, title, titleOK) + return nil +} diff --git a/internal/docs/check_test.go b/internal/docs/check_test.go new file mode 100644 index 0000000..79450d7 --- /dev/null +++ b/internal/docs/check_test.go @@ -0,0 +1,354 @@ +package docs + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +const ( + aID = "20261023T204512345Z-use-queue" + aPath = "docs/adr/" + aID + ".md" + bPath = "docs/adr/20261024T000000000Z-second.md" + pPath = "docs/plans/20261023T210000000Z-adopt-queue.md" + + // Line numbers in the expectations below refer to these documents. + adrBase = `--- +title: Use Queue +date: 2026-10-23 +status: proposed +implementation: not-started +summary: Run jobs on a queue. +tags: + - jobs +supersedes: [] +superseded_by: [] +related: [] +tracking: [] +implemented_by: [] +--- + +# Use Queue + +## Context and Problem Statement + +Jobs block requests. + +## Considered Options + +* Queue +* Inline + +## Decision Outcome + +Chosen option: Queue. + +### Consequences + +* One more service. +` + // bBase is a second ADR (dated 2026-10-24) that may supersede or be superseded. + bBase = `--- +title: Second +date: 2026-10-24 +status: accepted +implementation: not-started +summary: Second. +tags: + - jobs +supersedes: [] +superseded_by: [] +related: [] +tracking: [] +implemented_by: [] +--- + +# Second + +## Context and Problem Statement + +Why. + +## Considered Options + +* A + +## Decision Outcome + +Chosen option: A. + +### Consequences + +* Some. +` + planBase = `--- +status: draft +tracking: [] +adrs: [] +completion: [] +--- + +# Adopt Queue + +## Scope + +Move the job. + +## Steps + +1. Do it. + +## Validation + +It runs. +` +) + +// sub replaces old with new in s once; a stale expectation fails loudly. +func sub(s string, pairs ...string) string { + for i := 0; i < len(pairs); i += 2 { + if !strings.Contains(s, pairs[i]) { + panic("fixture does not contain " + pairs[i]) + } + s = strings.Replace(s, pairs[i], pairs[i+1], 1) + } + return s +} + +func tree(t *testing.T, files, links map[string]string) string { + t.Helper() + root, outside := t.TempDir(), t.TempDir() + if err := os.WriteFile(filepath.Join(outside, "f.md"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + for p, content := range files { + full := filepath.Join(root, p) + if strings.HasSuffix(p, "/") { + must(t, os.MkdirAll(full, 0o755)) + continue + } + must(t, os.MkdirAll(filepath.Dir(full), 0o755)) + must(t, os.WriteFile(full, []byte(content), 0o644)) + } + for p, target := range links { + must(t, os.MkdirAll(filepath.Dir(filepath.Join(root, p)), 0o755)) + must(t, os.Symlink(strings.Replace(target, "@outside", outside, 1), filepath.Join(root, p))) + } + return root +} + +func must(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatal(err) + } +} + +var wantRe = regexp.MustCompile(`^(.*?:\d+): (.*)$`) + +// expect requires got and want to pair up one to one. A want is "path:line: message substring"; the path may be a suffix. +func expect(t *testing.T, got []Problem, want []string) { + t.Helper() + left := append([]Problem(nil), got...) + for _, w := range want { + m := wantRe.FindStringSubmatch(w) + found := false + for i, p := range left { + if strings.HasSuffix(fmt.Sprintf("%s:%d", p.Path, p.Line), m[1]) && strings.Contains(p.Msg, m[2]) { + left = append(left[:i], left[i+1:]...) + found = true + break + } + } + if !found { + t.Errorf("missing problem %q in %v", w, got) + } + } + for _, p := range left { + t.Errorf("unexpected problem %s", p) + } +} + +var support = map[string]string{ + bPath: bBase, + pPath: planBase, + "docs/spec.txt": "spec", + "docs/sp ace.md": "space", +} + +// withSupport adds the shared targets to a case's files. +func withSupport(files map[string]string) map[string]string { + all := map[string]string{} + for k, v := range support { + all[k] = v + } + for k, v := range files { + all[k] = v + } + return all +} + +func TestCheck(t *testing.T) { + long := strings.Repeat("a", 81) + tests := []struct { + name string + files map[string]string + links map[string]string + want []string + }{ + // 1. Files and directories + {name: "dot entries and README are skipped", files: map[string]string{"docs/adr/.DS_Store": "x", "docs/adr/README.md": "x", "docs/plans/README.md": "x"}}, + {name: "subdirectory", files: map[string]string{"docs/adr/sub/": ""}, want: []string{"docs/adr/sub:1: regular file"}}, + {name: "non-md file", files: map[string]string{"docs/plans/notes.txt": "x"}, want: []string{"docs/plans/notes.txt:1: name must be"}}, + {name: "template-like name", files: map[string]string{"docs/adr/template.md": "x"}, want: []string{"docs/adr/template.md:1: name must be"}}, + {name: "symlinked record", files: map[string]string{"docs/spec.txt": "x"}, links: map[string]string{"docs/adr/20261023T204512345Z-s.md": "@outside/f.md"}, want: []string{"20261023T204512345Z-s.md:1: regular file"}}, + {name: "CRLF accepted", files: map[string]string{aPath: strings.ReplaceAll(adrBase, "\n", "\r\n")}}, + {name: "BOM", files: map[string]string{aPath: "\xef\xbb\xbf" + adrBase}, want: []string{aPath + ":1: byte order mark"}}, + {name: "invalid UTF-8", files: map[string]string{aPath: adrBase + "\xff\n"}, want: []string{aPath + ":1: UTF-8"}}, + + // 2. Names and IDs + {name: "invalid timestamp", files: map[string]string{"docs/adr/20261323T204512345Z-x.md": adrBase}, want: []string{"20261323T204512345Z-x.md:1: invalid UTC timestamp"}}, + {name: "slug double hyphen", files: map[string]string{"docs/adr/20261023T204512345Z-a--b.md": adrBase}, want: []string{"a--b.md:1: slug must be"}}, + {name: "slug uppercase", files: map[string]string{"docs/adr/20261023T204512345Z-Ab.md": adrBase}, want: []string{"Ab.md:1: slug must be"}}, + {name: "slug trailing hyphen", files: map[string]string{"docs/adr/20261023T204512345Z-ab-.md": adrBase}, want: []string{"ab-.md:1: slug must be"}}, + {name: "slug 80 characters", files: map[string]string{"docs/adr/20261023T204512345Z-" + long[:80] + ".md": adrBase}}, + {name: "slug 81 characters", files: map[string]string{"docs/adr/20261023T204512345Z-" + long + ".md": adrBase}, want: []string{".md:1: longer than 80"}}, + {name: "duplicate ID across directories", files: map[string]string{"docs/adr/20261023T210000000Z-adopt-queue.md": adrBase, pPath: planBase}, + want: []string{"docs/adr/20261023T210000000Z-adopt-queue.md:1: duplicate ID", pPath + ":1: duplicate ID"}}, + + // 3. Frontmatter + {name: "no frontmatter", files: map[string]string{aPath: "# Use Queue\n"}, want: []string{aPath + ":1: --- frontmatter"}}, + {name: "unterminated frontmatter", files: map[string]string{aPath: "---\ntitle: x\n"}, want: []string{aPath + ":1: not closed"}}, + {name: "empty frontmatter", files: map[string]string{aPath: "---\n---\n# x\n"}, want: []string{aPath + ":2: empty"}}, + {name: "frontmatter is a list", files: map[string]string{aPath: "---\n- a\n---\n"}, want: []string{aPath + ":2: mapping"}}, + {name: "invalid YAML", files: map[string]string{aPath: sub(adrBase, "title: Use Queue", "title: [")}, want: []string{aPath + ":3: invalid YAML"}}, + {name: "multiple YAML documents", files: map[string]string{aPath: sub(adrBase, "title: Use Queue\n", "title: Use Queue\n...\nx: y\n")}, want: []string{aPath + ":2: single YAML document"}}, + {name: "anchor and alias", files: map[string]string{aPath: sub(adrBase, "supersedes: []\nsuperseded_by: []", "supersedes: &a []\nsuperseded_by: *a")}, want: []string{aPath + ":9: anchors", aPath + ":10: aliases"}}, + {name: "explicit tag", files: map[string]string{aPath: sub(adrBase, "summary: Run", "summary: !!str Run")}, want: []string{aPath + ":6: explicit"}}, + {name: "unknown key", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by: []\nextra: x")}, want: []string{aPath + ":14: unknown key"}}, + {name: "missing key", files: map[string]string{aPath: sub(adrBase, "related: []\n", "")}, want: []string{aPath + ":1: missing key \"related\""}}, + {name: "duplicate key", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by: []\nsummary: again")}, want: []string{aPath + ":14: duplicate key"}}, + {name: "null tilde", files: map[string]string{aPath: sub(adrBase, "summary: Run jobs on a queue.", "summary: ~")}, want: []string{aPath + ":6: must not be null"}}, + {name: "null empty", files: map[string]string{aPath: sub(adrBase, "summary: Run jobs on a queue.", "summary:")}, want: []string{aPath + ":6: must not be null"}}, + {name: "null word", files: map[string]string{aPath: sub(adrBase, "summary: Run jobs on a queue.", "summary: Null")}, want: []string{aPath + ":6: must not be null"}}, + {name: "scalars are literal text", files: map[string]string{aPath: sub(adrBase, "title: Use Queue", "title: 2026", "# Use Queue", "# 2026", "date: 2026-10-23", `date: "2026-10-23"`, "summary: Run jobs on a queue.", `summary: "null"`)}}, + {name: "list item is null", files: map[string]string{aPath: sub(adrBase, "related: []", "related:\n - ~")}, want: []string{aPath + ":12: must not be null"}}, + + // 3.1 ADR keys + {name: "empty title", files: map[string]string{aPath: sub(adrBase, "title: Use Queue", `title: ""`)}, want: []string{aPath + ":2: title must be a non-empty single line"}}, + {name: "multi-line summary", files: map[string]string{aPath: sub(adrBase, "summary: Run jobs on a queue.", "summary: |\n a\n b")}, want: []string{aPath + ":6: summary must be a non-empty single line"}}, + {name: "title is a list", files: map[string]string{aPath: sub(adrBase, "title: Use Queue", "title: [a]")}, want: []string{aPath + ":2: title must be a string"}}, + {name: "invalid date", files: map[string]string{aPath: sub(adrBase, "date: 2026-10-23", "date: 2026-13-40")}, want: []string{aPath + ":3: valid YYYY-MM-DD"}}, + {name: "date differs from filename", files: map[string]string{aPath: sub(adrBase, "date: 2026-10-23", "date: 2026-10-24")}, want: []string{aPath + ":3: does not match"}}, + {name: "invalid status", files: map[string]string{aPath: sub(adrBase, "status: proposed", "status: done")}, want: []string{aPath + ":4: status must be one of"}}, + {name: "invalid implementation", files: map[string]string{aPath: sub(adrBase, "implementation: not-started", "implementation: done")}, want: []string{aPath + ":5: implementation must be one of"}}, + {name: "empty tags", files: map[string]string{aPath: sub(adrBase, "tags:\n - jobs", "tags: []")}, want: []string{aPath + ":7: tags must not be empty"}}, + {name: "tags is a string", files: map[string]string{aPath: sub(adrBase, "tags:\n - jobs", "tags: jobs")}, want: []string{aPath + ":7: tags must be a list"}}, + {name: "bad tag", files: map[string]string{aPath: sub(adrBase, " - jobs", " - Bad_Tag")}, want: []string{aPath + ":8: tag"}}, + {name: "duplicate tag", files: map[string]string{aPath: sub(adrBase, " - jobs", " - jobs\n - jobs")}, want: []string{aPath + ":9: duplicate tag"}}, + + // 3.2 Plan keys + {name: "plan has unknown title key", files: map[string]string{pPath: sub(planBase, "status: draft", "status: draft\ntitle: x")}, want: []string{pPath + ":3: unknown key"}}, + {name: "plan invalid status", files: map[string]string{pPath: sub(planBase, "status: draft", "status: done")}, want: []string{pPath + ":2: status must be one of"}}, + + // 3.3 References + {name: "related accepts ADR and plan", files: map[string]string{aPath: sub(adrBase, "related: []", "related:\n - /"+bPath+"\n - /"+pPath)}}, + {name: "ADR tracking accepts URL and plan", files: map[string]string{aPath: sub(adrBase, "tracking: []", "tracking:\n - https://example.com/issues/1\n - /"+pPath)}}, + {name: "ADR tracking rejects ADR path", files: map[string]string{aPath: sub(adrBase, "tracking: []", "tracking:\n - /"+bPath)}, want: []string{aPath + ":13: tracking"}}, + {name: "plan tracking rejects path", files: map[string]string{pPath: sub(planBase, "tracking: []", "tracking:\n - /"+aPath)}, want: []string{pPath + ":4: tracking"}}, + {name: "plan tracking accepts URL", files: map[string]string{pPath: sub(planBase, "tracking: []", "tracking:\n - https://example.com/issues/1")}}, + {name: "plan adrs accepts ADR", files: map[string]string{aPath: adrBase, pPath: sub(planBase, "adrs: []", "adrs:\n - /"+aPath)}}, + {name: "plan adrs rejects plan", files: map[string]string{pPath: sub(planBase, "adrs: []", "adrs:\n - /"+pPath)}, want: []string{pPath + ":5: adrs"}}, + {name: "implemented_by accepts URL and file", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by:\n - https://example.com/pr/1\n - /docs/spec.txt")}}, + {name: "implemented_by missing file", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by:\n - /docs/missing.txt")}, want: []string{aPath + ":14: does not exist"}}, + {name: "reference to missing ADR", files: map[string]string{aPath: sub(adrBase, "related: []", "related:\n - /docs/adr/20261023T000000000Z-gone.md")}, want: []string{aPath + ":12: does not exist"}}, + {name: "duplicate reference", files: map[string]string{aPath: sub(adrBase, "related: []", "related:\n - /"+bPath+"\n - /"+bPath)}, want: []string{aPath + ":13: duplicate reference"}}, + {name: "self reference", files: map[string]string{aPath: sub(adrBase, "related: []", "related:\n - /"+aPath)}, want: []string{aPath + ":12: itself"}}, + {name: "bare issue number", files: map[string]string{aPath: sub(adrBase, "tracking: []", "tracking:\n - \"#123\"")}, want: []string{aPath + ":13: not durable"}}, + {name: "owner repo issue number", files: map[string]string{aPath: sub(adrBase, "tracking: []", "tracking:\n - example/repo#123")}, want: []string{aPath + ":13: not durable"}}, + {name: "this PR placeholder", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by:\n - This PR")}, want: []string{aPath + ":14: not durable"}}, + {name: "fragment in frontmatter", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by:\n - /docs/spec.txt#x")}, want: []string{aPath + ":14: fragments"}}, + {name: "credentials in frontmatter URL", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by:\n - https://u:p@example.com/x")}, want: []string{aPath + ":14: credentials"}}, + {name: "relative path in frontmatter", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by:\n - ../docs/spec.txt")}, want: []string{aPath + ":14: relative"}}, + + // 4. Status + {name: "superseded needs superseded_by", files: map[string]string{aPath: sub(adrBase, "status: proposed", "status: superseded")}, want: []string{aPath + ":4: requires superseded_by"}}, + {name: "superseded_by needs superseded", files: map[string]string{aPath: sub(adrBase, "superseded_by: []", "superseded_by:\n - /"+bPath), bPath: sub(bBase, "supersedes: []", "supersedes:\n - /"+aPath)}, want: []string{aPath + ":10: requires status superseded"}}, + {name: "rejected needs not-applicable", files: map[string]string{aPath: sub(adrBase, "status: proposed", "status: rejected")}, want: []string{aPath + ":5: not-applicable"}}, + {name: "rejected not-applicable", files: map[string]string{aPath: sub(adrBase, "status: proposed", "status: rejected", "implementation: not-started", "implementation: not-applicable")}}, + {name: "implemented needs implemented_by", files: map[string]string{aPath: sub(adrBase, "implementation: not-started", "implementation: implemented")}, want: []string{aPath + ":5: requires implemented_by"}}, + {name: "implemented with implemented_by", files: map[string]string{aPath: sub(adrBase, "implementation: not-started", "implementation: implemented", "implemented_by: []", "implemented_by:\n - https://example.com/pr/1")}}, + {name: "active plan needs tracking", files: map[string]string{pPath: sub(planBase, "status: draft", "status: active")}, want: []string{pPath + ":2: requires tracking"}}, + {name: "active plan valid", files: map[string]string{pPath: sub(planBase, "status: draft", "status: active", "tracking: []", "tracking:\n - https://example.com/issues/1")}}, + {name: "active plan with completion", files: map[string]string{pPath: sub(planBase, "status: draft", "status: active", "tracking: []", "tracking:\n - https://example.com/issues/1", "completion: []", "completion:\n - https://example.com/pr/1")}, want: []string{pPath + ":6: completion must be empty"}}, + {name: "draft plan with completion", files: map[string]string{pPath: sub(planBase, "completion: []", "completion:\n - https://example.com/pr/1")}, want: []string{pPath + ":5: completion must be empty"}}, + {name: "completed plan needs completion", files: map[string]string{pPath: sub(planBase, "status: draft", "status: completed")}, want: []string{pPath + ":2: requires completion"}}, + {name: "cancelled plan needs completion", files: map[string]string{pPath: sub(planBase, "status: draft", "status: cancelled")}, want: []string{pPath + ":2: requires completion"}}, + {name: "completed plan valid", files: map[string]string{pPath: sub(planBase, "status: draft", "status: completed", "completion: []", "completion:\n - https://example.com/pr/1")}}, + + // 5. Supersession + {name: "supersession valid", files: map[string]string{ + aPath: sub(adrBase, "status: proposed", "status: superseded", "superseded_by: []", "superseded_by:\n - /"+bPath), + bPath: sub(bBase, "supersedes: []", "supersedes:\n - /"+aPath)}}, + {name: "supersedes not reciprocated", files: map[string]string{aPath: sub(adrBase, "status: proposed", "status: accepted", "supersedes: []", "supersedes:\n - /"+bPath)}, want: []string{aPath + ":10: must list this record in superseded_by"}}, + {name: "superseded_by not reciprocated", files: map[string]string{aPath: sub(adrBase, "status: proposed", "status: superseded", "superseded_by: []", "superseded_by:\n - /"+bPath)}, want: []string{aPath + ":11: must list this record in supersedes"}}, + {name: "superseder is proposed", files: map[string]string{ + aPath: sub(adrBase, "supersedes: []", "supersedes:\n - /"+bPath), + bPath: sub(bBase, "status: accepted", "status: superseded", "superseded_by: []", "superseded_by:\n - /"+aPath)}, want: []string{aPath + ":9: must be accepted"}}, + {name: "superseder is rejected", files: map[string]string{ + aPath: sub(adrBase, "status: proposed", "status: rejected", "implementation: not-started", "implementation: not-applicable", "supersedes: []", "supersedes:\n - /"+bPath), + bPath: sub(bBase, "status: accepted", "status: superseded", "superseded_by: []", "superseded_by:\n - /"+aPath)}, want: []string{aPath + ":9: must be accepted"}}, + {name: "supersession cycle", files: map[string]string{ + aPath: sub(adrBase, "status: proposed", "status: superseded", "supersedes: []", "supersedes:\n - /"+bPath, "superseded_by: []", "superseded_by:\n - /"+bPath), + bPath: sub(bBase, "status: accepted", "status: superseded", "supersedes: []", "supersedes:\n - /"+aPath, "superseded_by: []", "superseded_by:\n - /"+aPath)}, want: []string{bPath + ":10: cycle"}}, + + // 6. Body + {name: "no H1", files: map[string]string{aPath: sub(adrBase, "# Use Queue\n\n", "")}, want: []string{aPath + ":15: first heading must be an H1"}}, + {name: "H1 differs from title", files: map[string]string{aPath: sub(adrBase, "# Use Queue", "# Other")}, want: []string{aPath + ":16: must equal title"}}, + {name: "two H1s", files: map[string]string{aPath: adrBase + "\n# Again\n"}, want: []string{aPath + ":35: more than one H1"}}, + {name: "missing required section", files: map[string]string{aPath: sub(adrBase, "## Considered Options\n\n* Queue\n* Inline\n\n", "")}, want: []string{aPath + ":1: missing required section \"Considered Options\""}}, + {name: "repeated section", files: map[string]string{aPath: adrBase + "\n## Considered Options\n\nMore.\n"}, want: []string{aPath + ":35: appears more than once"}}, + {name: "heading in code does not count", files: map[string]string{aPath: sub(adrBase, "## Considered Options", "```\n## Considered Options\n```")}, want: []string{aPath + ":1: missing required section \"Considered Options\""}}, + {name: "section with only a comment", files: map[string]string{aPath: sub(adrBase, "* Queue\n* Inline", "")}, want: []string{aPath + ":22: has no prose"}}, + {name: "section with only code", files: map[string]string{aPath: sub(adrBase, "* Queue\n* Inline", "```\nQueue\n```")}, want: []string{aPath + ":22: has no prose"}}, + {name: "section with only inline code", files: map[string]string{aPath: sub(adrBase, "* Queue\n* Inline", "`Queue`")}, want: []string{aPath + ":22: has no prose"}}, + {name: "subsection prose counts for the section", files: map[string]string{aPath: sub(adrBase, "Jobs block requests.", "### Detail\n\nJobs block requests.")}}, + {name: "setext, closing hashes and blockquote", files: map[string]string{aPath: sub(adrBase, "## Context and Problem Statement", "## Context and Problem Statement ##", "## Considered Options\n\n* Queue\n* Inline", "Considered Options\n------\n\n> Queue or inline.")}}, + {name: "extra sections allowed", files: map[string]string{aPath: adrBase + "\n## More Information\n\nSee above.\n"}}, + {name: "Consequences as H2", files: map[string]string{aPath: sub(adrBase, "### Consequences", "## Consequences")}, + want: []string{aPath + ":31: not a ## section", aPath + ":27: must contain a ### Consequences"}}, + {name: "missing Consequences", files: map[string]string{aPath: sub(adrBase, "### Consequences\n\n* One more service.\n", "")}, want: []string{aPath + ":27: must contain a ### Consequences"}}, + {name: "repeated Consequences", files: map[string]string{aPath: adrBase + "\n### Consequences\n\nAgain.\n"}, want: []string{aPath + ":35: appears more than once"}}, + {name: "Consequences without prose", files: map[string]string{aPath: sub(adrBase, "* One more service.", "")}, want: []string{aPath + ":31: has no prose"}}, + {name: "Decision Outcome without own prose", files: map[string]string{aPath: sub(adrBase, "Chosen option: Queue.\n\n", "")}, want: []string{aPath + ":27: needs prose before"}}, + {name: "plan missing section", files: map[string]string{pPath: sub(planBase, "## Steps\n\n1. Do it.\n\n", "")}, want: []string{pPath + ":1: missing required section \"Steps\""}}, + {name: "plan empty H1", files: map[string]string{pPath: sub(planBase, "# Adopt Queue", "#")}, want: []string{pPath + ":7: H1"}}, + {name: "plan Outcome is optional", files: map[string]string{pPath: planBase + "\n## Outcome\n\nDone.\n"}}, + + // 6.3 Placeholders + {name: "marker in body", files: map[string]string{aPath: sub(adrBase, "Jobs block requests.", "{{docs:todo}}")}, want: []string{aPath + ":20: unfinished placeholder"}}, + {name: "marker in comment", files: map[string]string{aPath: adrBase + "\n\n"}, want: []string{aPath + ":35: unfinished placeholder"}}, + {name: "marker in code", files: map[string]string{aPath: adrBase + "\nUse `{{docs:todo}}` as the marker.\n\n```\n{{docs:todo}}\n```\n"}}, + {name: "TODO and TBD are fine", files: map[string]string{aPath: adrBase + "\nTODO and TBD.\n"}}, + {name: "marker in frontmatter string", files: map[string]string{aPath: sub(adrBase, "summary: Run jobs on a queue.", `summary: "{{docs:todo}}"`)}, want: []string{aPath + ":6: unfinished placeholder"}}, + {name: "marker as tag reports only the marker", files: map[string]string{aPath: sub(adrBase, " - jobs", ` - "{{docs:todo}}"`)}, want: []string{aPath + ":8: unfinished placeholder"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, err := Check(tree(t, withSupport(tt.files), tt.links)) + must(t, err) + expect(t, res.Problems, tt.want) + }) + } +} + +func TestCheckEmptyRoot(t *testing.T) { + res, err := Check(t.TempDir()) + must(t, err) + if res.Records != 0 || len(res.Problems) != 0 { + t.Fatalf("got %+v", res) + } +} diff --git a/internal/docs/frontmatter.go b/internal/docs/frontmatter.go new file mode 100644 index 0000000..a0c1a74 --- /dev/null +++ b/internal/docs/frontmatter.go @@ -0,0 +1,229 @@ +package docs + +import ( + "errors" + "io" + "regexp" + "slices" + "strconv" + "strings" + + "go.yaml.in/yaml/v3" +) + +const marker = "{{docs:todo}}" + +var yamlLineRe = regexp.MustCompile(`line (\d+)`) + +type field struct{ key, val *yaml.Node } + +type item struct { + v string + line int +} + +// fm reads the typed fields of one record's frontmatter mapping. +type fm struct { + c *checker + r *record + f map[string]field +} + +// splitFrontmatter returns the frontmatter text, the body and the file line the body starts on. +func splitFrontmatter(src string) (front, body string, bodyLine int, msg string) { + lines := strings.Split(src, "\n") + if lines[0] != "---" { + return "", "", 0, "file must start with a --- frontmatter line" + } + for i := 1; i < len(lines); i++ { + if lines[i] == "---" { + return strings.Join(lines[1:i], "\n"), strings.Join(lines[i+1:], "\n"), i + 2, "" + } + } + return "", "", 0, "frontmatter is not closed by a --- line" +} + +// parseFrontmatter decodes the mapping and rejects anything but plain keys and values. +func (c *checker) parseFrontmatter(r *record, front string) (*fm, bool) { + report := func(line int, format string, args ...any) { c.add(r.path, line, format, args...) } + dec := yaml.NewDecoder(strings.NewReader(front)) + var doc, extra yaml.Node + if err := dec.Decode(&doc); err != nil || len(doc.Content) == 0 { + if err == nil || errors.Is(err, io.EOF) { + report(2, "frontmatter is empty") + } else { + line := 2 + if m := yamlLineRe.FindStringSubmatch(err.Error()); m != nil { + n, _ := strconv.Atoi(m[1]) + line += n - 1 + } + report(line, "invalid YAML: %v", strings.TrimPrefix(err.Error(), "yaml: ")) + } + return nil, false + } + if err := dec.Decode(&extra); !errors.Is(err, io.EOF) { + report(2, "frontmatter must be a single YAML document") + return nil, false + } + root := doc.Content[0] + if root.Kind != yaml.MappingNode { + report(root.Line+1, "frontmatter must be a mapping") + return nil, false + } + ok := true + var walk func(n *yaml.Node) + walk = func(n *yaml.Node) { + switch { + case n.Kind == yaml.AliasNode: + report(n.Line+1, "YAML aliases are not allowed") + ok = false + case n.Anchor != "": + report(n.Line+1, "YAML anchors are not allowed") + ok = false + case n.Style&yaml.TaggedStyle != 0: + report(n.Line+1, "explicit YAML tags are not allowed") + ok = false + } + for _, ch := range n.Content { + walk(ch) + } + } + walk(root) + m := &fm{c: c, r: r, f: map[string]field{}} + for i := 0; i+1 < len(root.Content); i += 2 { + k, v := root.Content[i], root.Content[i+1] + if k.Kind != yaml.ScalarNode { + report(k.Line+1, "keys must be plain strings") + ok = false + } else if _, dup := m.f[k.Value]; dup { + report(k.Line+1, "duplicate key %q", k.Value) + ok = false + } else { + m.f[k.Value] = field{k, v} + } + markers(v, report) + } + return m, ok +} + +// markers reports an unfinished placeholder in any string value or list item. +func markers(n *yaml.Node, report func(int, string, ...any)) { + if n.Kind == yaml.ScalarNode && strings.Contains(n.Value, marker) { + report(n.Line+1, "unfinished placeholder %s", marker) + } + for _, ch := range n.Content { + markers(ch, report) + } +} + +// keys reports missing and unknown keys. +func (m *fm) keys(want ...string) { + for _, k := range want { + if _, ok := m.f[k]; !ok { + m.c.add(m.r.path, 1, "missing key %q", k) + } + } + for k, f := range m.f { + if !slices.Contains(want, k) { + m.c.add(m.r.path, f.key.Line+1, "unknown key %q", k) + } + } +} + +func (m *fm) line(key string) int { + if f, ok := m.f[key]; ok { + return f.key.Line + 1 + } + return 1 +} + +func (m *fm) isNull(key string, n *yaml.Node) bool { + if n.Kind == yaml.ScalarNode && n.Tag == "!!null" { + m.c.add(m.r.path, n.Line+1, "%s must not be null", key) + return true + } + return false +} + +// scalar returns the literal text of a non-null scalar value. +func (m *fm) scalar(key string) (string, int, bool) { + f, ok := m.f[key] + if !ok || m.isNull(key, f.val) { + return "", 0, false + } + if f.val.Kind != yaml.ScalarNode { + m.c.add(m.r.path, f.key.Line+1, "%s must be a string", key) + return "", 0, false + } + return f.val.Value, f.val.Line + 1, true +} + +// text is a non-empty single-line string. +func (m *fm) text(key string) (string, bool) { + v, line, ok := m.scalar(key) + if ok && (strings.TrimSpace(v) == "" || strings.ContainsAny(v, "\r\n")) { + m.c.add(m.r.path, line, "%s must be a non-empty single line", key) + return v, false + } + return v, ok +} + +func (m *fm) enum(key string, allowed ...string) (string, bool) { + v, line, ok := m.scalar(key) + if ok && !slices.Contains(allowed, v) { + m.c.add(m.r.path, line, "%s must be one of %s", key, strings.Join(allowed, ", ")) + return v, false + } + return v, ok +} + +// list returns the items of a sequence of non-null scalars. +func (m *fm) list(key string) ([]item, bool) { + f, ok := m.f[key] + if !ok || m.isNull(key, f.val) { + return nil, false + } + if f.val.Kind != yaml.SequenceNode { + m.c.add(m.r.path, f.key.Line+1, "%s must be a list", key) + return nil, false + } + var items []item + for _, n := range f.val.Content { + if m.isNull(key, n) { + continue + } + if n.Kind != yaml.ScalarNode { + m.c.add(m.r.path, n.Line+1, "%s items must be strings", key) + continue + } + items = append(items, item{n.Value, n.Line + 1}) + } + m.r.lists[key] = items + return items, true +} + +// tags checks the ADR tags list. +func (m *fm) tags() { + items, ok := m.list("tags") + if !ok { + return + } + if len(items) == 0 { + m.c.add(m.r.path, m.line("tags"), "tags must not be empty") + } + seen := map[string]bool{} + for _, it := range items { + switch { + case it.v == marker: + case !slugRe.MatchString(it.v): + m.c.add(m.r.path, it.line, "tag %q must be lowercase a-z0-9 words joined by single hyphens", it.v) + case seen[it.v]: + m.c.add(m.r.path, it.line, "duplicate tag %q", it.v) + } + seen[it.v] = true + } +} + +func (m *fm) problemf(key, format string, args ...any) { + m.c.add(m.r.path, m.line(key), format, args...) +} diff --git a/internal/docs/graph.go b/internal/docs/graph.go new file mode 100644 index 0000000..ae73813 --- /dev/null +++ b/internal/docs/graph.go @@ -0,0 +1,71 @@ +package docs + +import ( + "slices" + "strings" +) + +// graph checks cross-record rules: unique IDs and supersession. +func (c *checker) graph(recs []*record) { + byStem := map[string][]*record{} + byPath := map[string]*record{} + for _, r := range recs { + byStem[r.stem] = append(byStem[r.stem], r) + byPath["/"+r.path] = r + } + for _, group := range byStem { + for _, r := range group { + for _, o := range group { + if o != r { + c.add(r.path, 1, "duplicate ID %s, also used by %s", r.stem, o.path) + } + } + } + } + adrs := recs[:0:0] + for _, r := range recs { + if r.kind == "adr" && r.fm != nil { + adrs = append(adrs, r) + } + } + for _, r := range adrs { + if len(r.lists["supersedes"]) > 0 && !slices.Contains([]string{"accepted", "superseded", "deprecated"}, r.status) { + c.add(r.path, r.fm.line("supersedes"), "a record that supersedes another must be accepted, superseded or deprecated, not %s", r.status) + } + for _, e := range [][2]string{{"supersedes", "superseded_by"}, {"superseded_by", "supersedes"}} { + for _, it := range r.lists[e[0]] { + if o := byPath[it.v]; o != nil && o.kind == "adr" && !slices.ContainsFunc(o.lists[e[1]], func(x item) bool { return x.v == "/"+r.path }) { + c.add(r.path, it.line, "%s lists %s, which must list this record in %s", e[0], it.v, e[1]) + } + } + } + } + // Cycles over supersedes edges: report once, on the first record (in path order) that closes one. + const ( + visiting = 1 + done = 2 + ) + state := map[*record]int{} + var visit func(r *record) + visit = func(r *record) { + state[r] = visiting + for _, it := range r.lists["supersedes"] { + o := byPath[it.v] + if o == nil || o.kind != "adr" { + continue + } + switch state[o] { + case visiting: + c.add(r.path, it.line, "supersession cycle through %s", strings.TrimPrefix(it.v, "/")) + case 0: + visit(o) + } + } + state[r] = done + } + for _, r := range adrs { + if state[r] == 0 { + visit(r) + } + } +} diff --git a/internal/docs/id.go b/internal/docs/id.go new file mode 100644 index 0000000..93cd108 --- /dev/null +++ b/internal/docs/id.go @@ -0,0 +1,126 @@ +package docs + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "time" +) + +const ( + timestampLayout = "20060102T150405.000Z" + maxSlug = 80 +) + +var ( + nameRe = regexp.MustCompile(`^(\d{8}T\d{9}Z)-(.+)\.md$`) + slugRe = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) +) + +// parseName validates a record filename and returns its stem, UTC timestamp +// and a problem message (empty when valid). +func parseName(name string) (stem string, at time.Time, msg string) { + m := nameRe.FindStringSubmatch(name) + if m == nil { + return "", at, "name must be YYYYMMDDTHHMMSSmmmZ-slug.md" + } + at, err := time.Parse(timestampLayout, m[1][:15]+"."+m[1][15:]) + if err != nil { + return "", at, "name has an invalid UTC timestamp" + } + switch { + case !slugRe.MatchString(m[2]): + return "", at, "slug must be lowercase a-z0-9 words joined by single hyphens" + case len(m[2]) > maxSlug: + return "", at, fmt.Sprintf("slug is longer than %d characters", maxSlug) + } + return strings.TrimSuffix(name, ".md"), at, "" +} + +// slugify lowercases title and joins its ASCII letter and digit runs with hyphens. +func slugify(title string) string { + var b strings.Builder + for _, r := range strings.ToLower(title) { + if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' { + b.WriteRune(r) + } else if b.Len() > 0 && !strings.HasSuffix(b.String(), "-") { + b.WriteByte('-') + } + } + s := b.String() + return strings.Trim(s[:min(len(s), maxSlug)], "-") +} + +// New creates a record of the given kind ("adr" or "plan") under root and +// returns its root-relative slash path. The file is created exclusively; the +// timestamp advances by 1 ms on a collision. +func New(root, kind, title string, now time.Time) (string, error) { + title = strings.TrimSpace(title) + if title == "" || strings.ContainsAny(title, "\r\n") { + return "", errors.New("title must be non-empty and a single line") + } + slug := slugify(title) + if slug == "" { + return "", errors.New("title needs at least one ASCII letter or digit for the file name") + } + var dir string + for _, d := range dirs { + if d.kind == kind { + dir = d.dir + } + } + tmpl, err := templates.ReadFile("templates/" + kind + ".md") + if err != nil { + return "", fmt.Errorf("unknown record type %q", kind) + } + quoted, err := json.Marshal(title) // JSON strings are valid YAML double-quoted scalars + if err != nil { + return "", err + } + used, err := stems(root) + if err != nil { + return "", err + } + if err := os.MkdirAll(filepath.Join(root, dir), 0o755); err != nil { + return "", err + } + at := now.UTC().Truncate(time.Millisecond) + for ; ; at = at.Add(time.Millisecond) { + stem := strings.Replace(at.Format(timestampLayout), ".", "", 1) + "-" + slug + if used[stem] { + continue + } + rel := dir + "/" + stem + ".md" + f, err := os.OpenFile(filepath.Join(root, filepath.FromSlash(rel)), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if errors.Is(err, os.ErrExist) { + continue + } + if err != nil { + return "", err + } + content := strings.NewReplacer("@TITLE_YAML@", string(quoted), "@TITLE@", title, "@DATE@", at.Format(time.DateOnly)).Replace(string(tmpl)) + _, werr := f.WriteString(content) + return rel, errors.Join(werr, f.Close()) + } +} + +// stems returns the IDs of every record-named file in both directories. +func stems(root string) (map[string]bool, error) { + used := map[string]bool{} + for _, d := range dirs { + entries, err := os.ReadDir(filepath.Join(root, d.dir)) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, err + } + for _, e := range entries { + if stem, _, msg := parseName(e.Name()); msg == "" { + used[stem] = true + } + } + } + return used, nil +} diff --git a/internal/docs/links_test.go b/internal/docs/links_test.go new file mode 100644 index 0000000..4fd7954 --- /dev/null +++ b/internal/docs/links_test.go @@ -0,0 +1,117 @@ +package docs + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestLinkDestinations covers the section 7 rules through one body link. +func TestLinkDestinations(t *testing.T) { + tests := []struct{ dest, want string }{ // want "" means valid + {"/docs/spec.txt", ""}, + {"/docs/spec.txt#frag", ""}, + {"/docs/sp%20ace.md", ""}, + {"#anchor", ""}, + {"mailto:a@example.com", ""}, + {"https://example.com/a?b=1#c", ""}, + {"foo.md", "relative"}, + {"./foo.md", "relative"}, + {"../docs/spec.txt", "relative"}, + {"/docs/../docs/spec.txt", `".."`}, + {"/docs/%2e%2e/docs/spec.txt", `".."`}, + {`/docs\spec.txt`, "backslash"}, + {`C:\spec.txt`, "backslash"}, + {"//example.com/x", "protocol-relative"}, + {"/docs/spec.txt?x=1", "queries"}, + {"/docs/%zz", "percent-encoding"}, + {"/docs/missing.txt", "does not exist"}, + {"/Users/me/spec.txt", "does not exist"}, + {"/docs", "not a regular file"}, + {"/docs/out/f.md", "outside the repository"}, + {"file:///etc/passwd", "scheme"}, + {"ftp://example.com/x", "scheme"}, + {"javascript:alert(1)", "scheme"}, + {"https://u:p@example.com/x", "credentials"}, + {"https:///nohost", "host"}, + {"<>", "empty"}, + } + for _, tt := range tests { + t.Run(tt.dest, func(t *testing.T) { + dest := tt.dest + if strings.ContainsAny(dest, " ") { + dest = "<" + dest + ">" + } + doc := adrBase + "\nSee [x](" + dest + ").\n" + res, err := Check(tree(t, withSupport(map[string]string{aPath: doc}), map[string]string{"docs/out": "@outside"})) + must(t, err) + var want []string + if tt.want != "" { + want = []string{aPath + ":35: " + tt.want} + } + expect(t, res.Problems, want) + }) + } +} + +// TestLinkForms covers which Markdown constructs are link destinations. +func TestLinkForms(t *testing.T) { + tests := []struct { + name, body string + want []string + }{ + {"image", "![x](foo.md)", []string{":35: relative"}}, + {"autolink", "", []string{":35: scheme"}}, + {"reference link and definition", "[x][r]\n\n[r]: foo.md", []string{":35: relative", ":37: relative"}}, + {"unused definition", "[r]: foo.md", []string{":35: relative"}}, + {"link in a list", "* [x](foo.md)", []string{":35: relative"}}, + {"bare URL and raw HTML are not checked", "http://u:p@example.com and @user #123 abc1234\n\nx", nil}, + {"link in code is not a link", "`[x](foo.md)`\n\n```\n[x](foo.md)\n```", nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, err := Check(tree(t, withSupport(map[string]string{aPath: adrBase + "\n" + tt.body + "\n"}), nil)) + must(t, err) + var want []string + for _, w := range tt.want { + want = append(want, aPath+w) + } + expect(t, res.Problems, want) + }) + } +} + +// TestSpecExamples keeps the minimal examples in docs/docs-format.md valid. +func TestSpecExamples(t *testing.T) { + spec, err := os.ReadFile("../../docs/docs-format.md") + must(t, err) + root := t.TempDir() + parts := strings.Split(string(spec[strings.Index(string(spec), "## 9."):]), "```markdown\n") + if len(parts) != 3 { + t.Fatalf("expected two examples, got %d", len(parts)-1) + } + // The path of each example is the backticked name on the line before its fence. + heads := strings.Split(string(spec[strings.Index(string(spec), "## 9."):]), "\n") + var paths []string + for _, l := range heads { + if strings.HasPrefix(l, "`docs/") { + paths = append(paths, strings.Trim(l, "`:")) + } + } + if len(paths) != 2 { + t.Fatalf("expected two example paths, got %v", paths) + } + for i, part := range parts[1:] { + body, _, _ := strings.Cut(part, "```") + full := filepath.Join(root, paths[i]) + must(t, os.MkdirAll(filepath.Dir(full), 0o755)) + must(t, os.WriteFile(full, []byte(body), 0o644)) + } + res, err := Check(root) + must(t, err) + expect(t, res.Problems, nil) + if res.Records != 2 { + t.Fatalf("records = %d", res.Records) + } +} diff --git a/internal/docs/new_test.go b/internal/docs/new_test.go new file mode 100644 index 0000000..6ea56f9 --- /dev/null +++ b/internal/docs/new_test.go @@ -0,0 +1,104 @@ +package docs + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +var now = time.Date(2026, 10, 23, 20, 45, 12, 345_000_000, time.UTC) + +// TestNewTemplates checks that fresh records fail only on their markers and +// become valid once those are replaced. +func TestNewTemplates(t *testing.T) { + for _, tt := range []struct { + kind, path string + markers int + }{ + {"adr", "docs/adr/20261023T204512345Z-use-x-y.md", 6}, + {"plan", "docs/plans/20261023T204512345Z-use-x-y.md", 3}, + } { + t.Run(tt.kind, func(t *testing.T) { + root := t.TempDir() + path, err := New(root, tt.kind, `Use "X" & Y`, now) + must(t, err) + if path != tt.path { + t.Fatalf("path = %s", path) + } + res, err := Check(root) + must(t, err) + if len(res.Problems) != tt.markers { + t.Fatalf("got %v, want %d marker problems", res.Problems, tt.markers) + } + for _, p := range res.Problems { + if !strings.Contains(p.Msg, marker) { + t.Errorf("not a marker problem: %s", p) + } + } + data, err := os.ReadFile(filepath.Join(root, path)) + must(t, err) + must(t, os.WriteFile(filepath.Join(root, path), []byte(strings.ReplaceAll(string(data), marker, "x")), 0o644)) + res, err = Check(root) + must(t, err) + expect(t, res.Problems, nil) + }) + } +} + +func TestNewCollisions(t *testing.T) { + root := t.TempDir() + first, err := New(root, "adr", "Same", now) + must(t, err) + second, err := New(root, "adr", "Same", now) + must(t, err) + // The same stem in the other directory also advances the timestamp. + third, err := New(root, "plan", "Same", now) + must(t, err) + want := []string{ + "docs/adr/20261023T204512345Z-same.md", + "docs/adr/20261023T204512346Z-same.md", + "docs/plans/20261023T204512347Z-same.md", + } + for i, got := range []string{first, second, third} { + if got != want[i] { + t.Errorf("file %d = %s, want %s", i, got, want[i]) + } + } + // Advancing across midnight keeps the date equal to the filename. + root = t.TempDir() + _, err = New(root, "adr", "Edge", time.Date(2026, 10, 23, 23, 59, 59, 999_000_000, time.UTC)) + must(t, err) + got, err := New(root, "adr", "Edge", time.Date(2026, 10, 23, 23, 59, 59, 999_000_000, time.UTC)) + must(t, err) + if got != "docs/adr/20261024T000000000Z-edge.md" { + t.Fatalf("got %s", got) + } + res, err := Check(root) + must(t, err) + for _, p := range res.Problems { + if !strings.Contains(p.Msg, marker) { + t.Errorf("unexpected %s", p) + } + } +} + +func TestNewRejectsBadTitles(t *testing.T) { + for _, title := range []string{"", " ", "a\nb", "!!!"} { + if path, err := New(t.TempDir(), "adr", title, now); err == nil { + t.Errorf("title %q created %s", title, path) + } + } +} + +func TestSlugify(t *testing.T) { + for in, want := range map[string]string{"Use X, Y & Z!": "use-x-y-z", " --Hello-- ": "hello", "Ünï": "n"} { + if got := slugify(in); got != want { + t.Errorf("slugify(%q) = %q, want %q", in, got, want) + } + } + if got := slugify(strings.Repeat("ab ", 40)); len(got) != maxSlug || strings.HasSuffix(got, "-") { + t.Errorf("long slug = %q", got) + } +} diff --git a/internal/docs/plan.go b/internal/docs/plan.go new file mode 100644 index 0000000..8f025b7 --- /dev/null +++ b/internal/docs/plan.go @@ -0,0 +1,23 @@ +package docs + +var planRefs = []refSpec{{"tracking", kURL}, {"adrs", kAdr}, {"completion", kURL | kFile}} + +func (c *checker) plan(r *record, m *fm) { + m.keys("status", "tracking", "adrs", "completion") + status, ok := m.enum("status", "draft", "active", "completed", "cancelled") + m.refs(planRefs...) + r.status = status + if !ok { + return + } + closed := status == "completed" || status == "cancelled" + if status == "active" && len(r.lists["tracking"]) == 0 { + m.problemf("status", "status active requires tracking") + } + if closed && len(r.lists["completion"]) == 0 { + m.problemf("status", "status %s requires completion", status) + } + if !closed && len(r.lists["completion"]) > 0 { + m.problemf("completion", "completion must be empty while status is %s", status) + } +} diff --git a/internal/docs/refs.go b/internal/docs/refs.go new file mode 100644 index 0000000..18c5257 --- /dev/null +++ b/internal/docs/refs.go @@ -0,0 +1,176 @@ +package docs + +import ( + "fmt" + "net/url" + "os" + "path/filepath" + "regexp" + "slices" + "strings" +) + +// kinds is the set of reference forms a frontmatter key accepts. +type kinds uint8 + +const ( + kAdr kinds = 1 << iota + kPlan + kURL + kFile +) + +type refSpec struct { + key string + kinds kinds +} + +var ( + schemeRe = regexp.MustCompile(`^([A-Za-z][A-Za-z0-9+.-]*):`) + durableRe = regexp.MustCompile(`^([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)?#\d+$`) + idPattern = `\d{8}T\d{9}Z-[a-z0-9]+(?:-[a-z0-9]+)*` + adrPathRe = regexp.MustCompile(`^/docs/adr/` + idPattern + `\.md$`) + planPathRe = regexp.MustCompile(`^/docs/plans/` + idPattern + `\.md$`) +) + +type ref struct { + path string // decoded repo-root path; empty for URLs and anchors + remote bool +} + +// parseRef checks the syntax of a reference. body allows #anchors, mailto: and #fragments. +func parseRef(v string, body bool) (ref, string) { + if v == "" { + return ref{}, "empty reference" + } + if strings.Contains(v, `\`) { + return ref{}, "backslashes are not allowed" + } + if body && v[0] == '#' { + return ref{}, "" + } + if m := schemeRe.FindStringSubmatch(v); m != nil { + switch strings.ToLower(m[1]) { + case "http", "https": + u, err := url.Parse(v) + switch { + case err != nil || u.Hostname() == "": + return ref{}, "URL must have a host" + case u.User != nil: + return ref{}, "URL must not contain credentials" + } + return ref{remote: true}, "" + case "mailto": + if body { + return ref{}, "" + } + } + return ref{}, fmt.Sprintf("scheme %q is not allowed", m[1]) + } + if v[0] != '/' { + return ref{}, "relative paths are not allowed; use a repo-root path starting with /" + } + if strings.HasPrefix(v, "//") { + return ref{}, "protocol-relative references are not allowed" + } + p, frag, hasFrag := strings.Cut(v, "#") + if hasFrag && !body { + return ref{}, "fragments are not allowed here (" + frag + ")" + } + if strings.Contains(p, "?") { + return ref{}, "queries are not allowed" + } + dec, err := url.PathUnescape(p) + if err != nil { + return ref{}, "invalid percent-encoding" + } + if slices.Contains(strings.Split(dec, "/"), "..") || strings.Contains(dec, `\`) { + return ref{}, `".." segments and backslashes are not allowed` + } + return ref{path: dec}, "" +} + +// exists reports why a repo-root path is not a regular file inside the repository. +func (c *checker) exists(p string) string { + real, err := filepath.EvalSymlinks(filepath.Join(c.root, filepath.FromSlash(p))) + if err != nil { + return "target " + p + " does not exist" + } + if rel, err := filepath.Rel(c.realRoot, real); err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "target " + p + " resolves outside the repository" + } + if st, err := os.Stat(real); err != nil || !st.Mode().IsRegular() { + return "target " + p + " is not a regular file" + } + return "" +} + +// checkLink validates a body link destination. +func (c *checker) checkLink(r *record, line int, dest string) { + ref, msg := parseRef(dest, true) + if msg == "" && ref.path != "" { + msg = c.exists(ref.path) + } + if msg != "" { + c.add(r.path, line, "link %q: %s", dest, msg) + } +} + +// refs validates the reference lists of a record against their specs. +func (m *fm) refs(specs ...refSpec) { + self := "/" + m.r.path + for _, spec := range specs { + items, ok := m.list(spec.key) + if !ok { + continue + } + seen := map[string]bool{} + for _, it := range items { + if it.v == marker { + continue + } + if msg := m.checkRef(spec, it.v, self); msg != "" { + m.c.add(m.r.path, it.line, "%s: %s", spec.key, msg) + } else if seen[it.v] { + m.c.add(m.r.path, it.line, "%s: duplicate reference %q", spec.key, it.v) + } + seen[it.v] = true + } + } +} + +func (m *fm) checkRef(spec refSpec, v, self string) string { + if durableRe.MatchString(v) || slices.Contains([]string{"this pr", "this pull request", "this commit"}, strings.ToLower(v)) { + return fmt.Sprintf("%q is not durable; use a full URL", v) + } + r, msg := parseRef(v, false) + if msg != "" { + return msg + } + k := spec.kinds + switch { + case r.remote: + if k&kURL == 0 { + return "URLs are not accepted here; accepts " + k.String() + } + return "" + case k&kFile == 0 && !(k&kAdr != 0 && adrPathRe.MatchString(v)) && !(k&kPlan != 0 && planPathRe.MatchString(v)): + return fmt.Sprintf("%q is not accepted here; accepts %s", v, k) + case v == self: + return "a record must not reference itself" + } + return m.c.exists(r.path) +} + +func (k kinds) String() string { + var s []string + for _, e := range []struct { + k kinds + s string + }{{kAdr, "/docs/adr/.md"}, {kPlan, "/docs/plans/.md"}, {kURL, "http(s) URLs"}, {kFile, "repo-root file paths"}} { + if k&e.k != 0 { + s = append(s, e.s) + } + } + return strings.Join(s, ", ") +} diff --git a/internal/docs/templates/adr.md b/internal/docs/templates/adr.md new file mode 100644 index 0000000..9b53a94 --- /dev/null +++ b/internal/docs/templates/adr.md @@ -0,0 +1,32 @@ +--- +title: @TITLE_YAML@ +date: @DATE@ +status: proposed +implementation: not-started +summary: "{{docs:todo}}" +tags: + - "{{docs:todo}}" +supersedes: [] +superseded_by: [] +related: [] +tracking: [] +implemented_by: [] +--- + +# @TITLE@ + +## Context and Problem Statement + +{{docs:todo}} + +## Considered Options + +{{docs:todo}} + +## Decision Outcome + +{{docs:todo}} + +### Consequences + +{{docs:todo}} diff --git a/internal/docs/templates/plan.md b/internal/docs/templates/plan.md new file mode 100644 index 0000000..50f3701 --- /dev/null +++ b/internal/docs/templates/plan.md @@ -0,0 +1,20 @@ +--- +status: draft +tracking: [] +adrs: [] +completion: [] +--- + +# @TITLE@ + +## Scope + +{{docs:todo}} + +## Steps + +{{docs:todo}} + +## Validation + +{{docs:todo}} -- 2.54.0 From e5c203d4651b9212bd8429375d6f994149af39b1 Mon Sep 17 00:00:00 2001 From: Elijah Duffy Date: Fri, 23 Oct 2026 14:47:38 -0700 Subject: [PATCH 06/13] ci: add Gitea workflows and goreleaser config Co-Authored-By: Claude Sonnet 5.5 --- .gitea/workflows/ci.yml | 31 +++++++++++++++++++++++++++++++ .gitea/workflows/release.yml | 24 ++++++++++++++++++++++++ .gitignore | 1 + .goreleaser.yaml | 29 +++++++++++++++++++++++++++++ 4 files changed, 85 insertions(+) create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitea/workflows/release.yml create mode 100644 .gitignore create mode 100644 .goreleaser.yaml diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..73c5864 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,31 @@ +name: ci + +on: + push: + pull_request: + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7.0.1 + with: + fetch-depth: 0 + - uses: actions/setup-go@v7.0.0 + with: + go-version-file: go.mod + - name: gofmt + run: test -z "$(gofmt -l .)" + - run: go vet ./... + - run: go test -race ./... + - run: go run ./cmd/docs check + - uses: goreleaser/goreleaser-action@v7.3.0 + with: + distribution: goreleaser + version: v2.19.0 + args: check + - uses: goreleaser/goreleaser-action@v7.3.0 + with: + distribution: goreleaser + version: v2.19.0 + args: release --snapshot --clean diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..9e7cc7a --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,24 @@ +name: release + +on: + push: + tags: + - "v*" + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7.0.1 + with: + fetch-depth: 0 + - uses: actions/setup-go@v7.0.0 + with: + go-version-file: go.mod + - uses: goreleaser/goreleaser-action@v7.3.0 + with: + distribution: goreleaser + version: v2.19.0 + args: release --clean + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..849ddff --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..82b9abb --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,29 @@ +version: 2 +project_name: docs + +builds: + - id: docs + main: ./cmd/docs + binary: docs + env: + - CGO_ENABLED=0 + goos: [darwin, linux] + goarch: [amd64, arm64] + flags: [-trimpath] + ldflags: [-s -w] + +archives: + - formats: [tar.gz] + name_template: "docs_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + +checksum: + name_template: checksums.txt + +gitea_urls: + api: https://gitea.auvem.com/api/v1 + download: https://gitea.auvem.com + +release: + gitea: + owner: corestack + name: go -- 2.54.0 From e33e8df6c6b8990bee6a2b4038e3ae172b05e214 Mon Sep 17 00:00:00 2001 From: Elijah Duffy Date: Fri, 23 Oct 2026 14:47:46 -0700 Subject: [PATCH 07/13] docs: record decision to adopt a checked docs format Co-Authored-By: Claude Sonnet 5.5 --- ...ormat-for-adrs-and-implementation-plans.md | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 docs/adr/20261023T214738672Z-adopt-a-checked-format-for-adrs-and-implementation-plans.md diff --git a/docs/adr/20261023T214738672Z-adopt-a-checked-format-for-adrs-and-implementation-plans.md b/docs/adr/20261023T214738672Z-adopt-a-checked-format-for-adrs-and-implementation-plans.md new file mode 100644 index 0000000..c7cc81c --- /dev/null +++ b/docs/adr/20261023T214738672Z-adopt-a-checked-format-for-adrs-and-implementation-plans.md @@ -0,0 +1,81 @@ +--- +title: Adopt a checked format for ADRs and implementation plans +date: 2026-10-23 +status: proposed +implementation: in-progress +summary: Use one documented ADR and plan format, enforced by a shared Go CLI (docs check). +tags: + - documentation + - adr + - tooling +supersedes: [] +superseded_by: [] +related: [] +tracking: [] +implemented_by: [] +--- + +# Adopt a checked format for ADRs and implementation plans + +## Context and Problem Statement + +We record architecture decisions as ADRs and track delivery work as implementation plans. Across our repositories, four independent implementations of this practice grew up: each had its own filename scheme, frontmatter keys, status vocabulary and checking script (or none). The same concept was spelled differently from repository to repository, cross-references were relative paths that broke when files moved, and "accepted" did not say whether anything had been built. Tooling and agents had to learn each variant, and fixes to one checker did not reach the others. + +We need one format that people and tools can write without surprises, that separates *the decision* from *its delivery*, and that is verified mechanically so records do not rot. The format must be public and documented well enough that someone can write valid files from the specification alone. + +## Decision Drivers + +* One vocabulary and one layout everywhere, so a record means the same thing in every repository. +* Stable identity: a record's ID and path never change, so references stay valid. +* The decision status and the implementation status are tracked separately. +* Checks run locally and in CI with no service dependency. +* Low upkeep: one tool to maintain, not one per repository. + +## Considered Options + +* MADR 4 template plus generic Markdown and link linting. +* Keep a checking script per repository, converging informally. +* Extract and publish one existing checker. +* Define one format and implement one shared Go CLI that creates and checks records. + +## Decision Outcome + +Chosen option: "Define one format and implement one shared Go CLI that creates and checks records", because it is the only option that gives a single enforced vocabulary and identity scheme while keeping the checks dependency-free. + +The format is specified in [docs-format](/docs/docs-format.md). It uses the MADR 4 layout, with `### Consequences` under Decision Outcome made mandatory, and adds the parts MADR leaves open: timestamp-based permanent IDs in filenames, a fixed frontmatter schema, a separate `implementation` field, reciprocal supersession, durable repo-root references, and a plan record with its own lifecycle. The CLI, `docs`, offers `docs new adr|plan` and `docs check`. + +### Consequences + +* Good, because the format has one specification, one checker and one set of error messages. +* Good, because records are validated, so dead links, missing sections and contradictory statuses fail CI instead of accumulating. +* Good, because the permanent ID survives title edits. +* Bad, because the format is ours: we own the specification and the tool, and breaking changes before 1.0 will require updating adopters. +* Neutral, because the check validates structure only; it cannot tell whether a decision is sound. + +## Pros and Cons of the Options + +### MADR 4 template plus generic Markdown and link linting + +* Good, because it is a public, widely understood template and nothing custom is built. +* Bad, because MADR is a template with no checker of its own, and generic linters enforce no ID, status, supersession or reference rules. + +### Keep a checking script per repository + +* Good, because there is no new shared artifact and each team keeps full control. +* Bad, because the four existing variants already diverged; nothing stops further divergence. +* Bad, because each script is maintained, tested and fixed separately, and most repositories would simply not have one. + +### Extract and publish one existing checker + +* Good, because it is cheaper: an existing Go checker already handles timestamp IDs, exclusive creation and reciprocal supersession. +* Bad, because it has no plan record, no `implementation` field and no public specification, and it is tied to one repository's conventions. + +### Define one format and implement one shared Go CLI that creates and checks records + +* Good, because one specification and one implementation can be tested thoroughly and installed with `go install`. +* Good, because the same binary creates records, so new files start valid. +* Bad, because we commit to maintaining a small public tool and a specification. + +## More Information + +MADR 4: https://adr.github.io/madr/. The specification is [docs-format](/docs/docs-format.md); the implementation lands in the same pull request that records this decision. -- 2.54.0 From ddebde0b2e1e76332f8c90e8019ada0525447184 Mon Sep 17 00:00:00 2001 From: Elijah Duffy Date: Fri, 23 Oct 2026 14:57:21 -0700 Subject: [PATCH 08/13] ci: keep goreleaser to a single token in the release job Co-Authored-By: Claude Sonnet 5.5 --- .gitea/workflows/release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 9e7cc7a..2cb5e95 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -22,3 +22,5 @@ jobs: args: release --clean env: GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + # goreleaser fails on more than one token; an empty value counts as unset. + GITHUB_TOKEN: "" -- 2.54.0 From 789931d90f2578835f62851d7a581d3f610bc833 Mon Sep 17 00:00:00 2001 From: Elijah Duffy Date: Fri, 23 Oct 2026 14:57:21 -0700 Subject: [PATCH 09/13] docs: correct two statements in the format ADR Co-Authored-By: Claude Sonnet 5.5 --- ...dopt-a-checked-format-for-adrs-and-implementation-plans.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/adr/20261023T214738672Z-adopt-a-checked-format-for-adrs-and-implementation-plans.md b/docs/adr/20261023T214738672Z-adopt-a-checked-format-for-adrs-and-implementation-plans.md index c7cc81c..7a07f6f 100644 --- a/docs/adr/20261023T214738672Z-adopt-a-checked-format-for-adrs-and-implementation-plans.md +++ b/docs/adr/20261023T214738672Z-adopt-a-checked-format-for-adrs-and-implementation-plans.md @@ -40,7 +40,7 @@ We need one format that people and tools can write without surprises, that separ ## Decision Outcome -Chosen option: "Define one format and implement one shared Go CLI that creates and checks records", because it is the only option that gives a single enforced vocabulary and identity scheme while keeping the checks dependency-free. +Chosen option: "Define one format and implement one shared Go CLI that creates and checks records", because it is the only option that gives a single enforced vocabulary and identity scheme while keeping the checks free of service dependencies. The format is specified in [docs-format](/docs/docs-format.md). It uses the MADR 4 layout, with `### Consequences` under Decision Outcome made mandatory, and adds the parts MADR leaves open: timestamp-based permanent IDs in filenames, a fixed frontmatter schema, a separate `implementation` field, reciprocal supersession, durable repo-root references, and a plan record with its own lifecycle. The CLI, `docs`, offers `docs new adr|plan` and `docs check`. @@ -73,7 +73,7 @@ The format is specified in [docs-format](/docs/docs-format.md). It uses the MADR ### Define one format and implement one shared Go CLI that creates and checks records * Good, because one specification and one implementation can be tested thoroughly and installed with `go install`. -* Good, because the same binary creates records, so new files start valid. +* Good, because the same binary creates records, so new files start with the required structure. * Bad, because we commit to maintaining a small public tool and a specification. ## More Information -- 2.54.0 From d579e38ba58f12e26afb7120b2f190865888e172 Mon Sep 17 00:00:00 2001 From: Elijah Duffy Date: Fri, 23 Oct 2026 14:57:21 -0700 Subject: [PATCH 10/13] fix: address docs checker review findings Check every link reference definition, decode destinations per CommonMark, report correct lines for empty headings, autolinks and nested definitions, reject symlinked record directories and wrong-case path components, and refuse titles that cannot pass. Only top-level headings count (spec section 6). Co-Authored-By: Claude Sonnet 5.5 --- docs/docs-format.md | 2 +- internal/docs/body.go | 105 +++++++++++++++++++++++++++++------- internal/docs/check.go | 10 ++-- internal/docs/check_test.go | 24 ++++++++- internal/docs/id.go | 5 ++ internal/docs/links_test.go | 11 ++++ internal/docs/new_test.go | 2 +- internal/docs/refs.go | 14 ++++- 8 files changed, 146 insertions(+), 27 deletions(-) diff --git a/docs/docs-format.md b/docs/docs-format.md index 6ab9954..6a649a5 100644 --- a/docs/docs-format.md +++ b/docs/docs-format.md @@ -116,7 +116,7 @@ Plan `status`: ## 6. Body -The body is Markdown (CommonMark) after the frontmatter. Headings inside fenced code blocks or HTML comments do not count. Required section headings are matched case-sensitively and exactly, at level 2 (`##`). A section runs until the next heading of level 1 or 2, so its subsections belong to it. A required section MUST appear exactly once and contain non-empty prose: at least one paragraph (including paragraphs inside blockquotes), list item or table cell of text. HTML comments and code (fenced, indented or inline) do not count as prose. Extra sections are allowed anywhere. +The body is Markdown (CommonMark) after the frontmatter. Headings inside fenced code blocks or HTML comments do not count. Only headings at the top level of the document count. Required section headings are matched case-sensitively and exactly, at level 2 (`##`). A section runs until the next heading of level 1 or 2, so its subsections belong to it. A required section MUST appear exactly once and contain non-empty prose: at least one paragraph (including paragraphs inside blockquotes), list item or table cell of text. HTML comments and code (fenced, indented or inline) do not count as prose. Extra sections are allowed anywhere. Heading text is the raw source after the `#` markers, trimmed, with any closing `#` run removed; it is compared byte for byte, with no inline-markup rendering. Setext headings are read as the equivalent ATX heading. diff --git a/internal/docs/body.go b/internal/docs/body.go index 2fd20e5..dfb1b7b 100644 --- a/internal/docs/body.go +++ b/internal/docs/body.go @@ -3,12 +3,14 @@ package docs import ( "bytes" "regexp" + "slices" "strings" "github.com/yuin/goldmark" "github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" ) var ( @@ -16,6 +18,8 @@ var ( planSections = []string{"Scope", "Steps", "Validation"} ) +var atxRe = regexp.MustCompile(`(?m)^ {0,3}#{1,6}(?:[ \t]|$)`) + type heading struct { level int text string @@ -36,8 +40,35 @@ func (d *doc) lineAt(off int) int { return d.startLine + bytes.Count(d.src[:min(off, len(d.src))], []byte("\n")) } -// lineOf is the line of the first text under n, or of the nearest block that has lines. -func (d *doc) lineOf(n ast.Node) int { +// headingLine is the line of h. An empty ATX heading has no lines, so it is the first +// `#` line after the end of the previous top-level block. +func (d *doc) headingLine(h *ast.Heading, prev ast.Node) int { + if h.Lines().Len() > 0 { + return d.lineAt(h.Lines().At(0).Start) + } + from := 0 + if prev != nil { + _ = ast.Walk(prev, func(c ast.Node, enter bool) (ast.WalkStatus, error) { + if enter { + for i := range c.Lines().Len() { + from = max(from, c.Lines().At(i).Stop) + } + if t, ok := c.(*ast.Text); ok { + from = max(from, t.Segment.Stop) + } + } + return ast.WalkContinue, nil + }) + } + if m := atxRe.FindIndex(d.src[from:]); m != nil { + return d.lineAt(from + m[0]) + } + return d.startLine +} + +// lineOf is the line of the first text under n. Without text (an autolink, an image with +// no alt text) it is the line where needle appears in the enclosing block, else the block's first line. +func (d *doc) lineOf(n ast.Node, needle string) int { var off = -1 _ = ast.Walk(n, func(c ast.Node, enter bool) (ast.WalkStatus, error) { if t, ok := c.(*ast.Text); ok && enter { @@ -49,6 +80,9 @@ func (d *doc) lineOf(n ast.Node) int { for p := n; off < 0 && p != nil; p = p.Parent() { if p.Type() == ast.TypeBlock && p.Lines().Len() > 0 { off = p.Lines().At(0).Start + if i := bytes.Index(d.src[off:p.Lines().At(p.Lines().Len()-1).Stop], []byte(needle)); needle != "" && i >= 0 { + off += i + } } } if off < 0 { @@ -59,15 +93,12 @@ func (d *doc) lineOf(n ast.Node) int { func (c *checker) body(r *record, src string, startLine int, title string, titleOK bool) { b := []byte(src) - ctx := parser.NewContext() + ctx := &refCtx{Context: parser.NewContext()} root := goldmark.DefaultParser().Parse(text.NewReader(b), parser.WithContext(ctx)) d := &doc{c: c, r: r, src: b, startLine: startLine} for n := root.FirstChild(); n != nil; n = n.NextSibling() { if h, ok := n.(*ast.Heading); ok { - line := d.startLine - if h.Lines().Len() > 0 { - line = d.lineAt(h.Lines().At(0).Start) - } + line := d.headingLine(h, n.PreviousSibling()) d.heads = append(d.heads, heading{h.Level, strings.TrimSpace(string(h.Lines().Value(b))), line, len(d.kids)}) } d.kids = append(d.kids, n) @@ -161,7 +192,7 @@ func (d *doc) decisionOutcome() { // Prose before the first subsection is the decision itself. pre := nodes for i, n := range nodes { - if _, ok := n.(*ast.Heading); ok { + if h, ok := n.(*ast.Heading); ok && h.Level == 3 { pre = nodes[:i] break } @@ -223,9 +254,8 @@ func (d *doc) inlineText(n ast.Node) string { return s.String() } -// placeholders reports every marker outside code. -func (d *doc) placeholders(root ast.Node) { - var code [][2]int +// codeRanges returns the source ranges of code blocks and code spans. +func codeRanges(root ast.Node) (code [][2]int) { _ = ast.Walk(root, func(n ast.Node, enter bool) (ast.WalkStatus, error) { switch n.(type) { case *ast.FencedCodeBlock, *ast.CodeBlock: @@ -242,6 +272,12 @@ func (d *doc) placeholders(root ast.Node) { } return ast.WalkContinue, nil }) + return code +} + +// placeholders reports every marker outside code. +func (d *doc) placeholders(root ast.Node) { + code := codeRanges(root) for off := 0; ; { i := strings.Index(string(d.src[off:]), marker) if i < 0 { @@ -259,27 +295,56 @@ func (d *doc) placeholders(root ast.Node) { } } +// refCtx records every link reference definition; the parser keeps only the first of a label. +type refCtx struct { + parser.Context + all []parser.Reference +} + +func (c *refCtx) AddReference(r parser.Reference) { + c.all = append(c.all, r) + c.Context.AddReference(r) +} + +// defRe finds a definition's label at the start of a line, allowing blockquote and list prefixes. +var defRe = regexp.MustCompile(`(?m)^[ \t>]*(?:(?:[*+-]|\d+[.)])[ \t]+)*\[([^\]\n]+)\]:`) + +// dest decodes a link destination as CommonMark does before it is used. +func dest(b []byte) string { + return string(util.ResolveEntityNames(util.ResolveNumericReferences(util.UnescapePunctuations(b)))) +} + // links checks every link, image and autolink destination and every reference definition. -func (d *doc) links(root ast.Node, ctx parser.Context) { +func (d *doc) links(root ast.Node, ctx *refCtx) { _ = ast.Walk(root, func(n ast.Node, enter bool) (ast.WalkStatus, error) { if !enter { return ast.WalkContinue, nil } switch t := n.(type) { case *ast.Link: - d.c.checkLink(d.r, d.lineOf(n), string(t.Destination)) + d.c.checkLink(d.r, d.lineOf(n, "]("+string(t.Destination)), dest(t.Destination)) case *ast.Image: - d.c.checkLink(d.r, d.lineOf(n), string(t.Destination)) + d.c.checkLink(d.r, d.lineOf(n, "]("+string(t.Destination)), dest(t.Destination)) case *ast.AutoLink: - d.c.checkLink(d.r, d.lineOf(n), string(t.URL(d.src))) + d.c.checkLink(d.r, d.lineOf(n, "<"+string(t.Label(d.src))), string(t.URL(d.src))) } return ast.WalkContinue, nil }) - for _, ref := range ctx.References() { - line := d.startLine - if m := regexp.MustCompile(`(?im)^ {0,3}\[` + regexp.QuoteMeta(string(ref.Label())) + `\]:`).FindIndex(d.src); m != nil { - line = d.lineAt(m[0]) + // shortcut: a definition's line is its label's nth match outside code; a multi-line + // definition inside a container may be located by label only. Upgrade by tracking offsets. + code := codeRanges(root) + lines := map[string][]int{} + for _, m := range defRe.FindAllSubmatchIndex(d.src, -1) { + if !slices.ContainsFunc(code, func(r [2]int) bool { return m[0] >= r[0] && m[0] < r[1] }) { + k := util.ToLinkReference(d.src[m[2]:m[3]]) + lines[k] = append(lines[k], d.lineAt(m[0])) } - d.c.checkLink(d.r, line, string(ref.Destination())) + } + for _, ref := range ctx.all { + line, k := d.startLine, util.ToLinkReference(ref.Label()) + if len(lines[k]) > 0 { + line, lines[k] = lines[k][0], lines[k][1:] + } + d.c.checkLink(d.r, line, dest(ref.Destination())) } } diff --git a/internal/docs/check.go b/internal/docs/check.go index 097618b..ebac9af 100644 --- a/internal/docs/check.go +++ b/internal/docs/check.go @@ -57,10 +57,14 @@ func Check(root string) (Result, error) { c := &checker{root: root, realRoot: real} var recs []*record for _, d := range dirs { - entries, err := os.ReadDir(filepath.Join(root, d.dir)) - if os.IsNotExist(err) { + if st, err := os.Lstat(filepath.Join(root, d.dir)); os.IsNotExist(err) { continue - } else if err != nil { + } else if err == nil && !st.IsDir() { + c.add(d.dir, 1, "must be a real directory (symlinks are not allowed)") + continue + } + entries, err := os.ReadDir(filepath.Join(root, d.dir)) + if err != nil { return Result{}, err } for _, e := range entries { diff --git a/internal/docs/check_test.go b/internal/docs/check_test.go index 79450d7..75a9d7f 100644 --- a/internal/docs/check_test.go +++ b/internal/docs/check_test.go @@ -235,6 +235,8 @@ func TestCheck(t *testing.T) { {name: "null empty", files: map[string]string{aPath: sub(adrBase, "summary: Run jobs on a queue.", "summary:")}, want: []string{aPath + ":6: must not be null"}}, {name: "null word", files: map[string]string{aPath: sub(adrBase, "summary: Run jobs on a queue.", "summary: Null")}, want: []string{aPath + ":6: must not be null"}}, {name: "scalars are literal text", files: map[string]string{aPath: sub(adrBase, "title: Use Queue", "title: 2026", "# Use Queue", "# 2026", "date: 2026-10-23", `date: "2026-10-23"`, "summary: Run jobs on a queue.", `summary: "null"`)}}, + {name: "null lowercase", files: map[string]string{aPath: sub(adrBase, "summary: Run jobs on a queue.", "summary: null")}, want: []string{aPath + ":6: must not be null"}}, + {name: "null uppercase", files: map[string]string{aPath: sub(adrBase, "summary: Run jobs on a queue.", "summary: NULL")}, want: []string{aPath + ":6: must not be null"}}, {name: "list item is null", files: map[string]string{aPath: sub(adrBase, "related: []", "related:\n - ~")}, want: []string{aPath + ":12: must not be null"}}, // 3.1 ADR keys @@ -272,6 +274,11 @@ func TestCheck(t *testing.T) { {name: "this PR placeholder", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by:\n - This PR")}, want: []string{aPath + ":14: not durable"}}, {name: "fragment in frontmatter", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by:\n - /docs/spec.txt#x")}, want: []string{aPath + ":14: fragments"}}, {name: "credentials in frontmatter URL", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by:\n - https://u:p@example.com/x")}, want: []string{aPath + ":14: credentials"}}, + {name: "mailto in frontmatter", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by:\n - mailto:a@example.com")}, want: []string{aPath + ":14: scheme"}}, + {name: "anchor in frontmatter", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by:\n - \"#anchor\"")}, want: []string{aPath + ":14: relative"}}, + {name: "symlink escape in frontmatter", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by:\n - /docs/out/f.md")}, links: map[string]string{"docs/out": "@outside"}, want: []string{aPath + ":14: outside the repository"}}, + {name: "percent-encoded self reference", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by:\n - /docs/adr/20261023T204512345Z-use%2Dqueue.md")}, want: []string{aPath + ":14: itself"}}, + {name: "supersedes rejects plan path", files: map[string]string{aPath: sub(adrBase, "status: proposed", "status: accepted", "supersedes: []", "supersedes:\n - /"+pPath)}, want: []string{aPath + ":10: supersedes"}}, {name: "relative path in frontmatter", files: map[string]string{aPath: sub(adrBase, "implemented_by: []", "implemented_by:\n - ../docs/spec.txt")}, want: []string{aPath + ":14: relative"}}, // 4. Status @@ -298,6 +305,9 @@ func TestCheck(t *testing.T) { {name: "superseder is proposed", files: map[string]string{ aPath: sub(adrBase, "supersedes: []", "supersedes:\n - /"+bPath), bPath: sub(bBase, "status: accepted", "status: superseded", "superseded_by: []", "superseded_by:\n - /"+aPath)}, want: []string{aPath + ":9: must be accepted"}}, + {name: "superseder is deprecated", files: map[string]string{ + aPath: sub(adrBase, "status: proposed", "status: superseded", "superseded_by: []", "superseded_by:\n - /"+bPath), + bPath: sub(bBase, "status: accepted", "status: deprecated", "supersedes: []", "supersedes:\n - /"+aPath)}}, {name: "superseder is rejected", files: map[string]string{ aPath: sub(adrBase, "status: proposed", "status: rejected", "implementation: not-started", "implementation: not-applicable", "supersedes: []", "supersedes:\n - /"+bPath), bPath: sub(bBase, "status: accepted", "status: superseded", "superseded_by: []", "superseded_by:\n - /"+aPath)}, want: []string{aPath + ":9: must be accepted"}}, @@ -325,7 +335,13 @@ func TestCheck(t *testing.T) { {name: "Consequences without prose", files: map[string]string{aPath: sub(adrBase, "* One more service.", "")}, want: []string{aPath + ":31: has no prose"}}, {name: "Decision Outcome without own prose", files: map[string]string{aPath: sub(adrBase, "Chosen option: Queue.\n\n", "")}, want: []string{aPath + ":27: needs prose before"}}, {name: "plan missing section", files: map[string]string{pPath: sub(planBase, "## Steps\n\n1. Do it.\n\n", "")}, want: []string{pPath + ":1: missing required section \"Steps\""}}, - {name: "plan empty H1", files: map[string]string{pPath: sub(planBase, "# Adopt Queue", "#")}, want: []string{pPath + ":7: H1"}}, + {name: "plan empty H1", files: map[string]string{pPath: sub(planBase, "# Adopt Queue", "#")}, want: []string{pPath + ":8: H1"}}, + {name: "empty ADR H1 is on its own line", files: map[string]string{aPath: sub(adrBase, "# Use Queue", "#")}, want: []string{aPath + ":16: must equal title"}}, + {name: "headings inside blockquotes and lists do not count", files: map[string]string{aPath: adrBase + "\n> ## Consequences\n\n* ## Decision Outcome\n"}}, + {name: "H4 does not end the Decision Outcome prose", files: map[string]string{aPath: sub(adrBase, "Chosen option: Queue.", "#### Detail\n\nChosen.")}}, + {name: "plan second H1", files: map[string]string{pPath: planBase + "\n# Again\n"}, want: []string{pPath + ":22: more than one H1"}}, + {name: "plan repeated section", files: map[string]string{pPath: planBase + "\n## Scope\n\nMore.\n"}, want: []string{pPath + ":22: appears more than once"}}, + {name: "plan section without prose", files: map[string]string{pPath: sub(planBase, "1. Do it.", "")}, want: []string{pPath + ":14: has no prose"}}, {name: "plan Outcome is optional", files: map[string]string{pPath: planBase + "\n## Outcome\n\nDone.\n"}}, // 6.3 Placeholders @@ -345,6 +361,12 @@ func TestCheck(t *testing.T) { } } +func TestCheckSymlinkedDocsDir(t *testing.T) { + res, err := Check(tree(t, nil, map[string]string{"docs/plans": "@outside"})) + must(t, err) + expect(t, res.Problems, []string{"docs/plans:1: real directory"}) +} + func TestCheckEmptyRoot(t *testing.T) { res, err := Check(t.TempDir()) must(t, err) diff --git a/internal/docs/id.go b/internal/docs/id.go index 93cd108..bc9ae0f 100644 --- a/internal/docs/id.go +++ b/internal/docs/id.go @@ -55,6 +55,8 @@ func slugify(title string) string { return strings.Trim(s[:min(len(s), maxSlug)], "-") } +var closingRe = regexp.MustCompile(`(^|[ \t])#+$`) + // New creates a record of the given kind ("adr" or "plan") under root and // returns its root-relative slash path. The file is created exclusively; the // timestamp advances by 1 ms on a collision. @@ -63,6 +65,9 @@ func New(root, kind, title string, now time.Time) (string, error) { if title == "" || strings.ContainsAny(title, "\r\n") { return "", errors.New("title must be non-empty and a single line") } + if closingRe.MatchString(title) { + return "", errors.New(`title must not end in a "#" run: it would be read as a closing sequence of the H1`) + } slug := slugify(title) if slug == "" { return "", errors.New("title needs at least one ASCII letter or digit for the file name") diff --git a/internal/docs/links_test.go b/internal/docs/links_test.go index 4fd7954..7d1345e 100644 --- a/internal/docs/links_test.go +++ b/internal/docs/links_test.go @@ -16,6 +16,12 @@ func TestLinkDestinations(t *testing.T) { {"#anchor", ""}, {"mailto:a@example.com", ""}, {"https://example.com/a?b=1#c", ""}, + {`/docs/spec\.txt`, ""}, + {"/docs/spec.txt", ""}, + {"/docs/spec.txt", ""}, + {`/docs\\spec.txt`, "backslash"}, + {"/docs/SPEC.txt", "does not exist"}, + {"/Docs/spec.txt", "does not exist"}, {"foo.md", "relative"}, {"./foo.md", "relative"}, {"../docs/spec.txt", "relative"}, @@ -64,6 +70,11 @@ func TestLinkForms(t *testing.T) { {"image", "![x](foo.md)", []string{":35: relative"}}, {"autolink", "", []string{":35: scheme"}}, {"reference link and definition", "[x][r]\n\n[r]: foo.md", []string{":35: relative", ":37: relative"}}, + {"duplicate definition is checked", "[a]: /docs/spec.txt\n\n[a]: ../bad.md", []string{":37: relative"}}, + {"definition in a blockquote", "> [Zed]: ../bad.md", []string{":35: relative"}}, + {"definition-like line in code", "```\n[a]: x\n```\n\n[a]: ../bad.md", []string{":39: relative"}}, + {"autolink on a later line", "Para one\nline two\n", []string{":37: scheme"}}, + {"image without alt on a later line", "Para one\nline two\n![](foo.md)", []string{":37: relative"}}, {"unused definition", "[r]: foo.md", []string{":35: relative"}}, {"link in a list", "* [x](foo.md)", []string{":35: relative"}}, {"bare URL and raw HTML are not checked", "http://u:p@example.com and @user #123 abc1234\n\nx", nil}, diff --git a/internal/docs/new_test.go b/internal/docs/new_test.go index 6ea56f9..dcb6242 100644 --- a/internal/docs/new_test.go +++ b/internal/docs/new_test.go @@ -85,7 +85,7 @@ func TestNewCollisions(t *testing.T) { } func TestNewRejectsBadTitles(t *testing.T) { - for _, title := range []string{"", " ", "a\nb", "!!!"} { + for _, title := range []string{"", " ", "a\nb", "!!!", "Drop C #", "#", "C ##"} { if path, err := New(t.TempDir(), "adr", title, now); err == nil { t.Errorf("title %q created %s", title, path) } diff --git a/internal/docs/refs.go b/internal/docs/refs.go index 18c5257..b6e9b0e 100644 --- a/internal/docs/refs.go +++ b/internal/docs/refs.go @@ -92,6 +92,18 @@ func parseRef(v string, body bool) (ref, string) { // exists reports why a repo-root path is not a regular file inside the repository. func (c *checker) exists(p string) string { + // Match each component's case exactly, so case-insensitive filesystems agree with Linux. + dir := c.root + for _, part := range strings.Split(p, "/") { + if part == "" || part == "." { + continue + } + entries, err := os.ReadDir(dir) + if err != nil || !slices.ContainsFunc(entries, func(e os.DirEntry) bool { return e.Name() == part }) { + return "target " + p + " does not exist" + } + dir = filepath.Join(dir, part) + } real, err := filepath.EvalSymlinks(filepath.Join(c.root, filepath.FromSlash(p))) if err != nil { return "target " + p + " does not exist" @@ -156,7 +168,7 @@ func (m *fm) checkRef(spec refSpec, v, self string) string { return "" case k&kFile == 0 && !(k&kAdr != 0 && adrPathRe.MatchString(v)) && !(k&kPlan != 0 && planPathRe.MatchString(v)): return fmt.Sprintf("%q is not accepted here; accepts %s", v, k) - case v == self: + case r.path == self: return "a record must not reference itself" } return m.c.exists(r.path) -- 2.54.0 From 9eec1d10163869a195c8f49e192eb581dae2398a Mon Sep 17 00:00:00 2001 From: Elijah Duffy Date: Fri, 23 Oct 2026 14:58:14 -0700 Subject: [PATCH 11/13] docs: specify docs directory and path-case rules Co-Authored-By: Claude Sonnet 5.5 --- docs/docs-format.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs-format.md b/docs/docs-format.md index 6a649a5..53322a4 100644 --- a/docs/docs-format.md +++ b/docs/docs-format.md @@ -7,7 +7,7 @@ The ADR body uses the [MADR 4](https://adr.github.io/madr/) layout; `Consequence ## 1. Files and directories - ADRs live in `docs/adr/`, plans in `docs/plans/`, relative to the repository root. Both are flat: subdirectories are not scanned and are errors. -- A missing directory is treated as empty. +- A missing directory is treated as empty. `docs/adr` and `docs/plans` MUST be real directories: a symlink in their place is an error. - Entries whose name starts with `.` (such as `.DS_Store` or editor swap files) are ignored. `README.md` in either directory is skipped. Every other entry MUST be a regular file whose name matches section 2 and ends in `.md`. Any other file, directory or name is an error. There are no other exceptions: no templates and no companion files; every `.md` file is a record. - Files are UTF-8 without a byte order mark. LF and CRLF line endings are both accepted. @@ -146,7 +146,7 @@ Repo-root path: - Starts with a single `/` and is relative to the repository root, for example `/docs/adr/20261023T204512345Z-use-example-queue.md`. A leading `//` (protocol-relative `//host/...`) is rejected. - Repo-root links render correctly on Git forges (GitHub, Gitea) but not in every Markdown viewer. - The destination is percent-decoded before the existence check (`%20` is a space). A `?query` is an error. -- The target MUST exist as a regular file inside the repository. Symlinks that resolve outside the repository are rejected. +- The target MUST exist as a regular file inside the repository, and the path MUST match the target's case exactly, component by component, even on case-insensitive filesystems. Symlinks that resolve outside the repository are rejected. - In body links only, a `#fragment` suffix is allowed; the fragment is not verified. In frontmatter, a fragment is an error. - Rejected: relative paths (`foo.md`, `./foo.md`), any `..` segment and backslashes. Other non-existent targets, including machine paths such as `/Users/...` or `/home/...`, fail the existence rule; `file:` URLs fail the scheme rule. -- 2.54.0 From 694c43051e095d70dcb1ebde4d359b45a732c0e1 Mon Sep 17 00:00:00 2001 From: Elijah Duffy Date: Fri, 23 Oct 2026 15:03:26 -0700 Subject: [PATCH 12/13] fix: harden docs checker heading lines, definitions and docs symlinks - N1: never call Lines() on inline nodes; locate empty ATX headings after the end of all preceding blocks (no panic, correct line after `***`) - N2: take reference definition lines from ast.LinkReferenceDefinition and drop refCtx/defRe - N3: reject docs/adr and docs/plans that resolve outside the repository Co-Authored-By: Claude Sonnet 5.5 --- docs/docs-format.md | 2 +- internal/docs/body.go | 92 +++++++++++++------------------------ internal/docs/check.go | 3 ++ internal/docs/check_test.go | 14 ++++++ internal/docs/links_test.go | 2 + internal/docs/refs.go | 8 +++- 6 files changed, 59 insertions(+), 62 deletions(-) diff --git a/docs/docs-format.md b/docs/docs-format.md index 53322a4..a4fc522 100644 --- a/docs/docs-format.md +++ b/docs/docs-format.md @@ -7,7 +7,7 @@ The ADR body uses the [MADR 4](https://adr.github.io/madr/) layout; `Consequence ## 1. Files and directories - ADRs live in `docs/adr/`, plans in `docs/plans/`, relative to the repository root. Both are flat: subdirectories are not scanned and are errors. -- A missing directory is treated as empty. `docs/adr` and `docs/plans` MUST be real directories: a symlink in their place is an error. +- A missing directory is treated as empty. `docs/adr` and `docs/plans` MUST be real directories: a symlink in their place is an error, and neither may resolve outside the repository through a symlinked parent such as `docs`. - Entries whose name starts with `.` (such as `.DS_Store` or editor swap files) are ignored. `README.md` in either directory is skipped. Every other entry MUST be a regular file whose name matches section 2 and ends in `.md`. Any other file, directory or name is an error. There are no other exceptions: no templates and no companion files; every `.md` file is a record. - Files are UTF-8 without a byte order mark. LF and CRLF line endings are both accepted. diff --git a/internal/docs/body.go b/internal/docs/body.go index dfb1b7b..3f2748f 100644 --- a/internal/docs/body.go +++ b/internal/docs/body.go @@ -3,12 +3,10 @@ package docs import ( "bytes" "regexp" - "slices" "strings" "github.com/yuin/goldmark" "github.com/yuin/goldmark/ast" - "github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/text" "github.com/yuin/goldmark/util" ) @@ -40,30 +38,26 @@ func (d *doc) lineAt(off int) int { return d.startLine + bytes.Count(d.src[:min(off, len(d.src))], []byte("\n")) } -// headingLine is the line of h. An empty ATX heading has no lines, so it is the first -// `#` line after the end of the previous top-level block. -func (d *doc) headingLine(h *ast.Heading, prev ast.Node) int { - if h.Lines().Len() > 0 { - return d.lineAt(h.Lines().At(0).Start) - } - from := 0 - if prev != nil { - _ = ast.Walk(prev, func(c ast.Node, enter bool) (ast.WalkStatus, error) { - if enter { - for i := range c.Lines().Len() { - from = max(from, c.Lines().At(i).Stop) - } - if t, ok := c.(*ast.Text); ok { - from = max(from, t.Segment.Stop) - } +// blockEnd is the end offset of the last line under n. Only block nodes have lines. +func blockEnd(n ast.Node) (end int) { + _ = ast.Walk(n, func(c ast.Node, enter bool) (ast.WalkStatus, error) { + if enter && c.Type() == ast.TypeBlock { + for i := range c.Lines().Len() { + end = max(end, c.Lines().At(i).Stop) } - return ast.WalkContinue, nil - }) - } + } + return ast.WalkContinue, nil + }) + return end +} + +// emptyHeadingAt is the offset of an empty ATX heading, which has no lines: the first `#` +// line at or after from, the end of everything before it. +func (d *doc) emptyHeadingAt(from int) int { if m := atxRe.FindIndex(d.src[from:]); m != nil { - return d.lineAt(from + m[0]) + return from + m[0] } - return d.startLine + return from } // lineOf is the line of the first text under n. Without text (an autolink, an image with @@ -93,18 +87,25 @@ func (d *doc) lineOf(n ast.Node, needle string) int { func (c *checker) body(r *record, src string, startLine int, title string, titleOK bool) { b := []byte(src) - ctx := &refCtx{Context: parser.NewContext()} - root := goldmark.DefaultParser().Parse(text.NewReader(b), parser.WithContext(ctx)) + root := goldmark.DefaultParser().Parse(text.NewReader(b)) d := &doc{c: c, r: r, src: b, startLine: startLine} + from := 0 // end of the blocks seen so far for n := root.FirstChild(); n != nil; n = n.NextSibling() { if h, ok := n.(*ast.Heading); ok { - line := d.headingLine(h, n.PreviousSibling()) - d.heads = append(d.heads, heading{h.Level, strings.TrimSpace(string(h.Lines().Value(b))), line, len(d.kids)}) + at := from + if h.Lines().Len() > 0 { + at = h.Lines().At(0).Start + } else { + at = d.emptyHeadingAt(from) + from = at + bytes.IndexByte(d.src[at:], '\n') + 1 // the next line; ^ would match mid-line + } + d.heads = append(d.heads, heading{h.Level, strings.TrimSpace(string(h.Lines().Value(b))), d.lineAt(at), len(d.kids)}) } + from = max(from, blockEnd(n)) d.kids = append(d.kids, n) } d.placeholders(root) - d.links(root, ctx) + d.links(root) d.title(title, titleOK) if r.kind == "adr" { for _, name := range adrSections { @@ -295,27 +296,13 @@ func (d *doc) placeholders(root ast.Node) { } } -// refCtx records every link reference definition; the parser keeps only the first of a label. -type refCtx struct { - parser.Context - all []parser.Reference -} - -func (c *refCtx) AddReference(r parser.Reference) { - c.all = append(c.all, r) - c.Context.AddReference(r) -} - -// defRe finds a definition's label at the start of a line, allowing blockquote and list prefixes. -var defRe = regexp.MustCompile(`(?m)^[ \t>]*(?:(?:[*+-]|\d+[.)])[ \t]+)*\[([^\]\n]+)\]:`) - // dest decodes a link destination as CommonMark does before it is used. func dest(b []byte) string { return string(util.ResolveEntityNames(util.ResolveNumericReferences(util.UnescapePunctuations(b)))) } // links checks every link, image and autolink destination and every reference definition. -func (d *doc) links(root ast.Node, ctx *refCtx) { +func (d *doc) links(root ast.Node) { _ = ast.Walk(root, func(n ast.Node, enter bool) (ast.WalkStatus, error) { if !enter { return ast.WalkContinue, nil @@ -327,24 +314,9 @@ func (d *doc) links(root ast.Node, ctx *refCtx) { d.c.checkLink(d.r, d.lineOf(n, "]("+string(t.Destination)), dest(t.Destination)) case *ast.AutoLink: d.c.checkLink(d.r, d.lineOf(n, "<"+string(t.Label(d.src))), string(t.URL(d.src))) + case *ast.LinkReferenceDefinition: + d.c.checkLink(d.r, d.lineAt(t.Lines().At(0).Start), dest(t.Destination)) } return ast.WalkContinue, nil }) - // shortcut: a definition's line is its label's nth match outside code; a multi-line - // definition inside a container may be located by label only. Upgrade by tracking offsets. - code := codeRanges(root) - lines := map[string][]int{} - for _, m := range defRe.FindAllSubmatchIndex(d.src, -1) { - if !slices.ContainsFunc(code, func(r [2]int) bool { return m[0] >= r[0] && m[0] < r[1] }) { - k := util.ToLinkReference(d.src[m[2]:m[3]]) - lines[k] = append(lines[k], d.lineAt(m[0])) - } - } - for _, ref := range ctx.all { - line, k := d.startLine, util.ToLinkReference(ref.Label()) - if len(lines[k]) > 0 { - line, lines[k] = lines[k][0], lines[k][1:] - } - d.c.checkLink(d.r, line, dest(ref.Destination())) - } } diff --git a/internal/docs/check.go b/internal/docs/check.go index ebac9af..58be826 100644 --- a/internal/docs/check.go +++ b/internal/docs/check.go @@ -62,6 +62,9 @@ func Check(root string) (Result, error) { } else if err == nil && !st.IsDir() { c.add(d.dir, 1, "must be a real directory (symlinks are not allowed)") continue + } else if r, err := filepath.EvalSymlinks(filepath.Join(root, d.dir)); err == nil && !within(real, r) { + c.add(d.dir, 1, "must be inside the repository (a symlinked parent directory is not allowed)") + continue } entries, err := os.ReadDir(filepath.Join(root, d.dir)) if err != nil { diff --git a/internal/docs/check_test.go b/internal/docs/check_test.go index 75a9d7f..a6b0a47 100644 --- a/internal/docs/check_test.go +++ b/internal/docs/check_test.go @@ -337,6 +337,10 @@ func TestCheck(t *testing.T) { {name: "plan missing section", files: map[string]string{pPath: sub(planBase, "## Steps\n\n1. Do it.\n\n", "")}, want: []string{pPath + ":1: missing required section \"Steps\""}}, {name: "plan empty H1", files: map[string]string{pPath: sub(planBase, "# Adopt Queue", "#")}, want: []string{pPath + ":8: H1"}}, {name: "empty ADR H1 is on its own line", files: map[string]string{aPath: sub(adrBase, "# Use Queue", "#")}, want: []string{aPath + ":16: must equal title"}}, + {name: "empty H2 after a paragraph", files: map[string]string{aPath: adrBase + "\nText.\n\n##\n"}}, + {name: "H1 after an empty H2 after a paragraph", files: map[string]string{aPath: adrBase + "\nText.\n\n##\n#\n"}, want: []string{aPath + ":38: more than one H1"}}, + {name: "empty H1 after a thematic break", files: map[string]string{aPath: adrBase + "\n***\n#\n"}, want: []string{aPath + ":36: more than one H1"}}, + {name: "two empty H1s", files: map[string]string{aPath: adrBase + "\n#\n#\n"}, want: []string{aPath + ":35: more than one H1", aPath + ":36: more than one H1"}}, {name: "headings inside blockquotes and lists do not count", files: map[string]string{aPath: adrBase + "\n> ## Consequences\n\n* ## Decision Outcome\n"}}, {name: "H4 does not end the Decision Outcome prose", files: map[string]string{aPath: sub(adrBase, "Chosen option: Queue.", "#### Detail\n\nChosen.")}}, {name: "plan second H1", files: map[string]string{pPath: planBase + "\n# Again\n"}, want: []string{pPath + ":22: more than one H1"}}, @@ -367,6 +371,16 @@ func TestCheckSymlinkedDocsDir(t *testing.T) { expect(t, res.Problems, []string{"docs/plans:1: real directory"}) } +func TestCheckSymlinkedDocsParent(t *testing.T) { + root := tree(t, nil, map[string]string{"docs": "@outside"}) + out, err := os.Readlink(filepath.Join(root, "docs")) + must(t, err) + must(t, os.MkdirAll(filepath.Join(out, "adr"), 0o755)) + res, err := Check(root) + must(t, err) + expect(t, res.Problems, []string{"docs/adr:1: inside the repository"}) +} + func TestCheckEmptyRoot(t *testing.T) { res, err := Check(t.TempDir()) must(t, err) diff --git a/internal/docs/links_test.go b/internal/docs/links_test.go index 7d1345e..f59a20b 100644 --- a/internal/docs/links_test.go +++ b/internal/docs/links_test.go @@ -73,6 +73,8 @@ func TestLinkForms(t *testing.T) { {"duplicate definition is checked", "[a]: /docs/spec.txt\n\n[a]: ../bad.md", []string{":37: relative"}}, {"definition in a blockquote", "> [Zed]: ../bad.md", []string{":35: relative"}}, {"definition-like line in code", "```\n[a]: x\n```\n\n[a]: ../bad.md", []string{":39: relative"}}, + {"definition-like paragraph continuation", "Some text\n[a]: ../x.md\n\n[a]: ../bad.md", []string{":36: relative", ":38: relative"}}, // [a] in the text is a reference link + {"definition-like line in an HTML comment", "\n\n[a]: ../bad.md", []string{":39: relative"}}, {"autolink on a later line", "Para one\nline two\n", []string{":37: scheme"}}, {"image without alt on a later line", "Para one\nline two\n![](foo.md)", []string{":37: relative"}}, {"unused definition", "[r]: foo.md", []string{":35: relative"}}, diff --git a/internal/docs/refs.go b/internal/docs/refs.go index b6e9b0e..2f487cc 100644 --- a/internal/docs/refs.go +++ b/internal/docs/refs.go @@ -108,7 +108,7 @@ func (c *checker) exists(p string) string { if err != nil { return "target " + p + " does not exist" } - if rel, err := filepath.Rel(c.realRoot, real); err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + if !within(c.realRoot, real) { return "target " + p + " resolves outside the repository" } if st, err := os.Stat(real); err != nil || !st.Mode().IsRegular() { @@ -186,3 +186,9 @@ func (k kinds) String() string { } return strings.Join(s, ", ") } + +// within reports whether the real path p is root or lies under it. +func within(root, p string) bool { + rel, err := filepath.Rel(root, p) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} -- 2.54.0 From e2ddd765369a01ee6047e5f958fc7dbe1a3810db Mon Sep 17 00:00:00 2001 From: Elijah Duffy Date: Fri, 23 Oct 2026 15:55:31 -0700 Subject: [PATCH 13/13] docs: accept docs format ADR Co-Authored-By: Claude Opus 5.5 --- ...t-a-checked-format-for-adrs-and-implementation-plans.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/adr/20261023T214738672Z-adopt-a-checked-format-for-adrs-and-implementation-plans.md b/docs/adr/20261023T214738672Z-adopt-a-checked-format-for-adrs-and-implementation-plans.md index 7a07f6f..4c2e052 100644 --- a/docs/adr/20261023T214738672Z-adopt-a-checked-format-for-adrs-and-implementation-plans.md +++ b/docs/adr/20261023T214738672Z-adopt-a-checked-format-for-adrs-and-implementation-plans.md @@ -1,8 +1,8 @@ --- title: Adopt a checked format for ADRs and implementation plans date: 2026-10-23 -status: proposed -implementation: in-progress +status: accepted +implementation: implemented summary: Use one documented ADR and plan format, enforced by a shared Go CLI (docs check). tags: - documentation @@ -12,7 +12,8 @@ supersedes: [] superseded_by: [] related: [] tracking: [] -implemented_by: [] +implemented_by: + - https://gitea.auvem.com/corestack/go/pulls/1 --- # Adopt a checked format for ADRs and implementation plans -- 2.54.0