universalisos/tools/uos-cover/uos_covexport.py

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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
# ─── 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())