Coverage for plugin/scripts/sync_readme_help.py: 100%
93 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 14:46 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 14:46 +0000
1#!/usr/bin/env python3
2"""Sync the README's `make help` block from live output — behind `/rhiza:docs`.
4Keeps a README's list of `make` targets in lockstep with the actual `Makefile`, so
5contributors never read a stale target list. Replaces the retired rhiza-tools
6``update-readme`` command.
8The contract is deliberately narrow, because this edits a file humans have written:
10* it finds the marker line ``Run `make help` to see all available targets:`` and the
11 fenced code block immediately after it, and replaces **only that block's
12 contents** — the marker, the fences, and every other byte stay put;
13* marker missing, or present with no following fence? **No-op**, reported as such.
14 It never invents a place to put the list;
15* ``make help`` output is sanitised first — help targets colourise names, and
16 recursive makes emit ``Entering directory`` chatter — so the result is stable;
17* it is **idempotent**: against an unchanged ``Makefile`` a second run writes
18 nothing, which is what makes it safe to run on every `/rhiza:docs`.
20Usage:
21 uv run --python 3.12 --no-project python \
22 scripts/sync_readme_help.py [TARGET] [--readme README.md] [--json]
24Exit codes:
25 0 block refreshed, or already up to date, or nothing to do (no marker/Makefile)
26 2 `make help` failed
27"""
29from __future__ import annotations
31import argparse
32import json
33import os
34import re
35import shutil
36import subprocess # nosec B404
37import sys
38from pathlib import Path
39from typing import Any
41MARKER = "Run `make help` to see all available targets:"
42_FENCE = "```"
43_MAKEFILES = ("Makefile", "makefile", "GNUmakefile")
45_ANSI = re.compile(r"\x1b\[[0-9;]*m")
46_MAKE_CHATTER = re.compile(r"^make\[\d+\]: (Entering|Leaving) directory")
48EXIT_OK = 0
49EXIT_MAKE_FAILED = 2
52def find_makefile(target: Path) -> Path | None:
53 """Return the repo's makefile, or None when there isn't one."""
54 return next((target / name for name in _MAKEFILES if (target / name).is_file()), None)
57def has_help_target(makefile: Path) -> bool:
58 """Can make resolve a ``help`` target from *makefile*'s directory?
60 Asks **make**, via ``make -n help``, rather than reading the file. That matters
61 because a rhiza-managed repo's root Makefile rarely defines ``help`` itself: up to
62 template v1.3 it was essentially ``include .rhiza/rhiza.mk`` and the target lived in
63 the *included* file, and from v1.4 it is a shim whose ``help`` shells out to the
64 pinned `rhiza-task`. A text scan of the root therefore answers "no" for every repo
65 this is meant to serve, which silently disabled the whole sync there.
67 ``-n`` expands the recipe without running it, so the probe has no side effects.
68 """
69 make = shutil.which("make")
70 if make is None: # pragma: no cover - make is checked by the caller
71 return False
72 result = subprocess.run( # nosec B603
73 [make, "-n", "help"],
74 cwd=str(makefile.parent),
75 capture_output=True,
76 text=True,
77 check=False,
78 )
79 return result.returncode == 0
82def clean_help_output(raw: str) -> str:
83 """Strip ANSI colour codes and recursive-make chatter from ``make help`` output."""
84 lines = [_ANSI.sub("", line).rstrip() for line in raw.splitlines()]
85 kept = [line for line in lines if not _MAKE_CHATTER.match(line)]
86 while kept and not kept[0].strip():
87 kept.pop(0)
88 while kept and not kept[-1].strip():
89 kept.pop()
90 return "\n".join(kept)
93def find_block(lines: list[str]) -> tuple[int, int] | None:
94 """Return ``(first_content_idx, fence_close_idx)`` for the marker's fenced block.
96 ``first_content_idx`` is the line after the opening fence, so an empty block
97 yields a span where start == the closing fence index. Returns None when the
98 marker is absent, or present with no fence following it.
99 """
100 marker = next((i for i, line in enumerate(lines) if MARKER in line), None)
101 if marker is None:
102 return None
103 # The opening fence must follow the marker, allowing blank lines between.
104 opening = None
105 for i in range(marker + 1, len(lines)):
106 if not lines[i].strip():
107 continue
108 opening = i if lines[i].lstrip().startswith(_FENCE) else None
109 break
110 if opening is None:
111 return None
112 for j in range(opening + 1, len(lines)):
113 if lines[j].lstrip().startswith(_FENCE):
114 return opening + 1, j
115 return None
118def sync_readme_help(target: Path, readme_name: str = "README.md") -> dict[str, Any]:
119 """Refresh the README's `make help` block at *target*; return a summary dict."""
120 readme = target / readme_name
122 if not readme.is_file():
123 return _result("skipped", f"no {readme_name}")
125 makefile = find_makefile(target)
126 if makefile is None:
127 return _result("skipped", "no Makefile")
129 # Before probing: the probe itself asks make, so its absence must be reported as
130 # such rather than as "no help target", which would be misleading.
131 make = shutil.which("make")
132 if make is None:
133 return _result("skipped", "make is not on PATH")
134 if not has_help_target(makefile):
135 return _result("skipped", "make cannot resolve a `help` target")
137 original = readme.read_text(encoding="utf-8")
138 span = find_block(original.splitlines())
139 if span is None:
140 return _result("skipped", f"no `{MARKER}` marker with a following fenced block")
142 env = os.environ.copy()
143 env["NO_COLOR"] = "1"
144 proc = subprocess.run( # nosec B603
145 [make, "help"], cwd=str(target), capture_output=True, text=True, env=env, check=False
146 )
147 if proc.returncode != 0:
148 return {
149 "status": "failed",
150 "note": f"`make help` failed: {proc.stderr.strip()[:200]}",
151 "exit_code": EXIT_MAKE_FAILED,
152 }
154 body = clean_help_output(proc.stdout)
155 lines = original.splitlines()
156 start, close = span
157 if lines[start:close] == body.splitlines():
158 return _result("unchanged", "the target list already matches `make help`")
160 lines[start:close] = body.splitlines()
161 updated = "\n".join(lines)
162 if original.endswith("\n"):
163 updated += "\n"
164 readme.write_text(updated, encoding="utf-8")
165 return _result("refreshed", f"{len(body.splitlines())} line(s) from `make help`")
168def _result(status: str, note: str) -> dict[str, Any]:
169 """Build a summary dict for a non-failure outcome."""
170 return {"status": status, "note": note, "exit_code": EXIT_OK}
173def main(argv: list[str] | None = None) -> int:
174 """Entry point: sync the README block and return an exit code."""
175 parser = argparse.ArgumentParser(description="Sync a README's `make help` block.")
176 parser.add_argument(
177 "target", nargs="?", default=".", help="Repository root (default: current directory)."
178 )
179 parser.add_argument("--readme", default="README.md", help="README filename.")
180 parser.add_argument(
181 "--json", dest="json_output", action="store_true", help="Emit the summary as JSON."
182 )
183 args = parser.parse_args(argv)
185 summary = sync_readme_help(Path(args.target).resolve(), args.readme)
187 if args.json_output:
188 print(json.dumps(summary, indent=2))
189 else:
190 stream = sys.stderr if summary["status"] in ("skipped", "failed") else sys.stdout
191 print(f"{summary['status']:<10} {summary['note']}", file=stream)
192 return int(summary["exit_code"])
195if __name__ == "__main__":
196 raise SystemExit(main())