Coverage for tasks.py: 86%

405 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-06 02:59 +0000

1"""footman's own tasks — dogfooding the runner, run(), and toolroom's handles. 

2 

3Run with ``fm <task>`` (or ``uv run fm <task>`` before it is installed). 

4Chaining works: ``fm format lint --fix test``. 

5""" 

6 

7from __future__ import annotations 

8 

9import dataclasses 

10import re 

11from dataclasses import dataclass 

12from pathlib import Path 

13from typing import Annotated, Any, Literal 

14 

15from toolroom import ( 

16 basedpyright, 

17 mypy, 

18 pyrefly, 

19 pytest, 

20 ruff, 

21 ruff_format, 

22 ty, 

23 uv, 

24 zensical, 

25) 

26 

27from footman import ( 

28 RunFailed, 

29 doc, 

30 fail, 

31 group, 

32 lane, 

33 parallel, 

34 plugin, 

35 run, 

36 stdin, 

37 step, 

38 task, 

39) 

40 

41docs = group("docs", help="Documentation site (Zensical)") 

42 

43# The whole repo, as CI lints it (`ruff check .`). Anything narrower lets a 

44# tracked file outside src/tests — a comparison script, a scratch demo — pass 

45# the gate and fail the build; tracking `notes/` proved it within minutes. 

46SRC = (".",) 

47 

48# The light checks share one slot: pytest fans out `-n auto` workers of its 

49# own, and checkers running wide beside them fight those workers for cores 

50# (the `fm --profile check` trace drew it). Serialised against each other — 

51# and only each other — their sum still fits inside the suite's shadow. 

52CHECKERS = lane("checkers", reason="pytest -n auto owns the cores") 

53 

54 

55@task(lanes=(CHECKERS,)) 

56def lint(fix: Annotated[bool, doc("apply safe fixes in place")] = False): 

57 """Lint with ruff.""" 

58 ruff.check(*SRC, fix=fix) 

59 

60 

61@task(lanes=(CHECKERS,)) 

62def format(check: bool = False): 

63 """Format with ruff. 

64 

65 Args: 

66 check: report instead of rewriting 

67 """ 

68 ruff_format(*SRC, check=check) 

69 

70 

71@task(lanes=(CHECKERS,)) 

72def typecheck(): 

73 """Type-check with all four gating checkers, in parallel. 

74 

75 basedpyright runs `--warnings` so a warning fails the gate exactly as an 

76 error does — a warning nobody has to act on is a warning everybody stops 

77 reading. mypy is strict on footman itself and checks every test body as 

78 consumer code — once per platform (linux from config, darwin and win32 

79 by flag), since mypy has no all-platforms mode; ty and pyrefly check 

80 every platform at once (`python-platform = "all"`) at the scopes 

81 pyproject pins. All four gate: a checker footman uses is a checker the 

82 tree is clean against. 

83 """ 

84 

85 def based(): 

86 basedpyright(warnings=True) 

87 

88 # Each run gets its own cache dir: mypy's SQLite cache (2.x default) 

89 # does not tolerate three concurrent writers on one file. 

90 def mypy_linux(): 

91 mypy(cache_dir=".mypy_cache/linux") 

92 

93 def mypy_darwin(): 

94 mypy(platform="darwin", cache_dir=".mypy_cache/darwin") 

95 

96 def mypy_win32(): 

97 mypy(platform="win32", cache_dir=".mypy_cache/win32") 

98 

99 def run_ty(): 

100 ty.check() 

101 

102 def run_pyrefly(): 

103 pyrefly("check") 

104 

105 parallel( 

106 step(based, title="basedpyright")(), 

107 step(mypy_linux)(), 

108 step(mypy_darwin)(), 

109 step(mypy_win32)(), 

110 step(run_ty, title="ty")(), 

111 step(run_pyrefly, title="pyrefly")(), 

112 ) 

113 

114 

115@task(lanes=(CHECKERS,)) 

116def typecomplete(): 

117 """Verify the public API is 100% type-complete (pyright --verifytypes). 

118 

119 The exit code is the verdict: 0 only when every public symbol has a 

120 fully known type — a new unannotated export fails the gate here before 

121 a consumer's checker ever sees it. `--ignoreexternal` scopes the claim 

122 to footman's own surface (the pytest plugin necessarily references 

123 pytest types, whose completeness is not ours to promise). 

124 """ 

125 basedpyright(verifytypes="footman", ignoreexternal=True) 

126 

127 

128@task 

129def test(*pytest_args: str): 

130 """Run the test suite. 

131 

132 Args: 

133 pytest_args: forwarded to pytest verbatim 

134 """ 

135 pytest.opts(in_process=False)(*pytest_args) 

136 

137 

138@task 

139def check(): 

140 """Run format --check, lint, both type gates, and the covered suite — in parallel. 

141 

142 The gate: run it before every commit, and CI runs the same checks. The 

143 test step runs under coverage against a local floor, so this one command 

144 is the whole local gate — no separate `pytest --cov`. 

145 """ 

146 import os 

147 import shutil 

148 import tempfile 

149 

150 # Coverage data goes to a per-invocation file: two `fm check` runs 

151 # sharing the repo's .coverage (a second session, a hook racing a manual 

152 # run) clobber the SQLite file mid-write, and the reporter then sees a 

153 # bogus partial total with every test passing. 

154 # 

155 # Per-invocation means one directory per run, and the gate runs on every 

156 # commit and from the stop hook on every turn — so left behind they 

157 # accumulate at the rate the agent works. A day of one session left 308 

158 # of them, 80 MB, and nothing would ever have collected them. 

159 cov_dir = tempfile.mkdtemp(prefix="fm-check-cov-") 

160 cov_file = os.path.join(cov_dir, "coverage") 

161 

162 def covered(): 

163 # `fail_under` (pyproject, 92) is the *merged* bar: CI combines three 

164 # OSes x five Pythons plus the shell jobs, and disables the per-job 

165 # threshold because one slice can only ever see its own branches 

166 # (Windows run(), pwsh profiles, the other Pythons, the network-gated 

167 # tool fetch). One laptop measured against that bar is red by 

168 # construction, so the local run gets its own floor — low enough that 

169 # a single platform can clear it, high enough to catch a real 

170 # regression before CI merges the whole picture. 

171 run( 

172 "pytest --cov --cov-report= --cov-fail-under=90", 

173 # `env=` is the child's whole environment, as subprocess means it: 

174 # spread the task's own (which `os.environ` *is* in here) and add. 

175 env={**os.environ, "COVERAGE_FILE": cov_file}, 

176 ) 

177 

178 try: 

179 # The block form carries a task's arguments naturally, and the one 

180 # foreign def lifts into a named step — a receipt instead of a 

181 # borrowed __name__. 

182 with parallel() as p: 

183 format(check=True) 

184 lint() 

185 typecheck() 

186 typecomplete() 

187 p(step(covered, title="test")()) 

188 finally: 

189 # In a `finally`, because a red gate is the common case and the one 

190 # that would otherwise leak: `parallel` raises on the first failing 

191 # step, and a run that never reaches its own cleanup line is exactly 

192 # the run you make most often while fixing something. 

193 shutil.rmtree(cov_dir, ignore_errors=True) 

194 

195 

196@task 

197def sync(): 

198 """Sync the environment to the lockfile (uv sync). 

199 

200 Runs through the project's own uv (a dev dependency, resolved by the 

201 lockfile), so the same uv version writes uv.lock on every laptop and in CI. 

202 A mismatched system uv silently rewriting the lock is the source of the 

203 one-line churn this avoids. 

204 """ 

205 uv.sync() 

206 

207 

208def _scaffold_suggest_demo() -> str: 

209 """A scratch project whose tasks.py is typing.md's dynamic-completion 

210 example, extracted verbatim from the page — the recording exercises the 

211 documented code by construction, so example and cast cannot drift.""" 

212 import re 

213 import tempfile 

214 from pathlib import Path 

215 

216 section = Path("docs/typing.md").read_text(encoding="utf-8") 

217 section = section.split("## Dynamic completion", 1)[1] 

218 code = re.search(r"```python\n(.*?)```", section, re.S) 

219 assert code is not None, "typing.md lost its dynamic-completion example" 

