503 lines
18 KiB
Python
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} — {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())
|