Coverage for .github / scripts / check_bash_coverage.py: 100.00%

361 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-09-14 22:07 +0000

1"""Enforce the shell-script coverage floor from a bashcov/SimpleCov report. 

2 

3The ``unit:bats:shell`` job runs the BATS suite under ``bashcov``, which traces 

4Bash through ``BASH_XTRACEFD`` and writes a SimpleCov resultset describing every 

5*relevant* line of every shell file it saw and how many times each ran. This 

6script turns that resultset into a pass/fail gate. 

7 

8Deciding which lines of a shell script are even executable is the hard part of 

9Bash coverage — here-documents, ``case`` arms, line continuations and function 

10headers all have to be classified — so that judgement is deliberately left to 

11bashcov's lexer rather than re-implemented here, with two corrections that 

12come from measuring what ``set -x`` actually prints: 

13 

14**Lines Bash never traces.** The lexer works from the text alone and marks two 

15shapes executable that the tracer never reports, so no test could ever cover 

16them: a compound-command terminator that carries only redirections (``done <<< 

17"$rows"``, ``} > "$report"`` — the redirection belongs to the loop or group, 

18and ``set -x`` prints simple commands, not the loop) and a ``case`` arm with no 

19body (``*/*) ;;``). ``untraceable_lines()`` recognises exactly those two shapes 

20and ``evaluate()`` leaves them out of the count, the way SimpleCov leaves out a 

21comment. Anything that carries a command — a pipe into ``sed`` after ``}``, a 

22``:`` in the arm, a process substitution — is still measured. 

23 

24**Statements that span lines.** Bash reports one line per statement, and which 

25physical line it picks depends on the shape: the first line of ``python3 -c 

26"..."`` with a multi-line string, the *second* line of a plain backslash chain 

27(``kill_it \\ / one \\ / two`` reports line 2) or of a backgrounded one, and the 

28*last* line of ``VAR=$(...)``, of ``VAR="multi\\nline"``, and of the ``cat 

29<<'EOF' ... EOF )`` heredoc-in-substitution the client examples build their 

30payloads with. The lexer propagates the first line's count across some of these 

31shapes and not others (a ``\\"`` inside the string or a ``||`` on the last line 

32of a chain defeats its patterns), which left dozens of lines permanently at 

33zero. ``statement_spans()`` scans each script for statements that continue 

34across lines — a trailing backslash, an unclosed ``(``/``$(``, an open quote, 

35a here-document body — and ``evaluate()`` folds every span onto its first line 

36with the highest count seen on any of its lines. A statement is covered when 

37Bash reported it, wherever it reported it. A chain is split where a list 

38operator (``||``, ``&&``, ``|``) starts a new command at the top level, because 

39Bash does report those elements on their own lines; the fallback in 

40``aws ... 2>&1 || echo "may already exist"`` therefore stays a separately 

41measured statement. 

42 

43What this script owns beyond that is everything SimpleCov cannot know about 

44*this* repository: 

45 

46**Path shape.** bashcov reports absolute paths (``/home/runner/work/.../demo/ 

47lib_demo.sh``), while the inventory and every error message use 

48repository-relative ones. Reported paths are mapped back onto the tracked 

49script they refer to by longest path suffix, falling back to a unique basename, 

50and hits from every path that maps to the same script are merged — so a script 

51exercised by several suites is credited with all of them. 

52 

53That merging also covers copies of a script, which matters because a BATS 

54suite may ``cp`` the script under test into ``$BATS_TEST_TMPDIR`` and run it 

55from an isolated fake repository. It does not rescue such a suite on its own, 

56though: SimpleCov reads each file when it renders the report, and by then BATS 

57has deleted its temporary directories, so the copies are dropped before this 

58script ever sees them. A suite has to run the tracked file in place for its 

59hits to count (the recorders take a repository-root override for exactly 

60this). The merging is what keeps a *surviving* copy, or the same script seen 

61under two different absolute prefixes, from being counted as two half-covered 

62files. 

63 

64**The floor.** Every tracked script must be fully covered. The climb to 100% 

65was staged through a shrink-only list of not-yet-covered scripts 

66(``[tool.bash-coverage] ratchet`` in ``pyproject.toml``); it emptied and was 

67deleted, and this script reads no exclusion list of any kind — a new script is 

68covered, not listed. ``tests/test_check_bash_coverage.py`` keeps the list from 

69coming back. 

70 

71The gate fails closed: a tracked script absent from the report entirely is an 

72error, not a pass, because that is what a silently mis-scoped bashcov run or a 

73suite that never executes its subject looks like. 

74 

75**The published report.** SimpleCov's own HTML renders the raw line hits, so 

76it shows the two corrected shapes as misses and a lower number than the gate. 

77``--report DIR`` writes a statement-level HTML report and a ``summary.json`` 

78from the corrected data instead; ``unit:bats:shell`` ships it in the 

79``bash-coverage-report`` artifact and ``pages.yml`` serves it at 

80``/bash-coverage/`` and renders the README badge from the summary, so the 

81badge, the report and the gate describe one measurement. 

82 

83Usage:: 

84 

85 python3 .github/scripts/check_bash_coverage.py coverage/ 

86 python3 .github/scripts/check_bash_coverage.py coverage/ --report coverage/report 

87 python3 .github/scripts/check_bash_coverage.py coverage/.resultset.json 

88 

89Exit codes:: 

90 

91 0 every tracked script is fully covered 

92 1 at least one tracked script has uncovered lines or is missing 

93 2 the report could not be found or parsed 

94 

95The module is importable from the test suite — ``evaluate()`` holds the whole 

96decision and takes plain data, so it can be exercised without Ruby or bats. 

97""" 