220 demo = Path(tempfile.gettempdir()) / "footman-suggest-demo" 

221 demo.mkdir(parents=True, exist_ok=True) 

222 (demo / "pyproject.toml").write_text("[project]\nname = 'demo'\n", encoding="utf-8") 

223 (demo / "tasks.py").write_text(code.group(1), encoding="utf-8") 

224 run("fm --list", cwd=str(demo), capture=True) # warm the manifest TAB serves 

225 return str(demo) 

226 

227 

228def _scaffold_completion_demo() -> str: 

229 """The project every completion cast is recorded against. 

230 

231 One small CLI, so the five recordings differ only in *shell behaviour* — 

232 which is the whole reason to show five. Recording against footman's own 

233 tasks.py meant the casts drifted whenever the gate did, and the tasks a 

234 reader saw were ones only this repo has. 

235 

236 It carries what the casts need to show: documented options (a 

237 description column has to have something to put in it), a nested group 

238 (dotted addressing), and a `Literal` (value completion). 

239 """ 

240 import tempfile 

241 from pathlib import Path 

242 

243 demo = Path(tempfile.gettempdir()) / "footman-completion-demo" 

244 demo.mkdir(parents=True, exist_ok=True) 

245 (demo / "pyproject.toml").write_text("[project]\nname = 'demo'\n", encoding="utf-8") 

246 (demo / "tasks.py").write_text( 

247 "from typing import Literal\n" 

248 "from footman import group, task\n\n" 

249 "@task\n" 

250 "def build(release: bool = False, jobs: int = 4):\n" 

251 ' """Compile and bundle.\n\n' 

252 " Args:\n" 

253 " release: optimise and strip symbols\n" 

254 " jobs: parallel compile jobs\n" 

255 ' """\n\n' 

256 "@task\n" 

257 "def test(watch: bool = False, marker: str = ''):\n" 

258 ' """Run the test suite.\n\n' 

259 " Args:\n" 

260 " watch: re-run on every file change\n" 

261 " marker: only tests carrying this marker\n" 

262 ' """\n\n' 

263 "@task\n" 

264 "def lint(fix: bool = False):\n" 

265 ' """Check style and types.\n\n' 

266 " Args:\n" 

267 " fix: apply safe fixes in place\n" 

268 ' """\n\n' 

269 'deploy = group("deploy", help="Ship it somewhere")\n\n' 

270 "@deploy.task\n" 

271 "def staging():\n" 

272 ' "Deploy to staging."\n\n' 

273 "@deploy.task\n" 

274 "def prod(region: Literal['eu', 'us', 'ap'] = 'eu'):\n" 

275 ' """Deploy to production.\n\n' 

276 " Args:\n" 

277 " region: which region to ship to\n" 

278 ' """\n', 

279 encoding="utf-8", 

280 ) 

281 run("fm --list", cwd=str(demo), capture=True) # warm the manifest TAB serves 

282 return str(demo) 

283 

284 

285def _scaffold_hero_demo() -> str: 

286 """The project the front page's recording is made against. 

287 

288 Its point is the one thing a TOML-configured runner cannot do: a 

289 parameter whose candidates are *computed*. `branch` completes to the 

290 real branches of a real git repo, so the recording shows footman asking 

291 git rather than reading a list somebody typed out. It is a git repo for 

292 that reason alone: the branches have to be real for the demo to mean 

293 anything. 

294 

295 The tasks file is the one printed above the recordings on the front 

296 page, read from the page itself, so the code a reader copies is the 

297 code the shells were driven against. The same construction typing.md's 

298 dynamic-completion cast uses. 

299 """ 

300 import re 

301 import shutil 

302 import subprocess 

303 import tempfile 

304 from pathlib import Path 

305 

306 page = Path("docs/index.md").read_text(encoding="utf-8") 

307 marker = "<!-- hero-demo:" 

308 assert marker in page, "index.md lost its hero-demo marker" 

309 code = re.search(r"```python\n(.*?)```", page.split(marker, 1)[1], re.S) 

310 assert code is not None, "the hero-demo marker no longer leads a python block" 

311 

312 demo = Path(tempfile.gettempdir()) / "footman-hero-demo" 

313 shutil.rmtree(demo, ignore_errors=True) 

314 demo.mkdir(parents=True, exist_ok=True) 

315 (demo / "pyproject.toml").write_text("[project]\nname = 'demo'\n", encoding="utf-8") 

316 (demo / "tasks.py").write_text(code.group(1), encoding="utf-8") 

317 git = ["git", "-C", str(demo)] 

318 subprocess.run([*git, "init", "-q", "-b", "main"], check=True) 

319 subprocess.run([*git, "add", "-A"], check=True) 

320 subprocess.run( 

321 [ 

322 *git, 

323 "-c", 

324 "user.email=demo@example.com", 

325 "-c", 

326 "user.name=demo", 

327 # Signing is global config, and this throwaway repo has no business 

328 # reaching for the maintainer's key: a gated agent turns a docs 

329 # build into `failed to write commit object`, and an unattended one 

330 # (CI, a release) would hang on a prompt nobody can answer. The 

331 # identity above is overridden for the same reason. 

332 "-c", 

333 "commit.gpgsign=false", 

334 "commit", 

335 "-qm", 

336 "demo", 

337 ], 

338 check=True, 

339 ) 

340 for branch in ("feat/typed-flags", "fix/completion-cache", "release/v0.40.0"): 

341 subprocess.run([*git, "branch", branch], check=True) 

342 run("fm --list", cwd=str(demo), capture=True) # warm the manifest TAB serves 

343 return str(demo) 

344 

345 

346def _scaffold_interactive_demo() -> str: 

347 """A scratch project with one task per interactive shape — an `ask()` 

348 parameter, a `confirm=` gate, and an `interactive=True` wizard — so 

349 orchestration.md's interactive-input casts play the documented shapes.""" 

350 import tempfile 

351 from pathlib import Path 

352 

353 demo = Path(tempfile.gettempdir()) / "footman-interactive-demo" 

354 demo.mkdir(parents=True, exist_ok=True) 

355 (demo / "pyproject.toml").write_text("[project]\nname = 'demo'\n", encoding="utf-8") 

356 (demo / "tasks.py").write_text( 

357 "from typing import Annotated\n" 

358 "from footman import ask, prompt, select, task\n\n" 

359 "@task\n" 

360 "def release(version: Annotated[str, ask()]):\n" 

361 ' "Cut a release."\n' 

362 ' print(f"Releasing {version}")\n\n' 

363 '@task(confirm="Deploy to production?")\n' 

364 "def deploy():\n" 

365 ' "Ship it."\n' 

366 ' print("Deploying to production")\n\n' 

367 "@task(interactive=True)\n" 

368 "def scaffold():\n" 

369 ' "Start a project."\n' 

370 ' name = prompt("project name? ")\n' 

371 ' kind = select("what kind?", ["library", "app", "plugin"])\n' 

372 ' print(f"Scaffolding {name} ({kind})")\n', 

373 encoding="utf-8", 

374 ) 

375 return str(demo) 

376 

377 

378def _bash_renders_value_menus() -> bool: 

379 """Whether the bash on PATH draws candidates after `--opt=`. 

380 

381 bash 3.2 — which is what macOS ships, and still the default `/bin/bash` 

382 there — does not, so a hero recording made on a stock Mac shows the 

383 typed line and no menu. bash 4+ (Homebrew's, and every Linux runner's) 

384 does. The completion itself is fine either way: footman answers every 

385 argv shape bash sends, verified against the resolver directly. 

386 

387 So the beat is asserted where the shell can show it, rather than 

388 dropped everywhere to accommodate the oldest bash in the room. 

389 """ 

390 import shutil 

391 

392 from toolroom import bash 

393 

394 if shutil.which("bash") is None: 

395 return False 

396 # toolroom asks the binary itself and reads the answer with the same 

397 # parser its stub extractor uses, outside the task context — so a 

398 # dry run or a recording() cannot answer this one for us. 

399 return bash.installed_version() >= (4,) 

400 

401 

402def _record_cast( 

403 *keys: str, 

404 beats: list[str], 

405 out: Path, 

406 shell: Literal["zsh", "bash", "fish", "pwsh", "nushell"], 

407 width: int, 

408 height: int, 

409 cwd: Path, 

410 pace: float = 1.2, 

411) -> None: 

