4"""Generate a dashboard for the closed historical HAL completion record.
6This consumer renders archived completion evidence from docs/ROADMAP.md using
7the same conventions as scripts/report/roadmap_stats.py. It is not a current
8work tracker; live work belongs in GitHub issues and the project board.
10 - "## <phase>" -- phase heading (groups drivers)
11 - "### <name>" -- driver heading
12 - "`[<m>]` Status:" line where <m> is one of " x~!"
13 - a fenced ``` ... ``` checklist block per driver
15The script emits two artifacts:
17 1. docs/ROADMAP_DASHBOARD.md
18 A markdown historical report with a per-phase completion table
19 and a per-driver evidence bar.
21 2. docs/badges/<name>.svg
22 Static, shields.io-compatible SVG badges:
23 - drivers.svg "drivers <DONE>/<total>"
24 - coverage.svg "coverage <pct>%"
25 - done.svg "done <DONE>"
27 - blocked.svg "blocked <BLOCKED>"
28 - todo.svg "todo <TODO>"
30This script intentionally does NOT modify ROADMAP.md and does NOT
31share state with roadmap_stats.py beyond the on-disk markdown.
35 python3 scripts/report/roadmap_dashboard.py
36 python3 scripts/report/roadmap_dashboard.py --check
38In --check mode the script exits non-zero if the dashboard or any
39badge would change on disk.
42from __future__
import annotations
49REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
50ROADMAP_PATH = REPO_ROOT /
"docs" /
"ROADMAP.md"
51DASHBOARD_PATH = REPO_ROOT /
"docs" /
"ROADMAP_DASHBOARD.md"
52BADGES_DIR = REPO_ROOT /
"docs" /
"badges"
54PHASE_HEADING_RE = re.compile(
r"^##\s+(?!#)(.+?)\s*$")
55DRIVER_HEADING_RE = re.compile(
r"^###\s+(.+?)\s*$")
56STATUS_LINE_RE = re.compile(
r"`\[(?P<mark>[ x~!])\]`\s*Status:")
57CHECKBOX_RE = re.compile(
r"^\s*\[(?P<mark>[ x~!])\]")
71COLOR_BLOCKED =
"#e05d44"
83 """One "### <name>" driver section."""
85 def __init__(self, name: str, status: str, phase: str) ->
None:
86 """Create a driver section with zero counted boxes.
88 Box counts start at zero and are accumulated by the parser as it walks
89 the section, so a Driver is only complete once its whole section has
99 def status_label(self) -> str:
100 """Human-readable status, mapped from the single-character marker.
102 Raises KeyError on an unrecognised marker rather than defaulting: an
103 unknown status in ROADMAP.md is a typo, and silently rendering it as
104 TODO would understate progress.
106 return STATUS_LABEL[self.status]
109 def pct(self) -> float:
110 """Checklist completion as a percentage; 0.0 when the section has no boxes.
112 A box-less section reports 0 rather than 100 -- "nothing to do" and
113 "everything done" are different claims, and only the second is
116 if self.total_boxes == 0:
118 return 100.0 * self.ticked_boxes / self.total_boxes
121def parse_roadmap(text: str) -> list[Driver]:
122 """Walk ROADMAP.md and produce one Driver per "### " section."""
123 drivers: list[Driver] = []
124 lines = text.splitlines()
127 current_phase =
"(unphased)"
132 ph = PHASE_HEADING_RE.match(line)
134 current_phase = ph.group(1)
138 m = DRIVER_HEADING_RE.match(line)
146 status_mark: str |
None =
None
147 for j
in range(i + 1,
min(i + 8, n)):
148 sm = STATUS_LINE_RE.search(lines[j])
150 status_mark = sm.group(
"mark")
152 if status_mark
is None:
156 drv = Driver(name=name, status=status_mark, phase=current_phase)
161 if lines[k].startswith(
"### ")
or lines[k].startswith(
"## "):
163 if lines[k].strip().startswith(
"```"):
165 while k < n
and not lines[k].strip().startswith(
"```"):
166 cb = CHECKBOX_RE.match(lines[k])
169 if cb.group(
"mark") ==
"x":
170 drv.ticked_boxes += 1
181def progress_bar(ticked: int, total: int, width: int = BAR_WIDTH) -> str:
182 """Return a "[==== ] N/M" style ASCII progress bar."""
184 return "[" +
" " * width +
"] 0/0"
185 filled = (ticked * width) // total
186 filled =
min(filled, width)
187 bar =
"=" * filled +
" " * (width - filled)
188 return f
"[{bar}] {ticked}/{total}"
191def status_glyph(status: str) -> str:
192 """Badge text for a status marker, defaulting to TODO for anything unknown.
194 Unlike ``status_label`` this is lenient, because it feeds a rendered badge
195 where an exception would break the whole dashboard over one bad marker.
205def _render_header() -> list[str]:
206 """Render the do-not-edit banner and historical-record notice."""
209 " ROADMAP_DASHBOARD.md -- generated by",
210 " scripts/report/roadmap_dashboard.py. Do not edit by hand;",
211 " re-run `just docs::dashboard` to refresh.",
214 "# HAL Completion Dashboard (Historical)",
216 "This is a generated view of the closed completion evidence in",
217 "`docs/ROADMAP.md`, not a live roadmap or work tracker. Current work",
218 "belongs in GitHub issues and the repository project board.",
220 "Bars show `ticked / total` for each archived driver checklist.",
225def _render_totals(drivers: list[Driver]) -> list[str]:
226 """The whole-roadmap totals table and overall progress bar."""
227 counts = {
"DONE": 0,
"WIP": 0,
"BLOCKED": 0,
"TODO": 0}
231 counts[d.status_label] += 1
232 total_boxes += d.total_boxes
233 ticked_boxes += d.ticked_boxes
234 pct = (100.0 * ticked_boxes / total_boxes)
if total_boxes
else 0.0
238 "| Metric | Value |",
240 f
"| Drivers tracked | {len(drivers)} |",
241 f
"| DONE | {counts['DONE']} |",
242 f
"| WIP | {counts['WIP']} |",
243 f
"| BLOCKED | {counts['BLOCKED']} |",
244 f
"| TODO | {counts['TODO']} |",
245 f
"| Checklist coverage | {ticked_boxes}/{total_boxes} ({pct:.1f}%) |",
247 "Overall: `" + progress_bar(ticked_boxes, total_boxes, BAR_WIDTH * 2) +
"`",
252def _group_by_phase(drivers: list[Driver]) -> tuple[list[str], dict[str, list[Driver]]]:
253 """Group drivers by phase, preserving first-seen order.
255 First-seen rather than sorted: the roadmap's phase order is the plan's
256 order, and re-sorting it alphabetically would present the schedule wrong.
258 phase_order: list[str] = []
259 by_phase: dict[str, list[Driver]] = {}
261 if d.phase
not in by_phase:
262 by_phase[d.phase] = []
263 phase_order.append(d.phase)
264 by_phase[d.phase].append(d)
265 return phase_order, by_phase
268def _render_per_phase(phase_order: list[str], by_phase: dict[str, list[Driver]]) -> list[str]:
269 """One summary row per phase: driver counts by status, boxes, and a bar."""
271 "## Per-phase progress",
273 "| Phase | Drivers | DONE | WIP | BLOCKED | TODO | Boxes | Bar |",
274 "| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |",
276 for phase
in phase_order:
277 items = by_phase[phase]
278 ph_counts = {
"DONE": 0,
"WIP": 0,
"BLOCKED": 0,
"TODO": 0}
282 ph_counts[d.status_label] += 1
283 ph_total += d.total_boxes
284 ph_ticked += d.ticked_boxes
285 bar = progress_bar(ph_ticked, ph_total, BAR_WIDTH)
290 phase.replace(
"|",
"/"),
292 str(ph_counts[
"DONE"]),
293 str(ph_counts[
"WIP"]),
294 str(ph_counts[
"BLOCKED"]),
295 str(ph_counts[
"TODO"]),
296 f
"{ph_ticked}/{ph_total}",
306def _render_per_driver(phase_order: list[str], by_phase: dict[str, list[Driver]]) -> list[str]:
307 """One table per phase listing each driver's status and checklist bar."""
308 out = [
"## Per-driver progress",
""]
309 for phase
in phase_order:
311 [f
"### {phase}",
"",
"| Driver | Status | Boxes | Bar |",
"| --- | --- | ---: | --- |"]
313 for d
in by_phase[phase]:
314 bar = progress_bar(d.ticked_boxes, d.total_boxes, BAR_WIDTH)
319 d.name.replace(
"|",
"/"),
320 status_glyph(d.status),
321 f
"{d.ticked_boxes}/{d.total_boxes}",
331def render_dashboard(drivers: list[Driver]) -> str:
332 """Build the markdown dashboard text."""
333 phase_order, by_phase = _group_by_phase(drivers)
336 *_render_totals(drivers),
337 *_render_per_phase(phase_order, by_phase),
338 *_render_per_driver(phase_order, by_phase),
340 return "\n".join(out) +
"\n"
352def _text_px(text: str) -> int:
353 return max(_CHAR_PX * len(text), _CHAR_PX)
356def render_badge(label: str, value: str, color: str) -> str:
357 """Return a shields-style 2-segment SVG badge as a string."""
358 label_w = _text_px(label) + 10
359 value_w = _text_px(value) + 10
360 total_w = label_w + value_w
364 label_cx = label_w * 5
365 value_cx = label_w * 10 + value_w * 5
366 label_text_w = (label_w - 10) * 10
367 value_text_w = (value_w - 10) * 10
370 f
'<svg xmlns="http://www.w3.org/2000/svg" '
371 f
'width="{total_w}" height="{height}" '
372 f
'role="img" aria-label="{label}: {value}">\n'
373 f
" <title>{label}: {value}</title>\n"
374 f
' <linearGradient id="s" x2="0" y2="100%">\n'
375 f
' <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>\n'
376 f
' <stop offset="1" stop-opacity=".1"/>\n'
377 f
" </linearGradient>\n"
378 f
' <clipPath id="r"><rect width="{total_w}" height="{height}" '
379 f
'rx="3" fill="#fff"/></clipPath>\n'
380 f
' <g clip-path="url(#r)">\n'
381 f
' <rect width="{label_w}" height="{height}" fill="#555"/>\n'
382 f
' <rect x="{label_w}" width="{value_w}" height="{height}" '
383 f
'fill="{color}"/>\n'
384 f
' <rect width="{total_w}" height="{height}" fill="url(#s)"/>\n'
386 f
' <g fill="#fff" text-anchor="middle" '
387 f
'font-family="Verdana,Geneva,DejaVu Sans,sans-serif" '
388 f
'text-rendering="geometricPrecision" font-size="110">\n'
389 f
' <text aria-hidden="true" x="{label_cx}" y="150" '
390 f
'fill="#010101" fill-opacity=".3" transform="scale(.1)" '
391 f
'textLength="{label_text_w}">{label}</text>\n'
392 f
' <text x="{label_cx}" y="140" transform="scale(.1)" '
393 f
'fill="#fff" textLength="{label_text_w}">{label}</text>\n'
394 f
' <text aria-hidden="true" x="{value_cx}" y="150" '
395 f
'fill="#010101" fill-opacity=".3" transform="scale(.1)" '
396 f
'textLength="{value_text_w}">{value}</text>\n'
397 f
' <text x="{value_cx}" y="140" transform="scale(.1)" '
398 f
'fill="#fff" textLength="{value_text_w}">{value}</text>\n'
404def coverage_color(pct: float) -> str:
405 """Badge colour for a coverage percentage, banded worst-to-best.
407 Thresholds are compared descending so each band claims everything above
408 it; the order of the tests is the band definition.
410 if pct >= COV_EXCELLENT:
421def build_badges(drivers: list[Driver]) -> dict[str, str]:
422 """Return a {filename: svg_text} mapping for every badge we emit."""
423 counts = {
"DONE": 0,
"WIP": 0,
"BLOCKED": 0,
"TODO": 0}
427 counts[d.status_label] += 1
428 total_boxes += d.total_boxes
429 ticked_boxes += d.ticked_boxes
432 pct = (100.0 * ticked_boxes / total_boxes)
if total_boxes
else 0.0
434 badges: dict[str, str] = {}
435 badges[
"drivers.svg"] = render_badge(
437 f
"{counts['DONE']}/{total}",
438 COLOR_DONE
if counts[
"DONE"] == total
else COLOR_INFO,
440 badges[
"coverage.svg"] = render_badge(
"coverage", f
"{pct:.1f}%", coverage_color(pct))
441 badges[
"done.svg"] = render_badge(
"done", str(counts[
"DONE"]), COLOR_DONE)
442 badges[
"wip.svg"] = render_badge(
445 COLOR_WIP
if counts[
"WIP"]
else COLOR_TODO,
447 badges[
"blocked.svg"] = render_badge(
449 str(counts[
"BLOCKED"]),
450 COLOR_BLOCKED
if counts[
"BLOCKED"]
else COLOR_DONE,
452 badges[
"todo.svg"] = render_badge(
455 COLOR_TODO
if counts[
"TODO"]
else COLOR_DONE,
465def _write_if_changed(path: pathlib.Path, content: str) -> bool:
466 """Write `content` to `path` if different. Return True if changed."""
468 old = path.read_text(encoding=
"utf-8")
471 path.parent.mkdir(parents=
True, exist_ok=
True)
472 path.write_text(content, encoding=
"utf-8")
476def _build_parser() -> argparse.ArgumentParser:
477 """Build the command-line parser for this generator."""
478 parser = argparse.ArgumentParser(description=__doc__)
482 help=
"exit non-zero if any output file would change",
486 default=str(ROADMAP_PATH),
487 help=f
"path to ROADMAP.md (default: {ROADMAP_PATH})",
491 default=str(DASHBOARD_PATH),
492 help=f
"output dashboard path (default: {DASHBOARD_PATH})",
496 default=str(BADGES_DIR),
497 help=f
"output badges directory (default: {BADGES_DIR})",
502def _check_up_to_date(
503 dashboard_path: pathlib.Path,
505 badges_dir: pathlib.Path,
506 badges: dict[str, str],
509 """Compare every output against what would be generated, WITHOUT writing.
511 Writing here would defeat the purpose: CI runs this mode, and a generator
512 that refreshes its own output always agrees with itself and can never
515 Returns 1 (listing each stale path) when anything would change, else 0.
517 changed: list[str] = []
518 if not dashboard_path.exists()
or (dashboard_path.read_text(encoding=
"utf-8") != dashboard_md):
519 changed.append(str(dashboard_path))
520 for name, svg
in badges.items():
521 target = badges_dir / name
522 if not target.exists()
or (target.read_text(encoding=
"utf-8") != svg):
523 changed.append(str(target))
525 print(
"roadmap_dashboard.py: the following outputs are stale:", file=sys.stderr)
527 print(f
" {c}", file=sys.stderr)
528 print(
" (run `just docs::dashboard` to refresh)", file=sys.stderr)
531 f
"roadmap_dashboard.py: dashboard up to date (drivers={driver_count})",
537def main(argv: list[str]) -> int:
538 """Regenerate the roadmap dashboard, or with ``--check`` verify it is current.
540 ``--check`` writes nothing and exits 1 when the file would change, which
541 is what CI runs -- otherwise the dashboard would be regenerated by the
542 runner and always agree with itself.
544 Returns 0 when the dashboard is up to date or was rewritten, 1 under
545 ``--check`` when it is stale.
547 args = _build_parser().parse_args(argv)
549 roadmap_path = pathlib.Path(args.roadmap)
550 if not roadmap_path.exists():
552 f
"roadmap_dashboard.py: not found: {roadmap_path}",
557 text = roadmap_path.read_text(encoding=
"utf-8")
558 drivers = parse_roadmap(text)
560 dashboard_md = render_dashboard(drivers)
561 badges = build_badges(drivers)
563 dashboard_path = pathlib.Path(args.dashboard)
564 badges_dir = pathlib.Path(args.badges_dir)
566 changed: list[str] = []
569 return _check_up_to_date(dashboard_path, dashboard_md, badges_dir, badges, len(drivers))
571 if _write_if_changed(dashboard_path, dashboard_md):
572 changed.append(str(dashboard_path))
573 for name, svg
in badges.items():
574 if _write_if_changed(badges_dir / name, svg):
575 changed.append(str(badges_dir / name))
579 f
"roadmap_dashboard.py: wrote {len(changed)} file(s) (drivers={len(drivers)})",
583 print(f
" {c}", file=sys.stderr)
586 f
"roadmap_dashboard.py: no changes (drivers={len(drivers)})",
592if __name__ ==
"__main__":
593 raise SystemExit(
main(sys.argv[1:]))
void main(void)
The application entry point Reset_Handler hands control to.
#define min(x, y)
Untyped minimum shim used by the SOUP's buffer clamping.