98 

99from __future__ import annotations 

100 

101import argparse 

102import json 

103import re 

104import subprocess # nosec B404 # fixed argv, no shell: `git ls-files` only 

105import sys 

106from dataclasses import dataclass, field 

107from pathlib import Path 

108 

109REPO_ROOT = Path(__file__).resolve().parent.parent.parent 

110RESULTSET_NAME = ".resultset.json" 

111 

112# A redirection and its target word: an optional descriptor, one of the 

113# operators Bash has (including here-strings and ``>|``/``>&``), then a single 

114# quoted or bare word. Deliberately not a process substitution (``< <(cmd)``): 

115# the command inside one is traced on this line, so the line is measurable. 

116_REDIRECTION = r"""\d*(?:<<<|>>|<>|>\||[<>]&?|<)\s*(?:"(?:[^"\\]|\\.)*"|'[^']*'|[^\s()<>|&;]+)""" 

117 

118# ``done``, ``fi``, ``esac`` or ``}`` followed by nothing but redirections. 

119_TERMINATOR_WITH_REDIRECTIONS = re.compile( 

120 rf"^\s*(?:done|fi|esac|\}})(?:\s+{_REDIRECTION})+\s*(?:#.*)?$" 

121) 

122 

123# A ``case`` arm whose body is empty: ``pattern) ;;``. A ``:`` or any other 

124# command in the arm is a traced statement and keeps the line measurable. 

125_EMPTY_CASE_ARM = re.compile(r"^\s*[^)#\s][^)#]*\)\s*;;\s*(?:#.*)?$") 

126 

127 

128def untraceable_lines(source: str) -> set[int]: 

129 """Return the 1-based lines of ``source`` that Bash's tracer never reports. 

130 

131 See the module docstring: compound-command terminators carrying only 

132 redirections, and empty ``case`` arms. Both are marked executable by 

133 bashcov's lexer, so without this they read as permanently uncovered. 

134 """ 

135 return { 

136 number 

137 for number, line in enumerate(source.splitlines(), start=1) 

138 if _TERMINATOR_WITH_REDIRECTIONS.match(line) or _EMPTY_CASE_ARM.match(line) 

139 } 

140 

141 

142_HEREDOC = re.compile(r"<<-?\s*(?P<quote>['\"]?)(?P<tag>\w+)(?P=quote)") 

143_LIST_OPERATOR = re.compile(r"\|\||&&|\|(?!\|)") 

144 

145 

146@dataclass(frozen=True) 

147class _Scan: 

148 """Lexer state carried from one physical line to the next. 

149 

150 ``parens`` is the stack of unclosed parentheses; ``True`` marks one that 

151 keeps a statement open across lines — ``$(``, an array's ``=(``, a process 

152 substitution's ``<(``/``>(``, an arithmetic ``((`` — while ``False`` marks a 

153 subshell ``(``, whose inner commands Bash reports on their own lines and 

154 which must therefore not be folded. 

155 """ 

156 

157 parens: tuple[bool, ...] = () 

158 quote: str | None = None # "'" or '"' while inside a quoted string 

159 heredoc: str | None = None # terminator of the here-document being read 

160 heredoc_strip: bool = False # <<- : leading tabs are stripped before comparing 

161 continued: bool = False # the line ended with an escaping backslash 

162 

163 @property 

164 def depth(self) -> int: 

165 return sum(1 for spanning in self.parens if spanning) 

166 

167 def open(self) -> bool: 

168 """Whether the statement is still open at the end of a line.""" 

