ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
roadmap_dashboard.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""Generate a dashboard for the closed historical HAL completion record.
5
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.
9
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
14
15The script emits two artifacts:
16
17 1. docs/ROADMAP_DASHBOARD.md
18 A markdown historical report with a per-phase completion table
19 and a per-driver evidence bar.
20
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>"
26 - wip.svg "wip <WIP>"
27 - blocked.svg "blocked <BLOCKED>"
28 - todo.svg "todo <TODO>"
29
30This script intentionally does NOT modify ROADMAP.md and does NOT
31share state with roadmap_stats.py beyond the on-disk markdown.
32
33Usage:
34
35 python3 scripts/report/roadmap_dashboard.py
36 python3 scripts/report/roadmap_dashboard.py --check
37
38In --check mode the script exits non-zero if the dashboard or any
39badge would change on disk.
40"""
41
42from __future__ import annotations
43
44import argparse
45import pathlib
46import re
47import sys
48
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"
53
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~!])\‍]")
58
59BAR_WIDTH = 20
60
61STATUS_LABEL = {
62 "x": "DONE",
63 "~": "WIP",
64 "!": "BLOCKED",
65 " ": "TODO",
66}
67
68# Shields-style colors keyed by status / metric.
69COLOR_DONE = "#4c1"
70COLOR_WIP = "#dfb317"
71COLOR_BLOCKED = "#e05d44"
72COLOR_TODO = "#9f9f9f"
73COLOR_INFO = "#007ec6"
74
75# Coverage thresholds used to pick a badge color for checklist completion.
76COV_EXCELLENT = 95.0 # >= this -> green (COLOR_DONE)
77COV_GOOD = 75.0 # >= this -> light-green
78COV_OK = 50.0 # >= this -> yellow (COLOR_WIP)
79COV_LOW = 25.0 # >= this -> orange
80
81
82class Driver:
83 """One "### <name>" driver section."""
84
85 def __init__(self, name: str, status: str, phase: str) -> None:
86 """Create a driver section with zero counted boxes.
87
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
90 been read.
91 """
92 self.name = name
93 self.status = status # one of " x~!"
94 self.phase = phase
95 self.total_boxes = 0
96 self.ticked_boxes = 0
97
98 @property
99 def status_label(self) -> str:
100 """Human-readable status, mapped from the single-character marker.
101
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.
105 """
106 return STATUS_LABEL[self.status]
107
108 @property
109 def pct(self) -> float:
110 """Checklist completion as a percentage; 0.0 when the section has no boxes.
111
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
114 evidence.
115 """
116 if self.total_boxes == 0:
117 return 0.0
118 return 100.0 * self.ticked_boxes / self.total_boxes
119
120
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()
125 i = 0
126 n = len(lines)
127 current_phase = "(unphased)"
128
129 while i < n:
130 line = lines[i]
131
132 ph = PHASE_HEADING_RE.match(line)
133 if ph:
134 current_phase = ph.group(1)
135 i += 1
136 continue
137
138 m = DRIVER_HEADING_RE.match(line)
139 if not m:
140 i += 1
141 continue
142
143 name = m.group(1)
144
145 # Find the Status: line within the next 8 lines.
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])
149 if sm:
150 status_mark = sm.group("mark")
151 break
152 if status_mark is None:
153 i += 1
154 continue
155
156 drv = Driver(name=name, status=status_mark, phase=current_phase)
157
158 # Walk to the first fenced block before the next "### "/"## ".
159 k = i + 1
160 while k < n:
161 if lines[k].startswith("### ") or lines[k].startswith("## "):
162 break
163 if lines[k].strip().startswith("```"):
164 k += 1
165 while k < n and not lines[k].strip().startswith("```"):
166 cb = CHECKBOX_RE.match(lines[k])
167 if cb:
168 drv.total_boxes += 1
169 if cb.group("mark") == "x":
170 drv.ticked_boxes += 1
171 k += 1
172 break
173 k += 1
174
175 drivers.append(drv)
176 i = max(k, i + 1)
177
178 return drivers
179
180
181def progress_bar(ticked: int, total: int, width: int = BAR_WIDTH) -> str:
182 """Return a "[==== ] N/M" style ASCII progress bar."""
183 if total <= 0:
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}"
189
190
191def status_glyph(status: str) -> str:
192 """Badge text for a status marker, defaulting to TODO for anything unknown.
193
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.
196 """
197 return {
198 "x": "DONE",
199 "~": "WIP",
200 "!": "BLOCKED",
201 " ": "TODO",
202 }[status]
203
204
205def _render_header() -> list[str]:
206 """Render the do-not-edit banner and historical-record notice."""
207 return [
208 "<!--",
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.",
212 "-->",
213 "",
214 "# HAL Completion Dashboard (Historical)",
215 "",
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.",
219 "",
220 "Bars show `ticked / total` for each archived driver checklist.",
221 "",
222 ]
223
224
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}
228 total_boxes = 0
229 ticked_boxes = 0
230 for d in drivers:
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
235 return [
236 "## Totals",
237 "",
238 "| Metric | Value |",
239 "| --- | --- |",
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}%) |",
246 "",
247 "Overall: `" + progress_bar(ticked_boxes, total_boxes, BAR_WIDTH * 2) + "`",
248 "",
249 ]
250
251
252def _group_by_phase(drivers: list[Driver]) -> tuple[list[str], dict[str, list[Driver]]]:
253 """Group drivers by phase, preserving first-seen order.
254
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.
257 """
258 phase_order: list[str] = []
259 by_phase: dict[str, list[Driver]] = {}
260 for d in drivers:
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
266
267
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."""
270 out = [
271 "## Per-phase progress",
272 "",
273 "| Phase | Drivers | DONE | WIP | BLOCKED | TODO | Boxes | Bar |",
274 "| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |",
275 ]
276 for phase in phase_order:
277 items = by_phase[phase]
278 ph_counts = {"DONE": 0, "WIP": 0, "BLOCKED": 0, "TODO": 0}
279 ph_total = 0
280 ph_ticked = 0
281 for d in items:
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)
286 out.append(
287 "| "
288 + " | ".join(
289 [
290 phase.replace("|", "/"),
291 str(len(items)),
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}",
297 "`" + bar + "`",
298 ]
299 )
300 + " |"
301 )
302 out.append("")
303 return out
304
305
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:
310 out.extend(
311 [f"### {phase}", "", "| Driver | Status | Boxes | Bar |", "| --- | --- | ---: | --- |"]
312 )
313 for d in by_phase[phase]:
314 bar = progress_bar(d.ticked_boxes, d.total_boxes, BAR_WIDTH)
315 out.append(
316 "| "
317 + " | ".join(
318 [
319 d.name.replace("|", "/"),
320 status_glyph(d.status),
321 f"{d.ticked_boxes}/{d.total_boxes}",
322 "`" + bar + "`",
323 ]
324 )
325 + " |"
326 )
327 out.append("")
328 return out
329
330
331def render_dashboard(drivers: list[Driver]) -> str:
332 """Build the markdown dashboard text."""
333 phase_order, by_phase = _group_by_phase(drivers)
334 out = [
335 *_render_header(),
336 *_render_totals(drivers),
337 *_render_per_phase(phase_order, by_phase),
338 *_render_per_driver(phase_order, by_phase),
339 ]
340 return "\n".join(out) + "\n"
341
342
343# --------------------------------------------------------------------- #
344# SVG badge renderer (shields.io-compatible static SVG)
345# --------------------------------------------------------------------- #
346
347# Approximate "Verdana 11px" character width. We use a fixed 7-px-per-char
348# value which is close enough for rendering and keeps this stdlib-only.
349_CHAR_PX = 7
350
351
352def _text_px(text: str) -> int:
353 return max(_CHAR_PX * len(text), _CHAR_PX)
354
355
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
361 height = 20
362
363 # Centers, in 10x scale to mimic shields.
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
368
369 return (
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'
385 f" </g>\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'
399 f" </g>\n"
400 f"</svg>\n"
401 )
402
403
404def coverage_color(pct: float) -> str:
405 """Badge colour for a coverage percentage, banded worst-to-best.
406
407 Thresholds are compared descending so each band claims everything above
408 it; the order of the tests is the band definition.
409 """
410 if pct >= COV_EXCELLENT:
411 return COLOR_DONE
412 if pct >= COV_GOOD:
413 return "#97ca00"
414 if pct >= COV_OK:
415 return COLOR_WIP
416 if pct >= COV_LOW:
417 return "#fe7d37"
418 return COLOR_BLOCKED
419
420
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}
424 total_boxes = 0
425 ticked_boxes = 0
426 for d in drivers:
427 counts[d.status_label] += 1
428 total_boxes += d.total_boxes
429 ticked_boxes += d.ticked_boxes
430
431 total = len(drivers)
432 pct = (100.0 * ticked_boxes / total_boxes) if total_boxes else 0.0
433
434 badges: dict[str, str] = {}
435 badges["drivers.svg"] = render_badge(
436 "drivers",
437 f"{counts['DONE']}/{total}",
438 COLOR_DONE if counts["DONE"] == total else COLOR_INFO,
439 )
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(
443 "wip",
444 str(counts["WIP"]),
445 COLOR_WIP if counts["WIP"] else COLOR_TODO,
446 )
447 badges["blocked.svg"] = render_badge(
448 "blocked",
449 str(counts["BLOCKED"]),
450 COLOR_BLOCKED if counts["BLOCKED"] else COLOR_DONE,
451 )
452 badges["todo.svg"] = render_badge(
453 "todo",
454 str(counts["TODO"]),
455 COLOR_TODO if counts["TODO"] else COLOR_DONE,
456 )
457 return badges
458
459
460# --------------------------------------------------------------------- #
461# Entry point
462# --------------------------------------------------------------------- #
463
464
465def _write_if_changed(path: pathlib.Path, content: str) -> bool:
466 """Write `content` to `path` if different. Return True if changed."""
467 if path.exists():
468 old = path.read_text(encoding="utf-8")
469 if old == content:
470 return False
471 path.parent.mkdir(parents=True, exist_ok=True)
472 path.write_text(content, encoding="utf-8")
473 return True
474
475
476def _build_parser() -> argparse.ArgumentParser:
477 """Build the command-line parser for this generator."""
478 parser = argparse.ArgumentParser(description=__doc__)
479 parser.add_argument(
480 "--check",
481 action="store_true",
482 help="exit non-zero if any output file would change",
483 )
484 parser.add_argument(
485 "--roadmap",
486 default=str(ROADMAP_PATH),
487 help=f"path to ROADMAP.md (default: {ROADMAP_PATH})",
488 )
489 parser.add_argument(
490 "--dashboard",
491 default=str(DASHBOARD_PATH),
492 help=f"output dashboard path (default: {DASHBOARD_PATH})",
493 )
494 parser.add_argument(
495 "--badges-dir",
496 default=str(BADGES_DIR),
497 help=f"output badges directory (default: {BADGES_DIR})",
498 )
499 return parser
500
501
502def _check_up_to_date(
503 dashboard_path: pathlib.Path,
504 dashboard_md: str,
505 badges_dir: pathlib.Path,
506 badges: dict[str, str],
507 driver_count: int,
508) -> int:
509 """Compare every output against what would be generated, WITHOUT writing.
510
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
513 report a stale file.
514
515 Returns 1 (listing each stale path) when anything would change, else 0.
516 """
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))
524 if changed:
525 print("roadmap_dashboard.py: the following outputs are stale:", file=sys.stderr)
526 for c in changed:
527 print(f" {c}", file=sys.stderr)
528 print(" (run `just docs::dashboard` to refresh)", file=sys.stderr)
529 return 1
530 print(
531 f"roadmap_dashboard.py: dashboard up to date (drivers={driver_count})",
532 file=sys.stderr,
533 )
534 return 0
535
536
537def main(argv: list[str]) -> int:
538 """Regenerate the roadmap dashboard, or with ``--check`` verify it is current.
539
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.
543
544 Returns 0 when the dashboard is up to date or was rewritten, 1 under
545 ``--check`` when it is stale.
546 """
547 args = _build_parser().parse_args(argv)
548
549 roadmap_path = pathlib.Path(args.roadmap)
550 if not roadmap_path.exists():
551 print(
552 f"roadmap_dashboard.py: not found: {roadmap_path}",
553 file=sys.stderr,
554 )
555 return 2
556
557 text = roadmap_path.read_text(encoding="utf-8")
558 drivers = parse_roadmap(text)
559
560 dashboard_md = render_dashboard(drivers)
561 badges = build_badges(drivers)
562
563 dashboard_path = pathlib.Path(args.dashboard)
564 badges_dir = pathlib.Path(args.badges_dir)
565
566 changed: list[str] = []
567
568 if args.check:
569 return _check_up_to_date(dashboard_path, dashboard_md, badges_dir, badges, len(drivers))
570
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))
576
577 if changed:
578 print(
579 f"roadmap_dashboard.py: wrote {len(changed)} file(s) (drivers={len(drivers)})",
580 file=sys.stderr,
581 )
582 for c in changed:
583 print(f" {c}", file=sys.stderr)
584 else:
585 print(
586 f"roadmap_dashboard.py: no changes (drivers={len(drivers)})",
587 file=sys.stderr,
588 )
589 return 0
590
591
592if __name__ == "__main__":
593 raise SystemExit(main(sys.argv[1:]))
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298
#define min(x, y)
Untyped minimum shim used by the SOUP's buffer clamping.
Definition xz_config.h:157