diff --git a/.clean-docs.yml b/.clean-docs.yml index a279ddd..aa8e114 100644 --- a/.clean-docs.yml +++ b/.clean-docs.yml @@ -88,6 +88,51 @@ bindings: doc: docs/SECURITY_MODEL.md anchor: process-controls source: {path: src/clean_docs/isolation.py, symbol: run_isolated_process} + - id: tutorial-outcomes + type: region + doc: docs/learn/tutorial-catch-a-lying-doc.md + region: tutorial-outcomes + extractor: json + source: + path: .clean-docs/learning/tutorial-evidence.json + pointer: /steps + renderer: markdown-table + columns: [moment, command, exit, meaning] + - id: postmortem-measurements + type: region + doc: docs/learn/postmortem-the-readme-that-lied.md + region: postmortem-measurements + extractor: json + source: + path: .clean-docs/learning/ultra-csm-hygiene.json + pointer: /measurements + renderer: markdown-table + columns: [measure, before, after] + - id: postmortem-examples + type: region + doc: docs/learn/postmortem-the-readme-that-lied.md + region: postmortem-examples + extractor: json + source: + path: .clean-docs/learning/ultra-csm-hygiene.json + pointer: /examples + renderer: markdown-table + columns: [case, before, after] + - id: deterministic-seam-evidence + type: symbol + doc: docs/learn/deep-dive-the-deterministic-seam.md + anchor: evidence-authority + source: {path: src/clean_docs/engine.py, symbol: evaluate} + - id: deterministic-seam-phrasing + type: symbol + doc: docs/learn/deep-dive-the-deterministic-seam.md + anchor: phrasing-boundary + source: {path: src/clean_docs/phrasing.py, symbol: build_model_record} + - id: deterministic-seam-gate + type: symbol + doc: docs/learn/deep-dive-the-deterministic-seam.md + anchor: gate-authority + source: {path: src/clean_docs/outcomes.py, symbol: build_outcome_receipt} projections: llms_txt: output: llms.txt @@ -105,6 +150,10 @@ projections: - docs/SECURITY_MODEL.md - docs/SUPPORT.md - docs/SURFACE.md + - docs/learn/index.md + - docs/learn/tutorial-catch-a-lying-doc.md + - docs/learn/postmortem-the-readme-that-lied.md + - docs/learn/deep-dive-the-deterministic-seam.md - skill/SKILL.md bundles: - id: contributor diff --git a/.clean-docs/context/contributor.md b/.clean-docs/context/contributor.md index 74649b3..32c910d 100644 --- a/.clean-docs/context/contributor.md +++ b/.clean-docs/context/contributor.md @@ -1,13 +1,13 @@ # Context bundle: contributor - Source ref: `WORKTREE` -- Corpus sha256: `74f0d42c8d758125d6fbe9878054b8bd5abeb7b32477a26274e99f776128be01` +- Corpus sha256: `4de14e204a537a326c3881119c5d39f9efea575cf1c7355f9a573d1a8c85c279` - Content: exact canonical document bytes ## Canonical document: README.md - Source: [README.md](../../README.md) -- Content sha256: `4289b0409955e0ce025e67cdf718814f0c0d77971663fe779809a64aa8e39d05` +- Content sha256: `b3d947ea15bd363b589a256b91affbd9d954b8b78667aa34432e8d0478cc5358` # clean-docs @@ -17,6 +17,13 @@ clean-docs is a source-bound documentation engine and CLI for maintainers whose +[![CI](https://github.com/owieschon/clean-docs/actions/workflows/ci.yml/badge.svg)](https://github.com/owieschon/clean-docs/actions/workflows/ci.yml) +[![Release](https://img.shields.io/github/v/release/owieschon/clean-docs?display_name=tag&sort=semver)](https://github.com/owieschon/clean-docs/releases/latest) +[![License: MIT](https://img.shields.io/badge/license-MIT-25225f.svg)](LICENSE) + +**[Start here](docs/learn/index.md)** for the product map, runnable drift tutorial, real-repository +postmortem, and deterministic-boundary explanation. + ## Why clean-docs exists diff --git a/.clean-docs/learning/tutorial-evidence.json b/.clean-docs/learning/tutorial-evidence.json new file mode 100644 index 0000000..595dc37 --- /dev/null +++ b/.clean-docs/learning/tutorial-evidence.json @@ -0,0 +1,35 @@ +{ + "schema": "clean-docs.tutorial-evidence.v1", + "steps": [ + { + "command": "clean-docs check", + "exit": 0, + "meaning": "The bound page matches source.", + "moment": "Protected baseline" + }, + { + "command": "clean-docs check", + "exit": 1, + "meaning": "The status-actions binding is stale.", + "moment": "Source changed alone" + }, + { + "command": "clean-docs drive", + "exit": 0, + "meaning": "Only the bound region changes.", + "moment": "Declared region repaired" + }, + { + "command": "clean-docs project", + "exit": 0, + "meaning": "llms.txt receives the repaired page digest.", + "moment": "Projection refreshed" + }, + { + "command": "clean-docs verify", + "exit": 0, + "meaning": "Bindings and projections are current.", + "moment": "Repository verified" + } + ] +} diff --git a/.clean-docs/learning/ultra-csm-hygiene.json b/.clean-docs/learning/ultra-csm-hygiene.json new file mode 100644 index 0000000..f165565 --- /dev/null +++ b/.clean-docs/learning/ultra-csm-hygiene.json @@ -0,0 +1,63 @@ +{ + "examples": [ + { + "after": "relocated into the archive convention the repo already used, history intact", + "before": "an agent-to-agent handoff sat in the tracked doc tree a reader browses", + "case": "process exhaust off the reader surface" + }, + { + "after": "only the version `src/ultra_csm/agent1/slot_b.py:38` loads remains on the surface", + "before": "four versions of one prompt tracked side by side, each a near-duplicate of the last", + "case": "one canonical home for a versioned prompt" + }, + { + "after": "each passage is equally true, with the authoring history removed", + "before": "a worldbuilding reference tagged its content with the program that authored it", + "case": "provenance stripped from a reader-facing reference" + } + ], + "measurements": [ + { + "after": "73 (all justified in `NOTES.md`)", + "before": "280", + "measure": "Total findings" + }, + { + "after": "10 (cited evidence, per `docs/README.md`)", + "before": "91", + "measure": "surface (process artifact on reader surface)" + }, + { + "after": "33 (genuine long references)", + "before": "93", + "measure": "section-length" + }, + { + "after": "7 (shared concepts across separate docs)", + "before": "54", + "measure": "near-dup" + }, + { + "after": "21 (genuine long references)", + "before": "22", + "measure": "doc-length" + }, + { + "after": "1 (a run-ledger table cell)", + "before": "11", + "measure": "provenance" + }, + { + "after": "0", + "before": "7", + "measure": "audience" + }, + { + "after": "1", + "before": "2", + "measure": "restatement" + } + ], + "schema": "clean-docs.learning-evidence.v1", + "source": "docs/archive/v0/ultra-csm-before-after.md" +} diff --git a/.clean-docs/reader-trial-rubric-v1.1.yml b/.clean-docs/reader-trial-rubric-v1.1.yml new file mode 100644 index 0000000..28d69c1 --- /dev/null +++ b/.clean-docs/reader-trial-rubric-v1.1.yml @@ -0,0 +1,30 @@ +version: 1 +context: + - README.md + - docs/learn/index.md + - docs/learn/tutorial-catch-a-lying-doc.md + - docs/learn/postmortem-the-readme-that-lied.md + - docs/learn/deep-dive-the-deterministic-seam.md +profiles: + - id: anthropic-opus-4-8 + provider: anthropic + model: Opus 4.8 + - id: codex-gpt-5-6-sol-high + provider: codex + model: GPT 5.6 Sol High +tasks: + - id: route-orientation + instruction: Starting from the README first screen and learning index, choose the page that explains what clean-docs is and why source-bound documentation matters. + passes_when: The reader chooses the README overview for product orientation, then identifies the learning index thesis that documentation drift is a source-derivation problem. + - id: route-hands-on + instruction: Starting from the learning index, choose the page for learning the repair loop by doing it. + passes_when: The reader chooses the catch-a-lying-doc tutorial and states that it establishes a baseline, creates source drift, observes failure, repairs the declared region, refreshes projections, and verifies the result. + - id: route-mechanism + instruction: Starting from the learning index, choose the page that explains who owns facts, phrasing, and the gate result. + passes_when: The reader chooses the deterministic-seam deep dive and assigns evidence selection and pass-or-fail authority to deterministic code while limiting a model to phrasing supplied evidence. + - id: run-tutorial + instruction: Run the published tutorial against the candidate in a fresh repository and record each command exit and declared file change. + passes_when: The baseline passes, deliberate source drift fails first with the named binding, drive changes the declared documentation region, project refreshes llms.txt, and final check and verify exit zero. + - id: explain-authority-boundary + instruction: Explain why a model cannot select facts, widen scope, or decide whether clean-docs passes, using only the published learning pages. + passes_when: The answer preserves the evidence-to-phrasing-to-gate sequence, names deterministic code as the authority on inputs and outcomes, and does not grant the model merge-gate authority. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25a24d9..7f3ee9d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,14 @@ jobs: - run: clean-docs --root . release --from v0.4.0 --to HEAD --format json - run: python scripts/record_demo.py --out /tmp/clean-docs-demo-evidence.json - run: cmp .clean-docs/demo/evidence.json /tmp/clean-docs-demo-evidence.json + - run: python scripts/build_learning_evidence.py --out /tmp/clean-docs-learning-evidence.json + - run: cmp .clean-docs/learning/ultra-csm-hygiene.json /tmp/clean-docs-learning-evidence.json + - run: >- + python scripts/record_learning_tutorial.py + --clean-docs "$(command -v clean-docs)" + --out /tmp/clean-docs-tutorial-evidence.json + - run: cmp .clean-docs/learning/tutorial-evidence.json /tmp/clean-docs-tutorial-evidence.json + - run: python scripts/render_social_preview.py --check - run: python scripts/trusted_self_check.py public-dogfood: @@ -224,6 +232,32 @@ jobs: path: clean-docs-v10-acceptance.json if-no-files-found: error + acceptance-v1-1: + needs: test + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: "3.12" + cache: pip + - run: python -m pip install ".[dev]" + - run: >- + python scripts/run_acceptance.py + --registry tests/v11-acceptance.yml + --out clean-docs-v11-acceptance.json + - if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: clean-docs-v1.1-acceptance-${{ matrix.os }}-${{ github.sha }} + path: clean-docs-v11-acceptance.json + if-no-files-found: error + performance-v1-0: needs: test runs-on: ${{ matrix.os }} diff --git a/README.md b/README.md index 902e36c..b7836ec 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,13 @@ clean-docs is a source-bound documentation engine and CLI for maintainers whose +[![CI](https://github.com/owieschon/clean-docs/actions/workflows/ci.yml/badge.svg)](https://github.com/owieschon/clean-docs/actions/workflows/ci.yml) +[![Release](https://img.shields.io/github/v/release/owieschon/clean-docs?display_name=tag&sort=semver)](https://github.com/owieschon/clean-docs/releases/latest) +[![License: MIT](https://img.shields.io/badge/license-MIT-25225f.svg)](LICENSE) + +**[Start here](docs/learn/index.md)** for the product map, runnable drift tutorial, real-repository +postmortem, and deterministic-boundary explanation. + ## Why clean-docs exists diff --git a/docs/RELEASES.md b/docs/RELEASES.md index 48493f1..86d61ec 100644 --- a/docs/RELEASES.md +++ b/docs/RELEASES.md @@ -33,4 +33,11 @@ The factual Markdown and JSON remain unchanged when narrative validation fails. Release extraction is static and snapshot-bound. First-party and configured discoverer plugins run once per ref in disposable copies. The active worktree, its installed dependencies, and narrative output cannot change the typed delta. -Release-candidate builds rehearse the published reader tasks in CI. A stable release additionally requires one content-addressed trial from each model profile declared in the rubric: Anthropic Opus 4.8, Anthropic Sonnet 5, Codex GPT 5.5 High, and Codex GPT 5.6 Sol High. Every profile runs in a fresh session with only the rubric's published context, completes every task, and binds its evidence by SHA-256. Profiles cannot share conversation history or prior trial output. The receipt also names the exact candidate commit and wheel digest. `scripts/verify_reader_trial.py` checks `.clean-docs/reader-trial.json`; `scripts/build_release.py` reconstructs the candidate wheel and permits only the version and reader receipts to differ in the stable release. It refuses absent, duplicate, substituted, incomplete, stale, tampered, or untried evidence. +Release-candidate builds rehearse the published reader tasks in CI. A stable release additionally requires one content-addressed trial from each profile in that release line's rubric: + +| Release line | Independent reader set | Evidence contract | +| --- | --- | --- | +| `1.0` | Anthropic Opus 4.8, Anthropic Sonnet 5, Codex GPT 5.5 High, and Codex GPT 5.6 Sol High | Product identification, installation, protection, repair, and limitation tasks | +| `1.1` | Anthropic Opus 4.8 and Codex GPT 5.6 Sol High | Learning-path routing, tutorial execution, and deterministic-authority tasks | + +Every profile runs in a fresh session with only the rubric's published context, completes every task, and binds its evidence by SHA-256. Profiles cannot share conversation history or prior trial output. The receipt also names the exact candidate commit and wheel digest. `scripts/verify_reader_trial.py` selects the versioned rubric and evidence namespace; `scripts/build_release.py` reconstructs the candidate wheel and permits only the version and matching reader receipts to differ in the stable release. It refuses absent, duplicate, substituted, incomplete, stale, tampered, or untried evidence. diff --git a/docs/SURFACE.md b/docs/SURFACE.md index c434821..4433ec5 100644 --- a/docs/SURFACE.md +++ b/docs/SURFACE.md @@ -9,12 +9,12 @@ The catalog binding catches additions, removals, and replacements across the det | surface | discovered | examples | | --- | ---: | --- | -| api-symbol | 175 | `AcceptanceCase`, `Assertion`, `AuditFinding`, and 172 more | +| api-symbol | 184 | `AcceptanceCase`, `Assertion`, `AuditFinding`, and 181 more | | cli-command | 19 | `audit`, `benchmark`, `check`, and 16 more | -| cli-option | 50 | `--accept-hygiene-baseline`, `--base`, `--binding`, and 47 more | +| cli-option | 55 | `--accept-hygiene-baseline`, `--base`, `--binding`, and 52 more | | package | 1 | `clean-docs` | | runtime-constraint | 1 | `Python >=3.10` | -| test-suite | 47 | `scripts/test_release_lifecycle.py`, `tests/test_audit.py`, `tests/test_changed_check.py`, and 44 more | +| test-suite | 49 | `scripts/test_release_lifecycle.py`, `tests/test_audit.py`, `tests/test_changed_check.py`, and 46 more | - + diff --git a/docs/assets/clean-docs-social.png b/docs/assets/clean-docs-social.png new file mode 100644 index 0000000..5d8d369 Binary files /dev/null and b/docs/assets/clean-docs-social.png differ diff --git a/docs/assets/clean-docs-social.svg b/docs/assets/clean-docs-social.svg new file mode 100644 index 0000000..b3a6cd5 --- /dev/null +++ b/docs/assets/clean-docs-social.svg @@ -0,0 +1,73 @@ + + clean-docs: docs that answer to code + A three-step source-bound documentation flow. Repository sources own facts, bindings connect facts to prose, and a deterministic check rejects stale changes. + + + + + + + + + + + + + + + + + + + + clean-docs + + SOURCE-BOUND DOCUMENTATION ENGINE + Docs that answer to code. + When source moves, stale prose fails loudly. + + + + + + 01 · SOURCE + Repository sources + Code owns the fact. + + static evidence + + + + + + 02 · BIND + Source bindings + Prose names its owner. + + region · claim · symbol + + + + + + + 03 · PROVE + Deterministic check + Drift fails before merge. + + repair · reject · project + + + + + THE CHECKABLE SPINE STAYS ATTACHED + diff --git a/docs/learn/deep-dive-the-deterministic-seam.md b/docs/learn/deep-dive-the-deterministic-seam.md new file mode 100644 index 0000000..f35015e --- /dev/null +++ b/docs/learn/deep-dive-the-deterministic-seam.md @@ -0,0 +1,66 @@ +# Deep dive: the deterministic seam + + +This explanation is for maintainers deciding where model judgment may enter a documentation pipeline without weakening its gate. It separates evidence selection, prose phrasing, repository writes, and pass or fail authority so you can inspect the boundary in both the architecture and the implementation. + + +The short version is memorable because it is mechanical: a model may hold the pen, but it never +gets the keys. Facts enter from declared evidence, writes stay bounded to planned documents, and a +deterministic result decides whether the repository is current. + +The [product system map](../../README.md#how-the-pieces-fit) shows the complete path and its text +equivalent. This page follows the three authority handoffs inside it: evidence enters, phrasing may +change its form, and deterministic checks retain the verdict. + +## Evidence authority + +[`evaluate`](../../src/clean_docs/engine.py) owns the source-to-binding comparison. It loads the +manifest, creates a repository snapshot, dispatches only the declared extractor, renders the typed +value, and compares the result with the marked document region. Claim and symbol bindings take the +same route through typed results. + +This is the first side of the seam: source configuration selects which facts exist. A model does not +discover an extra capability, widen the selected repository scope, or turn an unsupported surface +into an accepted claim. + +## Phrasing boundary + +[`build_model_record`](../../src/clean_docs/phrasing.py) receives inventory facts that already +exist. Its prompt exposes an allowlist of prose templates and asks for fact identifiers plus template +names, not open-ended product copy. The parser rejects unknown facts, duplicate facts, unsupported +templates, malformed responses, and more than five drafts. + +The returned sentence is therefore a presentation of supplied evidence. It is not evidence. If the +provider fails, the operation stops before a repository write. The [security model](../SECURITY_MODEL.md) +defines the surrounding process and host boundaries without duplicating them here. + +## Gate authority + +[`build_outcome_receipt`](../../src/clean_docs/outcomes.py) combines deterministic audit, inventory, +binding, projection, and changed-file results. Its `ok` value follows those counts. It does not ask a +provider whether the prose sounds supported. + +That distinction keeps failure actionable. A stale region names its binding and source. A missing +symbol names its locator. A model score could express judgment about clarity, but it cannot replace +the repeatable evidence that made the check fail. + +## Why the seam is useful + +Pure generation can produce fluent documentation whose factual scope is impossible to audit. Pure +templates can keep facts current while flattening every explanation into reference prose. The seam +keeps both tools in their honest roles: + +| Responsibility | Owner | Why | +| --- | --- | --- | +| Select facts and scope | Manifest plus extractor | The input must be reproducible at a repository ref | +| Phrase supplied facts | Deterministic renderer or bounded model path | Presentation may vary without changing authority | +| Write documentation | Explicit derive or drive path | Unrelated prose must remain untouched | +| Decide pass or fail | Deterministic checks | CI needs the same answer without credentials or judgment drift | + +Use a human or model to judge motivation, pedagogy, and whether the chosen facts are enough. Use the +gate to prove that declared facts, locators, and projections still match. The seam is not a claim +that all prose is true forever. It is the line that prevents the checkable spine from drifting +silently. + +Run [the tutorial](tutorial-catch-a-lying-doc.md) to exercise that boundary, or use the +[extension guide](../EXTENSIONS.md) when a source needs a new deterministic adapter. diff --git a/docs/learn/index.md b/docs/learn/index.md new file mode 100644 index 0000000..d2eb375 --- /dev/null +++ b/docs/learn/index.md @@ -0,0 +1,30 @@ +# Learn clean-docs + + +This learning path is for maintainers deciding whether source-bound documentation fits their repository. It replaces vague confidence in prose with a mental model, a runnable drift repair, and a clear authority boundary, so you can choose the shortest page for the question in front of you. + + +Documentation drift is a source-derivation problem before it is a discipline problem. Review can +catch a stale sentence, but it cannot make the relationship between that sentence and its defining +source repeatable. clean-docs makes that relationship explicit and checks it again after the code +moves. + +The useful question is not "who forgot the docs?" It is "which source should have made this claim +fail?" Follow the path that matches your current job: + +| If you need to... | Start with | You will leave with... | +| --- | --- | --- | +| Decide what the product is | [Product overview](../../README.md) | The category, fit, architecture, and installation route | +| Protect one fact yourself | [Catch a lying doc](tutorial-catch-a-lying-doc.md) | A working binding, a failed drift check, and a repaired page | +| See the problem at repository scale | [The README that lied](postmortem-the-readme-that-lied.md) | A concrete cleanup case and the limits of its evidence | +| Understand the model boundary | [The deterministic seam](deep-dive-the-deterministic-seam.md) | A precise account of who owns facts, prose, and gate results | + +The tutorial is the fastest proof. The postmortem explains why the proof matters beyond a toy +fixture. The deep dive names the mechanism after you have seen it move. + +## What this path does not replace + +These pages teach a sequence and a mental model. Use the existing [CLI reference](../CLI.md), +[support guide](../SUPPORT.md), and [security model](../SECURITY_MODEL.md) when you need exact current +commands, installation variants, or trust boundaries. The lessons link to those canonical homes +instead of growing stale copies. diff --git a/docs/learn/postmortem-the-readme-that-lied.md b/docs/learn/postmortem-the-readme-that-lied.md new file mode 100644 index 0000000..ed85fc9 --- /dev/null +++ b/docs/learn/postmortem-the-readme-that-lied.md @@ -0,0 +1,78 @@ +# Postmortem: the README that lied + + +This case study is for maintainers whose documentation corpus feels polished but has lost a trustworthy reader path. It shows how one real repository separated current guidance from process exhaust, measured the result, and exposed which improvements still required human judgment. + + +The repository did not have one spectacularly false page. It had a quieter failure: current +guidance, superseded prompts, handoff notes, and authoring history all looked equally official. +Nothing in the prose announced which layer a reader had entered. + +This is a recorded historical cleanup of `ultra-csm`, not a claim about that repository's current +state. The typed record is derived from the archived before-and-after account at +[`docs/archive/v0/ultra-csm-before-after.md`](../archive/v0/ultra-csm-before-after.md). clean-docs +renders the tables below from that record. + +## What the scan found + +The first pass reported 280 findings across structure, duplication, length, provenance, and +audience signals: + + +| measure | before | after | +| --- | --- | --- | +| Total findings | 280 | 73 (all justified in `NOTES.md`) | +| surface (process artifact on reader surface) | 91 | 10 (cited evidence, per `docs/README.md`) | +| section-length | 93 | 33 (genuine long references) | +| near-dup | 54 | 7 (shared concepts across separate docs) | +| doc-length | 22 | 21 (genuine long references) | +| provenance | 11 | 1 (a run-ledger table cell) | +| audience | 7 | 0 | +| restatement | 2 | 1 | + + +The count fell because the reader surface got smaller and the checker lost false positives. It did +not fall because thresholds were loosened. The remaining findings were reviewed and justified in +the source repository. + +## Three changes did most of the work + +The cleanup made each document answer a harder question than "is this Markdown useful somewhere?" +It asked whether the page belonged on the current reader surface: + + +| case | before | after | +| --- | --- | --- | +| process exhaust off the reader surface | an agent-to-agent handoff sat in the tracked doc tree a reader browses | relocated into the archive convention the repo already used, history intact | +| one canonical home for a versioned prompt | four versions of one prompt tracked side by side, each a near-duplicate of the last | only the version `src/ultra_csm/agent1/slot_b.py:38` loads remains on the surface | +| provenance stripped from a reader-facing reference | a worldbuilding reference tagged its content with the program that authored it | each passage is equally true, with the authoring history removed | + + +The first change moved handoffs and status notes into the repository's archive convention. The +second left one loaded prompt version on the current surface and preserved older versions as +history. The third removed authoring-program labels from reference prose that stayed true without +them. + +The result was not tidier wording. It was a corpus whose paths had different meanings. + +## What the numbers do not prove + +A lower hygiene count does not prove that every sentence is accurate, helpful, or well taught. +Several remaining findings were legitimate long references or shared concepts, and three reductions +came from correcting the linter itself. The evidence supports a narrower claim: structural noise +became measurable, most of it was removed from the current reader path, and the exceptions became +explicit. + +That boundary matters. Corpus checks can find likely process exhaust, duplicate language, and +misplaced provenance. A source binding is still required when a specific product claim must fail +after its defining code changes. [Catch a lying doc](tutorial-catch-a-lying-doc.md) demonstrates that +second mechanism on one fact. + +## The reusable decision + +For every page, ask: if this vanished from the current documentation surface, would a reader lose +current product truth or only authoring history? Keep the first in the reader path. Preserve the +second in version history or an archive whose name makes its status clear. + +Then bind the factual spine that remains. Structure makes the right page findable; source evidence +makes its checkable claims answerable. diff --git a/docs/learn/tutorial-catch-a-lying-doc.md b/docs/learn/tutorial-catch-a-lying-doc.md new file mode 100644 index 0000000..fdd33b9 --- /dev/null +++ b/docs/learn/tutorial-catch-a-lying-doc.md @@ -0,0 +1,118 @@ +# Catch a lying doc + + +This tutorial is for maintainers who want to see one source-bound fact fail and recover in a disposable repository. It turns an easy-to-miss command rename into a named check failure, a region-only repair, and a verified projection using an installed clean-docs release. + + +A stale README keeps a straight face. This exercise gives it a tripwire. + +You will build Moonbase Status, a tiny operator guide whose public action table comes from a Python literal. No repository code is imported or executed. The full binding surface and installation variants remain in the [manifest reference](../../README.md#manifest-reference) and [support guide](../SUPPORT.md). + +## Before you begin + +Install a stable clean-docs artifact in an isolated environment and confirm `clean-docs --version` works. The [local artifact procedure](../SUPPORT.md#install-upgrade-roll-back-and-uninstall) is the offline path used to test this tutorial. You also need Git and Python 3.10 or newer. + +## 1. Build the fixture + +Create an empty repository, its source-owned action, the page, and the binding manifest: + +```bash +mkdir moonbase-status +cd moonbase-status +git init +mkdir src +cat > src/actions.py <<'PY' +ACTIONS = [ + {"command": "report", "job": "Send the current habitat status"}, +] +PY +cat > README.md <<'MD' +# Moonbase Status + + +Use this guide when a habitat operator needs the current public actions. It keeps the action table tied to source so a renamed command cannot remain plausible in the guide. + + +## Operator actions + + + +MD +cat > .clean-docs.yml <<'YAML' +version: 1 +bindings: + - id: status-actions + type: region + doc: README.md + region: status-actions + extractor: python-literal + source: {path: src/actions.py, symbol: ACTIONS} + renderer: markdown-table + columns: [command, job] +projections: + llms_txt: + output: llms.txt + title: Moonbase Status documentation + summary: Source-bound operator documentation. + include: [README.md] +YAML +``` + +## 2. Protect the baseline + +Render the declared region, generate the projection, and verify the repository: + +```bash +clean-docs drive +clean-docs project +clean-docs check +clean-docs verify +git add .clean-docs.yml README.md llms.txt src/actions.py +git commit -m "Protect operator actions" +``` + +The check exits `0`. The receipt reports `"ok": true`, and the README now contains the `report` row derived from `ACTIONS`. + +## 3. Create honest drift + +Rename the source command without touching the README: + +```bash +python3 - <<'PY' +from pathlib import Path + +path = Path("src/actions.py") +path.write_text(path.read_text().replace('"report"', '"publish"')) +PY +clean-docs check +``` + +The final command exits `1` and names `status-actions`. That failure is the lesson: the old prose still reads well, but it no longer gets to pass as current. + +## 4. Repair and verify + +Repair the bound region, inspect its exact diff, refresh the projection, and run the final gate: + +```bash +clean-docs drive +git diff -- README.md +clean-docs project +clean-docs check +clean-docs verify +``` + +`drive` changes the table row from `report` to `publish` without rewriting the surrounding prose. `project` updates the page digest in `llms.txt`. The last two commands exit `0`. + +The release-tested fixture records the same state changes: + + +| moment | command | exit | meaning | +| --- | --- | --- | --- | +| Protected baseline | clean-docs check | 0 | The bound page matches source. | +| Source changed alone | clean-docs check | 1 | The status-actions binding is stale. | +| Declared region repaired | clean-docs drive | 0 | Only the bound region changes. | +| Projection refreshed | clean-docs project | 0 | llms.txt receives the repaired page digest. | +| Repository verified | clean-docs verify | 0 | Bindings and projections are current. | + + +You have now seen the full loop. Read [the deterministic seam](deep-dive-the-deterministic-seam.md) to understand why a model can help with wording without gaining authority over this result. diff --git a/llms.txt b/llms.txt index f1fb27a..3f2a687 100644 --- a/llms.txt +++ b/llms.txt @@ -5,14 +5,18 @@ ## Canonical documentation - [CLEAN_DOCS_SPEC.md](CLEAN_DOCS_SPEC.md): declared canonical context; sha256: b0df161645ff6f5356913658789990083c09a7d041f385541df97e9e259e99d1 -- [README.md](README.md): bindings: manifest-reference, product-overview, supported-bindings; sha256: 4289b0409955e0ce025e67cdf718814f0c0d77971663fe779809a64aa8e39d05 +- [README.md](README.md): bindings: manifest-reference, product-overview, supported-bindings; sha256: b3d947ea15bd363b589a256b91affbd9d954b8b78667aa34432e8d0478cc5358 - [SECURITY.md](SECURITY.md): declared canonical context; sha256: 9b73fcc18efa2ff39d64d8271a6c33b278de5dc625d7ac7c319587edf0c31621 - [STANDARD.md](STANDARD.md): declared canonical context; sha256: 2af7b40f7ac3a91b4d3877e98b1d33420f47b36e30202eb8b493f9c1ee4d6792 - [docs/CLI.md](docs/CLI.md): bindings: cli-reference; sha256: dae82eebf8e8712dce8b4c17e480f13912155078b3e34fb0159ff2f2c45510bc - [docs/EVALUATION.md](docs/EVALUATION.md): bindings: evaluation-scorers; sha256: efbcce90691ec594da793977b33ba62add79dd47c0ac9452443e826d28e1bb2f - [docs/EXTENSIONS.md](docs/EXTENSIONS.md): bindings: extension-guide, migration-guide; sha256: a1da5683da8247c11fb9bb2d6f04c3aa30961cabe7aab5ee5791b70244835d2d -- [docs/RELEASES.md](docs/RELEASES.md): bindings: release-guide; sha256: 0c9024977bca84b77106d85b91a756bf97124638ed65601cdc79cc90bf007161 +- [docs/RELEASES.md](docs/RELEASES.md): bindings: release-guide; sha256: 44a95749c55a3989777a9c2516a1b6d7d2da02954a6565f76019f8183b574583 - [docs/SECURITY_MODEL.md](docs/SECURITY_MODEL.md): bindings: security-model; sha256: 29d9f2582186f19d72823e163512fa12f6d7459f4da273ca3d8be56d5befd2bf - [docs/SUPPORT.md](docs/SUPPORT.md): bindings: support-guide; sha256: bc0055417eb4be09a5e8722aaa0bfa7454877b3909e38a72b7d03f9fbd7576ab -- [docs/SURFACE.md](docs/SURFACE.md): bindings: repository-surface; sha256: a4dfbe4cc61e7368aedc05c4c30422fc0194fe21e8e7c0477705495c16e688bf +- [docs/SURFACE.md](docs/SURFACE.md): bindings: repository-surface; sha256: 7cc29bb2ccdf1a92a0740a114181a97ffdca8b5eb4744c64000aa6119b415c26 +- [docs/learn/deep-dive-the-deterministic-seam.md](docs/learn/deep-dive-the-deterministic-seam.md): bindings: deterministic-seam-evidence, deterministic-seam-gate, deterministic-seam-phrasing; sha256: d4dcc2aaf7e6fa917d5474bb8e265eb60c0dca7332e099c5342c5bd79fb83371 +- [docs/learn/index.md](docs/learn/index.md): declared canonical context; sha256: 54ca471c870ac4b295b0bfe72d51561891084c7483a649f3586517204ac974fb +- [docs/learn/postmortem-the-readme-that-lied.md](docs/learn/postmortem-the-readme-that-lied.md): bindings: postmortem-examples, postmortem-measurements; sha256: 2616fe1ad837581461ccbd07bcfd7e0c6aa799f762e64f40ed34a637f3460cb2 +- [docs/learn/tutorial-catch-a-lying-doc.md](docs/learn/tutorial-catch-a-lying-doc.md): bindings: tutorial-outcomes; sha256: b5aeabb02e76c64e328d637b28ee7ca8458964ca402f3ce5b915b6d10ff0c51a - [skill/SKILL.md](skill/SKILL.md): declared canonical context; sha256: 77f0b257236a184c5b202e8327c27785f3ab4abe9c1ae9c3c082f8fa06428da9 diff --git a/pyproject.toml b/pyproject.toml index 622f848..a697126 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "clean-docs" -version = "1.0.0" +version = "1.1.0rc1" description = "Keep repository documentation bound to its sources." readme = "README.md" requires-python = ">=3.10" diff --git a/scripts/build_learning_evidence.py b/scripts/build_learning_evidence.py new file mode 100644 index 0000000..0595d7c --- /dev/null +++ b/scripts/build_learning_evidence.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Build the typed historical record used by the learning postmortem.""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path + +from clean_docs.regions import atomic_write + + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / "docs/archive/v0/ultra-csm-before-after.md" +OUTPUT = ROOT / ".clean-docs/learning/ultra-csm-hygiene.json" + + +def _table_rows(text: str) -> list[dict[str, str]]: + match = re.search( + r"## The numbers\n\n\| Measure \| Before \| After \|\n" + r"\| --- \| --- \| --- \|\n(?P(?:\|.*\|\n)+)", + text, + ) + if match is None: + raise RuntimeError("archived measurement table not found") + rows: list[dict[str, str]] = [] + for line in match.group("body").splitlines(): + measure, before, after = (cell.strip() for cell in line.strip("|").split("|")) + rows.append({"measure": measure, "before": before, "after": after}) + return rows + + +def _examples(text: str) -> list[dict[str, str]]: + sections = re.findall( + r"## Example \d+: (?P[^\n]+)\n\n" + r"Before -- (?P[^\n]+):\n\n```text\n(?P.*?)\n```\n\n" + r"After -- (?P[^\n]+):\n\n```text\n(?P.*?)\n```", + text, + flags=re.DOTALL, + ) + if len(sections) != 3: + raise RuntimeError("expected three archived before-and-after examples") + return [ + { + "case": case, + "before": before_intro.rstrip("."), + "after": after_intro.rstrip("."), + } + for case, before_intro, _before, after_intro, _after in sections + ] + + +def build_record(source: Path = SOURCE) -> dict[str, object]: + text = source.read_text(encoding="utf-8") + return { + "schema": "clean-docs.learning-evidence.v1", + "source": source.relative_to(ROOT).as_posix(), + "measurements": _table_rows(text), + "examples": _examples(text), + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--source", type=Path, default=SOURCE) + parser.add_argument("--out", type=Path, default=OUTPUT) + args = parser.parse_args() + rendered = json.dumps(build_record(args.source.resolve()), indent=2, sort_keys=True) + "\n" + atomic_write(args.out.resolve(), rendered) + print(args.out) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/build_release.py b/scripts/build_release.py index 832e659..fdc5e79 100644 --- a/scripts/build_release.py +++ b/scripts/build_release.py @@ -114,11 +114,13 @@ def _verify_reader_candidate( candidate = str(reader_trial["candidate_commit"]) _run("git", "merge-base", "--is-ancestor", candidate, final_ref) changed = set(_run("git", "diff", "--name-only", candidate, final_ref).splitlines()) - allowed = {"pyproject.toml", ".clean-docs/reader-trial.json"} + receipt_path = str(reader_trial["receipt_path"]) + evidence_root = str(reader_trial["evidence_root"]).rstrip("/") + "/" + allowed = {"pyproject.toml", receipt_path} unexpected = sorted( path for path in changed - if path not in allowed and not path.startswith(".clean-docs/reader-trials/") + if path not in allowed and not path.startswith(evidence_root) ) if unexpected: raise RuntimeError( diff --git a/scripts/record_learning_tutorial.py b/scripts/record_learning_tutorial.py new file mode 100644 index 0000000..88cb575 --- /dev/null +++ b/scripts/record_learning_tutorial.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Run the published drift tutorial and record stable observed outcomes.""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import tempfile +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +OUTPUT = ROOT / ".clean-docs/learning/tutorial-evidence.json" +MANIFEST = """\ +version: 1 +bindings: + - id: status-actions + type: region + doc: README.md + region: status-actions + extractor: python-literal + source: {path: src/actions.py, symbol: ACTIONS} + renderer: markdown-table + columns: [command, job] +projections: + llms_txt: + output: llms.txt + title: Moonbase Status documentation + summary: Source-bound operator documentation. + include: [README.md] +""" +README = """\ +# Moonbase Status + + +Use this fixture when a public command changes before its operator guide. It gives maintainers a checked path from stale prose to a repaired and verified page. + + +## Operator actions + + + +""" +SOURCE_BEFORE = """\ +ACTIONS = [ + {"command": "report", "job": "Send the current habitat status"}, +] +""" +SOURCE_AFTER = SOURCE_BEFORE.replace('"report"', '"publish"') + + +def _run(executable: Path, root: Path, *arguments: str) -> dict[str, object]: + environment = dict(os.environ) + environment.pop("PYTHONPATH", None) + process = subprocess.run( + [str(executable), "--root", str(root), *arguments], + text=True, + capture_output=True, + env=environment, + timeout=30, + check=False, + ) + output = (process.stdout + process.stderr).strip() + return { + "command": "clean-docs " + " ".join(arguments), + "exit_code": process.returncode, + "observed": output.splitlines()[0] if output else "No terminal output", + } + + +def _write_fixture(root: Path) -> None: + (root / "src").mkdir() + (root / ".clean-docs.yml").write_text(MANIFEST, encoding="utf-8") + (root / "README.md").write_text(README, encoding="utf-8") + (root / "src/actions.py").write_text(SOURCE_BEFORE, encoding="utf-8") + + +def record(executable: Path) -> dict[str, object]: + with tempfile.TemporaryDirectory(prefix="clean-docs-learning-") as temporary: + root = Path(temporary) + _write_fixture(root) + drive_baseline = _run(executable, root, "drive") + project_baseline = _run(executable, root, "project") + check_baseline = _run(executable, root, "check") + verify_baseline = _run(executable, root, "verify") + (root / "src/actions.py").write_text(SOURCE_AFTER, encoding="utf-8") + readme_before_repair = (root / "README.md").read_text(encoding="utf-8") + drift = _run(executable, root, "check") + repair = _run(executable, root, "drive") + readme_after_repair = (root / "README.md").read_text(encoding="utf-8") + projection = _run(executable, root, "project") + final_check = _run(executable, root, "check") + final_verify = _run(executable, root, "verify") + + results = ( + drive_baseline, + project_baseline, + check_baseline, + verify_baseline, + drift, + repair, + projection, + final_check, + final_verify, + ) + exit_codes = tuple(item["exit_code"] for item in results) + if exit_codes != (0, 0, 0, 0, 1, 0, 0, 0, 0): + raise RuntimeError(f"tutorial returned unexpected exit codes: {exit_codes}") + if "publish" not in readme_after_repair or "report" not in readme_before_repair: + raise RuntimeError("tutorial repair did not update the declared documentation region") + prefix_before, suffix_before = readme_before_repair.split( + "", 1 + ) + prefix_after, suffix_after = readme_after_repair.split( + "", 1 + ) + if prefix_before != prefix_after or suffix_before.split( + "", 1 + )[1] != suffix_after.split("", 1)[1]: + raise RuntimeError("tutorial repair changed prose outside the declared region") + return { + "schema": "clean-docs.tutorial-evidence.v1", + "steps": [ + {"moment": "Protected baseline", "command": "clean-docs check", "exit": 0, "meaning": "The bound page matches source."}, + {"moment": "Source changed alone", "command": "clean-docs check", "exit": 1, "meaning": "The status-actions binding is stale."}, + {"moment": "Declared region repaired", "command": "clean-docs drive", "exit": 0, "meaning": "Only the bound region changes."}, + {"moment": "Projection refreshed", "command": "clean-docs project", "exit": 0, "meaning": "llms.txt receives the repaired page digest."}, + {"moment": "Repository verified", "command": "clean-docs verify", "exit": 0, "meaning": "Bindings and projections are current."}, + ], + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--clean-docs", type=Path, required=True) + parser.add_argument("--out", type=Path, default=OUTPUT) + args = parser.parse_args() + executable = args.clean_docs.resolve() + if not executable.is_file(): + raise SystemExit(f"clean-docs executable not found: {executable}") + rendered = json.dumps(record(executable), indent=2, sort_keys=True) + "\n" + args.out.resolve().parent.mkdir(parents=True, exist_ok=True) + args.out.resolve().write_text(rendered, encoding="utf-8") + print(args.out) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/render_social_preview.py b/scripts/render_social_preview.py new file mode 100644 index 0000000..57d0c64 --- /dev/null +++ b/scripts/render_social_preview.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +"""Render the repository social preview from the canonical system-map vocabulary.""" + +from __future__ import annotations + +import argparse +import re +import shutil +import struct +import subprocess +import tempfile +from pathlib import Path + +from clean_docs.regions import atomic_write + + +ROOT = Path(__file__).resolve().parents[1] +SYSTEM_MAP = ROOT / "docs/assets/clean-docs-system-map.svg" +SVG_OUTPUT = ROOT / "docs/assets/clean-docs-social.svg" +PNG_OUTPUT = ROOT / "docs/assets/clean-docs-social.png" +WIDTH = 1280 +HEIGHT = 640 + + +def _system_tokens(source: Path) -> dict[str, str]: + text = source.read_text(encoding="utf-8") + required = { + "Repository sources": "Repository sources", + "Source bindings": "Source bindings", + "Reject stale changes": "Reject stale changes", + } + missing = [label for label in required if label not in text] + if missing: + raise RuntimeError(f"system map is missing social-preview concepts: {', '.join(missing)}") + colors = re.findall(r"#[0-9a-fA-F]{6}", text) + for color in ("#edf6ff", "#25225f", "#302d78", "#4541a2"): + if color not in colors: + raise RuntimeError(f"system map is missing expected design token: {color}") + return { + "background": "#edf6ff", + "ink": "#15143b", + "line": "#25225f", + "border": "#302d78", + "accent": "#4541a2", + **required, + } + + +def render_svg(source: Path = SYSTEM_MAP) -> str: + token = _system_tokens(source) + return f''' + clean-docs: docs that answer to code + A three-step source-bound documentation flow. Repository sources own facts, bindings connect facts to prose, and a deterministic check rejects stale changes. + + + + + + + + + + + + + + + + + + + + clean-docs + + SOURCE-BOUND DOCUMENTATION ENGINE + Docs that answer to code. + When source moves, stale prose fails loudly. + + + + + + 01 · SOURCE + {token['Repository sources']} + Code owns the fact. + + static evidence + + + + + + 02 · BIND + {token['Source bindings']} + Prose names its owner. + + region · claim · symbol + + + + + + + 03 · PROVE + Deterministic check + Drift fails before merge. + + repair · reject · project + + + + + THE CHECKABLE SPINE STAYS ATTACHED + +''' + + +def _png_dimensions(path: Path) -> tuple[int, int]: + data = path.read_bytes()[:24] + if data[:8] != b"\x89PNG\r\n\x1a\n": + raise RuntimeError(f"not a PNG: {path}") + return struct.unpack(">II", data[16:24]) + + +def _chrome() -> str: + candidates = ( + shutil.which("google-chrome"), + shutil.which("chromium"), + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + ) + for candidate in candidates: + if candidate and Path(candidate).is_file(): + return candidate + raise RuntimeError("Chrome or Chromium is required to render the social preview PNG") + + +def render_png(svg: str, output: Path) -> None: + with tempfile.TemporaryDirectory(prefix="clean-docs-social-") as temporary: + html = Path(temporary) / "preview.html" + html.write_text( + '' + svg + "", + encoding="utf-8", + ) + subprocess.run( + [ + _chrome(), + "--headless=new", + "--hide-scrollbars", + "--disable-gpu", + "--force-device-scale-factor=1", + "--window-size=1280,640", + f"--screenshot={output.resolve()}", + html.resolve().as_uri(), + ], + check=True, + capture_output=True, + text=True, + timeout=30, + ) + if _png_dimensions(output) != (WIDTH, HEIGHT): + raise RuntimeError(f"social preview has unexpected dimensions: {_png_dimensions(output)}") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--check", action="store_true") + args = parser.parse_args() + svg = render_svg() + if args.check: + if SVG_OUTPUT.read_text(encoding="utf-8") != svg: + raise SystemExit("social preview SVG is stale") + if _png_dimensions(PNG_OUTPUT) != (WIDTH, HEIGHT): + raise SystemExit("social preview PNG is missing or has the wrong dimensions") + return 0 + atomic_write(SVG_OUTPUT, svg) + render_png(svg, PNG_OUTPUT) + print(PNG_OUTPUT) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_acceptance.py b/scripts/run_acceptance.py index 0570c24..300b50b 100644 --- a/scripts/run_acceptance.py +++ b/scripts/run_acceptance.py @@ -72,6 +72,14 @@ "upgrade-compatibility", "independent-reader-success", }, + "1.1": { + "public-repository-legibility", + "tutorial-from-a-clean-room", + "postmortem-facts-cannot-drift", + "deterministic-seam-boundary", + "additive-learning-corpus", + "fresh-reader-learning-path", + }, } diff --git a/scripts/verify_reader_trial.py b/scripts/verify_reader_trial.py index deca8ce..6c3f84b 100644 --- a/scripts/verify_reader_trial.py +++ b/scripts/verify_reader_trial.py @@ -8,6 +8,7 @@ import json import re import sys +from dataclasses import dataclass from datetime import datetime from pathlib import Path from typing import Any @@ -34,6 +35,24 @@ class ReaderTrialError(ValueError): """The reader-trial evidence does not prove the release gate.""" +@dataclass(frozen=True) +class TrialLayout: + rubric: Path + receipt: Path + evidence_root: Path + + +def trial_layout(release_version: str) -> TrialLayout: + """Keep each release line bound to the rubric its candidate readers saw.""" + if release_version.startswith("1.1."): + return TrialLayout( + rubric=Path(".clean-docs/reader-trial-rubric-v1.1.yml"), + receipt=Path(".clean-docs/reader-trial-v1.1.json"), + evidence_root=Path(".clean-docs/reader-trials-v1.1"), + ) + return TrialLayout(rubric=RUBRIC, receipt=RECEIPT, evidence_root=EVIDENCE_ROOT) + + def _sha256(data: bytes) -> str: return hashlib.sha256(data).hexdigest() @@ -80,14 +99,14 @@ def _timestamp(value: Any, *, label: str) -> None: raise ReaderTrialError(f"{label} must include a UTC offset") -def _load_rubric(root: Path) -> tuple[dict[str, Any], bytes]: +def _load_rubric(root: Path, rubric_path: Path) -> tuple[dict[str, Any], bytes]: try: import yaml except ImportError as exc: raise ReaderTrialError( "stable reader-trial verification requires PyYAML" ) from exc - path = root / RUBRIC + path = root / rubric_path try: data = path.read_bytes() raw: Any = yaml.safe_load(data) @@ -139,8 +158,9 @@ def _load_rubric(root: Path) -> tuple[dict[str, Any], bytes]: def verify_reader_trial(root: Path, release_version: str) -> dict[str, object]: """Return a release-safe summary or raise when evidence is incomplete.""" root = root.resolve() - rubric, rubric_bytes = _load_rubric(root) - receipt_path = root / RECEIPT + layout = trial_layout(release_version) + rubric, rubric_bytes = _load_rubric(root, layout.rubric) + receipt_path = root / layout.receipt try: receipt_bytes = receipt_path.read_bytes() raw: Any = json.loads(receipt_bytes) @@ -247,7 +267,7 @@ def verify_reader_trial(root: Path, release_version: str) -> dict[str, object]: raise ReaderTrialError("reader task evidence paths must be unique strings") if not isinstance(digest, str) or SHA256.fullmatch(digest) is None: raise ReaderTrialError(f"reader task evidence digest is invalid: {evidence}") - evidence_path = _relative_file(root, evidence, prefix=EVIDENCE_ROOT) + evidence_path = _relative_file(root, evidence, prefix=layout.evidence_root) evidence_bytes = evidence_path.read_bytes() if not evidence_bytes or len(evidence_bytes) > MAX_EVIDENCE_BYTES: raise ReaderTrialError(f"reader task evidence size is invalid: {evidence}") @@ -270,6 +290,8 @@ def verify_reader_trial(root: Path, release_version: str) -> dict[str, object]: "receipt_sha256": _sha256(receipt_bytes), "rubric_sha256": rubric_digest, "context_sha256": context_digest, + "receipt_path": layout.receipt.as_posix(), + "evidence_root": layout.evidence_root.as_posix(), "participants": completed_profiles, "tasks_per_participant": len(expected_tasks), } diff --git a/tests/test_reader_trial.py b/tests/test_reader_trial.py index b57999c..bf8606b 100644 --- a/tests/test_reader_trial.py +++ b/tests/test_reader_trial.py @@ -12,6 +12,7 @@ from scripts.verify_reader_trial import ( ReaderTrialError, + trial_layout, verify_reader_trial, verify_release_reader_trial, ) @@ -24,10 +25,16 @@ def _sha256(data: bytes) -> str: return hashlib.sha256(data).hexdigest() -def _write_trial(root: Path) -> Path: - rubric_path = root / ".clean-docs/reader-trial-rubric.yml" +def _write_trial( + root: Path, + *, + release_version: str = "1.0.0", + candidate: str = "1.0.0rc9", +) -> Path: + layout = trial_layout(release_version) + rubric_path = root / layout.rubric rubric_path.parent.mkdir(parents=True, exist_ok=True) - shutil.copyfile(ROOT / ".clean-docs/reader-trial-rubric.yml", rubric_path) + shutil.copyfile(ROOT / layout.rubric, rubric_path) rubric_bytes = rubric_path.read_bytes() rubric = yaml.safe_load(rubric_bytes) context = [] @@ -42,7 +49,7 @@ def _write_trial(root: Path) -> Path: profile_id = profile["id"] tasks = [] for task in rubric["tasks"]: - relative = Path(".clean-docs/reader-trials") / profile_id / f"{task['id']}.txt" + relative = layout.evidence_root / profile_id / f"{task['id']}.txt" evidence = root / relative evidence.parent.mkdir(parents=True, exist_ok=True) evidence.write_text(f"{profile_id} passed {task['id']} using published docs only\n") @@ -62,14 +69,14 @@ def _write_trial(root: Path) -> Path: }) receipt = { "schema": "clean-docs.independent-reader-trial.v2", - "candidate": "1.0.0rc9", + "candidate": candidate, "candidate_commit": "a" * 40, "candidate_artifact_sha256": "b" * 64, "rubric_sha256": _sha256(rubric_bytes), "context": context, "participants": participants, } - receipt_path = root / ".clean-docs/reader-trial.json" + receipt_path = root / layout.receipt receipt_path.write_text(json.dumps(receipt, indent=2, sort_keys=True) + "\n") return receipt_path @@ -92,6 +99,26 @@ def test_reader_trial_binds_rubric_context_participants_and_task_evidence( assert summary["receipt_sha256"] == _sha256(receipt.read_bytes()) +def test_version_11_reader_trial_uses_two_families_and_learning_tasks(tmp_path: Path) -> None: + receipt = _write_trial( + tmp_path, + release_version="1.1.0", + candidate="1.1.0rc1", + ) + + summary = verify_reader_trial(tmp_path, "1.1.0") + + assert summary["candidate"] == "1.1.0rc1" + assert summary["participants"] == { + "anthropic-opus-4-8": 1, + "codex-gpt-5-6-sol-high": 1, + } + assert summary["tasks_per_participant"] == 5 + assert summary["receipt_path"] == ".clean-docs/reader-trial-v1.1.json" + assert summary["evidence_root"] == ".clean-docs/reader-trials-v1.1" + assert summary["receipt_sha256"] == _sha256(receipt.read_bytes()) + + def test_reader_trial_rejects_tampered_or_incomplete_evidence(tmp_path: Path) -> None: receipt_path = _write_trial(tmp_path) receipt = json.loads(receipt_path.read_text()) diff --git a/tests/test_release_artifact.py b/tests/test_release_artifact.py index c24545b..1f21618 100644 --- a/tests/test_release_artifact.py +++ b/tests/test_release_artifact.py @@ -180,6 +180,8 @@ def test_stable_release_accepts_only_reader_candidate_version_and_receipts( "required": True, "candidate_commit": candidate, "candidate_artifact_sha256": hashlib.sha256(wheel.read_bytes()).hexdigest(), + "receipt_path": ".clean-docs/reader-trial.json", + "evidence_root": ".clean-docs/reader-trials", } release_builder._verify_reader_candidate(final, trial, tmp_path / "build") diff --git a/tests/test_v11_acceptance_registry.py b/tests/test_v11_acceptance_registry.py new file mode 100644 index 0000000..bbf9113 --- /dev/null +++ b/tests/test_v11_acceptance_registry.py @@ -0,0 +1,20 @@ +from pathlib import Path + +from scripts.run_acceptance import load_registry + + +ROOT = Path(__file__).parents[1] + + +def test_version_11_registry_names_every_scenario() -> None: + release, cases = load_registry(ROOT / "tests/v11-acceptance.yml") + + assert release == "1.1" + assert [case.id for case in cases] == [ + "public-repository-legibility", + "tutorial-from-a-clean-room", + "postmortem-facts-cannot-drift", + "deterministic-seam-boundary", + "additive-learning-corpus", + "fresh-reader-learning-path", + ] diff --git a/tests/test_v11_learning.py b/tests/test_v11_learning.py new file mode 100644 index 0000000..4d9b564 --- /dev/null +++ b/tests/test_v11_learning.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +import json +import shutil +import struct +import subprocess +import sys +from pathlib import Path + +from clean_docs.engine import drive, evaluate +from scripts.build_learning_evidence import build_record +from scripts.record_learning_tutorial import record +from scripts.render_social_preview import HEIGHT, WIDTH, render_svg + + +ROOT = Path(__file__).parents[1] +LEARN = ROOT / "docs/learn" + + +def test_public_first_screen_defines_the_product_and_routes_to_learning() -> None: + readme = (ROOT / "README.md").read_text(encoding="utf-8") + purpose = readme.index("") + badges = readme.index("[![CI]") + start = readme.index("**[Start here]") + detail = readme.index("## Why clean-docs exists") + + assert purpose < badges < start < detail + assert "source-bound documentation engine and CLI" in readme[purpose:badges] + assert "actions/workflows/ci.yml/badge.svg" in readme + assert "img.shields.io/github/v/release" in readme + assert "img.shields.io/badge/license-MIT" in readme + + +def test_social_preview_is_current_legible_and_at_repository_aspect_ratio() -> None: + svg = (ROOT / "docs/assets/clean-docs-social.svg").read_text(encoding="utf-8") + png = (ROOT / "docs/assets/clean-docs-social.png").read_bytes() + + assert svg == render_svg() + assert png[:8] == b"\x89PNG\r\n\x1a\n" + assert struct.unpack(">II", png[16:24]) == (WIDTH, HEIGHT) + for phrase in ( + "Docs that answer to code.", + "Repository sources", + "Source bindings", + "Deterministic check", + "Drift fails before merge.", + ): + assert phrase in svg + assert " None: + test_public_first_screen_defines_the_product_and_routes_to_learning() + test_social_preview_is_current_legible_and_at_repository_aspect_ratio() + + +def test_learning_surface_contains_only_the_index_and_three_lessons() -> None: + paths = sorted(path.name for path in LEARN.iterdir()) + assert paths == [ + "deep-dive-the-deterministic-seam.md", + "index.md", + "postmortem-the-readme-that-lied.md", + "tutorial-catch-a-lying-doc.md", + ] + for path in LEARN.iterdir(): + text = path.read_text(encoding="utf-8") + assert text.splitlines()[2] == "" + + +def test_learning_index_routes_each_reader_job_without_copying_reference() -> None: + index = (LEARN / "index.md").read_text(encoding="utf-8") + ordered = ( + "../../README.md", + "tutorial-catch-a-lying-doc.md", + "postmortem-the-readme-that-lied.md", + "deep-dive-the-deterministic-seam.md", + ) + positions = [index.index(path) for path in ordered] + assert positions == sorted(positions) + assert "../CLI.md" in index + assert "../SUPPORT.md" in index + assert "../SECURITY_MODEL.md" in index + + +def test_postmortem_record_is_derived_from_the_archived_case() -> None: + committed = json.loads( + (ROOT / ".clean-docs/learning/ultra-csm-hygiene.json").read_text(encoding="utf-8") + ) + assert committed == build_record() + assert committed["measurements"][0] == { + "measure": "Total findings", + "before": "280", + "after": "73 (all justified in `NOTES.md`)", + } + assert len(committed["examples"]) == 3 + + +def test_postmortem_evidence_drift_repairs_only_bound_regions(tmp_path: Path) -> None: + root = tmp_path / "postmortem" + (root / "docs/learn").mkdir(parents=True) + (root / ".clean-docs/learning").mkdir(parents=True) + document = LEARN / "postmortem-the-readme-that-lied.md" + evidence = ROOT / ".clean-docs/learning/ultra-csm-hygiene.json" + shutil.copyfile(document, root / "docs/learn/postmortem-the-readme-that-lied.md") + shutil.copyfile(evidence, root / ".clean-docs/learning/ultra-csm-hygiene.json") + (root / ".clean-docs.yml").write_text( + """\ +version: 1 +bindings: + - id: measurements + type: region + doc: docs/learn/postmortem-the-readme-that-lied.md + region: postmortem-measurements + extractor: json + source: {path: .clean-docs/learning/ultra-csm-hygiene.json, pointer: /measurements} + renderer: markdown-table + columns: [measure, before, after] + - id: examples + type: region + doc: docs/learn/postmortem-the-readme-that-lied.md + region: postmortem-examples + extractor: json + source: {path: .clean-docs/learning/ultra-csm-hygiene.json, pointer: /examples} + renderer: markdown-table + columns: [case, before, after] +""", + encoding="utf-8", + ) + data_path = root / ".clean-docs/learning/ultra-csm-hygiene.json" + data = json.loads(data_path.read_text(encoding="utf-8")) + data["measurements"][0]["before"] = "281" + data_path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8") + before = (root / "docs/learn/postmortem-the-readme-that-lied.md").read_text( + encoding="utf-8" + ) + unbound_before = before.split("", 1)[1] + + results = evaluate(root, root / ".clean-docs.yml") + assert [result.binding_id for result in results if result.changed] == ["measurements"] + _results, findings = drive(root, root / ".clean-docs.yml") + assert not findings + + after = (root / "docs/learn/postmortem-the-readme-that-lied.md").read_text( + encoding="utf-8" + ) + assert "| Total findings | 281 |" in after + assert after.split("", 1)[1] == unbound_before + assert not any(result.changed for result in evaluate(root, root / ".clean-docs.yml")) + + +def test_published_tutorial_runs_the_observed_drift_loop(tmp_path: Path) -> None: + wrapper = tmp_path / "clean-docs" + wrapper.write_text( + "#!/bin/sh\n" + f"export PYTHONPATH={ROOT / 'src'}\n" + f"exec {sys.executable} -m clean_docs \"$@\"\n", + encoding="utf-8", + ) + wrapper.chmod(0o755) + first = record(wrapper) + second = record(wrapper) + committed = json.loads( + (ROOT / ".clean-docs/learning/tutorial-evidence.json").read_text(encoding="utf-8") + ) + + assert first == second == committed + assert [step["exit"] for step in first["steps"]] == [0, 1, 0, 0, 0] + + +def test_deep_dive_claims_resolve_to_deterministic_implementation_sources() -> None: + ids = { + "deterministic-seam-evidence", + "deterministic-seam-phrasing", + "deterministic-seam-gate", + } + results = [result for result in evaluate(ROOT, ROOT / ".clean-docs.yml") if result.binding_id in ids] + + assert {result.binding_id for result in results} == ids + assert not any(result.changed for result in results) + sources = {result.provenance.path for result in results} + assert sources == { + "src/clean_docs/engine.py", + "src/clean_docs/phrasing.py", + "src/clean_docs/outcomes.py", + } + + +def test_llms_projection_contains_every_learning_page() -> None: + projection = (ROOT / "llms.txt").read_text(encoding="utf-8") + for path in sorted(LEARN.iterdir()): + assert path.relative_to(ROOT).as_posix() in projection + + +def test_additive_learning_corpus_passes_audit_projection_and_links() -> None: + test_learning_surface_contains_only_the_index_and_three_lessons() + test_learning_index_routes_each_reader_job_without_copying_reference() + test_llms_projection_contains_every_learning_page() + for document in LEARN.iterdir(): + text = document.read_text(encoding="utf-8") + assert len(text.splitlines()) <= 120 + for command in ( + [sys.executable, "-m", "clean_docs", "--root", str(ROOT), "audit"], + [sys.executable, "-m", "clean_docs", "--root", str(ROOT), "project", "--check"], + ): + result = subprocess.run( + command, + cwd=ROOT, + env={"PYTHONPATH": str(ROOT / "src")}, + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr diff --git a/tests/v11-acceptance.yml b/tests/v11-acceptance.yml new file mode 100644 index 0000000..39c12d3 --- /dev/null +++ b/tests/v11-acceptance.yml @@ -0,0 +1,15 @@ +version: 1 +release: "1.1" +scenarios: + - id: public-repository-legibility + test: tests/test_v11_learning.py::test_public_repository_legibility_e2e + - id: tutorial-from-a-clean-room + test: tests/test_v11_learning.py::test_published_tutorial_runs_the_observed_drift_loop + - id: postmortem-facts-cannot-drift + test: tests/test_v11_learning.py::test_postmortem_evidence_drift_repairs_only_bound_regions + - id: deterministic-seam-boundary + test: tests/test_v11_learning.py::test_deep_dive_claims_resolve_to_deterministic_implementation_sources + - id: additive-learning-corpus + test: tests/test_v11_learning.py::test_additive_learning_corpus_passes_audit_projection_and_links + - id: fresh-reader-learning-path + test: tests/test_reader_trial.py::test_version_11_reader_trial_uses_two_families_and_learning_tasks