diff --git a/.dev/tools/check-cookbook-snippets.py b/.dev/tools/check-cookbook-snippets.py index 4c692dd..c5799a9 100755 --- a/.dev/tools/check-cookbook-snippets.py +++ b/.dev/tools/check-cookbook-snippets.py @@ -1,13 +1,18 @@ #!/usr/bin/env python3 """Verify the Python snippets in doc/cookbook/*.md against the installed dp_python_lib. -Two passes, because they have different blind spots: +Three passes, because they have different blind spots: 1. ast.parse() -- syntax errors. 2. mypy -- wrong attribute names, wrong method names, wrong keyword arguments, wrong arity. This is the class of error that matters most here: a recipe that writes `result.pv_metadata_list` when the attribute is `result.pv_metadata` is valid Python and sails through pass 1. + 3. imports -- every name a `# cookbook:partial` snippet uses that the preamble + *imports* must also be bound somewhere in the snippet's own recipe + (#75). The preamble supplies those names to pass 2, so without this a + recipe that never imports `dfc` type-checks cleanly and still raises + NameError for a reader who copies it; #74 shipped that twice. Background: on the dp-grpc side, extracting and compiling the Java snippets found four real defects that a careful multi-agent proto-verification pass had missed entirely. Name-checking @@ -35,6 +40,7 @@ from __future__ import annotations import argparse +import ast import importlib.util import os import re @@ -246,8 +252,6 @@ def extract(path: Path) -> list[Snippet]: def check_syntax(snippet: Snippet) -> list[str]: """Pass 1: does it parse at all?""" - import ast - try: ast.parse(snippet.code) except SyntaxError as exc: @@ -354,6 +358,100 @@ def check_types(snippets: list[Snippet], verbose: bool) -> list[str]: return errors +def preamble_imports() -> set[str]: + """The names PREAMBLE binds by import -- the names pass 3 holds each recipe to importing itself. + + Parsed from the preamble rather than listed, so there is no second list to keep in step. Its + fixtures (`client`, `params`, the carried-forward ids, `acquire()`) are assignments and defs, not + imports, so they are excluded by construction: recipes legitimately carry those between snippets. + """ + names: set[str] = set() + for node in ast.walk(ast.parse(PREAMBLE)): + if isinstance(node, ast.Import): + names.update(a.asname or a.name.split(".")[0] for a in node.names) + elif isinstance(node, ast.ImportFrom): + names.update(a.asname or a.name for a in node.names) + return names + + +def bound_names(tree: ast.AST) -> set[str]: + """Every name a snippet binds, anywhere in it. + + Deliberately looser than Python: scope and order are ignored, so a name imported inside a + function or in a later block still counts. Recipes are flat scripts, and the question is + whether the recipe imports the name at all -- which is what #74 got wrong. + """ + names: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + names.update(a.asname or a.name.split(".")[0] for a in node.names) + elif isinstance(node, ast.ImportFrom): + names.update(a.asname or a.name for a in node.names) + elif isinstance(node, ast.Name) and isinstance(node.ctx, (ast.Store, ast.Del)): + names.add(node.id) + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + names.add(node.name) + elif isinstance(node, ast.arg): + names.add(node.arg) + elif isinstance(node, ast.ExceptHandler) and node.name: + names.add(node.name) + return names + + +def check_recipe_imports(snippets: list[Snippet]) -> list[str]: + """Pass 3: does each recipe import the preamble-imported names its partial snippets use? + + Bindings come from every checked snippet in the file (the imports block, inline imports, + standalone and no-mypy blocks); uses only from partial ones, since a standalone snippet is + type-checked without the preamble and mypy already reports a missing import there. Skipped + snippets contribute neither. One error per name per file, at its first use. Expects + snippets that parse; pass 1 has already reported any that do not. + """ + imported = preamble_imports() + by_file: dict[Path, list[Snippet]] = {} + for snippet in snippets: + if not snippet.skip: + by_file.setdefault(snippet.path, []).append(snippet) + + errors: list[str] = [] + for path, file_snippets in by_file.items(): + bound: set[str] = set() + uses: dict[str, list[int]] = {} + for snippet in file_snippets: + tree = ast.parse(snippet.code) + bound |= bound_names(tree) + if not snippet.partial: + continue + for node in ast.walk(tree): + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load) and node.id in imported: + uses.setdefault(node.id, []).append(snippet.start_line + node.lineno - 1) + + for name, lines in uses.items(): + if name in bound: + continue + lines.sort() + more = f" [+{len(lines) - 1} more use{'s' if len(lines) > 2 else ''}]" if len(lines) > 1 else "" + errors.append( + f"{display_path(path)}:{lines[0]}: '{name}' is used but never imported by this recipe " + f"(the checker preamble supplies it; add it to the recipe's imports){more}" + ) + return errors + + +# Pass 3's self-test recipe: a partial snippet using a preamble import the recipe never imports. +# Checked alone it must be reported; with IMPORTS_CANARY_FIX alongside it, it must not be. The +# first catches a rule that stops matching; the second, one that ignores bindings and would then +# fail the real cookbook for a confusing reason. +IMPORTS_CANARY = """\ +# cookbook:partial +columns = dfc.data_frame_columns(frame) +""" + +IMPORTS_CANARY_FIX = """\ +from dp_python_lib.client import data_frame_conversions as dfc +""" + + # A snippet that MUST fail. If mypy stops resolving dp_python_lib -- a moved src layout, a # missing MYPYPATH, an uninstalled package -- it reports success on everything and the checker # becomes a rubber stamp that looks exactly like clean docs. This canary makes that loud. @@ -365,7 +463,7 @@ def check_types(snippets: list[Snippet], verbose: bool) -> list[str]: def self_test(verbose: bool) -> list[str]: - """Confirm the mypy pass can still detect a known-bad attribute.""" + """Confirm pass 2 still flags a known-bad attribute, and pass 3 a missing import (and only that).""" canary = Snippet( path=REPO_ROOT / "", start_line=1, @@ -384,7 +482,38 @@ def self_test(verbose: bool) -> list[str]: " Verify with: MYPYPATH=$PWD/src .venv/bin/mypy --ignore-missing-imports " "--follow-imports=silent " ] - return [] + + errors: list[str] = [] + imported = preamble_imports() + if "dfc" not in imported: + errors.append( + "SELF-TEST FAILED: the preamble's imports were not found (expected 'dfc' among " + f"{sorted(imported)}), so the recipe import check would check nothing." + ) + + def canary_snippet(code: str, start_line: int, partial: bool) -> Snippet: + return Snippet( + path=REPO_ROOT / "", + start_line=start_line, + code=code, + partial=partial, + skip=False, + no_mypy=False, + ) + + if not check_recipe_imports([canary_snippet(IMPORTS_CANARY, 1, True)]): + errors.append( + "SELF-TEST FAILED: the recipe import check did not flag 'dfc' used without an import, " + "so a recipe missing its imports would pass." + ) + fixed = [canary_snippet(IMPORTS_CANARY_FIX, 1, False), canary_snippet(IMPORTS_CANARY, 5, True)] + found = check_recipe_imports(fixed) + if found: + errors.append( + "SELF-TEST FAILED: the recipe import check flagged a name the recipe does import, " + f"so it is ignoring bindings: {found}" + ) + return errors def main() -> int: @@ -394,7 +523,7 @@ def main() -> int: parser.add_argument( "--no-self-test", action="store_true", - help="skip the canary that verifies name checking still works", + help="skip the canaries that verify name and import checking still work", ) args = parser.parse_args() @@ -433,7 +562,7 @@ def main() -> int: # Verify the checker itself works before trusting a clean result from it. if not args.no_self_test: if args.verbose: - print(" running self-test (canary)...", file=sys.stderr) + print(" running self-test (canaries)...", file=sys.stderr) canary_errors = self_test(args.verbose) if canary_errors: print("\nFAIL: checker self-test failed\n") @@ -450,8 +579,13 @@ def main() -> int: errors.extend(found) syntax_failed.add(id(snippet)) + parsed = [s for s in checked if id(s) not in syntax_failed] + + # Pass 3 needs only the AST, so run it before the slow mypy pass; its errors are sorted in below. + errors.extend(check_recipe_imports(parsed)) + # Pass 2, excluding anything that already failed to parse. - errors.extend(check_types([s for s in checked if id(s) not in syntax_failed], args.verbose)) + errors.extend(check_types(parsed, args.verbose)) files_desc = f"{len(paths)} file{'s' if len(paths) != 1 else ''}" counts = f"{len(checked)} snippet{'s' if len(checked) != 1 else ''} in {files_desc}" diff --git a/CLAUDE.md b/CLAUDE.md index 36915ed..b64b046 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,6 +43,11 @@ ruff format --check . # verify formatting without writing (what CI runs) Two paths are excluded deliberately: `src/dp_python_lib/grpc/` (generated, regenerated wholesale) and `*.md` (ruff would reformat the hand-wrapped Python snippets in this file and `doc/cookbook/`; those are verified instead by `.dev/tools/check-cookbook-snippets.py`). +That checker also holds each recipe to its own imports (#75): its shared preamble supplies the +library's imports to every partial snippet, which hid a missing import from mypy twice in #74, so +every name a partial snippet uses that the preamble imports must be bound somewhere in the same +recipe. It still cannot check values carried *between* snippets (`dataset_id`, `provider_id`); +running a recipe as one script with only its own imports remains the way to verify those. When a rule fires on something intentional, suppress it with a per-line `# noqa: RULE` plus a comment saying why, rather than reshaping correct code to satisfy the linter. Existing diff --git a/doc/cookbook/README.md b/doc/cookbook/README.md index 25d5b7b..d8d998c 100644 --- a/doc/cookbook/README.md +++ b/doc/cookbook/README.md @@ -66,7 +66,8 @@ Attribute names and values are the facility's; tag values are illustrative place ## Verifying the examples Every Python snippet in this directory is mechanically checked — parsed for syntax, then -type-checked against the installed package to catch wrong attribute and method names: +type-checked against the installed package to catch wrong attribute and method names — and each +recipe is checked for importing every library name its fragments use: ```bash pip install -e .[dev] @@ -75,7 +76,12 @@ pip install -e .[dev] The checker self-tests before each run: if mypy ever stops resolving `dp_python_lib`, it would report success on every snippet regardless of correctness, so a canary asserts that a known-bad -attribute is still flagged. +attribute is still flagged. A second canary does the same for the import check. Snippets carry `# cookbook:partial` when they are fragments that assume a client, and -`# cookbook:skip` or `# cookbook:no-mypy` where checking does not apply. +`# cookbook:skip` or `# cookbook:no-mypy` where checking does not apply. A partial snippet is +type-checked with a shared preamble that supplies `client` and the library's imports, so the +import check is what holds a recipe to its own imports: every name a partial snippet uses that the +preamble imports must be bound somewhere in the same recipe — usually its "Imports used by the +examples" block, which is checked like any other snippet. Do not mark that block +`# cookbook:skip`; a skipped block binds nothing. diff --git a/doc/cookbook/connecting.md b/doc/cookbook/connecting.md index e76fa0e..8444032 100644 --- a/doc/cookbook/connecting.md +++ b/doc/cookbook/connecting.md @@ -174,6 +174,8 @@ a key present in the YAML file silently ignored its `MLDP_*` variable. ```python # cookbook:partial +from dp_python_lib.client import QueryParams, PvQuery as PV + if client.query is None: raise RuntimeError("no query channel configured") diff --git a/doc/cookbook/conventions.md b/doc/cookbook/conventions.md index e58b5f8..2049e1d 100644 --- a/doc/cookbook/conventions.md +++ b/doc/cookbook/conventions.md @@ -11,6 +11,17 @@ For the wire-level view of these same conventions — the protobuf messages and pattern this library wraps — see the [dp-grpc cookbook](https://github.com/osprey-dcs/dp-grpc/blob/main/doc/cookbook/conventions.md). +### Imports used by the examples + +```python +from dp_python_lib.client import ( + MldpClient, + SavePvMetadataRequestParams, + PvMetadataQuery as Q, + to_timestamp, +) +``` + ## Contents - [Checking results](#checking-results) — the one pattern every call shares diff --git a/doc/cookbook/datasets-and-annotations.md b/doc/cookbook/datasets-and-annotations.md index 49dce1b..6c0cfad 100644 --- a/doc/cookbook/datasets-and-annotations.md +++ b/doc/cookbook/datasets-and-annotations.md @@ -15,7 +15,6 @@ channel is configured, so guard on it before reaching through. ### Imports used by the examples ```python -# cookbook:skip from datetime import datetime, timezone from dp_python_lib.client import ( diff --git a/doc/cookbook/ingestion.md b/doc/cookbook/ingestion.md index ce21b39..6351f3e 100644 --- a/doc/cookbook/ingestion.md +++ b/doc/cookbook/ingestion.md @@ -17,7 +17,6 @@ All examples use `client.ingestion_client`. ### Imports used by the examples ```python -# cookbook:skip import contextlib from datetime import datetime, timedelta, timezone diff --git a/doc/cookbook/machine-configuration.md b/doc/cookbook/machine-configuration.md index 29d375f..d863ed2 100644 --- a/doc/cookbook/machine-configuration.md +++ b/doc/cookbook/machine-configuration.md @@ -11,7 +11,6 @@ unless an annotation channel is configured, so guard on `client.annotation` befo ### Imports used by the examples ```python -# cookbook:skip from datetime import datetime, timezone from dp_python_lib.client import ( diff --git a/doc/cookbook/pv-metadata.md b/doc/cookbook/pv-metadata.md index 2a2e7ba..734063c 100644 --- a/doc/cookbook/pv-metadata.md +++ b/doc/cookbook/pv-metadata.md @@ -12,7 +12,6 @@ unless an annotation channel is configured, so guard on `client.annotation` befo ### Imports used by the examples ```python -# cookbook:skip from dp_python_lib.client import ( MldpClient, SavePvMetadataRequestParams, diff --git a/doc/cookbook/query.md b/doc/cookbook/query.md index a531b0c..ba64ef0 100644 --- a/doc/cookbook/query.md +++ b/doc/cookbook/query.md @@ -17,7 +17,6 @@ All examples use `client.query`, which is `None` unless a query channel is confi ### Imports used by the examples ```python -# cookbook:skip from datetime import datetime, timezone from dp_python_lib.client import ( diff --git a/doc/cookbook/sample-status.md b/doc/cookbook/sample-status.md index c7aba41..9b3104b 100644 --- a/doc/cookbook/sample-status.md +++ b/doc/cookbook/sample-status.md @@ -13,7 +13,6 @@ unless an annotation channel is configured, so guard on `client.annotation` befo ### Imports used by the examples ```python -# cookbook:skip from datetime import datetime, timezone from dp_python_lib.client import ( diff --git a/plan/tickets/75/plan.md b/plan/tickets/75/plan.md new file mode 100644 index 0000000..34f4dbd --- /dev/null +++ b/plan/tickets/75/plan.md @@ -0,0 +1,166 @@ +# Issue #75 — Cookbook checker: verify each recipe imports the names its snippets use + +**Status:** triaged and implemented 2026-10-02; plan and implementation land in one PR (the ticket +is small enough that a separate plan-only PR was declined). + +## Overview + +`.dev/tools/check-cookbook-snippets.py` gains a third pass: every name a `# cookbook:partial` snippet +uses that the checker preamble *imports* must also be bound somewhere in the snippet's own recipe. +The six recipes' "Imports used by the examples" blocks stop being `# cookbook:skip`, so they are +type-checked themselves and supply the bindings the new pass looks for. `conventions.md` and +`connecting.md`, the two pages that rely on preamble imports today, get imports of their own. For +cookbook authors and reviewers: the class of miss that #74 hit twice becomes a CI failure. + +## Background / triage findings + +- **The draft's diagnosis holds.** The preamble (`PREAMBLE`, checker L70–188) imports 66 names, and + every partial snippet is checked with it prepended, so a recipe's own imports are never consulted. + A prototype of the proposed rule (pure `ast`, below) run against the cookbook as it stood before each + #74 fix reports exactly the two misses the ticket names: `dfc` in `query.md` (at `eedae6a~1`), and + `QueryParams` / `PV` / `bc` in `ingestion.md` (reconstructed by deleting those three imports, since + that fix landed inside 00d9487 rather than as its own commit). Against today's six recipes it + reports nothing. +- **The imports blocks themselves are never checked — a gap the ticket does not mention.** All six + "Imports used by the examples" blocks open with `# cookbook:skip`, as they have since the cookbook + was written (495ed2f, #15); they are the only skipped blocks in the cookbook. So a misspelled name + *in the imports block* passes today: changing `PvQuery as PV` to `PvQuerry as PV` and + `query_conversions` to `query_conversion` in `query.md` is reported by nothing. With the skip + removed, mypy flags both (`Module "dp_python_lib.client" has no attribute "PvQuerry"`), and the + current cookbook still passes (128 snippets, 0 skipped). This also settles the draft's step 1: + the blocks have to be checked anyway, and once they are, their bindings need no special handling. +- **The `conventions.md` / `connecting.md` question in the draft resolves to "real hits".** Both + uses are in fenced, checked code blocks, not prose: + - `conventions.md` uses `Q` (L69 onward), `SavePvMetadataRequestParams` (L235, L241), and + `to_timestamp` (L269 onward) and has no imports block at all. It is a cross-cutting page that + recipes link to, but a reader copying from it meets the same `NameError`. + - `connecting.md`'s "Sub-clients can be None" block (L175) uses `QueryParams` and `PV`. Every + other block on the page is standalone and imports what it uses. + Decision (2026-10-02): give both pages imports rather than an opt-out directive (D5). +- **The draft's prose hits need no special handling.** The checker only ever sees fenced + ` ```python ` blocks, so `Q.attributes(...)` in a callout or `dfb.serialized_column()` in a bullet + is invisible to it by construction. +- **`ruff --select F821` is the wrong tool here** (the draft offers it as one option). mypy already + reports any name the preamble does not define, so the only gap is names the preamble *does* define; + F821 over a concatenated recipe would re-report mypy's errors and need a stub header for the + fixtures. It would also tie the checker to ruff, while the checker's one external tool (mypy) is + deliberately resolved from the running interpreter. +- **No open ticket folds in.** #61's step 5 waits on a stub sync carrying `.pyi` files (dp-grpc#158 + is closed, but no `grpc-sync-*` PR has brought them, and dp-grpc's latest release is still + rel-1.16.0); its "checker gets stricter" item is about mypy, not imports. #68 waits on + osprey-dcs/dp-grpc#165 and osprey-dcs/dp-service#302, both open. #76 (PyPI) touches the cookbook's + install text only. + +## Design decisions + +**D1. Pure `ast`, no new dependency.** The pass parses each snippet (already done in pass 1) and +walks it. Rejected: `ruff --select F821` (above). + +**D2. "Used" means a `Load` of a name the preamble imports.** The set is computed by parsing +`PREAMBLE`'s own `import` / `from … import` statements (asname if present, else the top-level module +for `import a.b`, else the imported name), so it tracks the preamble with no second list to maintain. +The preamble's fixtures (`client`, `begin`/`end`, `params`, `t0`/`t1`, the carried-forward ids, +`since`, `request`, `acquire()`) are not imports and are therefore out of scope automatically, as +the ticket asks. + +**D3. "Bound" means bound anywhere in the same file's checked blocks.** Imports (`import`, +`from … import`, with `as`), assignment and other `Store` targets (`for`, `with … as`, +comprehensions, walrus), `def` / `class` names, parameters, and `except … as` names, collected from +every non-skip block in the file: the imports block, inline imports, standalone blocks, and +`no-mypy` blocks. Order and scope are ignored: a name imported in a later block, or inside a +function, still counts. That is looser than Python, but recipes are flat scripts and no real case +needs the strictness; the job is "does the recipe import it at all", which is exactly what failed in +#74. Rejected: order-sensitive, module-level-only binding (more code, no case it would catch). +`# cookbook:skip` blocks bind nothing, because they may be pseudo-code or deliberately wrong. + +**D4. Only partial blocks are checked for uses.** A standalone block is checked without the +preamble, so mypy already reports a missing import there as `Name "x" is not defined`; checking it +again would double-report. Partial `no-mypy` blocks *are* checked, since this pass does not depend +on mypy. + +**D5. Unskip the imports blocks; add imports to the two shared pages.** Each recipe's imports block +loses `# cookbook:skip` and is then a standalone block like any other: syntax-checked, type-checked, +and a source of bindings. No new directive. `conventions.md` gets its own "Imports used by the +examples" block (after the intro, before Contents, matching the recipes); `connecting.md`'s one +partial block gets an inline `from dp_python_lib.client import QueryParams, PvQuery as PV`, since the +page has no imports block and every other block there is self-contained. Rejected: a documented +opt-out directive for shared pages — it would exempt exactly the pages most often copied from. + +**D6. One error per name per file, at its first use.** Format: +`doc/cookbook/query.md:434: 'dfc' is used but never imported by this recipe (the checker preamble +supplies it; add it to the recipe's imports) [+3 more uses]`. Sorted with the other errors by file +and line. + +**D7. Self-test, in the style of the existing canary.** Before the real run, the pass is fed two +synthetic recipes: one partial snippet using `dfc` with no import anywhere (must report `dfc`), and +the same snippet plus a separate block importing `data_frame_conversions as dfc` (must report +nothing). Also asserted: the preamble-import set is non-empty and contains `dfc`, so a preamble +refactor that the parser stops seeing (say, imports moved under a conditional the walk skips) fails +loudly. The first case catches a rule that stops matching; the second catches one that ignores +bindings and would then fail on the real cookbook for a confusing reason. Both run under the +existing `--no-self-test` switch. + +**D8. No `NEXT.md` entry.** The change is to repository tooling and to cookbook import lines; no user +of the library or its release artifacts behaves differently. Same call as #70. + +## Implementation tasks + +**`.dev/tools/check-cookbook-snippets.py`** +- Module docstring: "Two passes" becomes three; describe pass 3 and why it exists (the preamble + masks a recipe's own imports). +- `preamble_imports() -> set[str]` from `ast.parse(PREAMBLE)`. +- `bound_names(tree) -> set[str]` per D3. +- `check_recipe_imports(snippets) -> list[str]`: group by `snippet.path`; bindings from every + non-skip snippet that parses; uses from partial non-skip snippets; errors per D6. +- `self_test()`: add the D7 cases next to the canary; report failures in the same + `SELF-TEST FAILED: ...` form. +- `main()`: run pass 3 after pass 1, over snippets that parsed (it needs the AST; a syntax error is + already reported). As built it also runs before pass 2: it needs nothing from mypy, and the + errors are sorted by file and line afterwards anyway, so the order is invisible in the output. + +**Cookbook** +- Remove `# cookbook:skip` from the imports block in `datasets-and-annotations.md`, `ingestion.md`, + `machine-configuration.md`, `pv-metadata.md`, `query.md`, `sample-status.md`. +- `conventions.md`: add "### Imports used by the examples" with `from dp_python_lib.client import + MldpClient, SavePvMetadataRequestParams, PvMetadataQuery as Q, to_timestamp`. As built, those + are the three names the pass reported plus `MldpClient`, listed to match the other recipes' + blocks; `datetime` / `timezone` were left out because the page's one block using them already + imports them inline. +- `connecting.md` L175 block: inline import per D5. +- `README.md` "Verifying the examples": add that each recipe must import what its snippets use, and + that this is checked; the directives line no longer needs to cover the imports block. + +**`CLAUDE.md`** +- In Linting and Formatting, where the checker is first mentioned, one sentence: the checker also + enforces that each recipe's own imports cover its partial snippets, because the shared preamble + would otherwise hide a missing import. It still cannot check values carried *between* snippets; + running a recipe as a script with only its own imports remains the way to verify those. + +**Verification** +- `python .dev/tools/check-cookbook-snippets.py` passes on the branch. +- Against `git show eedae6a~1:doc/cookbook/query.md` and an `ingestion.md` with the three imports + removed, it reports `dfc` and `QueryParams` / `PV` / `bc` respectively. +- Misspelling a name in an imports block fails the run. +- `ruff check` / `ruff format --check` are unaffected (`.dev/` and `*.md` are excluded); CI needs no + change, since the quality job already runs the script. + +## Out of scope + +- Checking values carried between snippets (`dataset_id`, `provider_id`, …). The preamble seeds + those deliberately; a recipe binding the wrong name is still caught only by running it as one + script, as the preamble's own comment says. +- Pruning preamble imports no recipe uses (e.g. the `typing` aliases). Harmless once the new pass + exists, and unrelated. +- Making the checker importable as a module (its `@dataclass` needs `sys.modules` registration when + loaded by path); the self-test lives inside it, so nothing needs that. + +## Dependencies and sequencing + +None. Independent of #61, #68, and #76. + +## Open questions + +Resolved 2026-10-02: + +1. **Shared pages: imports or opt-out?** Imports (D5). +2. **PR shape?** One PR carrying the plan and the implementation, `Closes #75`.