169 return ( 

170 self.depth > 0 or self.quote is not None or self.heredoc is not None or self.continued 

171 ) 

172 

173 

174def _scan_line(line: str, state: _Scan) -> tuple[_Scan, bool]: 

175 """Advance ``state`` over one physical line. 

176 

177 Returns the new state and whether a list operator (``||``, ``&&``, ``|``) 

178 appeared at the top level of this line — i.e. outside quotes and outside 

179 any parenthesis — which is where Bash starts a new, separately reported 

180 command inside a backslash chain. 

181 """ 

182 parens = list(state.parens) 

183 quote, heredoc, heredoc_strip = state.quote, state.heredoc, state.heredoc_strip 

184 if heredoc is not None: 

185 candidate = line.lstrip("\t") if heredoc_strip else line 

186 if candidate == heredoc: 

187 heredoc = None 

188 return _Scan(tuple(parens), quote, heredoc, heredoc_strip, False), False 

189 

190 pending_heredoc: tuple[str, bool] | None = None 

191 list_operator = False 

192 continued = False 

193 index = 0 

194 length = len(line) 

195 while index < length: 

196 char = line[index] 

197 if quote == "'": 

198 if char == "'": 

199 quote = None 

200 index += 1 

201 continue 

202 if quote == '"': 

203 if char == "\\": 

204 if index == length - 1: 

205 continued = True # a backslash-newline inside "..." continues the string 

206 index += 2 

207 continue 

208 if char == '"': 

209 quote = None 

210 index += 1 

211 continue 

212 # Unquoted. 

213 if char == "\\": 

214 if index == length - 1: 

215 continued = True 

216 index += 2 

217 continue 

218 if char == "#" and (index == 0 or line[index - 1] in " \t;("): 

219 break # comment to end of line 

220 if char in "'\"": 

221 quote = char 

222 index += 1 

223 continue 

224 if char == "(": 

225 parens.append(index > 0 and line[index - 1] in "$=<>(") 

226 elif char == ")": 

227 if parens: 

228 parens.pop() # a `pattern)` case arm has no opener: nothing to pop 

229 elif char == "<" and pending_heredoc is None: 

230 match = _HEREDOC.match(line, index) 

231 if match: 

232 pending_heredoc = (match.group("tag"), line[index : index + 3] == "<<-") 

233 index = match.end() 

234 continue 

235 elif char in "|&" and not parens: 

236 match = _LIST_OPERATOR.match(line, index) 

237 if match: 

238 list_operator = True 

239 index = match.end() 

240 continue 

241 index += 1 

242 

243 if pending_heredoc is not None: 

244 heredoc, heredoc_strip = pending_heredoc 

245 return _Scan(tuple(parens), quote, heredoc, heredoc_strip, continued), list_operator 

246 

247 

248def statement_spans(source: str) -> list[tuple[int, int]]: 

249 """Return ``(first, last)`` line pairs for statements spanning several lines. 

250 

251 A statement continues onto the next line while a parenthesis, quote or 

252 here-document is open or the line ends with a backslash. Inside a 

253 backslash chain a line that starts a new top-level list element (``||``, 

254 ``&&``, ``|``) begins a new span, since Bash reports that command on its 

255 own line. Single-line statements are not returned. 

256 """ 

257 spans: list[tuple[int, int]] = [] 

258 state = _Scan() 

259 start: int | None = None 

260 for number, line in enumerate(source.splitlines(), start=1): 

261 was_open = state.open() 

262 chain_only = was_open and state.depth == 0 and state.quote is None and state.heredoc is None 

263 state, list_operator = _scan_line(line, state) 

264 if chain_only and list_operator and start is not None: 

265 # `cmd \` / ` arg \` / ` arg || fallback`: the fallback is its own statement. 

266 if number - 1 > start: 

267 spans.append((start, number - 1)) 

268 start = number 

269 elif not was_open: 

270 start = number 

271 if not state.open(): 

272 if start is not None and number > start: 

273 spans.append((start, number)) 

274 start = None 

275 if start is not None and state.open(): 

276 spans.append((start, len(source.splitlines()))) 

277 return spans 

278 

279 

280class ReportError(Exception): 

281 """The bashcov report is missing, unreadable or not a SimpleCov resultset.""" 

282 

283 

284@dataclass 

285class ScriptCoverage: 

286 """Merged coverage for one tracked script, across all of its copies.""" 

287 

288 path: str 

289 hits: dict[int, int] = field(default_factory=dict) 

290 sources: set[str] = field(default_factory=set) 

291 

292 @property 

293 def measured(self) -> bool: 

