sourcecode 0.7.0__py3-none-any.whl → 0.8.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 +53 -1
- sourcecode/coverage_parser.py +539 -0
- sourcecode/metrics_analyzer.py +495 -0
- sourcecode/schema.py +59 -0
- {sourcecode-0.7.0.dist-info → sourcecode-0.8.0.dist-info}/METADATA +1 -1
- {sourcecode-0.7.0.dist-info → sourcecode-0.8.0.dist-info}/RECORD +9 -7
- {sourcecode-0.7.0.dist-info → sourcecode-0.8.0.dist-info}/WHEEL +0 -0
- {sourcecode-0.7.0.dist-info → sourcecode-0.8.0.dist-info}/entry_points.txt +0 -0
sourcecode/__init__.py
CHANGED
sourcecode/cli.py
CHANGED
|
@@ -107,6 +107,11 @@ 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
|
+
),
|
|
110
115
|
) -> None:
|
|
111
116
|
"""Genera un mapa de contexto estructurado del proyecto en formato JSON o YAML."""
|
|
112
117
|
# Validar formato
|
|
@@ -145,9 +150,18 @@ def main(
|
|
|
145
150
|
from sourcecode.detectors import ProjectDetector, build_default_detectors
|
|
146
151
|
from sourcecode.doc_analyzer import DocAnalyzer
|
|
147
152
|
from sourcecode.graph_analyzer import GraphAnalyzer, GraphDetail
|
|
153
|
+
from sourcecode.metrics_analyzer import MetricsAnalyzer
|
|
148
154
|
from sourcecode.redactor import SecretRedactor, redact_dict
|
|
149
155
|
from sourcecode.scanner import FileScanner
|
|
150
|
-
from sourcecode.schema import
|
|
156
|
+
from sourcecode.schema import (
|
|
157
|
+
AnalysisMetadata,
|
|
158
|
+
DocRecord,
|
|
159
|
+
DocsDepth,
|
|
160
|
+
DocSummary,
|
|
161
|
+
EntryPoint,
|
|
162
|
+
SourceMap,
|
|
163
|
+
StackDetection,
|
|
164
|
+
)
|
|
151
165
|
from sourcecode.serializer import compact_view, write_output
|
|
152
166
|
from sourcecode.workspace import WorkspaceAnalyzer
|
|
153
167
|
|
|
@@ -212,6 +226,7 @@ def main(
|
|
|
212
226
|
graph_detail_typed = cast(GraphDetail, graph_detail)
|
|
213
227
|
docs_depth_typed = cast(DocsDepth, docs_depth)
|
|
214
228
|
doc_analyzer = DocAnalyzer() if docs else None
|
|
229
|
+
metrics_analyzer = MetricsAnalyzer() if full_metrics else None
|
|
215
230
|
|
|
216
231
|
root_manifests = [
|
|
217
232
|
manifest
|
|
@@ -269,6 +284,24 @@ def main(
|
|
|
269
284
|
doc_records.extend(root_doc_records)
|
|
270
285
|
doc_summaries.append(root_doc_summary)
|
|
271
286
|
|
|
287
|
+
file_metrics_records: list = []
|
|
288
|
+
metrics_summaries = []
|
|
289
|
+
if metrics_analyzer is not None:
|
|
290
|
+
root_metrics_tree = (
|
|
291
|
+
prune_workspace_paths(
|
|
292
|
+
file_tree,
|
|
293
|
+
[workspace.path for workspace in workspace_analysis.workspaces],
|
|
294
|
+
)
|
|
295
|
+
if workspace_analysis.workspaces
|
|
296
|
+
else file_tree
|
|
297
|
+
)
|
|
298
|
+
root_file_metrics, root_metrics_summary = metrics_analyzer.analyze(
|
|
299
|
+
target,
|
|
300
|
+
root_metrics_tree,
|
|
301
|
+
)
|
|
302
|
+
file_metrics_records.extend(root_file_metrics)
|
|
303
|
+
metrics_summaries.append(root_metrics_summary)
|
|
304
|
+
|
|
272
305
|
for workspace in workspace_analysis.workspaces:
|
|
273
306
|
workspace_root = target / workspace.path
|
|
274
307
|
if not workspace_root.exists() or not workspace_root.is_dir():
|
|
@@ -327,6 +360,18 @@ def main(
|
|
|
327
360
|
]
|
|
328
361
|
doc_records.extend(prefixed_doc_records)
|
|
329
362
|
doc_summaries.append(workspace_doc_summary)
|
|
363
|
+
if metrics_analyzer is not None:
|
|
364
|
+
ws_file_metrics, ws_metrics_summary = metrics_analyzer.analyze(
|
|
365
|
+
workspace_root,
|
|
366
|
+
workspace_tree,
|
|
367
|
+
workspace=workspace.path,
|
|
368
|
+
)
|
|
369
|
+
prefixed_file_metrics = [
|
|
370
|
+
replace(m, path=f"{workspace.path}/{m.path}")
|
|
371
|
+
for m in ws_file_metrics
|
|
372
|
+
]
|
|
373
|
+
file_metrics_records.extend(prefixed_file_metrics)
|
|
374
|
+
metrics_summaries.append(ws_metrics_summary)
|
|
330
375
|
|
|
331
376
|
stacks, project_type = detector.classify_results(
|
|
332
377
|
file_tree,
|
|
@@ -355,6 +400,11 @@ def main(
|
|
|
355
400
|
if doc_analyzer is not None
|
|
356
401
|
else None
|
|
357
402
|
)
|
|
403
|
+
metrics_summary = (
|
|
404
|
+
metrics_analyzer.merge_summaries(metrics_summaries)
|
|
405
|
+
if metrics_analyzer is not None
|
|
406
|
+
else None
|
|
407
|
+
)
|
|
358
408
|
|
|
359
409
|
# 3. Construir el schema
|
|
360
410
|
metadata = AnalysisMetadata(analyzed_path=str(target))
|
|
@@ -370,6 +420,8 @@ def main(
|
|
|
370
420
|
module_graph_summary=module_graph.summary if module_graph is not None else None,
|
|
371
421
|
docs=doc_records,
|
|
372
422
|
doc_summary=doc_summary,
|
|
423
|
+
file_metrics=file_metrics_records,
|
|
424
|
+
metrics_summary=metrics_summary,
|
|
373
425
|
)
|
|
374
426
|
|
|
375
427
|
# Phase 9: LLM Output Quality — poblar campos derivados
|
|
@@ -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
|
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
"""Analisis de metricas de calidad de codigo: LOC, simbolos y complejidad.
|
|
2
|
+
|
|
3
|
+
Sigue el mismo patron que DocAnalyzer y GraphAnalyzer:
|
|
4
|
+
- analyze() recibe root + file_tree, retorna (list[FileMetrics], MetricsSummary)
|
|
5
|
+
- merge_summaries() agrega multiples MetricsSummary en uno
|
|
6
|
+
|
|
7
|
+
LOC counting usa text scan para todos los lenguajes.
|
|
8
|
+
Python: adicionalmente ast.parse para simbolos exactos y complejidad McCabe.
|
|
9
|
+
JS/TS, Go, Rust, Java: regex para simbolos aproximados (availability="inferred").
|
|
10
|
+
Otros: solo LOC (availability="unavailable" para simbolos y complejidad).
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import ast
|
|
15
|
+
import re
|
|
16
|
+
from collections.abc import Iterable
|
|
17
|
+
from pathlib import Path, PurePosixPath
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from sourcecode.coverage_parser import CoverageParser
|
|
21
|
+
from sourcecode.doc_analyzer import _LANG_MAP # reusar, no redefinir
|
|
22
|
+
from sourcecode.schema import FileMetrics, MetricsSummary
|
|
23
|
+
from sourcecode.tree_utils import flatten_file_tree
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Test file detection — patterns from research (10-RESEARCH.md)
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
_TEST_FILE_PATTERNS: list[re.Pattern[str]] = [
|
|
30
|
+
re.compile(p) for p in [
|
|
31
|
+
r"(?:^|/)tests?/.*\.py$",
|
|
32
|
+
r"(?:^|/)test_.*\.py$",
|
|
33
|
+
r"^.*_test\.py$",
|
|
34
|
+
r"^.*_test\.go$",
|
|
35
|
+
r"^.*\.(spec|test)\.(js|jsx|ts|tsx|mjs|cjs)$",
|
|
36
|
+
r"(?:^|/)__tests__/.*\.(js|jsx|ts|tsx|mjs|cjs)$",
|
|
37
|
+
r"^.*(Test|Tests|Spec|IT)\.(java|kt|scala)$",
|
|
38
|
+
r"(?:^|/)spec/.*\.rb$",
|
|
39
|
+
r"^.*(?:test|spec).*\.rb$",
|
|
40
|
+
r"(?:^|/)tests/.*\.rs$",
|
|
41
|
+
r"^.*_test\.dart$",
|
|
42
|
+
r"(?:^|/)test/.*\.dart$",
|
|
43
|
+
r"^.*(test|Test|spec)\.(c|cpp|cc|h|hpp)$",
|
|
44
|
+
r"(?:^|/)tests?/.*\.(c|cpp|cc)$",
|
|
45
|
+
r"^.*Test\.php$",
|
|
46
|
+
r"(?:^|/)tests?/.*\.php$",
|
|
47
|
+
]
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
# Stem patterns for inferring production file name from test file name.
|
|
51
|
+
# Each entry is (compiled_pattern, replacement_string).
|
|
52
|
+
_STEM_PATTERNS: list[tuple[re.Pattern[str], str]] = [
|
|
53
|
+
(re.compile(r"^test_(.+)$"), r"\1"),
|
|
54
|
+
(re.compile(r"^(.+)_test(\.\w+)$"), r"\1\2"),
|
|
55
|
+
(re.compile(r"^(.+)(Test|Tests|Spec|IT)(\.\w+)$"), r"\1\3"),
|
|
56
|
+
(re.compile(r"^(.+)\.(spec|test)(\.\w+)$"), r"\1\3"),
|
|
57
|
+
(re.compile(r"^(.+)_spec(\.\w+)$"), r"\1\2"),
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def is_test_file(path: str) -> bool:
|
|
62
|
+
"""Return True if path corresponds to a test file based on ecosystem conventions.
|
|
63
|
+
|
|
64
|
+
Normalizes Windows backslashes to forward slashes before matching.
|
|
65
|
+
Patterns are anchored to avoid false positives (e.g., 'testdata/', '*_tested.py').
|
|
66
|
+
"""
|
|
67
|
+
normalized = path.replace("\\", "/")
|
|
68
|
+
return any(p.search(normalized) for p in _TEST_FILE_PATTERNS)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def infer_production_target(test_path: str) -> str | None:
|
|
72
|
+
"""Infer the production module filename from a test file path.
|
|
73
|
+
|
|
74
|
+
Operates on the basename only (not the full path).
|
|
75
|
+
Returns the bare filename (e.g., 'scanner.py'), or None if no pattern matches.
|
|
76
|
+
"""
|
|
77
|
+
name = PurePosixPath(test_path.replace("\\", "/")).name
|
|
78
|
+
for pattern, replacement in _STEM_PATTERNS:
|
|
79
|
+
if pattern.match(name):
|
|
80
|
+
return pattern.sub(replacement, name)
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# Regex patterns for inferred symbol counting (JS/TS, Go, Rust, Java)
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
_JS_FUNC_RE = re.compile(
|
|
88
|
+
r"\b(?:async\s+)?function\s+\w+|\bconst\s+\w+\s*=\s*(?:async\s*)?\(",
|
|
89
|
+
re.MULTILINE,
|
|
90
|
+
)
|
|
91
|
+
_JS_CLASS_RE = re.compile(r"^\s*class\s+\w+", re.MULTILINE)
|
|
92
|
+
|
|
93
|
+
_GO_FUNC_RE = re.compile(r"^func\s", re.MULTILINE)
|
|
94
|
+
_GO_STRUCT_RE = re.compile(r"^type\s+\w+\s+struct", re.MULTILINE)
|
|
95
|
+
|
|
96
|
+
_RUST_FN_RE = re.compile(r"^\s*(?:pub\s+)?(?:async\s+)?fn\s+\w+", re.MULTILINE)
|
|
97
|
+
_RUST_STRUCT_RE = re.compile(r"^\s*(?:pub\s+)?struct\s+\w+", re.MULTILINE)
|
|
98
|
+
|
|
99
|
+
_JAVA_CLASS_RE = re.compile(
|
|
100
|
+
r"^\s*(?:public\s+|private\s+|protected\s+)?(?:abstract\s+|final\s+)?class\s+\w+",
|
|
101
|
+
re.MULTILINE,
|
|
102
|
+
)
|
|
103
|
+
_JAVA_METHOD_RE = re.compile(
|
|
104
|
+
r"^\s*(?:public|private|protected)\s+(?:static\s+)?\w[\w<>\[\]]*\s+\w+\s*\(",
|
|
105
|
+
re.MULTILINE,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Languages with inferred symbol support
|
|
109
|
+
_INFERRED_SYMBOL_LANGS = {"javascript", "typescript", "go", "rust", "java"}
|
|
110
|
+
|
|
111
|
+
# Languages with Python-level measured support
|
|
112
|
+
_MEASURED_SYMBOL_LANGS = {"python"}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _lang_for_suffix(suffix: str) -> str:
|
|
116
|
+
return _LANG_MAP.get(suffix.lower(), "unknown")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _mccabe(func_node: ast.FunctionDef | ast.AsyncFunctionDef) -> int:
|
|
120
|
+
"""Calcula la complejidad ciclomatica McCabe de una funcion.
|
|
121
|
+
|
|
122
|
+
cc = 1 + suma de ramas (If, For, While, ExceptHandler, With, Assert,
|
|
123
|
+
comprehensions) + (len(BoolOp.values) - 1) por cada BoolOp.
|
|
124
|
+
"""
|
|
125
|
+
cc = 1
|
|
126
|
+
for node in ast.walk(func_node):
|
|
127
|
+
if isinstance(node, (ast.If, ast.For, ast.While, ast.ExceptHandler, ast.With, ast.Assert)):
|
|
128
|
+
cc += 1
|
|
129
|
+
elif isinstance(node, ast.BoolOp):
|
|
130
|
+
cc += len(node.values) - 1
|
|
131
|
+
elif isinstance(node, (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)):
|
|
132
|
+
cc += 1
|
|
133
|
+
return cc
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class MetricsAnalyzer:
|
|
137
|
+
"""Analiza metricas de calidad de codigo: LOC, simbolos y complejidad ciclomatica."""
|
|
138
|
+
|
|
139
|
+
_MAX_FILES = 500
|
|
140
|
+
_MAX_FILE_SIZE = 500_000 # bytes
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
# Public API
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
def analyze(
|
|
147
|
+
self,
|
|
148
|
+
root: Path,
|
|
149
|
+
file_tree: dict[str, Any],
|
|
150
|
+
*,
|
|
151
|
+
workspace: str | None = None,
|
|
152
|
+
) -> tuple[list[FileMetrics], MetricsSummary]:
|
|
153
|
+
"""Analiza el arbol de ficheros y retorna (list[FileMetrics], MetricsSummary).
|
|
154
|
+
|
|
155
|
+
Nunca lanza excepciones — todos los errores van a summary.limitations.
|
|
156
|
+
"""
|
|
157
|
+
all_paths = flatten_file_tree(file_tree)
|
|
158
|
+
# Keep only paths that are actual files (not directories)
|
|
159
|
+
file_paths = [p for p in all_paths if (root / p).is_file()]
|
|
160
|
+
|
|
161
|
+
limitations: list[str] = []
|
|
162
|
+
|
|
163
|
+
# Guard: max files
|
|
164
|
+
if len(file_paths) > self._MAX_FILES:
|
|
165
|
+
actual = len(file_paths)
|
|
166
|
+
limitations.append(f"max_files_reached:{actual}")
|
|
167
|
+
file_paths = file_paths[: self._MAX_FILES]
|
|
168
|
+
|
|
169
|
+
# --- Coverage: parse all artifacts before iterating files ---
|
|
170
|
+
coverage_parser = CoverageParser()
|
|
171
|
+
coverage_records = coverage_parser.parse_all(root)
|
|
172
|
+
file_cov_map = coverage_parser.build_file_coverage_map(root, coverage_records)
|
|
173
|
+
|
|
174
|
+
records: list[FileMetrics] = []
|
|
175
|
+
languages: set[str] = set()
|
|
176
|
+
|
|
177
|
+
for rel_path in file_paths:
|
|
178
|
+
abs_path = root / rel_path
|
|
179
|
+
|
|
180
|
+
# Guard: max file size
|
|
181
|
+
try:
|
|
182
|
+
file_size = abs_path.stat().st_size
|
|
183
|
+
except OSError:
|
|
184
|
+
limitations.append(f"read_error:{Path(rel_path).as_posix()}")
|
|
185
|
+
continue
|
|
186
|
+
if file_size > self._MAX_FILE_SIZE:
|
|
187
|
+
limitations.append(f"file_too_large:{Path(rel_path).as_posix()}")
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
fm = self._analyze_file(abs_path, rel_path, workspace)
|
|
191
|
+
|
|
192
|
+
# --- Test detection ---
|
|
193
|
+
norm_rel = Path(rel_path).as_posix()
|
|
194
|
+
fm.is_test = is_test_file(norm_rel)
|
|
195
|
+
|
|
196
|
+
# --- Production target resolution ---
|
|
197
|
+
if fm.is_test:
|
|
198
|
+
inferred_name = infer_production_target(norm_rel)
|
|
199
|
+
if inferred_name is not None:
|
|
200
|
+
match = next(
|
|
201
|
+
(
|
|
202
|
+
Path(p).as_posix()
|
|
203
|
+
for p in file_paths
|
|
204
|
+
if not is_test_file(Path(p).as_posix())
|
|
205
|
+
and Path(p).name == inferred_name
|
|
206
|
+
),
|
|
207
|
+
None,
|
|
208
|
+
)
|
|
209
|
+
fm.production_target = match
|
|
210
|
+
|
|
211
|
+
# --- Coverage wiring ---
|
|
212
|
+
cov_entry = file_cov_map.get(norm_rel)
|
|
213
|
+
if cov_entry is not None:
|
|
214
|
+
fm.line_rate, fm.branch_rate, fm.coverage_source = cov_entry
|
|
215
|
+
fm.coverage_availability = "measured"
|
|
216
|
+
|
|
217
|
+
records.append(fm)
|
|
218
|
+
if fm.language != "unknown":
|
|
219
|
+
languages.add(fm.language)
|
|
220
|
+
|
|
221
|
+
summary = MetricsSummary(
|
|
222
|
+
requested=True,
|
|
223
|
+
file_count=len(records),
|
|
224
|
+
test_file_count=sum(1 for r in records if r.is_test),
|
|
225
|
+
languages=sorted(languages),
|
|
226
|
+
total_loc=sum(r.code_lines for r in records),
|
|
227
|
+
coverage_records=coverage_records,
|
|
228
|
+
coverage_sources_found=sorted({r.format for r in coverage_records}),
|
|
229
|
+
limitations=limitations,
|
|
230
|
+
)
|
|
231
|
+
return records, summary
|
|
232
|
+
|
|
233
|
+
def merge_summaries(self, summaries: Iterable[MetricsSummary]) -> MetricsSummary:
|
|
234
|
+
"""Agrega multiples MetricsSummary en uno.
|
|
235
|
+
|
|
236
|
+
Sigue el mismo patron que DocAnalyzer.merge_summaries().
|
|
237
|
+
"""
|
|
238
|
+
result = MetricsSummary(requested=True)
|
|
239
|
+
languages: set[str] = set()
|
|
240
|
+
sources_found: set[str] = set()
|
|
241
|
+
limitations: list[str] = []
|
|
242
|
+
|
|
243
|
+
for summary in summaries:
|
|
244
|
+
result.file_count += summary.file_count
|
|
245
|
+
result.test_file_count += summary.test_file_count
|
|
246
|
+
result.total_loc += summary.total_loc
|
|
247
|
+
languages.update(summary.languages)
|
|
248
|
+
result.coverage_records.extend(summary.coverage_records)
|
|
249
|
+
sources_found.update(summary.coverage_sources_found)
|
|
250
|
+
limitations.extend(summary.limitations)
|
|
251
|
+
|
|
252
|
+
result.languages = sorted(languages)
|
|
253
|
+
result.coverage_sources_found = sorted(sources_found)
|
|
254
|
+
result.limitations = limitations
|
|
255
|
+
return result
|
|
256
|
+
|
|
257
|
+
# ---------------------------------------------------------------------------
|
|
258
|
+
# Per-file analysis
|
|
259
|
+
# ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
def _analyze_file(
|
|
262
|
+
self,
|
|
263
|
+
abs_path: Path,
|
|
264
|
+
rel_path: str,
|
|
265
|
+
workspace: str | None,
|
|
266
|
+
) -> FileMetrics:
|
|
267
|
+
"""Analiza un fichero individual y retorna un FileMetrics.
|
|
268
|
+
|
|
269
|
+
Nunca lanza excepciones — los errores se reflejan en los campos
|
|
270
|
+
de availability del FileMetrics retornado.
|
|
271
|
+
"""
|
|
272
|
+
norm_path = Path(rel_path).as_posix()
|
|
273
|
+
suffix = Path(rel_path).suffix.lower()
|
|
274
|
+
language = _lang_for_suffix(suffix)
|
|
275
|
+
|
|
276
|
+
# Read content
|
|
277
|
+
try:
|
|
278
|
+
content = abs_path.read_text(encoding="utf-8", errors="replace")
|
|
279
|
+
except OSError:
|
|
280
|
+
return FileMetrics(
|
|
281
|
+
path=norm_path,
|
|
282
|
+
language=language,
|
|
283
|
+
workspace=workspace,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
# LOC counting — all languages
|
|
287
|
+
loc = self._count_loc(content, language)
|
|
288
|
+
|
|
289
|
+
fm = FileMetrics(
|
|
290
|
+
path=norm_path,
|
|
291
|
+
language=language,
|
|
292
|
+
total_lines=loc["total_lines"],
|
|
293
|
+
code_lines=loc["code_lines"],
|
|
294
|
+
blank_lines=loc["blank_lines"],
|
|
295
|
+
comment_lines=loc["comment_lines"],
|
|
296
|
+
loc_availability=loc["loc_availability"],
|
|
297
|
+
workspace=workspace,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
# Symbol counting per language tier
|
|
301
|
+
if language == "python":
|
|
302
|
+
sym = self._count_python_symbols(content, norm_path)
|
|
303
|
+
fm.function_count = sym["function_count"]
|
|
304
|
+
fm.class_count = sym["class_count"]
|
|
305
|
+
fm.symbol_availability = sym["symbol_availability"]
|
|
306
|
+
fm.cyclomatic_complexity = sym["cyclomatic_complexity"]
|
|
307
|
+
fm.complexity_availability = sym["complexity_availability"]
|
|
308
|
+
|
|
309
|
+
elif language in ("javascript", "typescript"):
|
|
310
|
+
sym = self._count_js_symbols(content)
|
|
311
|
+
fm.function_count = sym["function_count"]
|
|
312
|
+
fm.class_count = sym["class_count"]
|
|
313
|
+
fm.symbol_availability = "inferred"
|
|
314
|
+
fm.complexity_availability = "unavailable"
|
|
315
|
+
|
|
316
|
+
elif language == "go":
|
|
317
|
+
sym = self._count_go_symbols(content)
|
|
318
|
+
fm.function_count = sym["function_count"]
|
|
319
|
+
fm.class_count = sym["class_count"]
|
|
320
|
+
fm.symbol_availability = "inferred"
|
|
321
|
+
fm.complexity_availability = "unavailable"
|
|
322
|
+
|
|
323
|
+
elif language == "rust":
|
|
324
|
+
sym = self._count_rust_symbols(content)
|
|
325
|
+
fm.function_count = sym["function_count"]
|
|
326
|
+
fm.class_count = sym["class_count"]
|
|
327
|
+
fm.symbol_availability = "inferred"
|
|
328
|
+
fm.complexity_availability = "unavailable"
|
|
329
|
+
|
|
330
|
+
elif language == "java":
|
|
331
|
+
sym = self._count_java_symbols(content)
|
|
332
|
+
fm.function_count = sym["function_count"]
|
|
333
|
+
fm.class_count = sym["class_count"]
|
|
334
|
+
fm.symbol_availability = "inferred"
|
|
335
|
+
fm.complexity_availability = "unavailable"
|
|
336
|
+
|
|
337
|
+
# All other languages: LOC only, symbols and complexity unavailable
|
|
338
|
+
# (defaults already set to "unavailable" in FileMetrics)
|
|
339
|
+
|
|
340
|
+
return fm
|
|
341
|
+
|
|
342
|
+
# ---------------------------------------------------------------------------
|
|
343
|
+
# LOC counting (all languages — text scan)
|
|
344
|
+
# ---------------------------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
def _count_loc(self, content: str, language: str) -> dict[str, Any]:
|
|
347
|
+
"""Cuenta lineas de codigo, blank y comentario via text scan.
|
|
348
|
+
|
|
349
|
+
Para JS/TS: usa state machine para block comments (/* ... */).
|
|
350
|
+
Para Python y otros: solo detecta comentarios de linea (#).
|
|
351
|
+
Retorna dict con total_lines, blank_lines, comment_lines, code_lines,
|
|
352
|
+
loc_availability.
|
|
353
|
+
"""
|
|
354
|
+
lines = content.splitlines()
|
|
355
|
+
total = len(lines)
|
|
356
|
+
blank = 0
|
|
357
|
+
comment = 0
|
|
358
|
+
|
|
359
|
+
if language in ("javascript", "typescript"):
|
|
360
|
+
in_block = False
|
|
361
|
+
for line in lines:
|
|
362
|
+
stripped = line.strip()
|
|
363
|
+
if not stripped:
|
|
364
|
+
blank += 1
|
|
365
|
+
continue
|
|
366
|
+
if in_block:
|
|
367
|
+
comment += 1
|
|
368
|
+
if "*/" in stripped:
|
|
369
|
+
in_block = False
|
|
370
|
+
continue
|
|
371
|
+
if stripped.startswith("//"):
|
|
372
|
+
comment += 1
|
|
373
|
+
elif stripped.startswith("/*"):
|
|
374
|
+
comment += 1
|
|
375
|
+
if "*/" not in stripped[2:]:
|
|
376
|
+
in_block = True
|
|
377
|
+
elif "/*" in stripped and "*/" not in stripped[stripped.index("/*") + 2:]:
|
|
378
|
+
# inline block comment start on a code line — count as code
|
|
379
|
+
in_block = True
|
|
380
|
+
# else: code line (not counted as comment)
|
|
381
|
+
else:
|
|
382
|
+
# Python, Go, Rust, Java, and others: simple line-comment detection
|
|
383
|
+
comment_prefixes: tuple[str, ...]
|
|
384
|
+
if language == "python":
|
|
385
|
+
comment_prefixes = ("#",)
|
|
386
|
+
elif language in ("go", "rust", "java", "kotlin", "scala", "csharp",
|
|
387
|
+
"cpp", "c", "swift", "php"):
|
|
388
|
+
comment_prefixes = ("//",)
|
|
389
|
+
else:
|
|
390
|
+
comment_prefixes = ("#",) # generic fallback
|
|
391
|
+
|
|
392
|
+
for line in lines:
|
|
393
|
+
stripped = line.strip()
|
|
394
|
+
if not stripped:
|
|
395
|
+
blank += 1
|
|
396
|
+
elif stripped.startswith(comment_prefixes):
|
|
397
|
+
comment += 1
|
|
398
|
+
|
|
399
|
+
code = total - blank - comment
|
|
400
|
+
return {
|
|
401
|
+
"total_lines": total,
|
|
402
|
+
"blank_lines": blank,
|
|
403
|
+
"comment_lines": comment,
|
|
404
|
+
"code_lines": code,
|
|
405
|
+
"loc_availability": "measured",
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
# ---------------------------------------------------------------------------
|
|
409
|
+
# Python symbol + complexity (AST)
|
|
410
|
+
# ---------------------------------------------------------------------------
|
|
411
|
+
|
|
412
|
+
def _count_python_symbols(self, content: str, rel_path: str) -> dict[str, Any]:
|
|
413
|
+
"""Cuenta simbolos Python y calcula complejidad McCabe via ast.parse.
|
|
414
|
+
|
|
415
|
+
Si hay SyntaxError retorna symbol_availability='unavailable' pero
|
|
416
|
+
loc_availability sigue siendo 'measured' (el text scan no necesita AST).
|
|
417
|
+
"""
|
|
418
|
+
try:
|
|
419
|
+
tree = ast.parse(content, filename=rel_path)
|
|
420
|
+
except SyntaxError:
|
|
421
|
+
return {
|
|
422
|
+
"function_count": 0,
|
|
423
|
+
"class_count": 0,
|
|
424
|
+
"symbol_availability": "unavailable",
|
|
425
|
+
"cyclomatic_complexity": None,
|
|
426
|
+
"complexity_availability": "unavailable",
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
func_nodes: list[ast.FunctionDef | ast.AsyncFunctionDef] = []
|
|
430
|
+
class_count = 0
|
|
431
|
+
|
|
432
|
+
for node in ast.walk(tree):
|
|
433
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
434
|
+
func_nodes.append(node)
|
|
435
|
+
elif isinstance(node, ast.ClassDef):
|
|
436
|
+
class_count += 1
|
|
437
|
+
|
|
438
|
+
function_count = len(func_nodes)
|
|
439
|
+
|
|
440
|
+
if func_nodes:
|
|
441
|
+
complexities = [_mccabe(fn) for fn in func_nodes]
|
|
442
|
+
avg_cc = float(sum(complexities)) / len(complexities)
|
|
443
|
+
cyclomatic_complexity: float | None = avg_cc
|
|
444
|
+
complexity_availability = "measured"
|
|
445
|
+
else:
|
|
446
|
+
cyclomatic_complexity = None
|
|
447
|
+
complexity_availability = "unavailable"
|
|
448
|
+
|
|
449
|
+
return {
|
|
450
|
+
"function_count": function_count,
|
|
451
|
+
"class_count": class_count,
|
|
452
|
+
"symbol_availability": "measured",
|
|
453
|
+
"cyclomatic_complexity": cyclomatic_complexity,
|
|
454
|
+
"complexity_availability": complexity_availability,
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
# ---------------------------------------------------------------------------
|
|
458
|
+
# JS/TS symbol counting (regex — inferred)
|
|
459
|
+
# ---------------------------------------------------------------------------
|
|
460
|
+
|
|
461
|
+
def _count_js_symbols(self, content: str) -> dict[str, int]:
|
|
462
|
+
"""Cuenta funciones y clases en JS/TS via regex (inferred)."""
|
|
463
|
+
function_count = len(_JS_FUNC_RE.findall(content))
|
|
464
|
+
class_count = len(_JS_CLASS_RE.findall(content))
|
|
465
|
+
return {"function_count": function_count, "class_count": class_count}
|
|
466
|
+
|
|
467
|
+
# ---------------------------------------------------------------------------
|
|
468
|
+
# Go symbol counting (regex — inferred)
|
|
469
|
+
# ---------------------------------------------------------------------------
|
|
470
|
+
|
|
471
|
+
def _count_go_symbols(self, content: str) -> dict[str, int]:
|
|
472
|
+
"""Cuenta funciones y structs en Go via regex (inferred)."""
|
|
473
|
+
function_count = len(_GO_FUNC_RE.findall(content))
|
|
474
|
+
class_count = len(_GO_STRUCT_RE.findall(content))
|
|
475
|
+
return {"function_count": function_count, "class_count": class_count}
|
|
476
|
+
|
|
477
|
+
# ---------------------------------------------------------------------------
|
|
478
|
+
# Rust symbol counting (regex — inferred)
|
|
479
|
+
# ---------------------------------------------------------------------------
|
|
480
|
+
|
|
481
|
+
def _count_rust_symbols(self, content: str) -> dict[str, int]:
|
|
482
|
+
"""Cuenta funciones y structs en Rust via regex (inferred)."""
|
|
483
|
+
function_count = len(_RUST_FN_RE.findall(content))
|
|
484
|
+
class_count = len(_RUST_STRUCT_RE.findall(content))
|
|
485
|
+
return {"function_count": function_count, "class_count": class_count}
|
|
486
|
+
|
|
487
|
+
# ---------------------------------------------------------------------------
|
|
488
|
+
# Java symbol counting (regex — inferred)
|
|
489
|
+
# ---------------------------------------------------------------------------
|
|
490
|
+
|
|
491
|
+
def _count_java_symbols(self, content: str) -> dict[str, int]:
|
|
492
|
+
"""Cuenta metodos y clases en Java via regex (inferred)."""
|
|
493
|
+
function_count = len(_JAVA_METHOD_RE.findall(content))
|
|
494
|
+
class_count = len(_JAVA_CLASS_RE.findall(content))
|
|
495
|
+
return {"function_count": function_count, "class_count": class_count}
|
sourcecode/schema.py
CHANGED
|
@@ -151,6 +151,62 @@ class ModuleGraph:
|
|
|
151
151
|
|
|
152
152
|
DocsDepth = Literal["module", "symbols", "full"]
|
|
153
153
|
|
|
154
|
+
MetricAvailability = Literal["measured", "inferred", "unavailable"]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass
|
|
158
|
+
class FileMetrics:
|
|
159
|
+
"""Metricas de calidad de un fichero de codigo fuente."""
|
|
160
|
+
|
|
161
|
+
path: str
|
|
162
|
+
language: str
|
|
163
|
+
is_test: bool = False
|
|
164
|
+
production_target: Optional[str] = None
|
|
165
|
+
total_lines: int = 0
|
|
166
|
+
code_lines: int = 0
|
|
167
|
+
blank_lines: int = 0
|
|
168
|
+
comment_lines: int = 0
|
|
169
|
+
loc_availability: MetricAvailability = "unavailable"
|
|
170
|
+
function_count: int = 0
|
|
171
|
+
class_count: int = 0
|
|
172
|
+
symbol_availability: MetricAvailability = "unavailable"
|
|
173
|
+
cyclomatic_complexity: Optional[float] = None
|
|
174
|
+
complexity_availability: MetricAvailability = "unavailable"
|
|
175
|
+
line_rate: Optional[float] = None
|
|
176
|
+
branch_rate: Optional[float] = None
|
|
177
|
+
coverage_source: Optional[str] = None
|
|
178
|
+
coverage_availability: MetricAvailability = "unavailable"
|
|
179
|
+
workspace: Optional[str] = None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass
|
|
183
|
+
class CoverageRecord:
|
|
184
|
+
"""Registro de cobertura de codigo de un fichero de cobertura."""
|
|
185
|
+
|
|
186
|
+
source_file: str
|
|
187
|
+
format: str
|
|
188
|
+
line_rate: Optional[float] = None
|
|
189
|
+
branch_rate: Optional[float] = None
|
|
190
|
+
lines_covered: Optional[int] = None
|
|
191
|
+
lines_valid: Optional[int] = None
|
|
192
|
+
timestamp: Optional[str] = None
|
|
193
|
+
tool_version: Optional[str] = None
|
|
194
|
+
file_count: int = 0
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@dataclass
|
|
198
|
+
class MetricsSummary:
|
|
199
|
+
"""Resumen del analisis de metricas de calidad de codigo."""
|
|
200
|
+
|
|
201
|
+
requested: bool = False
|
|
202
|
+
file_count: int = 0
|
|
203
|
+
test_file_count: int = 0
|
|
204
|
+
languages: list[str] = field(default_factory=list)
|
|
205
|
+
total_loc: int = 0
|
|
206
|
+
coverage_records: list[CoverageRecord] = field(default_factory=list)
|
|
207
|
+
coverage_sources_found: list[str] = field(default_factory=list)
|
|
208
|
+
limitations: list[str] = field(default_factory=list)
|
|
209
|
+
|
|
154
210
|
|
|
155
211
|
@dataclass
|
|
156
212
|
class DocRecord:
|
|
@@ -207,3 +263,6 @@ class SourceMap:
|
|
|
207
263
|
file_paths: list[str] = field(default_factory=list)
|
|
208
264
|
project_summary: Optional[str] = None
|
|
209
265
|
key_dependencies: list[DependencyRecord] = field(default_factory=list)
|
|
266
|
+
# Phase 10: Code Quality Metrics
|
|
267
|
+
file_metrics: list[FileMetrics] = field(default_factory=list)
|
|
268
|
+
metrics_summary: Optional[MetricsSummary] = None
|
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
sourcecode/__init__.py,sha256
|
|
1
|
+
sourcecode/__init__.py,sha256=-BJQU1YX2j0SbQwjuSKaEsMlqnb5__gRa_g_viFJ9-Q,99
|
|
2
2
|
sourcecode/classifier.py,sha256=7ubGTn5vfw9z-9eosLIYDtQUu7d8dvB4XKKDxysUVSM,6910
|
|
3
|
-
sourcecode/cli.py,sha256=
|
|
3
|
+
sourcecode/cli.py,sha256=vmY601Pt3WfO2AEIWjaYdna1MMUrxHS5n6sgc2v8TXM,17928
|
|
4
|
+
sourcecode/coverage_parser.py,sha256=0IfE-0BNF1NxTjRnAuHnn1U0VzcB_aGBQMITR6Qsy3E,19701
|
|
4
5
|
sourcecode/dependency_analyzer.py,sha256=c8tSbUpug-2avKNWCP4-_e2_uhBwWl96LwL1Be8O90M,39067
|
|
5
6
|
sourcecode/doc_analyzer.py,sha256=nnTOsO1tfZvaDPy5PKjRLlg6EQZ-2ONRbXlxDeogH8o,19891
|
|
6
7
|
sourcecode/graph_analyzer.py,sha256=S5z8mrGfLwN26Mt2IaCR4dOpQ_APU16cfihSp640eGQ,45241
|
|
8
|
+
sourcecode/metrics_analyzer.py,sha256=zQrIPKDrGIUq4iptXbckflOj2V9897e_6vGZKEwa4b4,20124
|
|
7
9
|
sourcecode/redactor.py,sha256=abSf5jH_IzYzpjF3lEN6hCwdXxvmyHs-3DHz9fd8Mic,2883
|
|
8
10
|
sourcecode/scanner.py,sha256=TvcuD77m_NF8OlTj3XH1b_jO0qrto8c90fND96ecqb0,6182
|
|
9
|
-
sourcecode/schema.py,sha256=
|
|
11
|
+
sourcecode/schema.py,sha256=y4NAcjVvfH49otBWK5n4AJ-Ke5EsHDXf6IlME3OSoNQ,7926
|
|
10
12
|
sourcecode/serializer.py,sha256=x_F2qIEGpKXrzgBHwkcN9O94It8R_QV02VfMnWdlKD0,3274
|
|
11
13
|
sourcecode/summarizer.py,sha256=mNZL4uIZ_jnOYJt3_XVCqEFn2JuLR4E8LlySB1TsTxc,2567
|
|
12
14
|
sourcecode/tree_utils.py,sha256=5WHQc2L-AGqxKFfYYcQftE6hETxgUxP9MWDuaThcAUw,991
|
|
@@ -30,7 +32,7 @@ sourcecode/detectors/rust.py,sha256=iUz9lE4HfBy8CBaDwGhfh6kLsNIovrH2ay2v3y6qNDc,
|
|
|
30
32
|
sourcecode/detectors/systems.py,sha256=qf5M9tio1H6XDorSstTWIxKe0xVLR_QGTJpFGyhDz4A,1690
|
|
31
33
|
sourcecode/detectors/terraform.py,sha256=5EOKQtTViWo7u3tFK0h45YD8GQPwhrww0cOicxGPPn4,1732
|
|
32
34
|
sourcecode/detectors/tooling.py,sha256=wPvudLCJZ8_cr9GIx44C8jkLAlEN9uTUZcJsP7eVYnQ,1937
|
|
33
|
-
sourcecode-0.
|
|
34
|
-
sourcecode-0.
|
|
35
|
-
sourcecode-0.
|
|
36
|
-
sourcecode-0.
|
|
35
|
+
sourcecode-0.8.0.dist-info/METADATA,sha256=S6jCRU9pdod1JsJ6mBxRmzuT6PuI4JYi_6apx3hzKNc,14903
|
|
36
|
+
sourcecode-0.8.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
37
|
+
sourcecode-0.8.0.dist-info/entry_points.txt,sha256=voDtdlGU2-jR3wCZpQoOls_0eUKaDVCKIfajGHFDATs,50
|
|
38
|
+
sourcecode-0.8.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|