412 """Record a cast, and re-record it once if the interaction went missing. 

413 

414 `cast()` already retries a session that produced *no* frames — a shell 

415 too cold to draw anything inside one settle window. It cannot retry the 

416 other failure, because it looks identical to success: a session that 

417 boots, misses the keystrokes, and renders a perfectly valid SVG of a 

418 prompt sitting there. Only the caller knows what the recording was 

419 supposed to contain, so only the caller can tell the two apart. 

420 

421 That failure took down a docs build on a loaded runner — pwsh, whose 

422 .NET startup is the slowest of the five — and a failed docs build means 

423 the published site silently stays stale. Cheap insurance: the happy path 

424 pays nothing, and a mute capture costs one more recording instead of the 

425 whole build. 

426 """ 

427 from footman.tasks.docs import cast as taskdocs_cast 

428 

429 for attempt in (1, 2): 

430 taskdocs_cast( 

431 *keys, 

432 out=out, 

433 shell=shell, 

434 width=width, 

435 height=height, 

436 cwd=cwd, 

437 pace=pace, 

438 # The retry is not the same recording again: a drop means the 

439 # quiet-detection read a CPU-starved shell as settled and typed 

440 # into a menu that had not opened. The second take triples the 

441 # quiet windows (`steady`), which is patience, not slowness — 

442 # a loaded CI runner dropped the nushell hero cast on BOTH 

443 # attempts when the retry only repeated the first take. 

444 steady=1.0 if attempt == 1 else 3.0, 

445 ) 

446 try: 

447 _assert_cast_captured(out, beats) 

448 except RuntimeError: 

449 if attempt == 2: 

450 raise 

451 print(f"note: {out.name} came back mute — re-recording, steadier") 

452 continue 

453 return 

454 

455 

456def _assert_cast_captured(svg: Path, needles: list[str]) -> None: 

457 """A cast whose answer landed too early (a timing regression) still renders 

458 a valid SVG — just without the interaction. Strip the markup and fail the 

459 build loudly if a prompt, its answer, or the result went missing, rather 

460 than shipping a mute recording no test would catch.""" 

461 import re 

462 

463 text = re.sub( 

464 r"&#160;", "", re.sub(r"<[^>]+>", "", svg.read_text(encoding="utf-8")) 

465 ) 

466 missing = [n for n in needles if n not in text] 

467 if missing: 

468 raise RuntimeError(f"{svg.name} dropped {missing} — cast timing regressed?") 

469 

470 

471# The release heading the home page's latest block is lifted from. Named so 

472# the drift test pins *this* reading rather than a copy of it — a copy is 

473# how the two drifted apart for nine releases. 

474_LATEST_HEADING = r"^## \[(\d[^\]]+)\] [-—] (.+?)$" 

475 

476 

477def _write_latest_changes() -> None: 

478 """Extract the newest release's section from CHANGELOG.md into a 

479 collapsed admonition the home page includes — version, date, and the 

480 entries, straight from the one source of truth. Rolling the changelog 

481 for a release updates the home page by construction.""" 

482 import re 

483 from pathlib import Path 

484 

485 text = Path("CHANGELOG.md").read_text(encoding="utf-8") 

486 # Either separator: Keep a Changelog spells the heading with a hyphen 

487 # and this file used an em dash for its first forty-odd releases. The 

488 # em-dash-only reading did not fail loudly when the convention changed 

489 # — `search` simply found the newest heading that still matched, so the 

490 # home page advertised a release from nine versions back and nothing 

491 # said so. Accepting both keeps the archive parsable and cannot silently 

492 # pick the wrong entry again. 

493 head = re.search(_LATEST_HEADING, text, re.M) 

494 if head is None: # a fresh fork with only [Unreleased]: skip quietly 

495 body_block = "" 

496 else: 

497 rest = text[head.end() :] 

498 nxt = re.search(r"^## \[", rest, re.M) 

499 entries = rest[: nxt.start() if nxt else len(rest)].strip() 

500 indented = "\n".join( 

501 f" {line}" if line else "" for line in entries.splitlines() 

502 ) 

503 title = f"Latest release: {head.group(1)} — {head.group(2)}" 

504 # No links in here: the file is validated as its own page, where 

505 # relative targets differ from the including page's. The home page 

506 # carries the changelog link itself, right after the include. 

507 body_block = f'??? info "{title}"\n\n{indented}\n' 

508 

509 out = Path("_generated/latest-changes.md") 

510 out.parent.mkdir(parents=True, exist_ok=True) 

511 out.write_text(body_block, encoding="utf-8") 

512 print(f"wrote {out}") 

513 

514 

515def _write_llms_txt() -> None: 

516 """Generate docs/llms.txt and docs/llms-full.txt from the nav. 

517 

518 llms.txt (https://llmstxt.org) is the agent-facing index of the docs 

519 site: every page in nav order, each with a one-line description pulled 

520 from its first prose line. llms-full.txt is the whole site's text in one 

521 file. Both are derived — never hand-edited — and the build copies them 

522 into site/ like any other docs/ file. 

523 """ 

524 import tomllib 

525 from pathlib import Path 

526 

527 site = "https://willemkokke.github.io/footman/" 

528 with open("zensical.toml", "rb") as fh: 

529 nav = tomllib.load(fh)["project"]["nav"] 

530 

531 pages: list[tuple[str, str]] = [] # (title, md filename), nav order 

532 

533 def walk(items: list[dict[str, Any]]) -> None: 

534 for item in items: 

535 for title, value in item.items(): 

536 if isinstance(value, list): 

537 walk(value) 

538 else: 

539 pages.append((title, value)) 

540 

541 walk(nav) 

542 

543 def first_sentence(text: str) -> str: 

544 """The first sentence of the page's first *prose* paragraph. 

545 

546 Prose, strictly: list items and table rows are structure, not 

547 description — a quiz admonition's `1. What four conditions…` and a 

548 generated page's `| Task | Description |` both used to be served 

549 as the page's one-line summary, which read as junk in every agent 

550 index built on this file. 

551 """ 

552 if text.startswith("---\n"): # strip front-matter 

553 _, _, text = text.partition("\n---\n") 

554 skip = ("#", "---", "[![", "!!!", ">", "<", "--8<--", "|", "- ", "* ") 

555 fenced = False 

556 admonition = False 

557 paragraph: list[str] = [] 

558 for raw in text.splitlines(): 

559 line = raw.strip() 

560 if admonition: 

561 # An admonition's whole indented body is chrome — the quiz 

562 # block's numbered questions AND its "skip ahead" prose 

563 # both read as junk when served as a page description. 

564 if not line or raw.startswith((" ", "\t")): 

565 continue 

566 admonition = False 

567 if line.startswith("!!!"): 

568 admonition = True 

569 continue 

570 if line.startswith("```"): 

571 fenced = not fenced 

572 continue 

573 digits = line.split(".", 1)[0] 

574 numbered = digits.isdigit() and line.startswith(f"{digits}.") 

575 if fenced or line.startswith(skip) or numbered: 

576 if paragraph: 576 ↛ 577line 576 didn't jump to line 577 because the condition on line 576 was never true

577 break # structure ends the paragraph it follows 

578 continue 

579 if not line: 

580 if paragraph: 

581 break # paragraph complete 

582 continue 

583 paragraph.append(line) 

584 prose = " ".join(paragraph) 

585 

586 # A relative .md link 404s the moment an agent resolves it against 

587 # the site root; rewrite to the published URL, index pages included. 

588 def absolute(match: re.Match[str]) -> str: 

589 target = match.group(1) 

590 if target == "index.md" or target.endswith("/index.md"): 

591 stem = target.removesuffix("index.md").rstrip("/") 

592 return f"]({site}{stem + '/' if stem else ''})" 

593 return f"]({site}{target.removesuffix('.md')}/)" 

594 

595 prose = re.sub(r"\]\((?!https?://)([^)#]+\.md)\)", absolute, prose) 

596 end = prose.find(". ") 

597 return prose[: end + 1] if end != -1 else prose 

598 