294 """Whether any reported path mapped onto this script. 

295 

296 False means no BATS suite executed it under a traced Bash, which is not 

297 the same as "fully covered" — the coverage is simply unknown. 

298 """ 

299 return bool(self.sources) 

300 

301 @property 

302 def total_lines(self) -> int: 

303 return len(self.hits) 

304 

305 @property 

306 def missed_lines(self) -> list[int]: 

307 return sorted(line for line, count in self.hits.items() if count == 0) 

308 

309 @property 

310 def percent(self) -> float: 

311 if not self.hits: 

312 return 100.0 

313 covered = self.total_lines - len(self.missed_lines) 

314 return 100.0 * covered / self.total_lines 

315 

316 

317@dataclass 

318class Result: 

319 """Outcome of a gate evaluation: one record per tracked script.""" 

320 

321 scripts: list[ScriptCoverage] 

322 failures: list[str] 

323 unmapped: list[str] 

324 

325 @property 

326 def ok(self) -> bool: 

327 return not self.failures 

328 

329 

330def find_report(target: Path) -> Path: 

331 """Return the SimpleCov resultset inside ``target``. 

332 

333 ``target`` may be the resultset itself or the directory bashcov wrote 

334 (``coverage/`` by default). 

335 """ 

336 if target.is_file(): 

337 return target 

338 if not target.is_dir(): 

339 raise ReportError(f"bashcov output path does not exist: {target}") 

340 direct = target / RESULTSET_NAME 

341 if direct.is_file(): 

342 return direct 

343 nested = sorted(target.glob(f"**/{RESULTSET_NAME}")) 

344 if not nested: 

345 raise ReportError(f"no {RESULTSET_NAME} found under {target}") 

346 return nested[0] 

347 

348 

349def parse_report(report: Path) -> dict[str, dict[int, int]]: 

350 """Return ``{reported_path: {line_number: hits}}`` from a SimpleCov resultset. 

351 

352 SimpleCov stores one entry per test command, each mapping an absolute file 

353 path to a list indexed by line number minus one, where ``null`` marks a 

354 line that is not executable and an integer is a hit count. Commands are 

355 merged by taking the highest count seen for each line, so a script executed 

356 by several suites is credited with all of them. 

357 """ 

358 try: 

359 raw = json.loads(report.read_text(encoding="utf-8")) 

360 except (OSError, json.JSONDecodeError) as exc: 

361 raise ReportError(f"could not parse {report}: {exc}") from exc 

362 if not isinstance(raw, dict): 

363 raise ReportError(f"{report} is not a SimpleCov resultset (expected an object)") 

364 

365 parsed: dict[str, dict[int, int]] = {} 

366 for command in raw.values(): 

367 if not isinstance(command, dict): 

368 continue 

369 coverage = command.get("coverage") 

370 if not isinstance(coverage, dict): 

371 continue 

372 for filename, entry in coverage.items(): 

373 if not filename.endswith(".sh"): 

374 continue 

375 # SimpleCov 1.x nests the array under "lines"; older payloads store 

376 # the bare array. Accept both so a gem bump cannot silently zero 

377 # the gate. 

378 lines = entry.get("lines") if isinstance(entry, dict) else entry 

379 if not isinstance(lines, list): 

380 continue 

381 merged = parsed.setdefault(filename, {}) 

382 for index, hits in enumerate(lines): 

383 if not isinstance(hits, int): 

384 continue # null => line is not executable 

385 line_number = index + 1 

386 merged[line_number] = max(merged.get(line_number, 0), hits) 

387 return parsed 

388 

389 

390def map_to_tracked(reported_path: str, inventory: list[str]) -> str | None: 

391 """Map a path from the report onto the tracked script it is a copy of. 

392 

393 Prefers the longest matching path suffix (``/tmp/x/y/demo/lib_demo.sh`` 

394 maps to ``demo/lib_demo.sh``). Falls back to matching on basename alone, 

395 which is safe only while script basenames are unique — a property 

396 ``tests/test_check_bash_coverage.py`` asserts against the real inventory. 

397 Returns ``None`` when nothing matches. 

398 """ 

399 normalised = reported_path.replace("\\", "/") 

400 suffix_matches = [ 

401 tracked 

402 for tracked in inventory 

403 if normalised == tracked or normalised.endswith("/" + tracked) 

404 ] 

405 if suffix_matches: 

406 return max(suffix_matches, key=lambda tracked: (tracked.count("/"), len(tracked))) 

407 

408 basename = normalised.rsplit("/", 1)[-1] 

409 basename_matches = [tracked for tracked in inventory if tracked.rsplit("/", 1)[-1] == basename] 

410 if len(basename_matches) == 1: 

