diff --git a/docs/host-setup.md b/docs/host-setup.md index add21460..96109f08 100644 --- a/docs/host-setup.md +++ b/docs/host-setup.md @@ -14,13 +14,15 @@ Supported hosts: This section is the **contract**: which tools a host needs and which repo procedure stops working without each one. It deliberately names no installer, because `winget`, `brew` and `apt` differ per platform while the requirement does not. Per-platform install commands are tracked separately, so this table stays true on every host. -| Tool | Needed by | Present when | -| --- | --- | --- | -| `git` | everything, and the identity and signing contract in [`STANDUP.md`][standup] step 0 | `git --version` | -| `gh` | the PR and review loop, `gh api` queries, `repo-config/configure.sh` | `gh --version` | -| Python 3 | `scripts/` and `spec/` (standard library only, no packages to install) | `python3 --version`, or `py -3 --version` on native Windows | -| `docker` | the four linters, which run as pinned images rather than local installs | `docker --version` | -| `uv` / `uvx` | coverage runs, and the Python toolchain (`ruff`, `pyright` or `mypy`) in a Python repo | `uv --version` | +| Tool | Needed by | Present when | Floor | +| --- | --- | --- | --- | +| `git` | everything, and the identity and signing contract in [`STANDUP.md`][standup] step 0 | `git --version` | none | +| `gh` | the PR and review loop, `gh api` queries, `repo-config/configure.sh` | `gh --version` | **2.47.0**, measured | +| Python 3 | `scripts/` and `spec/` (standard library only, no packages to install) | `python3 --version`, or `py -3 --version` on native Windows | **3.13**, target | +| `docker` | the four linters, which run as pinned images rather than local installs | `docker --version` | none | +| `uv` / `uvx` | coverage runs, and the Python toolchain (`ruff`, `pyright` or `mypy`) in a Python repo | `uv --version` | none | + +The **Floor** column exists because presence and sufficiency are different questions and the answer to the first was being read as the answer to the second. A tool below its floor still answers `--version`, so every other column reports it as fine while `scripts/host_gate.py` fails it. The kind is named beside the number, since a **measured** floor sits above a version known to break a documented procedure and gives a failing host a defect to point at, where a **target** floor names the version the repo's toolchain is configured for and does not. The next section carries the reasoning behind each one. Two consequences worth reading off the table rather than discovering later. **Python 3 needs no packages**, because every script here is standard library only, so a bare interpreter is enough. And **the linters need only `docker`**, not `node`, `dotnet` or a local `markdownlint`, since each runs as a pinned image, which is what keeps a local run and CI the same check. diff --git a/scripts/test_host_gate.py b/scripts/test_host_gate.py index b82d10c1..bcc1ddc3 100755 --- a/scripts/test_host_gate.py +++ b/scripts/test_host_gate.py @@ -9,6 +9,7 @@ from __future__ import annotations import json +import re import sys import unittest from pathlib import Path @@ -415,6 +416,55 @@ def test_the_declared_floors_are_the_ones_with_a_stated_reason(self): floors = {t['name'] for t in self.data['tools'] if t['minimum'] is not None} self.assertEqual(floors, {'gh', 'git-restore-mtime', 'python3'}) + def test_the_contract_table_carries_every_declared_floor(self): + """docs/host-setup.md restates the floors, so the doc goes stale the moment the data moves. + + The table's other columns answer presence, and a tool below its floor answers `--version` + like any other, so the Floor column is the only thing there that distinguishes present from + sufficient. A number that drifts out of step with the data is worse than an absent one, + since a host reads the table and stops. + """ + def norm(text): + """A prose label reduced to the identifier it names, so `Python 3` keys as `python3`.""" + return re.sub(r'[^a-z0-9.]', '', text.lower()) + + doc = (host_gate.SPEC.parent.parent / 'docs' / 'host-setup.md').read_text(encoding='utf-8') + rows, floor_col = {}, None + for ln in doc.splitlines(): + cells = [c.strip() for c in ln.strip('|').split('|')] if ln.startswith('| ') else None + # Reading stops at the end of the contract table rather than at the end of the file. + # A later table's first column is a key like any other and would overwrite a tool row. + # The document carries a second table today whose keys collide with nothing. + # A test that depends on that is a test the next table silently breaks. + if floor_col is not None and cells is None: + break + if cells is None: + continue + # Every lookup here is on one named cell, never on the row and never on a fixed index. + # Searching the row matches the probe command in `Present when` as well. + # A table stating a version there would then satisfy this with the Floor column deleted. + # That is the failure this test exists to catch rather than to reproduce. + if floor_col is None: + if 'floor' in [c.lower() for c in cells]: + floor_col = [c.lower() for c in cells].index('floor') + continue + if cells: + rows[norm(cells[0])] = cells + self.assertIsNotNone(floor_col, 'docs/host-setup.md has no contract table with a Floor column') + for t in self.data['tools']: + # An optional tool is deliberately outside the table, which lists what a host must provide. + # `git-restore-mtime` carries a floor and no row, and that is correct. + if t['minimum'] is None or not t.get('required'): + continue + key = norm(t['name']) + self.assertIn(key, rows, f'{t["name"]} declares a floor and has no row in the contract table') + cells = rows[key] + self.assertGreater(len(cells), floor_col, + f'the {t["name"]} row has no Floor cell') + self.assertIn(t['minimum'], cells[floor_col], + f'{t["name"]} declares {t["minimum"]} and its Floor cell says ' + f'{cells[floor_col]!r}') + def test_a_target_floor_says_so_rather_than_implying_a_defect(self): """The python3 floor is a target, so its `why` has to distinguish itself from a measured one.