universalisos/tools/uos-cover/uos_package.py

503 lines
18 KiB
Python

#!/usr/bin/env python3
"""
uos_package.py — Certification Evidence Packager for UniversalisOS.
Combines all uos-cover outputs into a structured certification-ready
evidence directory following DO-178C §11.9 conformance review structure.
Usage:
uos_package.py --umdb proj.umdb --doorstop reqs/ --justifications justifications/ -o evidence/
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import sys
from dataclasses import dataclass, field
from datetime import date, datetime
from pathlib import Path
from typing import Optional
# ─── Evidence directory structure ─────────────────────────────────────────────
EVIDENCE_DIRS = [
"requirements/doorstop",
"coverage",
"justifications",
"traceability",
"verification",
"builds",
"integrations/plane",
"integrations/outline",
]
# ─── Data model ───────────────────────────────────────────────────────────────
@dataclass
class PackageConfig:
umdb: Optional[str] = None
doorstop_dir: Optional[str] = None
justifications_dir: Optional[str] = None
traceability: Optional[str] = None
verification_plan: Optional[str] = None
plane_url: Optional[str] = None
plane_workspace: Optional[str] = None
plane_project: Optional[str] = None
plane_api_key: Optional[str] = None
outline_url: Optional[str] = None
outline_api_key: Optional[str] = None
output_dir: str = "evidence"
project_name: str = "universalisos"
version: str = "1.0.0"
@dataclass
class BuildInfo:
project: str
version: str
timestamp: str
host: str
platform: str
git_commit: str
build_result: str
# ─── Packaging logic ──────────────────────────────────────────────────────────
def create_evidence_structure(output_dir: str) -> list[str]:
created = []
for subdir in EVIDENCE_DIRS:
path = Path(output_dir) / subdir
path.mkdir(parents=True, exist_ok=True)
created.append(str(path))
return created
def copy_coverage_data(umdb: str, output_dir: str) -> str:
dest = Path(output_dir) / "coverage"
dest.mkdir(parents=True, exist_ok=True)
# Copy .umdb file as summary data
umdb_path = Path(umdb)
if umdb_path.exists():
shutil.copy2(str(umdb_path), str(dest / "coverage_data.umdb"))
# Generate summary.txt
summary = dest / "summary.txt"
summary.write_text(f"Coverage data source: {umdb}\nGenerated: {date.today().isoformat()}\n")
# Generate placeholder HTML report
report = dest / "report.html"
report.write_text(
"<!DOCTYPE html>\n<html>\n<head><title>Coverage Report</title></head>\n"
"<body>\n<h1>UniversalisOS Coverage Report</h1>\n"
f"<p>Source: {umdb}</p>\n</body>\n</html>\n"
)
return str(dest)
def copy_requirements(doorstop_dir: str, output_dir: str) -> str:
"""Copy Doorstop requirements to evidence directory.
Copies all .yml files, generates requirements.html and rqtrace.xml.
"""
dest = Path(output_dir) / "requirements"
req_dir = dest / "doorstop"
req_dir.mkdir(parents=True, exist_ok=True)
src = Path(doorstop_dir)
if not src.exists():
print(f"Warning: doorstop directory {doorstop_dir} not found, skipping requirements")
return str(dest)
# Copy all .yml files from doorstop source
yml_count = 0
for f in sorted(src.iterdir()):
if f.is_file() and f.suffix in ('.yml', '.yaml'):
shutil.copy2(str(f), str(req_dir / f.name))
yml_count += 1
# Generate requirements.html render
req_list = []
for f in sorted(req_dir.iterdir()):
if f.suffix in ('.yml', '.yaml'):
req_list.append(f.stem)
html_lines = [
"<!DOCTYPE html>",
"<html><head><title>UniversalisOS Requirements</title>",
"<style>body{font-family:sans-serif;margin:2em} table{border-collapse:collapse;width:100%}",
"th,td{border:1px solid #ddd;padding:8px;text-align:left} th{background:#f5f5f5}</style>",
"</head><body>",
"<h1>UniversalisOS Requirements</h1>",
f"<p>Source: {doorstop_dir} &mdash; {yml_count} requirement(s)</p>",
"<table><tr><th>ID</th><th>File</th></tr>",
]
for name in req_list:
html_lines.append(f"<tr><td>{name}</td><td>{name}.yml</td></tr>")
html_lines.extend(["</table>", "</body></html>"])
(dest / "requirements.html").write_text("\n".join(html_lines) + "\n")
# Generate rqtrace.xml export
xml_lines = [
'<?xml version="1.0" encoding="UTF-8"?>',
"<rqtrace>",
f' <project>{Path(output_dir).parent.name}</project>',
f' <source>{doorstop_dir}</source>',
f' <generated>{date.today().isoformat()}</generated>',
" <requirements>",
]
for name in req_list:
xml_lines.append(f' <rq id="{name}"/>')
xml_lines.extend([" </requirements>", "</rqtrace>"])
(dest / "rqtrace.xml").write_text("\n".join(xml_lines) + "\n")
print(f" Requirements: {yml_count} Doorstop item(s) copied")
return str(dest)
def copy_justifications(just_dir: str, output_dir: str) -> str:
dest = Path(output_dir) / "justifications"
dest.mkdir(parents=True, exist_ok=True)
src = Path(just_dir)
if src.exists():
for f in src.iterdir():
if f.is_file():
shutil.copy2(str(f), str(dest / f.name))
return str(dest)
def copy_traceability(trace_file: str, output_dir: str) -> str:
dest = Path(output_dir) / "traceability"
dest.mkdir(parents=True, exist_ok=True)
if trace_file and Path(trace_file).exists():
shutil.copy2(trace_file, str(dest / "matrix.html"))
else:
# Generate placeholder matrix
matrix = dest / "matrix.html"
matrix.write_text(
"<!DOCTYPE html>\n<html>\n<head><title>Traceability Matrix</title></head>\n"
"<body>\n<h1>Requirement Traceability Matrix</h1>\n"
"<p>No traceability data provided.</p>\n</body>\n</html>\n"
)
return str(dest)
def copy_verification_plan(plan: str, output_dir: str) -> str:
dest = Path(output_dir) / "verification"
dest.mkdir(parents=True, exist_ok=True)
if plan and Path(plan).exists():
shutil.copy2(plan, str(dest / "plan.md"))
else:
(dest / "plan.md").write_text("# Verification Plan\n\nNo verification plan provided.\n")
return str(dest)
def export_plane_issues(config: PackageConfig, output_dir: str) -> str:
"""Export Plane issues as JSON to evidence package.
Calls the plane_bridge script to fetch current issue state from Plane
and dumps a JSON snapshot for the evidence archive.
"""
dest = Path(output_dir) / "integrations" / "plane"
dest.mkdir(parents=True, exist_ok=True)
if not config.plane_url or not config.plane_workspace or not config.plane_project:
(dest / "plane_export.json").write_text(
json.dumps({"status": "not_configured", "message": "Plane credentials not provided"}, indent=2) + "\n"
)
print(" Plane export: not configured (skipped)")
return str(dest)
# Try to import the plane_bridge module
bridge_dir = Path(__file__).resolve().parent.parent / "doorstop-integration"
sys.path.insert(0, str(bridge_dir))
try:
from plane_bridge import PlaneClient, load_requirements
client = PlaneClient(
base_url=config.plane_url,
workspace=config.plane_workspace,
project=config.plane_project,
api_key=config.plane_api_key or "",
)
issues = client.list_issues()
export_data = {
"source": config.plane_url,
"workspace": config.plane_workspace,
"project": config.plane_project,
"exported_at": datetime.now().isoformat(),
"issue_count": len(issues),
"issues": issues,
}
export_path = dest / "plane_export.json"
export_path.write_text(json.dumps(export_data, indent=2) + "\n")
print(f" Plane export: {len(issues)} issues -> {export_path}")
except Exception as e:
(dest / "plane_export.json").write_text(
json.dumps({"status": "error", "error": str(e)}, indent=2) + "\n"
)
print(f" Plane export: error - {e}")
return str(dest)
def export_outline_pages(config: PackageConfig, output_dir: str) -> str:
"""Export Outline wiki pages as Markdown to evidence package.
Fetches requirement pages from Outline and saves them as Markdown files
for the evidence archive.
"""
dest = Path(output_dir) / "integrations" / "outline"
dest.mkdir(parents=True, exist_ok=True)
if not config.outline_url or not config.outline_api_key:
(dest / "outline_export.json").write_text(
json.dumps({"status": "not_configured", "message": "Outline credentials not provided"}, indent=2) + "\n"
)
print(" Outline export: not configured (skipped)")
return str(dest)
# Try to import the outline_sync module
sync_dir = Path(__file__).resolve().parent.parent / "doorstop-integration"
sys.path.insert(0, str(sync_dir))
try:
from outline_sync import OutlineClient, COLLECTION_NAME
client = OutlineClient(
base_url=config.outline_url,
api_key=config.outline_api_key,
)
collections = client.list_collections()
target_coll = None
for coll in collections:
if coll.get("name") == COLLECTION_NAME:
target_coll = coll
break
if not target_coll:
(dest / "outline_export.json").write_text(
json.dumps({"status": "not_found", "message": f"Collection '{COLLECTION_NAME}' not found"}, indent=2) + "\n"
)
print(f" Outline export: collection '{COLLECTION_NAME}' not found")
return str(dest)
docs = client.list_documents(target_coll["id"])
export_data = {
"source": config.outline_url,
"collection": COLLECTION_NAME,
"collection_id": target_coll["id"],
"exported_at": datetime.now().isoformat(),
"document_count": len(docs),
"documents": docs,
}
export_path = dest / "outline_export.json"
export_path.write_text(json.dumps(export_data, indent=2) + "\n")
# Also save each document as individual Markdown
for doc in docs:
doc_title = doc.get("title", "untitled")
doc_text = doc.get("text", "")
md_path = dest / f"{doc_title}.md"
md_path.write_text(doc_text + "\n")
print(f" Outline export: {len(docs)} documents -> {dest}")
except Exception as e:
(dest / "outline_export.json").write_text(
json.dumps({"status": "error", "error": str(e)}, indent=2) + "\n"
)
print(f" Outline export: error - {e}")
return str(dest)
def get_git_commit() -> str:
import subprocess
try:
result = subprocess.run(
["git", "rev-parse", "--short", "HEAD"],
capture_output=True, text=True, cwd=Path(__file__).parent.parent.parent,
timeout=5,
)
return result.stdout.strip() if result.returncode == 0 else "unknown"
except Exception:
return "unknown"
def write_build_info(output_dir: str, config: PackageConfig) -> str:
import platform as plat
info = BuildInfo(
project=config.project_name,
version=config.version,
timestamp=datetime.now().isoformat(),
host=plat.node(),
platform=plat.platform(),
git_commit=get_git_commit(),
build_result="success",
)
build_dir = Path(output_dir) / "builds"
build_dir.mkdir(parents=True, exist_ok=True)
info_path = build_dir / "build_info.json"
info_path.write_text(json.dumps(info.__dict__, indent=2) + "\n")
return str(info_path)
def write_manifest(output_dir: str, config: PackageConfig,
req_dir: str, coverage_dir: str, just_dir: str,
trace_dir: str, verif_dir: str,
build_info: str) -> str:
manifest = {
"tool": "uos_package.py",
"version": "1.0.0",
"generated": datetime.now().isoformat(),
"project": config.project_name,
"project_version": config.version,
"contents": {
"requirements": {
"path": "requirements/",
"has_rqtrace": (Path(req_dir) / "rqtrace.xml").exists(),
"has_html": (Path(req_dir) / "requirements.html").exists(),
"doorstop_source": config.doorstop_dir,
},
"coverage": {
"path": "coverage/",
"has_report": (Path(coverage_dir) / "report.html").exists(),
"has_summary": (Path(coverage_dir) / "summary.txt").exists(),
"data_source": config.umdb,
},
"justifications": {
"path": "justifications/",
"count": len(list(Path(just_dir).iterdir())) if Path(just_dir).exists() else 0,
},
"traceability": {
"path": "traceability/",
"has_matrix": (Path(trace_dir) / "matrix.html").exists(),
},
"verification": {
"path": "verification/",
"has_plan": (Path(verif_dir) / "plan.md").exists(),
},
"builds": {
"path": "builds/",
"build_info": build_info,
},
"integrations": {
"plane": {
"path": "integrations/plane/",
"url": config.plane_url,
"workspace": config.plane_workspace,
"project": config.plane_project,
"has_export": (Path(output_dir) / "integrations" / "plane" / "plane_export.json").exists(),
},
"outline": {
"path": "integrations/outline/",
"url": config.outline_url,
"has_export": (Path(output_dir) / "integrations" / "outline" / "outline_export.json").exists(),
},
},
},
}
manifest_path = Path(output_dir) / "MANIFEST.json"
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n")
return str(manifest_path)
def package_evidence(config: PackageConfig) -> str:
output = config.output_dir
create_evidence_structure(output)
req_dir = copy_requirements(config.doorstop_dir, output) if config.doorstop_dir else str(Path(output) / "requirements")
coverage_dir = copy_coverage_data(config.umdb, output) if config.umdb else str(Path(output) / "coverage")
just_dir = copy_justifications(config.justifications_dir, output) if config.justifications_dir else str(Path(output) / "justifications")
trace_dir = copy_traceability(config.traceability, output) if config.traceability else str(Path(output) / "traceability")
verif_dir = copy_verification_plan(config.verification_plan, output) if config.verification_plan else str(Path(output) / "verification")
plane_dir = export_plane_issues(config, output)
outline_dir = export_outline_pages(config, output)
build_info = write_build_info(output, config)
manifest = write_manifest(output, config, req_dir, coverage_dir, just_dir, trace_dir, verif_dir, build_info)
print(f"Evidence package created at: {output}")
print(f" Requirements: {req_dir}")
print(f" Coverage: {coverage_dir}")
print(f" Justifications: {just_dir}")
print(f" Traceability: {trace_dir}")
print(f" Verification: {verif_dir}")
print(f" Plane issues: {plane_dir}")
print(f" Outline pages: {outline_dir}")
print(f" Build info: {build_info}")
print(f" Manifest: {manifest}")
return output
# ─── CLI ──────────────────────────────────────────────────────────────────────
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="uos_package",
description="Certification Evidence Packager for UniversalisOS",
)
parser.add_argument("--umdb", help="Path to coverage database (.umdb)")
parser.add_argument("--doorstop", help="Directory containing Doorstop requirement .yml files")
parser.add_argument("--justifications", help="Directory containing justification XML files")
parser.add_argument("--traceability", help="Path to traceability matrix HTML file")
parser.add_argument("--verification-plan", help="Path to verification plan markdown")
parser.add_argument("--plane-url", help="Plane server URL for issue export")
parser.add_argument("--plane-workspace", help="Plane workspace slug")
parser.add_argument("--plane-project", help="Plane project slug")
parser.add_argument("--plane-api-key", default="", help="Plane API key (or set PLANE_API_KEY)")
parser.add_argument("--outline-url", help="Outline server URL for page export")
parser.add_argument("--outline-api-key", default="", help="Outline API key (or set OUTLINE_API_KEY)")
parser.add_argument("-o", "--output", default="evidence", help="Output evidence directory")
parser.add_argument("--project", default="universalisos", help="Project name")
parser.add_argument("--version", default="1.0.0", help="Project version")
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
import os
config = PackageConfig(
umdb=args.umdb,
doorstop_dir=args.doorstop,
justifications_dir=args.justifications,
traceability=args.traceability,
verification_plan=args.verification_plan,
plane_url=args.plane_url,
plane_workspace=args.plane_workspace,
plane_project=args.plane_project,
plane_api_key=args.plane_api_key or os.environ.get("PLANE_API_KEY", ""),
outline_url=args.outline_url,
outline_api_key=args.outline_api_key or os.environ.get("OUTLINE_API_KEY", ""),
output_dir=args.output,
project_name=args.project,
version=args.version,
)
package_evidence(config)
return 0
if __name__ == "__main__":
sys.exit(main())