sourcecode 0.7.0__tar.gz → 0.8.0__tar.gz

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.
Files changed (92) hide show
  1. {sourcecode-0.7.0 → sourcecode-0.8.0}/PKG-INFO +1 -1
  2. {sourcecode-0.7.0 → sourcecode-0.8.0}/docs/schema.md +2 -2
  3. {sourcecode-0.7.0 → sourcecode-0.8.0}/pyproject.toml +1 -1
  4. {sourcecode-0.7.0 → sourcecode-0.8.0}/src/sourcecode/__init__.py +1 -1
  5. {sourcecode-0.7.0 → sourcecode-0.8.0}/src/sourcecode/cli.py +53 -1
  6. sourcecode-0.8.0/src/sourcecode/coverage_parser.py +539 -0
  7. sourcecode-0.8.0/src/sourcecode/metrics_analyzer.py +495 -0
  8. {sourcecode-0.7.0 → sourcecode-0.8.0}/src/sourcecode/schema.py +59 -0
  9. sourcecode-0.8.0/tests/fixtures/coverage.xml +15 -0
  10. sourcecode-0.8.0/tests/fixtures/jacoco.xml +14 -0
  11. sourcecode-0.8.0/tests/fixtures/lcov.info +16 -0
  12. sourcecode-0.8.0/tests/test_coverage_parser.py +182 -0
  13. sourcecode-0.8.0/tests/test_integration_metrics.py +119 -0
  14. sourcecode-0.8.0/tests/test_metrics_analyzer.py +456 -0
  15. {sourcecode-0.7.0 → sourcecode-0.8.0}/.gitignore +0 -0
  16. {sourcecode-0.7.0 → sourcecode-0.8.0}/.ruff.toml +0 -0
  17. {sourcecode-0.7.0 → sourcecode-0.8.0}/README.md +0 -0
  18. {sourcecode-0.7.0 → sourcecode-0.8.0}/src/sourcecode/classifier.py +0 -0
  19. {sourcecode-0.7.0 → sourcecode-0.8.0}/src/sourcecode/dependency_analyzer.py +0 -0
  20. {sourcecode-0.7.0 → sourcecode-0.8.0}/src/sourcecode/detectors/__init__.py +0 -0
  21. {sourcecode-0.7.0 → sourcecode-0.8.0}/src/sourcecode/detectors/base.py +0 -0
  22. {sourcecode-0.7.0 → sourcecode-0.8.0}/src/sourcecode/detectors/dart.py +0 -0
  23. {sourcecode-0.7.0 → sourcecode-0.8.0}/src/sourcecode/detectors/dotnet.py +0 -0
  24. {sourcecode-0.7.0 → sourcecode-0.8.0}/src/sourcecode/detectors/elixir.py +0 -0
  25. {sourcecode-0.7.0 → sourcecode-0.8.0}/src/sourcecode/detectors/go.py +0 -0
  26. {sourcecode-0.7.0 → sourcecode-0.8.0}/src/sourcecode/detectors/heuristic.py +0 -0
  27. {sourcecode-0.7.0 → sourcecode-0.8.0}/src/sourcecode/detectors/java.py +0 -0
  28. {sourcecode-0.7.0 → sourcecode-0.8.0}/src/sourcecode/detectors/jvm_ext.py +0 -0
  29. {sourcecode-0.7.0 → sourcecode-0.8.0}/src/sourcecode/detectors/nodejs.py +0 -0
  30. {sourcecode-0.7.0 → sourcecode-0.8.0}/src/sourcecode/detectors/parsers.py +0 -0
  31. {sourcecode-0.7.0 → sourcecode-0.8.0}/src/sourcecode/detectors/php.py +0 -0
  32. {sourcecode-0.7.0 → sourcecode-0.8.0}/src/sourcecode/detectors/project.py +0 -0
  33. {sourcecode-0.7.0 → sourcecode-0.8.0}/src/sourcecode/detectors/python.py +0 -0
  34. {sourcecode-0.7.0 → sourcecode-0.8.0}/src/sourcecode/detectors/ruby.py +0 -0
  35. {sourcecode-0.7.0 → sourcecode-0.8.0}/src/sourcecode/detectors/rust.py +0 -0
  36. {sourcecode-0.7.0 → sourcecode-0.8.0}/src/sourcecode/detectors/systems.py +0 -0
  37. {sourcecode-0.7.0 → sourcecode-0.8.0}/src/sourcecode/detectors/terraform.py +0 -0
  38. {sourcecode-0.7.0 → sourcecode-0.8.0}/src/sourcecode/detectors/tooling.py +0 -0
  39. {sourcecode-0.7.0 → sourcecode-0.8.0}/src/sourcecode/doc_analyzer.py +0 -0
  40. {sourcecode-0.7.0 → sourcecode-0.8.0}/src/sourcecode/graph_analyzer.py +0 -0
  41. {sourcecode-0.7.0 → sourcecode-0.8.0}/src/sourcecode/redactor.py +0 -0
  42. {sourcecode-0.7.0 → sourcecode-0.8.0}/src/sourcecode/scanner.py +0 -0
  43. {sourcecode-0.7.0 → sourcecode-0.8.0}/src/sourcecode/serializer.py +0 -0
  44. {sourcecode-0.7.0 → sourcecode-0.8.0}/src/sourcecode/summarizer.py +0 -0
  45. {sourcecode-0.7.0 → sourcecode-0.8.0}/src/sourcecode/tree_utils.py +0 -0
  46. {sourcecode-0.7.0 → sourcecode-0.8.0}/src/sourcecode/workspace.py +0 -0
  47. {sourcecode-0.7.0 → sourcecode-0.8.0}/tests/__init__.py +0 -0
  48. {sourcecode-0.7.0 → sourcecode-0.8.0}/tests/conftest.py +0 -0
  49. {sourcecode-0.7.0 → sourcecode-0.8.0}/tests/fixtures/fastapi_app/pyproject.toml +0 -0
  50. {sourcecode-0.7.0 → sourcecode-0.8.0}/tests/fixtures/fastapi_app/src/main.py +0 -0
  51. {sourcecode-0.7.0 → sourcecode-0.8.0}/tests/fixtures/go_service/cmd/api/main.go +0 -0
  52. {sourcecode-0.7.0 → sourcecode-0.8.0}/tests/fixtures/go_service/go.mod +0 -0
  53. {sourcecode-0.7.0 → sourcecode-0.8.0}/tests/fixtures/nextjs_app/app/page.tsx +0 -0
  54. {sourcecode-0.7.0 → sourcecode-0.8.0}/tests/fixtures/nextjs_app/package.json +0 -0
  55. {sourcecode-0.7.0 → sourcecode-0.8.0}/tests/fixtures/nextjs_app/pnpm-lock.yaml +0 -0
  56. {sourcecode-0.7.0 → sourcecode-0.8.0}/tests/fixtures/pnpm_monorepo/apps/web/app/page.tsx +0 -0
  57. {sourcecode-0.7.0 → sourcecode-0.8.0}/tests/fixtures/pnpm_monorepo/apps/web/package.json +0 -0
  58. {sourcecode-0.7.0 → sourcecode-0.8.0}/tests/fixtures/pnpm_monorepo/packages/api/main.py +0 -0
  59. {sourcecode-0.7.0 → sourcecode-0.8.0}/tests/fixtures/pnpm_monorepo/packages/api/pyproject.toml +0 -0
  60. {sourcecode-0.7.0 → sourcecode-0.8.0}/tests/fixtures/pnpm_monorepo/pnpm-workspace.yaml +0 -0
  61. {sourcecode-0.7.0 → sourcecode-0.8.0}/tests/test_classifier.py +0 -0
  62. {sourcecode-0.7.0 → sourcecode-0.8.0}/tests/test_cli.py +0 -0
  63. {sourcecode-0.7.0 → sourcecode-0.8.0}/tests/test_dependency_analyzer_node_python.py +0 -0
  64. {sourcecode-0.7.0 → sourcecode-0.8.0}/tests/test_dependency_analyzer_polyglot.py +0 -0
  65. {sourcecode-0.7.0 → sourcecode-0.8.0}/tests/test_dependency_schema.py +0 -0
  66. {sourcecode-0.7.0 → sourcecode-0.8.0}/tests/test_detector_go_rust_java.py +0 -0
  67. {sourcecode-0.7.0 → sourcecode-0.8.0}/tests/test_detector_nodejs.py +0 -0
  68. {sourcecode-0.7.0 → sourcecode-0.8.0}/tests/test_detector_php_ruby_dart.py +0 -0
  69. {sourcecode-0.7.0 → sourcecode-0.8.0}/tests/test_detector_python.py +0 -0
  70. {sourcecode-0.7.0 → sourcecode-0.8.0}/tests/test_detector_universal_managed.py +0 -0
  71. {sourcecode-0.7.0 → sourcecode-0.8.0}/tests/test_detector_universal_systems.py +0 -0
  72. {sourcecode-0.7.0 → sourcecode-0.8.0}/tests/test_detectors_base.py +0 -0
  73. {sourcecode-0.7.0 → sourcecode-0.8.0}/tests/test_doc_analyzer_jsdom.py +0 -0
  74. {sourcecode-0.7.0 → sourcecode-0.8.0}/tests/test_doc_analyzer_python.py +0 -0
  75. {sourcecode-0.7.0 → sourcecode-0.8.0}/tests/test_graph_analyzer_polyglot.py +0 -0
  76. {sourcecode-0.7.0 → sourcecode-0.8.0}/tests/test_graph_analyzer_python_node.py +0 -0
  77. {sourcecode-0.7.0 → sourcecode-0.8.0}/tests/test_graph_schema.py +0 -0
  78. {sourcecode-0.7.0 → sourcecode-0.8.0}/tests/test_integration.py +0 -0
  79. {sourcecode-0.7.0 → sourcecode-0.8.0}/tests/test_integration_dependencies.py +0 -0
  80. {sourcecode-0.7.0 → sourcecode-0.8.0}/tests/test_integration_detection.py +0 -0
  81. {sourcecode-0.7.0 → sourcecode-0.8.0}/tests/test_integration_docs.py +0 -0
  82. {sourcecode-0.7.0 → sourcecode-0.8.0}/tests/test_integration_graph_modules.py +0 -0
  83. {sourcecode-0.7.0 → sourcecode-0.8.0}/tests/test_integration_lqn.py +0 -0
  84. {sourcecode-0.7.0 → sourcecode-0.8.0}/tests/test_integration_multistack.py +0 -0
  85. {sourcecode-0.7.0 → sourcecode-0.8.0}/tests/test_integration_universal.py +0 -0
  86. {sourcecode-0.7.0 → sourcecode-0.8.0}/tests/test_packaging.py +0 -0
  87. {sourcecode-0.7.0 → sourcecode-0.8.0}/tests/test_real_projects.py +0 -0
  88. {sourcecode-0.7.0 → sourcecode-0.8.0}/tests/test_redactor.py +0 -0
  89. {sourcecode-0.7.0 → sourcecode-0.8.0}/tests/test_scanner.py +0 -0
  90. {sourcecode-0.7.0 → sourcecode-0.8.0}/tests/test_schema.py +0 -0
  91. {sourcecode-0.7.0 → sourcecode-0.8.0}/tests/test_summarizer.py +0 -0
  92. {sourcecode-0.7.0 → sourcecode-0.8.0}/tests/test_workspace_analyzer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: Genera un mapa de contexto estructurado de proyectos de software para agentes IA
