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

1#!/usr/bin/env python3 

2"""Give same-named functions in an lcov tracefile distinct names before genhtml. 

3 

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:: 

9 

10 genhtml: ERROR: (inconsistent) "lcov.info":72: duplicate function 'onAbort' 

11 starts on line "index.mjs":974 but previous definition started on 897 

12 

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. 

19 

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. 

25 

26Usage:: 

27 

28 python3 .github/scripts/disambiguate_lcov_functions.py lcov.info [--output OTHER] 

29""" 

30 

31from __future__ import annotations 

32 

33import argparse 

34import re 

35import sys 

36from collections import Counter 

37from collections.abc import Sequence 

38from dataclasses import dataclass, field 

39from pathlib import Path 

40 

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$)") 

47 

48 

49class TracefileError(ValueError): 

50 """The tracefile is not something this script can rename safely.""" 

51 

52 

53@dataclass 

54class Record: 

55 """One ``SF:`` block's function records, as indices into the tracefile's lines.""" 

56 

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 

61 

62 

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 

87 

88 

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} 

103 

104 

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 

122 

123 

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 

145 

146 

147if __name__ == "__main__": 

148 sys.exit(main())