599 index = [ 

600 "# footman", 

601 "", 

602 "> A Python task runner: typed function signatures become real CLI " 

603 "flags, modules become command groups, independent tasks run in " 

604 "parallel by default, and shell completion answers from a cached " 

605 "manifest without importing your code (building that cache imports " 

606 "it once, in a detached subprocess). Machine surface: `fm --json " 

607 "--list` (catalog), `fm --json <chain>` (results), `fm --help <task>`.", 

608 "", 

609 "## Docs", 

610 "", 

611 ] 

612 full = ["# footman — full documentation", ""] 

613 

614 def resolve_snippets(text: str) -> str: 

615 """Inline `--8<-- "path"` includes, the way the site build does. 

616 

617 llms-full.txt promises the whole site's text in one file; a raw 

618 directive where a section belongs is six missing sections and a 

619 syntax no reader resolves. Paths are repo-relative, matching the 

620 pymdownx configuration; a missing file keeps the directive so the 

621 gap is visible rather than silent. 

622 """ 

623 

624 def inline(match: re.Match[str]) -> str: 

625 path = Path(match.group(1)) 

626 if not path.is_file(): 

627 return match.group(0) 

628 return resolve_snippets(path.read_text(encoding="utf-8").rstrip()) 

629 

630 return re.sub( 

631 r"""^--8<-- ["']([^"']+)["']$""", inline, text, flags=re.MULTILINE 

632 ) 

633 

634 for title, name in pages: 

635 if name == "coverage.md": 

636 continue # an embedded HTML report; nothing for a reader here 

637 text = (Path("docs") / name).read_text(encoding="utf-8") 

638 if name.endswith("index.md"): 

639 # A directory's index publishes at the directory, not at 

640 # …/index/ — the generated Task reference 404'd on the wrong 

641 # form for as long as nothing read this file critically. 

642 stem = name.removesuffix("index.md").rstrip("/") 

643 url = f"{site}{stem + '/' if stem else ''}" 

644 else: 

645 url = f"{site}{name.removesuffix('.md')}/" 

646 # Snippets resolve before anything reads the page: the description 

647 # of a page that IS an include (changelog.md) lives in the included 

648 # text, and llms-full.txt carries the sections, not the directives. 

649 resolved = resolve_snippets(text.rstrip()) 

650 desc = first_sentence(resolved) 

651 index.append(f"- [{title}]({url}): {desc}" if desc else f"- [{title}]({url})") 

652 full += [ 

653 "", 

654 "---", 

655 "", 

656 f"<!-- {title} — {url} -->", 

657 "", 

658 resolved, 

659 ] 

660 (Path("docs") / "llms.txt").write_text("\n".join(index) + "\n", encoding="utf-8") 

661 joined = "\n".join(full) + "\n" 

662 (Path("docs") / "llms-full.txt").write_text(joined, encoding="utf-8") 

663 

664 

665@docs.task(infinite=True) # runs until Ctrl-C — and now says so 

666def serve(): 

667 """Build and serve the docs with live reload.""" 

668 _write_llms_txt() 

669 run("zensical serve") 

670 

671 

672# The output contract, dogfooded: the annotation below bakes this shape into 

673# the manifest, `fm --json docs.coverage` carries it as `returned_schema` 

674# beside the data, `fm --describe=docs.coverage` renders it as JSON Schema, 

675# and a rename here goes red in our own gate via `returned_mismatch`. 

676@dataclass 

677class CoverageReport: 

678 percent: float 

679 statements: int 

680 missing: int 

681 report: Path 

682 

683 

684@docs.task 

685def coverage() -> CoverageReport: 

686 """Generate the coverage HTML report into docs/htmlcov (embedded in the site). 

687 

688 Returns: 

689 The measured total and where the HTML report landed. 

690 """ 

691 import json 

692 import tempfile 

693 

694 with tempfile.TemporaryDirectory(prefix="fm-cov-totals-") as tmp: 

695 totals_file = Path(tmp) / "coverage.json" 

696 run( 

697 "pytest --cov --cov-report=html:docs/htmlcov " 

698 f"--cov-report=json:{totals_file} -q" 

699 ) 

700 totals = json.loads(totals_file.read_text("utf-8"))["totals"] 

701 return CoverageReport( 

702 percent=round(totals["percent_covered"], 2), 

703 statements=totals["num_statements"], 

704 missing=totals["missing_lines"], 

705 report=Path("docs/htmlcov/index.html"), 

706 ) 

707 

708 

709# --- The API reference page is generated: correct by construction -------- 

710# Input: the typechecked TYPE_CHECKING export table in src/footman/ 

711# __init__.py (a wrong module path there fails basedpyright). Presentation: 

712# the declaration below. The build refuses on divergence in either 

713# direction, naming the name — a new export cannot ship undocumented, and a 

714# stale entry cannot outlive its export. Directives use PUBLIC paths 

715# (`::: footman.run`), so anchors and the objects.inv inventory carry the 

716# contract spelling, not the defining module. 

717 

718_API_INTRO = """\ 

719<!-- Generated by 'fm docs.api' — edit _API_SECTIONS in tasks.py, not this file. --> 

720 

721# API reference 

722 

723Auto-generated from the source via 

724[mkdocstrings](https://mkdocstrings.github.io/). Everything here is importable 

725straight from the `footman` package (`from footman import task, run, App`). 

726""" 

727 

728# (section title, intro prose or "", entries). An entry is a dotted path 

729# under `footman.`; its first component must be a root export, or the whole 

730# entry sits in _API_EXTRA with a reason. 