411 return basename_matches[0] 

412 return None 

413 

414 

415def fold_spans(hits: dict[int, int], spans: list[tuple[int, int]]) -> dict[int, int]: 

416 """Collapse each multi-line statement onto its first line. 

417 

418 Every line of a span that the report lists is replaced by the span's first 

419 line carrying the highest count seen anywhere in the span, so a statement 

420 Bash reported on its second or last line counts once, as covered. A span 

421 none of whose lines the report mentions stays absent (bashcov judged it 

422 non-executable, e.g. a multi-line comment block would never be a span). 

423 """ 

424 folded = dict(hits) 

425 for first, last in spans: 

426 members = [line for line in range(first, last + 1) if line in folded] 

427 if not members: 

428 continue 

429 best = max(folded[line] for line in members) 

430 for line in members: 

431 del folded[line] 

432 folded[first] = best 

433 return folded 

434 

435 

436def evaluate( 

437 reported: dict[str, dict[int, int]], 

438 inventory: list[str], 

439 untraceable: dict[str, set[int]] | None = None, 

440 spans: dict[str, list[tuple[int, int]]] | None = None, 

441) -> Result: 

442 """Merge reported coverage onto the inventory and apply the floor to all of it. 

443 

444 ``untraceable`` maps a tracked script to the line numbers that 

445 :func:`untraceable_lines` found in it; those lines are dropped from the 

446 script's count whatever the report says about them. ``spans`` maps a 

447 tracked script to its :func:`statement_spans`, each folded onto its first 

448 line by :func:`fold_spans`. 

449 """ 

450 merged: dict[str, ScriptCoverage] = {path: ScriptCoverage(path=path) for path in inventory} 

451 unmapped: list[str] = [] 

452 untraceable = untraceable or {} 

453 spans = spans or {} 

454 

455 for reported_path, lines in sorted(reported.items()): 

456 tracked = map_to_tracked(reported_path, inventory) 

457 if tracked is None: 

458 unmapped.append(reported_path) 

459 continue 

460 record = merged[tracked] 

461 record.sources.add(reported_path) 

462 skipped = untraceable.get(tracked, set()) 

463 for line_number, hits in lines.items(): 

464 if line_number in skipped: 

465 continue 

466 record.hits[line_number] = max(record.hits.get(line_number, 0), hits) 

467 

468 for path, record in merged.items(): 

469 if record.hits and path in spans: 

470 record.hits = fold_spans(record.hits, spans[path]) 

471 

472 scripts = [record for _, record in sorted(merged.items())] 

473 

474 failures: list[str] = [] 

475 for record in scripts: 

476 if not record.measured: 

477 failures.append( 

478 f"{record.path}: absent from the bashcov report — no BATS suite executed it, " 

479 "so its coverage is unknown" 

480 ) 

481 continue 

482 missed = record.missed_lines 

483 if missed: 

484 shown = ", ".join(str(line) for line in missed[:20]) 

485 more = "" if len(missed) <= 20 else f" (+{len(missed) - 20} more)" 

486 failures.append( 

487 f"{record.path}: {len(missed)}/{record.total_lines} lines uncovered " 

488 f"({record.percent:.2f}%): {shown}{more}" 

489 ) 

490 return Result(scripts=scripts, failures=failures, unmapped=unmapped) 

491 

492 

493def tracked_shell_scripts(root: Path) -> list[str]: 

494 """Return every tracked ``*.sh`` path, excluding the test-suite's own. 

495 

496 Uses ``git ls-files`` so generated and untracked scripts never enter the 

497 gate, matching how ``lint:shellcheck:shell`` builds its inventory. 

498 """ 

499 try: 

500 completed = subprocess.run( # nosec B603 # fixed argv, no shell 

501 ["git", "-C", str(root), "ls-files", "*.sh"], 

502 capture_output=True, 

503 text=True, 

504 check=True, 

505 ) 

506 except (OSError, subprocess.CalledProcessError) as exc: 

507 raise ReportError(f"could not list tracked shell scripts: {exc}") from exc 

508 paths = [line.strip() for line in completed.stdout.splitlines() if line.strip()] 

509 return sorted(path for path in paths if not path.startswith("tests/")) 

510 

511 

512def classify_scripts( 

513 root: Path, inventory: list[str] 

514) -> tuple[dict[str, set[int]], dict[str, list[tuple[int, int]]]]: 

515 """Run :func:`untraceable_lines` and :func:`statement_spans` over the inventory. 

516 

517 Returns the two per-script maps :func:`evaluate` takes, each holding only 

518 the scripts that have something to report. 

519 """ 

