619 lines
23 KiB
Python
619 lines
23 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
uos_covexport.py — Generate coverage reports from .umdb databases.
|
|
|
|
Supports txt, csv, xml, and html formats. HTML includes a summary dashboard,
|
|
per-file tables, and source-level coverage view.
|
|
|
|
CLI: uos_covexport.py project.umdb --fmt {txt,csv,xml,html} -o output
|
|
|
|
Uses libuosipoint for .umdb I/O and data model.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import csv
|
|
import io
|
|
import sys
|
|
import xml.etree.ElementTree as ET
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from libuosipoint import (
|
|
open_umdb,
|
|
KIND_STMT,
|
|
KIND_DECISION,
|
|
KIND_CALL,
|
|
KIND_FUNCTION,
|
|
)
|
|
|
|
|
|
# ─── Data collection ───────────────────────────────────────────────────────────
|
|
|
|
@dataclass
|
|
class IpointInfo:
|
|
"""Aggregated info about an ipoint across datasets."""
|
|
id: int
|
|
kind: str
|
|
line: int
|
|
col: int
|
|
n_cond: int
|
|
function_name: str = ""
|
|
function_line: int = 0
|
|
file_path: str = ""
|
|
hit_counts: dict = field(default_factory=dict) # dataset_id -> count
|
|
mcdc_vectors: dict = field(default_factory=dict) # dataset_id -> vector_bits
|
|
|
|
|
|
@dataclass
|
|
class FileInfo:
|
|
"""Aggregated info about a file across datasets."""
|
|
path: str
|
|
md5: str = ""
|
|
ipoints: list[IpointInfo] = field(default_factory=list)
|
|
functions: list[str] = field(default_factory=list)
|
|
|
|
|
|
@dataclass
|
|
class DatasetInfo:
|
|
"""Dataset metadata."""
|
|
id: int
|
|
name: str
|
|
build_id: str = ""
|
|
created: str = ""
|
|
|
|
|
|
@dataclass
|
|
class ReportData:
|
|
"""Complete report data."""
|
|
datasets: list[DatasetInfo] = field(default_factory=list)
|
|
files: list[FileInfo] = field(default_factory=list)
|
|
total_ipoints: int = 0
|
|
total_stmt: int = 0
|
|
total_decision: int = 0
|
|
total_call: int = 0
|
|
total_function: int = 0
|
|
|
|
|
|
def collect_report_data(conn, dataset_ids: list[int] | None = None) -> ReportData:
|
|
"""Collect all report data from the .umdb.
|
|
|
|
If dataset_ids is None, uses all datasets.
|
|
"""
|
|
cur = conn.cursor()
|
|
data = ReportData()
|
|
|
|
# Get datasets
|
|
if dataset_ids:
|
|
placeholders = ",".join("?" * len(dataset_ids))
|
|
rows = cur.execute(
|
|
f"SELECT id, name, build_id, created FROM datasets WHERE id IN ({placeholders})",
|
|
dataset_ids,
|
|
).fetchall()
|
|
else:
|
|
rows = cur.execute("SELECT id, name, build_id, created FROM datasets").fetchall()
|
|
|
|
for row in rows:
|
|
data.datasets.append(DatasetInfo(
|
|
id=row["id"],
|
|
name=row["name"],
|
|
build_id=row["build_id"] or "",
|
|
created=row["created"] or "",
|
|
))
|
|
|
|
# Collect ipoints with hit data
|
|
ipoint_rows = cur.execute("""
|
|
SELECT ip.id, ip.kind, ip.line, ip.col, ip.n_cond,
|
|
f.name AS func_name, f.line AS func_line,
|
|
files.path AS file_path, files.md5 AS file_md5
|
|
FROM ipoints ip
|
|
JOIN functions f ON ip.function_id = f.id
|
|
JOIN files ON f.file_id = files.id
|
|
ORDER BY files.path, f.line, ip.line, ip.id
|
|
""").fetchall()
|
|
|
|
# Build file -> function -> ipoint tree
|
|
file_map: dict[str, FileInfo] = {}
|
|
|
|
for row in ipoint_rows:
|
|
file_path = row["file_path"]
|
|
if file_path not in file_map:
|
|
file_map[file_path] = FileInfo(path=file_path, md5=row["file_md5"] or "")
|
|
|
|
fi = file_map[file_path]
|
|
if row["func_name"] and row["func_name"] not in fi.functions:
|
|
fi.functions.append(row["func_name"])
|
|
|
|
ip = IpointInfo(
|
|
id=row["id"],
|
|
kind=row["kind"],
|
|
line=row["line"],
|
|
col=row["col"] or 0,
|
|
n_cond=row["n_cond"] or 0,
|
|
function_name=row["func_name"],
|
|
function_line=row["func_line"] or 0,
|
|
file_path=file_path,
|
|
)
|
|
|
|
# Get hit counts for this ipoint across datasets
|
|
hit_rows = cur.execute(
|
|
"SELECT dataset_id, count FROM hits WHERE ipoint_id = ?",
|
|
(row["id"],),
|
|
).fetchall()
|
|
for h in hit_rows:
|
|
ip.hit_counts[h["dataset_id"]] = h["count"]
|
|
|
|
# Get MC/DC vectors
|
|
mcdc_rows = cur.execute(
|
|
"SELECT dataset_id, vector_bits FROM mcdc_hits WHERE decision_id = ?",
|
|
(row["id"],),
|
|
).fetchall()
|
|
for m in mcdc_rows:
|
|
ip.mcdc_vectors[m["dataset_id"]] = m["vector_bits"]
|
|
|
|
fi.ipoints.append(ip)
|
|
|
|
data.files = sorted(file_map.values(), key=lambda f: f.path)
|
|
|
|
# Totals
|
|
for fi in data.files:
|
|
for ip in fi.ipoints:
|
|
data.total_ipoints += 1
|
|
if ip.kind == KIND_STMT:
|
|
data.total_stmt += 1
|
|
elif ip.kind == KIND_DECISION:
|
|
data.total_decision += 1
|
|
elif ip.kind == KIND_CALL:
|
|
data.total_call += 1
|
|
elif ip.kind == KIND_FUNCTION:
|
|
data.total_function += 1
|
|
|
|
return data
|
|
|
|
|
|
# ─── Text report ───────────────────────────────────────────────────────────────
|
|
|
|
def format_txt(data: ReportData) -> str:
|
|
"""Generate a plain text report."""
|
|
lines = []
|
|
lines.append("UniversalisOS Coverage Report")
|
|
lines.append("=" * 40)
|
|
lines.append("")
|
|
|
|
# Dataset summary
|
|
lines.append("Datasets:")
|
|
for ds in data.datasets:
|
|
lines.append(f" {ds.name} (build: {ds.build_id}, created: {ds.created})")
|
|
lines.append("")
|
|
|
|
# Overall summary
|
|
lines.append(f"Total ipoints: {data.total_ipoints}")
|
|
lines.append(f" Statements: {data.total_stmt}")
|
|
lines.append(f" Decisions: {data.total_decision}")
|
|
lines.append(f" Calls: {data.total_call}")
|
|
lines.append(f" Functions: {data.total_function}")
|
|
lines.append("")
|
|
|
|
# Per-file summary
|
|
for fi in data.files:
|
|
lines.append(f"File: {fi.path}")
|
|
lines.append("-" * 40)
|
|
|
|
# Group by function
|
|
func_groups: dict[str, list[IpointInfo]] = {}
|
|
for ip in fi.ipoints:
|
|
key = f"{ip.function_name}@{ip.function_line}"
|
|
func_groups.setdefault(key, []).append(ip)
|
|
|
|
for func_key, ips in func_groups.items():
|
|
func_name = ips[0].function_name
|
|
lines.append(f" Function: {func_name}")
|
|
|
|
for ip in ips:
|
|
hit_strs = []
|
|
for ds in data.datasets:
|
|
count = ip.hit_counts.get(ds.id, 0)
|
|
hit_strs.append(f"{ds.name}={count}")
|
|
|
|
kind_str = ip.kind.upper()
|
|
cond_str = f" [{ip.n_cond} cond]" if ip.n_cond else ""
|
|
lines.append(
|
|
f" L{ip.line}: {kind_str}{cond_str} "
|
|
f"id={ip.id} hits: {', '.join(hit_strs)}"
|
|
)
|
|
|
|
# MC/DC vectors
|
|
for ds in data.datasets:
|
|
vec = ip.mcdc_vectors.get(ds.id, 0)
|
|
if vec:
|
|
lines.append(f" MC/DC vector ({ds.name}): 0x{vec:08x}")
|
|
|
|
lines.append("")
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
# ─── CSV report ────────────────────────────────────────────────────────────────
|
|
|
|
def format_csv(data: ReportData) -> str:
|
|
"""Generate a CSV report."""
|
|
buf = io.StringIO()
|
|
writer = csv.writer(buf)
|
|
|
|
# Header
|
|
header = ["file", "function", "line", "kind", "id", "n_cond"]
|
|
for ds in data.datasets:
|
|
header.append(f"hit:{ds.name}")
|
|
header.append(f"mcdc:{ds.name}")
|
|
writer.writerow(header)
|
|
|
|
# Rows
|
|
for fi in data.files:
|
|
for ip in fi.ipoints:
|
|
row = [
|
|
fi.path,
|
|
ip.function_name,
|
|
ip.line,
|
|
ip.kind,
|
|
ip.id,
|
|
ip.n_cond,
|
|
]
|
|
for ds in data.datasets:
|
|
row.append(ip.hit_counts.get(ds.id, 0))
|
|
vec = ip.mcdc_vectors.get(ds.id, 0)
|
|
row.append(f"0x{vec:08x}" if vec else "")
|
|
writer.writerow(row)
|
|
|
|
return buf.getvalue()
|
|
|
|
|
|
# ─── XML report ────────────────────────────────────────────────────────────────
|
|
|
|
def format_xml(data: ReportData) -> str:
|
|
"""Generate an XML report."""
|
|
root = ET.Element("uos-coverage")
|
|
root.set("version", "1.0")
|
|
|
|
# Datasets
|
|
ds_elem = ET.SubElement(root, "datasets")
|
|
for ds in data.datasets:
|
|
e = ET.SubElement(ds_elem, "dataset")
|
|
e.set("id", str(ds.id))
|
|
e.set("name", ds.name)
|
|
e.set("build_id", ds.build_id)
|
|
e.set("created", ds.created)
|
|
|
|
# Summary
|
|
summary = ET.SubElement(root, "summary")
|
|
summary.set("total_ipoints", str(data.total_ipoints))
|
|
summary.set("statements", str(data.total_stmt))
|
|
summary.set("decisions", str(data.total_decision))
|
|
summary.set("calls", str(data.total_call))
|
|
summary.set("functions", str(data.total_function))
|
|
|
|
# Files
|
|
files_elem = ET.SubElement(root, "files")
|
|
for fi in data.files:
|
|
fe = ET.SubElement(files_elem, "file")
|
|
fe.set("path", fi.path)
|
|
fe.set("md5", fi.md5)
|
|
|
|
for ip in fi.ipoints:
|
|
ie = ET.SubElement(fe, "ipoint")
|
|
ie.set("id", str(ip.id))
|
|
ie.set("kind", ip.kind)
|
|
ie.set("line", str(ip.line))
|
|
ie.set("col", str(ip.col))
|
|
ie.set("function", ip.function_name)
|
|
if ip.n_cond:
|
|
ie.set("n_cond", str(ip.n_cond))
|
|
|
|
for ds in data.datasets:
|
|
count = ip.hit_counts.get(ds.id, 0)
|
|
if count:
|
|
he = ET.SubElement(ie, "hit")
|
|
he.set("dataset", ds.name)
|
|
he.set("count", str(count))
|
|
|
|
vec = ip.mcdc_vectors.get(ds.id, 0)
|
|
if vec:
|
|
me = ET.SubElement(ie, "mcdc")
|
|
me.set("dataset", ds.name)
|
|
me.set("vector", f"0x{vec:08x}")
|
|
|
|
ET.indent(root)
|
|
return ET.tostring(root, encoding="unicode", xml_declaration=True) + "\n"
|
|
|
|
|
|
# ─── HTML report ───────────────────────────────────────────────────────────────
|
|
|
|
def format_html(data: ReportData) -> str:
|
|
"""Generate a self-contained HTML coverage report with dashboard, per-file tables, and source view."""
|
|
# Compute per-dataset coverage stats
|
|
ds_stats = {}
|
|
for ds in data.datasets:
|
|
stmt_covered = 0
|
|
dec_covered = 0
|
|
call_covered = 0
|
|
func_covered = 0
|
|
for fi in data.files:
|
|
for ip in fi.ipoints:
|
|
count = ip.hit_counts.get(ds.id, 0)
|
|
if count > 0:
|
|
if ip.kind == KIND_STMT:
|
|
stmt_covered += 1
|
|
elif ip.kind == KIND_DECISION:
|
|
dec_covered += 1
|
|
elif ip.kind == KIND_CALL:
|
|
call_covered += 1
|
|
elif ip.kind == KIND_FUNCTION:
|
|
func_covered += 1
|
|
ds_stats[ds.id] = {
|
|
"stmt_pct": (stmt_covered / data.total_stmt * 100) if data.total_stmt else 0,
|
|
"dec_pct": (dec_covered / data.total_decision * 100) if data.total_decision else 0,
|
|
"call_pct": (call_covered / data.total_call * 100) if data.total_call else 0,
|
|
"func_pct": (func_covered / data.total_function * 100) if data.total_function else 0,
|
|
"stmt_covered": stmt_covered,
|
|
"dec_covered": dec_covered,
|
|
"call_covered": call_covered,
|
|
"func_covered": func_covered,
|
|
}
|
|
|
|
html = []
|
|
html.append("""<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>UniversalisOS Coverage Report</title>
|
|
<style>
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #1a1a2e; background: #f0f2f5; line-height: 1.5; }
|
|
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
|
|
h1 { font-size: 24px; margin-bottom: 8px; }
|
|
h2 { font-size: 18px; margin: 24px 0 12px; color: #16213e; }
|
|
h3 { font-size: 15px; margin: 12px 0 8px; color: #0f3460; }
|
|
.header { background: linear-gradient(135deg, #0f3460, #16213e); color: white; padding: 24px; border-radius: 8px; margin-bottom: 20px; }
|
|
.header h1 { font-size: 28px; }
|
|
.header p { opacity: 0.85; margin-top: 4px; }
|
|
.dashboard { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }
|
|
.metric-card { background: white; border-radius: 8px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
|
.metric-card .label { font-size: 12px; text-transform: uppercase; color: #666; letter-spacing: 0.5px; }
|
|
.metric-card .value { font-size: 32px; font-weight: 700; margin: 4px 0; }
|
|
.metric-card .pct { font-size: 14px; color: #888; }
|
|
.metric-card .bar { height: 6px; background: #e9ecef; border-radius: 3px; margin-top: 8px; overflow: hidden; }
|
|
.metric-card .bar-fill { height: 100%; border-radius: 3px; transition: width 0.3s; }
|
|
.pct-high { color: #27ae60; }
|
|
.pct-mid { color: #f39c12; }
|
|
.pct-low { color: #e74c3c; }
|
|
table { width: 100%; border-collapse: collapse; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 16px; }
|
|
th { background: #16213e; color: white; padding: 10px 12px; text-align: left; font-size: 13px; font-weight: 600; }
|
|
td { padding: 8px 12px; border-bottom: 1px solid #eee; font-size: 13px; }
|
|
tr:hover td { background: #f8f9fa; }
|
|
.hit { color: #27ae60; font-weight: 600; }
|
|
.miss { color: #e74c3c; }
|
|
.source-view { background: white; border-radius: 8px; padding: 16px; overflow-x: auto; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 16px; }
|
|
.source-view pre { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 13px; line-height: 1.6; }
|
|
.src-line { display: flex; }
|
|
.src-linenum { min-width: 50px; text-align: right; padding-right: 12px; color: #999; user-select: none; }
|
|
.src-code { flex: 1; white-space: pre; }
|
|
.src-hit { background: #d4edda; }
|
|
.src-miss { background: #f8d7da; }
|
|
.tag { display: inline-block; padding: 2px 6px; border-radius: 3px; font-size: 11px; font-weight: 600; }
|
|
.tag-stmt { background: #e3f2fd; color: #1565c0; }
|
|
.tag-dec { background: #fff3e0; color: #e65100; }
|
|
.tag-call { background: #e8f5e9; color: #2e7d32; }
|
|
.tag-func { background: #f3e5f5; color: #6a1b9a; }
|
|
折叠区 { margin-bottom: 8px; }
|
|
.collapsible { cursor: pointer; user-select: none; }
|
|
.collapsible::before { content: "▸ "; }
|
|
.collapsible.open::before { content: "▾ "; }
|
|
.dataset-tabs { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
|
|
.dataset-tab { padding: 6px 14px; border-radius: 4px; cursor: pointer; border: 1px solid #ddd; background: white; font-size: 13px; }
|
|
.dataset-tab.active { background: #0f3460; color: white; border-color: #0f3460; }
|
|
footer { text-align: center; padding: 20px; color: #999; font-size: 12px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
""")
|
|
|
|
# Header
|
|
html.append('<div class="container">')
|
|
html.append('<div class="header">')
|
|
html.append('<h1>UniversalisOS Coverage Report</h1>')
|
|
if data.datasets:
|
|
ds_names = ", ".join(ds.name for ds in data.datasets)
|
|
html.append(f'<p>Datasets: {ds_names}</p>')
|
|
html.append('</div>')
|
|
|
|
# Dashboard
|
|
html.append('<h2>Coverage Summary</h2>')
|
|
html.append('<div class="dashboard">')
|
|
|
|
metrics = [
|
|
("Statements", "stmt", data.total_stmt),
|
|
("Decisions", "dec", data.total_decision),
|
|
("Calls", "call", data.total_call),
|
|
("Functions", "func", data.total_function),
|
|
]
|
|
|
|
for label, key, total in metrics:
|
|
# Use first dataset for dashboard
|
|
ds_id = data.datasets[0].id if data.datasets else None
|
|
pct = ds_stats.get(ds_id, {}).get(f"{key}_pct", 0) if ds_id else 0
|
|
covered = ds_stats.get(ds_id, {}).get(f"{key}_covered", 0) if ds_id else 0
|
|
pct_class = "pct-high" if pct >= 80 else ("pct-mid" if pct >= 50 else "pct-low")
|
|
bar_color = "#27ae60" if pct >= 80 else ("#f39c12" if pct >= 50 else "#e74c3c")
|
|
|
|
html.append(f'''<div class="metric-card">
|
|
<div class="label">{label}</div>
|
|
<div class="value {pct_class}">{pct:.1f}%</div>
|
|
<div class="pct">{covered}/{total} covered</div>
|
|
<div class="bar"><div class="bar-fill" style="width:{pct:.1f}%;background:{bar_color}"></div></div>
|
|
</div>''')
|
|
|
|
html.append('</div>')
|
|
|
|
# Per-file table
|
|
html.append('<h2>Files</h2>')
|
|
html.append('<table>')
|
|
html.append('<tr><th>File</th><th>Stmts</th><th>Decisions</th><th>Calls</th><th>Functions</th></tr>')
|
|
|
|
for fi in data.files:
|
|
file_stmt = sum(1 for ip in fi.ipoints if ip.kind == KIND_STMT)
|
|
file_dec = sum(1 for ip in fi.ipoints if ip.kind == KIND_DECISION)
|
|
file_call = sum(1 for ip in fi.ipoints if ip.kind == KIND_CALL)
|
|
file_func = sum(1 for ip in fi.ipoints if ip.kind == KIND_FUNCTION)
|
|
|
|
# Coverage for first dataset
|
|
ds_id = data.datasets[0].id if data.datasets else None
|
|
stmt_cov = sum(1 for ip in fi.ipoints if ip.kind == KIND_STMT and ip.hit_counts.get(ds_id, 0) > 0) if ds_id else 0
|
|
dec_cov = sum(1 for ip in fi.ipoints if ip.kind == KIND_DECISION and ip.hit_counts.get(ds_id, 0) > 0) if ds_id else 0
|
|
call_cov = sum(1 for ip in fi.ipoints if ip.kind == KIND_CALL and ip.hit_counts.get(ds_id, 0) > 0) if ds_id else 0
|
|
func_cov = sum(1 for ip in fi.ipoints if ip.kind == KIND_FUNCTION and ip.hit_counts.get(ds_id, 0) > 0) if ds_id else 0
|
|
|
|
pct_s = f"{stmt_cov/file_stmt*100:.0f}%" if file_stmt else "-"
|
|
pct_d = f"{dec_cov/file_dec*100:.0f}%" if file_dec else "-"
|
|
pct_c = f"{call_cov/file_call*100:.0f}%" if file_call else "-"
|
|
pct_f = f"{func_cov/file_func*100:.0f}%" if file_func else "-"
|
|
|
|
html.append(f'<tr><td>{fi.path}</td><td>{pct_s}</td><td>{pct_d}</td><td>{pct_c}</td><td>{pct_f}</td></tr>')
|
|
|
|
html.append('</table>')
|
|
|
|
# Per-file detail tables
|
|
html.append('<h2>Detailed Per-File Coverage</h2>')
|
|
|
|
for fi in data.files:
|
|
safe_id = fi.path.replace("/", "_").replace(".", "_").replace("-", "_")
|
|
html.append(f'<div id="file-{safe_id}">')
|
|
html.append(f'<h3 class="collapsible" onclick="toggle(\'detail-{safe_id}\')">{fi.path}</h3>')
|
|
html.append(f'<div id="detail-{safe_id}" style="display:none">')
|
|
html.append('<table>')
|
|
html.append('<tr><th>Line</th><th>Kind</th><th>ID</th><th>Function</th>')
|
|
for ds in data.datasets:
|
|
html.append(f'<th>{ds.name}</th>')
|
|
html.append('</tr>')
|
|
|
|
for ip in fi.ipoints:
|
|
tag_cls = f"tag-{ip.kind[:4]}" if ip.kind != KIND_FUNCTION else "tag-func"
|
|
html.append(f'<tr><td>{ip.line}</td><td><span class="tag {tag_cls}">{ip.kind}</span></td><td>{ip.id}</td><td>{ip.function_name}</td>')
|
|
|
|
for ds in data.datasets:
|
|
count = ip.hit_counts.get(ds.id, 0)
|
|
cls = "hit" if count > 0 else "miss"
|
|
html.append(f'<td class="{cls}">{count}</td>')
|
|
|
|
html.append('</tr>')
|
|
|
|
html.append('</table>')
|
|
|
|
# Source view
|
|
source_path = Path(fi.path)
|
|
if source_path.exists():
|
|
html.append(f'<h3>Source: {fi.path}</h3>')
|
|
html.append('<div class="source-view"><pre>')
|
|
try:
|
|
source_lines = source_path.read_text().splitlines()
|
|
# Build line -> ipoint mapping
|
|
line_ips: dict[int, list[IpointInfo]] = {}
|
|
for ip in fi.ipoints:
|
|
line_ips.setdefault(ip.line, []).append(ip)
|
|
|
|
for lineno, src_line in enumerate(source_lines, 1):
|
|
ips_at_line = line_ips.get(lineno, [])
|
|
if ips_at_line:
|
|
cls = "src-hit" if any(ip.hit_counts.get(data.datasets[0].id, 0) > 0 for ip in ips_at_line if data.datasets) else "src-miss"
|
|
else:
|
|
cls = ""
|
|
html.append(f'<div class="src-line"><span class="src-linenum">{lineno}</span><span class="src-code {cls}">{_escape_html(src_line)}</span></div>')
|
|
except Exception:
|
|
html.append(f'<div class="src-line"><span class="src-code">[source not available]</span></div>')
|
|
html.append('</pre></div>')
|
|
|
|
html.append('</div></div>')
|
|
|
|
html.append('</div>')
|
|
html.append('<footer>Generated by uos-covexport (UniversalisOS Coverage Toolchain)</footer>')
|
|
html.append('</body></html>')
|
|
|
|
return "\n".join(html)
|
|
|
|
|
|
def _escape_html(s: str) -> str:
|
|
return s.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
|
|
|
|
# ─── CLI ───────────────────────────────────────────────────────────────────────
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(
|
|
description="uos-covexport: Generate coverage reports from .umdb",
|
|
prog="uos_covexport.py",
|
|
)
|
|
parser.add_argument(
|
|
"umdb",
|
|
help="Path to the .umdb database",
|
|
)
|
|
parser.add_argument(
|
|
"--fmt",
|
|
required=True,
|
|
choices=["txt", "csv", "xml", "html"],
|
|
help="Output format",
|
|
)
|
|
parser.add_argument(
|
|
"-o", "--output",
|
|
required=True,
|
|
help="Output file path",
|
|
)
|
|
parser.add_argument(
|
|
"--show-all-vectors",
|
|
action="store_true",
|
|
help="Show all MC/DC condition vectors",
|
|
)
|
|
|
|
args = parser.parse_args(argv)
|
|
|
|
umdb_path = Path(args.umdb)
|
|
if not umdb_path.exists():
|
|
print(f"Error: .umdb not found: {umdb_path}", file=sys.stderr)
|
|
return 1
|
|
|
|
try:
|
|
conn = open_umdb(umdb_path)
|
|
data = collect_report_data(conn)
|
|
conn.close()
|
|
except Exception as e:
|
|
print(f"Error reading .umdb: {e}", file=sys.stderr)
|
|
return 1
|
|
|
|
# Format output
|
|
formatters = {
|
|
"txt": format_txt,
|
|
"csv": format_csv,
|
|
"xml": format_xml,
|
|
"html": format_html,
|
|
}
|
|
|
|
try:
|
|
output = formatters[args.fmt](data)
|
|
except Exception as e:
|
|
print(f"Error generating {args.fmt} report: {e}", file=sys.stderr)
|
|
return 1
|
|
|
|
# Write output
|
|
out_path = Path(args.output)
|
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
out_path.write_text(output)
|
|
|
|
print(f"Report written to {out_path} ({args.fmt} format)")
|
|
print(f" {len(data.files)} files, {data.total_ipoints} ipoints, "
|
|
f"{len(data.datasets)} dataset(s)")
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|