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