731_API_SECTIONS: list[tuple[str, str, list[str]]] = [ 

732 ("Defining tasks", "", ["task", "group", "Group", "expose"]), 

733 ( 

734 "Availability gates", 

735 "Stack these above `@task` to list a task as unavailable (with a " 

736 "reason) where it can't run. Every availability gate is evaluated " 

737 "live, and all failures are collected.", 

738 ["requires", "requires_dep", "requires_tool", "requires_env"], 

739 ), 

740 ( 

741 "Running commands", 

742 "", 

743 [ 

744 "run", 

745 "Result", 

746 "ResultView", 

747 "AuditEntry", 

748 "Argv", 

749 "RunFailed", 

750 "parallel", 

751 "step", 

752 "pre_record", 

753 "passthrough", 

754 "inherited", 

755 ], 

756 ), 

757 ("Failing on purpose", "", ["fail", "Failed", "TimedOut"]), 

758 ("Progress", "", ["progress", "track"]), 

759 ( 

760 "Profiling from inside a task", 

761 "", 

762 ["section", "mark", "stream", "Stream", "Section"], 

763 ), 

764 ( 

765 "Asking the person running it", 

766 "", 

767 ["prompt", "confirm", "select", "attended", "tty", "colored"], 

768 ), 

769 ( 

770 "The process boundary", 

771 "stdin binds to typed parameters, and a `Stdout[T]` return owns " 

772 "stdout. The full contract lives on [Pipelines](pipelines.md) and " 

773 "[JSON output](json.md).", 

774 ["Stdin", "stdin", "Stdout", "stdout"], 

775 ), 

776 ( 

777 "The working directory & lanes", 

778 "", 

779 ["cwd", "chdir", "Lane", "lane", "cwd_lane", "console_lane"], 

780 ), 

781 ( 

782 "Where a task keeps things", 

783 "Two folders footman writes, both created on access. `cache_dir()` " 

784 "is derived data the collector sweeps by age; `data_dir()` is " 

785 "durable and machine-local — credentials, tokens, generated assets " 

786 "— and is never collected. The rest are places footman *reads*, " 

787 "created by nobody but you: `config_dir()` and `config_file()` are " 

788 "your user-level settings, `user_tasks_file()` the personal rung " 

789 "riding every project, and `project_root()` the top of this " 

790 "invocation's cascade (`None` outside a project). `fm self.path` " 

791 "is the same set from the command line. Where each one lands is the " 

792 "CLI's business, not the task's; see " 

793 "[Custom CLIs](custom-cli.md#two-folders-of-your-own).", 

794 [ 

795 "cache_dir", 

796 "data_dir", 

797 "config_dir", 

798 "config_file", 

799 "user_tasks_file", 

800 "project_root", 

801 ], 

802 ), 

803 ( 

804 "Which CLI is running", 

805 "For output that has to call the CLI back — a generated CI workflow, " 

806 "a README line — so a branded runner emits its own name instead of " 

807 "`fm`. `prog()` is the command someone types, `dist()` the package " 

808 "that ships it (`None` when the brand never declared one). Inside a " 

809 "task body `ctx.prog` says the same and is the better reach: it is " 

810 "the invocation's own answer rather than process state.", 

811 ["prog", "dist"], 

812 ), 

813 ("Fetching", "", ["fetch", "FetchError"]), 

814 ( 

815 "The task context", 

816 "`given()` answers whether the caller supplied a parameter or footman " 

817 "filled it in — the difference between asking for the default and " 

818 "having no opinion, which the value alone cannot tell you.", 

819 ["Context", "given", "use_context"], 

820 ), 

821 ("Composing tasks", "", ["include", "plugin", "capture"]), 

822 ( 

823 "The invocation, and editing the discovered tree", 

824 "`@pre_tasks` runs a hook once per invocation, over the fully-merged " 

825 "cascade and before anything else — see " 

826 "[Hooks & plugin options](hooks.md#editing-the-discovered-tree). It is " 

827 "handed the `Invocation`, whose `tasks` is a `Tasks` view; iterating " 

828 "or indexing that yields a `TaskView` that reads and edits one task. " 

829 "The per-task pair — `@pre_task` and `@post_task` — runs around every " 

830 "execution; see " 

831 "[Around every task](hooks.md#around-every-task-pre_task-and-post_task).", 

832 [ 

833 "pre_tasks", 

834 "pre_bind", 

835 "pre_task", 

836 "post_task", 

837 "post_tasks", 

838 "GlobalOption", 

839 "config_section", 

840 "wrap_task", 

841 "wrap_bind", 

842 "Invocation", 

843 "Tasks", 

844 "TaskView", 

845 ], 

846 ), 

847 ("Custom CLI", "", ["App", "Brand", "main"]), 

848 ( 

849 "Typed-parameter helpers", 

850 "", 

851 [ 

852 "Many", 

853 "Arg", 

854 "Forward", 

855 "forward", 

856 "NoSplit", 

857 "nosplit", 

858 "Hidden", 

859 "hidden", 

860 "suggest", 

861 "exists", 

862 "isfile", 

863 "isdir", 

864 "Exists", 

865 "IsFile", 

866 "IsDir", 

867 "matching", 

868 "between", 

869 "env", 

870 "check", 

871 "default", 

872 "doc", 

873 "ask", 

874 "Secret", 

875 ], 

876 ), 

877 ( 

878 "Docstrings", 

879 "Standalone (stdlib-only, no footman imports) — reusable outside footman.", 

880 ["docstrings.parse", "docstrings.Docstring"], 

881 ), 

882 ( 

883 "Markdown export", 

884 "Pure functions over manifest tree nodes — see " 

885 "[Your tasks, documented](taskdocs.md) for the task-level surface.", 

886 ["markdown.render_page", "markdown.render_site"], 

887 ), 

888 ("Testing", "", ["Runner", "testing.InvokeResult", "recording"]), 

889] 

890 

891# Entries documented beyond the export table — each with its reason, never 

892# silent — and exports deliberately absent from the page, ditto. 

893_API_EXTRA: dict[str, str] = { 

894 "testing.InvokeResult": "Runner.invoke's return type", 

895} 

896_API_OMITTED: dict[str, str] = {} 

897 

898 

899@docs.task 

900def api(out: Path = Path("docs/api.md")): 

901 "Generate the API reference from the export table; refuse on drift." 

902 out.parent.mkdir(parents=True, exist_ok=True) 

903 out.write_text(_api_markdown(), encoding="utf-8") 

904 

905 

906def _api_markdown() -> str: 

907 """The API page content, validated against the export table. 

908 

909 Pure — no filesystem writes — so the docs-drift test can join the 

910 generated page into its blob by construction: a fresh checkout has no 

911 docs/api.md on disk until the docs build runs.""" 

912 import ast 

913 

914 src = Path("src/footman/__init__.py").read_text("utf-8") 

915 exported: set[str] = set() 

916 for node in ast.walk(ast.parse(src)): 

917 if ( 

918 isinstance(node, ast.Assign) 

919 and isinstance(node.value, ast.List | ast.Tuple) 

920 and any(getattr(t, "id", "") == "__all__" for t in node.targets) 

921 ): 

922 exported = {ast.literal_eval(e) for e in node.value.elts} 

923 exported -= {"__version__"} 

924 

925 entries = [e for _, _, names in _API_SECTIONS for e in names] 

926 dupes = sorted({e for e in entries if entries.count(e) > 1}) 

927 if dupes: 927 ↛ 928line 927 didn't jump to line 928 because the condition on line 927 was never true

928 fail(f"docs.api: duplicated entries: {', '.join(dupes)}") 

929 covered = {e.split(".")[0] for e in entries} 

930 problems = [] 

931 missing = sorted(exported - covered - set(_API_OMITTED)) 

932 if missing: 932 ↛ 933line 932 didn't jump to line 933 because the condition on line 932 was never true

933 problems.append( 

934 f"exported but undocumented: {', '.join(missing)} — add each to " 

935 f"a section in _API_SECTIONS, or to _API_OMITTED with a reason" 

936 ) 

937 stale = sorted( 

938 e for e in entries if e.split(".")[0] not in exported and e not in _API_EXTRA 

939 ) 

940 if stale: 940 ↛ 941line 940 didn't jump to line 941 because the condition on line 940 was never true

941 problems.append( 

942 f"documented but not exported: {', '.join(stale)} — drop the " 

943 f"entry, or record it in _API_EXTRA with a reason" 

944 ) 

945 ghosts = sorted(set(_API_OMITTED) & covered) + sorted(set(_API_OMITTED) - exported) 

946 if ghosts: 946 ↛ 947line 946 didn't jump to line 947 because the condition on line 946 was never true

947 problems.append( 

948 f"_API_OMITTED disagrees with the page or the exports: " 

949 f"{', '.join(dict.fromkeys(ghosts))}" 

950 ) 

951 if problems: 951 ↛ 952line 951 didn't jump to line 952 because the condition on line 951 was never true

952 fail( 

953 "docs.api: the page and the export table disagree — " + "; ".join(problems) 

954 ) 

955 

956 parts = [_API_INTRO] 

957 for title, intro, names in _API_SECTIONS: 

958 parts.append(f"\n## {title}\n") 

959 if intro: 

960 parts.append(f"\n{intro}\n") 

961 for name in names: 

962 parts.append(f"\n::: footman.{name}\n") 

963 return "".join(parts) 

964 

965 

966def _generate_pages() -> None: 

967 """Every generated page except the pty-recorded shots and casts. 

968 

969 The cheap, pure prefix of the docs build — API reference, task pages, 

970 the grammar/config/errors tables, latest-changes — split out so the 

971 llms drift guard can regenerate it from an empty checkout: the guard 

972 failed on a fresh CI tree precisely because these pages are gitignored 

973 and only the full build had ever written them. 

974 """ 

975 import shutil 

976 from pathlib import Path 

977 

978 from footman.tasks.docs import config as taskdocs_config 

979 from footman.tasks.docs import errors as taskdocs_errors 

980 from footman.tasks.docs import globals_ as taskdocs_globals 

981 from footman.tasks.docs import notes as taskdocs_notes 

982 from footman.tasks.docs import page as taskdocs_page 

983 from footman.tasks.docs import site as taskdocs_site 

984 

985 # Start from nothing, which is the only state that ever gets validated: 

986 # the trees are gitignored, so CI builds them from an empty checkout 

987 # while a working copy accumulates whatever an older layout left behind. 

988 # Clearing costs nothing: every file below is rewritten unconditionally. 

989 # Two generated homes on purpose: snippet sources live in `_generated/` 

990 # *outside* docs/, so the site never publishes them as orphan pages — 

991 # only what a page `--8<--`-includes reaches the reader. `docs/_generated` 

992 # keeps what genuinely is site content: the errors reference (in the 

993 # nav) and the shots/ images the pages embed. 

994 for stale in (Path("docs/_generated"), Path("docs/tasks"), Path("_generated")): 