5
5
  License: MIT
6
6
  Requires-Python: >=3.9
@@ -61,7 +61,7 @@ Campos:
61
61
  {
62
62
  "schema_version": "1.0",
63
63
  "generated_at": "2026-04-07T19:41:05.686277+00:00",
64
- "sourcecode_version": "0.7.0",
64
+ "sourcecode_version": "0.8.0",
65
65
  "analyzed_path": "/abs/path/to/project"
66
66
  }
67
67
  ```
@@ -582,7 +582,7 @@ Ejemplo de salida para un monorepo con web Node.js y API Python con `--dependenc
582
582
  "metadata": {
583
583
  "schema_version": "1.0",
584
584
  "generated_at": "2026-04-07T19:41:05.686277+00:00",
585
- "sourcecode_version": "0.7.0",
585
+ "sourcecode_version": "0.8.0",
586
586
  "analyzed_path": "/abs/path/to/project"
587
587
  },
588
588
  "file_tree": {
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sourcecode"
7
- version = "0.7.0"
7
+ version = "0.8.0"
8
8
  description = "Genera un mapa de contexto estructurado de proyectos de software para agentes IA"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -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.8.0"
@@ -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 AnalysisMetadata, DocRecord, DocSummary, DocsDepth, EntryPoint, SourceMap, StackDetection
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