Skip to content

Spec 047 — Documentation Framework Tooling

Scope: Build & CI tooling. Specifies the bridge, gate, and pipeline that enforce the documentation framework. Not library source code; the contracts here govern scripts/docs/ and scripts/check_docs_framework.py.

Prefix: DOCFRAME

Author-facing surface: AUTHORING.md. Authors do not need to read this spec to write or classify documents. Marker syntax, classification rules, directory defaults, and per-class semantics live in AUTHORING.md and are normative there. This spec defers to AUTHORING.md for those rules and only specifies the tooling that enforces them.

Related decisions: ADR-0027 selects the single-bridge architecture and inline-marker classification. ADR-0007 established the three-tier docs-src/ + gen-files + source-dirs split; this spec narrows ADR-0007's "build hook" tier to one bridge mechanism.

Tracks: BK-167a. BK-167b applies the framework and closes the remaining audit-012 findings. BK-171 collapses link validation into a single on-disk gate (DOCFRAME-008). BK-236 extends the gate to docs-site links (DOCFRAME-009).


DOCFRAME-001: Single Bridge Mechanism

Invariant: Exactly one mechanism in scripts/docs/ discovers the source→virtual-dest mapping for dual files. Every dual file reaches the docs site through that mechanism.

Postcondition: No include-markdown Jinja directive is present in any docs-src/**/*.md file. No _link_map.yml is present. No mechanism other than the one defined here writes virtual pages for dual content.

Mechanism: scripts/docs/scan.py:scan_dual_files(repo_root) returns an iterable of DualEntry records. scripts/docs/render.py:render_dual_pages emits one virtual page per entry using the existing LinkResolver.

Rationale: AUTHORING.md Rule 4. See ADR-0027 for the selection rationale and the three legacy mechanisms it supersedes.


DOCFRAME-002: Classification Parser Contract

Invariant: The marker parser implements the syntax and semantics defined in AUTHORING.md Rule 1 and its "Classification markers" guide. This spec adds only the parsing contract:

  • The parser scans the first 5 non-blank lines of each .md file.
  • The parser is regex-based; no Markdown AST is constructed.
  • A malformed marker (unrecognised class, dest= on a non-dual class, missing dest= on a dual marker, multiple markers in one file) is a parse error reported by the gate as G-01.
  • Marker absence falls back to the directory-default table in AUTHORING.md. Files matching no default and carrying no marker are a G-01 failure.

Postcondition: Every .md in the repository resolves to exactly one class, derivable from the file plus the AUTHORING.md default table; no separate manifest is consulted.

Rationale: AUTHORING.md owns the rules; this spec owns the parser that enforces them. Splitting prevents author-facing content (syntax, examples) from drifting into a tooling spec authors do not read.


DOCFRAME-003: DualEntry Record

Invariant: scripts/docs/scan.py:DualEntry is a frozen dataclass with shape:

@dataclass(frozen=True)
class DualEntry:
    source: Path        # absolute repo path
    dest: str           # virtual dest, e.g. "explanation/design/authoring.md"

DualEntry carries no class field: by construction, only dual entries reach render input. Repo-only and docs-only files are observed by the gate's classification audit (G-01) on a separate code path that does not produce DualEntry records.


DOCFRAME-004: PR-Time Gate

Invariant: scripts/check_docs_framework.py exits 0 if and only if every framework rule listed below holds. Wired into hatch run all via a docs-check script in pyproject.toml. CI runs hatch run all on every PR.

Checks (each is a separate failure mode):