995 shutil.rmtree(stale, ignore_errors=True) 

996 

997 # The API reference regenerates from the export table every build — the 

998 # page cannot omit an export, because the generator refuses to. 

999 api() 

1000 taskdocs_site(Path("docs/tasks"), all=True) 

1001 taskdocs_page( 

1002 target="docs", 

1003 heading=3, 

1004 flavor="material", 

1005 out=Path("_generated/tasks-page.md"), 

1006 ) 

1007 # The CLI reference's global-options table, from the grammar itself — 

1008 # reference.md snippet-includes it, so it can't drift from --help. 

1009 taskdocs_globals(out=Path("_generated/globals.md")) 

1010 # The [tool.footman] key table, from the keys the runner recognises — 

1011 # configuration.md snippet-includes it, so it can't fall behind. 

1012 taskdocs_config(out=Path("_generated/config.md")) 

1013 # The note-kind table, from the runtime's own registry — notes.md 

1014 # snippet-includes it, so a new kind cannot ship undocumented. 

1015 taskdocs_notes(out=Path("_generated/notes.md")) 

1016 # Every error and note the runtime can say, extracted from the source — 

1017 # a reference page that regenerates each build and so can never drift. 

1018 taskdocs_errors(out=Path("docs/_generated/errors.md")) 

1019 _write_latest_changes() 

1020 

1021 

1022@docs.task(name="build") 

1023def docs_build(check: bool = False): # pragma: no cover — see below 

1024 """Build the docs site into ./site; regenerates llms.txt and docs/tasks/. 

1025 

1026 Not unit-tested, and deliberately: the body is orchestration over 

1027 zensical, a pty screenshotter and five real shells, so a test could only 

1028 stub twenty collaborators and assert the call order back — a change 

1029 detector that passes while the site breaks. Its real test is CI's 

1030 strict docs build, which runs the whole thing against the actual tools. 

1031 The pieces with logic of their own — the llms.txt generator, the cast 

1032 guard, the scratch projects — are tested separately, where a test can 

1033 say something true. 

1034 

1035 Args: 

1036 check: build strictly (what CI runs) 

1037 """ 

1038 # Dogfood the first-party plugin: regenerate the live task-reference 

1039 # pages (site mode) and the single-page example the taskdocs guide 

1040 # embeds (page mode). Plain calls — @task returns plain functions. 

1041 # Order matters on a fresh checkout: llms.txt walks the nav, and the 

1042 # nav includes the generated tasks/ pages — generate them first. 

1043 from pathlib import Path 

1044 

1045 from footman.tasks.docs import shots as taskdocs_shots 

1046 

1047 _generate_pages() 

1048 # Terminal screenshots, captured from the real CLI on a pty and framed 

1049 # as SVGs — the pages show footman exactly as a terminal does, and a 

1050 # rebuild regenerates them, so they cannot drift either. 

1051 shot = Path("docs/_generated/shots") 

1052 taskdocs_shots("--tree", out=shot / "tree.svg", width=100) 

1053 taskdocs_shots("--help", out=shot / "help.svg", width=100) 

1054 taskdocs_shots("format", "lint", out=shot / "run.svg", width=72) 

1055 # The animated ones: five real shells, one script, one demo project, so 

1056 # the recordings differ only in shell behaviour — which is the entire 

1057 # reason to show five. Same regeneration rule as the stills: the docs 

1058 # play what the CLI does, because they are recordings of it doing it. 

1059 from footman.tasks.docs import cast as taskdocs_cast 

1060 

1061 demo = Path(_scaffold_completion_demo()) 

1062 for sh in ("zsh", "bash", "fish", "pwsh", "nushell"): 

1063 # Two per-shell adjustments, both about *default* behaviour rather 

1064 # than a tuned config nobody reading this has: 

1065 # 

1066 # Vanilla bash reveals the candidate list on the SECOND Tab — the 

1067 # first only rings the bell — so it presses twice everywhere the 

1068 # script means "show me". 

1069 tab = ("<TAB>", "<TAB>") if sh == "bash" else ("<TAB>",) 

1070 # PSReadLine's menu swallows Ctrl-C while it is open, so the line 

1071 # survived and the next keystrokes landed inside it — one recording 

1072 # ended on `fm build -- fm deploy.release`. Escape closes the menu 

1073 # first, and then the cancel reaches the line. 

1074 clear = ("<ESC>", "<CTRL-C>") if sh == "pwsh" else ("<CTRL-C>",) 

1075 out = shot / f"{sh}-cast.svg" 

1076 # A cast whose keystrokes raced the shell still renders a valid SVG, 

1077 # just a mute one. Pin the beats every shell can show; descriptions 

1078 # are checked only where the shell has a column for them (bash has 

1079 # none, pwsh puts them in a tooltip). 

1080 beats = ["build", "deploy", "--release", "--jobs", "deploy.staging"] 

1081 if sh in ("zsh", "fish", "nushell"): 

1082 beats += ["Compileandbundle", "optimiseandstripsymbols"] 

1083 _record_cast( 

1084 # Every task, each with its summary. 

1085 "fm ", 

1086 *tab, 

1087 "<WAIT>", 

1088 # Prefix-complete to one of them. 

1089 "bui", 

1090 "<TAB>", 

1091 "<WAIT:600>", 

1092 # Its options — and what each one does. 

1093 " --", 

1094 *tab, 

1095 "<WAIT>", 

1096 # Descend a group by its dotted address. 

1097 *clear, 

1098 "fm deploy.", 

1099 *tab, 

1100 "<WAIT>", 

1101 beats=beats, 

1102 out=out, 

1103 shell=sh, 

1104 width=80, 

1105 height=18, 

1106 cwd=demo, 

1107 ) 

1108 # The front page's recording, one per shell behind a tab. It shows the 

1109 # beat the per-shell pages above cannot: `--branch` completes to the 

1110 # real branches of a real git repo, because its candidates are computed 

1111 # by a function rather than listed in a config file. 

1112 hero = Path(_scaffold_hero_demo()) 

1113 # fish leads because the tab order decides the front page's default. 

1114 hero_shells: tuple[Literal["zsh", "bash", "fish", "pwsh", "nushell"], ...] = ( 

1115 "fish", 

1116 "zsh", 

1117 "bash", 

1118 "pwsh", 

1119 "nushell", 

1120 ) 

1121 # What a shell needs after a completion is taken, before the next token 

1122 # can be typed. fish, zsh and bash insert a space themselves; nushell 

1123 # does not, so `deploy` and `-` ran together into one token that 

1124 # completed nothing. PSReadLine leaves its menu open and lets the next 

1125 # key *replace* the selection — `-` wiped `deploy` off the line — so it 

1126 # needs the menu accepted first, which is what Right does. 

1127 accept: dict[str, tuple[str, ...]] = { 

1128 "nushell": ("<SPACE>",), 

1129 "pwsh": ("<RIGHT>", "<SPACE>"), 

1130 } 

1131 for sh in hero_shells: 

1132 tab = ("<TAB>",) 

1133 # Taking a value out of the menu, spelled the way each shell means 

1134 # it. fish and zsh walk and insert as the selection moves; pwsh 

1135 # lists the *values* only once a character narrows them, so it 

1136 # narrows first and then walks its grid; nushell shows the menu but 

1137 # commits nothing until Enter, which selects rather than submits 

1138 # while a menu is open; bash does not walk at all, so it takes the 

1139 # prefix route — which shows the filtering as well. 

1140 pick = { 

1141 "fish": (*tab, *tab), 

1142 "zsh": (*tab, *tab), 

1143 "pwsh": ("f", *tab, *tab), 

1144 "nushell": (*tab, "<DOWN>", "<ENTER>"), 

1145 }.get(sh, ("f", *tab, "i", *tab)) 

1146 pick_region = { 

1147 "fish": tab, 

1148 "zsh": tab, 

1149 "nushell": (*tab, "<DOWN>", "<ENTER>"), 

1150 }.get(sh, ("u", *tab)) 

1151 # What ends a menu so the *next* Tab asks again rather than filtering 

1152 # what is already listed. Only PSReadLine keeps a session like that. 

1153 close = ("<SPACE>", "<BACKSPACE>") if sh == "pwsh" else () 

