This page is how to change this repository: setup, the layout, the quality gates, the test data, the local runs, the action's venv, CI, and the page rules. Anyone who changes the code or the pages reads it.
Read before: README · Next: Contributing
- Setup
- Layout
- Gates
- Test data
- CLI run
- Debug run
- Local Integration Run
- Action venv
- CI
- Writing documentation
- Clone the repository:
git clone https://github.com/AbsaOSS/living-doc-toolkit.git, thencd living-doc-toolkit. - Use Python 3.10 or later; create and activate a venv:
python3 -m venv .venv, thensource .venv/bin/activate. - Run
make install:requirements-dev.txt, then every package editable with its[dev]extra, in dependency order →Makefile::install
- The supported floor is 3.10,
requires-python = ">=3.10"in every package; CI runs 3.10 to 3.14 →packages/core/pyproject.toml::requires-python - Run every install from the repository root: pip reads
./packages/...against the directory it runs in, not against the file. - To install the toolkit as the action does, not editable:
pip install -r requirements.txtin a fresh venv.
| File | What it holds | Who installs it |
|---|---|---|
requirements.txt |
Runtime only: the living-doc-utilities==0.8.0 pin, then ./packages/core, ./packages/services/normalize_docs, ./packages/services/normalize_tests, ./packages/services/coverage_matrix, ./apps/cli, in dependency order |
action.yml, into its venv, on every run; a plain install, above |
requirements-dev.txt |
-r requirements.txt, the gate and test tools pinned, and PyYAML for the root tests |
make install and CI |
- Each package declares its own runtime dependencies in its
pyproject.toml;requirements.txtnames the packages, not their dependencies, with one exception: theliving-doc-utilitiespin.- Every package declares
living-doc-utilities==0.8.0, from PyPI, as each imports it; one pin, the same in each. requirements.txtpins the same version, so the file the action installs names the contract version it runs on.
- Every package declares
- Every package declares the toolkit's one version,
0.1.0, the version the action's release tag carries →packages/core/src/living_doc_core/__init__.py::__version__- A written file's
metadata.producer.versioncomes from the installedliving-doc-core, so a release changes every package's version together.
- A written file's
- The development tools live in both places. Each package's
[dev]extra keeps the ranges that package needs on its own;requirements-dev.txtpins the versionsmake installand CI use. requirements.txtcarries no-e.make installgives the editable install for development; the action and a plain install need a plain one.- Why the order in
make install: a laterpip install -r requirements.txtwould reinstall the packages non-editable. - Why two calls: one call holding both
./packages/coreand-e packages/coredoes not resolve.
- Why the order in
- A plain install builds each package in place and leaves a
build/directory beside itssrc/; each package's mypyexcludeskips it →packages/core/pyproject.toml::build/- Why:
make types-<alias>runsmypy ., which would otherwise find every module twice.
- Why:
- A new package under
packages/orapps/goes intorequirements.txtafter the packages it depends on.- Why: the toolkit's packages are not on PyPI; a package depending on an unlisted one fails with "No matching distribution found".
- A development tool goes into
requirements-dev.txt, neverrequirements.txt.- Why: the action installs
requirements.txton every run.
- Why: the action installs
- Dependabot covers both files from its
pipentry on/, beside one entry per package directory →.github/dependabot.yml::pip-root- Each package entry, and the root entry, ignores
living-doc-utilities: its pin moves by hand, in every package and in the rootrequirements.txt, in one change.- Why: an entry updates only its own directory, so its bump moves one pin and the five packages stop installing together.
- A new
living-doc-utilitiesis also a contract change, made in its own issue.
- Each package entry, and the root entry, ignores
| Path | Package | What it holds |
|---|---|---|
packages/core |
living-doc-core |
Logging, the shared envelope, reader table, input validation, view filter and CI run context (README) |
packages/services/normalize_docs |
living-doc-service-normalize-docs |
The normalize-docs transform and its lineage table (README) |
packages/services/normalize_tests |
living-doc-service-normalize-tests |
The normalize-tests transform and its lineage table (README) |
packages/services/coverage_matrix |
living-doc-service-coverage-matrix |
The coverage-matrix transform and its lineage table over both inputs (README) |
apps/cli |
living-doc-cli |
The living-doc command group; registers normalize-docs, normalize-tests and coverage-matrix (README) |
- A package directory
packages/<group>/<name>is named by the group's singular and its name: the distributionliving-doc-<kind>-<name>, with-for_, and the importliving_doc_<kind>_<name>→packages/services/normalize_docs/pyproject.toml::living-doc-service-normalize-docs- So
packages/services/normalize_docsisliving-doc-service-normalize-docs, imported asliving_doc_service_normalize_docs. - The packages ship only inside the action's release, never on PyPI.
- Why the prefix: the packages share the action's venv with
living_doc_utilitiesand its dependencies;living_doc_keeps them apart. <kind>is this repository's convention.
- So
- Each command's transform is a package of its own under
packages/services/, with its lineage table in it. - Each package has its own
pyproject.toml,src/layout andtests/directory; its gates run from inside its directory, as CI runs them →Makefile::dir-core - The toolkit's packages ship no
py.typed, so a package importing another reads its annotations through a mypyfollow_untyped_importsoverride →pyproject.toml::follow_untyped_imports- The override sits in its
pyproject.toml; the root has one forliving_doc_core.
- The override sits in its
- The repository root holds the action:
action.yml, its entry pointmain.py, the local run'slocal-integration.shandtools/, and the roottests/for them. The root gates run from the root with the rootpyproject.toml→Makefile::ROOT_PY
make qa runs format-check, lint, types, deptry and test on every package, then the root's gates (qa-root).
It ends with no-vendored-schemas and stops at the first failure → Makefile::qa. Run it before opening a pull request.
| Target | Runs | Gate | One package | The root | Old name |
|---|---|---|---|---|---|
make qa |
the five package gates, then qa-root, then no-vendored-schemas |
every gate below | qa-<alias> |
qa-root: every root gate, no deptry |
py-qa |
make format |
ruff autofix, then Black, rewriting files | line length 120 | format-<alias> |
format-root: main.py, tools/, tests/ |
black |
make format-check |
Black in check mode, no writes | line length 120 | format-check-<alias> |
format-check-root: main.py, tools/, tests/ |
— |
make lint |
ruff, then Pylint over the tracked *.py outside tests/ |
Pylint ≥ 9.5 / 10 | lint-<alias> |
lint-root: ruff over main.py, tools/, tests/; Pylint over main.py, tools/ |
pylint |
make types |
mypy, [tool.mypy] python_version = "3.10" |
clean | types-<alias> |
types-root: main.py, tools/ |
mypy |
make deptry |
deptry | no undeclared, unused or transitive dependency | deptry-<alias> |
none | — |
make test |
pytest over tests/; each pyproject.toml adds coverage with pytest-cov |
pass, coverage ≥ 80 % | test-<alias> |
test-root: tests/, measuring main.py and tools/ |
pytest-unit |
make coverage |
pytest with --cov=src --cov-fail-under=80 on the command line; --cov-report=html writes <package>/htmlcov/index.html |
coverage ≥ 80 % | coverage-<alias> |
coverage-root |
— |
make no-vendored-schemas |
living-doc-utilities' vendored-schema check over the whole repository |
no tracked *-schema.json or *.schema.json outside a tests/ directory |
none | none | — |
- The aliases are
core,normalize-docs,normalize-tests,coverage-matrixandcli→Makefile::ALIASES - Each aggregate target but
deptryandno-vendored-schemasruns its root form too →Makefile::ROOT_TARGETS - An old name, and its
-<alias>form, prints a deprecation notice and runs the new target; they stay for one release →Makefile::DEPRECATED test.ymlruns the same targets, so local runs and CI never drift →.github/workflows/test.yml::coveragemake helplists every target →Makefile::help- Black, ruff, Pylint, mypy and pytest read their settings from each package's
pyproject.toml; one file:cd packages/core && pylint src/living_doc_core/input_validation.py→packages/core/pyproject.toml::tool.pylint - mypy checks against the 3.10 floor, so a standard-library use newer than 3.10 fails →
packages/core/pyproject.toml::python_version deptryfails on an undeclared import, on a declared dependency nothing imports, and on an import only another package brings →packages/core/pyproject.toml::tool.deptry- Each package sets
optional_dependencies_dev_groups = ["dev"], because the[dev]extra holds only tools, andknown_first_party, its ownsrc/package. - It has no root form: the root declares no package, and
main.pyimports only the standard library andliving_doc_core, whichrequirements.txtinstalls.
- Each package sets
no-vendored-schemasis R12 check 1 ofliving-doc-utilities' component checks: no repository butliving-doc-utilitiescommits a contract schema →Makefile::check_no_vendored_schemas- It reads git's index, so it misses a file not yet added, and skips anything under a
tests/directory. - Why: the contract models and schemas come from the installed
living-doc-utilities; a committed copy stops tracking it, and nothing reports that.
- It reads git's index, so it misses a file not yet added, and skips anything under a
- R12 check 2, one read path and one write path, is the code's. Every read goes through
read_artifactand returns a contract model → The reader table- Every write goes through
write_artifactfrom a contract model: each command's § What a run does, normalize-docs, normalize-tests, coverage-matrix.
- Every write goes through
- R12 check 3 and R11 are each transform's own tests: the full-sample test, and
assert_completeover its lineage table → each command's § Lineage, normalize-docs, normalize-tests, coverage-matrix - The page checks in
tests/docs/are root tests, somake testandmake qarun them → Checks
tests/fixtures/collector-gh/holds the one copy ofliving-doc's exampledoc-source.jsonandui-tests.json, with their source noted beside them; every package's tests andintegration.ymlread it → READMEtests/fixtures/utilities-golden/holdsliving-doc-utilities' six golden entities, whichpackages/services/normalize_docs' tests read → README- Each command's golden output is in its package's
tests/expected/, with how to regenerate it → normalize-docs, normalize-tests, coverage-matrix - Each command runs end to end over the fixtures against its golden, and through the entry point →
tests/test_main.py::test_the_entry_point_runs_coverage_matrix_over_the_fixture_pair_and_sets_the_output_path - Each command's change brings its own end-to-end tests and its step in
integration.yml'saction-run→ CI
After make install, living-doc runs the commands over the fixtures; every option is in the CLI reference.
living-doc --version
living-doc --help
living-doc --verbose normalize-docs --input tests/fixtures/collector-gh/doc-source.json \
--output output/generator-ready.json --view release
living-doc normalize-tests --input tests/fixtures/collector-gh/ui-tests.json \
--output output/ui-test-catalog.json
living-doc coverage-matrix --doc-input tests/fixtures/collector-gh/doc-source.json \
--tests-input tests/fixtures/collector-gh/ui-tests.json --output output/coverage-matrix.json --fail-under 50The run of main.py as the action runs it, in the development venv, from the repository root after make install.
What it checks and the lines it fails with: Errors.
export INPUT_COMMAND=normalize-docs
export INPUT_INPUT=tests/fixtures/collector-gh/doc-source.json
export INPUT_OUTPUT=output/generator-ready.json
export INPUT_VERBOSE_LOGGING=true # optional: --verbose and DEBUG lines; default false; RUNNER_DEBUG=1 too
python3 main.py # writes output/generator-ready.json- Each input is an
INPUT_<NAME>variable, upper case with_for-, asaction.ymlpasses it →action.yml::INPUT_DOCUMENT_TITLE - Without
GITHUB_OUTPUT, as outside GitHub Actions, the run sets nooutput-pathoutput and logs that once →tests/test_main.py::test_a_run_outside_github_actions_sets_no_output_and_says_so tests/test_main.pyreplaces the CLI to test the run itself: the command line, the exit code passed through, andoutput-path→tests/test_main.py::test_a_failed_command_ends_the_run_with_its_exit_code_and_sets_no_output- It also runs
normalize-docs,normalize-testsandcoverage-matrixthrough the entry point over their fixtures, for real.
- It also runs
local-integration.sh runs the real toolkit over a given doc-source.json and ui-tests.json, on the machine that
has them, outside any workflow. It runs the three commands through main.py in a venv of its own. It re-reads every
output with read_artifact, and prints what ran and what came out. No workflow runs it; like any local run, it is a
development and operator-check affordance, not a second supported deployment target.
- The usual inputs are the two files a
living-doc-collector-ghrun wrote: its Local Integration Run says where they land. The script never derives another component's paths; you configure both files →.env.local.example::DOC_INPUT
The script checks the Python version, git and the two inputs, and nothing else of the environment → local-integration.sh::--check. The rest is yours:
- Python ≥ 3.10, with its
venvmodule. The script usesPYTHONfrom.env.localwhen set, elsepython3, elsepythonfromPATH, the orderaction.ymluses.- On Git Bash for Windows,
python3may be the Microsoft Store alias, which does not run: setPYTHON=python.
- On Git Bash for Windows,
gitonPATH: the report's header names this repository's commit.- Network and pip configuration that lets
pip install -r requirements.txtsucceed:pip.conf,PIP_INDEX_URL,PIP_FIND_LINKS, a proxy. The script leaves them as they are. - A POSIX shell: bash 3.2 or later; Linux, macOS, or Git Bash on Windows.
No token: every command reads local files and calls no API, so the script reads no credential.
- It unsets
GITHUB_TOKEN,GH_TOKEN,GH_ENTERPRISE_TOKEN,GITHUB_ENTERPRISE_TOKENand every inheritedINPUT_*before it starts anything →local-integration.sh::GH_TOKEN .env.localis read as data, never sourced. A token left in it is ignored; the script reports its line by number, never its text.
cp .env.local.example .env.local # .env.local is git-ignoredAs committed, the example runs over the fixture pair in tests/fixtures/collector-gh/, unchanged.
| Key | Required | What it sets |
|---|---|---|
DOC_INPUT |
yes | the documentation input, a doc-source artifact: normalize-docs and coverage-matrix input |
TESTS_INPUT |
yes | the tests input, a ui-tests artifact: normalize-tests input, coverage-matrix tests-input |
VIEW |
no | view for normalize-docs and coverage-matrix: inner (the default) or release |
OUT_DIR |
no | the output root, default ./output (git-ignored) |
PYTHON |
no | the interpreter the venv is built from |
- A relative path resolves against this repository's root;
~/is the home directory. - One
KEY=valueper line; a line starting with#is a comment, and values are taken literally.
./local-integration.sh --check # Python, git, the configuration and both inputs; creates no venv, runs nothing
./local-integration.sh # the runA run, in order:
- Checks what
--checkchecks. One line names what stops it:- a missing setting or input file;
- an input inside a directory the run clears;
- a
VIEWother thaninnerorrelease; - a Python below 3.10.
- Builds its venv,
.venv-local-integration/(git-ignored), separate from the development.venv. It installs onlyrequirements.txt(pip install -r), from the repository root: a runtime requirement missing from that file fails here, as on a runner.- It follows Action venv: the same six variables unset, the same import check after the install, and
main.pyrun with-E. - The venv is reused only when the same interpreter built it from the same
requirements.txt.
- It follows Action venv: the same six variables unset, the same import check after the install, and
- Prints what runs: this repository's commit, the installed
living-doc-utilitiesversion, the Python version, both inputs, the view and the output directory. The commit is marked when tracked files differ from it. - Runs
normalize-docs,normalize-tests, thencoverage-matrix, each as its ownmain.pyrun with only the inputs it takes (Inputs). Each command is echoed before it runs.- The outputs go to
$OUT_DIR/toolkit/<command>/; the run clears those three directories first. - A command that fails stops the run with one line naming it, after the toolkit's own error lines. A
DOC_INPUTthat is not adoc-sourceartifact stopsnormalize-docswith[CONTRACT_MISMATCH].
- The outputs go to
- Validates and reports each output →
tools/local_integration.py::report- It re-reads the output with
read_artifactagainst its contract, then prints its producer, run time, size andmetadata.stats.cardinality. - For
coverage-matrixit also prints the rootsummary. - It prints each
metadata.source_inputs[]entry: the input's contract, the producer that wrote it,source.project_idandsource.repositories. - It prints the warnings by code: each code's count and its first ten occurrences.
- Per input, it matches each warning to one the output carries unchanged, every field the same but the context. The context names the input first. It prints how many matched beside the input's own count; one that does not match fails the run (
errors.md§ Forwarded warnings). - The report spells out control characters in text from the files (
\n,\x1b), so an input cannot forge a line. The toolkit's own log lines above it are printed as the toolkit writes them.
- It re-reads the output with
- It exits non-zero when a check, the install, a command or a validation fails →
tools/local_integration.py::main - Outside GitHub Actions every
metadata.runfield is empty; the inputs' producers inmetadata.source_inputs[]say where the run's data came from. - For an issue or a report, keep the whole output:
set -o pipefail; ./local-integration.sh 2>&1 | tee local-integration.log.
action.yml runs the toolkit in a venv of its own, built from the caller's Python. The rules are
living-doc-generator-markdown's; what differs here follows them. What the caller provides, and the lines the step
fails with: Prerequisites, Errors.
PIP_TARGET,PIP_PREFIX,PIP_USER,PIP_ROOT,PIP_PYTHONandPYTHONPATHare unset before the install →action.yml::PIP_TARGET- Why: pip reads them as configuration; an exported one would empty or shadow the venv, or (
PIP_PYTHON) target the caller's interpreter.
- Why: pip reads them as configuration; an exported one would empty or shadow the venv, or (
- The venv is reused only when the interpreter now on
PATHbuilt it, compared assys.base_prefixplus version; otherwise it is rebuilt →action.yml::IDENTITY - After the install, one import of
living_doc_cli.mainthrough the venv's interpreter proves the packages landed there →action.yml::living_doc_cli.main main.pyruns with-E, so a caller'sPYTHONPATHcannot shadow the venv →action.yml::-E- Every input reaches
main.pythrough the run step'senv:; norun:script holds${{ inputs.* }}→action.yml::INPUT_COMMAND main.pyrunspython -I -m living_doc_cli.main <command> --<option>=<value> …on the venv's interpreter;-Iis-Ethat also keeps the working directory offsys.path→main.py::run- Why: the step runs in the caller's checkout, which
python -msearches first; aclick.pyorjson.pywould shadow the venv's.
- Why: the step runs in the caller's checkout, which
tests/test_action_yml.pyruns the venv step against a fake interpreter;integration.ymlshows the reuse, the rebuild on an interpreter change, and the rejection of Python 3.9 on real ones (CI) →tests/test_action_yml.py::test_the_venv_is_reused_when_the_interpreter_on_path_built_it
What differs from living-doc-generator-markdown:
- The install and the import check run from the action's own directory,
$GITHUB_ACTION_PATH→action.yml::GITHUB_ACTION_PATH- Why: pip reads
requirements.txt's./packages/...against its working directory; from the caller's workspace they do not exist. - Why the variable, not
${{ github.action_path }}: in acontainer:job the expression gives the runner host's path, which does not exist inside the container.
- Why: pip reads
- The import check imports
living_doc_cli.main, which loadsclick, the CLI package, each registered command and those commands' packages: the three services,living-doc-coreandliving-doc-utilities. - pip rebuilds a local directory on every install, so each run reinstalls the five packages into the reused venv; their dependencies stay installed.
test.yml runs the gates, integration.yml runs the action itself, and link-check.yml checks the Markdown links.
- The checks to mark as required are
QA Gateand everyintegration.ymljob.QA Gatealways runs, passes over a skipped job and fails on a failed or cancelled one. Why: a required check must report on every pull request, also on one that changed no Python.integration.ymlruns on every pull request tomaster, so its jobs always report. Why: its jobs are the only ones that run the action itself; without them a broken action merges green.
- The static checks and the tests run when a Python-related file changed:
*.py,requirements.txt,requirements-dev.txt, apyproject.toml, atests/fixtures/file, a*-schema.jsonor*.schema.jsonfile,Makefile,action.ymlortest.yml→.github/workflows/test.yml::python_changed- The fixtures are in the list because
packages/core's tests read them. - Schema files are in the list because
no-vendored-schemasfails on one. action.ymlis in the list becausetests/test_action_yml.pyreads it.
- The fixtures are in the list because
- The tests also run when a page,
action.yml, a shell script, an.examplefile,dependabot.ymlor a workflow changed →.github/workflows/test.yml::docs_changed- Why:
tests/docs/checks every page against the code, and the root tests read the local run's script and its example configuration.
- Why:
- A pull request counts a renamed file by both paths, so leaving a set still runs the tests →
tests/docs/test_ci_filter.py::test_a_rename_lists_its_old_path_too - A manual run runs every gate, and a manual run of
link-check.ymlchecks every page.- Why: a manual run is a request to re-check the branch, whatever its last commit touched.
format-check,lint,types,deptryandno-vendored-schemasrun once, on Python 3.10, over every package and the root.- Why: they give the same result on every interpreter, and on the floor a syntax newer than 3.10 fails.
make coverageruns on Python 3.10 to 3.14.- Why: the action runs on whatever Python ≥ 3.10 the caller puts on
PATH.
- Why: the action runs on whatever Python ≥ 3.10 the caller puts on
integration.yml's jobs run the action throughuses: ./over the committed fixtures; they have no local form, as they need a GitHub runner.action-runruns each command the action runs and checks what it wrote:normalize-docsoverdoc-source.jsonin the release view,normalize-testsoverui-tests.json, andcoverage-matrixover both in the release view.- It reads the file with
read_artifacton the action's venv and checks:document.view, theselection_summary, and the feature files or the root coverage summary;- a keyword row's status and keyword;
- that
metadata.run.run_idis the job'sGITHUB_RUN_IDandproducer.buildisnull; - that
output-pathis the file's absolute path.
- It reads the file with
- The venv jobs run
normalize-docs, and the isolation job also runscoverage-matrix; each writes its file.- Each run step has
continue-on-error, and the job asserts itsoutcome:successfor each. - A step cannot read another step's log, so each job replays the run's inputs on the action's venv. It runs
main.pyas the run step does. Each replay asserts exit code 0,output-pathand the file.- Why: a failed install fails the step too; the replay shows the venv imports and the run ends where it should.
- Each job reads the files the run steps wrote too, with
read_artifact: validgenerator-readyfiles, and acoverage-matrixfile with the fixture pair's counted criteria. In the isolation job thegenerator-readyfiles carry the view and title the step gave.
- Each run step has
- The jobs need only
contents: read: they run the repository's code and make no GitHub API call. - The isolation job installs
pydantic<2into the runner's interpreter and compares itspython, version and pydantic before and after. It runs on 3.12, a version pydantic 1.x publishes wheels for.- Why: the packages need pydantic 2, through
living-doc-utilities; on that interpreter the action's import check would fail. - An action installing into that interpreter would replace its pydantic.
- Why: the packages need pydantic 2, through
- It proves the reuse by
pyvenv.cfg's mtime, unchanged across its runs of the action.- Why:
python -m venvwithout--clearkeeps existing files, so a marker survives a re-create;pyvenv.cfgis rewritten every time.
- Why:
- It runs
normalize-docs, thencoverage-matrix, thennormalize-docsagain withviewanddocument-title, and replays all three. - The rebuild job runs the action on 3.12, switches
setup-pythonto 3.11 and runs it again. It asks the venv's own interpreter for its version after each run.- Why: the isolation job has one Python, so it cannot test the interpreter comparison.
- A reused venv would still report 3.12, so the version proves the rebuild, where an mtime would not.
- The 3.9 job runs the action with
continue-on-errorand asserts the failure, and that no venv exists. It asserts thatPATH's Python is 3.9 before it checks the failure.- Why: otherwise it would also pass if
setup-pythonresolved something newer and the action failed for another reason.
- Why: otherwise it would also pass if
- The 3.9 job runs on
ubuntu-24.04; every other job runs onubuntu-latest.- Why:
setup-pythonpublishes no 3.9 forubuntu-latest(26.04), so the job would fail setting up Python before the action runs.
- Why:
Provisional. These rules are a pilot; the owner tunes them after this first rebuild, and a skill may replace this section. This section is their only copy in the repository.
Depth is where a page sits in the folder tree. Deeper means more detail.
| Depth | Pages | Rule |
|---|---|---|
| 1 | README.md; DEVELOPER.md, CONTRIBUTING.md and SPEC.md |
README.md is the entry: what the action is for, overview, usage tip, inputs and outputs at a glance, "where next". The other three are solo: one page each, no child pages |
| 2 | docs/<topic>.md |
a hub: principle, decision list, routing to depth 3; it names and links facts, and defines none |
| 2 leaf | docs/<topic>.md with no docs/<topic>/ folder; a package's README.md, beside its pyproject.toml |
a leaf may define, like a depth-3 page |
| 3 | docs/<topic>/<page>.md |
detail: fact lists, option lists, examples |
- One fact, one page: define a fact on the deepest page that needs it; shallower pages name it and link.
# Title.## Purpose: 1 to 3 sentences, who reads the page and what they get; thenRead before:andNext:links where the page sits on a reading path.README.mdis the exception: its Purpose says what the action does and where it sits in the pipeline. Why: GitHub shows the README as the repository's front page, so it opens with the action, not the page.
## Contents: links to every other##chapter of the page, and nothing else.- Only the parts the page needs.
| Part | Form | Checked against |
|---|---|---|
| Principle | at most 5 short sentences: the core idea | review |
| Decision list | one line per decision; the page's code map names where the code realises it | code, through the code map |
| Fact list | one line per fact the code relies on; the page's code map names its path::symbol, or a test |
code, through the code map |
| Why | one indented Why: line under a rule, decision or fact, at most 20 words |
review |
| Option list | every available option with a one-line meaning; complete | the input, output, command, option or code list in code |
| Example | at most 20 lines; run or parsed by a test | tests |
| Prose | only in Purpose, Principle and one-line list lead-ins; a block is at most 4 lines | review |
| Language | sentences of at most 20 words; active voice; one term per idea, the glossary's | review |
- The limits (prose block 4 lines, sentence 20 words, example 20 lines) are targets, not hard caps.
- Exceed one only with a reason, stated next to the block:
<!-- over limit: <reason> -->. - Keep the overrun small; split the block when you can.
- A page states facts for people and carries no
path::symbol. TheCONTEXT.mdbeside the code holds the page's code map.- A code map is a
## Code mapchapter, one line per fact:fact → path::symbol.tests/docs/pages.py::CODE_MAPSpairs each depth-2 page with its map. - Why: an agent needs the anchor; a reader needs the fact.
- A code map is a
DEVELOPER.mdmay carry anchors: its readers work in theMakefile, the workflows and the scripts.- A changed fact changes the page and its code map together.
pathis relative to the repository root, through a package'ssrc/:packages/core/src/living_doc_core/readers.py::READERS.symbolis a module-level name, or a dotted one for a member:InputProblem.__str__.- A test is
tests/<path>.py::test_name; a non-Python file names a text it contains:Makefile::qa. - Never use line numbers; they drift.
- A rule another component must realise names that component instead of an anchor:
→ utilities,→ collector-gh. - Every code-map item ends in
→and an anchor, a component or a link. README.mdis no PyPI description, so it links repository files relatively:docs/action.md.- A package's
README.mdis itspyproject.tomlreadme, but the packages never reach PyPI, so it links relatively too.
- A package's
- A
yamlblock must parse; everywith:key it gives this action must be anaction.ymlinput. - Every
uses:of this action names the same release, so a release bump changes them all. - A
jsonblock must parse; a test runs everypythonblock, each in a fresh namespace and a temporary directory. - A
shellblock is a command line, not an example; it is not run.
| Check | Where |
|---|---|
page list and depths, Purpose then Contents, Contents links, no orphan page, README links relative |
tests/docs/test_page_structure.py |
every path::symbol on a page or in a code map resolves; no line-number anchor; no anchor on a page but DEVELOPER.md; every depth-2 page has a code map, each item saying where it is realised |
tests/docs/test_anchors.py |
every example parses or runs; every with: key is an action.yml input; one release named |
tests/docs/test_examples.py |
the input and output lists equal action.yml; each input's commands equal main.py::COMMAND_INPUTS; the command list equals it and the CLI's; each command's options equal the CLI's; every code a page names is in ALL_CODES |
tests/docs/test_option_lists.py |
links and #fragments resolve |
.github/workflows/link-check.yml::lychee |
a pull request that changes only a page, a code map, action.yml, the local run's script or a file an anchor names still runs these tests |
.github/workflows/test.yml::docs_changed, tests/docs/test_ci_filter.py |
To add a page: put it at its depth, add it to tests/docs/pages.py::APPROVED_PAGES, and link it from README.md; at
depth 2, add its code map and pair the two in tests/docs/pages.py::CODE_MAPS.