Check ID Rule What it asserts
G-01 AUTHORING R1 Every .md resolves to exactly one class via marker or default rule. Ambiguous, missing, and conflicting markers fail.
G-02 AUTHORING R2 The source→dest map is injective: no two dual sources point to the same dest, no source has two dests.
G-03 AUTHORING R3 Dual files contain no {% ... %} Jinja directive and no MkDocs macro syntax. The --8<-- snippet form is permitted.
G-04 AUTHORING R4 No include-markdown directive in any docs-src/**/*.md. No _link_map.yml exists.
G-05 AUTHORING R3 + DOCFRAME-008 Every relative ](https://github.com/haalfi/remote-store/blob/master/sdd/specs/path) link in every git-tracked .md file resolves on disk in the repo. No class-based carve-out.
G-06 DOCUMENTATION R7 For every page reachable through docs-src/_nav.yml and its child _nav.yml files, the URL prefix matches the nav-section prefix (Reference → /reference/, Explanation → /explanation/, etc.). The check parses the nav source files directly; it does not rely on the generated SUMMARY.md (which is a build-time artifact, not a source).
G-07 DOCUMENTATION R8 mkdocs build --strict succeeds. (--strict promotes all warnings to failures; MkDocs 1.x does not accept error as a literal value for validation.links.not_found.)

Failure output: one line per violation, formatted <check-id> <path>: <reason>. Lines are stable across runs (sorted by path) so diffs in CI logs are minimal.

Performance: scan + checks G-01 through G-05 run in under 5 seconds on the full tree; G-06 and G-07 invoke MkDocs, bounded by docs build time.

Rationale: AUTHORING.md Rule 5.


DOCFRAME-005: Bridge Replaces, Not Augments

Invariant: When this spec is implemented, the following functions and files are removed:

  • scripts/docs/scan.py:load_link_map
  • scripts/docs/scan.py:scan_include_wrappers
  • scripts/docs/render.py:render_link_rewritten
  • docs-src/_link_map.yml
  • docs-src/design/authoring.md
  • docs-src/design/content-rules.md
  • docs-src/design/design.md
  • docs-src/design/documentation.md
  • docs-src/design/testing.md

Postcondition: git grep -n "include-markdown" docs-src/ returns no matches. git ls-files docs-src/_link_map.yml returns no matches.

Rationale: AUTHORING Rule 4 forbids parallel mechanisms. Renaming without removing is not compliance.


Invariant: CI invokes mkdocs build --strict. A broken internal link is a build failure, not a warning. MkDocs 1.x does not accept error as a literal value for validation.links.not_found; --strict promotes all warnings (including link warnings) to failures, achieving the same effect.

Postcondition: Audit-012 F-03 and W-01 are closed.

Implementation note: This step lands AFTER DOCFRAME-005 because several existing wrapper-routed links break under --strict until the bridge unifies the source→dest map.


DOCFRAME-007: Nav and URL Alignment

Invariant: Each top-level nav section maps to a URL prefix matching its label (lowercased, hyphenated). The top-level nav structure follows DOCUMENTATION.md Rule 9 (Tutorial, Guides, Reference, Explanation as the only content sections; Home: permitted as the index entry).

Required docs-src/_nav.yml shape after this spec lands:

- Home: index.md
- Tutorial:
    - Getting Started: getting-started.md
    - Examples: examples/
- Guides: ...
- Reference: ...
- Explanation:
    - ...
    - Design: explanation/design/

Examples nest under Tutorial (the learn-by-doing quadrant). Changelog nests under Reference at the URL /reference/changelog/ (closes F-10). "Further Reading" is removed; its prior contents (Development Story, Contributing) move under Explanation or Reference per their content type.

Closes: F-05 (Tutorial top-level), F-06 (Changelog out of Reference), F-07 (Further Reading out of Diataxis), F-08 (Design URL prefix), F-10 (Changelog URL).

Implementation note: Folded into BK-167a per the user-confirmed scope. Each finding's nav/URL fix is a one-line _nav.yml edit plus matching directory move under docs-src/. The G-06 gate verifies the result.


Scope: BK-171.

Invariant: Every relative ](https://github.com/haalfi/remote-store/blob/master/sdd/specs/path) link in every git-tracked .md file in the repository resolves to an on-disk file in the repository. This applies uniformly to repo-only, dual, and docs-only files: no class-based carve-out exists. External URLs (http://, https://, mailto:, ftp://) and pure anchors (#section) are exempt.

Postcondition: hatch run check-links exits 0 against the live repository. The two-mode (--mode repo vs --mode site) interface is removed; the script takes only --root.

Bridge: Authors write on-disk paths everywhere. At build time, the mkdocs hook scripts/mkdocs_hooks.py::on_page_markdown applies :class:~scripts.docs.link.LinkResolver to every docs-src/ file so that on-disk links into sdd/, repo-root duals (CHANGELOG.md, CONTRIBUTING.md, ...), and examples/*.py get rewritten to the corresponding docs-site URLs. The rewrite pass for dual virtual pages emitted by render_dual_pages is unchanged: the hook detects them by page.file.abs_src_path (outside docs-src/ ⇒ already pre-rewritten) and passes through.

Source map: scripts/docs/link.py:build_source_map accepts an example_entries iterable so that examples/<subdir>/<stem>.py paths resolve to tutorial/examples/<slug>.md URLs. SDD kind source directories (e.g. sdd/adrs/) are mapped to their generated index pages (explanation/design/<kind>/index.md) so that docs-src links pointing at a kind directory get rewritten to the in-site index URL rather than falling through to a GitHub blob URL. SDD subdir rules are loaded from docs-src/_path_rules.yml via scripts/docs/scan.py; per-file <!-- doc: dual dest=... --> markers retain their existing override role for one-off files (CHANGELOG.md, sdd/AUTHORING.md, etc.).

Closes: Audit-012 F-01 substantively (BK-167b's closure left docs-only link validation to mkdocs build --strict, which validates rendered URLs not GitHub-browser presentation; BK-171 enforces R1 honestly).

Implementation note: The pre-BK-171 check_links.py skipped docs-src/** files in --mode repo and only validated dual entries in --mode site. This mode split is removed: a single walker over _git_repo_markdown(repo_root) checks every link in every file.


Scope: BK-236.

Invariant: Every absolute link to the published docs site (https://docs.remotestore.dev/) in any git-tracked .md file whose path carries a moving version alias (/stable/ or /latest/) resolves to a page the docs site builds. DOCFRAME-008 governs relative on-disk links and exempts external URLs wholesale; this rule narrows that exemption for the one external host the repository itself owns.

Out of scope: A different host, the bare site root (it redirects to the default version), and numbered-version snapshots (/0.25/..., which the current docs-src/ tree cannot vouch for) are not checked.

Mechanism: scripts/docs/check_links.py:check_docs_site_links derives the valid page set from build_source_map — the same source→docs-URL map the mkdocs bridge (DOCFRAME-008) uses — so the gate stays in lockstep with the real site without a docs build or a live HTTP request. Each served page also validates its ancestor directories: a section directory has an index page (mkdocs-section-index plus literate-nav), so reference/api/store makes reference/api a valid section URL.

Postcondition: hatch run check-links exits 0 against the live repository. main reports on-disk (DOCFRAME-008) and docs-site (DOCFRAME-009) violations together, one line per violation.

Rationale: The pre-BK-236 gate validated relative links only. Two README links shipped to PyPI pointing at docs-site paths that never existed (/stable/api/store/, /stable/how-to/extensions/); nothing offline could have caught them. Closing the loop for the project's own docs host costs one extra source-map scan and keeps the README — the package's PyPI long description — honest.


Tests

tests/scripts/test_docs_framework.py (parser and scanner — DOCFRAME-001..003):

Test Spec ref Note
test_marker_parses_dual_with_dest DOCFRAME-002
test_marker_parses_repo_only_no_dest DOCFRAME-002
test_marker_absent_defaults_to_dual_in_sdd_subdir DOCFRAME-002
test_marker_absent_in_repo_root_is_an_error DOCFRAME-002, G-01
test_classify_file_templates_dir_is_repo_only DOCFRAME-002
test_scan_dual_files_yields_only_dual_class DOCFRAME-001, DOCFRAME-003
test_render_dual_pages_uses_link_resolver DOCFRAME-001 deferred
test_mkdocs_strict_passes_after_bridge G-07 deferred

tests/scripts/test_check_links.py (link gate — DOCFRAME-008):

Test Spec ref Note
test_check_repo_links_includes_docs_only_files DOCFRAME-008 docs-src no carve-out
test_check_repo_links_resolves_cross_tree_on_disk_target DOCFRAME-008 on-disk repo path passes
test_check_repo_links_no_broken DOCFRAME-008 positive control
test_check_repo_links_detects_broken DOCFRAME-008
test_check_repo_links_strips_fragment DOCFRAME-008 anchor handling
test_check_repo_links_against_live_repo DOCFRAME-008 live repo: exercises git ls-files path

tests/scripts/test_check_links.py (docs-site gate — DOCFRAME-009):

Test Spec ref Note
test_resolve_docs_site_path_returns_page_for_stable_alias DOCFRAME-009 docs-site URL → page path
test_resolve_docs_site_path_skips_numbered_version DOCFRAME-009 pinned snapshot out of scope
test_normalize_docs_dest_section_index DOCFRAME-009 index.md → directory URL
test_find_broken_docs_site_links_detects_stale_segment DOCFRAME-009 the README bug class
test_find_broken_docs_site_links_accepts_section_index DOCFRAME-009 section directory is a valid URL
test_docs_site_links_against_live_repo DOCFRAME-009 live repo: no broken docs-site links

tests/scripts/test_scan_sdd_kinds.py (YAML loader — DOCFRAME-008 Source map):

Test Spec ref Note
test_load_sdd_kinds_positive DOCFRAME-008 positive control: real file, expected slugs
test_load_sdd_kinds_filenotfound DOCFRAME-008 missing file: friendly error
test_load_sdd_kinds_missing_required_field DOCFRAME-008 missing slug: KeyError
test_load_sdd_kinds_empty_list_raises DOCFRAME-008 empty sdd_kinds list: ValueError

tests/scripts/test_link.py (build_source_map — DOCFRAME-008 Source map):

Test Spec ref Note
test_build_source_map_includes_sdd_kind_dirs DOCFRAME-008 kind_dir → index.md
test_build_source_map_kind_dir_unconditional DOCFRAME-008 kind_dir added even with empty entries
test_build_source_map_includes_example_sources DOCFRAME-008 examples/*.py → tutorial wrapper
test_build_source_map_includes_docs_src_html DOCFRAME-008 docs-src *.html → in-site path
test_build_source_map_includes_docs_src_images DOCFRAME-008 docs-src image assets → in-site path
test_link_resolver_rewrites_image_syntax_to_in_site_path DOCFRAME-008 image ![…](https://github.com/haalfi/remote-store/blob/master/sdd/specs/…) rewrites to in-site path, not GitHub blob

tests/scripts/test_mkdocs_hooks.py (hook dispatch — DOCFRAME-008 Bridge):

Test Spec ref Note
test_on_page_markdown_passthrough_when_abs_src_none DOCFRAME-008 Branch 1: no abs_src_path
test_on_page_markdown_passthrough_outside_docs_src DOCFRAME-008 Branch 2: gen-files virtual page
test_on_page_markdown_rewrites_docs_src_links DOCFRAME-008 Branch 3: docs-src link rewritten

tests/scripts/test_check_docs_framework.py (gate — DOCFRAME-004, G-02..G-06):

Test Spec ref Note
test_dest_collision_fails G-02
test_g02_no_collision_passes G-02 positive control
test_jinja_in_dual_file_fails G-03
test_g03_no_jinja_passes G-03 positive control
test_include_markdown_in_docs_src_fails G-04 include-markdown branch
test_link_map_yml_in_docs_src_fails G-04 _link_map.yml branch
test_g04_no_violations_passes G-04 positive control
test_broken_repo_link_in_dual_fails G-05
test_g05_valid_link_passes G-05 positive control
test_url_nav_misalignment_fails G-06
test_g06_correct_prefix_passes G-06 positive control

Each test traces back via @pytest.mark.spec("DOCFRAME-NNN") per 000-process.md Rule 2.