1154 # `*-cast.svg` is the convention the docs' player keys on: any 

1155 # recording ending that way gets play/pause/scrub, a still does not. 

1156 out = shot / f"hero-{sh}-cast.svg" 

1157 # `deploy` is typed, so it proves nothing about the menus. 

1158 # `fix/completion-cache` and the regions never are: they can only 

1159 # appear if the completer answered. Assert the beat, not the echo. 

1160 beats = ["deploy", "build", "test"] 

1161 if sh != "bash" or _bash_renders_value_menus(): 

1162 beats += ["feat/typed-flags", "fix/completion-cache"] 

1163 _record_cast( 

1164 # Nothing is typed out in full: every token on the line arrives 

1165 # by completion, and the longest thing typed is two characters. 

1166 # The task surface, each task with its summary. 

1167 "fm ", 

1168 *tab, 

1169 # The task, by prefix — deterministic whatever order a shell 

1170 # lists in, which is the shell's choice and not ours. 

1171 "de", 

1172 *tab, 

1173 *accept.get(sh, ()), 

1174 # What it takes. `-` keeps the menu to options: with the task's 

1175 # arity satisfied, a bare Tab also offers the next task in the 

1176 # chain, which is true but not what this beat is about. 

1177 "-", 

1178 *tab, 

1179 # One option, both spellings — a value is `=`-attached. 

1180 "b", 

1181 *tab, 

1182 # Close the menu before typing the `=`. PSReadLine does not 

1183 # re-ask on the next keystroke: a character typed into an open 

1184 # menu filters the candidates it already holds, so `=` narrowed 

1185 # `--branch`/`--branch=` to one entry and footman was never asked 

1186 # for the branch values at all. Space accepts and closes it; 

1187 # Backspace takes back the space, leaving the token whole. 

1188 # (Right accepts the *selection*, and Escape reverts — measured.) 

1189 *close, 

1190 # Its values: every branch in the repo, asked of git on the spot. 

1191 "=", 

1192 *tab, 

1193 # Take one, then a Literal straight from the signature. Both 

1194 # dashes on `--r`: the candidates are `--region`/`--region=`, so 

1195 # `-r` is a prefix of neither — the beat above only reaches `b` 

1196 # because `-`+Tab expanded the common `--` first. 

1197 *pick, 

1198 *accept.get(sh, ()), 

1199 "--r", 

1200 *tab, 

1201 *close, 

1202 "=", 

1203 *tab, 

1204 *pick_region, 

1205 pace=0.75, 

1206 beats=beats, 

1207 out=out, 

1208 shell=sh, 

1209 width=84, 

1210 height=16, 

1211 cwd=hero, 

1212 ) 

1213 # Dynamic completion, recorded against typing.md's own example (the 

1214 # demo project's tasks.py is extracted from the page): TAB offers the 

1215 # values a plain function returned, and TAB again walks the menu. 

1216 taskdocs_cast( 

1217 "fm mount ", 

1218 "<TAB>", 

1219 "<WAIT>", 

1220 "<TAB>", 

1221 "<WAIT:1200>", 

1222 out=shot / "pwsh-suggest-cast.svg", 

1223 shell="pwsh", 

1224 width=80, 

1225 height=12, 

1226 cwd=Path(_scaffold_suggest_demo()), 

1227 ) 

1228 # Interactive input, one cast per shape (orchestration.md), against a demo 

1229 # project with an ask() parameter, a confirm= gate, and an interactive wizard. 

1230 # <SETTLE> holds each answer until the prompt has finished rendering (output 

1231 # goes quiet), so a frame always lands on the prompt and a slow boot can't 

1232 # race the script — timing-independent, unlike a fixed wait. 

1233 interactive_demo = Path(_scaffold_interactive_demo()) 

1234 taskdocs_cast( 

1235 "fm release", 

1236 "<ENTER>", 

1237 "<SETTLE>", 

1238 "1.4.0", 

1239 "<ENTER>", 

1240 "<WAIT:2000>", 

1241 out=shot / "ask-cast.svg", 

1242 shell="zsh", 

1243 width=64, 

1244 height=10, 

1245 cwd=interactive_demo, 

1246 ) 

1247 taskdocs_cast( 

1248 "fm deploy", 

1249 "<ENTER>", 

1250 "<SETTLE>", 

1251 "y", 

1252 "<ENTER>", 

1253 "<WAIT:2000>", 

1254 out=shot / "confirm-cast.svg", 

1255 shell="zsh", 

1256 width=64, 

1257 height=10, 

1258 cwd=interactive_demo, 

1259 ) 

1260 taskdocs_cast( 

1261 "fm scaffold", 

1262 "<ENTER>", 

1263 "<SETTLE>", 

1264 "myapp", 

1265 "<ENTER>", 

1266 "<SETTLE>", 

1267 "2", 

1268 "<ENTER>", 

1269 "<WAIT:2000>", 

1270 out=shot / "interactive-cast.svg", 

1271 shell="zsh", 

1272 width=64, 

1273 height=12, 

1274 cwd=interactive_demo, 

1275 ) 

1276 # Each interactive cast must show its whole interaction — the <SETTLE> 

1277 # timing exists to guarantee it, so verify the SVGs actually captured the 

1278 # prompt, the typed answer, and the result before shipping them. 

1279 _assert_cast_captured(shot / "ask-cast.svg", ["version", "1.4.0", "Releasing"]) 

1280 _assert_cast_captured(shot / "confirm-cast.svg", ["production", "Deploying"]) 

1281 _assert_cast_captured( 

1282 shot / "interactive-cast.svg", 

1283 # single tokens: the SVG drops inter-word spaces (&#160;), so "what 

1284 # kind?" would collapse — "kind" alone still proves the select rendered. 

1285 ["project", "kind", "library", "plugin", "Scaffolding", "myapp"], 

1286 ) 

1287 _write_llms_txt() 

1288 # A conditional flag needs no ternary: strict=check is --strict when 

1289 # check is true, omitted otherwise (strict is off by default in zensical). 

1290 zensical.opts(in_process=False).build(clean=True, strict=check) 

1291 

1292 

1293# The agent-hook adapters: machine-called, human-invisible (the hidden 

1294# group keeps the whole subtree out of --list/--tree/completion). Each one 

1295# reads its harness's JSON payload from stdin as a typed dataclass — no jq, 

1296# no host dependency — and speaks the exit-code contract its caller reads: 

1297# 0 all quiet, 2 a blocking verdict with the receipts on stderr, and 

1298# footman's own 64 passing through untouched, so a broken hook line reaches 

1299# the human who wired it, never the model as a fake verdict. Coupling a 

1300# task to its caller is fine here — this is user-land; only src/footman/ 

1301# must stay caller-blind. Wired in .claude/settings.json as 

1302# `uv run fm hooks.<name> 1>&2`. 

1303hooks = group("hooks", hidden=True, help="Agent lifecycle hooks (stdin-driven)") 

1304 

1305 

1306@dataclass 

1307class ToolInput: 

1308 file_path: str = "" 

1309 command: str = "" # Bash only: what the agent is about to run 

1310 

1311 

1312@dataclass 

1313class HookEvent: 

1314 tool_input: ToolInput = dataclasses.field(default_factory=ToolInput) 

1315 stop_hook_active: bool = False 

1316 session_id: str = "" 

1317 

1318 

1319def _worktree(path: str) -> Path | None: 

1320 """The checkout *path* belongs to — a git worktree root, or `None`. 

1321 

1322 A worktree's `.git` is a file rather than a directory, so `exists` is the 

1323 test and not `is_dir`. 

1324 """ 

1325 from pathlib import Path 

1326 

1327 if not path: 

1328 return None 

1329 start = Path(path).expanduser().resolve() 

1330 for candidate in (start, *start.parents): 

1331 if (candidate / ".git").exists(): 

1332 return candidate 

1333 return None 

1334 

1335 

1336def _gate_dir(session: str) -> Path: 