520 untraceable: dict[str, set[int]] = {} 

521 spans: dict[str, list[tuple[int, int]]] = {} 

522 for path in inventory: 

523 try: 

524 source = (root / path).read_text(encoding="utf-8") 

525 except OSError as exc: 

526 raise ReportError(f"could not read tracked script {path}: {exc}") from exc 

527 lines = untraceable_lines(source) 

528 if lines: 

529 untraceable[path] = lines 

530 found = statement_spans(source) 

531 if found: 

532 spans[path] = found 

533 return untraceable, spans 

534 

535 

536def format_report(result: Result) -> str: 

537 """Render the human-facing summary printed by ``main``.""" 

538 lines: list[str] = [] 

539 failing = [record for record in result.scripts if record.missed_lines or not record.measured] 

540 covered = len(result.scripts) - len(failing) 

541 lines.append(f"bash coverage: {covered}/{len(result.scripts)} tracked scripts at 100%") 

542 if result.unmapped: 

543 lines.append( 

544 f"note: {len(result.unmapped)} reported path(s) matched no tracked script " 

545 "and were ignored:" 

546 ) 

547 lines.extend(f" {path}" for path in result.unmapped[:10]) 

548 if result.failures: 

549 lines.append("") 

550 lines.append("ERROR: shell scripts are not fully covered:") 

551 lines.extend(f" {failure}" for failure in result.failures) 

552 lines.append("") 

553 lines.append( 

554 "Add BATS coverage for the lines above. Every tracked *.sh file is held " 

555 "to 100%: a new script ships with a suite that executes it, and a script " 

556 "absent from the report needs its suite to run the tracked file in place " 

557 "rather than a copy." 

558 ) 

559 return "\n".join(lines) 

560 

561 

562# -------------------------------------------------------------------------- 

563# The published report 

564# 

565# SimpleCov's own HTML report renders the raw line hits, so it shows the 

566# untraceable lines and the later lines of multi-line statements as misses 

567# and reports a lower number than the gate does. The report published to 

568# GitHub Pages (by pages.yml, from the artifact) is rendered here instead, 

569# from the same corrected data the verdict comes from, so the badge, the 

570# report and the gate cannot tell three different stories. 

571# -------------------------------------------------------------------------- 

572 

573SUMMARY_NAME = "summary.json" 

574 

575_REPORT_CSS = """ 

576body { font: 14px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 2em auto; max-width: 72em; padding: 0 1em; color: #222; } 

577h1 { font-size: 1.5em; } h1 code { font-size: 0.9em; } 

578table { border-collapse: collapse; width: 100%; } 

579th, td { text-align: left; padding: 0.25em 0.6em; border-bottom: 1px solid #ddd; } 

580th { background: #f3f3f3; } td.num { text-align: right; font-variant-numeric: tabular-nums; } 

581.ok { color: #1a7f37; } .bad { color: #b42318; } .muted { color: #666; } 

582table.source { font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; } 

583table.source td { border: 0; padding: 0 0.6em; white-space: pre; } 

584table.source td.n { text-align: right; color: #888; user-select: none; width: 3em; } 

585table.source td.c { text-align: right; color: #666; width: 3em; } 

586tr.covered td.s { background: #dafbe1; } tr.missed td.s { background: #ffebe9; } 

587tr.continued td.n::after { content: " \\2026"; } 

588tr.untraceable td.s { background: #f3f3f3; color: #666; } 

589.legend span { display: inline-block; padding: 0 0.5em; margin-right: 0.6em; } 

590.legend .covered { background: #dafbe1; } .legend .missed { background: #ffebe9; } .legend .untraceable { background: #f3f3f3; } 

591""" 

592 

593 

594def _escape(text: str) -> str: 

595 return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;") 

596 

597 

598def _page_name(path: str) -> str: 

599 """The file page for a script: the path with ``/`` doubled into ``__``. 

600 

601 A leading dot is dropped so ``.github/scripts/x.sh`` does not become a 

602 hidden file on the site. Basenames are unique across the inventory (a test 

603 asserts it), so no two scripts share a page. 

604 """ 

605 return path.replace("/", "__").lstrip(".") + ".html" 

606 

607 

608def line_states( 

609 source: str, 

610 hits: dict[int, int], 

611 untraceable: set[int], 

612 spans: list[tuple[int, int]], 

613) -> list[tuple[str, int | None, str]]: 

614 """Classify every physical line of a script for its file page. 

615 

616 Returns one ``(kind, count, text)`` per line. ``kind`` is ``covered`` or 

617 ``missed`` for a measured statement, the same with ``continued`` added for 

618 the later physical lines of a multi-line statement (they take their 

619 statement's fate — ``hits`` is the folded map, so only the first line 

620 carries the count), ``untraceable`` for a line Bash never traces, and 

621 ``none`` for a line that is not executable. 

622 """ 

