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 CHANGED
@@ -1,3 +1,3 @@
1
1
  """sourcecode — Genera mapas de contexto estructurado para agentes IA."""
2
2
 
3
- __version__ = "0.7.0"
3
+ __version__ = "0.8.0"
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 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
@@ -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,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
@@ -1,12 +1,14 @@
1
- sourcecode/__init__.py,sha256=IWLAZFD5WGstfu_e3HvB2uIh4i20cAuAjW4yX1398_0,99
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=_TRe_bmvH3oZlguLMiUk3AC399Um5fxv-dV0M7WtELs,16133
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=tFoe3k2Xq4t41BvZROTEGRWaPcjmvySfjIv23wLLFgA,6045
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.7.0.dist-info/METADATA,sha256=3I_ObyreSLRy_zK7iBs-TMuho2l6WJ3epPJssH_Xt2E,14903
34
- sourcecode-0.7.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
35
- sourcecode-0.7.0.dist-info/entry_points.txt,sha256=voDtdlGU2-jR3wCZpQoOls_0eUKaDVCKIfajGHFDATs,50
36
- sourcecode-0.7.0.dist-info/RECORD,,
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,,