1
+ #!/usr/bin/env python3
2
+ """
3
+ full_coverage.py – Generates a single **lcov.info** for a multi-package Flutter
4
+ repository and uploads it to SonarQube.
5
+
6
+ Workflow
7
+ ========
8
+ 1. Read **sonar-project.properties** → use *exactly* the folders listed in
9
+ `sonar.sources`.
10
+ 2. Warn if there are libraries under `modules/*/lib` that are **not** declared
11
+ in `sonar.sources` (they would be ignored by SonarQube otherwise).
12
+ 3. Run tests *per module* (if a `test/` folder exists) and generate one LCOV
13
+ report per module.
14
+ 4. Normalise every `SF:` line so paths start with `app/lib/…` or
15
+ `modules/<module>/lib/…` — this guarantees SonarQube can resolve them.
16
+ 5. Merge all module reports and add **0 % coverage blocks** for every Dart file
17
+ that still has no tests.
18
+ 6. Validate paths before launching **sonar-scanner**.
19
+
20
+ Usage
21
+ -----
22
+ Interactive:
23
+ python3 coverage/full_coverage.py
24
+
25
+ CI (no Y/N prompt):
26
+ python3 coverage/full_coverage.py --ci
27
+
28
+ Dry-run (show commands, don’t execute):
29
+ python3 coverage/full_coverage.py --dry-run
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import argparse
34
+ import configparser
35
+ import fnmatch
36
+ import getpass
37
+ import os
38
+ import re
39
+ import shutil
40
+ import subprocess
41
+ from pathlib import Path
42
+ from typing import Dict , List , Set
43
+
44
+ # Basic paths
45
+ PROJECT_ROOT = Path .cwd ()
46
+ COVERAGE_DIR = PROJECT_ROOT / "coverage"
47
+ LCOV_MERGED_FILE = COVERAGE_DIR / "lcov.merged.info"
48
+ LCOV_FULL_FILE = COVERAGE_DIR / "lcov.info"
49
+
50
+ # 1 · Read `sonar.sources` → build MODULE_PATHS
51
+ def load_sonar_sources (props : Path = PROJECT_ROOT / "sonar-project.properties" ) -> List [str ]:
52
+ """Return the comma/semicolon-separated folders configured in sonar.sources."""
53
+ if not props .exists ():
54
+ return []
55
+ # ConfigParser needs a header, so prepend a dummy section
56
+ text = "[dummy]\n " + props .read_text (encoding = "utf-8" )
57
+ cfg = configparser .ConfigParser ()
58
+ cfg .read_string (text )
59
+ raw = cfg .get ("dummy" , "sonar.sources" , fallback = "" )
60
+ return [p .strip () for p in re .split (r"[;,]" , raw ) if p .strip ()]
61
+
62
+ SONAR_SOURCES : List [str ] = load_sonar_sources ()
63
+
64
+ # Map friendly module name → lib path
65
+ MODULE_PATHS : Dict [str , Path ] = {}
66
+ for src in SONAR_SOURCES :
67
+ parts = src .split ("/" )
68
+ if parts [0 ] == "app" :
69
+ MODULE_PATHS ["app" ] = PROJECT_ROOT / src
70
+ elif parts [0 ] == "modules" and len (parts ) >= 3 :
71
+ MODULE_PATHS [parts [1 ]] = PROJECT_ROOT / src
72
+
73
+ # 1.1 · Warn if there are libs not declared in sonar.sources
74
+ def warn_untracked_libs () -> None :
75
+ detected : Set [str ] = set ()
76
+ modules_dir = PROJECT_ROOT / "modules"
77
+ if not modules_dir .exists ():
78
+ return
79
+
80
+ for pkg in modules_dir .iterdir ():
81
+ if not pkg .is_dir ():
82
+ continue
83
+ if (pkg / "lib" ).exists ():
84
+ detected .add (f"modules/{ pkg .name } /lib" )
85
+
86
+ missing = detected - set (SONAR_SOURCES )
87
+ if missing :
88
+ print ("\n ⚠️ Libraries found in the repo but **not** listed in sonar.sources:" )
89
+ for m in sorted (missing ):
90
+ print (f" • { m } " )
91
+ print (" ➜ Add them to sonar.sources if you want them analysed and covered,\n "
92
+ " otherwise they will be ignored by SonarQube.\n " )
93
+
94
+ warn_untracked_libs ()
95
+
96
+ # 2 · Ignore patterns and helper functions
97
+ IGNORE_PATTERNS = [
98
+ "**/*.g.dart" , "**/*.freezed.dart" , "**/*.mocks.dart" , "**/*.gr.dart" ,
99
+ "**/*.gql.dart" , "**/*.graphql.dart" , "**/*.graphql.schema.*" ,
100
+ "**/*.arb" , "messages_*.dart" , "lib/presenter/**" , "**/generated/**" ,
101
+ ]
102
+ IGNORED_CLASS_TYPES = ["abstract class" , "mixin" , "enum" ]
103
+
104
+ def run (cmd : List [str ], * , cwd : Path | None = None , dry : bool = False ) -> None :
105
+ """subprocess.run with an optional DRY-RUN mode."""
106
+ if dry :
107
+ print ("DRY $" , " " .join (cmd ))
108
+ return
109
+ subprocess .run (cmd , cwd = cwd , check = True )
110
+
111
+ # 3 · Test + coverage per module
112
+ def run_coverage_for_module (name : str , lib_path : Path , * , dry : bool = False ) -> None :
113
+ print (f"\n 📦 Running coverage for module: { name } " )
114
+ module_dir , test_dir = lib_path .parent , lib_path .parent / "test"
115
+
116
+ if not test_dir .exists ():
117
+ print (f"⚠️ '{ name } ' has no test directory → marked as 0 %" )
118
+ return
119
+
120
+ run (["flutter" , "test" , "--coverage" ], cwd = module_dir , dry = dry )
121
+
122
+ src = module_dir / "coverage/lcov.info"
123
+ dst = COVERAGE_DIR / f"lcov_{ name } .info"
124
+ if src .exists () and not dry :
125
+ shutil .move (src , dst )
126
+ print (f"✅ Coverage for { name } → { dst .relative_to (PROJECT_ROOT )} " )
127
+
128
+ # 4 · Merge and normalise paths
129
+ def norm_path (module : str , original : str ) -> str :
130
+ """Convert `lib/foo.dart` → `app/lib/foo.dart` or `modules/<module>/lib/foo.dart`."""
131
+ return f"app/{ original } " if module == "app" else f"modules/{ module } /{ original } "
132
+
133
+ def merge_lcov_files (* , dry : bool = False ) -> None :
134
+ print ("\n 🔗 Merging module reports… (normalising SF: paths)" )
135
+ COVERAGE_DIR .mkdir (exist_ok = True )
136
+ if dry :
137
+ print ("DRY would merge individual LCOV files here" )
138
+ return
139
+
140
+ with LCOV_MERGED_FILE .open ("w" , encoding = "utf-8" ) as merged :
141
+ for module in MODULE_PATHS :
142
+ file = COVERAGE_DIR / f"lcov_{ module } .info"
143
+ if not file .exists ():
144
+ continue
145
+ for line in file .read_text (encoding = "utf-8" ).splitlines ():
146
+ if line .startswith ("SF:" ):
147
+ p = line [3 :].strip ()
148
+ if p .startswith ("lib/" ):
149
+ p = norm_path (module , p )
150
+ merged .write (f"SF:{ p } \n " )
151
+ else :
152
+ merged .write (line + "\n " )
153
+ print (f"✅ Merged → { LCOV_MERGED_FILE .relative_to (PROJECT_ROOT )} " )
154
+
155
+ # 5 · Add 0 % blocks for uncovered files
156
+ def ignore_file (path : Path ) -> bool :
157
+ rel = path .relative_to (PROJECT_ROOT ).as_posix ()
158
+ return any (fnmatch .fnmatch (rel , pat ) for pat in IGNORE_PATTERNS )
159
+
160
+ def ignore_entire_file (lines : List [str ]) -> bool :
161
+ if any ("// coverage:ignore-file" in l for l in lines ):
162
+ return True
163
+ return any (l .startswith (t ) for t in IGNORED_CLASS_TYPES for l in lines )
164
+
165
+ def is_executable (line : str ) -> bool :
166
+ line = line .strip ()
167
+ if not line or line .startswith (("//" , "/*" , "*" , "@" , "import" , "export" , "part " )):
168
+ return False
169
+ if "override" in line :
170
+ return False
171
+ return True # simplified: good enough for 0-coverage entries
172
+
173
+ def existing_covered () -> Set [Path ]:
174
+ covered : Set [Path ] = set ()
175
+ if LCOV_MERGED_FILE .exists ():
176
+ for l in LCOV_MERGED_FILE .read_text (encoding = "utf-8" ).splitlines ():
177
+ if l .startswith ("SF:" ):
178
+ covered .add ((PROJECT_ROOT / l [3 :].strip ()).resolve ())
179
+ return covered
180
+
181
+ def write_full_coverage () -> None :
182
+ print ("\n 🧠 Writing final lcov.info (filling 0 % files)…" )
183
+ covered = existing_covered ()
184
+ all_files : Set [Path ] = set ()
185
+ for src in MODULE_PATHS .values ():
186
+ all_files .update ({f .resolve () for f in src .rglob ("*.dart" ) if not ignore_file (f )})
187
+
188
+ with LCOV_FULL_FILE .open ("w" , encoding = "utf-8" ) as out :
189
+ if LCOV_MERGED_FILE .exists ():
190
+ out .write (LCOV_MERGED_FILE .read_text (encoding = "utf-8" ))
191
+
192
+ for f in sorted (all_files - covered ):
193
+ lines = f .read_text (encoding = "utf-8" ).splitlines ()
194
+ if ignore_entire_file (lines ):
195
+ continue
196
+ rel = f .relative_to (PROJECT_ROOT ).as_posix ()
197
+ da = [f"DA:{ i } ,0" for i , l in enumerate (lines , 1 ) if is_executable (l )]
198
+ if da :
199
+ entry = ["SF:" + rel , * da , f"LF:{ len (da )} " , "LH:0" , "end_of_record" ]
200
+ out .write ("\n " .join (entry ) + "\n " )
201
+ print (f"✅ Final lcov.info → { LCOV_FULL_FILE .relative_to (PROJECT_ROOT )} " )
202
+
203
+ # 6 · Coverage summary
204
+ def coverage_summary () -> None :
205
+ total = hits = 0
206
+ for line in LCOV_FULL_FILE .read_text (encoding = "utf-8" ).splitlines ():
207
+ if line .startswith ("LF:" ):
208
+ total += int (line .split (":" )[1 ])
209
+ elif line .startswith ("LH:" ):
210
+ hits += int (line .split (":" )[1 ])
211
+ pct = 0 if total == 0 else hits / total * 100
212
+ print (f"\n 📊 Global coverage: { hits } /{ total } lines ({ pct :.2f} %)" )
213
+
214
+ # 7 · Validate paths before running sonar-scanner
215
+ def lcov_paths_valid () -> bool :
216
+ for line in LCOV_FULL_FILE .read_text (encoding = "utf-8" ).splitlines ():
217
+ if line .startswith ("SF:" ):
218
+ p = line [3 :].strip ()
219
+ if not any (p .startswith (src ) for src in SONAR_SOURCES ):
220
+ print (f"⚠️ Path outside sonar.sources: { p } " )
221
+ return False
222
+ return True
223
+
224
+ # MAIN
225
+ def main () -> None :
226
+ parser = argparse .ArgumentParser ()
227
+ parser .add_argument ("--ci" , action = "store_true" , help = "Non-interactive mode (always run sonar-scanner and fail on prompts)" )
228
+ parser .add_argument ("--dry-run" , action = "store_true" , help = "Show what would happen without executing tests or sonar-scanner" )
229
+ args = parser .parse_args ()
230
+
231
+ # Clean previous coverage artefacts
232
+ print ("\n 🧹 Cleaning coverage/" )
233
+ COVERAGE_DIR .mkdir (exist_ok = True )
234
+ for f in COVERAGE_DIR .glob ("lcov*.info" ):
235
+ f .unlink ()
236
+
237
+ # Generate coverage per module
238
+ for name , lib in MODULE_PATHS .items ():
239
+ run_coverage_for_module (name , lib , dry = args .dry_run )
240
+
241
+ merge_lcov_files (dry = args .dry_run )
242
+ if not args .dry_run :
243
+ write_full_coverage ()
244
+ coverage_summary ()
245
+
246
+ # SonarQube
247
+ if not args .ci and input ("\n 🤖 Run sonar-scanner now? (y/n): " ).lower () != "y" :
248
+ print ("👋 Done without scanning." )
249
+ return
250
+
251
+ if not args .dry_run and not lcov_paths_valid ():
252
+ print ("❌ Fix the paths before scanning." )
253
+ return
254
+
255
+ if not args .dry_run :
256
+ token = os .environ .get ("SONAR_TOKEN" ) or getpass .getpass ("SONAR_TOKEN: " )
257
+ os .environ ["SONAR_TOKEN" ] = token
258
+
259
+ print ("\n 📡 Launching sonar-scanner…" )
260
+ run (["sonar-scanner" ], dry = args .dry_run )
261
+
262
+ if __name__ == "__main__" :
263
+ main ()
264
+
0 commit comments