New repo_capture module: resolve a branch or tag-pattern ref rule, fetch it shallowly by name into a bare cache, read the glob-selected files as blobs, and record repo/ref/commit/globs/capture fields in _capture.json. raw status reports changed captured bundles as A/M/D; raw accept --replaces-bundle swaps a captured bundle for its new edition at the same address. iter_raw_files now skips _capture.json and anchors the CONTRACT.md exclusion to raw/CONTRACT.md. Files changed: - .gitignore - CHANGES.md - README.md - VERSION - instructions/wiki-ingest/SKILL.md - raw/CONTRACT.md - tools/CONTRACT.md - tools/README.md - tools/chemenu/cli_contract.py - tools/chemenu/commands/dist_cmd.py - tools/chemenu/commands/docs_verify.py - tools/chemenu/commands/raw_cmd.py - tools/chemenu/config.py - tools/chemenu/repo_capture.py - tools/chemenu/tests/test_cli.py - tools/chemenu/tests/test_raw_capture.py Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
285 lines
19 KiB
Markdown
285 lines
19 KiB
Markdown
# tools/
|
|
|
|
Developer documentation for `wikitool` - how the CLI is built, how to change it,
|
|
and how to run its tests.
|
|
|
|
**This is not the command reference.** That is [CONTRACT.md](CONTRACT.md), which
|
|
`wikitool docs verify` checks against the registered commands. Copying its
|
|
generated command records here would create a second copy that drifts, so this
|
|
file deliberately has none - and `docs verify` now enforces that.
|
|
|
|
| Document | Audience |
|
|
|---|---|
|
|
| `README.md` (this file) | Humans working *on* wikitool |
|
|
| [`CONTRACT.md`](CONTRACT.md) | Agents working *with* wikitool - commands, error contracts, maintenance schedule |
|
|
| [`../AGENTS.md`](../AGENTS.md) | The invariants that say when using a command is mandatory |
|
|
| [`../CHANGES.md`](../CHANGES.md) | What changed in the stack, and when |
|
|
|
|
## Setup
|
|
|
|
```bash
|
|
tools/preflight.sh # from the repo root
|
|
```
|
|
|
|
```powershell
|
|
pwsh -NoProfile -ExecutionPolicy Bypass -File tools/preflight.ps1 # PowerShell 7 on Windows
|
|
```
|
|
|
|
The preflight is the one way a checkout gets its environment: it checks the
|
|
tools listed in `prerequisites.txt`, records their absolute paths in
|
|
`../.wikitool-tools.json`, creates `.venv` and installs `requirements.txt` into
|
|
it with `-m pip`. It is POSIX sh because it has to run before Python is known
|
|
to exist; exit 42 means the user has to act, and its output says how. The
|
|
procedure an agent follows around it is `instructions/preflight.md`.
|
|
|
|
Each release also attaches `preflight.sh` and `preflight.ps1` as assets, for the first install
|
|
before any tree exists. `release.yml` writes the download address of that same release into two
|
|
placeholder lines of the copy (`RELEASE_ARCHIVE_URL`/`RELEASE_CHECKSUM_URL` in the shell script,
|
|
`$ReleaseArchiveUrl`/`$ReleaseChecksumUrl` in the PowerShell one; the tree copy leaves them
|
|
empty). With no `prerequisites.txt` beside it, the script is in *asset mode*: it downloads the
|
|
tarball and its `.sha256`, refuses on a mismatch, reads the folder limit out of the archive,
|
|
unpacks into `<script folder>/chemenu` (`--into <path>` to choose, an existing target is refused),
|
|
and runs the tree copy of itself, passing `--set` and its exit code through. `--archive <tarball>`
|
|
takes a local tarball instead of a download (its `.sha256` has to lie next to it); that is also
|
|
how the tests drive it.
|
|
|
|
`tools/wikitool` refuses to start (exit 42) until the preflight has written a
|
|
*complete* `.wikitool-tools.json` and the venv exists. Past that, every `git`
|
|
and `rg` the package starts goes through `chemenu/toolpaths.py`, which reads
|
|
the recorded path - or, with no file at all (the test suite, a bare
|
|
`python -m chemenu.cli`), falls back to the bare name. A file that is present
|
|
but names a path that has gone raises `ToolPathError`, which the CLI turns
|
|
into an `ERROR` line pointing at the preflight rather than a traceback.
|
|
|
|
The package runs natively on Windows as well, which CI never does. So the rules that keep
|
|
it portable are held by reading the source, in the origin repository's `tests/test_portability.py`: a path that
|
|
becomes a string goes through `.as_posix()`, a text file is opened with `encoding=` and
|
|
written with `newline="\n"`, a `subprocess` call with `text=True` names `encoding="utf-8"`,
|
|
and only `filelock.py` imports `fcntl` or `msvcrt`. An `rg` path comes back with `\` on
|
|
Windows even with `--path-separator /`, so `search/ripgrep.py` converts it where it parses
|
|
the JSON. `.gitattributes` keeps every text file LF in a checkout, `raw/` and `incoming/`
|
|
excepted.
|
|
|
|
`jsonschema` and `PyYAML` are hard dependencies, not optional extras: schema
|
|
validation is the tool's whole safety net, so `cli.py` fails loudly with the
|
|
fix rather than degrading silently.
|
|
|
|
## Layout
|
|
|
|
```
|
|
tools/
|
|
wikitool entry point (POSIX sh): stops with exit 42 until the preflight has passed
|
|
wikitool.ps1 the same entry point for PowerShell 7, which resolves `tools/wikitool` to this file first
|
|
run_wikitool.py what the launcher runs with the venv's Python - puts chemenu on sys.path without PYTHONPATH, sets stdout/stderr to UTF-8
|
|
preflight.sh checks prerequisites.txt, records .wikitool-tools.json, creates .venv (POSIX sh); as the release asset, downloads the stack and unpacks it into its own (empty) folder first
|
|
preflight.ps1 the same for PowerShell 7; also checks the execution policy and the Mark of the Web
|
|
prerequisites.txt what the machine needs, one `|`-separated line per tool - read by the preflight and `doctor`
|
|
trace-hook what the harness hooks call: trace_ingest.py under the venv's Python
|
|
trace-hook.ps1 the same for PowerShell, which resolves `./tools/trace-hook` to this file first - without it Windows asks which app opens the sh script
|
|
bugreport entry point for the bug-report collector (POSIX sh): finds a Python 3.8+ on PATH itself, skipping the Microsoft Store aliases, and runs bugreport.py - no preflight needed
|
|
bugreport.ps1 the same for PowerShell, which resolves `tools/bugreport` to this file first; keeps to what Windows PowerShell 5.1 understands
|
|
bugreport.py the bug-report collector itself - see below
|
|
chemenu/
|
|
cli.py Typer app: registers every command, runs the budget gate, renders `-h`/`--help` from cli_contract
|
|
cli_contract.py one data record per command (name, synopsis, properties, exit status) - the source `-h`, the index and CONTRACT.md's generated region render from
|
|
config.py repo layout: root resolution and every path under it
|
|
api.py the in-process entry point - point Chemenu at a corpus and read it
|
|
errors.py ChemenuError / ValidationError / BackendError
|
|
toolpaths.py where git and rg are started from: .wikitool-tools.json, bare name only without the file
|
|
filelock.py an exclusive lock on an open file, flock on POSIX and msvcrt on Windows - the only module that imports either
|
|
prerequisites.py prerequisites.txt read from Python, plus the platform and long-path questions `doctor` asks
|
|
install_doc.py INSTALL.md held to the instructions: its prerequisites lists generated from prerequisites.txt, its setup questions matched to setup-instance.md's markers
|
|
corpus_cache.py one parsed corpus per commit, never cached while the tree is dirty
|
|
kb_scan.py page iteration/loading over kb/
|
|
blocks.py generated regions in a page body, found by marker rather than by heading
|
|
links.py labelled edges in `related:` - the graph's semantics as data, not prose
|
|
kb_collections.py collection discovery (a directory with COLLECTION.md), and what one declares about itself
|
|
conventions.py kb/CONVENTIONS.md: what this instance decided about authoring, as opposed to what the stack enforces
|
|
ownership.py the stack-vs-instance boundary under a content stage - one predicate, so no caller keeps a list of its own
|
|
type_resolver.py type-spec loading and schema resolution
|
|
catalog.py how the corpus groups into collections and areas, and the shard threshold - with no CLI attached
|
|
lint_core.py the lint checks and the report, with no CLI attached
|
|
types_core.py type-spec listing/description, with no CLI attached
|
|
review.py the weekly review's five checks - the read-time join of kb/gtd/ against the tracker, with no CLI attached
|
|
markdown_code.py masks code spans/fences so a page may show wiki notation, not only use it
|
|
version.py the stack version: VERSION, the release stamp, the compatibility rule
|
|
kb_state.py the KB version (.wikitool-kb.json) and the migration chain
|
|
corpus_diff.py invariant comparison of kb/ between two revisions
|
|
web_capture.py `raw fetch`'s core: fetch a page, decide its charset, derive Markdown-like text from the HTML - standard library only, deterministic
|
|
repo_capture.py `raw capture`/`raw status`'s core: resolve a ref rule, fetch it into a bare cache, select files by glob as blobs, the `_capture.json` manifest - git with the host's credentials, never a prompt
|
|
search/ pluggable search backends, plus service.py - the search core
|
|
tasks/ the task-tracker provider layer: protocol.py (TaskReader/TaskWriter), config.py (.wikitool-tasks.json), one module per adapter - no instruction ever learns which provider it is
|
|
commands/ one module per command or command group: the terminal adapters
|
|
tests/ pytest suite - origin repository only, `dist export` leaves it out
|
|
```
|
|
|
|
**A script beside the package.** `bugreport.py` is not part of `chemenu` and imports nothing
|
|
from it: it is the bug-report collector ([../instructions/bug-report.md](../instructions/bug-report.md)),
|
|
and the case it exists for is a checkout where `wikitool` does not start. It therefore uses the
|
|
standard library only, keeps to Python 3.8 syntax so that an old interpreter can still run it, and
|
|
exits 0 or 1 - never 42, since it opens no gate. It has a second mode: `--pseudonymise` replaces the
|
|
identities it can read from the machine (stage 1), and `--bundle`/`--candidates` applies names a
|
|
model found in the result (stage 2), both word by word so that length, separators and depth
|
|
survive. The mapping, the review list and the candidate file stay beside the bundle directory.
|
|
`dist export` ships it with the rest of `tools/`;
|
|
its tests (`tests/test_bugreport.py`, in the origin repository) run it on a bare interpreter (`-I -S`) to keep that promise.
|
|
|
|
It is started through `tools/bugreport` (`bugreport.ps1` under PowerShell), which assumes no more
|
|
than the collector does: no preflight, no `.wikitool-tools.json`, no venv. It looks for the
|
|
interpreter the way the preflight does - `python3`, `python` on `PATH`; on Windows `python`,
|
|
`py -3`, `python3` - then tries the venv's, probes each for 3.8 or newer, and never starts one under
|
|
`WindowsApps`, where `python3` in Git Bash is the Microsoft Store's alias. Finding none, it exits 1
|
|
and says why.
|
|
|
|
**Two consumers, one core.** The CLI is not the only caller any more. The cores
|
|
(`search/service.py`, `lint_core.py`, `types_core.py`, `catalog.py`) hold what
|
|
decides an answer and import no `typer` and no `rich`; the modules under `commands/` turn
|
|
those values into terminal output and those exceptions into exit codes.
|
|
`api.Corpus` is the in-process entry point over the same functions - it takes a
|
|
corpus root, returns exactly the structures the `--json` forms print, and
|
|
raises instead of exiting. A second consumer is therefore a second adapter
|
|
rather than a second implementation, and the write commands are unreachable
|
|
from `api` because nothing under `commands/` is imported there.
|
|
|
|
**Which corpus.** The root resolves by precedence: an explicit argument, then
|
|
`$CHEMENU_ROOT`, then a walk up from the package's own location. The walk-up is
|
|
the default, so the CLI is unaffected by any of this. Nothing under the root is
|
|
bound at import time - `KB_DIR` and friends follow whatever `ROOT` currently is.
|
|
|
|
## Adding a command
|
|
|
|
1. Write the module under `chemenu/commands/`. A group is a `typer.Typer()`
|
|
app; a single command is a plain function.
|
|
2. Register it in `cli.py` (`app.add_typer(...)` or `app.command(...)`).
|
|
3. Attach a `@cli_contract.record(cli_contract.CommandRecord(...))` decorator to
|
|
the command function, in its own module, and add its path to the matching
|
|
group in `cli_contract.GROUPS`. That one record is the source `wikitool
|
|
<cmd> -h`, the index (`wikitool -h`, and the top of
|
|
[CONTRACT.md](CONTRACT.md)), and CONTRACT.md's generated `#### <path>`
|
|
section all render from - see `cli_contract.py`'s own module docstring for
|
|
the record's shape, and the `CommandRecord` docstring for how its prose is
|
|
written (NOTES as present-tense bullets, one `Failure` per cause with its
|
|
reaction, copyable EXAMPLES, NEVER, SEE ALSO, and no "why" - that goes into
|
|
a comment next to the code). `docs verify` fails in both directions - a command with
|
|
no record and a `GROUPS` entry naming no real command are equally reported -
|
|
and also checks that every non-hidden flag appears in the record's SYNOPSIS.
|
|
Then regenerate the copy: `wikitool docs contract --apply`. Write the
|
|
record's prose without citing an issue number: `docs verify` also refuses
|
|
one in a command's rendered `--help` text (a docstring above its `\f`
|
|
marker, or an option's `help=`) and in any `.md`/`.template` `dist export`
|
|
ships, because the tracker exists only in this repo
|
|
(`instructions/dev/issue-tracking.md` § Citing an issue in the repo).
|
|
4. Add tests. Pure logic belongs in a function separate from the Typer callback
|
|
(see `mass_update_gate_message`, `derive_run_key`, `run_export`), so a test
|
|
does not need a CLI runner - and a Typer callback called directly from a test
|
|
receives `OptionInfo` objects, not values, for any argument the test omits.
|
|
5. Raise the version: `wikitool version bump --minor --title "..."` for a new
|
|
command (`--patch` for a fix, `--major` when the new version is **not a
|
|
drop-in replacement** - a renamed flag or artefact, a stricter check that
|
|
newly fails on **shipped content an instance already had** (a `kb/` page,
|
|
an `instructions/*.md` file), anything needing hand-work after the copy).
|
|
A new command that is merely pickier about its *own* fresh input - a flag
|
|
it did not previously accept, a write it now refuses without more from the
|
|
caller - is the ordinary MINOR case: nothing an instance already has stops
|
|
validating, there is simply more to say when the command is next invoked.
|
|
Content migration is one way to land in the MAJOR row, not the definition
|
|
of it: a `--major` may well ship `--no-migration`, and one that does
|
|
migrate also needs a document under `instructions/migrations/`. The full
|
|
test is `instructions/dev/version-parts.md` - read it before choosing
|
|
`--major`.
|
|
A new command reaches every future instance, and CI's version gate refuses a
|
|
stack change that moved no version.
|
|
|
|
Every command is counted against the iteration budget unless its `cli_contract`
|
|
record's `budget:` property says `exempt` (or, for `version regrade`'s own
|
|
shape, `exempt_without_args`) - `run_budget.is_exempt` reads it from there,
|
|
not from a list of its own. Only read-only retrieval earns it.
|
|
|
|
## Design notes
|
|
|
|
**Deterministic by default.** Anything an LLM would otherwise re-derive -
|
|
frontmatter, index statistics, cross-reference bookkeeping, log formatting,
|
|
version arithmetic - is computed here so it comes out the same every time.
|
|
|
|
**Gates are code, not prompts.** The Mass-Update Gate (`git_publish.py`) and the
|
|
Iteration Budget Gate (`run_budget.py`) refuse in-process, because a
|
|
prompt-level limit is one an agent can talk itself past. Exemption lists are
|
|
constants, never flags.
|
|
|
|
**Clearance is an exit code, not an instruction.** `EXIT_NEEDS_CLEARANCE`
|
|
(42, in `commands/_util.py`) is a third outcome beside success and validation
|
|
error, meaning "a human has to see this output first". The command's own
|
|
message carries the reason, the evidence and the exact `--confirm <token>`
|
|
re-run line; the instruction layer deliberately holds none of it, because a
|
|
procedure written down in advance is one an agent can complete alone. Whether
|
|
a human *actually* saw it is not enforced here - that question is answered in
|
|
the eval layer (`evals/trajectory.py`, `clearance-ended-the-turn`).
|
|
|
|
**Nothing locates a region by its prose.** `xref` owns the links region and `cite` the footnotes
|
|
region, and each is delimited by a `<!-- wikitool:<name> -->` marker pair (`blocks.py`). The
|
|
heading inside is rendered from `kb/CONVENTIONS.md` and is replaced along with the rest of the
|
|
region on every write - so no heading text exists in Python, and changing the declaration cannot
|
|
split a page.
|
|
|
|
Both halves of that mattered. Matching on the heading made the KB language a compiler constant;
|
|
*guessing* where the region ended - at the next heading, and before that at the end of the file -
|
|
silently deleted content sitting after it on eight pages. `migrate verify` compares marker-pair
|
|
counts for the same reason it compares wikilink counts: a dropped marker is invisible otherwise.
|
|
|
|
**A relationship label is data, not prose.** `related:` carries `- <label>: <target>`
|
|
(`links.py`), the label drawn from `instructions/link-taxonomy.md` and authorised per
|
|
destination by the *source* collection's `outbound:` block. The body bullet is a rendering of
|
|
that, which is what removed the need to parse a German phrase back into a relationship - and why
|
|
the vocabulary can be checked at all, after drifting to 152 distinct labels while it could not
|
|
be. Both readers accept a bare title as an unlabelled edge: that is the shape a page is in
|
|
between the machinery landing and the migration reaching it, and `lint` is what reports it.
|
|
|
|
**An edge is authored in one direction.** `xref add` writes one, on the asserting page. The
|
|
inbound view is rendered from the graph rather than stored, so navigation does not depend on
|
|
anyone writing a mirror - and per-collection authorisation stays coherent, which it cannot be if
|
|
the tool writes edges into a collection whose rules the author never read.
|
|
|
|
**Generated output is never committed.** `reports/`, `.agents/skills/` and
|
|
`.claude/skills/` are build output; `docs verify` carries canaries in both
|
|
directions so an ignore rule can neither swallow tracked content nor stop
|
|
ignoring generated copies.
|
|
|
|
<!-- dist:strip-start -->
|
|
## Tests
|
|
|
|
The suite exists in the origin repository only: `dist export` leaves out `chemenu/tests/`,
|
|
`pytest.ini` and `.coveragerc`, because the suite tests that repository's own type-specs and
|
|
conventions, not an instance's.
|
|
|
|
```bash
|
|
cd tools && .venv/bin/python -m pytest -q
|
|
```
|
|
|
|
`pytest.ini` sets the import path. Tests use `tmp_path` fixtures and monkeypatch
|
|
`config` paths rather than touching the real `kb/`.
|
|
|
|
Coverage is optional locally and measured on every CI run:
|
|
|
|
```bash
|
|
.venv/bin/pip install pytest-cov # one time, CI-only dependency
|
|
.venv/bin/python -m pytest -q --cov
|
|
```
|
|
|
|
`pytest-cov` is deliberately not in `requirements.txt`: that file is what an *instance*
|
|
needs at runtime and ships with `dist export`, and an instance does not measure this
|
|
suite. Configuration is `.coveragerc` (coverage.py does not read `pytest.ini`), which
|
|
measures `chemenu/` without `chemenu/tests/` and sets no threshold - see EVALS.md
|
|
for the measured number and how to read it.
|
|
|
|
**Two exceptions, and they bite.** `test_types_cmd.py` and `test_index_build.py` resolve the real
|
|
`types/` through the shared `resolver`, because what they assert *is* that behaviour comes from the
|
|
type-specs rather than from a hardcoded map. Editing a type-spec can therefore fail tests that look
|
|
unrelated to it - translating the wiki broke six that way. Anything those tests pin must be
|
|
structural: `dir:` values and layout order are asserted exactly, display titles are read from the
|
|
spec at assert time, never written out. The same rule applies to the template fence - anchor on a
|
|
section name from `sections`, not on a literal.
|
|
|
|
Assert on prose in a type-spec or a contract only when the prose is the subject of the test.
|
|
Otherwise it is a tripwire that fires on an edit nobody connected to the test.
|
|
<!-- dist:strip-end -->
|