|
| 1 | +"""Runtime signal processing (e.g., coverage, traces).""" |
| 2 | + |
| 3 | +import xml.etree.ElementTree as ET |
| 4 | +from pathlib import Path |
| 5 | +from typing import Optional |
| 6 | + |
| 7 | +from knowcode.models import ( |
| 8 | + Entity, |
| 9 | + EntityKind, |
| 10 | + Location, |
| 11 | + ParseResult, |
| 12 | + Relationship, |
| 13 | + RelationshipKind, |
| 14 | +) |
| 15 | + |
| 16 | + |
| 17 | +class CoverageProcessor: |
| 18 | + """Process coverage reports.""" |
| 19 | + |
| 20 | + def __init__(self, root_dir: str | Path) -> None: |
| 21 | + """Initialize coverage processor. |
| 22 | +
|
| 23 | + Args: |
| 24 | + root_dir: Root directory of the codebase (for relative path resolution). |
| 25 | + """ |
| 26 | + self.root_dir = Path(root_dir).resolve() |
| 27 | + |
| 28 | + def process_cobertura(self, xml_path: str | Path) -> ParseResult: |
| 29 | + """Process a Cobertura XML coverage report. |
| 30 | +
|
| 31 | + Args: |
| 32 | + xml_path: Path to coverage.xml. |
| 33 | +
|
| 34 | + Returns: |
| 35 | + ParseResult containing COVERAGE_REPORT entity and COVERS relationships. |
| 36 | + """ |
| 37 | + xml_path = Path(xml_path) |
| 38 | + if not xml_path.exists(): |
| 39 | + return ParseResult( |
| 40 | + file_path=str(xml_path), |
| 41 | + entities=[], |
| 42 | + relationships=[], |
| 43 | + errors=[f"Coverage file not found: {xml_path}"], |
| 44 | + ) |
| 45 | + |
| 46 | + entities: list[Entity] = [] |
| 47 | + relationships: list[Relationship] = [] |
| 48 | + errors: list[str] = [] |
| 49 | + |
| 50 | + try: |
| 51 | + tree = ET.parse(xml_path) |
| 52 | + root = tree.getroot() |
| 53 | + |
| 54 | + # Create Report Entity |
| 55 | + report_id = f"coverage::{xml_path.name}" |
| 56 | + # Extract timestamp if available available in root attributes usually 'timestamp' |
| 57 | + timestamp = root.get("timestamp", str(xml_path.stat().st_mtime)) |
| 58 | + |
| 59 | + report_entity = Entity( |
| 60 | + id=report_id, |
| 61 | + kind=EntityKind.COVERAGE_REPORT, |
| 62 | + name=f"Coverage Report ({xml_path.name})", |
| 63 | + qualified_name=xml_path.name, |
| 64 | + location=Location(str(xml_path), 0, 0), |
| 65 | + metadata={ |
| 66 | + "timestamp": timestamp, |
| 67 | + "line-rate": root.get("line-rate", "0"), |
| 68 | + "branch-rate": root.get("branch-rate", "0"), |
| 69 | + }, |
| 70 | + ) |
| 71 | + entities.append(report_entity) |
| 72 | + |
| 73 | + # Traverse packages -> classes -> lines |
| 74 | + # Structure: coverage -> packages -> package -> classes -> class -> lines -> line |
| 75 | + |
| 76 | + # We want to map files/classes to the report. |
| 77 | + # <class name="knowcode.models" filename="src/knowcode/models.py" line-rate="1.0" ...> |
| 78 | + |
| 79 | + for cls in root.findall(".//class"): |
| 80 | + filename = cls.get("filename") |
| 81 | + if not filename: |
| 82 | + continue |
| 83 | + |
| 84 | + # Resolve file path to simple module ID |
| 85 | + # We assume standard module ID: /abs/path/to/file::filename_stem |
| 86 | + # filename in coverage.xml is usually relative to root |
| 87 | + abs_file_path = (self.root_dir / filename).resolve() |
| 88 | + module_name = abs_file_path.stem |
| 89 | + module_id = f"{abs_file_path}::{module_name}" |
| 90 | + |
| 91 | + line_rate = cls.get("line-rate", "0") |
| 92 | + |
| 93 | + # Relationship: REPORT -> COVERS -> MODULE |
| 94 | + relationships.append( |
| 95 | + Relationship( |
| 96 | + source_id=report_id, |
| 97 | + target_id=module_id, |
| 98 | + kind=RelationshipKind.COVERS, |
| 99 | + metadata={ |
| 100 | + "line-rate": line_rate, |
| 101 | + "hits": cls.get("lines-covered", "0") + "/" + cls.get("lines-valid", "0") |
| 102 | + } |
| 103 | + ) |
| 104 | + ) |
| 105 | + |
| 106 | + # We could map specific lines to entities if we had line ranges of entities loaded. |
| 107 | + # Since CoverageProcessor runs independently or after graph build, |
| 108 | + # we usually just link to the File/Module level for MVP. |
| 109 | + # Detailed line mapping requires access to the full graph to find which entity covers line X. |
| 110 | + # For v1.4 MVP, linking to Module is sufficient. |
| 111 | + |
| 112 | + # Note: We can also add "EXECUTED_BY" from Module to Report |
| 113 | + relationships.append( |
| 114 | + Relationship( |
| 115 | + source_id=module_id, |
| 116 | + target_id=report_id, |
| 117 | + kind=RelationshipKind.EXECUTED_BY |
| 118 | + ) |
| 119 | + ) |
| 120 | + |
| 121 | + except ET.ParseError as e: |
| 122 | + errors.append(f"Invalid XML format: {e}") |
| 123 | + except Exception as e: |
| 124 | + errors.append(f"Error processing coverage: {e}") |
| 125 | + |
| 126 | + return ParseResult( |
| 127 | + file_path=str(xml_path), |
| 128 | + entities=entities, |
| 129 | + relationships=relationships, |
| 130 | + errors=errors, |
| 131 | + ) |
0 commit comments