diff --git a/Makefile b/Makefile index 6d09da7..7158a95 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ test: ## Run template tests echo "uvx is required to run template tests. Install uv from https://docs.astral.sh/uv/getting-started/installation/" >&2; \ exit 1; \ fi - $(ACT_TEST_ENV) $(UV) --with pytest-copier --with pyyaml --with syrupy --with make-parser --with hypothesis pytest tests/ + $(ACT_TEST_ENV) $(UV) --with pytest-copier --with pyyaml --with syrupy --with make-parser --with hypothesis --with mdast pytest tests/ help: ## Show available targets @grep -E '^[a-zA-Z_-]+:.*?##' $(MAKEFILE_LIST) | \ diff --git a/docs/developers-guide.md b/docs/developers-guide.md index ea9469b..68655aa 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -13,7 +13,7 @@ make test ``` The target uses -`uvx --with pytest-copier --with pyyaml --with syrupy --with make-parser --with hypothesis pytest tests/`, +`uvx --with pytest-copier --with pyyaml --with syrupy --with make-parser --with hypothesis --with mdast pytest tests/`, so Python test dependencies must be added to that invocation before tests import them. Keep long runs logged through `tee` into `/tmp`, following the example in `AGENTS.md`. @@ -22,6 +22,13 @@ The tests render both library and application projects, run generated public gates such as `make all`, validate generated Makefiles with `mbake`, and parse generated `Cargo.toml` files as TOML. +Documentation snapshot tests parse Markdown files into normalized mdast JSON +before comparing snapshots. The normalization removes parser source positions +and treats soft line wrapping as formatting noise, while preserving document +structure such as headings, links, lists, tables, inline code, and code blocks. +Markdown formatting remains covered separately by `markdownlint-cli2` and +`mdtablefix`. + Generated audit coverage is tested without network access by replacing Cargo with a fake executable. The regression verifies that `make rust-audit` derives the workspace root from `cargo metadata`, ignores manifests outside workspace @@ -55,6 +62,7 @@ projects: - `syrupy` for generated structured file snapshots in tests. - `make-parser` for generated Makefile structure assertions in tests. - `hypothesis` for generated-file schema helper property tests. +- `mdast` for parent documentation semantic snapshot tests. - Rust and Cargo through `rustup`. - cargo-nextest for generated fast test execution in CI, while generated Makefiles still fall back to `cargo test` for contributors. @@ -87,6 +95,8 @@ Reusable test support lives under `tests/helpers/`: project command helpers. - `tests/helpers/generated_files.py` centralizes generated text, TOML, and YAML parsing with pytest failure messages. +- `tests/helpers/markdown_semantics.py` parses Markdown as normalized mdast JSON + for semantic documentation snapshots. - `tests/helpers/tooling_contracts/` contains generated Makefile, Cargo, documentation, CI, release, and coverage-action contract assertions. - `tests/conftest.py` provides the session-scoped `act_ready` fixture, which diff --git a/docs/execplans/mdast-snapshot-assertions.md b/docs/execplans/mdast-snapshot-assertions.md new file mode 100644 index 0000000..d095033 --- /dev/null +++ b/docs/execplans/mdast-snapshot-assertions.md @@ -0,0 +1,380 @@ +# Validate Documentation Snapshots with mdast JSON + +This ExecPlan (execution plan) is a living document. The sections +`Constraints`, `Tolerances`, `Risks`, `Progress`, `Surprises & Discoveries`, +`Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work +proceeds. + +Status: COMPLETE + +## Purpose / Big Picture + +Documentation snapshot tests should fail when the meaning or structure of a +Markdown document changes, not when line wrapping, table alignment, or other +formatting-only details change. Markdown formatting is already checked by +`markdownlint-cli2` and `mdtablefix`; snapshot tests should instead compare a +normalized Markdown abstract syntax tree. + +After this change, the parent template tests and the tests rendered into +generated Rust projects will parse documentation files into mdast JSON. An +mdast document is the Markdown abstract syntax tree used by the unified +ecosystem: headings, paragraphs, lists, links, tables, code blocks, and other +document nodes represented as structured JSON. The snapshot tests will remove +position and offset fields before snapshotting so formatting changes do not +churn snapshots. A reviewer can observe success by changing only line wrapping +in a documented file and seeing the mdast snapshot remain stable, then changing +a heading, link target, list item, table cell, or code block content and seeing +the focused snapshot fail. + +This plan does not derive from a roadmap task. It is a direct change request +for the `mdast-snapshot-assertions` branch. + +## Constraints + +The branch for this work is `mdast-snapshot-assertions`, tracking +`origin/mdast-snapshot-assertions`. The plan file is +`docs/execplans/mdast-snapshot-assertions.md`. + +This repository is a Copier template. Files under `template/` are rendered +into downstream Rust projects, while files under `tests/` validate rendered +output through `pytest-copier`. Any generated-project test behaviour must be +implemented in `template/` and proven from the parent repository tests. + +Follow the repository guidance in `AGENTS.md`: prefer Makefile targets, run +long gates through `tee` into `/tmp`, do not run format, lint, or test suites in +parallel, and commit after each completed and gated change. + +The work must validate document semantics, not Markdown formatting. Do not use +raw Markdown text, bytes, or binary file snapshots for documentation semantics. +Do not remove `markdownlint-cli2` or `mdtablefix`; those tools remain +responsible for formatting. + +Snapshot JSON must be deterministic and reviewable. It must omit parser source +locations such as byte offsets, line numbers, and column numbers. It must not +include filesystem-specific absolute paths, temporary directory names, or other +nondeterministic values. + +Parent test dependencies must be declared in the Makefile's `uvx --with ...` +invocation before imported in tests. Generated Rust test dependencies must be +declared in `template/Cargo.toml.jinja` so rendered projects can run their +public test gates without manual setup. + +The initial ExecPlan draft is an approval artifact only. Do not implement the +mdast conversion until the user explicitly approves this plan or asks for +revisions. + +## Tolerances (Exception Triggers) + +Stop and ask for direction if a Markdown parser cannot preserve GitHub +Flavoured Markdown constructs that this template relies on, such as tables, +task-list items, footnotes, fenced code blocks, or inline HTML. + +Stop and ask for direction if supporting true mdast JSON requires adding a +runtime dependency outside the parent Python test environment and the generated +Rust dev-dependency set. For example, do not introduce a Node, Bun, or npm +snapshot pipeline without approval. + +Stop and ask for direction if the generated project `make test` or parent +repository `make test` cannot pass within one 1200 second command and cannot be +split into smaller public gates. + +Stop and ask for direction before deleting existing snapshot coverage rather +than replacing it with equivalent mdast JSON assertions. + +Stop and ask for direction if the implementation requires changing generated +project public commands in a way unrelated to documentation snapshot +semantics. + +## Risks + +Python and Rust Markdown parsers may not emit identical mdast shapes. The plan +does not require identical implementations across languages; it requires both +parent and generated-project tests to snapshot normalized JSON whose nodes +represent document semantics. The mitigation is to define a small local +normalization contract in each environment and snapshot those normalized +objects, not parser-private representations. + +The existing parent snapshot test in +`tests/test_template/test_snapshots.py` covers structured tooling files rather +than documentation files. Implementation may discover separate documentation +assertions instead of a direct replacement there. The mitigation is to search +for every documentation snapshot or raw Markdown snapshot assertion before +editing, then add the minimum missing coverage if no documentation snapshot +test currently exists. + +Generated projects currently contain guidance about using `insta` snapshots, +but they may not yet render documentation snapshot tests. The mitigation is to +add generated tests only where they prove this requested behaviour and to keep +them focused on documentation files under `docs/`. + +Markdown parser dependency versions may change after this plan is drafted. The +mitigation is to resolve current compatible versions during implementation +using the package manager commands already used by the repository, then record +the selected versions in this plan's `Decision Log`. + +Snapshot updates can accidentally bless a formatting-sensitive representation. +The mitigation is to add an explicit regression test that parses two Markdown +strings with different wrapping but the same semantics and asserts their +normalized mdast JSON is equal. + +## Progress + +- [x] 2026-06-28: Loaded the `leta` skill first as required for code + navigation, then loaded the `execplans` skill for this draft and the + `rust-router` skill for generated Rust test planning. +- [x] 2026-06-28: Confirmed the starting branch was `session/aab84371`, not + `main`, and renamed it locally to `mdast-snapshot-assertions`. +- [x] 2026-06-28: Inspected `AGENTS.md`, `docs/developers-guide.md`, + `template/AGENTS.md.jinja`, `tests/test_template/test_snapshots.py`, and + current snapshot files to identify the relevant test surfaces. +- [x] 2026-06-28: Drafted this ExecPlan as a review artifact before + implementation. +- [x] 2026-06-28: Ran `make test 2>&1 | tee + /tmp/test-agent-template-rust-mdast-snapshot-assertions-plan.out`; result was + 50 passed and 1 skipped. +- [x] 2026-06-28: Received explicit user approval to implement the planned + functionality and began execution. +- [x] 2026-06-28: Removed the `Plan: ` prefix from PR #43 and renamed the + Lody session to `Validate documentation snapshots with mdast JSON`. +- [x] 2026-06-28: Inventoried snapshot and documentation assertion surfaces. + The only existing syrupy snapshot test is + `tests/test_template/test_snapshots.py`, covering generated tooling files. + Existing generated documentation assertions live in + `tests/helpers/tooling_contracts/documentation.py` and are raw string + contract checks rather than semantic snapshots. No generated-project + `insta` documentation snapshot tests currently exist. +- [x] 2026-06-28: Established the parent red test by adding + `tests/test_template/test_documentation_snapshots.py` before the helper + existed. The focused run + `uvx --with pytest-copier --with pyyaml --with syrupy --with make-parser + --with hypothesis pytest tests/test_template -k 'snapshot or documentation' + 2>&1 | tee /tmp/test-mdast-snapshot-assertions-focused-parent-red.out` + failed during collection with `ModuleNotFoundError: + tests.helpers.markdown_semantics`. +- [x] 2026-06-28: Implemented the parent mdast helper in + `tests/helpers/markdown_semantics.py`, declared `mdast` in the parent + `Makefile` uvx dependency list, updated the parent Makefile contract test, + generated `tests/test_template/__snapshots__/test_documentation_snapshots.ambr`, + and reran the focused parent gate with `--with mdast`; result was 3 passed, + 19 deselected. +- [x] 2026-06-28: Established the generated red test by adding + `template/tests/documentation_snapshots.rs` without declaring its + dev-dependencies. After one formatting-only correction, the focused generated + gate + `uvx --with pytest-copier --with pyyaml --with syrupy --with make-parser + --with hypothesis --with mdast pytest tests/test_template/test_tooling_contracts.py + -x 2>&1 | tee /tmp/test-mdast-snapshot-assertions-generated-red-2.out` + failed during generated `make all` with unresolved `serde_json`, `insta`, + and `markdown` crates. +- [x] Implement parent-repository mdast normalization and snapshot assertions. +- [x] 2026-06-28: Implemented generated Rust-project mdast normalization, + `insta` JSON snapshots, and Rust dev-dependencies. The focused generated + gate passed across lib, app, and alternate-target lib variants: + `uvx --with pytest-copier --with pyyaml --with syrupy --with make-parser + --with hypothesis --with mdast pytest tests/test_template/test_tooling_contracts.py + 2>&1 | tee /tmp/test-mdast-snapshot-assertions-generated-green-6.out`; + result was 3 passed. +- [x] Implement generated Rust-project mdast normalization and snapshot + assertions. +- [x] Update documentation for the new semantic snapshot contract. +- [x] 2026-06-28: Ran the full parent gate after parent and generated + implementation: + `make test 2>&1 | tee /tmp/test-agent-template-rust-mdast-snapshot-assertions-full.out`; + result was 52 passed and 1 skipped in 121.45 seconds. +- [x] Run formatting, linting, and test gates sequentially with `/tmp` logs. +- [x] 2026-06-28: Committed parent mdast snapshot coverage in `8d2c02c` and + generated Rust-project mdast snapshot coverage in `0c0e924`. +- [x] 2026-06-28: Ran `coderabbit review --agent` after each major milestone. + Both the parent snapshot milestone and generated snapshot milestone completed + with 0 findings. +- [x] Commit the approved implementation changes after gates pass. + +## Surprises & Discoveries + +The parent repository has one existing syrupy snapshot file at +`tests/test_template/__snapshots__/test_snapshots.ambr`, but that snapshot +currently covers structured tooling outputs such as Cargo config, Makefile +text, and GitHub Actions workflows. The obvious documentation-related parent +tests are currently contract assertions in `tests/helpers/tooling_contracts/` +rather than Markdown document snapshots. + +The generated template contains guidance in `template/AGENTS.md.jinja` that +recommends `insta` snapshots for stable output boundaries, but the rendered +template currently has no obvious generated `insta` documentation snapshot +test file. + +The repository documentation already states that parent tests use `syrupy` for +generated structured file snapshots. That wording will need to remain true for +tooling snapshots while new wording explains mdast JSON snapshots for +documentation semantics. + +Python package discovery found a `mdast` package that emits unified-style +mdast dictionaries and supports opt-in GitHub Flavoured Markdown extensions +for tables, task-list items, footnotes, strikethrough, and autolink literals. +Those extension flags must be enabled explicitly; otherwise tables and +task-list checkboxes are parsed as plain paragraph or text content. + +Rust crate discovery found `markdown = 1.0.0`, the Rust `markdown-rs` crate, +which describes itself as CommonMark compliant with ASTs and extensions and +offers a `json` feature backed by `serde`. `insta = 1.48.0` is the current +snapshot crate version and has a `json` feature for JSON snapshots. + +The Rust `markdown::to_mdast` API returns `markdown::message::Message` on +failure, and that message type does not implement `std::error::Error`. +Generated tests must convert it explicitly at the helper boundary rather than +using `?` directly into `Box`. + +Generated repository-layout documentation differs between library and +application flavours. A single rendered `insta` snapshot cannot cover both +flavours; generated tests now choose `docs_repository_layout_lib` or +`docs_repository_layout_app` based on whether the rendered layout mentions the +release workflow. + +## Decision Log + +Use normalized mdast JSON as the documentation snapshot contract rather than +raw Markdown. This follows the request directly and separates semantic +assertions from formatting checks owned by `markdownlint-cli2` and +`mdtablefix`. + +Keep parent and generated-project implementations local to their runtimes. The +parent repository should use a Python helper under `tests/helpers/` so pytest +tests can snapshot JSON-compatible objects with syrupy. Generated Rust projects +should use Rust dev-dependencies and `insta` JSON snapshots so `make test` +continues to be the public generated-project gate. + +Treat parser source positions as formatting noise. Normalization must remove +`position`, `start`, `end`, `offset`, `line`, and `column` style fields before +snapshot comparison. + +Keep raw tooling-file snapshots out of scope unless they are documentation +snapshots. Existing snapshots for `Makefile`, `.cargo/config.toml`, and +GitHub Actions workflows validate structured tooling output, not Markdown +documentation semantics. + +Use Python `mdast` for the parent pytest helper, with GitHub Flavoured +Markdown extensions enabled through `ParseOptions`. Declare it in the parent +`Makefile` `uvx --with ...` invocation before importing it in tests. Use Rust +`markdown = { version = "1.0.0", features = ["json"] }`, `serde_json`, and +`insta = { version = "1.48.0", features = ["json"] }` for generated-project +documentation snapshot tests. + +Use separate generated repository-layout snapshots for library and application +flavours. This preserves flavour-specific documentation semantics rather than +normalizing away public files such as `.github/workflows/release.yml` and +`src/main.rs`. + +## Implementation Plan + +Begin by inventorying current snapshot and documentation assertions. Search +`tests/`, `template/`, and generated output for `snapshot`, `insta`, +`assert_snapshot`, `docs/`, and Markdown file reads. Record every in-scope +documentation snapshot assertion in this plan. If no generated-project +documentation snapshot test exists, create one as new coverage rather than +pretending an existing test was converted. + +For the parent repository red step, add a helper test that proves +normalization is semantic. The test should parse two Markdown snippets that +have equivalent structure but different line wrapping or table spacing and +assert the normalized mdast JSON objects are equal. Add or update a +documentation snapshot test that renders a project, reads a representative +generated documentation file such as `docs/contents.md` or the relevant +section of `docs/repository-layout.md`, converts it to normalized mdast JSON, +and compares that JSON-compatible object with a syrupy snapshot. Run the +focused pytest command and confirm it fails for the expected reason before +implementation. + +For the parent repository green step, add a Markdown-to-mdast helper under +`tests/helpers/`, using a Python dependency declared in the Makefile's +`uvx --with ...` list. The helper should expose a small API such as +`parse_markdown_semantics(markdown: str) -> object`. It should parse Markdown, +convert the parser result into plain dictionaries and lists, and recursively +remove source-position fields. Re-run the focused pytest command until it +passes, then update the syrupy snapshot only after checking that the JSON +contains meaningful headings, links, lists, tables, and code block nodes. + +For the generated-project red step, add a rendered Rust test file under +`template/tests/` that reads one or more generated documentation files with +`include_str!` or `std::fs`, parses them to normalized mdast JSON, and compares +the result with `insta` JSON snapshots. Also add a unit test that parses two +formatting variants of the same Markdown and asserts their normalized JSON is +equal. Render a temporary project through the parent tests and run the +generated public test gate, expecting it to fail before the Rust helper and +snapshots exist. + +For the generated-project green step, add the required Rust dev-dependencies +to `template/Cargo.toml.jinja`. Prefer a Rust Markdown parser that can produce +mdast-compatible nodes and serialize them through `serde_json`. Add a small +normalization helper inside the generated test module that removes source +positions and returns deterministic JSON. Keep snapshots scoped to +documentation semantics, for example headings and link structure in +`docs/contents.md` plus the repository tree section in +`docs/repository-layout.md`. Re-run the generated public gate until it passes. + +For refactor, remove duplication between parent pytest assertions if multiple +documentation files need the same normalization. In generated Rust tests, keep +helper functions private to the test module unless another generated test +needs them. Update `docs/developers-guide.md` and the generated +`template/docs/developers-guide.md.jinja` if contributor workflow or snapshot +update instructions change. + +## Validation + +Run focused parent tests first, logging to `/tmp`: + +```sh +uvx \ + --with pytest-copier \ + --with pyyaml \ + --with syrupy \ + --with make-parser \ + --with hypothesis \ + pytest tests/test_template -k 'snapshot or documentation' \ + 2>&1 | tee /tmp/test-mdast-snapshot-assertions-focused-parent.out +``` + +The red run should fail because the expected normalized mdast snapshot or +helper behaviour does not exist yet. The green run should pass after the +helper and snapshots are implemented. + +Render and validate a generated project through the parent test harness rather +than hand-editing rendered output. If a focused parent test is added for the +generated documentation snapshot test, run it with `tee` and confirm it invokes +the generated public test gate. + +Run the full parent gate after focused checks pass: + +```sh +make test 2>&1 | tee /tmp/test-agent-template-rust-mdast-snapshot-assertions.out +``` + +Expected result: pytest reports all tests passing. If output is truncated, +inspect the `/tmp` log before committing. + +If documentation files or generated Markdown tooling instructions change, run +the generated or parent formatting and Markdown checks through their public +Makefile targets where available, sequentially and with `/tmp` logs. + +## Outcomes & Retrospective + +This section is intentionally empty in the draft. During implementation, record +the final parser choices, snapshot files added or changed, gates run, log +paths, commit hashes, and any lessons about parser compatibility or snapshot +scope. + +Parent mdast snapshot assertions are implemented with Python `mdast` and +stored in `tests/test_template/__snapshots__/test_documentation_snapshots.ambr`. +Generated Rust projects use `markdown = 1.0.0`, `serde_json`, and +`insta = 1.48.0` to parse and snapshot selected documentation files. Generated +repository-layout snapshots are split by flavour so app-only paths remain part +of the semantic contract. + +The implemented behavior is observable in two places. In the parent template +repository, `tests/test_template/test_documentation_snapshots.py` renders a +project and snapshots normalized mdast JSON for `docs/contents.md` and +`docs/repository-layout.md` with syrupy. In generated projects, +`tests/documentation_snapshots.rs` parses the rendered documentation with +`markdown-rs`, removes source-position metadata through `serde_json`, and +compares `insta` JSON snapshots. The final full validation was +`make test 2>&1 | tee /tmp/test-agent-template-rust-mdast-snapshot-assertions-full.out`, +which passed with 52 tests and 1 skip. diff --git a/template/Cargo.toml.jinja b/template/Cargo.toml.jinja index 69f4e91..19e207e 100644 --- a/template/Cargo.toml.jinja +++ b/template/Cargo.toml.jinja @@ -14,6 +14,11 @@ categories = [{% for category in package_category_values %}"{{ category }}"{% if [dependencies] +[dev-dependencies] +insta = { version = "1.48.0", features = ["json"] } +markdown = { version = "1.0.0", features = ["json"] } +serde_json = "1.0" + {% if flavour == 'app' -%} # The package.metadata.binstall pkg-url intentionally mixes Jinja2 values # rendered at project generation with single-brace cargo-binstall variables diff --git a/template/docs/developers-guide.md.jinja b/template/docs/developers-guide.md.jinja index 21dd314..2636227 100644 --- a/template/docs/developers-guide.md.jinja +++ b/template/docs/developers-guide.md.jinja @@ -17,6 +17,15 @@ The main `.github/workflows/ci.yml` workflow deliberately does not run `make test WITH_ACT=1`; the separate Act workflow runs those slower container-backed checks in parallel. +Documentation snapshot tests live in `tests/documentation_snapshots.rs`. They +parse selected Markdown files into normalized mdast JSON before comparing +`insta` snapshots, so line wrapping and table alignment changes do not churn +semantic snapshots. Markdown formatting is still checked by `make markdownlint` +and formatted by `make fmt`. When a documentation meaning or structure change +is intentional, update the affected `tests/snapshots/*.snap` files with +`INSTA_UPDATE=always cargo test --test documentation_snapshots`, then review +the JSON diff before committing it. + ## Tooling Development builds use Cranelift for debug code generation. On Linux targets, @@ -27,6 +36,10 @@ LLVM-compatible linker behaviour. Install `clang`, `lld`, `mold`, `python3`, and `cargo-audit` before running the full generated workflow locally on Linux. +The documentation snapshot tests use the `markdown`, `serde_json`, and `insta` +dev-dependencies declared in `Cargo.toml`; no Node or npm Markdown snapshot +pipeline is required. + ### Security audit ignores Security audit jobs may set `CARGO_AUDIT_IGNORES` for narrowly scoped diff --git a/template/tests/documentation_snapshots.rs b/template/tests/documentation_snapshots.rs new file mode 100644 index 0000000..c8983eb --- /dev/null +++ b/template/tests/documentation_snapshots.rs @@ -0,0 +1,106 @@ +//! Semantic documentation snapshot tests. + +use serde_json::Value; + +fn parse_markdown_semantics(markdown: &str) -> Result> { + let tree = markdown::to_mdast(markdown, &markdown::ParseOptions::gfm()) + .map_err(|message| std::io::Error::other(message.to_string()))?; + let mut value = serde_json::to_value(tree)?; + normalise_node(&mut value); + Ok(value) +} + +fn normalise_node(value: &mut Value) { + match value { + Value::Object(object) => { + let node_type = object + .get("type") + .and_then(Value::as_str) + .map(str::to_owned); + object.remove("position"); + object.remove("line"); + object.remove("column"); + object.remove("offset"); + + if node_type.as_deref() == Some("text") + && let Some(Value::String(text)) = object.get_mut("value") + { + *text = normalise_soft_text(text); + } + + for child in object.values_mut() { + normalise_node(child); + } + } + Value::Array(children) => { + for child in children { + normalise_node(child); + } + } + _ => {} + } +} + +fn normalise_soft_text(value: &str) -> String { + value.split_whitespace().collect::>().join(" ") +} + +fn parse_markdown_semantics_or_panic(markdown: &str) -> Value { + match parse_markdown_semantics(markdown) { + Ok(value) => value, + Err(error) => panic!("failed to parse Markdown semantics: {error}"), + } +} + +#[test] +fn markdown_semantics_ignore_formatting_noise() { + let compact = "\ +# Documentation + +The generated project links to [docs](docs/contents.md) and explains +the workflow in one paragraph. + +| Path | Purpose | +| - | - | +| `docs/contents.md` | Index | + +- [x] Keep snapshots semantic +"; + let reflowed = "\ +# Documentation + +The generated project links to [docs](docs/contents.md) and explains the +workflow in one paragraph. + +| Path | Purpose | +| -------------------- | ------- | +| `docs/contents.md` | Index | + +- [x] Keep snapshots semantic +"; + + assert_eq!( + parse_markdown_semantics_or_panic(compact), + parse_markdown_semantics_or_panic(reflowed) + ); +} + +#[test] +fn generated_documentation_matches_semantic_snapshots() { + let repository_layout = include_str!("../docs/repository-layout.md"); + let repository_layout_snapshot = if repository_layout.contains(".github/workflows/release.yml") + { + "docs_repository_layout_app" + } else { + "docs_repository_layout_lib" + }; + + insta::assert_json_snapshot!( + "docs_contents", + parse_markdown_semantics_or_panic(include_str!("../docs/contents.md")) + ); + insta::assert_json_snapshot!( + repository_layout_snapshot, + parse_markdown_semantics_or_panic(repository_layout) + ); +} diff --git a/template/tests/snapshots/documentation_snapshots__docs_contents.snap.jinja b/template/tests/snapshots/documentation_snapshots__docs_contents.snap.jinja new file mode 100644 index 0000000..0d49113 --- /dev/null +++ b/template/tests/snapshots/documentation_snapshots__docs_contents.snap.jinja @@ -0,0 +1,330 @@ +--- +source: tests/documentation_snapshots.rs +expression: "parse_markdown_semantics_or_panic(include_str!(\"../docs/contents.md\"))" +--- +{ + "children": [ + { + "children": [ + { + "type": "text", + "value": "Documentation contents" + } + ], + "depth": 1, + "type": "heading" + }, + { + "children": [ + { + "children": [ + { + "type": "text", + "value": "Documentation contents" + } + ], + "type": "link", + "url": "contents.md" + }, + { + "type": "text", + "value": "is the index for {{ project_name }}'s documentation set." + } + ], + "type": "paragraph" + }, + { + "children": [ + { + "type": "text", + "value": "Project guides" + } + ], + "depth": 2, + "type": "heading" + }, + { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "type": "text", + "value": "User guide" + } + ], + "type": "link", + "url": "users-guide.md" + }, + { + "type": "text", + "value": "explains how to use the generated project and its public build and test commands." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "children": [ + { + "type": "text", + "value": "Developer guide" + } + ], + "type": "link", + "url": "developers-guide.md" + }, + { + "type": "text", + "value": "explains the local workflow and implementation tooling for contributors." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "children": [ + { + "type": "text", + "value": "Repository layout" + } + ], + "type": "link", + "url": "repository-layout.md" + }, + { + "type": "text", + "value": "explains the generated project's top-level files, directories, and ownership boundaries." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "children": [ + { + "type": "text", + "value": "Documentation style guide" + } + ], + "type": "link", + "url": "documentation-style-guide.md" + }, + { + "type": "text", + "value": "defines the spelling, structure, Markdown, Architecture Decision Record (ADR), Request for Comments (RFC), and roadmap conventions used by this documentation set." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + } + ], + "ordered": false, + "spread": false, + "type": "list" + }, + { + "children": [ + { + "type": "text", + "value": "Rust reference material" + } + ], + "depth": 2, + "type": "heading" + }, + { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "type": "text", + "value": "Reliable testing in Rust via dependency injection" + } + ], + "type": "link", + "url": "reliable-testing-in-rust-via-dependency-injection.md" + }, + { + "type": "text", + "value": "explains how to keep tests deterministic by injecting environment, clock, filesystem, and other external dependencies." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "children": [ + { + "type": "text", + "value": "Rust doctest Don't Repeat Yourself guide" + } + ], + "type": "link", + "url": "rust-doctest-dry-guide.md" + }, + { + "type": "text", + "value": "explains how to write maintainable, executable Rust documentation examples." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "children": [ + { + "type": "text", + "value": "Rust testing with" + }, + { + "type": "inlineCode", + "value": "rstest" + }, + { + "type": "text", + "value": "fixtures" + } + ], + "type": "link", + "url": "rust-testing-with-rstest-fixtures.md" + }, + { + "type": "text", + "value": "explains fixture-based, parameterized, and asynchronous testing with" + }, + { + "type": "inlineCode", + "value": "rstest" + }, + { + "type": "text", + "value": "." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + } + ], + "ordered": false, + "spread": false, + "type": "list" + }, + { + "children": [ + { + "type": "text", + "value": "Engineering practice" + } + ], + "depth": 2, + "type": "heading" + }, + { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "type": "text", + "value": "Complexity antipatterns and refactoring strategies" + } + ], + "type": "link", + "url": "complexity-antipatterns-and-refactoring-strategies.md" + }, + { + "type": "text", + "value": "explains cognitive complexity, the bumpy-road antipattern, and refactoring approaches for maintainable code." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "children": [ + { + "type": "text", + "value": "Scripting standards" + } + ], + "type": "link", + "url": "scripting-standards.md" + }, + { + "type": "text", + "value": "explains the preferred Python scripting stack, command execution patterns, and test expectations for helper scripts." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + } + ], + "ordered": false, + "spread": false, + "type": "list" + } + ], + "type": "root" +} diff --git a/template/tests/snapshots/documentation_snapshots__docs_repository_layout_app.snap.jinja b/template/tests/snapshots/documentation_snapshots__docs_repository_layout_app.snap.jinja new file mode 100644 index 0000000..15f16cd --- /dev/null +++ b/template/tests/snapshots/documentation_snapshots__docs_repository_layout_app.snap.jinja @@ -0,0 +1,709 @@ +--- +source: tests/documentation_snapshots.rs +expression: "parse_markdown_semantics_or_panic(include_str!(\"../docs/repository-layout.md\"))" +--- +{ + "children": [ + { + "children": [ + { + "type": "text", + "value": "Repository layout" + } + ], + "depth": 1, + "type": "heading" + }, + { + "children": [ + { + "type": "text", + "value": "This document describes the generated {{ project_name }} repository layout. It is the canonical reference for where source code, tests, configuration, automation, and long-lived documentation belong." + } + ], + "type": "paragraph" + }, + { + "children": [ + { + "type": "text", + "value": "Top-level tree" + } + ], + "depth": 2, + "type": "heading" + }, + { + "children": [ + { + "type": "text", + "value": "The tree below shows the generated repository structure. It is intentionally compact and omits build output such as" + }, + { + "type": "inlineCode", + "value": "target/" + }, + { + "type": "text", + "value": "." + } + ], + "type": "paragraph" + }, + { + "lang": "plaintext", + "type": "code", + "value": ".\n├── .cargo/\n│ └── config.toml\n├── .github/\n│ ├── dependabot.yml\n│ └── workflows/\n│ ├── act-validation.yml\n│ ├── ci.yml\n\n│ └── release.yml\n\n├── docs/\n│ ├── contents.md\n│ ├── developers-guide.md\n│ ├── repository-layout.md\n│ ├── users-guide.md\n│ └── ...\n├── src/\n\n│ ├── lib.rs\n│ └── main.rs\n\n├── tests/\n│ └── stub.rs\n├── AGENTS.md\n├── Cargo.toml\n├── LICENSE\n├── Makefile\n├── README.md\n├── clippy.toml\n├── codecov.yml\n└── rust-toolchain.toml" + }, + { + "children": [ + { + "type": "text", + "value": "Path responsibilities" + } + ], + "depth": 2, + "type": "heading" + }, + { + "children": [ + { + "children": [ + { + "children": [ + { + "type": "inlineCode", + "value": ".cargo/config.toml" + }, + { + "type": "text", + "value": ": Configures Cargo defaults for local development, including Linux linker and code-generation settings." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "inlineCode", + "value": ".github/dependabot.yml" + }, + { + "type": "text", + "value": ": Configures automated dependency update checks." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "inlineCode", + "value": ".github/workflows/act-validation.yml" + }, + { + "type": "text", + "value": ": Runs the generated workflow validation through" + }, + { + "type": "inlineCode", + "value": "act" + }, + { + "type": "text", + "value": "separately from main CI." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "inlineCode", + "value": ".github/workflows/ci.yml" + }, + { + "type": "text", + "value": ": Runs the generated project's continuous integration checks." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "inlineCode", + "value": ".github/workflows/release.yml" + }, + { + "type": "text", + "value": ": Builds and publishes binary release artefacts for the application flavour." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "inlineCode", + "value": "docs/" + }, + { + "type": "text", + "value": ": Holds long-lived reference documentation, guides, style rules, and design material." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "inlineCode", + "value": "docs/contents.md" + }, + { + "type": "text", + "value": ": Indexes the documentation set and should be updated when documentation files are added, renamed, or removed." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "inlineCode", + "value": "docs/users-guide.md" + }, + { + "type": "text", + "value": ": Explains how to use the generated project and its public build and test commands." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "inlineCode", + "value": "docs/developers-guide.md" + }, + { + "type": "text", + "value": ": Explains the contributor workflow and local tooling used to work on the generated project." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "inlineCode", + "value": "docs/repository-layout.md" + }, + { + "type": "text", + "value": ": Documents the repository tree and path responsibilities." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "inlineCode", + "value": "src/lib.rs" + }, + { + "type": "text", + "value": ": Contains library support for application logic and doctested examples." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "inlineCode", + "value": "src/main.rs" + }, + { + "type": "text", + "value": ": Contains the application entrypoint and top-level executable wiring." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "inlineCode", + "value": "tests/" + }, + { + "type": "text", + "value": ": Holds integration and behavioural tests that exercise public behaviour." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "inlineCode", + "value": "tests/stub.rs" + }, + { + "type": "text", + "value": ": Keeps the generated test directory valid until real tests replace it." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "inlineCode", + "value": "AGENTS.md" + }, + { + "type": "text", + "value": ": Provides repository-specific working instructions for agents and contributors." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "inlineCode", + "value": "Cargo.toml" + }, + { + "type": "text", + "value": ": Defines package metadata, dependencies, lint policy, and Cargo configuration." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "inlineCode", + "value": "LICENSE" + }, + { + "type": "text", + "value": ": Records the project licence text." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "inlineCode", + "value": "Makefile" + }, + { + "type": "text", + "value": ": Provides the public build, lint, test, coverage, and documentation validation commands." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "inlineCode", + "value": "README.md" + }, + { + "type": "text", + "value": ": Introduces the project and gives the shortest useful getting-started path." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "inlineCode", + "value": "clippy.toml" + }, + { + "type": "text", + "value": ": Configures Clippy lint behaviour that is not expressed directly in" + }, + { + "type": "inlineCode", + "value": "Cargo.toml" + }, + { + "type": "text", + "value": "." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "inlineCode", + "value": "codecov.yml" + }, + { + "type": "text", + "value": ": Configures coverage reporting behaviour." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "inlineCode", + "value": "rust-toolchain.toml" + }, + { + "type": "text", + "value": ": Pins the Rust toolchain channel and required components." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + } + ], + "ordered": false, + "spread": true, + "type": "list" + }, + { + "children": [ + { + "type": "text", + "value": "Ownership boundaries" + } + ], + "depth": 2, + "type": "heading" + }, + { + "children": [ + { + "children": [ + { + "children": [ + { + "type": "text", + "value": "Keep generated source code under" + }, + { + "type": "inlineCode", + "value": "src/" + }, + { + "type": "text", + "value": ". Add modules below" + }, + { + "type": "inlineCode", + "value": "src/" + }, + { + "type": "text", + "value": "when a feature grows beyond a small entrypoint or crate root." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "text", + "value": "Keep black-box integration tests and externally observable workflow tests under" + }, + { + "type": "inlineCode", + "value": "tests/" + }, + { + "type": "text", + "value": "." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "text", + "value": "Keep reusable documentation under" + }, + { + "type": "inlineCode", + "value": "docs/" + }, + { + "type": "text", + "value": ". Update" + }, + { + "type": "inlineCode", + "value": "docs/contents.md" + }, + { + "type": "text", + "value": "whenever a documentation file is added, renamed, or removed." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "text", + "value": "Keep build and validation entrypoints in" + }, + { + "type": "inlineCode", + "value": "Makefile" + }, + { + "type": "text", + "value": "; prefer adding or extending a Make target over documenting an ad hoc command." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "text", + "value": "Keep continuous integration workflow changes under" + }, + { + "type": "inlineCode", + "value": ".github/workflows/" + }, + { + "type": "text", + "value": "and dependency-update policy under" + }, + { + "type": "inlineCode", + "value": ".github/dependabot.yml" + }, + { + "type": "text", + "value": "." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "text", + "value": "Do not commit generated build output such as" + }, + { + "type": "inlineCode", + "value": "target/" + }, + { + "type": "text", + "value": ", coverage artefacts, or local editor state." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + } + ], + "ordered": false, + "spread": false, + "type": "list" + }, + { + "children": [ + { + "type": "text", + "value": "Updating this document" + } + ], + "depth": 2, + "type": "heading" + }, + { + "children": [ + { + "type": "text", + "value": "Update this document when the repository gains a new top-level directory, a new long-lived documentation category, a new workflow file, or a changed ownership boundary that would otherwise make the tree misleading." + } + ], + "type": "paragraph" + } + ], + "type": "root" +} diff --git a/template/tests/snapshots/documentation_snapshots__docs_repository_layout_lib.snap.jinja b/template/tests/snapshots/documentation_snapshots__docs_repository_layout_lib.snap.jinja new file mode 100644 index 0000000..7acded0 --- /dev/null +++ b/template/tests/snapshots/documentation_snapshots__docs_repository_layout_lib.snap.jinja @@ -0,0 +1,671 @@ +--- +source: tests/documentation_snapshots.rs +expression: "parse_markdown_semantics_or_panic(include_str!(\"../docs/repository-layout.md\"))" +--- +{ + "children": [ + { + "children": [ + { + "type": "text", + "value": "Repository layout" + } + ], + "depth": 1, + "type": "heading" + }, + { + "children": [ + { + "type": "text", + "value": "This document describes the generated {{ project_name }} repository layout. It is the canonical reference for where source code, tests, configuration, automation, and long-lived documentation belong." + } + ], + "type": "paragraph" + }, + { + "children": [ + { + "type": "text", + "value": "Top-level tree" + } + ], + "depth": 2, + "type": "heading" + }, + { + "children": [ + { + "type": "text", + "value": "The tree below shows the generated repository structure. It is intentionally compact and omits build output such as" + }, + { + "type": "inlineCode", + "value": "target/" + }, + { + "type": "text", + "value": "." + } + ], + "type": "paragraph" + }, + { + "lang": "plaintext", + "type": "code", + "value": ".\n├── .cargo/\n│ └── config.toml\n├── .github/\n│ ├── dependabot.yml\n│ └── workflows/\n│ ├── act-validation.yml\n│ ├── ci.yml\n\n├── docs/\n│ ├── contents.md\n│ ├── developers-guide.md\n│ ├── repository-layout.md\n│ ├── users-guide.md\n│ └── ...\n├── src/\n\n│ └── lib.rs\n\n├── tests/\n│ └── stub.rs\n├── AGENTS.md\n├── Cargo.toml\n├── LICENSE\n├── Makefile\n├── README.md\n├── clippy.toml\n├── codecov.yml\n└── rust-toolchain.toml" + }, + { + "children": [ + { + "type": "text", + "value": "Path responsibilities" + } + ], + "depth": 2, + "type": "heading" + }, + { + "children": [ + { + "children": [ + { + "children": [ + { + "type": "inlineCode", + "value": ".cargo/config.toml" + }, + { + "type": "text", + "value": ": Configures Cargo defaults for local development, including Linux linker and code-generation settings." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "inlineCode", + "value": ".github/dependabot.yml" + }, + { + "type": "text", + "value": ": Configures automated dependency update checks." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "inlineCode", + "value": ".github/workflows/act-validation.yml" + }, + { + "type": "text", + "value": ": Runs the generated workflow validation through" + }, + { + "type": "inlineCode", + "value": "act" + }, + { + "type": "text", + "value": "separately from main CI." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "inlineCode", + "value": ".github/workflows/ci.yml" + }, + { + "type": "text", + "value": ": Runs the generated project's continuous integration checks." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "inlineCode", + "value": "docs/" + }, + { + "type": "text", + "value": ": Holds long-lived reference documentation, guides, style rules, and design material." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "inlineCode", + "value": "docs/contents.md" + }, + { + "type": "text", + "value": ": Indexes the documentation set and should be updated when documentation files are added, renamed, or removed." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "inlineCode", + "value": "docs/users-guide.md" + }, + { + "type": "text", + "value": ": Explains how to use the generated project and its public build and test commands." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "inlineCode", + "value": "docs/developers-guide.md" + }, + { + "type": "text", + "value": ": Explains the contributor workflow and local tooling used to work on the generated project." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "inlineCode", + "value": "docs/repository-layout.md" + }, + { + "type": "text", + "value": ": Documents the repository tree and path responsibilities." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "inlineCode", + "value": "src/lib.rs" + }, + { + "type": "text", + "value": ": Contains the library crate root and exported public API surface." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "inlineCode", + "value": "tests/" + }, + { + "type": "text", + "value": ": Holds integration and behavioural tests that exercise public behaviour." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "inlineCode", + "value": "tests/stub.rs" + }, + { + "type": "text", + "value": ": Keeps the generated test directory valid until real tests replace it." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "inlineCode", + "value": "AGENTS.md" + }, + { + "type": "text", + "value": ": Provides repository-specific working instructions for agents and contributors." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "inlineCode", + "value": "Cargo.toml" + }, + { + "type": "text", + "value": ": Defines package metadata, dependencies, lint policy, and Cargo configuration." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "inlineCode", + "value": "LICENSE" + }, + { + "type": "text", + "value": ": Records the project licence text." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "inlineCode", + "value": "Makefile" + }, + { + "type": "text", + "value": ": Provides the public build, lint, test, coverage, and documentation validation commands." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "inlineCode", + "value": "README.md" + }, + { + "type": "text", + "value": ": Introduces the project and gives the shortest useful getting-started path." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "inlineCode", + "value": "clippy.toml" + }, + { + "type": "text", + "value": ": Configures Clippy lint behaviour that is not expressed directly in" + }, + { + "type": "inlineCode", + "value": "Cargo.toml" + }, + { + "type": "text", + "value": "." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "inlineCode", + "value": "codecov.yml" + }, + { + "type": "text", + "value": ": Configures coverage reporting behaviour." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "inlineCode", + "value": "rust-toolchain.toml" + }, + { + "type": "text", + "value": ": Pins the Rust toolchain channel and required components." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + } + ], + "ordered": false, + "spread": true, + "type": "list" + }, + { + "children": [ + { + "type": "text", + "value": "Ownership boundaries" + } + ], + "depth": 2, + "type": "heading" + }, + { + "children": [ + { + "children": [ + { + "children": [ + { + "type": "text", + "value": "Keep generated source code under" + }, + { + "type": "inlineCode", + "value": "src/" + }, + { + "type": "text", + "value": ". Add modules below" + }, + { + "type": "inlineCode", + "value": "src/" + }, + { + "type": "text", + "value": "when a feature grows beyond a small entrypoint or crate root." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "text", + "value": "Keep black-box integration tests and externally observable workflow tests under" + }, + { + "type": "inlineCode", + "value": "tests/" + }, + { + "type": "text", + "value": "." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "text", + "value": "Keep reusable documentation under" + }, + { + "type": "inlineCode", + "value": "docs/" + }, + { + "type": "text", + "value": ". Update" + }, + { + "type": "inlineCode", + "value": "docs/contents.md" + }, + { + "type": "text", + "value": "whenever a documentation file is added, renamed, or removed." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "text", + "value": "Keep build and validation entrypoints in" + }, + { + "type": "inlineCode", + "value": "Makefile" + }, + { + "type": "text", + "value": "; prefer adding or extending a Make target over documenting an ad hoc command." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "text", + "value": "Keep continuous integration workflow changes under" + }, + { + "type": "inlineCode", + "value": ".github/workflows/" + }, + { + "type": "text", + "value": "and dependency-update policy under" + }, + { + "type": "inlineCode", + "value": ".github/dependabot.yml" + }, + { + "type": "text", + "value": "." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + }, + { + "children": [ + { + "children": [ + { + "type": "text", + "value": "Do not commit generated build output such as" + }, + { + "type": "inlineCode", + "value": "target/" + }, + { + "type": "text", + "value": ", coverage artefacts, or local editor state." + } + ], + "type": "paragraph" + } + ], + "spread": false, + "type": "listItem" + } + ], + "ordered": false, + "spread": false, + "type": "list" + }, + { + "children": [ + { + "type": "text", + "value": "Updating this document" + } + ], + "depth": 2, + "type": "heading" + }, + { + "children": [ + { + "type": "text", + "value": "Update this document when the repository gains a new top-level directory, a new long-lived documentation category, a new workflow file, or a changed ownership boundary that would otherwise make the tree misleading." + } + ], + "type": "paragraph" + } + ], + "type": "root" +} diff --git a/tests/helpers/markdown_semantics.py b/tests/helpers/markdown_semantics.py new file mode 100644 index 0000000..936de6c --- /dev/null +++ b/tests/helpers/markdown_semantics.py @@ -0,0 +1,51 @@ +"""Normalize Markdown documents as mdast JSON for semantic assertions.""" + +from __future__ import annotations + +import re +from typing import Any + +import mdast + +_POSITION_KEYS = frozenset({"position", "line", "column", "offset"}) +_SOFT_WHITESPACE = re.compile(r"[ \t\r\n]+") + + +def parse_markdown_semantics(markdown: str) -> object: + """Parse Markdown into normalized mdast JSON.""" + tree = mdast.md_to_ast(markdown, config=_gfm_parse_options()) + return _normalise_node(tree) + + +def _gfm_parse_options() -> mdast.ParseOptions: + return mdast.ParseOptions( + gfm_autolink_literal=True, + gfm_footnote_definition=True, + gfm_label_start_footnote=True, + gfm_strikethrough=True, + gfm_table=True, + gfm_task_list_item=True, + ) + + +def _normalise_node(value: Any) -> object: + if isinstance(value, dict): + normalised = {} + node_type = value.get("type") + for key, child in value.items(): + if key in _POSITION_KEYS: + continue + if key == "value" and node_type == "text" and isinstance(child, str): + normalised[key] = _normalise_soft_text(child) + continue + normalised[key] = _normalise_node(child) + return normalised + + if isinstance(value, list): + return [_normalise_node(child) for child in value] + + return value + + +def _normalise_soft_text(value: str) -> str: + return _SOFT_WHITESPACE.sub(" ", value) diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 0b29068..9580030 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -403,10 +403,10 @@ def test_parent_makefile_test_target_contract() -> None: ) assert ( "$(UV) --with pytest-copier --with pyyaml --with syrupy --with make-parser " - "--with hypothesis pytest tests/" + "--with hypothesis --with mdast pytest tests/" ) in makefile, ( "expected parent Makefile test target to run pytest through $(UV) with " - "pytest-copier, pyyaml, syrupy, make-parser, and hypothesis" + "pytest-copier, pyyaml, syrupy, make-parser, hypothesis, and mdast" ) diff --git a/tests/test_template/__snapshots__/test_documentation_snapshots.ambr b/tests/test_template/__snapshots__/test_documentation_snapshots.ambr new file mode 100644 index 0000000..eb8d305 --- /dev/null +++ b/tests/test_template/__snapshots__/test_documentation_snapshots.ambr @@ -0,0 +1,1069 @@ +# serializer version: 1 +# name: test_generated_documentation_semantic_snapshots + dict({ + 'docs/contents.md': dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'type': 'text', + 'value': 'Documentation contents', + }), + ]), + 'depth': 1, + 'type': 'heading', + }), + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'type': 'text', + 'value': 'Documentation contents', + }), + ]), + 'type': 'link', + 'url': 'contents.md', + }), + dict({ + 'type': 'text', + 'value': " is the index for DocumentationSnapshotExample's documentation set.", + }), + ]), + 'type': 'paragraph', + }), + dict({ + 'children': list([ + dict({ + 'type': 'text', + 'value': 'Project guides', + }), + ]), + 'depth': 2, + 'type': 'heading', + }), + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'type': 'text', + 'value': 'User guide', + }), + ]), + 'type': 'link', + 'url': 'users-guide.md', + }), + dict({ + 'type': 'text', + 'value': ' explains how to use the generated project and its public build and test commands.', + }), + ]), + 'type': 'paragraph', + }), + ]), + 'spread': False, + 'type': 'listItem', + }), + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'type': 'text', + 'value': 'Developer guide', + }), + ]), + 'type': 'link', + 'url': 'developers-guide.md', + }), + dict({ + 'type': 'text', + 'value': ' explains the local workflow and implementation tooling for contributors.', + }), + ]), + 'type': 'paragraph', + }), + ]), + 'spread': False, + 'type': 'listItem', + }), + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'type': 'text', + 'value': 'Repository layout', + }), + ]), + 'type': 'link', + 'url': 'repository-layout.md', + }), + dict({ + 'type': 'text', + 'value': " explains the generated project's top-level files, directories, and ownership boundaries.", + }), + ]), + 'type': 'paragraph', + }), + ]), + 'spread': False, + 'type': 'listItem', + }), + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'type': 'text', + 'value': 'Documentation style guide', + }), + ]), + 'type': 'link', + 'url': 'documentation-style-guide.md', + }), + dict({ + 'type': 'text', + 'value': ' defines the spelling, structure, Markdown, Architecture Decision Record (ADR), Request for Comments (RFC), and roadmap conventions used by this documentation set.', + }), + ]), + 'type': 'paragraph', + }), + ]), + 'spread': False, + 'type': 'listItem', + }), + ]), + 'ordered': False, + 'spread': False, + 'type': 'list', + }), + dict({ + 'children': list([ + dict({ + 'type': 'text', + 'value': 'Rust reference material', + }), + ]), + 'depth': 2, + 'type': 'heading', + }), + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'type': 'text', + 'value': 'Reliable testing in Rust via dependency injection', + }), + ]), + 'type': 'link', + 'url': 'reliable-testing-in-rust-via-dependency-injection.md', + }), + dict({ + 'type': 'text', + 'value': ' explains how to keep tests deterministic by injecting environment, clock, filesystem, and other external dependencies.', + }), + ]), + 'type': 'paragraph', + }), + ]), + 'spread': False, + 'type': 'listItem', + }), + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'type': 'text', + 'value': "Rust doctest Don't Repeat Yourself guide", + }), + ]), + 'type': 'link', + 'url': 'rust-doctest-dry-guide.md', + }), + dict({ + 'type': 'text', + 'value': ' explains how to write maintainable, executable Rust documentation examples.', + }), + ]), + 'type': 'paragraph', + }), + ]), + 'spread': False, + 'type': 'listItem', + }), + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'type': 'text', + 'value': 'Rust testing with ', + }), + dict({ + 'type': 'inlineCode', + 'value': 'rstest', + }), + dict({ + 'type': 'text', + 'value': ' fixtures', + }), + ]), + 'type': 'link', + 'url': 'rust-testing-with-rstest-fixtures.md', + }), + dict({ + 'type': 'text', + 'value': ' explains fixture-based, parameterized, and asynchronous testing with ', + }), + dict({ + 'type': 'inlineCode', + 'value': 'rstest', + }), + dict({ + 'type': 'text', + 'value': '.', + }), + ]), + 'type': 'paragraph', + }), + ]), + 'spread': False, + 'type': 'listItem', + }), + ]), + 'ordered': False, + 'spread': False, + 'type': 'list', + }), + dict({ + 'children': list([ + dict({ + 'type': 'text', + 'value': 'Engineering practice', + }), + ]), + 'depth': 2, + 'type': 'heading', + }), + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'type': 'text', + 'value': 'Complexity antipatterns and refactoring strategies', + }), + ]), + 'type': 'link', + 'url': 'complexity-antipatterns-and-refactoring-strategies.md', + }), + dict({ + 'type': 'text', + 'value': ' explains cognitive complexity, the bumpy-road antipattern, and refactoring approaches for maintainable code.', + }), + ]), + 'type': 'paragraph', + }), + ]), + 'spread': False, + 'type': 'listItem', + }), + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'type': 'text', + 'value': 'Scripting standards', + }), + ]), + 'type': 'link', + 'url': 'scripting-standards.md', + }), + dict({ + 'type': 'text', + 'value': ' explains the preferred Python scripting stack, command execution patterns, and test expectations for helper scripts.', + }), + ]), + 'type': 'paragraph', + }), + ]), + 'spread': False, + 'type': 'listItem', + }), + ]), + 'ordered': False, + 'spread': False, + 'type': 'list', + }), + ]), + 'type': 'root', + }), + 'docs/repository-layout.md': dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'type': 'text', + 'value': 'Repository layout', + }), + ]), + 'depth': 1, + 'type': 'heading', + }), + dict({ + 'children': list([ + dict({ + 'type': 'text', + 'value': 'This document describes the generated DocumentationSnapshotExample repository layout. It is the canonical reference for where source code, tests, configuration, automation, and long-lived documentation belong.', + }), + ]), + 'type': 'paragraph', + }), + dict({ + 'children': list([ + dict({ + 'type': 'text', + 'value': 'Top-level tree', + }), + ]), + 'depth': 2, + 'type': 'heading', + }), + dict({ + 'children': list([ + dict({ + 'type': 'text', + 'value': 'The tree below shows the generated repository structure. It is intentionally compact and omits build output such as ', + }), + dict({ + 'type': 'inlineCode', + 'value': 'target/', + }), + dict({ + 'type': 'text', + 'value': '.', + }), + ]), + 'type': 'paragraph', + }), + dict({ + 'lang': 'plaintext', + 'type': 'code', + 'value': ''' + . + ├── .cargo/ + │ └── config.toml + ├── .github/ + │ ├── dependabot.yml + │ └── workflows/ + │ ├── act-validation.yml + │ ├── ci.yml + + │ └── release.yml + + ├── docs/ + │ ├── contents.md + │ ├── developers-guide.md + │ ├── repository-layout.md + │ ├── users-guide.md + │ └── ... + ├── src/ + + │ ├── lib.rs + │ └── main.rs + + ├── tests/ + │ └── stub.rs + ├── AGENTS.md + ├── Cargo.toml + ├── LICENSE + ├── Makefile + ├── README.md + ├── clippy.toml + ├── codecov.yml + └── rust-toolchain.toml + ''', + }), + dict({ + 'children': list([ + dict({ + 'type': 'text', + 'value': 'Path responsibilities', + }), + ]), + 'depth': 2, + 'type': 'heading', + }), + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'type': 'inlineCode', + 'value': '.cargo/config.toml', + }), + dict({ + 'type': 'text', + 'value': ': Configures Cargo defaults for local development, including Linux linker and code-generation settings.', + }), + ]), + 'type': 'paragraph', + }), + ]), + 'spread': False, + 'type': 'listItem', + }), + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'type': 'inlineCode', + 'value': '.github/dependabot.yml', + }), + dict({ + 'type': 'text', + 'value': ': Configures automated dependency update checks.', + }), + ]), + 'type': 'paragraph', + }), + ]), + 'spread': False, + 'type': 'listItem', + }), + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'type': 'inlineCode', + 'value': '.github/workflows/act-validation.yml', + }), + dict({ + 'type': 'text', + 'value': ': Runs the generated workflow validation through ', + }), + dict({ + 'type': 'inlineCode', + 'value': 'act', + }), + dict({ + 'type': 'text', + 'value': ' separately from main CI.', + }), + ]), + 'type': 'paragraph', + }), + ]), + 'spread': False, + 'type': 'listItem', + }), + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'type': 'inlineCode', + 'value': '.github/workflows/ci.yml', + }), + dict({ + 'type': 'text', + 'value': ": Runs the generated project's continuous integration checks.", + }), + ]), + 'type': 'paragraph', + }), + ]), + 'spread': False, + 'type': 'listItem', + }), + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'type': 'inlineCode', + 'value': '.github/workflows/release.yml', + }), + dict({ + 'type': 'text', + 'value': ': Builds and publishes binary release artefacts for the application flavour.', + }), + ]), + 'type': 'paragraph', + }), + ]), + 'spread': False, + 'type': 'listItem', + }), + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'type': 'inlineCode', + 'value': 'docs/', + }), + dict({ + 'type': 'text', + 'value': ': Holds long-lived reference documentation, guides, style rules, and design material.', + }), + ]), + 'type': 'paragraph', + }), + ]), + 'spread': False, + 'type': 'listItem', + }), + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'type': 'inlineCode', + 'value': 'docs/contents.md', + }), + dict({ + 'type': 'text', + 'value': ': Indexes the documentation set and should be updated when documentation files are added, renamed, or removed.', + }), + ]), + 'type': 'paragraph', + }), + ]), + 'spread': False, + 'type': 'listItem', + }), + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'type': 'inlineCode', + 'value': 'docs/users-guide.md', + }), + dict({ + 'type': 'text', + 'value': ': Explains how to use the generated project and its public build and test commands.', + }), + ]), + 'type': 'paragraph', + }), + ]), + 'spread': False, + 'type': 'listItem', + }), + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'type': 'inlineCode', + 'value': 'docs/developers-guide.md', + }), + dict({ + 'type': 'text', + 'value': ': Explains the contributor workflow and local tooling used to work on the generated project.', + }), + ]), + 'type': 'paragraph', + }), + ]), + 'spread': False, + 'type': 'listItem', + }), + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'type': 'inlineCode', + 'value': 'docs/repository-layout.md', + }), + dict({ + 'type': 'text', + 'value': ': Documents the repository tree and path responsibilities.', + }), + ]), + 'type': 'paragraph', + }), + ]), + 'spread': False, + 'type': 'listItem', + }), + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'type': 'inlineCode', + 'value': 'src/lib.rs', + }), + dict({ + 'type': 'text', + 'value': ': Contains library support for application logic and doctested examples.', + }), + ]), + 'type': 'paragraph', + }), + ]), + 'spread': False, + 'type': 'listItem', + }), + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'type': 'inlineCode', + 'value': 'src/main.rs', + }), + dict({ + 'type': 'text', + 'value': ': Contains the application entrypoint and top-level executable wiring.', + }), + ]), + 'type': 'paragraph', + }), + ]), + 'spread': False, + 'type': 'listItem', + }), + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'type': 'inlineCode', + 'value': 'tests/', + }), + dict({ + 'type': 'text', + 'value': ': Holds integration and behavioural tests that exercise public behaviour.', + }), + ]), + 'type': 'paragraph', + }), + ]), + 'spread': False, + 'type': 'listItem', + }), + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'type': 'inlineCode', + 'value': 'tests/stub.rs', + }), + dict({ + 'type': 'text', + 'value': ': Keeps the generated test directory valid until real tests replace it.', + }), + ]), + 'type': 'paragraph', + }), + ]), + 'spread': False, + 'type': 'listItem', + }), + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'type': 'inlineCode', + 'value': 'AGENTS.md', + }), + dict({ + 'type': 'text', + 'value': ': Provides repository-specific working instructions for agents and contributors.', + }), + ]), + 'type': 'paragraph', + }), + ]), + 'spread': False, + 'type': 'listItem', + }), + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'type': 'inlineCode', + 'value': 'Cargo.toml', + }), + dict({ + 'type': 'text', + 'value': ': Defines package metadata, dependencies, lint policy, and Cargo configuration.', + }), + ]), + 'type': 'paragraph', + }), + ]), + 'spread': False, + 'type': 'listItem', + }), + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'type': 'inlineCode', + 'value': 'LICENSE', + }), + dict({ + 'type': 'text', + 'value': ': Records the project licence text.', + }), + ]), + 'type': 'paragraph', + }), + ]), + 'spread': False, + 'type': 'listItem', + }), + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'type': 'inlineCode', + 'value': 'Makefile', + }), + dict({ + 'type': 'text', + 'value': ': Provides the public build, lint, test, coverage, and documentation validation commands.', + }), + ]), + 'type': 'paragraph', + }), + ]), + 'spread': False, + 'type': 'listItem', + }), + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'type': 'inlineCode', + 'value': 'README.md', + }), + dict({ + 'type': 'text', + 'value': ': Introduces the project and gives the shortest useful getting-started path.', + }), + ]), + 'type': 'paragraph', + }), + ]), + 'spread': False, + 'type': 'listItem', + }), + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'type': 'inlineCode', + 'value': 'clippy.toml', + }), + dict({ + 'type': 'text', + 'value': ': Configures Clippy lint behaviour that is not expressed directly in ', + }), + dict({ + 'type': 'inlineCode', + 'value': 'Cargo.toml', + }), + dict({ + 'type': 'text', + 'value': '.', + }), + ]), + 'type': 'paragraph', + }), + ]), + 'spread': False, + 'type': 'listItem', + }), + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'type': 'inlineCode', + 'value': 'codecov.yml', + }), + dict({ + 'type': 'text', + 'value': ': Configures coverage reporting behaviour.', + }), + ]), + 'type': 'paragraph', + }), + ]), + 'spread': False, + 'type': 'listItem', + }), + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'type': 'inlineCode', + 'value': 'rust-toolchain.toml', + }), + dict({ + 'type': 'text', + 'value': ': Pins the Rust toolchain channel and required components.', + }), + ]), + 'type': 'paragraph', + }), + ]), + 'spread': False, + 'type': 'listItem', + }), + ]), + 'ordered': False, + 'spread': True, + 'type': 'list', + }), + dict({ + 'children': list([ + dict({ + 'type': 'text', + 'value': 'Ownership boundaries', + }), + ]), + 'depth': 2, + 'type': 'heading', + }), + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'type': 'text', + 'value': 'Keep generated source code under ', + }), + dict({ + 'type': 'inlineCode', + 'value': 'src/', + }), + dict({ + 'type': 'text', + 'value': '. Add modules below ', + }), + dict({ + 'type': 'inlineCode', + 'value': 'src/', + }), + dict({ + 'type': 'text', + 'value': ' when a feature grows beyond a small entrypoint or crate root.', + }), + ]), + 'type': 'paragraph', + }), + ]), + 'spread': False, + 'type': 'listItem', + }), + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'type': 'text', + 'value': 'Keep black-box integration tests and externally observable workflow tests under ', + }), + dict({ + 'type': 'inlineCode', + 'value': 'tests/', + }), + dict({ + 'type': 'text', + 'value': '.', + }), + ]), + 'type': 'paragraph', + }), + ]), + 'spread': False, + 'type': 'listItem', + }), + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'type': 'text', + 'value': 'Keep reusable documentation under ', + }), + dict({ + 'type': 'inlineCode', + 'value': 'docs/', + }), + dict({ + 'type': 'text', + 'value': '. Update ', + }), + dict({ + 'type': 'inlineCode', + 'value': 'docs/contents.md', + }), + dict({ + 'type': 'text', + 'value': ' whenever a documentation file is added, renamed, or removed.', + }), + ]), + 'type': 'paragraph', + }), + ]), + 'spread': False, + 'type': 'listItem', + }), + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'type': 'text', + 'value': 'Keep build and validation entrypoints in ', + }), + dict({ + 'type': 'inlineCode', + 'value': 'Makefile', + }), + dict({ + 'type': 'text', + 'value': '; prefer adding or extending a Make target over documenting an ad hoc command.', + }), + ]), + 'type': 'paragraph', + }), + ]), + 'spread': False, + 'type': 'listItem', + }), + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'type': 'text', + 'value': 'Keep continuous integration workflow changes under ', + }), + dict({ + 'type': 'inlineCode', + 'value': '.github/workflows/', + }), + dict({ + 'type': 'text', + 'value': ' and dependency-update policy under ', + }), + dict({ + 'type': 'inlineCode', + 'value': '.github/dependabot.yml', + }), + dict({ + 'type': 'text', + 'value': '.', + }), + ]), + 'type': 'paragraph', + }), + ]), + 'spread': False, + 'type': 'listItem', + }), + dict({ + 'children': list([ + dict({ + 'children': list([ + dict({ + 'type': 'text', + 'value': 'Do not commit generated build output such as ', + }), + dict({ + 'type': 'inlineCode', + 'value': 'target/', + }), + dict({ + 'type': 'text', + 'value': ', coverage artefacts, or local editor state.', + }), + ]), + 'type': 'paragraph', + }), + ]), + 'spread': False, + 'type': 'listItem', + }), + ]), + 'ordered': False, + 'spread': False, + 'type': 'list', + }), + dict({ + 'children': list([ + dict({ + 'type': 'text', + 'value': 'Updating this document', + }), + ]), + 'depth': 2, + 'type': 'heading', + }), + dict({ + 'children': list([ + dict({ + 'type': 'text', + 'value': 'Update this document when the repository gains a new top-level directory, a new long-lived documentation category, a new workflow file, or a changed ownership boundary that would otherwise make the tree misleading.', + }), + ]), + 'type': 'paragraph', + }), + ]), + 'type': 'root', + }), + }) +# --- diff --git a/tests/test_template/test_documentation_snapshots.py b/tests/test_template/test_documentation_snapshots.py new file mode 100644 index 0000000..6691e7a --- /dev/null +++ b/tests/test_template/test_documentation_snapshots.py @@ -0,0 +1,63 @@ +"""Rendered documentation semantic snapshot tests.""" + +from __future__ import annotations + +from pathlib import Path + +from pytest_copier.plugin import CopierFixture +from syrupy.assertion import SnapshotAssertion + +from tests.helpers.markdown_semantics import parse_markdown_semantics +from tests.helpers.rendering import APP, render_project + + +def test_markdown_semantics_ignore_formatting_noise() -> None: + """Equivalent Markdown formatting yields the same normalized mdast JSON.""" + compact = """\ +# Documentation + +The generated project links to [docs](docs/contents.md) and explains +the workflow in one paragraph. + +| Path | Purpose | +| - | - | +| `docs/contents.md` | Index | + +- [x] Keep snapshots semantic +""" + reflowed = """\ +# Documentation + +The generated project links to [docs](docs/contents.md) and explains the +workflow in one paragraph. + +| Path | Purpose | +| -------------------- | ------- | +| `docs/contents.md` | Index | + +- [x] Keep snapshots semantic +""" + + assert parse_markdown_semantics(compact) == parse_markdown_semantics(reflowed) + + +def test_generated_documentation_semantic_snapshots( + tmp_path: Path, copier: CopierFixture, snapshot: SnapshotAssertion +) -> None: + """Generated documentation matches reviewed normalized mdast snapshots.""" + project = render_project( + tmp_path, + copier, + project_name="DocumentationSnapshotExample", + package_name="documentation_snapshot_example", + flavour=APP, + ) + + assert { + "docs/contents.md": parse_markdown_semantics( + (project / "docs/contents.md").read_text(encoding="utf-8") + ), + "docs/repository-layout.md": parse_markdown_semantics( + (project / "docs/repository-layout.md").read_text(encoding="utf-8") + ), + } == snapshot