Coverage for .github / scripts / disambiguate_lcov_functions.py: 100.00%
81 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
1#!/usr/bin/env python3
2"""Give same-named functions in an lcov tracefile distinct names before genhtml.
4Node's test runner writes one ``FN:<line>,<name>`` and one ``FNDA:<count>,<name>``
5record per function, named as V8 names it. Two closures in one file can share
6a name (``index.mjs`` has an ``onAbort`` in ``openUpstream`` and another in
7``sleep``). lcov 2.4's ``genhtml`` (the Ubuntu 26.04 package) treats a repeated
8name inside one ``SF:`` block as an inconsistent tracefile and stops::
10 genhtml: ERROR: (inconsistent) "lcov.info":72: duplicate function 'onAbort'
11 starts on line "index.mjs":974 but previous definition started on 897
13``--ignore-errors inconsistent`` would merge the two functions' hit counts
14under one name and hide a real inconsistency the next time one appears.
15Renaming one closure in the source would recur with the next same-named
16closure. This script instead renames only the repeated functions, to
17``<name>@L<line>``, in both records, so the report shows each function at its
18own line with its own count and every other name stays as V8 wrote it.
20The k-th ``FNDA`` of a name belongs to the k-th ``FN`` of that name: Node
21writes both lists in the same order. The script fails closed when a record's
22``FN`` and ``FNDA`` names do not agree in multiplicity, when a line is not a
23tracefile record, or when ``FNF`` disagrees with the functions listed, rather
24than guess.
26Usage::
28 python3 .github/scripts/disambiguate_lcov_functions.py lcov.info [--output OTHER]
29"""
31from __future__ import annotations
33import argparse
34import re
35import sys
36from collections import Counter
37from collections.abc import Sequence
38from dataclasses import dataclass, field
39from pathlib import Path
41#: ``FN:<start>[,<end>],<name>`` (lcov 2 may write an end line; Node does not).
42FN_RE = re.compile(r"^FN:(\d+)(?:,(\d+))?,(.+)$")
43#: ``FNDA:<count>,<name>``
44FNDA_RE = re.compile(r"^FNDA:(\d+),(.+)$")
45#: Every other record kind a tracefile may carry; anything else is not lcov.
46RECORD_RE = re.compile(r"^(?:TN:|SF:|FNF:|FNH:|DA:|LF:|LH:|BRDA:|BRF:|BRH:|VER:|end_of_record$)")
49class TracefileError(ValueError):
50 """The tracefile is not something this script can rename safely."""
53@dataclass
54class Record:
55 """One ``SF:`` block's function records, as indices into the tracefile's lines."""
57 source: str
58 fn: list[tuple[int, str, str]] = field(default_factory=list) # (index, line, name)
59 fnda: list[tuple[int, str]] = field(default_factory=list) # (index, name)
60 fnf: int | None = None
63def parse_records(lines: Sequence[str]) -> list[Record]:
64 """Split the tracefile into ``SF:`` blocks, refusing any line that is not a record."""
65 records: list[Record] = []
66 current: Record | None = None
67 for index, line in enumerate(lines):
68 if not line.strip() or line.startswith("TN:"):
69 continue
70 if line.startswith("SF:"):
71 current = Record(source=line.removeprefix("SF:"))
72 records.append(current)
73 continue
74 if current is None:
75 raise TracefileError(f"line {index + 1}: {line!r} appears before any SF: record")
76 if fn := FN_RE.match(line):
77 current.fn.append((index, fn.group(1), fn.group(3)))
78 elif fnda := FNDA_RE.match(line):
79 current.fnda.append((index, fnda.group(2)))
80 elif line.startswith("FNF:"):
81 current.fnf = int(line.removeprefix("FNF:"))
82 elif not RECORD_RE.match(line):
83 raise TracefileError(f"line {index + 1}: {line!r} is not an lcov record")
84 if line == "end_of_record":
85 current = None
86 return records
89def _check(record: Record) -> set[str]:
90 """Validate one record's function lists; return the names that repeat."""
91 fn_names = Counter(name for _, _, name in record.fn)
92 fnda_names = Counter(name for _, name in record.fnda)
93 if fn_names != fnda_names:
94 disagreements = ", ".join(
95 f"{name!r} FN x{fn_names[name]} FNDA x{fnda_names[name]}"
96 for name in sorted(set(fn_names) | set(fnda_names))
97 if fn_names[name] != fnda_names[name]
98 )
99 raise TracefileError(f"{record.source}: FN and FNDA records disagree: {disagreements}")
100 if record.fnf is not None and record.fnf != len(record.fn):
101 raise TracefileError(f"{record.source}: FNF:{record.fnf} but {len(record.fn)} FN records")
102 return {name for name, count in fn_names.items() if count > 1}
105def disambiguate(text: str) -> tuple[str, list[str]]:
106 """Return the tracefile with repeated function names made distinct, and the renames."""
107 lines = text.splitlines()
108 renames: list[str] = []
109 for record in parse_records(lines):
110 repeated = _check(record)
111 new_names: dict[str, list[str]] = {name: [] for name in repeated}
112 for index, line, name in record.fn:
113 if name in repeated:
114 new_name = f"{name}@L{line}"
115 new_names[name].append(new_name)
116 lines[index] = lines[index].removesuffix(name) + new_name
117 renames.append(f"{record.source}: {name} at line {line} -> {new_name}")
118 for index, name in record.fnda:
119 if name in repeated:
120 lines[index] = lines[index].removesuffix(name) + new_names[name].pop(0)
121 return "\n".join(lines) + ("\n" if text.endswith("\n") else ""), renames
124def main(argv: Sequence[str] | None = None) -> int:
125 parser = argparse.ArgumentParser(
126 description="Rename repeated function names in an lcov tracefile to <name>@L<line>."
127 )
128 parser.add_argument("tracefile", type=Path, help="lcov tracefile to read")
129 parser.add_argument(
130 "--output",
131 type=Path,
132 help="where to write the result (default: rewrite the tracefile in place)",
133 )
134 args = parser.parse_args(argv)
135 try:
136 text, renames = disambiguate(args.tracefile.read_text(encoding="utf-8"))
137 except (OSError, TracefileError) as exc:
138 print(f"::error::{args.tracefile}: {exc}", flush=True)
139 return 1
140 (args.output or args.tracefile).write_text(text, encoding="utf-8")
141 for rename in renames:
142 print(rename)
143 print(f"{args.tracefile}: {len(renames)} function name(s) made distinct", flush=True)
144 return 0
147if __name__ == "__main__":
148 sys.exit(main())