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 +1 -1
- sourcecode/cli.py +102 -1
- sourcecode/coverage_parser.py +539 -0
- sourcecode/metrics_analyzer.py +495 -0
- sourcecode/schema.py +124 -0
- sourcecode/semantic_analyzer.py +1887 -0
- {sourcecode-0.7.0.dist-info → sourcecode-0.9.0.dist-info}/METADATA +1 -1
- {sourcecode-0.7.0.dist-info → sourcecode-0.9.0.dist-info}/RECORD +10 -7
- {sourcecode-0.7.0.dist-info → sourcecode-0.9.0.dist-info}/WHEEL +0 -0
- {sourcecode-0.7.0.dist-info → sourcecode-0.9.0.dist-info}/entry_points.txt +0 -0
sourcecode/__init__.py
CHANGED
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
|
|
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
|