1337 """Where a session records the checkout its edits landed in. 

1338 

1339 A hook runs in the session's *shell* directory, which is not reliably the 

1340 checkout being worked on: `cd` somewhere outside the workspace and the 

1341 harness resets it to the project root. Several agents share this repo 

1342 through worktrees, so a stop hook that ran wherever the shell finished 

1343 would gate one session's work on another's half-finished tree — which it 

1344 did, reporting a failure in a branch this session had never touched. 

1345 

1346 So the edits say where the work is, and the file keys on the session so 

1347 two agents cannot answer for each other. 

1348 """ 

1349 import tempfile 

1350 from pathlib import Path 

1351 

1352 return Path(tempfile.gettempdir()) / f"footman-gate-{session or 'unknown'}" 

1353 

1354 

1355_QUOTED = re.compile(r"'(?:[^'\\]|\\.)*'|\"(?:[^\"\\]|\\.)*\"") 

1356_RUNS_FM = re.compile(r"^\s*(?:uv run(?: --\S+)* )?f(?:m|ootman)\b") 

1357_TRUNCATES = re.compile(r"\|\s*(?:tail|head)\b") 

1358_PUSHES = re.compile(r"^\s*git\s+(?:-C\s+(\S+)\s+)?push\b") 

1359_PUSH_EXEMPT = re.compile(r"\s(?:--delete|-d|--tags)\b|\bpush\s+(?:\S+\s+)?main\b") 

1360 

1361 

1362def _push_conflicts(repo: str | None) -> bool: 

1363 """Whether HEAD conflicts with `origin/main` — GitHub's test-merge, run 

1364 locally in milliseconds, before the push can create the silent state. 

1365 

1366 Fails open on every uncertainty: an offline fetch probes whatever 

1367 `origin/main` the clone last saw, and a repo with no such ref (a scratch 

1368 checkout, a fresh clone of something else) is not this guard's business. 

1369 Only a conflict — `merge-tree` exit 1, distinct from its other failures — 

1370 speaks. 

1371 """ 

1372 import contextlib 

1373 

1374 git = ["git", *(("-C", repo) if repo else ())] 

1375 with contextlib.suppress(RunFailed): 

1376 # Offline / no remote: the last-seen origin/main still answers. 

1377 run([*git, "fetch", "--quiet", "origin", "main"], capture=True) 

1378 try: 

1379 run([*git, "rev-parse", "--verify", "-q", "origin/main^{commit}"], capture=True) 

1380 except RunFailed: 

1381 return False # no origin/main at all: not this guard's business 

1382 try: 

1383 run([*git, "merge-tree", "--write-tree", "origin/main", "HEAD"], capture=True) 

1384 except RunFailed as exc: 

1385 # With the ref verified, exit 1 is merge-tree's one honest meaning: 

1386 # "merged, with conflicts". (Unverified, 1 also means "no such ref".) 

1387 return exc.result == 1 

1388 return False 

1389 

1390 

1391@hooks.task 

1392def pre_bash(event: Annotated[HookEvent, stdin]) -> None: 

1393 """Refuse the Bash commands that succeed while creating a silently broken 

1394 state: a footman gate piped into tail/head, and a `git push` of a branch 

1395 that conflicts with `origin/main`. 

1396 

1397 **The pipe guard.** A gate's **exit code is its verdict**, and a pipe 

1398 replaces it with the filter's — so `fm check | tail -4` reports 0 

1399 whatever happened, and prints the parallel step summary while the failing 

1400 step scrolls past above. This session called a red gate green exactly 

1401 that way. 

1402 

1403 **The push guard.** Several agent sessions share this repo, so `main` 

1404 moves while a branch is being built — and a branch pushed from a stale 

1405 base opens a CONFLICTING pull request, for which GitHub cannot build its 

1406 test-merge and therefore **spawns no CI at all**: no red X, no checks, 

1407 just an absence nothing points at (PR #304 sat exactly that way). The 

1408 guard runs the same test-merge locally (`git merge-tree --write-tree`) 

1409 before letting the push through. Tag pushes, deletions and pushes of 

1410 `main` itself pass untouched. 

1411 

1412 Both are deliberately narrow. Command separators split first, so `fm 

1413 check && echo done | tail` stays legal; quoted spans are data, so `rg "fm 

1414 check" | head` passes. Nudges, not a sandbox: `grep` destroys a verdict 

1415 just as well, and `--force` is still force — widening either guard 

1416 starts eating ordinary commands. 

1417 """ 

1418 segments = re.split(r";|&&|\|\|", event.tool_input.command) 

1419 blind = [_QUOTED.sub('""', segment) for segment in segments] 

1420 if any( 

1421 _TRUNCATES.search(segment[match.end() :]) 

1422 for segment in blind 

1423 if (match := _RUNS_FM.search(segment)) is not None 

1424 ): 

1425 fail( 

1426 "piping a footman command into tail/head replaces its exit code " 

1427 "with the filter's and hides the failing step — this session " 

1428 "reported a red gate as green that way. Run it unpiped and read " 

1429 "the exit code; to keep the output short, redirect to a file and " 

1430 "slice the file.", 

1431 code=2, 

1432 ) 

1433 for segment in blind: 

1434 push = _PUSHES.search(segment) 

1435 if push is None or _PUSH_EXEMPT.search(segment): 

1436 continue 

1437 repo = push.group(1) # a quoted -C path was blinded; probe the cwd then 

1438 if _push_conflicts(None if repo in (None, '""') else repo): 

1439 fail( 

1440 "git push refused: this branch conflicts with origin/main. A " 

1441 "conflicting PR spawns no CI at all — GitHub cannot build its " 

1442 "test-merge, so there is no red X, no checks, just silence " 

1443 "(PR #304 sat that way). Rebase (git fetch origin && git " 

1444 "rebase origin/main), re-run the gate, then push.", 

1445 code=2, 

1446 ) 

1447 

1448 

1449@hooks.task 

1450def post_edit(event: Annotated[HookEvent, stdin]) -> None: 

1451 """Format and lint a Python file the agent just edited.""" 

1452 # Every edit, not only the Python ones: `stop` needs to know which 

1453 # checkout this session is working in whatever it touched last. 

1454 if root := _worktree(event.tool_input.file_path): 

1455 _gate_dir(event.session_id).write_text(str(root), encoding="utf-8") 

1456 if not event.tool_input.file_path.endswith(".py"): 

1457 return 

1458 try: 

1459 format() 

1460 lint() 

1461 except RunFailed: 

1462 fail("format/lint failed — fix it before continuing", code=2) 

1463 

1464 

1465@hooks.task 

1466def stop(event: Annotated[HookEvent, stdin]) -> None: 

1467 """Refuse to let a session end on a red gate.""" 

1468 if event.stop_hook_active: 

1469 return # this stop already is the retry — never ping-pong 

1470 from pathlib import Path 

1471 

1472 here = _worktree(str(Path.cwd())) 

1473 try: 

1474 recorded = _gate_dir(event.session_id).read_text(encoding="utf-8").strip() 

1475 except OSError: 

1476 recorded = "" # nothing was edited, or the note is gone: gate here 

1477 target = _worktree(recorded) 

1478 try: 

1479 if target is None or target == here: 

1480 check() 

1481 else: 

1482 # Another checkout entirely, so the gate runs there — as a 

1483 # subprocess, because that tree has its own environment and its 

1484 # own footman, and this one has no business standing in for it. 

1485 run(["uv", "run", "fm", "check"], cwd=target) 

1486 except RunFailed: 

1487 where = "" if target is None or target == here else f" in {target.name}" 

1488 fail(f"the gate is red{where} — fix it before stopping", code=2) 

1489 

1490 

1491dist = group("dist", help="Build and publish") 

1492 

1493 

1494@dist.task 

1495def build(): 

1496 """Build the sdist and wheel.""" 

1497 uv("build") 

1498 

1499 

1500@dist.task 

1501def clean(): 

1502 """Remove build artifacts.""" 

1503 run("rm -rf dist") 

1504 

1505 

1506# Dogfood: pull footman's own first-party plugins, exactly as a user would. 

1507# Each node lands under its own name and merges with what the file defines 

1508# — the docs tasks join the local `docs` group leaf by leaf. Order doesn't 

1509# matter (a local group() adopts a pulled one, and pulls compose with 

1510# existing groups); pulled last only so the file's own tasks list first. 

1511plugin("footman.docs") 

1512plugin("footman.profile")