623 continued: dict[int, int] = {} 

624 for first, last in spans: 

625 if first in hits: # folded onto its first line, so the statement is measured 

626 for line in range(first + 1, last + 1): 

627 continued[line] = first 

628 states: list[tuple[str, int | None, str]] = [] 

629 for number, text in enumerate(source.splitlines(), start=1): 

630 if number in untraceable: 

631 states.append(("untraceable", None, text)) 

632 elif number in hits: 

633 states.append(("covered" if hits[number] else "missed", hits[number], text)) 

634 elif number in continued: 

635 count = hits[continued[number]] 

636 states.append((("covered" if count else "missed") + " continued", None, text)) 

637 else: 

638 states.append(("none", None, text)) 

639 return states 

640 

641 

642def _percent(covered: int, total: int) -> float: 

643 return round(100.0 * covered / total, 2) if total else 0.0 

644 

645 

646def summarize(result: Result) -> dict[str, object]: 

647 """The machine-readable summary the badge is rendered from.""" 

648 files = [] 

649 statements = covered = 0 

650 for record in result.scripts: 

651 missed = record.missed_lines 

652 statements += record.total_lines 

653 covered += record.total_lines - len(missed) 

654 files.append( 

655 { 

656 "path": record.path, 

657 "measured": record.measured, 

658 "statements": record.total_lines, 

659 "covered": record.total_lines - len(missed), 

660 "missed": missed, 

661 "percent": _percent(record.total_lines - len(missed), record.total_lines), 

662 } 

663 ) 

664 at_floor = [record for record in result.scripts if record.measured and not record.missed_lines] 

665 return { 

666 "ok": result.ok, 

667 "scripts": len(result.scripts), 

668 "scripts_at_100": len(at_floor), 

669 "unmeasured": [record.path for record in result.scripts if not record.measured], 

670 "statements": statements, 

671 "covered": covered, 

672 "missed": statements - covered, 

673 "percent": _percent(covered, statements), 

674 "files": files, 

675 } 

676 

677 

678def _file_page(record: ScriptCoverage, states: list[tuple[str, int | None, str]]) -> str: 

679 stats: str 

680 if not record.measured: 

681 stats = '<p class="bad">Not executed by any suite, so its coverage is unknown.</p>' 

682 else: 

683 missed = len(record.missed_lines) 

684 klass = "ok" if not missed else "bad" 

685 stats = ( 

686 f'<p class="{klass}">{record.total_lines - missed} of {record.total_lines} ' 

687 f"statements covered ({_percent(record.total_lines - missed, record.total_lines):.2f}%)" 

688 f"{'' if not missed else f', {missed} missed'}.</p>" 

689 ) 

690 rows = [] 

691 for number, (kind, count, text) in enumerate(states, start=1): 

692 shown = "" if count is None else str(count) 

693 rows.append( 

694 f'<tr class="{kind}"><td class="n">{number}</td><td class="c">{shown}</td>' 

695 f'<td class="s">{_escape(text) or " "}</td></tr>' 

696 ) 

697 legend = ( 

698 '<p class="legend"><span class="covered">covered</span>' 

699 '<span class="missed">missed</span>' 

700 '<span class="untraceable">never traced by Bash (not counted)</span>' 

701 "A line ending in \u2026 continues the statement above it and shares its fate.</p>" 

702 ) 

703 return ( 

704 '<!DOCTYPE html>\n<html lang="en"><head><meta charset="utf-8">' 

705 f"<title>{_escape(record.path)} \u2014 shell coverage</title>" 

706 f"<style>{_REPORT_CSS}</style></head><body>\n" 

707 f'<p><a href="../index.html">\u2190 all scripts</a></p>\n' 

708 f"<h1><code>{_escape(record.path)}</code></h1>\n{stats}\n{legend}\n" 

709 '<table class="source">\n' + "\n".join(rows) + "\n</table>\n</body></html>\n" 

710 ) 

711 

712 

713def _index_page(summary: dict[str, object]) -> str: 

714 files = summary["files"] 

715 assert isinstance(files, list) 

716 rows = [] 

717 for entry in files: 

718 link = f'<a href="files/{_page_name(entry["path"])}">{_escape(entry["path"])}</a>' 

719 if not entry["measured"]: 

720 rows.append( 

721 f"<tr><td>{link}</td>" 

722 '<td class="bad" colspan="4">not executed by any suite</td></tr>' 

723 ) 

