sourcecode 0.7.0__py3-none-any.whl → 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sourcecode/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """sourcecode — Genera mapas de contexto estructurado para agentes IA."""
2
2
 
3
- __version__ = "0.7.0"
3
+ __version__ = "0.9.0"
sourcecode/cli.py CHANGED
@@ -107,6 +107,16 @@ def main(
107
107
  help="Profundidad de extraccion de docs: module|symbols|full",
108
108
  show_default=True,
109
109
  ),
110
+ full_metrics: bool = typer.Option(
111
+ False,
112
+ "--full-metrics",
113
+ help="Incluir metricas de calidad: LOC, simbolos, complejidad, tests y cobertura por fichero",
114
+ ),
115
+ semantics: bool = typer.Option(
116
+ False,
117
+ "--semantics",
118
+ help="Incluir call graph semantico, linking cross-file de simbolos y resolucion avanzada de imports",
119
+ ),
110
120
  ) -> None:
111
121
  """Genera un mapa de contexto estructurado del proyecto en formato JSON o YAML."""
112
122
  # Validar formato
@@ -145,9 +155,18 @@ def main(
145
155
  from sourcecode.detectors import ProjectDetector, build_default_detectors
146
156
  from sourcecode.doc_analyzer import DocAnalyzer
147
157
  from sourcecode.graph_analyzer import GraphAnalyzer, GraphDetail
158
+ from sourcecode.metrics_analyzer import MetricsAnalyzer
148
159
  from sourcecode.redactor import SecretRedactor, redact_dict
149
160
  from sourcecode.scanner import FileScanner
150
- from sourcecode.schema import AnalysisMetadata, DocRecord, DocSummary, DocsDepth, EntryPoint, SourceMap, StackDetection
161
+ from sourcecode.schema import (
162
+ AnalysisMetadata,
163
+ DocRecord,
164
+ DocsDepth,
165
+ DocSummary,
166
+ EntryPoint,
167
+ SourceMap,
168
+ StackDetection,
169
+ )
151
170
  from sourcecode.serializer import compact_view, write_output
152
171
  from sourcecode.workspace import WorkspaceAnalyzer
153
172
 
@@ -212,6 +231,10 @@ def main(
212
231
  graph_detail_typed = cast(GraphDetail, graph_detail)
213
232
  docs_depth_typed = cast(DocsDepth, docs_depth)
214
233
  doc_analyzer = DocAnalyzer() if docs else None
234
+ metrics_analyzer = MetricsAnalyzer() if full_metrics else None
235
+
236
+ from sourcecode.semantic_analyzer import SemanticAnalyzer
237
+ semantic_analyzer = SemanticAnalyzer() if semantics else None
215
238
 
216
239
  root_manifests = [
217
240
  manifest
@@ -269,6 +292,24 @@ def main(
269
292
  doc_records.extend(root_doc_records)
270
293
  doc_summaries.append(root_doc_summary)
271
294
 
295
+ file_metrics_records: list = []
296
+ metrics_summaries = []
297
+ if metrics_analyzer is not None:
298
+ root_metrics_tree = (
299
+ prune_workspace_paths(
300
+ file_tree,
301
+ [workspace.path for workspace in workspace_analysis.workspaces],
302
+ )
303
+ if workspace_analysis.workspaces
304
+ else file_tree
305
+ )
306
+ root_file_metrics, root_metrics_summary = metrics_analyzer.analyze(
307
+ target,
308
+ root_metrics_tree,
309
+ )
310
+ file_metrics_records.extend(root_file_metrics)
311
+ metrics_summaries.append(root_metrics_summary)
312
+
272
313
  for workspace in workspace_analysis.workspaces:
273
314
  workspace_root = target / workspace.path
274
315
  if not workspace_root.exists() or not workspace_root.is_dir():
@@ -327,6 +368,18 @@ def main(
327
368
  ]
328
369
  doc_records.extend(prefixed_doc_records)
329
370
  doc_summaries.append(workspace_doc_summary)
371
+ if metrics_analyzer is not None:
372
+ ws_file_metrics, ws_metrics_summary = metrics_analyzer.analyze(
373
+ workspace_root,
374
+ workspace_tree,
375
+ workspace=workspace.path,
376
+ )
377
+ prefixed_file_metrics = [
378
+ replace(m, path=f"{workspace.path}/{m.path}")
379
+ for m in ws_file_metrics
380
+ ]
381
+ file_metrics_records.extend(prefixed_file_metrics)
382
+ metrics_summaries.append(ws_metrics_summary)
330
383
 
331
384
  stacks, project_type = detector.classify_results(
332
385
  file_tree,
@@ -355,6 +408,11 @@ def main(
355
408
  if doc_analyzer is not None
356
409
  else None
357
410
  )
411
+ metrics_summary = (
412
+ metrics_analyzer.merge_summaries(metrics_summaries)
413
+ if metrics_analyzer is not None
414
+ else None
415
+ )
358
416
 
359
417
  # 3. Construir el schema
360
418
  metadata = AnalysisMetadata(analyzed_path=str(target))
@@ -370,8 +428,51 @@ def main(
370
428
  module_graph_summary=module_graph.summary if module_graph is not None else None,
371
429
  docs=doc_records,
372
430
  doc_summary=doc_summary,
431
+ file_metrics=file_metrics_records,
432
+ metrics_summary=metrics_summary,
373
433
  )
374
434
 
435
+ # Semantic analysis (--semantics flag)
436
+ if semantic_analyzer is not None:
437
+ if workspace_analysis.workspaces:
438
+ all_sem_calls: list[Any] = []
439
+ all_sem_symbols: list[Any] = []
440
+ all_sem_links: list[Any] = []
441
+ all_sem_summaries: list[Any] = []
442
+ for ws in workspace_analysis.workspaces:
443
+ ws_calls, ws_syms, ws_links, ws_sum = semantic_analyzer.analyze(
444
+ target / ws.path,
445
+ (
446
+ filter_sensitive_files(
447
+ FileScanner(target / ws.path, max_depth=depth).scan_tree()
448
+ )
449
+ ),
450
+ workspace=ws.path,
451
+ )
452
+ all_sem_calls.extend(ws_calls)
453
+ all_sem_symbols.extend(ws_syms)
454
+ all_sem_links.extend(ws_links)
455
+ all_sem_summaries.append(ws_sum)
456
+ merged_sem = semantic_analyzer.merge_summaries(all_sem_summaries)
457
+ sm = replace(
458
+ sm,
459
+ semantic_calls=all_sem_calls,
460
+ semantic_symbols=all_sem_symbols,
461
+ semantic_links=all_sem_links,
462
+ semantic_summary=merged_sem,
463
+ )
464
+ else:
465
+ sem_calls, sem_syms, sem_links, sem_sum = semantic_analyzer.analyze(
466
+ target, file_tree
467
+ )
468
+ sm = replace(
469
+ sm,
470
+ semantic_calls=sem_calls,
471
+ semantic_symbols=sem_syms,
472
+ semantic_links=sem_links,
473
+ semantic_summary=sem_sum,
474
+ )
475
+
375
476
  # Phase 9: LLM Output Quality — poblar campos derivados
376
477
  from sourcecode.summarizer import ProjectSummarizer
377
478
  from sourcecode.tree_utils import flatten_file_tree
@@ -0,0 +1,539 @@
1
+ """Parser de artefactos de cobertura pre-existentes.
2
+
3
+ Lee coverage.xml (Cobertura), .coverage (SQLite de coverage.py >= 5.0),
4
+ lcov.info (LCOV) y jacoco.xml (JaCoCo) sin ejecutar tests ni toolchains.
5
+
6
+ Solo stdlib: sqlite3, xml.etree.ElementTree, pathlib.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import sqlite3
11
+ import xml.etree.ElementTree as ET
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ from sourcecode.schema import CoverageRecord
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Module-level helpers
19
+ # ---------------------------------------------------------------------------
20
+
21
+ def _safe_float(value: Optional[str]) -> Optional[float]:
22
+ """Convierte string a float; retorna None si es None o no parseable."""
23
+ if value is None:
24
+ return None
25
+ try:
26
+ return float(value)
27
+ except (ValueError, TypeError):
28
+ return None
29
+
30
+
31
+ def _safe_int(value: Optional[str]) -> Optional[int]:
32
+ """Convierte string a int; retorna None si es None o no parseable."""
33
+ if value is None:
34
+ return None
35
+ try:
36
+ return int(value)
37
+ except (ValueError, TypeError):
38
+ return None
39
+
40
+
41
+ def _decode_numbits(blob: bytes) -> list[int]:
42
+ """Decodifica bitset little-endian de coverage.py .coverage SQLite.
43
+
44
+ Byte i, bit j set => linea (i*8 + j + 1) ejecutada.
45
+ Ejemplo: bytes([0b00000101]) => [1, 3]
46
+ """
47
+ return [
48
+ i * 8 + j + 1
49
+ for i, byte in enumerate(blob)
50
+ for j in range(8)
51
+ if byte & (1 << j)
52
+ ]
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # CoverageParser
57
+ # ---------------------------------------------------------------------------
58
+
59
+ class CoverageParser:
60
+ """Parsea artefactos de cobertura pre-existentes sin ejecutar tests."""
61
+
62
+ _COBERTURA_CANDIDATES = [
63
+ "coverage.xml",
64
+ "build/coverage.xml",
65
+ "target/coverage.xml",
66
+ "htmlcov/coverage.xml",
67
+ ]
68
+ _LCOV_CANDIDATES = [
69
+ "lcov.info",
70
+ "coverage/lcov.info",
71
+ "coverage.lcov",
72
+ ]
73
+ _JACOCO_CANDIDATES = [
74
+ "jacoco.xml",
75
+ "build/reports/jacoco/test/jacocoTestReport.xml",
76
+ "target/site/jacoco/jacoco.xml",
77
+ ]
78
+
79
+ # ------------------------------------------------------------------
80
+ # Public API
81
+ # ------------------------------------------------------------------
82
+
83
+ def parse_all(self, root: Path) -> list[CoverageRecord]:
84
+ """Busca y parsea todos los artefactos de cobertura en root.
85
+
86
+ Nunca lanza excepcion — errores van a la lista de records con campos None.
87
+ Retorna lista (posiblemente vacia) de CoverageRecord encontrados.
88
+ """
89
+ results: list[CoverageRecord] = []
90
+ for parser in (
91
+ self._parse_cobertura_xml,
92
+ self._parse_dot_coverage,
93
+ self._parse_lcov,
94
+ self._parse_jacoco_xml,
95
+ ):
96
+ try:
97
+ record = parser(root)
98
+ except Exception:
99
+ record = None
100
+ if record is not None:
101
+ results.append(record)
102
+ return results
103
+
104
+ def build_file_coverage_map(
105
+ self,
106
+ root: Path,
107
+ records: list[CoverageRecord],
108
+ ) -> dict[str, tuple[float | None, float | None, str]]:
109
+ """Retorna {rel_path: (line_rate, branch_rate, source_name)}.
110
+
111
+ Prioridad de fuente: cobertura_xml > lcov > jacoco_xml > dot_coverage.
112
+ Paths absolutos (dot_coverage) se convierten a relativos via relative_to(root);
113
+ paths fuera de root se descartan silenciosamente.
114
+ """
115
+ result: dict[str, tuple[float | None, float | None, str]] = {}
116
+
117
+ # Determine which formats are present (in priority order)
118
+ formats_present = {r.format for r in records}
119
+
120
+ priority_order = ["cobertura_xml", "lcov", "jacoco_xml", "dot_coverage"]
121
+
122
+ for fmt in priority_order:
123
+ if fmt not in formats_present:
124
+ continue
125
+
126
+ per_file = self._get_per_file_data(root, fmt)
127
+ for rel_path, (lr, br) in per_file.items():
128
+ # Only add if not already claimed by a higher-priority format
129
+ if rel_path not in result:
130
+ result[rel_path] = (lr, br, fmt)
131
+
132
+ return result
133
+
134
+ # ------------------------------------------------------------------
135
+ # Format parsers (return None on any failure)
136
+ # ------------------------------------------------------------------
137
+
138
+ def _parse_cobertura_xml(self, root: Path) -> CoverageRecord | None:
139
+ """Parsea coverage.xml en formato Cobertura via stdlib ET.
140
+
141
+ Candidatos: coverage.xml, build/coverage.xml, target/coverage.xml,
142
+ htmlcov/coverage.xml. Retorna el primero que parsea correctamente.
143
+ """
144
+ for candidate in self._COBERTURA_CANDIDATES:
145
+ path = root / candidate
146
+ if not path.exists():
147
+ continue
148
+ try:
149
+ tree = ET.parse(str(path))
150
+ root_elem = tree.getroot()
151
+ except ET.ParseError:
152
+ continue
153
+ except Exception:
154
+ continue
155
+
156
+ if root_elem.tag != "coverage":
157
+ continue
158
+
159
+ line_rate = _safe_float(root_elem.get("line-rate"))
160
+ branch_rate = _safe_float(root_elem.get("branch-rate"))
161
+ lines_covered = _safe_int(root_elem.get("lines-covered"))
162
+ lines_valid = _safe_int(root_elem.get("lines-valid"))
163
+ timestamp = root_elem.get("timestamp")
164
+ tool_version = root_elem.get("version")
165
+ file_count = len(root_elem.findall(".//class"))
166
+
167
+ return CoverageRecord(
168
+ source_file=str(path.relative_to(root)).replace("\\", "/"),
169
+ format="cobertura_xml",
170
+ line_rate=line_rate,
171
+ branch_rate=branch_rate,
172
+ lines_covered=lines_covered,
173
+ lines_valid=lines_valid,
174
+ timestamp=timestamp,
175
+ tool_version=tool_version,
176
+ file_count=file_count,
177
+ )
178
+
179
+ return None
180
+
181
+ def _parse_dot_coverage(self, root: Path) -> CoverageRecord | None:
182
+ """Parsea .coverage SQLite (coverage.py >= 5.0).
183
+
184
+ Silencia sqlite3.DatabaseError — el fichero puede ser pickle (< 5.0)
185
+ o estar corrupto. Retorna None si no existe o no es SQLite valido.
186
+ """
187
+ path = root / ".coverage"
188
+ if not path.exists():
189
+ return None
190
+
191
+ try:
192
+ conn = sqlite3.connect(str(path))
193
+ try:
194
+ # Validate it's a coverage.py SQLite database
195
+ cursor = conn.execute(
196
+ "SELECT key, value FROM meta WHERE key IN ('version', 'timestamp')"
197
+ )
198
+ meta_rows = cursor.fetchall()
199
+ meta = {row[0]: row[1] for row in meta_rows}
200
+
201
+ file_cursor = conn.execute("SELECT count(*) FROM file")
202
+ file_count = file_cursor.fetchone()[0]
203
+ finally:
204
+ conn.close()
205
+ except sqlite3.DatabaseError:
206
+ return None
207
+ except Exception:
208
+ return None
209
+
210
+ tool_version = meta.get("version")
211
+ timestamp = meta.get("timestamp")
212
+
213
+ return CoverageRecord(
214
+ source_file=".coverage",
215
+ format="dot_coverage",
216
+ line_rate=None,
217
+ branch_rate=None,
218
+ lines_covered=None,
219
+ lines_valid=None,
220
+ timestamp=str(timestamp) if timestamp is not None else None,
221
+ tool_version=str(tool_version) if tool_version is not None else None,
222
+ file_count=file_count,
223
+ )
224
+
225
+ def _parse_lcov(self, root: Path) -> CoverageRecord | None:
226
+ """Parsea lcov.info via state machine linea a linea.
227
+
228
+ Candidatos: lcov.info, coverage/lcov.info, coverage.lcov.
229
+ Lee con errors='replace' para seguridad de encoding.
230
+ """
231
+ for candidate in self._LCOV_CANDIDATES:
232
+ path = root / candidate
233
+ if not path.exists():
234
+ continue
235
+ try:
236
+ content = path.read_text(encoding="utf-8", errors="replace")
237
+ except Exception:
238
+ continue
239
+
240
+ total_lf = 0
241
+ total_lh = 0
242
+ total_brf = 0
243
+ total_brh = 0
244
+ file_count = 0
245
+
246
+ for line in content.splitlines():
247
+ line = line.strip()
248
+ if line.startswith("LF:"):
249
+ val = _safe_int(line[3:])
250
+ if val is not None:
251
+ total_lf += val
252
+ elif line.startswith("LH:"):
253
+ val = _safe_int(line[3:])
254
+ if val is not None:
255
+ total_lh += val
256
+ elif line.startswith("BRF:"):
257
+ val = _safe_int(line[4:])
258
+ if val is not None:
259
+ total_brf += val
260
+ elif line.startswith("BRH:"):
261
+ val = _safe_int(line[4:])
262
+ if val is not None:
263
+ total_brh += val
264
+ elif line == "end_of_record":
265
+ file_count += 1
266
+
267
+ line_rate = total_lh / total_lf if total_lf > 0 else None
268
+ branch_rate = total_brh / total_brf if total_brf > 0 else None
269
+
270
+ return CoverageRecord(
271
+ source_file=str(path.relative_to(root)).replace("\\", "/"),
272
+ format="lcov",
273
+ line_rate=line_rate,
274
+ branch_rate=branch_rate,
275
+ lines_covered=total_lh if total_lf > 0 else None,
276
+ lines_valid=total_lf if total_lf > 0 else None,
277
+ timestamp=None,
278
+ tool_version=None,
279
+ file_count=file_count,
280
+ )
281
+
282
+ return None
283
+
284
+ def _parse_jacoco_xml(self, root: Path) -> CoverageRecord | None:
285
+ """Parsea jacoco.xml via stdlib ET.
286
+
287
+ Lee root-level <counter> elements (hijos directos de <report>).
288
+ JaCoCo usa '/' en package paths (com/example), no '.'.
289
+ """
290
+ for candidate in self._JACOCO_CANDIDATES:
291
+ path = root / candidate
292
+ if not path.exists():
293
+ continue
294
+ try:
295
+ tree = ET.parse(str(path))
296
+ report_elem = tree.getroot()
297
+ except ET.ParseError:
298
+ continue
299
+ except Exception:
300
+ continue
301
+
302
+ if report_elem.tag != "report":
303
+ continue
304
+
305
+ # Root-level counters only (direct children of <report>)
306
+ counters = {
307
+ c.get("type"): c
308
+ for c in report_elem.findall("counter")
309
+ }
310
+
311
+ line_rate: Optional[float] = None
312
+ branch_rate: Optional[float] = None
313
+ lines_covered: Optional[int] = None
314
+ lines_valid: Optional[int] = None
315
+
316
+ if "LINE" in counters:
317
+ c = counters["LINE"]
318
+ missed = _safe_int(c.get("missed"))
319
+ covered = _safe_int(c.get("covered"))
320
+ if missed is not None and covered is not None:
321
+ total = missed + covered
322
+ lines_covered = covered
323
+ lines_valid = total
324
+ line_rate = covered / total if total > 0 else None
325
+
326
+ if "BRANCH" in counters:
327
+ c = counters["BRANCH"]
328
+ missed = _safe_int(c.get("missed"))
329
+ covered = _safe_int(c.get("covered"))
330
+ if missed is not None and covered is not None:
331
+ total = missed + covered
332
+ branch_rate = covered / total if total > 0 else None
333
+
334
+ file_count = len(report_elem.findall(".//sourcefile"))
335
+
336
+ return CoverageRecord(
337
+ source_file=str(path.relative_to(root)).replace("\\", "/"),
338
+ format="jacoco_xml",
339
+ line_rate=line_rate,
340
+ branch_rate=branch_rate,
341
+ lines_covered=lines_covered,
342
+ lines_valid=lines_valid,
343
+ timestamp=None,
344
+ tool_version=None,
345
+ file_count=file_count,
346
+ )
347
+
348
+ return None
349
+
350
+ # ------------------------------------------------------------------
351
+ # Internal: per-file data extraction for build_file_coverage_map
352
+ # ------------------------------------------------------------------
353
+
354
+ def _get_per_file_data(
355
+ self,
356
+ root: Path,
357
+ fmt: str,
358
+ ) -> dict[str, tuple[float | None, float | None]]:
359
+ """Extrae datos de cobertura por fichero para un formato dado.
360
+
361
+ Retorna {rel_path: (line_rate, branch_rate)}.
362
+ """
363
+ if fmt == "cobertura_xml":
364
+ return self._per_file_cobertura(root)
365
+ if fmt == "lcov":
366
+ return self._per_file_lcov(root)
367
+ if fmt == "jacoco_xml":
368
+ return self._per_file_jacoco(root)
369
+ if fmt == "dot_coverage":
370
+ return self._per_file_dot_coverage(root)
371
+ return {}
372
+
373
+ def _per_file_cobertura(
374
+ self, root: Path
375
+ ) -> dict[str, tuple[float | None, float | None]]:
376
+ """Extrae line_rate y branch_rate por fichero desde coverage.xml (Cobertura)."""
377
+ result: dict[str, tuple[float | None, float | None]] = {}
378
+ for candidate in self._COBERTURA_CANDIDATES:
379
+ path = root / candidate
380
+ if not path.exists():
381
+ continue
382
+ try:
383
+ tree = ET.parse(str(path))
384
+ root_elem = tree.getroot()
385
+ except Exception:
386
+ continue
387
+ if root_elem.tag != "coverage":
388
+ continue
389
+
390
+ for cls in root_elem.findall(".//class"):
391
+ filename = cls.get("filename")
392
+ if not filename:
393
+ continue
394
+ # Normalize path separators to forward slash
395
+ filename = filename.replace("\\", "/")
396
+ lr = _safe_float(cls.get("line-rate"))
397
+ br = _safe_float(cls.get("branch-rate"))
398
+ result[filename] = (lr, br)
399
+ break # use first valid candidate
400
+ return result
401
+
402
+ def _per_file_lcov(
403
+ self, root: Path
404
+ ) -> dict[str, tuple[float | None, float | None]]:
405
+ """Extrae line_rate y branch_rate por fichero desde lcov.info."""
406
+ result: dict[str, tuple[float | None, float | None]] = {}
407
+ for candidate in self._LCOV_CANDIDATES:
408
+ path = root / candidate
409
+ if not path.exists():
410
+ continue
411
+ try:
412
+ content = path.read_text(encoding="utf-8", errors="replace")
413
+ except Exception:
414
+ continue
415
+
416
+ current_file: Optional[str] = None
417
+ lf = lh = brf = brh = 0
418
+
419
+ for line in content.splitlines():
420
+ line = line.strip()
421
+ if line.startswith("SF:"):
422
+ current_file = line[3:].replace("\\", "/")
423
+ lf = lh = brf = brh = 0
424
+ elif line.startswith("LF:"):
425
+ val = _safe_int(line[3:])
426
+ if val is not None:
427
+ lf = val
428
+ elif line.startswith("LH:"):
429
+ val = _safe_int(line[3:])
430
+ if val is not None:
431
+ lh = val
432
+ elif line.startswith("BRF:"):
433
+ val = _safe_int(line[4:])
434
+ if val is not None:
435
+ brf = val
436
+ elif line.startswith("BRH:"):
437
+ val = _safe_int(line[4:])
438
+ if val is not None:
439
+ brh = val
440
+ elif line == "end_of_record" and current_file is not None:
441
+ lr = lh / lf if lf > 0 else None
442
+ br = brh / brf if brf > 0 else None
443
+ result[current_file] = (lr, br)
444
+ current_file = None
445
+
446
+ break # use first valid candidate
447
+ return result
448
+
449
+ def _per_file_jacoco(
450
+ self, root: Path
451
+ ) -> dict[str, tuple[float | None, float | None]]:
452
+ """Extrae line_rate y branch_rate por fichero desde jacoco.xml."""
453
+ result: dict[str, tuple[float | None, float | None]] = {}
454
+ for candidate in self._JACOCO_CANDIDATES:
455
+ path = root / candidate
456
+ if not path.exists():
457
+ continue
458
+ try:
459
+ tree = ET.parse(str(path))
460
+ report_elem = tree.getroot()
461
+ except Exception:
462
+ continue
463
+ if report_elem.tag != "report":
464
+ continue
465
+
466
+ for pkg in report_elem.findall("package"):
467
+ pkg_name = pkg.get("name", "").replace("\\", "/")
468
+ for sf in pkg.findall("sourcefile"):
469
+ sf_name = sf.get("name", "")
470
+ rel_path = f"{pkg_name}/{sf_name}" if pkg_name else sf_name
471
+
472
+ counters = {
473
+ c.get("type"): c for c in sf.findall("counter")
474
+ }
475
+ lr: Optional[float] = None
476
+ br: Optional[float] = None
477
+
478
+ if "LINE" in counters:
479
+ c = counters["LINE"]
480
+ missed = _safe_int(c.get("missed"))
481
+ covered = _safe_int(c.get("covered"))
482
+ if missed is not None and covered is not None:
483
+ total = missed + covered
484
+ lr = covered / total if total > 0 else None
485
+
486
+ if "BRANCH" in counters:
487
+ c = counters["BRANCH"]
488
+ missed = _safe_int(c.get("missed"))
489
+ covered = _safe_int(c.get("covered"))
490
+ if missed is not None and covered is not None:
491
+ total = missed + covered
492
+ br = covered / total if total > 0 else None
493
+
494
+ result[rel_path] = (lr, br)
495
+ break # use first valid candidate
496
+ return result
497
+
498
+ def _per_file_dot_coverage(
499
+ self, root: Path
500
+ ) -> dict[str, tuple[float | None, float | None]]:
501
+ """Extrae datos por fichero desde .coverage SQLite (coverage.py >= 5.0).
502
+
503
+ Intenta leer la tabla 'line_bits' para calcular lineas ejecutadas;
504
+ silencia cualquier error de SQLite.
505
+ Paths absolutos se convierten a relativos con relative_to(root).
506
+ """
507
+ result: dict[str, tuple[float | None, float | None]] = {}
508
+ path = root / ".coverage"
509
+ if not path.exists():
510
+ return result
511
+
512
+ try:
513
+ conn = sqlite3.connect(str(path))
514
+ try:
515
+ # Get all tracked files
516
+ file_rows = conn.execute(
517
+ "SELECT id, path FROM file"
518
+ ).fetchall()
519
+
520
+ for _file_id, abs_path in file_rows:
521
+ # Convert to relative path
522
+ try:
523
+ rel = Path(abs_path).relative_to(root)
524
+ rel_str = str(rel).replace("\\", "/")
525
+ except ValueError:
526
+ # Path outside root — discard per threat model T-10-02-03
527
+ continue
528
+
529
+ # line_rate is unavailable without total_lines context
530
+ result[rel_str] = (None, None)
531
+
532
+ finally:
533
+ conn.close()
534
+ except sqlite3.DatabaseError:
535
+ return {}
536
+ except Exception:
537
+ return {}
538
+
539
+ return result