diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index e6ab5ef4..d376de9a 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -23,7 +23,7 @@ ENV NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt ENV REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt ENV PIP_CERT=/etc/ssl/certs/ca-certificates.crt -# Python toolchain (uv/CPython 3.11/Poetry) baked as the vscode user, so it lands +# Python toolchain (uv/CPython 3.12/Poetry) baked as the vscode user, so it lands # in /home/vscode/.local (owned by the runtime user) and is on PATH the instant the # container starts. Same script bare WSL and CI run at runtime — one source of truth. COPY bootstrap_python.sh /tmp/bootstrap_python.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da48aa23..0690f69e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,53 @@ jobs: echo "Selected gate marker: $marker" echo "marker=$marker" >> "$GITHUB_OUTPUT" + documentation: + name: Documentation (no compiler) + runs-on: ubuntu-24.04 + timeout-minutes: 15 + needs: determine-gate + + # The documentation is derivable from the sources alone: KConfig is pure + # Python and the documents are text. Only CMake's top-level project() + # call needs a C toolchain, and nothing here goes through it. So this + # gate installs a Python and the locked dependencies, and nothing else -- + # no poks, no scoop, no cross-compiler. It is the fastest signal in the + # workflow and it covers every variant, where the build jobs cover the + # variants they build. + steps: + - name: Checkout Code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install documentation dependencies + run: | + pipx install poetry==2.4.1 + poetry config virtualenvs.in-project true + poetry install --no-root + + # `ubc` is the second reader. Without it the parity tests skip, and + # a guarantee that only holds on a developer machine is not a + # guarantee -- so the gate installs it and CI_REQUIRE_UBC turns a + # skip into a failure. No licence is needed for `check`; the inputs + # are passed anyway so that adding the secrets later just works. + - name: Install ubc + uses: useblocks/ubc-action@0.1 + with: + version: "0.35.0" + license-key: ${{ secrets.UBCODE_LICENSE_KEY }} + license-user: ${{ secrets.UBCODE_LICENSE_USER }} + + - name: Documentation gate + env: + CI_REQUIRE_UBC: "1" + run: poetry run pytest -m "docs and ${{ needs.determine-gate.outputs.marker }}" + test-on-windows: name: Build and Test on Windows runs-on: windows-2025 diff --git a/.gitignore b/.gitignore index 5ece19af..dda3759d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,11 @@ # Binary output dir, not recommended to push binary results to Git. /build +# Current-variant pointer written by tools/variant_data.py: a symlink (or, on +# Windows without Developer Mode, a copy) of the configured variant's build +# output directory. Generated output, never committed. +/generated + # Output directory of test results /test/output diff --git a/.vscode/settings.json b/.vscode/settings.json index 9d1d758d..fb6ee1a3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -14,6 +14,14 @@ "editor.defaultFormatter": "josetr.cmake-language-support-vscode" }, "cmake.configureOnOpen": false, + // Generated output. tools/variant_data.py writes the variant data here and + // CMake writes everything else; an edit under build/ is lost on the next + // configure, and worse, it looks like it worked. Making the editor refuse + // the edit is the only guard rail that also applies to an assistant. + "files.readonlyInclude": { + "build/**": true, + "generated/**": true + }, "cmake.buildDirectory": "${workspaceFolder}/build/${variant:variant}/${buildKit}/${buildType}", "cmake.copyCompileCommands": "${workspaceFolder}/build/compile_commands.json", "cmake.configureSettings": { diff --git a/.vscode/tasks.json b/.vscode/tasks.json index ccf8cedc..c02c03ce 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -27,6 +27,59 @@ }, "problemMatcher": [] }, + { + "label": "Select documentation variant", + "detail": "Point the docs and ubCode at one variant -- no CMake, no compiler", + "type": "shell", + // Always the docs cell. Choosing `reports` here would open every + // report fence in the IDE, and each one globs into build/**, which + // ubCode excludes -- so every component would report an unmatched + // glob. CMake writes the docs cell as the pointer for the same + // reason. The target choice belongs to the build task below, which + // is where it changes what is produced rather than what is linted. + "command": "${command:python.interpreterPath} tools/variant_data.py --variant ${input:variant} --kit ${input:buildKit} --target docs --current", + "problemMatcher": [], + "presentation": { + "reveal": "silent", + "panel": "shared" + } + }, + { + "label": "Generate all variant data", + "detail": "Write build/variants///.json for every variant", + "type": "shell", + "command": "${command:python.interpreterPath} tools/variant_data.py --all", + "problemMatcher": [] + }, + { + "label": "Build documentation for a variant", + "detail": "Sphinx docs for one variant, without configuring or building the software", + "type": "shell", + "command": "${command:python.interpreterPath} -m sphinx -b html . build/docs/${input:variant}", + "options": { + "env": { + "VARIANT": "${input:variant}", + "VARIANT_DATA_FILE": "build/variants/${input:variant}/${input:buildKit}/${input:docsTarget}.json" + } + }, + "problemMatcher": [] + }, + { + "label": "Documentation gate (all variants)", + "detail": "What CI's compiler-free job runs: generate every variant and build its documents", + "type": "shell", + "command": "${command:python.interpreterPath} -m pytest -m docs", + "group": "test", + "problemMatcher": [] + }, + { + "label": "Check documentation with ubc (all variants)", + "detail": "Lint every variant with the reader that never runs conf.py", + "type": "shell", + "command": "${command:python.interpreterPath} -m pytest -m docs -k ubc", + "group": "test", + "problemMatcher": [] + }, { "label": "Open variant test report", "detail": "Open the variant's overall test report in your web browser", @@ -111,6 +164,24 @@ "components/spled" ] }, + { + "type": "pickString", + "id": "buildKit", + "description": "Which build kit? (the test kit adds a variant's test suites to its component list)", + "options": [ + "test", + "prod" + ] + }, + { + "type": "pickString", + "id": "docsTarget", + "description": "Which build shape? (reports additionally shows the generated test and coverage pages)", + "options": [ + "docs", + "reports" + ] + }, { "type": "pickString", "id": "buildType", diff --git a/AGENTS.md b/AGENTS.md index 2a8d0696..28fbc68a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ On a **bare Linux host (e.g. WSL Ubuntu) that is not a devcontainer**, `build.sh ```bash sudo ./bootstrap_ubuntu.sh # root: apt packages (libc6-dev, build-essential, 7zip, pipx) -./bootstrap_python.sh # user: uv, CPython 3.11, Poetry (into ~/.local) +./bootstrap_python.sh # user: uv, CPython 3.12, Poetry (into ~/.local) # then open a fresh shell (see note below) so ~/.local/bin is on PATH: ./build.sh --install # user: poetry install + poks toolchain ``` @@ -46,6 +46,24 @@ If `poetry` is not found after `bootstrap_python.sh`, open a new shell (or `sour **Always** start VS Code with: `.\build.ps1 -startVSCode` to ensure proper environment variables and Python virtual environment activation (`.venv` with Poetry dependencies). +### Working against a local spl-core checkout + +Some changes to this project need a change in `spl-core` first. `CMakeLists.txt` +locates spl-core by importing it from the venv and then includes its +`spl.cmake`, so a single editable install redirects **both** the Python and the +CMake side at a local checkout: + +```bash +.venv/bin/python -m pip install -e ../spl-core --no-deps # point at the checkout +./build.sh --install # ...and back to the pinned release +``` + +The path is deliberately **not** committed to `pyproject.toml`: CI must keep +resolving the released spl-core, so a local override never silently becomes the +build everyone gets. Land the spl-core change upstream, release it, and bump the +pin in `pyproject.toml` — the override is for the span of one change, not a +working mode. + ### VS Code CMake Extension Configuration VS Code users can build directly using the CMake extension via `.vscode` configuration files: @@ -98,7 +116,8 @@ CI runs on **GitHub Actions** (`.github/workflows/ci.yml`) for every push/PR to Jobs: -- `determine-gate` — computes the `gate_*` quality-gate marker once (by event/branch) and shares it with all three build jobs via `needs`. +- `determine-gate` — computes the `gate_*` quality-gate marker once (by event/branch) and shares it with all four jobs via `needs`. +- `documentation` (`ubuntu-24.04`) — the compiler-free gate: a Python and the locked dependencies, then `pytest -m "docs and "`. It generates the variant data for **every** variant and builds each one's documents, where the build jobs only cover the variants they build. No poks, no scoop, no cross-compiler, so it is also the fastest signal in the workflow. - `test-on-windows` (`windows-2025`) — `build.ps1 -install` then `-selftests -marker `. - `test-on-linux` (`ubuntu-24.04`) — bare-runner path: `bootstrap_ubuntu.sh` + `bootstrap_python.sh`, then `build.sh --install` and `--selftests --marker `. - `test-devcontainer` (`ubuntu-24.04`) — builds `.devcontainer/` via `devcontainers/ci` (which runs `onCreateCommand`, i.e. `build.sh --install`) and runs `build.sh --selftests --marker ` inside the container. @@ -144,6 +163,167 @@ Edit feature config: `.\build.ps1 -command ".venv\Scripts\poetry run guiconfig"` Check feature values in source code via generated `autoconf.h` header. +## Variant-Dependent Documentation + +Documents never use Jinja. The global `source-read` pass that rendered every +document is gone, and bringing it back is a regression, not a shortcut. + +One narrowly scoped `source-read` handler does exist, and it is not that. +spl-core passes `--jinja-raw-tags` to clanguru, so generated source listings +under `__source_docs` wrap their code in `{% raw %}` markers that nothing else +removes. `conf.py` blanks those two lines, for those docnames only: a line +filter, not a template render. It goes away when `pyproject.toml` can pin an +spl-core that lets the flag be turned off -- no released version does yet. + +Everything variant-dependent is decided from **one file**: the variant data +that `tools/variant_data.py` writes, exposed as `var.*`. The governing rule: + +> **Everything a condition may name has to be IN the variant data file.** + +A key that only `conf.py` knows is invisible to ubCode, `ubc` and a reviewer's +editor, so their view of the project silently disagrees with the build — +silently, because a condition a tool cannot evaluate gates content **off** +rather than failing. That is why `conf.py` reads the file and adds nothing to +it. + +### The three mechanisms + +**Whole documents: `[[source.variant_sources]]` in `ubproject.toml`.** One rule +per component, gated on membership of the variant's component list: + +```toml +[[source.variant_sources]] +if = "'components/auto_off' in var.build_config.components" +files = [ + "components/auto_off/doc/**", + "generated/components/auto_off/reports/**", + "generated/components/auto_off/__source_docs/**", +] +``` + +Membership, never identity. The component list comes from that variant's +`parts.cmake`, so the product structure is stated once, in the file that already +states it. **Never gate on the variant name** — that is a second encoding of the +same fact, free to drift. + +Rules are *subtractive*: a FALSE rule removes the files it names, a TRUE rule +does nothing. Two rules naming the same file therefore compose as AND, which is +how generated output is gated on both the build shape and the component. + +**Blocks inside a document: the `{if}` directive of Sphinx-Needs.** Four-backtick +fence, condition as the argument: + +````text +````{if} var.features.BLINKING +... +```` +```` + +Content behind a false condition is never parsed, so its needs never enter the +traceability data. + +**External trees: `[[source.mounts]]`.** Currently unused. Everything this +project shows lives in the tree or under `generated/`. + +### What the data holds + +| Key | | +| --- | --- | +| `var.features.*` | every KConfig symbol, with **every** declared boolean present — including the promptless ones KConfig omits when they are off | +| `var.build_config.variant` | e.g. `Disco`, `Base/Dev` | +| `var.build_config.kit` | `prod` or `test` | +| `var.build_config.target` | `docs` or `reports` | +| `var.build_config.components` | the variant's component list, from `parts.cmake` | + +### Two differences between the engines + +The `{if}` directive takes a real Python expression, so a bare +`var.features.BLINKING` is enough. A `variant_sources` condition uses a +restricted grammar that needs `== True`. And a condition that cannot be +evaluated **excludes** what it gates, so a typo silently shrinks the document +set rather than failing loudly — which is what `test_ubproject_config.py` is +for. + +### Checking with the other reader + +The Sphinx build is only half the story: the point of keeping everything in the +variant data file is that a reader which never runs `conf.py` decides the same +things. `ubc` is that reader, and `pytest -m docs -k ubc` proves it — per +variant, it asserts that ubCode removes **exactly** the component documents the +variant's component list omits. + +`ubc` ships inside the ubCode VS Code extension and is on neither PyPI nor npm, +so there is no install step this repository can own. The tests find it on +`PATH`, via the `UBC` environment variable, or in the extension directory, and +**skip** when it is absent rather than pretending to cover it: + +```bash +export UBC="$HOME/.vscode/extensions/useblocks.ubcode-0.35.0-darwin-arm64/server/cli/ubc" +pytest -m docs -k ubc +``` + +To look at one variant by hand, override the data file rather than switching +the project: + +```bash +ubc check -c "needs.variant_data_file = 'build/variants/Sleep/test/docs.json'" +``` + +Two things to know about `ubproject.toml` when editing it. Configuring parsers +puts ubCode in **parser mode**, where the document set comes from each +`[parse.parsers.*].include` and `[source] extend_include` is *ignored* — so the +parser includes have to stay in step with `include_patterns` in `conf.py`, or +the two readers are looking at different files. And `[[source.variant_sources]]` +rules are implemented as exclusions, which is why a rule that cannot be +evaluated removes what it gates. + +### Adding a component + +1. Add it to the variant's `parts.cmake`. +2. Add one `[[source.variant_sources]]` rule in `ubproject.toml`. +3. Add one line to the 150% toctree in `doc/components/index.md`. + +Nothing is generated, no loop is edited, and nothing under `build/` is touched. + +### Generated output + +`build/` and `generated/` are output. **Nobody edits them — not a person, not an +assistant.** The editor is configured to refuse it (`files.readonlyInclude`) and +`build/variants/GENERATED` says so on disk. + +`generated/` is the configured variant's build directory, and today **nothing +reads it**. The report toctrees still glob `/build/**`, and `conf.py` narrows +the source set to the configured build so each glob resolves to one page. + +That is a deferral, not the end state. A fixed path under `generated/` would be +better, and the configuration for it is already in place -- the rst parser +include and the `generated/...` entries in every variant rule. It cannot be +switched on from this repository: spl-core writes the gcovr tree at +`reports/html//coverage/index.html` and looks its +report artifacts up in the same place, so moving the page that links to it +without moving the tree breaks every coverage link. + +When spl-core does write the tree relative to the page, the `build/` forwarding +in `conf.py` and the `generated` entry in its `exclude_patterns` have to go in +the *same* change, or Sphinx discovers every report page under both names. +`test_generated_and_build_discovery_are_never_both_live` fails if only half of +that is done. + +Regenerate without a compiler — KConfig is pure Python, and CMake's top-level +`project()` call demands a C toolchain before it will configure at all: + +```bash +python tools/variant_data.py --all # the whole matrix +python tools/variant_data.py --variant Sleep --kit test # ...and point at one cell +python tools/variant_data.py --all --check # CI: regenerate and diff +``` + +Preview a variant by pointing the build at its cell: + +```bash +VARIANT_DATA_FILE=build/variants/Sleep/test/docs.json sphinx-build -b html . out +``` + ## Project-Specific Conventions 1. **No direct CMake invocation**: Always use `build.ps1` wrapper (handles variant selection, environment, Poetry, etc.) diff --git a/CMakeLists.txt b/CMakeLists.txt index 426e58ac..222fc752 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -46,14 +46,74 @@ else() endif() include("${SPL_CORE_DIR}/spl.cmake") -# Mirror the currently configured variant's KConfig JSON to a fixed path, the same -# way cmake.copyCompileCommands mirrors compile_commands.json, so tools outside CMake -# (e.g. ubproject.toml's variant_data_file) can track whichever variant/build kit/build -# type is currently selected in the CMake Tools extension without a hardcoded path. -if(EXISTS ${AUTOCONF_JSON}) - configure_file(${AUTOCONF_JSON} ${CMAKE_SOURCE_DIR}/build/autoconf.json COPYONLY) +# Variant data: one generation step, everything downstream declarative. +# +# tools/variant_data.py writes build/variants///.json for +# every variant, plus build/autoconf.json as the "current" pointer and a +# `generated` symlink to this build directory. It is invoked here so that +# configuring in the IDE keeps the pointer in step with whatever CMake Tools +# selected -- the same role cmake.copyCompileCommands plays for the compilation +# database. +# +# It is deliberately a standalone script and not CMake code. KConfig is pure +# Python, while the top-level project() call above demands a C toolchain before +# CMake will configure at all, so anything expressed here could only ever run on +# a machine that can build the software. The documentation, its quality gate and +# the IDE must not need a compiler, so the same script has to be runnable on its +# own: +# +# python tools/variant_data.py --all +# +# Everything a condition in ubproject.toml or an {if} directive may name lives in +# these files -- the complete feature vector including the booleans KConfig omits, +# the variant, the build kit, the build target and the component list. A key that +# only conf.py knew would be invisible to ubCode and every other reader, and their +# view of the project would then silently disagree with the build. +set(_SPLED_VARIANT_DATA_SCRIPT ${CMAKE_SOURCE_DIR}/tools/variant_data.py) +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS + ${_SPLED_VARIANT_DATA_SCRIPT} + ${CMAKE_SOURCE_DIR}/variants/${VARIANT}/parts.cmake +) +execute_process( + COMMAND "${_VENV_PYTHON}" ${_SPLED_VARIANT_DATA_SCRIPT} + --variant ${VARIANT} + --kit ${BUILD_KIT} + --target docs + --current + --build-dir ${CMAKE_BINARY_DIR} + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + RESULT_VARIABLE _spled_variant_data_result + OUTPUT_VARIABLE _spled_variant_data_output + ERROR_VARIABLE _spled_variant_data_output +) +if(NOT _spled_variant_data_result EQUAL 0) + message(FATAL_ERROR "tools/variant_data.py failed:\n${_spled_variant_data_output}") endif() +# Publish the configured variant's two cells under fixed names, so a Sphinx +# build can find the one matching its build shape without being told the variant +# or the kit. conf.py reads these; `build/autoconf.json` (the docs cell) stays +# the pointer ubCode reads, so the IDE and the docs build see identical data and +# the report fences resolve to a clean false in the IDE rather than being +# undecidable. +# +# Fixed names rather than an environment variable because spl-core only passes +# one in from 8.9 onwards, and this project builds against the version pinned in +# pyproject.toml. Where that variable IS passed, conf.py prefers it. +configure_file( + ${CMAKE_SOURCE_DIR}/build/variants/${VARIANT}/${BUILD_KIT}/docs.json + ${CMAKE_SOURCE_DIR}/build/variant-data-docs.json COPYONLY) +configure_file( + ${CMAKE_SOURCE_DIR}/build/variants/${VARIANT}/${BUILD_KIT}/reports.json + ${CMAKE_SOURCE_DIR}/build/variant-data-reports.json COPYONLY) + +# Honoured by spl-core 8.9+; harmlessly unused before that, which is why the +# fixed-name files above exist. +set(SPL_VARIANT_DATA_FILE_DOCS + ${CMAKE_SOURCE_DIR}/build/variant-data-docs.json CACHE FILEPATH "" FORCE) +set(SPL_VARIANT_DATA_FILE_REPORTS + ${CMAKE_SOURCE_DIR}/build/variant-data-reports.json CACHE FILEPATH "" FORCE) + # The object_deps_report extension is currently Windows-only: its index.cmake # hardcodes the runner as "object_deps_report.exe", which does not exist on # Linux/macOS (the console script is "object_deps_report"). Guard the include to diff --git a/README.md b/README.md index d8cfd2cc..dca9cdc5 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Provision the OS-level prerequisites once per machine, then install as usual: ```bash sudo ./bootstrap_ubuntu.sh # apt packages -./bootstrap_python.sh # uv, CPython 3.11, Poetry (into ~/.local) +./bootstrap_python.sh # uv, CPython 3.12, Poetry (into ~/.local) # open a fresh shell (or `source ~/.bashrc`) so ~/.local/bin is on PATH ./build.sh --install ``` diff --git a/bootstrap.json b/bootstrap.json index daa30d4f..86479e54 100644 --- a/bootstrap.json +++ b/bootstrap.json @@ -1,5 +1,5 @@ { - "python_version": "3.11", + "python_version": "3.12", "python_package_manager": "poetry>=2.1.0", "bootstrap_packages": [ "pip-system-certs>=4.0,<5.0" diff --git a/bootstrap_python.sh b/bootstrap_python.sh index 020d851f..f6af5a0e 100755 --- a/bootstrap_python.sh +++ b/bootstrap_python.sh @@ -1,7 +1,7 @@ #!/bin/bash # User-level Python toolchain for SPLed (no root / no sudo). -# Installs uv, a standalone CPython 3.11, and Poetry into the current user's -# ~/.local, then points Poetry at the uv 3.11 for the project virtualenv. +# Installs uv, a standalone CPython 3.12, and Poetry into the current user's +# ~/.local, then points Poetry at the uv 3.12 for the project virtualenv. # uv and Poetry are version-pinned below; bump them there. # Run AFTER bootstrap_ubuntu.sh (which provides pipx): # - the devcontainer bakes it into the image at build time (.devcontainer/Dockerfile) @@ -15,9 +15,11 @@ set -euo pipefail # line is what introduced the bare-`python` PATH probe worked around below. UV_VERSION="0.11.32" POETRY_VERSION="2.4.1" -# Minor only: the 3.11 patch level deliberately floats so CPython security fixes are -# picked up. pyproject requires >=3.11,<3.12, so any 3.11.x satisfies the project. -PYTHON_VERSION="3.11" +# Minor only: the 3.12 patch level deliberately floats so CPython security fixes are +# picked up. pyproject requires >=3.12,<3.14, and 3.12 is the minor this project is +# built and gated on -- one interpreter everywhere, so a path computed from the minor +# (a venv site-packages path, a tool config) cannot disagree with the venv that exists. +PYTHON_VERSION="3.12" # pipx (from bootstrap_ubuntu.sh) installs isolated CLI tools into ~/.local/bin. # --force on both installs below: pipx keys on the package NAME, not the version spec, @@ -28,7 +30,7 @@ pipx ensurepath # ~/.local/bin on PATH for future shells export PATH="$HOME/.local/bin:$PATH" # ...and for the rest of this script # Standalone CPython (prebuilt: no from-source build, no GPG keyserver; -# matches pyproject requires-python <3.12,>=3.11). +# matches pyproject requires-python >=3.12,<3.14). uv python install "$PYTHON_VERSION" uv_py="$(uv python find "$PYTHON_VERSION")" @@ -36,7 +38,7 @@ uv_py="$(uv python find "$PYTHON_VERSION")" # interpreter — verified that `virtualenvs.use-poetry-python = true` does NOT suppress # this. The base image ships no bare `python`, so `poetry install` dies with # "[Errno 2] No such file or directory: 'python'". Give it one, pointing at the -# uv-managed 3.11 (which matches pyproject's requires-python). This is the same thing +# uv-managed 3.12 (which matches pyproject's requires-python). This is the same thing # the pre-refactor image achieved; we just keep it in ~/.local instead of /usr/local. # # ONLY `python` — deliberately NOT `python3`: every host this runs on already has a @@ -50,7 +52,7 @@ pipx install --force --python "$uv_py" "poetry==$POETRY_VERSION" # in-project set GLOBALLY (no --local): this script also runs at image-build time, where # the CWD is not a writable project dir. build.sh --install re-applies `--local` in the -# workspace anyway. use-poetry-python additionally pins Poetry's own uv-3.11 as the venv +# workspace anyway. use-poetry-python additionally pins Poetry's own uv-3.12 as the venv # interpreter (belt-and-suspenders alongside the `python` symlink above). poetry config virtualenvs.in-project true poetry config virtualenvs.use-poetry-python true diff --git a/components/auto_off/doc/index.md b/components/auto_off/doc/index.md index 832a49e1..c37df2df 100644 --- a/components/auto_off/doc/index.md +++ b/components/auto_off/doc/index.md @@ -1,9 +1,6 @@ -# Software Detailed Design +# Auto Off -```{toctree} -:maxdepth: 2 -:caption: Table of Contents -``` +**Software Detailed Design** ## Introduction @@ -30,16 +27,14 @@ The Auto Off Controller monitors three types of user input to determine system a Any of these inputs will reset the inactivity timer. ``` -{% if config.AUTO_OFF %} - +````{if} var.features.AUTO_OFF ```{spec} Configurable Timeout Period :id: SWDD_AO-102 :refines: SWARCH_001 The auto off timeout period is configurable through CONFIG_AUTO_OFF_PERIOD_SECONDS, with a valid range of 5 to 7200 seconds (5 seconds to 2 hours). ``` - -{% endif %} +```` ```{spec} Timer Countdown Behavior :id: SWDD_AO-103 @@ -92,16 +87,14 @@ The Auto Off Controller shall use the RTE interface `RteIsKeyPressed()` to monit Any activity detected from these monitored keys will reset the inactivity timer. ``` -{% if config.AUTO_OFF %} - +````{if} var.features.AUTO_OFF ```{spec} Auto Off State Output :id: SWDD_AO-205 :refines: SWARCH_001 The Auto Off Controller shall use the RTE interface `RteSetAutoOffState()` to communicate the current auto off state to other system components. The state is set to FALSE when the system is active and TRUE when the timeout period has elapsed. ``` - -{% endif %} +```` ## Timing Behavior @@ -128,3 +121,18 @@ The internal timer calculation follows the formula: Key press detection and timer reset occur within one execution cycle (CONFIG_OS_TASK_PERIOD), ensuring immediate response to user activity. ``` + +````{if} var.build_config.target == "reports" + +## Verification + +```{toctree} +:maxdepth: 1 +:glob: + +/build/**/components/auto_off/reports/unit_test_spec +/build/**/components/auto_off/reports/unit_test_results +/build/**/components/auto_off/reports/coverage +``` + +```` diff --git a/components/brightness_controller/doc/index.md b/components/brightness_controller/doc/index.md index 36dd1db8..f6c099a6 100644 --- a/components/brightness_controller/doc/index.md +++ b/components/brightness_controller/doc/index.md @@ -1,9 +1,6 @@ -# Software Detailed Design +# Brightness Controller -```{toctree} -:maxdepth: 2 -:caption: Table of Contents -``` +**Software Detailed Design** ## Introduction @@ -71,3 +68,18 @@ The Brightness Controller is use the RTE interface `RteSetBrightnessAdjustmentCo ```{src-trace} :project: brightness_controller ``` + +````{if} var.build_config.target == "reports" + +## Verification + +```{toctree} +:maxdepth: 1 +:glob: + +/build/**/components/brightness_controller/reports/unit_test_spec +/build/**/components/brightness_controller/reports/unit_test_results +/build/**/components/brightness_controller/reports/coverage +``` + +```` diff --git a/components/examples/flight_controller/doc/index.md b/components/examples/flight_controller/doc/index.md index bf5c8302..c4682094 100644 --- a/components/examples/flight_controller/doc/index.md +++ b/components/examples/flight_controller/doc/index.md @@ -1,11 +1,5 @@ # Software Detailed Design -```{toctree} -:maxdepth: 2 -:caption: Contents -:class: toc -``` - ## Introduction The Flight Controller module is responsible for evaluating mission abort conditions and triggering the SelfDestruct system if necessary. It demonstrates a clear MC/DC coverage example. diff --git a/components/light_controller/doc/index.md b/components/light_controller/doc/index.md index eea06dc0..6fa4ed27 100644 --- a/components/light_controller/doc/index.md +++ b/components/light_controller/doc/index.md @@ -1,9 +1,6 @@ -# Software Detailed Design +# Light Controller -```{toctree} -:maxdepth: 2 -:caption: Table of Contents -``` +**Software Detailed Design** ## Introduction @@ -18,16 +15,14 @@ The Light Controller is responsible for managing the behavior of the LED based o The light can be in one of two states: ON or OFF. The state transitions are triggered by changes in the system's power state. ``` -{% if config.BLINKING %} - +````{if} var.features.BLINKING ```{spec} Blinking Behavior :id: SWDD_LC-101 :refines: SWARCH_001 When the light is ON, it may exhibit a blinking behavior. The blinking rate is configurable and is determined based on an external input (main knob value). ``` - -{% endif %} +```` ```{spec} Color Management :id: SWDD_LC-102 @@ -67,27 +62,23 @@ The Light Controller uses the RTE interface `RteGetPowerState()` to get the curr The Light Controller uses the RTE interface `RteSetLightValue()` to set the light color. ``` -{% if config.BLINKING %} - +````{if} var.features.BLINKING ```{spec} Main Knob Input :id: SWDD_LC-203 :refines: SWARCH_001 The Light Controller uses the RTE interface `RteGetMainKnobValue()` to get the main knob value for controlling the blinking rate. ``` +```` -{% endif %} - -{% if config.BRIGHTNESS_ADJUSTMENT_ENABLED %} - +````{if} var.features.BRIGHTNESS_ADJUSTMENT_ENABLED ```{spec} Brightness Adjustment :id: SWDD_LC-204 :refines: SWARCH_001 The Light Controller uses the RTE interface `RteGetBrightnessValue()` to get the required brightness value for the light. ``` - -{% endif %} +```` ## Internal Behavior @@ -98,16 +89,40 @@ The Light Controller uses the RTE interface `RteGetBrightnessValue()` to get the The Light Controller is implemented as a state machine. The state machine is shown below. ``` +````{if} var.features.BLINKING ```{mermaid} stateDiagram-v2 [*] --> LIGHT_OFF: Initial State LIGHT_OFF --> LIGHT_ON : Power State != OFF LIGHT_ON --> LIGHT_OFF : Power State == OFF -{% if config.BLINKING %} LIGHT_ON --> BlinkON : Blink Counter >= Blink Period BlinkON --> BlinkOFF : Blink State == TRUE BlinkOFF --> BlinkON : Blink State == FALSE BlinkON --> LIGHT_ON : Reset Blink Counter BlinkOFF --> LIGHT_ON : Reset Blink Counter -{% endif %} ``` +```` + +````{if} not var.features.BLINKING +```{mermaid} +stateDiagram-v2 + [*] --> LIGHT_OFF: Initial State + LIGHT_OFF --> LIGHT_ON : Power State != OFF + LIGHT_ON --> LIGHT_OFF : Power State == OFF +``` +```` + +````{if} var.build_config.target == "reports" + +## Verification + +```{toctree} +:maxdepth: 1 +:glob: + +/build/**/components/light_controller/reports/unit_test_spec +/build/**/components/light_controller/reports/unit_test_results +/build/**/components/light_controller/reports/coverage +``` + +```` diff --git a/components/main_control_knob/doc/index.md b/components/main_control_knob/doc/index.md index 40b2b290..8791758f 100644 --- a/components/main_control_knob/doc/index.md +++ b/components/main_control_knob/doc/index.md @@ -1,9 +1,6 @@ -# Software Detailed Design +# Main Control Knob -```{toctree} -:maxdepth: 2 -:caption: Table of Contents -``` +**Software Detailed Design** ## Introduction @@ -54,3 +51,18 @@ The main control knob component uses the RTE interface `RteGetMainKnobValue()` t The main control knob component uses the RTE interface `RteSetMainKnobValue()` to set the new value of the main control knob. ``` + +````{if} var.build_config.target == "reports" + +## Verification + +```{toctree} +:maxdepth: 1 +:glob: + +/build/**/components/main_control_knob/reports/unit_test_spec +/build/**/components/main_control_knob/reports/unit_test_results +/build/**/components/main_control_knob/reports/coverage +``` + +```` diff --git a/components/power_button/doc/index.md b/components/power_button/doc/index.md index 2359cc84..f28bde6c 100644 --- a/components/power_button/doc/index.md +++ b/components/power_button/doc/index.md @@ -1,9 +1,6 @@ -# Software Detailed Design +# Power Button -```{toctree} -:maxdepth: 2 -:caption: Table of Contents -``` +**Software Detailed Design** ## Introduction @@ -72,3 +69,18 @@ stateDiagram-v2 PRESSED --> RELEASED: Button released long enough RELEASED --> PRESSED: Button pressed long enough ``` + +````{if} var.build_config.target == "reports" + +## Verification + +```{toctree} +:maxdepth: 1 +:glob: + +/build/**/components/power_button/reports/unit_test_spec +/build/**/components/power_button/reports/unit_test_results +/build/**/components/power_button/reports/coverage +``` + +```` diff --git a/components/power_signal_processing/doc/index.md b/components/power_signal_processing/doc/index.md index ccf1b8eb..875f2e0b 100644 --- a/components/power_signal_processing/doc/index.md +++ b/components/power_signal_processing/doc/index.md @@ -1,4 +1,6 @@ -# Software Detailed Design +# Power Signal Processing + +**Software Detailed Design** This module is responsible for processing power signals based on key presses. @@ -43,8 +45,7 @@ If the retrieved power state is POWER_STATE_OFF, the function shall set the powe If the retrieved power state is not POWER_STATE_OFF, the function shall set the power state to POWER_STATE_OFF. ``` -{% if config.AUTO_OFF %} - +````{if} var.features.AUTO_OFF ```{spec} Auto off event handling :id: SWDD_PSP-004 :refines: SWARCH_001 @@ -60,11 +61,35 @@ When no power key is pressed and the auto off state is TRUE, the function shall When no power key is pressed and the auto off state is FALSE, the function shall take no action regarding power state changes. ``` - -{% endif %} +```` ## Function Flow +````{if} var.features.AUTO_OFF +```{mermaid} +graph TD + Start[Start] + KeyCheck{Is 'P' key pressed?} + GetState{Get current power state} + IsOff{Is state OFF?} + TurnOn[Set state to ON] + TurnOff[Set state to OFF] + AutoOffCheck{Is auto off state TRUE?} + AutoOffPowerDown[Set state to OFF] + End[End] + + Start --> KeyCheck + KeyCheck -->|Yes| GetState + KeyCheck -->|No| AutoOffCheck + GetState --> IsOff + IsOff -->|Yes| TurnOn --> End + IsOff -->|No| TurnOff --> End + AutoOffCheck -->|Yes| AutoOffPowerDown --> End + AutoOffCheck -->|No| End +``` +```` + +````{if} not var.features.AUTO_OFF ```{mermaid} graph TD Start[Start] @@ -73,16 +98,28 @@ graph TD IsOff{Is state OFF?} TurnOn[Set state to ON] TurnOff[Set state to OFF] - {% if config.AUTO_OFF %}AutoOffCheck{Is auto off state TRUE?} - AutoOffPowerDown[Set state to OFF]{% endif %} End[End] Start --> KeyCheck KeyCheck -->|Yes| GetState - KeyCheck -->|No| {% if config.AUTO_OFF %}AutoOffCheck{% else %}End{% endif %} + KeyCheck -->|No| End GetState --> IsOff IsOff -->|Yes| TurnOn --> End IsOff -->|No| TurnOff --> End - {% if config.AUTO_OFF %}AutoOffCheck -->|Yes| AutoOffPowerDown --> End - AutoOffCheck -->|No| End{% endif %} ``` +```` + +````{if} var.build_config.target == "reports" + +## Verification + +```{toctree} +:maxdepth: 1 +:glob: + +/build/**/components/power_signal_processing/reports/unit_test_spec +/build/**/components/power_signal_processing/reports/unit_test_results +/build/**/components/power_signal_processing/reports/coverage +``` + +```` diff --git a/conf.py b/conf.py index 5c304dd6..9a8abf05 100644 --- a/conf.py +++ b/conf.py @@ -3,7 +3,8 @@ import datetime import os -from importlib.resources import files +from pathlib import Path + from spl_core.report_generation.spl_sphinx import SplSphinx from spl_core.report_generation.spl_html_settings import html_theme, html_show_sourcelink, html_theme_options, html_sidebars, html_last_updated_fmt # noqa: F401 @@ -28,9 +29,34 @@ ".venv", ".git", "**/test_results.rst", # We renamed this file, but nobody deletes it. + # `generated` is a symlink to the configured variant's build directory, and + # Sphinx walks with followlinks=True -- so without this it descends the + # whole build tree a second time, under a second set of paths that the + # `build/...` exclusions above do not match. Excluding it prunes the walk + # (get_matching_files applies exclude_patterns to directories, not just + # files), which halves the scan on a small build directory and more on a + # real one. + # + # It also means Sphinx CANNOT discover anything through `generated`, which + # is the invariant test_ubproject_config.py guards: the report pages are + # discovered through spl-core's `build/` patterns instead, and exactly one + # of those two routes may ever be live or every page exists twice. + "generated", ] -include_patterns = ["index.md", "doc/**"] +# The 150% source set: every hand-written document the product line has. +# +# Which of them the build actually contains is decided by the +# [[source.variant_sources]] rules in ubproject.toml, against the variant data. +# Narrowing the set here as well would put a second, invisible gate in front of +# the declared one -- and an invisible gate is the thing this whole design +# exists to remove. +include_patterns = [ + "index.md", + "doc/**", + "components/**/doc/**", + "test/**/doc/**", +] # configuration of built-in stuff ########################################### # @see https://www.sphinx-doc.org/en/master/usage/configuration.html @@ -40,8 +66,11 @@ # html config ############################################################### # @see https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output -# Omit "documentation" in title -html_title = f"{project} {release}" +# Omit "documentation" in title. Include the variant, so that a browser tab or +# the sidebar logo tells two variants' builds apart -- both otherwise render the +# same generic title and are indistinguishable when opened side by side. +_html_title_variant = os.environ.get("VARIANT", "") +html_title = f"{project} {_html_title_variant} {release}".strip() if _html_title_variant else f"{project} {release}" html_logo = "doc/_figures/SPLED_logo.png" @@ -57,52 +86,153 @@ myst_enable_extensions = extension_configs["myst_enable_extensions"] source_suffix = extension_configs["source_suffix"] -# Import default SPL sphinx-needs configuration -needs_from_toml = str(files("spl_core.report_generation").joinpath("ubproject.toml")) - -needs_fields = { - "image": { - "description": "Image associated with the need", - "schema": { - "type": "string" - }, - "nullable": True, - }, -} - -# Additional import required because the configuration references custom functions defined in this module +# The needs model -- types, link types, fields, and the build_json switch -- +# lives in ubproject.toml, which ubCode and ubc read directly. sphinx-needs +# reads exactly one TOML file and does not implement ubCode's `extend`, so the +# only way both readers can agree is for that one file to be complete. It is; +# spl-core's base configuration is vendored into it. +needs_from_toml = "ubproject.toml" + +# The same file, named again for the other extension that reads it. sphinx-mounts +# already defaults to this path; naming it makes the coupling visible in conf.py +# rather than resting on a library default. +sources_from_toml = "ubproject.toml" + +# Registers project Python that the configuration references by name. This is +# the last thing in the needs model that ubCode cannot see, because it cannot +# run project functions; it goes away with sple_tr_link. needs_functions = SplSphinx.default_needs_functions needs_global_options = SplSphinx.default_needs_global_options -# Always write the merged needs.json (all needs, after import/resolution) to the build output dir. -needs_build_json = True - -# Expose KConfig feature values (e.g. CUSTOMER) as `var.features.*` for the {if} directive -if "AUTOCONF_JSON_FILE" in os.environ: - needs_variant_data_file = os.environ["AUTOCONF_JSON_FILE"] - -# Provide all config values to jinja -html_context = SplSphinx.get_default_html_context() -include_patterns.extend(html_context["build_config"].get("include_patterns", [])) +# variant data ############################################################## +# +# ONE file, and nothing computed here. +# +# Everything a document's {if} directive or a condition in ubproject.toml may +# name -- the complete feature vector, the variant, the build kit, the build +# target, the component list -- is written by tools/variant_data.py and read +# from here verbatim. That is the whole point: ubCode, ubc and a reviewer's +# editor cannot execute this file, so anything synthesized here would be +# invisible to them and their view of the project would silently disagree with +# the build. The rule is "everything a condition may name has to be IN the +# file", and it only holds if this file adds nothing. +# +# Which cell of the matrix this build reads. Selecting a file is not the same as +# synthesizing data: the file is complete and generated, and nothing here adds a +# key to it. +# +# Three sources, in order. VARIANT_DATA_FILE if something passed one -- spl-core +# does from 8.9, and the tests do. Otherwise the fixed-name cell that CMake +# publishes for this build shape, which spl-core tells us via the directory its +# per-target configuration file sits in. Otherwise nothing, and the pointer named +# in ubproject.toml applies, which is what a bare `sphinx-build` and the IDE get. +# +# Without the middle step the reports build would quietly read the docs cell and +# every report fence would evaluate false -- a reports target with no reports in +# it, and no error anywhere. +# spl-core names the build shape by the directory holding the per-target +# configuration file it points us at. Derived once: the variant data file and +# the source set both depend on it, and two derivations of one fact drift. +_shape = "reports" if Path(os.environ.get("SPHINX_BUILD_CONFIGURATION_FILE", "")).parent.name == "reports" else "docs" + +_variant_data_file = os.environ.get("VARIANT_DATA_FILE") +if not _variant_data_file: + _published = Path(__file__).parent / "build" / f"variant-data-{_shape}.json" + if _published.is_file(): + _variant_data_file = str(_published) + +# build shape ############################################################### +# +# The rest of this file is Sphinx plumbing, not variant data: which documents +# are in the source set and which one is the root. No other tool needs to +# decide these, and none of it may leak into `var.*`. + +_build_config = SplSphinx.get_default_html_context()["build_config"] + +# Two shapes of build use this configuration: the variant-wide one, and the +# per-component report that spl-core builds for a single component. This is the +# only build-shape decision left here, and it is a Sphinx one -- which document +# is the root -- not variant data. No other reader has to decide it. +if _build_config.get("component_info"): + root_doc = "doc/component_report" + exclude_patterns.append("index.md") + # A per-component report builds one component and stitches its own table of + # contents, so the project-wide variant rules do not apply to it. + sources_from_toml = None + include_patterns.extend(_build_config.get("include_patterns", [])) +else: + root_doc = "index" + exclude_patterns.append("doc/component_report.md") + # The generated pages of the CONFIGURED variant, named by spl-core for the + # build it is running. This is what makes the `/build/**` globs in the report + # sections resolve to one page each rather than one per variant on disk. + # + # A stable path would be better and `generated` exists for it, but spl-core + # writes the gcovr tree at `reports/html//coverage` + # and looks its report artifacts up there too. Moving the page without moving + # those breaks the coverage link, so the stable path waits for the spl-core + # change. Only the hand-written component trees are dropped, because the + # variant rules already own those; keeping them here would parse every need + # in them a second time under a second docname. + # + # The report pages are admitted ONLY by the reports shape. spl-core writes + # unit_test_spec.rst, unit_test_results.rst and coverage.rst at configure + # time and lists them for both shapes, so a docs build would read all three + # per component and reference none of them -- the fences that would have + # linked them are false in a docs build. That is three orphan warnings per + # component, fifteen on Spa, and no reader is better off for it. + include_patterns.extend( + pattern + for pattern in _build_config.get("include_patterns", []) + if pattern.startswith("build/") + and (_shape == "reports" or not pattern.endswith("/reports/**")) + ) + + +# generated source listings ################################################## +# +# COMPATIBILITY SHIM, not a return of the Jinja pass. +# +# spl-core passes --jinja-raw-tags to clanguru, so every generated listing under +# __source_docs wraps its code-block in `{% raw %}` / `{% endraw %}` lines. The +# only thing that ever consumed those markers was the global Jinja `source-read` +# hook this project deleted, so without this they render as two literal +# paragraphs on every listing page in a reports build. +# +# This is a line filter, not a template render. Nothing is evaluated; no brace +# anywhere else in the project is touched; hand-written documents are not seen +# at all. The markers are replaced by EMPTY LINES rather than removed, so the +# file keeps its line count and a warning about a generated page still points at +# the right line -- the source-mapping breakage was one of the reasons the Jinja +# pass had to go, and re-creating it here would be missing the point. +# +# REMOVE THIS once pyproject.toml pins an spl-core that lets the flag be turned +# off (`SPL_SOURCE_DOCS_JINJA_RAW_TAGS`). No released version does today: 8.8.0 +# is the newest stable and 9.0.1rc4 the newest prerelease, and both hardcode it. +# Until then this is load-bearing, not a TODO. +_JINJA_RAW_MARKERS = frozenset({"{% raw %}", "{% endraw %}"}) + + +def _strip_jinja_raw_markers(app, docname, source): + if "__source_docs/" not in f"{docname}/": + return + lines = source[0].splitlines(keepends=True) + if not any(line.strip() in _JINJA_RAW_MARKERS for line in lines): + return + source[0] = "".join( + ("\n" if line.endswith("\n") else "") if line.strip() in _JINJA_RAW_MARKERS else line + for line in lines + ) -build_config = html_context["build_config"].copy() -build_config.pop("components_info", None) -needs_variant_data = { - "build_config": build_config, -} +def setup(app): + """Register the two handlers this configuration needs.""" + app.connect("source-read", _strip_jinja_raw_markers) -def rstjinja(app, docname, source): - """ - Render our pages as a jinja template for fancy templating goodness. - """ - # Make sure we're outputting HTML - if app.builder.format != "html": + if not _variant_data_file: return - src = source[0] - rendered = app.builder.templates.render_string(src, app.config.html_context) - source[0] = rendered + def _select_variant_data(app, config): + config.needs_variant_data_file = _variant_data_file -def setup(app): - app.connect("source-read", rstjinja) + app.connect("config-inited", _select_variant_data, priority=20) diff --git a/doc/component_report.md b/doc/component_report.md new file mode 100644 index 00000000..ba070bd3 --- /dev/null +++ b/doc/component_report.md @@ -0,0 +1,23 @@ +# Software Component Report + +**Variant:** {variant}`build_config.variant` + +This is the root document of the per-component report that spl-core builds for +a single component. The build configuration restricts the source set to that +one component, so the glob below resolves to exactly its design document, which +in turn groups that component's verification pages -- and names the component, +which is why this page does not. + +The component name is deliberately not read from `build_config.component_info`. +That key is written per BUILD, by spl-core, for one component; the variant data +every other reader has cannot contain it, because the generator does not know +which component a per-component report is for. A role naming it resolves inside +that one build and nowhere else, which is the divergence between readers this +project's configuration exists to prevent. + +```{toctree} +:maxdepth: 2 +:glob: + +/**/doc/index +``` diff --git a/doc/components/index.md b/doc/components/index.md index a72d081b..9ca703b1 100644 --- a/doc/components/index.md +++ b/doc/components/index.md @@ -1,20 +1,34 @@ # Components -{% for component_info in build_config.components_info %} -{% if component_info.has_docs %} +This page lists every component the product line has. It is a 150% view: the +entries are always all of them, and the ones the built variant does not contain +are removed from the build by the `[[source.variant_sources]]` rules in +`ubproject.toml`, each gated on membership of that variant's component list. -## {{ component_info.long_name or component_info.name }} +So this page needs no templating and no generated content. Adding a component +to the report means adding it to a variant's `parts.cmake` and adding one rule +plus one line here — never editing a loop, and never editing anything under +`build/`. + +An entry naming a document the current variant excludes is reported as INFO by +both Sphinx and ubCode, by design: the 150% tree is the source of truth, and a +variant showing less of it is the normal case rather than an error. + +Each entry is a group. A component's own document is the entry point and +carries the toctree for its verification pages, so unit test results and +coverage appear underneath the component they belong to rather than in one flat +list. ```{toctree} :maxdepth: 2 -/{{ component_info.path }}/doc/index -{% if (build_config.target == 'reports') and component_info.has_reports %} -/{{ component_info.reports_output_dir }}/unit_test_results -/{{ component_info.reports_output_dir }}/doxygen/html/index -/{{ component_info.reports_output_dir }}/coverage -{% endif %} +/components/light_controller/doc/index +/components/main_control_knob/doc/index +/components/power_button/doc/index +/components/power_signal_processing/doc/index +/components/brightness_controller/doc/index +/components/auto_off/doc/index +/components/examples/hello_gmock/doc/index +/components/examples/flight_controller/doc/index +/test/spled_integration/doc/index ``` - -{% endif %} -{% endfor %} diff --git a/index.md b/index.md index de302af9..161f8d79 100644 --- a/index.md +++ b/index.md @@ -1,29 +1,6 @@ -{% if build_config.component_info %} - -# Software Component Report - -**Variant:** {{ build_config.variant }}
-**Component:** {{ build_config.component_info.long_name }}
-**Timestamp:** {{ timestamp }} - -```{toctree} -:maxdepth: 2 - -{{ build_config.component_info.path }}/doc/index -{% if build_config.component_info.has_reports %} -{{ build_config.component_info.reports_output_dir }}/unit_test_spec -{{ build_config.component_info.reports_output_dir }}/unit_test_results -{{ build_config.component_info.reports_output_dir }}/doxygen/html/index -{{ build_config.component_info.reports_output_dir }}/coverage -{% endif %} -``` - -{% else %} - # Variant Report -**Variant:** {variant}`build_config.variant`
-**Timestamp:** {{ timestamp }} +**Variant:** {variant}`build_config.variant` ```{toctree} :maxdepth: 1 @@ -33,9 +10,26 @@ doc/customer_requirements/index doc/software_architecture/index doc/sw_requirements/index doc/components/index -{% if build_config.target == 'reports' %} -{{ build_config.reports_output_dir }}/coverage -{% endif %} ``` -{% endif %} +````{if} var.build_config.target == "reports" + +The glob matches the variant-wide coverage page for any variant name and build +type, and not a component's own, because the directory in front of `reports` is +the build type there and the component name here. It resolves to exactly one +page because the source set is the configured variant's build directory. + +A fixed path would be better, and `generated` exists for it, but spl-core writes +the gcovr tree next to the build-relative page path and its report artifacts are +looked up there too -- so the stable path needs the spl-core change, and is +deferred with it. + +```{toctree} +:caption: Code Coverage +:maxdepth: 1 +:glob: + +/build/**/[A-Z]*/reports/coverage +``` + +```` diff --git a/pypeline.yaml b/pypeline.yaml index 5af50f6d..6ea98ca6 100644 --- a/pypeline.yaml +++ b/pypeline.yaml @@ -3,12 +3,12 @@ pipeline: module: pypeline.steps.create_venv config: bootstrap_script: .bootstrap/bootstrap.py - # The uv-managed python3.11 (installed by bootstrap_python.sh into ~/.local/bin) - # is what "python_version: 3.11" would auto-discover via PATH, but its bundled + # The uv-managed python3.12 (installed by bootstrap_python.sh into ~/.local/bin) + # is what "python_version: 3.12" would auto-discover via PATH, but its bundled # ensurepip cannot seed pip into a fresh venv. This step's own throwaway venv - # (just used to pip-install poetry) doesn't need 3.11, so point it at the + # (just used to pip-install poetry) doesn't need 3.12, so point it at the # apt-provisioned system python3 instead -- poetry itself still targets the - # uv-managed 3.11 via virtualenvs.use-poetry-python (see bootstrap_python.sh). + # uv-managed 3.12 via virtualenvs.use-poetry-python (see bootstrap_python.sh). python_executable: python3 python_package_manager: poetry>=2.2 - step: PoksInstall diff --git a/pytest.ini b/pytest.ini index 357b7eb8..726babf6 100644 --- a/pytest.ini +++ b/pytest.ini @@ -9,6 +9,7 @@ addopts = -vv markers = unittests: tests of individual units of code + docs: tests of the documentation build, which needs no compiler reports: tests of the report generation build_debug: tests of the target builds with build type debug build_release: tests of the target builds with build type release diff --git a/test/spled_integration/doc/index.md b/test/spled_integration/doc/index.md index 27cbca19..72fe22b6 100644 --- a/test/spled_integration/doc/index.md +++ b/test/spled_integration/doc/index.md @@ -1 +1,16 @@ # SPLED Integration Tests + +````{if} var.build_config.target == "reports" + +## Verification + +```{toctree} +:maxdepth: 1 +:glob: + +/build/**/test/spled_integration/reports/unit_test_spec +/build/**/test/spled_integration/reports/unit_test_results +/build/**/test/spled_integration/reports/coverage +``` + +```` diff --git a/test/test_documentation.py b/test/test_documentation.py new file mode 100644 index 00000000..cb3b3c7b --- /dev/null +++ b/test/test_documentation.py @@ -0,0 +1,534 @@ +"""The documentation gate: every variant's documents build, and build right. + +**This suite needs no compiler.** KConfig is pure Python and the documents are +text, while CMake's top-level `project()` call demands a C toolchain before it +will configure at all. Keeping the gate independent of that is the point: the +documentation is the part of this product line that most people read and edit, +and it should not take a cross-compiler to check it. + +What it proves, per variant: + +- the variant data generates at all -- which exercises the KConfig model and the + parts.cmake grammar for every variant, not just the one someone last built; +- Sphinx builds the variant's documents without error; +- the documents present are exactly the ones the variant's component list says, + which is the property the whole variant-gating design exists to provide. +""" + +import json +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT_ROOT / "tools")) + +import variant_data # noqa: E402 + +pytestmark = [ + pytest.mark.docs, + pytest.mark.gate_develop_pr, + pytest.mark.gate_develop_push, + pytest.mark.gate_develop_nightly, + pytest.mark.gate_release_pr, + pytest.mark.gate_release, +] + +VARIANTS = ["Base/Dev", "Disco", "IDEA/Sloemada", "Sleep", "Spa"] + +#: Component documents that exist in the tree, with the component each belongs +#: to. The build must contain a page exactly when the variant contains the +#: component -- stated here rather than derived, so that a bug in the derivation +#: cannot make the test agree with it. +COMPONENT_DOCS = { + "components/light_controller": "components/light_controller/doc/index.html", + "components/main_control_knob": "components/main_control_knob/doc/index.html", + "components/power_button": "components/power_button/doc/index.html", + "components/power_signal_processing": "components/power_signal_processing/doc/index.html", + "components/brightness_controller": "components/brightness_controller/doc/index.html", + "components/auto_off": "components/auto_off/doc/index.html", + "test/spled_integration": "test/spled_integration/doc/index.html", + "components/examples/hello_gmock": "components/examples/hello_gmock/doc/index.html", + "components/examples/flight_controller": "components/examples/flight_controller/doc/index.html", +} + + +@pytest.fixture(scope="module") +def all_variant_data() -> None: + """Generate the whole matrix once, the way a developer or CI would.""" + subprocess.run( + [sys.executable, str(PROJECT_ROOT / "tools" / "variant_data.py"), "--all"], + cwd=PROJECT_ROOT, + check=True, + capture_output=True, + ) + + +def _build_docs(variant: str, kit: str, target: str, out_dir: Path) -> subprocess.CompletedProcess: + env = { + **os.environ, + "VARIANT_DATA_FILE": str(PROJECT_ROOT / "build" / "variants" / variant / kit / f"{target}.json"), + "VARIANT": variant, + } + # Deliberately NOT inheriting SPHINX_BUILD_CONFIGURATION_FILE: this gate is + # the bare build, the one a reader with no CMake in sight performs. + env.pop("SPHINX_BUILD_CONFIGURATION_FILE", None) + return subprocess.run( + [sys.executable, "-m", "sphinx", "-b", "html", str(PROJECT_ROOT), str(out_dir)], + cwd=PROJECT_ROOT, + env=env, + capture_output=True, + text=True, + ) + + +def test_variant_data_generates_for_every_variant(all_variant_data: None) -> None: + """`--check` passes right after `--all`, so the two agree by construction.""" + result = subprocess.run( + [sys.executable, str(PROJECT_ROOT / "tools" / "variant_data.py"), "--all", "--check"], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + + +@pytest.mark.parametrize("variant", VARIANTS) +def test_variant_documents_build(all_variant_data: None, variant: str, tmp_path: Path) -> None: + result = _build_docs(variant, "test", "docs", tmp_path / "html") + assert result.returncode == 0, f"sphinx-build failed for {variant}:\n{result.stdout[-4000:]}\n{result.stderr[-4000:]}" + assert (tmp_path / "html" / "index.html").is_file() + + +@pytest.mark.parametrize("variant", VARIANTS) +def test_the_built_documents_are_exactly_the_variants_components(all_variant_data: None, variant: str, tmp_path: Path) -> None: + """The property the whole design exists to provide. + + A component's document is in the build exactly when the variant's + parts.cmake adds it. Not when a feature suggests it, and never because the + variant happens to be called something. + """ + out = tmp_path / "html" + result = _build_docs(variant, "test", "docs", out) + assert result.returncode == 0, (result.stdout + result.stderr)[-2000:] + + components = set(variant_data.components(PROJECT_ROOT, variant, "test")) + + for component, page in COMPONENT_DOCS.items(): + built = (out / page).is_file() + if component in components: + assert built, f"{variant} contains {component} but {page} was not built" + else: + assert not built, f"{variant} does not contain {component} but {page} was built" + + +def test_the_integration_suite_follows_the_build_kit(all_variant_data: None, tmp_path: Path) -> None: + """Disco's parts.cmake adds it for the test kit only. + + This is the case the old `variant == "Disco"` gate got wrong: it claimed the + suite for Disco's prod kit too. Gating on the component list is what makes + the document follow the same rule the build does. + """ + page = COMPONENT_DOCS["test/spled_integration"] + + prod = tmp_path / "prod" + assert _build_docs("Disco", "prod", "docs", prod).returncode == 0 + assert not (prod / page).is_file(), "the integration suite is not in Disco's prod kit" + + test = tmp_path / "test" + assert _build_docs("Disco", "test", "docs", test).returncode == 0 + assert (test / page).is_file(), "the integration suite is in Disco's test kit" + + +def test_no_document_is_rendered_through_jinja(all_variant_data: None, tmp_path: Path) -> None: + """A Jinja construct in a document would now reach the reader verbatim. + + There is no `source-read` hook any more, so this is not a style rule: an + accidental `{{ ... }}` is shipped as literal text rather than rendered. The + check is on the sources, because the built HTML escapes braces anyway. + """ + offenders = [] + for pattern in ("index.md", "doc/**/*.md", "components/**/doc/*.md", "test/*/doc/*.md"): + for path in PROJECT_ROOT.glob(pattern): + text = path.read_text(encoding="utf-8") + if "{{" in text or "{%" in text: + offenders.append(str(path.relative_to(PROJECT_ROOT))) + assert not offenders, f"Jinja constructs in documents: {offenders}" + + +def test_the_only_source_read_handler_is_the_scoped_marker_strip() -> None: + """The rule is "no templating of documents", not "no handler at all". + + conf.py does register one `source-read` handler: a line filter that blanks + the `{% raw %}` markers spl-core puts in generated listings, scoped to + docnames under __source_docs. That is categorically different from the + global Jinja pass this branch removed -- nothing is evaluated and no + hand-written document is seen -- so this asserts the shape rather than the + absence of a string. + """ + conf = (PROJECT_ROOT / "conf.py").read_text(encoding="utf-8") + + handlers = re.findall(r'app\.connect\(\s*"source-read"\s*,\s*([A-Za-z_][A-Za-z0-9_]*)', conf) + assert handlers == ["_strip_jinja_raw_markers"], f"unexpected source-read handlers: {handlers}" + + # The pass that rendered every document is gone and stays gone. + assert "render_string" not in conf, "the global Jinja pass must not come back" + assert "__source_docs" in conf, "the handler must stay scoped to generated listings" + + +# --- the cross-reader gate ------------------------------------------------- +# +# Everything above proves the Sphinx build behaves. The point of the whole +# design, though, is that a SECOND reader -- one that never runs conf.py -- +# decides the same things. That can only be proven by running it. +# +# `ubc` ships inside the ubCode VS Code extension and is on neither PyPI nor +# npm, so there is no install step this repository can own. These tests skip +# when it is absent rather than pretending to cover it; set UBC, or put it on +# PATH, to turn them on. See AGENTS.md. + + +def _find_ubc() -> str | None: + if (explicit := os.environ.get("UBC")) and Path(explicit).is_file(): + return explicit + if found := shutil.which("ubc"): + return found + extensions = Path.home() / ".vscode" / "extensions" + candidates = sorted(extensions.glob("useblocks.ubcode-*/server/cli/ubc")) + return str(candidates[-1]) if candidates else None + + +UBC = _find_ubc() +needs_ubc = pytest.mark.skipif(UBC is None, reason="ubc not found; set UBC or put it on PATH") + + +def test_ubc_is_available_where_it_is_required() -> None: + """A skipped parity test must not be able to pass the gate by omission. + + The tests below are the only check that the two readers agree, and they + skip when ubc is absent -- which it is on any machine that has not + installed it. A check that silently does not run is worse than no check, + because the green tick claims it did. + + So the CI job that installs ubc sets CI_REQUIRE_UBC, and this turns the + skip into one clear failure. The other tests still skip rather than + erroring on a missing binary, so the reason is stated once. + """ + if not os.environ.get("CI_REQUIRE_UBC"): + pytest.skip("CI_REQUIRE_UBC is not set; ubc is optional here") + + assert UBC is not None, ( + "CI_REQUIRE_UBC is set but ubc was not found on PATH, in $UBC, or in the " + "VS Code extension directory. The parity tests would have skipped and the " + "documentation gate would have passed without checking that ubCode and " + "Sphinx agree, which is the property it exists for." + ) + + +def _ubc_check(variant: str, kit: str, target: str) -> list[dict]: + result = subprocess.run( + [ + UBC, + "check", + "-c", + f"needs.variant_data_file = 'build/variants/{variant}/{kit}/{target}.json'", + "--output-format", + "json", + ], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + ) + assert result.stdout, f"ubc check produced no JSON for {variant}:\n{result.stderr[-2000:]}" + return json.loads(result.stdout).get("diagnostics", []) + + +@needs_ubc +@pytest.mark.parametrize("variant", VARIANTS) +def test_ubc_reports_no_errors(all_variant_data: None, variant: str) -> None: + diagnostics = _ubc_check(variant, "test", "docs") + errors = [d for d in diagnostics if d["severity"] == "error"] + assert not errors, f"{variant}: " + "; ".join(d["message"] for d in errors) + + +@needs_ubc +def test_ubc_finds_no_configuration_problem(all_variant_data: None) -> None: + """A `config` diagnostic means the two readers are configured differently. + + The one informational exception is ubCode noting that a Sphinx build honours + the variant-gating keys only when sphinx-mounts is installed -- which it is, + and which the rest of this suite proves. + """ + diagnostics = _ubc_check("Disco", "test", "docs") + problems = [ + d + for d in diagnostics + if d["code"].startswith("config") and d["code"] != "config.variant_sources_sphinx_unsupported" + ] + assert not problems, "; ".join(f"{d['code']}: {d['message']}" for d in problems) + + +@needs_ubc +@pytest.mark.parametrize("variant", VARIANTS) +def test_ubc_excludes_exactly_what_sphinx_excludes(all_variant_data: None, variant: str) -> None: + """The property the whole design exists to provide, proven across readers. + + ubCode never runs conf.py. If it removes exactly the component documents + that the variant's component list omits -- the same set the Sphinx build + omits, asserted above -- then both readers are deciding from the same data + and agreeing. That is the claim; this is the test of it. + """ + diagnostics = _ubc_check(variant, "test", "docs") + + excluded_by_ubc = set() + for diagnostic in diagnostics: + if diagnostic["code"] != "toctree.variant_excluded": + continue + match = re.search(r"toctree entry '([^']+)'", diagnostic["message"]) + assert match, diagnostic["message"] + excluded_by_ubc.add(match.group(1)) + + components = set(variant_data.components(PROJECT_ROOT, variant, "test")) + expected = { + f"{component}/doc/index" for component in COMPONENT_DOCS if component not in components + } + + assert excluded_by_ubc == expected, ( + f"{variant}: ubCode and the component list disagree.\n" + f" ubCode excluded : {sorted(excluded_by_ubc)}\n" + f" expected : {sorted(expected)}" + ) + + +# --- the build shape must select its own cell ------------------------------- + + +def _build_with_spl_core_env(shape: str, out_dir: Path, tmp_path: Path, config: dict | None = None) -> subprocess.CompletedProcess: + """Build the way spl-core starts Sphinx: a per-target config.json, no more. + + spl-core only passes VARIANT_DATA_FILE from 8.9; against the version this + project pins it passes SPHINX_BUILD_CONFIGURATION_FILE, and the directory + that file sits in is what names the build shape. + """ + config_dir = tmp_path / "cfg" / shape + config_dir.mkdir(parents=True, exist_ok=True) + (config_dir / "config.json").write_text(json.dumps(config or {}), encoding="utf-8") + + env = {**os.environ, "SPHINX_BUILD_CONFIGURATION_FILE": str(config_dir / "config.json"), "VARIANT": "Disco"} + env.pop("VARIANT_DATA_FILE", None) + return subprocess.run( + [sys.executable, "-m", "sphinx", "-b", "html", str(PROJECT_ROOT), str(out_dir)], + cwd=PROJECT_ROOT, + env=env, + capture_output=True, + text=True, + ) + + +def test_the_reports_shape_reads_the_reports_cell(all_variant_data: None, tmp_path: Path) -> None: + """Without this, a reports build quietly reads the docs cell. + + Every `{if} var.build_config.target == "reports"` fence would then evaluate + false and the reports target would contain no reports -- with no error + anywhere, and with the reports test still passing, because it asserts that + the build succeeded and not that it produced anything. + + Checked on the rendered page rather than on a file list, because the fence + is what is under test: the report sections only appear when the variant data + says `reports`. + """ + published = { + "reports": PROJECT_ROOT / "build" / "variant-data-reports.json", + "docs": PROJECT_ROOT / "build" / "variant-data-docs.json", + } + for shape, path in published.items(): + path.write_text((PROJECT_ROOT / "build" / "variants" / "Disco" / "test" / f"{shape}.json").read_text(encoding="utf-8")) + + try: + for shape, expect_verification in (("reports", True), ("docs", False)): + out = tmp_path / f"{shape}_html" + result = _build_with_spl_core_env(shape, out, tmp_path) + assert result.returncode == 0, (result.stdout + result.stderr)[-2000:] + page = (out / "components" / "light_controller" / "doc" / "index.html").read_text(encoding="utf-8") + assert ("Verification" in page) is expect_verification, ( + f"the {shape} shape {'should' if expect_verification else 'should not'} render the verification section" + ) + finally: + for path in published.values(): + path.unlink(missing_ok=True) + + +# --- the generated report pages belong to the reports shape only ------------ + + +#: The three pages spl-core writes per component at CONFIGURE time, and lists +#: among its include patterns for BOTH build shapes. +SPL_CORE_REPORT_PAGES = ("unit_test_spec", "unit_test_results", "coverage") + + +def _fake_spl_core_report_tree(component: str) -> tuple[Path, str]: + """Write what spl-core's configure step writes, and the pattern naming it. + + Under build/, so it is gitignored and invisible to every other test. + """ + rel = Path("build") / "_fake_report_tree" / component / "reports" + root = PROJECT_ROOT / rel + root.mkdir(parents=True, exist_ok=True) + for page in SPL_CORE_REPORT_PAGES: + (root / f"{page}.rst").write_text( + f"{page}\n{'=' * len(page)}\n\nGenerated by spl-core at configure time.\n", + encoding="utf-8", + ) + return root, f"{rel.as_posix()}/**" + + +def test_the_docs_shape_does_not_read_the_generated_report_pages(all_variant_data: None, tmp_path: Path) -> None: + """spl-core lists the report pages for both shapes; only one should read them. + + They exist from configure time, and in a docs build the fences that would + link them are false -- so reading them yields three documents per component + that no toctree references. Fifteen orphan warnings on Spa, for pages nobody + asked to see. + + This needs no compiler: the only thing a CMake build contributes here is the + config.json, and that is three lines of JSON. + """ + published = PROJECT_ROOT / "build" / "variant-data-docs.json" + published.write_text( + (PROJECT_ROOT / "build" / "variants" / "Disco" / "test" / "docs.json").read_text(encoding="utf-8"), + encoding="utf-8", + ) + tree, pattern = _fake_spl_core_report_tree("components/light_controller") + + try: + out = tmp_path / "docs_html" + result = _build_with_spl_core_env("docs", out, tmp_path, {"target": "docs", "include_patterns": [pattern]}) + assert result.returncode == 0, (result.stdout + result.stderr)[-2000:] + + # Sphinx writes warnings to stderr, so scanning stdout alone made an + # earlier version of this test pass with the fix reverted. + orphaned = [ + line + for line in (result.stdout + result.stderr).splitlines() + if "toc.not_included" in line and "_fake_report_tree" in line + ] + assert not orphaned, "the docs shape read the generated report pages:\n" + "\n".join(orphaned) + finally: + shutil.rmtree(tree.parent.parent, ignore_errors=True) + published.unlink(missing_ok=True) + + +def test_the_reports_shape_still_reads_them(all_variant_data: None, tmp_path: Path) -> None: + """The other half: narrowing the docs shape must not starve the reports one.""" + published = PROJECT_ROOT / "build" / "variant-data-reports.json" + published.write_text( + (PROJECT_ROOT / "build" / "variants" / "Disco" / "test" / "reports.json").read_text(encoding="utf-8"), + encoding="utf-8", + ) + tree, pattern = _fake_spl_core_report_tree("components/light_controller") + + try: + out = tmp_path / "reports_html" + result = _build_with_spl_core_env("reports", out, tmp_path, {"target": "reports", "include_patterns": [pattern]}) + assert result.returncode == 0, (result.stdout + result.stderr)[-2000:] + + built = out / "build" / "_fake_report_tree" / "components" / "light_controller" / "reports" + for page in SPL_CORE_REPORT_PAGES: + assert (built / f"{page}.html").is_file(), f"the reports shape did not read {page}" + finally: + shutil.rmtree(tree.parent.parent, ignore_errors=True) + published.unlink(missing_ok=True) + + +# --- the generated listings must not show their Jinja armour ---------------- + + +def test_generated_source_listings_carry_no_jinja_markers(all_variant_data: None, tmp_path: Path) -> None: + """spl-core wraps generated listings in `{% raw %}`; nothing else unwraps them. + + The global Jinja pass used to consume those markers. It is gone, and no + released spl-core lets the flag be turned off, so without the scoped strip + in conf.py they reach the reader as two literal paragraphs on every listing + page of a reports build. + + The fixture is produced by clanguru itself rather than hand-written, so this + tracks what spl-core actually emits instead of what it emitted once. + """ + clanguru = shutil.which("clanguru") or str(Path(sys.executable).parent / "clanguru") + if not Path(clanguru).exists(): + pytest.skip("clanguru not installed") + + source = tmp_path / "sample.c" + source.write_text("int add(int a, int b) { return a + b; }\n", encoding="utf-8") + + listing_dir = PROJECT_ROOT / "build" / "_fake_source_docs" / "components" / "x" / "__source_docs" + listing_dir.mkdir(parents=True, exist_ok=True) + listing = listing_dir / "sample_c.rst" + subprocess.run( + [clanguru, "docs", "--source-file", str(source), "--output-file", str(listing), + "--format", "rst", "--jinja-raw-tags"], + check=True, capture_output=True, + ) + assert "{% raw %}" in listing.read_text(encoding="utf-8"), "fixture is not representative" + + published = PROJECT_ROOT / "build" / "variant-data-reports.json" + published.write_text( + (PROJECT_ROOT / "build" / "variants" / "Disco" / "test" / "reports.json").read_text(encoding="utf-8"), + encoding="utf-8", + ) + pattern = "build/_fake_source_docs/components/x/__source_docs/**" + + try: + out = tmp_path / "html" + result = _build_with_spl_core_env( + "reports", out, tmp_path, {"target": "reports", "include_patterns": [pattern]} + ) + assert result.returncode == 0, (result.stdout + result.stderr)[-2000:] + + page = out / "build" / "_fake_source_docs" / "components" / "x" / "__source_docs" / "sample_c.html" + assert page.is_file(), "the listing was not built" + rendered = page.read_text(encoding="utf-8") + for marker in ("{% raw %}", "{% endraw %}"): + assert marker not in rendered, f"{marker} reached the reader" + + # Syntax highlighting splits the code across spans, so assert on the + # text rather than the markup -- checking the raw HTML for a contiguous + # "int add" is how an earlier version of this test fooled itself. + text = re.sub(r"<[^>]+>", "", rendered) + assert "int" in text and "add" in text, "the strip removed more than the markers" + finally: + shutil.rmtree(listing_dir.parent.parent.parent, ignore_errors=True) + published.unlink(missing_ok=True) + + +def test_the_strip_preserves_line_numbers(all_variant_data: None) -> None: + """Markers become blank lines, not nothing. + + Removing them would shift every line after them, so a warning about a + generated page would point at the wrong line -- which is one of the reasons + the global Jinja pass had to go. Re-creating it in the replacement would be + missing the point. + """ + spec = __import__("importlib.util", fromlist=["util"]).spec_from_file_location( + "spled_conf", PROJECT_ROOT / "conf.py" + ) + module = __import__("importlib.util", fromlist=["util"]).module_from_spec(spec) + spec.loader.exec_module(module) + + before = "a\n{% raw %}\n.. code-block:: c\n\n int x;\n{% endraw %}\n" + source = [before] + module._strip_jinja_raw_markers(None, "components/x/__source_docs/y", source) + + assert source[0].count("\n") == before.count("\n"), "line count changed" + assert "{% raw %}" not in source[0] and "{% endraw %}" not in source[0] + assert ".. code-block:: c" in source[0] and "int x;" in source[0] + + # A hand-written document is never touched, whatever it contains. + untouched = ["{% raw %}\nkeep me\n"] + module._strip_jinja_raw_markers(None, "doc/components/index", untouched) + assert untouched[0] == "{% raw %}\nkeep me\n" diff --git a/test/test_ubproject_config.py b/test/test_ubproject_config.py new file mode 100644 index 00000000..94171e78 --- /dev/null +++ b/test/test_ubproject_config.py @@ -0,0 +1,368 @@ +"""Tests for ubproject.toml, the one configuration file both readers read. + +ubCode and ubc read this file directly; the Sphinx build reads it through +`needs_from_toml` in conf.py. sphinx-needs implements neither ubCode's `extend` +nor any other include mechanism -- it reads exactly one file's `[needs]` table -- +so the only way the two readers can agree is for this file to be complete on its +own. spl-core's base needs configuration is therefore vendored into it. + +Vendoring is a copy, and a copy rots. These tests are what stops it rotting +quietly: they fail when spl-core changes something this project copied, so the +choice to follow or to deviate is made deliberately, in a reviewable commit, +rather than discovered later as a link type that exists in one reader and not +the other. +""" + +import json +import subprocess +import sys +import tomllib +from importlib.resources import files +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parent.parent + +pytestmark = [ + pytest.mark.unittests, + pytest.mark.gate_develop_pr, + pytest.mark.gate_develop_push, + pytest.mark.gate_develop_nightly, + pytest.mark.gate_release_pr, + pytest.mark.gate_release, +] + + +@pytest.fixture(scope="module") +def project_config() -> dict: + with (PROJECT_ROOT / "ubproject.toml").open("rb") as handle: + return tomllib.load(handle) + + +@pytest.fixture(scope="module") +def spl_core_config() -> dict: + """The base configuration spl-core ships, as installed.""" + with files("spl_core.report_generation").joinpath("ubproject.toml").open("rb") as handle: + return tomllib.load(handle) + + +# --- the file has to stand on its own -------------------------------------- + + +def test_does_not_extend_anything(project_config: dict) -> None: + """No `extend`, because only one of the two readers implements it. + + The path it used to name also had the Python minor version baked into it + and pointed at an interpreter that does not exist, so the base + configuration silently failed to resolve for ubCode while conf.py resolved + it a second time through importlib. + """ + assert "extend" not in project_config + + +def test_sphinx_reads_this_exact_file() -> None: + conf = (PROJECT_ROOT / "conf.py").read_text(encoding="utf-8") + assert 'needs_from_toml = "ubproject.toml"' in conf + + +# --- the vendored copy has to match what spl-core ships -------------------- + + +def test_every_spl_core_link_type_is_vendored(project_config: dict, spl_core_config: dict) -> None: + """Modernized from `extra_links` to `[needs.links]` while copying.""" + expected = { + link["option"]: {"incoming": link["incoming"], "outgoing": link["outgoing"]} + for link in spl_core_config["needs"]["extra_links"] + } + assert project_config["needs"]["links"] == expected + + +def test_every_spl_core_field_is_vendored(project_config: dict, spl_core_config: dict) -> None: + """Modernized from `extra_options` to `[needs.fields]` while copying. + + The project declares fields of its own on top, so this is a subset check. + `integrity` carries an explicit empty-string default because that is what + the deprecated `extra_options` form implies; defaulting to null instead + would change the value on every existing need. + """ + for option in spl_core_config["needs"]["extra_options"]: + assert option in project_config["needs"]["fields"], f"spl-core declares the field {option!r}, this file does not" + + assert project_config["needs"]["fields"]["integrity"]["default"] == "" + + +def test_every_spl_core_need_type_is_vendored(project_config: dict, spl_core_config: dict) -> None: + ours = {t["directive"]: t for t in project_config["needs"]["types"]} + for need_type in spl_core_config["needs"]["types"]: + directive = need_type["directive"] + assert directive in ours, f"spl-core declares the need type {directive!r}, this file does not" + assert ours[directive] == need_type, f"the {directive!r} need type differs from spl-core's" + + +def test_source_exclusions_are_vendored(project_config: dict, spl_core_config: dict) -> None: + assert project_config["source"]["exclude"] == spl_core_config["source"]["exclude"] + assert project_config["source"]["respect_gitignore"] == spl_core_config["source"]["respect_gitignore"] + + +# --- the deliberate deviations --------------------------------------------- + + +def test_build_output_is_not_indexed(project_config: dict) -> None: + """ubCode must not index every variant and kit ever built. + + spl-core's base config turns `respect_gitignore` off and then includes the + generated source listings, but excludes nothing else under the build + directory. The result is every need ID appearing once per variant built on + that machine, so the IDE cannot agree with any single build about what the + project contains. Sphinx never saw this because conf.py narrows its source + set per build shape -- which is exactly the kind of divergence between + readers this configuration exists to remove. + """ + assert "build/**" in project_config["source"]["extend_exclude"] + + +def test_the_per_component_report_root_is_hidden_from_ubcode(project_config: dict) -> None: + """conf.py excludes it from every variant build, so ubCode must too. + + doc/component_report.md is the root document of spl-core's PER-COMPONENT + report -- a build shape ubCode never performs. conf.py drops it from the + variant-wide source set, so Sphinx never sees it; ubCode indexed it anyway + and reported it as an orphan. Four orphans in one reader and five in the + other is the two of them disagreeing about the document set, which is the + one thing this configuration exists to prevent. + + `[parse.parsers.*]` has no `exclude`, but `extend_exclude` is honoured in + parser mode -- unlike `extend_include` -- so that is where it belongs. + """ + conf = (PROJECT_ROOT / "conf.py").read_text(encoding="utf-8") + assert 'exclude_patterns.append("doc/component_report.md")' in conf + assert "doc/component_report.md" in project_config["source"]["extend_exclude"] + + +def test_extend_include_is_not_used_because_it_would_be_inert(project_config: dict, spl_core_config: dict) -> None: + """Configuring parsers puts ubCode in parser mode, where this key is ignored. + + spl-core's base config includes the generated listings with + `[source] extend_include`, which works only while no parser is configured. + This project configures both parsers, so file discovery comes from their + `include` lists and `extend_include` does nothing -- silently, apart from + one config warning. Carrying the key anyway would look like configuration + and behave like a comment. `ubc check` is what caught this. + """ + assert spl_core_config["source"]["extend_include"] == ["build/**/__source_docs/**"] + assert "extend_include" not in project_config["source"] + assert project_config["parse"]["parsers"]["rst"]["include"] == ["generated/**/*.rst"] + + +def test_ubcode_and_sphinx_see_the_same_documents(project_config: dict) -> None: + """The parser includes have to match conf.py's include_patterns. + + They did not: `include = ["*.md"]` matched every Markdown file in the tree, + so ubCode parsed AGENTS.md, README.md, CLAUDE.md and three dozen agent skill + definitions as project documents -- 38 files Sphinx never sees. Two readers + with different document sets cannot agree about the project, which is the + whole thing this configuration exists to prevent. + """ + conf = (PROJECT_ROOT / "conf.py").read_text(encoding="utf-8") + markdown_includes = project_config["parse"]["parsers"]["md"]["include"] + + assert markdown_includes == [ + "index.md", + "doc/**/*.md", + "components/**/doc/**/*.md", + "test/**/doc/**/*.md", + ] + # Every tree the parser reads is a tree conf.py also names. + for tree in ("index.md", "doc/**", "components/**/doc/**", "test/**/doc/**"): + assert f'"{tree}"' in conf, f"conf.py does not include {tree}, which the md parser reads" + + +# --- what the variant machinery needs -------------------------------------- + + +def test_variant_data_file_is_the_current_pointer(project_config: dict) -> None: + """What ubCode reads, and what a bare sphinx-build falls back to.""" + assert project_config["needs"]["variant_data_file"] == "build/autoconf.json" + + +def test_needs_json_is_written(project_config: dict) -> None: + assert project_config["needs"]["build_json"] is True + + +# --- the variant rules ----------------------------------------------------- +# +# These evaluate the real conditions with sphinx-mounts' own interpreter, +# against the real generated variant data. That is the closest a test can get +# to "ubCode and the build decide the same thing", because it is literally the +# same grammar and the same file. + + +@pytest.fixture(scope="module") +def rules(project_config: dict) -> list[dict]: + return project_config["source"]["variant_sources"] + + +@pytest.fixture(scope="module", autouse=True) +def generated_variant_data() -> None: + """Generate the matrix before reading it. + + build/ is gitignored, so on a fresh checkout -- which is every CI run -- + these files do not exist yet. Reading them without generating them made + these tests pass only on a machine that had happened to build already. + """ + subprocess.run( + [sys.executable, str(PROJECT_ROOT / "tools" / "variant_data.py"), "--all"], + cwd=PROJECT_ROOT, + check=True, + capture_output=True, + ) + + +def _variant_data(variant: str, kit: str, target: str) -> dict: + with (PROJECT_ROOT / "build" / "variants" / variant / kit / f"{target}.json").open(encoding="utf-8") as handle: + return json.load(handle) + + +def test_every_component_document_is_gated_by_a_rule(rules: list[dict]) -> None: + """A hand-written component document with no rule is 150% in every variant. + + It would then appear in variants that do not contain the component, and + every need in it would enter their traceability data. The failure is + additive and silent, which is why it needs a test rather than a review. + """ + gated = {pattern for rule in rules for pattern in rule["files"]} + for doc_dir in sorted(PROJECT_ROOT.glob("components/**/doc")) + sorted(PROJECT_ROOT.glob("test/*/doc")): + rel = doc_dir.relative_to(PROJECT_ROOT).as_posix() + assert f"{rel}/**" in gated, f"{rel} is not gated by any [[source.variant_sources]] rule" + + +def test_no_rule_names_a_variant_by_name(rules: list[dict]) -> None: + """Membership, never identity. + + Gating on the variant name re-encodes what parts.cmake already says, and + the two are then free to drift. The integration suite is the case in point: + it used to be gated on `variant == "Disco"`, which also claimed it for + Disco's prod kit, where parts.cmake does not add it. + """ + known_variants = {"Disco", "Sleep", "Spa", "Base/Dev", "IDEA/Sloemada"} + for rule in rules: + for variant in known_variants: + assert f'"{variant}"' not in rule["if"], f"rule {rule['if']!r} names a variant directly" + assert f"'{variant}'" not in rule["if"], f"rule {rule['if']!r} names a variant directly" + + +def test_every_rule_condition_is_inside_the_grammar(rules: list[dict]) -> None: + """A condition outside the grammar is refused rather than evaluated.""" + from sphinx_mounts import variants + + for rule in rules: + variants.validate(rule["if"]) + + +@pytest.mark.parametrize( + ("variant", "kit", "expect_present", "expect_absent"), + [ + # Disco: BLINKING, so no brightness; no auto-off; integration suite in + # the test kit only. + ("Disco", "test", ["components/light_controller/doc/**", "test/spled_integration/doc/**"], ["components/auto_off/doc/**", "components/brightness_controller/doc/**"]), + ("Disco", "prod", ["components/light_controller/doc/**"], ["test/spled_integration/doc/**", "components/auto_off/doc/**"]), + # Sleep: manual brightness and auto-off, no integration suite. + ("Sleep", "test", ["components/auto_off/doc/**", "components/brightness_controller/doc/**"], ["test/spled_integration/doc/**"]), + # Spa: brightness but no auto-off. + ("Spa", "test", ["components/brightness_controller/doc/**"], ["components/auto_off/doc/**"]), + # Base/Dev: the example components, none of the product ones. + ("Base/Dev", "test", ["components/examples/hello_gmock/doc/**"], ["components/light_controller/doc/**", "components/auto_off/doc/**"]), + ], +) +def test_rules_select_the_expected_documents(rules: list[dict], variant: str, kit: str, expect_present: list[str], expect_absent: list[str]) -> None: + from sphinx_mounts import variants + + data = _variant_data(variant, kit, "docs") + included: set[str] = set() + excluded: set[str] = set() + for rule in rules: + ok = variants.interpret(variants.validate(rule["if"]), data) + (included if ok else excluded).update(rule["files"]) + + for pattern in expect_present: + assert pattern in included, f"{variant}/{kit}: expected {pattern} to be included" + assert pattern not in excluded, f"{variant}/{kit}: {pattern} is both included and excluded" + for pattern in expect_absent: + assert pattern in excluded, f"{variant}/{kit}: expected {pattern} to be excluded" + + +def test_generated_output_is_gated_on_the_reports_target(rules: list[dict]) -> None: + """A docs build must not read report pages it will not show. + + Rules are subtractive -- a FALSE rule removes the files it names, a TRUE + one does nothing -- so this rule and the per-component ones compose as AND. + """ + from sphinx_mounts import variants + + shape_rule = next(r for r in rules if "target" in r["if"]) + assert shape_rule["files"] == ["generated/**"] + + tree = variants.validate(shape_rule["if"]) + assert variants.interpret(tree, _variant_data("Disco", "test", "reports")) is True + assert variants.interpret(tree, _variant_data("Disco", "test", "docs")) is False + + +def test_report_globs_resolve_through_the_configured_build_only() -> None: + """The report toctrees glob `/build/**`, and that is currently correct. + + A fixed path under `generated/` would be better, and the concept note asks + for one. It cannot be adopted yet: spl-core writes the gcovr tree at + `reports/html//coverage/index.html` and looks its + report artifacts up at the same place, so moving the page that links to it + without moving the tree breaks the coverage link. Deferred with the spl-core + change. + + What keeps a glob honest meanwhile is that conf.py admits exactly the + configured variant's build directory, so each pattern resolves to one page. + """ + conf = (PROJECT_ROOT / "conf.py").read_text(encoding="utf-8") + assert 'pattern.startswith("build/")' in conf, "conf.py must narrow the source set to the configured build" + + globbed = [ + path + for pattern in ("components/*/doc/index.md", "test/*/doc/index.md") + for path in PROJECT_ROOT.glob(pattern) + if "/build/**" in path.read_text(encoding="utf-8") + ] + for path in globbed: + body = path.read_text(encoding="utf-8") + assert ":glob:" in body, f"{path.relative_to(PROJECT_ROOT)} uses /build/** without :glob:" + + +def test_generated_and_build_discovery_are_never_both_live(project_config: dict) -> None: + """Exactly one route to the generated report pages, or they exist twice. + + Two routes are configured, on purpose, and only one is switched on: + + * Sphinx reaches them through spl-core's `build/...` include patterns, + which conf.py forwards for the reports shape. Live today. + * `generated/`, the stable path, is named by the rst parser and by every + component's variant rule. Inert today -- conf.py excludes `generated` + from the Sphinx walk, and ubCode does not descend symlinks. + + The forward-looking half stays because it is the shape we want once + spl-core writes the gcovr tree relative to the page. But when that lands, + dropping the `build/` forwarding has to happen in the SAME change, or + Sphinx discovers every report page under both names. A comment saying so + would be read once; this fails the build instead. + """ + conf = (PROJECT_ROOT / "conf.py").read_text(encoding="utf-8") + + sphinx_walks_generated = '"generated",' not in conf + forwards_build_patterns = 'pattern.startswith("build/")' in conf + + assert not (sphinx_walks_generated and forwards_build_patterns), ( + "conf.py both lets Sphinx walk `generated/` and forwards spl-core's " + "`build/` patterns. Every generated report page is then discovered " + "twice, under two docnames. Drop the `build/` forwarding in the same " + "change that makes `generated/` real." + ) + + # And the forward-looking configuration is still there to be switched on. + assert project_config["parse"]["parsers"]["rst"]["include"] == ["generated/**/*.rst"] diff --git a/test/test_variant_data.py b/test/test_variant_data.py new file mode 100644 index 00000000..aa4451c0 --- /dev/null +++ b/test/test_variant_data.py @@ -0,0 +1,243 @@ +"""Tests for the variant data generator, tools/variant_data.py. + +These run without a compiler, a CMake configure or a Sphinx build, which is the +whole point of the generator: the documentation and its gate must be derivable +from the sources alone. + +The parts.cmake parser gets the most attention here. It is the one place where +this project reads a CMake file with something other than CMake, and the failure +mode it guards against is silent: a parser that shrugged at a condition it did +not understand would return a component list that is wrong for some kit, and +that list is what gates the documents. The symptom would be documents missing +from a variant's build -- no error, no warning, just less. +""" + +import json +import sys +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT_ROOT / "tools")) + +import variant_data # noqa: E402 + +# The variant data is the input to every documentation gate, and generating it +# is pure Python that costs milliseconds -- so it runs in all of them. A +# regression here does not fail a build; it silently drops documents from a +# variant, which is exactly what a gate has to catch early. +pytestmark = [ + pytest.mark.unittests, + pytest.mark.gate_develop_pr, + pytest.mark.gate_develop_push, + pytest.mark.gate_develop_nightly, + pytest.mark.gate_release_pr, + pytest.mark.gate_release, +] + + +# --- the variant set ------------------------------------------------------- + + +def test_finds_every_variant_including_nested_ones() -> None: + """A variant is a directory holding config.cmake, one or two levels deep.""" + assert variant_data.variant_names(PROJECT_ROOT) == [ + "Base/Dev", + "Disco", + "IDEA/Sloemada", + "Sleep", + "Spa", + ] + + +# --- parts.cmake: the grammar this project actually uses -------------------- + + +@pytest.mark.parametrize("kit", variant_data.KITS) +def test_flat_parts_file_is_kit_independent(kit: str) -> None: + """Spa has no guard, so both kits see the same components.""" + assert variant_data.components(PROJECT_ROOT, "Spa", kit) == variant_data.components(PROJECT_ROOT, "Spa", "prod") + + +def test_test_kit_guard_is_honoured() -> None: + """Disco adds its integration suite only for the test kit.""" + prod = variant_data.components(PROJECT_ROOT, "Disco", "prod") + test = variant_data.components(PROJECT_ROOT, "Disco", "test") + + assert "test/spled_integration" not in prod + assert "test/spled_integration" in test + assert test[: len(prod)] == prod, "the guard must only ADD, not reorder" + + +def test_component_order_follows_the_file() -> None: + """The list is the product structure, so it keeps parts.cmake's order.""" + assert variant_data.components(PROJECT_ROOT, "Disco", "prod")[:3] == [ + "components/platform_types", + "components/rte", + "components/main", + ] + + +# --- parts.cmake: everything outside the grammar has to raise --------------- + + +def _write_parts(tmp_path: Path, body: str) -> Path: + parts_dir = tmp_path / "variants" / "Fake" + parts_dir.mkdir(parents=True) + (parts_dir / "parts.cmake").write_text(body, encoding="utf-8") + return tmp_path + + +@pytest.mark.parametrize( + ("body", "because"), + [ + ("if(SOME_OTHER_CONDITION)\n spl_add_component(components/a)\nendif()\n", "unknown condition"), + ("if(BUILD_KIT STREQUAL test)\n if(X)\n endif()\nendif()\n", "nested if"), + ("if(BUILD_KIT STREQUAL test)\nelseif(X)\nendif()\n", "elseif"), + ("spl_add_component(components/a)\nendif()\n", "endif without if"), + ("else()\n", "else outside if"), + ("if(BUILD_KIT STREQUAL test)\n spl_add_component(components/a)\n", "unterminated if"), + ("set(SOMETHING on)\n", "statement that is not spl_add_component"), + ], +) +def test_unsupported_grammar_raises(tmp_path: Path, body: str, because: str) -> None: + root = _write_parts(tmp_path, body) + with pytest.raises(ValueError): + variant_data.components(root, "Fake", "test") + + +def test_comments_and_blank_lines_are_ignored(tmp_path: Path) -> None: + root = _write_parts( + tmp_path, + "# leading comment\n\nspl_add_component(components/a) # trailing\n\n", + ) + assert variant_data.components(root, "Fake", "prod") == ["components/a"] + + +def test_else_branch_belongs_to_the_other_kit(tmp_path: Path) -> None: + """Not used in this project today, but it must not be silently wrong.""" + root = _write_parts( + tmp_path, + "if(BUILD_KIT STREQUAL test)\n" + " spl_add_component(test/suite)\n" + "else()\n" + " spl_add_component(components/stub)\n" + "endif()\n", + ) + assert variant_data.components(root, "Fake", "test") == ["test/suite"] + assert variant_data.components(root, "Fake", "prod") == ["components/stub"] + + +# --- the feature vector ---------------------------------------------------- + + +def test_feature_vector_is_complete() -> None: + """Every boolean the model declares is present, so no condition is unknown. + + BRIGHTNESS_ADJUSTMENT_ENABLED is the case that motivates this: it is + promptless, so KConfig omits it from its JSON for exactly the variants + where it is off. A document or a mount condition naming it would then be + unevaluable there -- and both tools gate unevaluable content OFF rather + than answering False, so the content would silently disappear. + """ + disco = variant_data.features(PROJECT_ROOT, "Disco") + sleep = variant_data.features(PROJECT_ROOT, "Sleep") + + for name in ("BRIGHTNESS_ADJUSTMENT_ENABLED", "AUTO_OFF", "BLINKING", "COLOR_1_IS_ENABLED"): + assert name in disco, f"{name} missing from Disco's feature vector" + assert name in sleep, f"{name} missing from Sleep's feature vector" + + # Disco selects BLINKING, which the brightness choice depends on being off. + assert disco["BLINKING"] is True + assert disco["BRIGHTNESS_ADJUSTMENT_ENABLED"] is False + assert disco["AUTO_OFF"] is False + + # Sleep selects manual brightness and auto-off. + assert sleep["BLINKING"] is False + assert sleep["BRIGHTNESS_ADJUSTMENT_ENABLED"] is True + assert sleep["AUTO_OFF"] is True + + +def test_non_boolean_values_keep_their_type() -> None: + disco = variant_data.features(PROJECT_ROOT, "Disco") + assert disco["CUSTOMER"] == "A" + assert disco["OS_TASK_PERIOD"] == 10 + + +def test_variant_without_config_txt_uses_model_defaults() -> None: + """Base/Dev ships no config.txt; the model's own defaults apply.""" + assert not (PROJECT_ROOT / "variants" / "Base" / "Dev" / "config.txt").exists() + base = variant_data.features(PROJECT_ROOT, "Base/Dev") + assert base["CUSTOMER"] == "None" + assert base["BLINKING"] is False + + +# --- the cell as a whole --------------------------------------------------- + + +@pytest.mark.parametrize("variant", ["Base/Dev", "Disco", "IDEA/Sloemada", "Sleep", "Spa"]) +@pytest.mark.parametrize("kit", variant_data.KITS) +@pytest.mark.parametrize("target", variant_data.TARGETS) +def test_every_cell_carries_the_whole_contract(variant: str, kit: str, target: str) -> None: + """Everything a condition may name has to be IN the file. + + This is the rule the whole design rests on: a key that only conf.py knows + is invisible to ubCode and every other reader, and their view of the + project then silently disagrees with the build. + """ + data = variant_data.variant_data(PROJECT_ROOT, variant, kit, target) + + assert set(data) == {"features", "build_config"} + assert data["build_config"]["variant"] == variant + assert data["build_config"]["kit"] == kit + assert data["build_config"]["target"] == target + assert data["build_config"]["components"] + assert data["features"] + + # It has to survive the round trip to the file both tools read. + assert json.loads(json.dumps(data)) == data + + +# --- the current-variant pointer ------------------------------------------- + + +def test_the_pointer_is_a_symlink_where_the_platform_allows_one(tmp_path: Path) -> None: + build_dir = tmp_path / "build" / "V" / "test" / "Debug" + build_dir.mkdir(parents=True) + (build_dir / "payload.txt").write_text("x", encoding="utf-8") + + variant_data.write_pointer(tmp_path, {"features": {}, "build_config": {}}, build_dir) + + generated = tmp_path / "generated" + assert generated.is_symlink() + assert (generated / "payload.txt").is_file() + + +def test_a_refused_symlink_copies_nothing(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The fallback must not duplicate the CMake binary directory. + + It used to `copytree` the whole thing at CONFIGURE time -- objects, + binaries, CMakeFiles -- before the reports it exists for had been generated, + and `dirs_exist_ok` meant a later configure never cleared an earlier one's + leftovers. Nothing reads `generated/` yet, so it bought nothing at all. + + Only Windows without Developer Mode takes this path, which is why it needs a + test rather than the platform nobody develops on finding out. + """ + build_dir = tmp_path / "build" / "V" / "test" / "Debug" + (build_dir / "CMakeFiles").mkdir(parents=True) + (build_dir / "CMakeFiles" / "huge.o").write_text("x" * 1000, encoding="utf-8") + + def refuse(*args, **kwargs): + raise OSError("symlinks not permitted") + + monkeypatch.setattr(Path, "symlink_to", refuse) + + variant_data.write_pointer(tmp_path, {"features": {}, "build_config": {}}, build_dir) + + generated = tmp_path / "generated" + assert generated.is_dir() and not generated.is_symlink() + assert (generated / "NO_SYMLINK").is_file(), "the fallback must explain itself" + assert not (generated / "CMakeFiles").exists(), "the fallback copied the binary directory" + assert [p.name for p in generated.iterdir()] == ["NO_SYMLINK"] diff --git a/tools/variant_data.py b/tools/variant_data.py new file mode 100644 index 00000000..2a6177f4 --- /dev/null +++ b/tools/variant_data.py @@ -0,0 +1,400 @@ +#!/usr/bin/env python3 +"""Write the variant data files that every documentation tool reads. + +One generation step, one artefact per build shape, and nothing downstream needs +Python to decide anything: `ubproject.toml` conditions and the `{if}` directives +in the documents read these files, and so do `sphinx-build`, `ubc` and the +ubCode language server. The rule that follows from that is the only one worth +remembering here: + + everything a condition may name has to be IN the file. + +A key that only `conf.py` knows is invisible to every other reader, and their +view of the project then silently disagrees with the build. That is why this +writes the complete feature vector -- including the booleans KConfig omits -- +plus the build shape, rather than leaving any of it to be synthesized later. + +Layout:: + + build/variants///.json the matrix + build/variants/GENERATED marker; nobody edits generated output + build/autoconf.json the "current" pointer, one cell of the matrix + generated/ symlink to the current cell's build directory + +Nothing here needs a compiler. KConfig is pure Python, while CMake's top-level +`project()` call demands a C toolchain before it will even configure -- so with +this script the documentation and its quality gate can be built on a machine +that cannot build the software. + +Usage:: + + python tools/variant_data.py --all + python tools/variant_data.py --variant Disco --kit test --target reports --current + python tools/variant_data.py --all --check # CI: regenerate and diff +""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import sys +from pathlib import Path +from typing import Any + +PROJECT_ROOT = Path(__file__).resolve().parent.parent + +#: Components a variant's `parts.cmake` adds only for the test build kit. +TEST_KIT_GUARD = "BUILD_KIT STREQUAL test" + +#: The build shapes spl-core builds. `docs` is the design documentation of a +#: variant; `reports` additionally carries the generated test and coverage +#: output, which is why documents gate report sections on it. +TARGETS = ("docs", "reports") + +#: The build kits. `components` differs between them, because a variant's +#: parts.cmake adds its test suites only for `test`. +KITS = ("prod", "test") + + +def _spl_core_dir() -> Path: + import spl_core + + return Path(spl_core.__file__).parent + + +def variant_names(project_root: Path) -> list[str]: + """Every variant in `variants/`, as the slash-separated name CMake uses. + + A variant is a directory holding a `config.cmake`; it may be one level deep + (`Disco`) or two (`Base/Dev`), which is why this walks rather than lists. + """ + variants_dir = project_root / "variants" + return sorted( + str(path.parent.relative_to(variants_dir)).replace(os.sep, "/") + for path in variants_dir.rglob("config.cmake") + ) + + +def _declared_boolean_symbols(kconfig: Any) -> list[str]: + """Every boolean the feature model declares, however spl-core lets us ask. + + spl-core grew a public `declared_boolean_symbols()` for exactly this, but + this project must keep working against the version pinned in + pyproject.toml -- which is what CI installs and therefore what "works" + means. Depending on an unreleased accessor would make the build pass only + on a machine with a local checkout, which is the same class of mistake as + a configuration only one reader can evaluate. + + So: use the accessor when it is there, and otherwise read the kconfiglib + instance spl-core holds. Drop the fallback once pyproject.toml pins a + release that has the method. + """ + if hasattr(kconfig, "declared_boolean_symbols"): + return kconfig.declared_boolean_symbols() + + import kconfiglib + + return sorted( + name + for name, symbol in kconfig._config.syms.items() # noqa: SLF001 - see docstring + if name and symbol.orig_type == kconfiglib.BOOL + ) + + +def features(project_root: Path, variant: str) -> dict[str, Any]: + """The variant's complete feature vector. + + Every boolean the feature model declares is present, defaulted to False, + before the variant's own values are overlaid. KConfig writes a boolean into + its JSON only when the symbol has a prompt or evaluates to y, so a helper + symbol such as BRIGHTNESS_ADJUSTMENT_ENABLED is simply absent from the + variants where it is off -- and a condition naming it would fail to evaluate + for exactly those variants, which both tools then report as unevaluable and + gate off rather than cleanly answering False. + """ + from spl_core.kconfig.kconfig import JsonWriter, KConfig + + os.environ.setdefault("SPL_CORE_DIR", str(_spl_core_dir())) + os.environ.setdefault("srctree", str(project_root)) + + model_file = project_root / "KConfig" + config_file = project_root / "variants" / variant / "config.txt" + + kconfig = KConfig( + model_file, + config_file if config_file.exists() else None, + project_root, + ) + + # `KConfig.config` only carries the symbols KConfig would write out. The + # model itself knows every symbol, which is what makes the vector complete. + defaults = dict.fromkeys(_declared_boolean_symbols(kconfig), False) + + # spl-core's own JSON writer, so the value conversion (tristates to bool, + # hex, the ${VAR} substitution) is the build's and not a second opinion. + # `generate_content` is the pure half of it; nothing is written here. + values = json.loads(JsonWriter(Path(os.devnull)).generate_content(kconfig.config))["features"] + return {**defaults, **values} + + +def components(project_root: Path, variant: str, kit: str) -> list[str]: + """The components the variant's `parts.cmake` adds for this build kit. + + Parsed rather than imported, because the point is to state the product + structure once, in the file that already states it. The grammar every + parts.cmake in this project uses is a flat list of `spl_add_component()` + calls, optionally with one `if(BUILD_KIT STREQUAL test)` / `else()` / + `endif()` block. + + Anything outside that grammar raises. A parser that shrugged at an `if()` + it did not understand would treat the guarded body as unconditional and + hand back a component list that is quietly wrong for some kit -- and since + this list is what gates the documents, the failure would surface as + documents silently missing from a variant, which is the hardest kind of + wrong to notice. Better to stop and make someone either extend the grammar + or derive the list from CMake. + """ + parts = project_root / "variants" / variant / "parts.cmake" + result: list[str] = [] + #: None outside any if-block, else the kit whose branch we are currently in. + branch_kit: str | None = None + depth = 0 + + for number, raw_line in enumerate(parts.read_text(encoding="utf-8").splitlines(), start=1): + line = raw_line.split("#", 1)[0].strip() + if not line: + continue + lowered = line.lower() + where = f"{parts.relative_to(project_root)}:{number}" + + if lowered.startswith(("if(", "if ")): + if depth or TEST_KIT_GUARD.lower() not in lowered: + raise ValueError( + f"{where}: unsupported condition {line!r}. " + f"{parts.name} may only use a single, non-nested " + f"`if({TEST_KIT_GUARD})` block; extend the grammar in " + "tools/variant_data.py (and its test) deliberately." + ) + depth += 1 + branch_kit = "test" + continue + if lowered.startswith(("else(", "else ", "else")) and not lowered.startswith("elseif"): + if not depth: + raise ValueError(f"{where}: `else()` outside any `if()`.") + # The other side of the test-kit guard is every kit that is not test. + branch_kit = "prod" + continue + if lowered.startswith("elseif"): + raise ValueError(f"{where}: `elseif()` is not supported, see above.") + if lowered.startswith(("endif(", "endif ", "endif")): + if not depth: + raise ValueError(f"{where}: `endif()` without `if()`.") + depth -= 1 + branch_kit = None + continue + if lowered.startswith("spl_add_component("): + if branch_kit is not None and branch_kit != kit: + continue + result.append(line[len("spl_add_component(") : line.rindex(")")].strip()) + continue + + raise ValueError( + f"{where}: unsupported statement {line!r}. {parts.name} may only " + "contain `spl_add_component()` calls and the test-kit guard." + ) + + if depth: + raise ValueError(f"{parts.relative_to(project_root)}: unterminated `if()`.") + + return result + + +def variant_data(project_root: Path, variant: str, kit: str, target: str) -> dict[str, Any]: + """One cell of the matrix: everything a condition anywhere may name.""" + return { + "features": features(project_root, variant), + "build_config": { + "variant": variant, + "kit": kit, + "target": target, + "components": components(project_root, variant, kit), + }, + } + + +def cell_path(project_root: Path, variant: str, kit: str, target: str) -> Path: + return project_root / "build" / "variants" / variant / kit / f"{target}.json" + + +def write_cell(project_root: Path, variant: str, kit: str, target: str, data: dict[str, Any]) -> Path: + path = cell_path(project_root, variant, kit, target) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return path + + +def write_pointer(project_root: Path, data: dict[str, Any], build_dir: Path | None) -> None: + """Point `build/autoconf.json` and `generated` at one cell. + + `ubproject.toml` names both: the data file as `variant_data_file`, and the + build directory as the `dir` of the report mount. A mount `dir` is resolved + relative to the configuration file and cannot name variant data, so the + indirection has to be on the filesystem. + + The link is called `generated` and sits at the project root rather than + inside `build/`, and that name is load-bearing twice over. It is the docname + prefix of every generated page in BOTH tools: sphinx-mounts renames a mount's + documents to its `mount_at`, while ubCode keeps the path a file is found at, + so the only way one toctree entry can mean one page in both is for the path + and the prefix to be the same string. And ubCode's default `exclude` contains + "build", which would otherwise drop the whole mounted tree. + + KNOWN LIMITATION: a symlink gets this right for Sphinx and wrong for ubCode, + which does not descend symlinked directories. The generated report pages are + therefore invisible to the IDE. Materialising them -- copying the .rst files + into a real `generated/` tree after the reports target has produced them -- + is the fix, and it has to happen after that target runs, not here at + configure time when they do not exist yet. Pointing `generated` at a real + directory makes ubCode index them, gated exactly as intended. + """ + pointer = project_root / "build" / "autoconf.json" + pointer.parent.mkdir(parents=True, exist_ok=True) + pointer.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + if build_dir is None: + return + + current = project_root / "generated" + if current.is_symlink() or current.is_file(): + current.unlink() + elif current.is_dir(): + shutil.rmtree(current) + try: + current.symlink_to(build_dir.resolve(), target_is_directory=True) + except OSError: + # Windows without Developer Mode refuses a symlink. + # + # This used to copy the tree. That was wrong three times over: it ran at + # CONFIGURE time, when the reports the path exists for have not been + # generated yet, so it only ever copied object files, binaries and + # CMakeFiles; `dirs_exist_ok` meant a later configure never removed what + # an earlier one left, so the copy grew stale rather than wrong-and- + # obvious; and nothing reads `generated/` yet anyway, so the whole cost + # bought nothing. + # + # A marker instead. It keeps the path present and self-explanatory, and + # when the path does acquire a consumer this is where the decision -- + # copy the documents, or require Developer Mode -- gets made with the + # facts of that day. + current.mkdir(parents=True, exist_ok=True) + (current / "NO_SYMLINK").write_text( + "This platform refused a symlink, so `generated` is a plain directory.\n" + f"It would have pointed at: {build_dir.resolve()}\n" + "\n" + "Nothing reads `generated` yet, so nothing is copied here. Enable\n" + "Developer Mode on Windows to get the symlink, or see\n" + "tools/variant_data.py if this path has since gained a consumer.\n", + encoding="utf-8", + ) + + +#: Dropped into every directory this script owns. `build/` itself gets one too, +#: because that is the directory somebody is most likely to open and edit in. +GENERATED_MARKER = ( + "Written by tools/variant_data.py. Everything under build/ is generated\n" + "output: nobody edits it, neither a person nor an assistant.\n" +) + + +def mark_generated(project_root: Path) -> None: + for directory in (project_root / "build", project_root / "build" / "variants"): + directory.mkdir(parents=True, exist_ok=True) + (directory / "GENERATED").write_text(GENERATED_MARKER, encoding="utf-8") + + +def _check(project_root: Path, selected: list[str]) -> int: + """Regenerate in memory and report cells that are missing or stale. + + The gate this serves is "the variant data on disk is what the sources say + it is". It has to be a comparison rather than a regeneration, because a + regeneration always passes: it would simply overwrite the drift it was + meant to catch and report success. + """ + stale: list[str] = [] + for variant in selected: + for kit in KITS: + for target in TARGETS: + expected = json.dumps(variant_data(project_root, variant, kit, target), indent=2, sort_keys=True) + "\n" + path = cell_path(project_root, variant, kit, target) + rel = path.relative_to(project_root) + if not path.exists(): + stale.append(f"{rel}: missing") + elif path.read_text(encoding="utf-8") != expected: + stale.append(f"{rel}: stale") + + if stale: + print("variant data is not up to date:", file=sys.stderr) + for line in stale: + print(f" {line}", file=sys.stderr) + print("\nrun: python tools/variant_data.py --all", file=sys.stderr) + return 1 + + print(f"variant data up to date ({len(selected)} variants x {len(KITS)} kits x {len(TARGETS)} targets)") + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--project-root", type=Path, default=PROJECT_ROOT) + parser.add_argument("--all", action="store_true", help="generate the whole matrix") + parser.add_argument("--variant", help="variant name, e.g. Disco or Base/Dev") + parser.add_argument("--kit", choices=KITS, default="prod") + parser.add_argument("--target", choices=TARGETS, default="docs") + parser.add_argument( + "--current", + action="store_true", + help="also write build/autoconf.json and the `generated` link for the selected cell", + ) + parser.add_argument( + "--build-dir", + type=Path, + help="CMake binary dir of the selected cell; `generated` points at it", + ) + parser.add_argument( + "--check", + action="store_true", + help="do not write: compare what would be generated against what is on disk", + ) + args = parser.parse_args(argv) + + project_root: Path = args.project_root.resolve() + + if not args.all and not args.variant: + parser.error("either --all or --variant is required") + + selected = variant_names(project_root) if args.all else [args.variant] + + if args.check: + return _check(project_root, selected) + + for variant in selected: + for kit in KITS: + for target in TARGETS: + data = variant_data(project_root, variant, kit, target) + path = write_cell(project_root, variant, kit, target, data) + print(f"wrote {path.relative_to(project_root)}") + + mark_generated(project_root) + + if args.current or not args.all: + data = variant_data(project_root, args.variant or selected[0], args.kit, args.target) + write_pointer(project_root, data, args.build_dir) + print(f"current -> {args.variant or selected[0]} / {args.kit} / {args.target}") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/ubproject.toml b/ubproject.toml index 5625b187..ac11240b 100644 --- a/ubproject.toml +++ b/ubproject.toml @@ -1,24 +1,173 @@ "$schema" = "https://ubcode.useblocks.com/ubproject.schema.json" -extend = ".venv/lib/python3.13/site-packages/spl_core/report_generation/ubproject.toml" +# --------------------------------------------------------------------------- +# The one configuration file. Both readers of this project's documentation read +# it: ubCode and ubc directly, and the Sphinx build through `needs_from_toml` +# in conf.py. +# +# It used to `extend` spl-core's base configuration out of the virtualenv, by a +# path with the Python minor version baked into it. That was wrong twice over. +# The path named a Python that did not exist, so the base configuration silently +# failed to resolve for ubCode; and sphinx-needs does not implement `extend` at +# all -- it reads exactly one file's [needs] table -- so conf.py resolved the +# same base file a second time, through importlib. Two mechanisms for one file, +# one of them broken, and no way to tell from either end. +# +# spl-core's base needs configuration is therefore vendored below rather than +# referenced. test_ubproject_config.py fails if it drifts from what the +# installed spl-core ships, so vendoring cannot rot quietly. +# --------------------------------------------------------------------------- [server] index_on_save = true hover_need_refs = true -[parse.parsers.rst] +# File discovery. Configuring parsers puts ubCode in "parser mode", where the +# document set comes from each parser's `include` and `[source] extend_include` +# is IGNORED -- silently, apart from one config warning. So the document set +# lives here, and it has to be the same set conf.py gives Sphinx through +# `include_patterns`. It was not: `include = ["*.md"]` matched every Markdown +# file in the tree, so ubCode was also parsing AGENTS.md, README.md, CLAUDE.md +# and three dozen agent skill definitions as project documents -- 38 documents +# Sphinx never sees, each reported as missing from a toctree. [parse.parsers.md] flavour = "myst" -include = ["*.md"] +include = [ + "index.md", + "doc/**/*.md", + "components/**/doc/**/*.md", + "test/**/doc/**/*.md", +] -[needs] -schema_definitions_from_json = "schema.json" +# Every .rst in this project is generated: the clanguru source listings and the +# report pages, which live under the configured variant's build directory and +# are therefore covered by `extend_exclude = ["build/**"]` above. ubCode +# deliberately shows none of them -- they are build output, and indexing them +# for every variant ever built is the problem that exclusion solves. +# +# The include below names `generated`, the stable path a future spl-core will +# let the report pages live at. It is inert twice over today: nothing is written +# there yet, and ubCode does not descend symlinked directories anyway +# ("directories are skipped and symlinked directories are not descended"), +# which is what tools/variant_data.py currently makes it. Both were verified, +# not assumed. +[parse.parsers.rst] +include = ["generated/**/*.rst"] +[source] +exclude = [".bzr", ".direnv", ".eggs", ".git", ".git-rewrite", ".hg", ".svn", ".venv", ".vscode", "_build", "dist", "node_modules", "site-packages"] +respect_gitignore = false +# The generated source listings are documents; the rest of build/ is build +# output. Without the exclude, ubCode indexes every variant and every kit ever +# built, so need IDs appear many times over and the IDE disagrees with any one +# build about what the project contains. Sphinx never saw this because conf.py +# narrows its source set per build shape. +# `extend_include` is deliberately absent: it is ignored in parser mode, so the +# generated listings are named by [parse.parsers.rst] instead. +extend_exclude = [ + "build/**", + # The root document of spl-core's PER-COMPONENT report, a build shape + # ubCode never performs. conf.py excludes it from every variant-wide + # build, so Sphinx never sees it either; without this line ubCode indexed + # it and reported it as an orphan -- the two readers disagreeing about the + # document set, which is the one thing this configuration exists to + # prevent. `[parse.parsers.*]` has no `exclude`, and extend_exclude IS + # honoured in parser mode (unlike extend_include), so this is the knob. + "doc/component_report.md", +] + +[needs] variant_data_file = "build/autoconf.json" src_trace_config_from_toml = "ubproject.toml" +# Write the merged needs.json (all needs, after import and resolution). +build_json = true + +# --- vendored from spl-core ------------------------------------------------- +# Modernized to the `links`/`fields` tables while copying: the `extra_links` +# and `extra_options` lists spl-core still ships are deprecated in sphinx-needs +# and warned about on every build, and the `image` field below already used the +# new form, so the file was mixing both. + +[needs.links.realizes] +incoming = "is realized by" +outgoing = "realizes" + +[needs.links.fulfills] +incoming = "is fulfilled by" +outgoing = "fulfills" + +[needs.links.refines] +incoming = "is refined by" +outgoing = "refines" + +[needs.links.implements] +incoming = "is implemented by" +outgoing = "implements" + +[needs.links.results] +incoming = "results from" +outgoing = "results" + +[needs.links.verifies] +incoming = "is verified by" +outgoing = "verifies" + +[needs.links.tests] +incoming = "is tested by" +outgoing = "tests" + +[needs.fields.integrity] +# spl-core declares this through the deprecated `extra_options` list. That form +# implies an empty-string default, which is what every existing need carries, so +# it is stated explicitly here -- the modern form warns when a field declares +# neither schema, nullable nor default, and defaulting to null instead would +# change the value of `integrity` on all 87 needs. +description = "Integrity level of the need" +default = "" + +[needs.fields.integrity.schema] +type = "string" + +[[needs.types]] +directive = "arch" +title = "Architecture" +prefix = "A_" +color = "#DF744A" +style = "node" + +[[needs.types]] +directive = "req" +title = "Requirement" +prefix = "R_" +color = "#BFD8D2" +style = "node" + +[[needs.types]] +directive = "spec" +title = "Specification" +prefix = "S_" +color = "#FEDCD2" +style = "node" + +[[needs.types]] +directive = "impl" +title = "Implementation" +prefix = "I_" +color = "#DF744A" +style = "node" + +[[needs.types]] +directive = "test" +title = "Test Case" +prefix = "T_" +color = "#DCB239" +style = "node" + +# --- end vendored ----------------------------------------------------------- + [needs.fields.image] description = "Image associated with the need" nullable = true @@ -70,3 +219,121 @@ default = [] name = "fulfills" type = "list[str]" default = [] + +# --------------------------------------------------------------------------- +# Variant-dependent documentation. +# +# Two mechanisms, one data source. Both read the file named by +# `[needs] variant_data_file` above, which tools/variant_data.py writes, so +# ubCode, ubc and the Sphinx build decide every condition identically. +# +# `[[source.variant_sources]]` gates whole documents that live IN this tree. +# `[[source.mounts]]` brings in trees that do not -- here, the generated report +# output. Blocks inside a document are gated by the {if} directive of +# Sphinx-Needs, which reads the same `var.*`. +# +# A condition may only name what the variant data file declares. Naming +# anything else makes it unevaluable, and an unevaluable condition EXCLUDES +# what it gates -- so a typo silently shrinks the document set rather than +# failing loudly. +# --------------------------------------------------------------------------- + +# Rules are subtractive: a rule whose condition is FALSE removes the files it +# names, and a TRUE rule does nothing. Two rules naming the same file therefore +# compose as AND, which is what gates the generated report pages on both the +# build shape and the component being part of the variant. + +# Generated output belongs to the `reports` build shape only. +# +# `generated` is the configured variant's build directory, maintained by +# tools/variant_data.py. Without this rule a `docs` build with a populated +# build directory would read every report page and then leave it out of every +# toctree -- pages nobody asked for, one orphan warning each. +[[source.variant_sources]] +if = 'var.build_config.target == "reports"' +files = ["generated/**"] + +# Component documentation, hand-written and generated alike. +# +# Gated on membership of the variant's component list, which the generator +# reads out of that variant's parts.cmake. That is the point: the product +# structure is stated once, in the file that already states it. Gating on the +# owning KConfig feature instead would be a second encoding of the same fact, +# free to drift -- and gating the integration suite on `variant == "Disco"`, as +# this did before, was exactly that drift made concrete: it claimed the suite +# for Disco's prod kit too, where parts.cmake does not add it. +# +# doc/components/index.md carries the full 150% toctree. An entry naming a +# document a rule excluded is reported as INFO by both tools, by design. + +[[source.variant_sources]] +if = "'components/light_controller' in var.build_config.components" +files = [ + "components/light_controller/doc/**", + "generated/components/light_controller/reports/**", + "generated/components/light_controller/__source_docs/**", +] + +[[source.variant_sources]] +if = "'components/main_control_knob' in var.build_config.components" +files = [ + "components/main_control_knob/doc/**", + "generated/components/main_control_knob/reports/**", + "generated/components/main_control_knob/__source_docs/**", +] + +[[source.variant_sources]] +if = "'components/power_button' in var.build_config.components" +files = [ + "components/power_button/doc/**", + "generated/components/power_button/reports/**", + "generated/components/power_button/__source_docs/**", +] + +[[source.variant_sources]] +if = "'components/power_signal_processing' in var.build_config.components" +files = [ + "components/power_signal_processing/doc/**", + "generated/components/power_signal_processing/reports/**", + "generated/components/power_signal_processing/__source_docs/**", +] + +[[source.variant_sources]] +if = "'components/brightness_controller' in var.build_config.components" +files = [ + "components/brightness_controller/doc/**", + "generated/components/brightness_controller/reports/**", + "generated/components/brightness_controller/__source_docs/**", +] + +[[source.variant_sources]] +if = "'components/auto_off' in var.build_config.components" +files = [ + "components/auto_off/doc/**", + "generated/components/auto_off/reports/**", + "generated/components/auto_off/__source_docs/**", +] + +[[source.variant_sources]] +if = "'test/spled_integration' in var.build_config.components" +files = [ + "test/spled_integration/doc/**", + "generated/test/spled_integration/reports/**", + "generated/test/spled_integration/__source_docs/**", +] + +[[source.variant_sources]] +if = "'components/examples/hello_gmock' in var.build_config.components" +files = [ + "components/examples/hello_gmock/doc/**", + "generated/components/examples/hello_gmock/reports/**", + "generated/components/examples/hello_gmock/__source_docs/**", +] + +[[source.variant_sources]] +if = "'components/examples/flight_controller' in var.build_config.components" +files = [ + "components/examples/flight_controller/doc/**", + "generated/components/examples/flight_controller/reports/**", + "generated/components/examples/flight_controller/__source_docs/**", +]