724 continue 

725 klass = "ok" if not entry["missed"] else "bad" 

726 rows.append( 

727 f"<tr><td>{link}</td>" 

728 f'<td class="num">{entry["statements"]}</td>' 

729 f'<td class="num">{entry["covered"]}</td>' 

730 f'<td class="num">{len(entry["missed"])}</td>' 

731 f'<td class="num {klass}">{entry["percent"]:.2f}%</td></tr>' 

732 ) 

733 klass = "ok" if summary["ok"] else "bad" 

734 verdict = ( 

735 f'<p class="{klass}"><b>{summary["scripts_at_100"]}/{summary["scripts"]} tracked scripts ' 

736 f"at 100%</b> \u2014 {summary['covered']} of {summary['statements']} statements covered " 

737 f"({summary['percent']:.2f}%).</p>" 

738 ) 

739 return ( 

740 '<!DOCTYPE html>\n<html lang="en"><head><meta charset="utf-8">' 

741 "<title>Shell coverage</title>" 

742 f"<style>{_REPORT_CSS}</style></head><body>\n" 

743 "<h1>Shell coverage</h1>\n" 

744 f"{verdict}\n" 

745 '<p class="muted">Every tracked shell script, measured in statements by the ' 

746 "<code>unit:bats:shell</code> job: BATS runs each script under bashcov, and " 

747 "<code>check_bash_coverage.py</code> corrects the raw line hits for the lines Bash " 

748 "never traces and for statements that span several lines. This page is that " 

749 "corrected view, and the same numbers are what the gate enforces.</p>\n" 

750 "<table><thead><tr><th>Script</th><th>Statements</th><th>Covered</th>" 

751 "<th>Missed</th><th>Coverage</th></tr></thead>\n<tbody>\n" 

752 + "\n".join(rows) 

753 + "\n</tbody></table>\n</body></html>\n" 

754 ) 

755 

756 

757def write_report( 

758 result: Result, 

759 root: Path, 

760 untraceable: dict[str, set[int]], 

761 spans: dict[str, list[tuple[int, int]]], 

762 out_dir: Path, 

763) -> dict[str, object]: 

764 """Write ``index.html``, one page per script and ``summary.json`` to ``out_dir``. 

765 

766 Rendered from the evaluated result, so the numbers agree with the verdict 

767 ``main`` prints. Returns the summary that was written. 

768 """ 

769 summary = summarize(result) 

770 files_dir = out_dir / "files" 

771 files_dir.mkdir(parents=True, exist_ok=True) 

772 for record in result.scripts: 

773 source = (root / record.path).read_text(encoding="utf-8") 

774 states = line_states( 

775 source, record.hits, untraceable.get(record.path, set()), spans.get(record.path, []) 

776 ) 

777 (files_dir / _page_name(record.path)).write_text( 

778 _file_page(record, states), encoding="utf-8" 

779 ) 

780 (out_dir / "index.html").write_text(_index_page(summary), encoding="utf-8") 

781 (out_dir / SUMMARY_NAME).write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8") 

782 return summary 

783 

784 

785def main(argv: list[str] | None = None) -> int: 

786 parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0]) 

787 parser.add_argument( 

788 "bashcov_output", 

789 type=Path, 

790 help="bashcov output directory (coverage/), or a .resultset.json directly.", 

791 ) 

792 parser.add_argument( 

793 "--root", 

794 type=Path, 

795 default=REPO_ROOT, 

796 help="Repository root used to build the tracked-script inventory.", 

797 ) 

798 parser.add_argument( 

799 "--report", 

800 type=Path, 

801 default=None, 

802 metavar="DIR", 

803 help=( 

804 "Also write the statement-level HTML report (index.html, files/*.html) and " 

805 f"{SUMMARY_NAME} to DIR — the view pages.yml publishes. Written whatever the " 

806 "verdict, so a failing run can be inspected." 

807 ), 

808 ) 

809 args = parser.parse_args(argv) 

810 

811 try: 

812 report = find_report(args.bashcov_output) 

813 reported = parse_report(report) 

814 inventory = tracked_shell_scripts(args.root) 

815 untraceable, spans = classify_scripts(args.root, inventory) 

816 except ReportError as exc: 

817 print(f"ERROR: {exc}", file=sys.stderr) 

818 return 2 

819 

820 result = evaluate(reported, inventory, untraceable, spans) 

821 if args.report is not None: 

822 write_report(result, args.root, untraceable, spans, args.report) 

823 print(format_report(result)) 

824 return 0 if result.ok else 1 

825 

826 

827if __name__ == "__main__": 

828 sys.exit(main())