From d9091b8d9820adb44a8ad98514c6dd7e6206db3f Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 9 Jul 2026 07:19:38 +0100 Subject: [PATCH 1/2] feat(readme): enforce anchor-ID convention + document .adoc bootstrap (ADR-004) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discovered while verifying the Phase C pilot on boj-server's real README: a machine md->adoc conversion of a README with an intra-doc TOC produces dead TOC links, because Asciidoctor's default section IDs are '_prefixed_underscored' while GitHub Markdown anchors are 'lowercase-hyphenated'. The vocabulary gate cannot catch this (it checks words, not link targets). - readme-derive-reusable.yml: new 'Anchor-ID convention guard' step — if the canonical .adoc has link:#… anchors but does not set ':idprefix:' (empty) and ':idseparator: -', fail-and-tell before deriving. - ADR-004: new 'Bootstrapping a canonical .adoc' section — the mandatory idprefix/idseparator convention + the one-time md->adoc bootstrap recipe, both verified end-to-end on boj-server (11/11 TOC anchors resolve, 12/12 badges intact, >=98% vocab with only AsciiDoc scaffolding tokens 'lost'). Co-Authored-By: Claude Opus 4.8 --- .github/workflows/readme-derive-reusable.yml | 25 ++++++++++ .../ADR-004-readme-single-source-derive.adoc | 50 +++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/.github/workflows/readme-derive-reusable.yml b/.github/workflows/readme-derive-reusable.yml index 2710f161..e5e9807d 100644 --- a/.github/workflows/readme-derive-reusable.yml +++ b/.github/workflows/readme-derive-reusable.yml @@ -126,6 +126,31 @@ jobs: https://github.com/hyperpolymath/standards.git "$RUNNER_TEMP/standards" chmod +x "$RUNNER_TEMP/standards/.github/scripts/readme-vocab-gate.sh" + - name: Anchor-ID convention guard + if: steps.decl.outputs.derive == 'true' + env: + CANONICAL: ${{ steps.decl.outputs.canonical }} + run: | + set -euo pipefail + # A canonical .adoc with intra-document anchor links (link:#foo[] or + # <> written as #foo) only renders a working TOC on GitHub and + # re-derives to matching Markdown anchors when it opts out of + # Asciidoctor's default '_prefixed_underscored' section IDs. So if the + # source uses #anchors, it MUST set :idprefix: (empty) and + # :idseparator: - — otherwise every TOC link silently dead-links + # (a failure the vocabulary gate cannot see, since it checks words not + # link targets). See ADR-004 "Bootstrapping a canonical .adoc". + if grep -qE 'link:#[a-z0-9-]+\[' "$CANONICAL"; then + if ! grep -qE '^:idprefix:[[:space:]]*$' "$CANONICAL" \ + || ! grep -qE '^:idseparator:[[:space:]]+-[[:space:]]*$' "$CANONICAL"; then + echo "::error::$CANONICAL has intra-doc anchor links (link:#…) but does not set ':idprefix:' (empty) and ':idseparator: -' in its header. Without them Asciidoctor generates '_underscored' section IDs and every #anchor TOC link dead-links on GitHub and in the derived README.md. Add both attributes to the document header (see ADR-004)." + exit 1 + fi + echo "✓ anchor-ID convention satisfied (idprefix/idseparator set)" + else + echo "✓ no intra-doc anchors — convention not required" + fi + - name: Derive Markdown from AsciiDoc if: steps.decl.outputs.derive == 'true' env: diff --git a/docs/decisions/ADR-004-readme-single-source-derive.adoc b/docs/decisions/ADR-004-readme-single-source-derive.adoc index 02829d06..8e9d869d 100644 --- a/docs/decisions/ADR-004-readme-single-source-derive.adoc +++ b/docs/decisions/ADR-004-readme-single-source-derive.adoc @@ -98,6 +98,56 @@ Rules: * Any estate sweep touching README format MUST read this block first and skip undeclared repos. This is the anti-runaway guard. +=== Bootstrapping a canonical `.adoc` from an existing `README.md` + +The derive direction (`.adoc → .md`) is a clean one-shot. Producing the +*initial* canonical `.adoc` from a rich existing `README.md` is a +one-time authoring step, not part of the CI path, and has one sharp edge +verified on boj-server's real README (264 lines, 12 badges, 15-entry TOC): + +**Anchor-ID convention (mandatory when the README has an intra-doc TOC).** +GitHub Markdown auto-anchors headings as lowercase-hyphenated +(`## Capabilities overview` → `#capabilities-overview`). Asciidoctor +instead defaults to `_prefixed_underscored` IDs +(`_capabilities_overview`), so a converted `link:#capabilities-overview[]` +TOC would dead-link on GitHub *and* re-derive to a broken Markdown anchor. +The canonical `.adoc` MUST therefore set, in its document header: + +[source,asciidoc] +---- += Project Title +:idprefix: +:idseparator: - +---- + +With those two attributes, `== Features` → id `features`, +`== Capabilities overview` → id `capabilities-overview` — matching GitHub +Markdown exactly. `readme-derive-reusable.yml` enforces this: if the +canonical source contains `link:#…[]` anchors without both attributes, the +job fails-and-tells (the vocabulary gate cannot catch this — it checks +words, not link targets). + +**One-time bootstrap recipe** (verified end-to-end on boj-server — +faithful round-trip: 11/11 TOC anchors resolve, 12/12 badges intact, +≥98% vocab, only AsciiDoc scaffolding tokens "lost"): + +[source,bash] +---- +# 1. md -> asciidoc body +pandoc -f gfm -t asciidoc --wrap=preserve README.md -o body.adoc +# 2. promote every heading one level (== -> =, === -> ==, …) so the top +# heading becomes the '= doctitle' and section nesting stays correct +sed -E 's/^==(=*) /=\1 /' body.adoc > body2.adoc +# 3. assemble canonical README.adoc: SPDX header + '= Title' + +# :idprefix:/:idseparator: - + body2 (minus its first title line) +# 4. sanity: render to HTML, confirm section ids match the TOC anchors +---- + +pandoc wraps inline code containing `_ * { +` in AsciiDoc passthroughs +(`++_*++`, `{plus}`) — correct and harmless (renders as literal text; the +derived `.md` comes back clean), just visible noise in the source. Accept +it, or tidy by hand; do not "fix" it in a way that changes rendered output. + === Derivation pipeline (reusable workflow, `standards`) One reusable workflow, `readme-derive.yml`, called by opted-in repos From bbb0b1bbc9ccd03609b634fa19f52f13a07bb98e Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 9 Jul 2026 07:25:29 +0100 Subject: [PATCH 2/2] feat(readme): pin toolchain + normalise freshness for reproducible derivation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make derivation reproducible so the freshness check is trustworthy: - Install step pins pandoc 3.10 + asciidoctor 2.0.26 + asciidoctor-reducer 1.1.1 (was: floating apt/gem latest). The committed README.md must be generated by the same versions CI runs, or cosmetic toolchain drift would be misreported as staleness. Bump deliberately + regenerate; never float. - Freshness check normalises (strip banner, trim trailing WS, squeeze blank runs) before diffing — belt-and-braces so a harmless whitespace nudge is not flagged, while real prose/structure/anchor changes still show. Verified against boj-server's real README end-to-end: normalised body byte-identical on re-derive, vocab 98.7%, anchor guard satisfied. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/readme-derive-reusable.yml | 33 +++++++++++++++----- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/.github/workflows/readme-derive-reusable.yml b/.github/workflows/readme-derive-reusable.yml index e5e9807d..bbcb1789 100644 --- a/.github/workflows/readme-derive-reusable.yml +++ b/.github/workflows/readme-derive-reusable.yml @@ -110,11 +110,22 @@ jobs: - name: Install toolchain if: steps.decl.outputs.derive == 'true' + env: + # Pinned so derivation is REPRODUCIBLE: the same versions used to + # generate the committed README.md must run in CI, or the freshness + # check would flag cosmetic toolchain-drift diffs as staleness. Bump + # deliberately (and regenerate every derived README) — never float. + PANDOC_VERSION: "3.10" + ASCIIDOCTOR_VERSION: "2.0.26" + ASCIIDOCTOR_REDUCER_VERSION: "1.1.1" run: | - sudo apt-get update -qq - sudo apt-get install -y -qq pandoc - # asciidoctor + include-reducer from rubygems (ubuntu ships ruby) - sudo gem install --no-document asciidoctor asciidoctor-reducer + set -euo pipefail + curl -fsSL -o /tmp/pandoc.deb \ + "https://github.com/jgm/pandoc/releases/download/${PANDOC_VERSION}/pandoc-${PANDOC_VERSION}-1-amd64.deb" + sudo dpkg -i /tmp/pandoc.deb + sudo gem install --no-document \ + asciidoctor -v "$ASCIIDOCTOR_VERSION" \ + asciidoctor-reducer -v "$ASCIIDOCTOR_REDUCER_VERSION" pandoc --version | head -1 asciidoctor --version | head -1 @@ -190,13 +201,21 @@ jobs: CANONICAL: ${{ steps.decl.outputs.canonical }} run: | set -euo pipefail + # Normalise before comparing: drop the banner comment lines, trim + # trailing whitespace, and squeeze blank-line runs. The toolchain is + # version-pinned so output is reproducible; this normalisation is a + # belt-and-braces layer that keeps a harmless whitespace nudge from + # being reported as staleness, while any real prose/structure/anchor + # change still shows. + norm() { grep -vE '^