bin/rebuild-documentation-reports.py
#!/usr/bin/env python3
"""Rebuild all documentation reports in staging and publish them atomically."""
from __future__ import annotations
import argparse
import html
import datetime as dt
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
import zipfile
from pathlib import Path
RUNTIME_ROOT = Path(__file__).resolve().parents[1]
def _existing_directory(value: str) -> Path:
path = Path(value).expanduser().resolve()
if not path.is_dir():
raise argparse.ArgumentTypeError(f"Repository-Wurzel fehlt: {path}")
return path
def _repository_inputs(args: argparse.Namespace) -> list[tuple[str, Path]]:
"""Return only current engineering repositories for report publication."""
repositories = [
("enterprise-architecture", args.enterprise_architecture_root),
# Solution specializations resolve Enterprise nodes while publishing;
# publish the canonical source first so its node catalog is present in
# the shared staging platform.
("solution-architecture", args.solution_architecture_root),
# Tool contracts reference the runtime responsibility document as an
# active cross-repository target.
("runtime", args.runtime_source_root),
("engineering-tools", args.engineering_tools_root),
]
if args.engineering_platform_root is not None:
repositories.append(("engineering-platform", args.engineering_platform_root))
return repositories
def _publish_atomically(source_reports: Path, output_root: Path, *, move_source: bool) -> None:
"""Replace a publication target atomically, moving a staging tree when safe."""
backup = output_root.with_name(f".{output_root.name}.previous")
staged = output_root.with_name(f".{output_root.name}.new")
if backup.exists():
raise RuntimeError(f"Vorherige Backup-Wurzel muss zuerst geprüft werden: {backup}")
if staged.exists():
raise RuntimeError(f"Unvollständige Staging-Wurzel muss zuerst geprüft werden: {staged}")
moved_previous = False
try:
output_root.parent.mkdir(parents=True, exist_ok=True)
if move_source:
source_reports.replace(staged)
else:
shutil.copytree(source_reports, staged)
if output_root.exists():
output_root.replace(backup)
moved_previous = True
staged.replace(output_root)
except Exception:
if moved_previous and backup.exists() and not output_root.exists():
backup.replace(output_root)
raise
finally:
if staged.exists():
shutil.rmtree(staged, ignore_errors=True)
if backup.exists() and output_root.exists():
shutil.rmtree(backup)
def _link_check_repositories(inputs: list[tuple[str, Path]]) -> list[str]:
"""Preserve canonical repository names while disambiguating a legacy twin."""
counts: dict[str, int] = {}
for kind, _ in inputs:
counts[kind] = counts.get(kind, 0) + 1
seen: dict[str, int] = {}
values: list[str] = []
for kind, root in inputs:
seen[kind] = seen.get(kind, 0) + 1
name = kind
if counts[kind] > 1 and seen[kind] < counts[kind]:
name = f"legacy-{kind}"
values.append(f"{name}={root}")
return values
def _link_checker_path(engineering_tools_root: Path) -> Path:
return engineering_tools_root / "40-validation" / "validate-links.py"
def _deployment_web_root(args) -> Path:
"""Resolve the web root belonging to the selected runtime environment."""
if args.deployment_web_root is not None:
return args.deployment_web_root.expanduser().resolve()
# Keep the old option for existing test automation, but do not let it
# accidentally redirect a production publication.
if args.test_web_root is not None:
return args.test_web_root.expanduser().resolve()
return (args.runtime_source_root / args.target_runtime / "installations" /
"engineering-platform" / "web")
def _append_link_findings_to_relationship_pages(output_root: Path, link_inventory: dict) -> int:
"""Project published-link findings into every relationship findings page.
The link checker can only run after all repositories have been published,
whereas the relationship findings page originates during each repository
publication. This small post-publication projection keeps the two
diagnostics together without changing the authoritative JSON/Markdown
inventory or the source documents.
"""
findings = [item for item in link_inventory.get("findings", []) if item.get("severity") == "ERROR"]
status = html.escape(str(link_inventory.get("status", "UNKNOWN")), quote=False)
items = []
for finding in findings:
source = html.escape(str(finding.get("sourceReference", "unbekannte Quelle")), quote=False)
target = html.escape(str(finding.get("expectedTarget", finding.get("target", "unbekanntes Ziel"))), quote=False)
error_class = html.escape(str(finding.get("errorClass", "LINK_FINDING")), quote=False)
hint = html.escape(str(finding.get("correctionHint", "Linkziel prüfen.")), quote=False)
items.append(
f"<li><code>{source}</code> → <code>{target}</code>: "
f"<strong>{error_class}</strong> – {hint}</li>"
)
detail = "<p>Keine fehlerhaften veröffentlichten Links festgestellt.</p>" if not items else (
"<details><summary>Einzelbefunde anzeigen (" + str(len(items)) + ")</summary><ul>" + "".join(items) + "</ul></details>"
)
changed = 0
for page in output_root.rglob("relationship-ledger-findings.html"):
report_href = Path(os.path.relpath(
output_root / "link-validation" / "SE-0109-published-link-inventory.md", page.parent
)).as_posix()
section = (
'<section class="published-link-findings"><h2>Linkbefunde aus der Veröffentlichung</h2>'
f'<p>Status: <strong>{status}</strong>. Fehlerhafte aktuelle Navigationsreferenzen: <strong>{len(items)}</strong>. '
f'<a href="{html.escape(report_href, quote=True)}">Vollständigen Linkbefund öffnen</a>.</p>{detail}</section>'
)
text = page.read_text(encoding="utf-8")
marker = "</main>"
if marker not in text:
raise RuntimeError(f"Beziehungsbefundseite besitzt keinen Hauptbereich: {page}")
page.write_text(text.replace(marker, section + marker, 1), encoding="utf-8")
changed += 1
return changed
def _write_issue_package(output_root: Path, workspace_root: Path | None, link_inventory: dict, unresolved: int) -> Path:
"""Create a small read-only follow-up package; never includes source repositories."""
directory = (workspace_root / "out" / "_chatgpt") if workspace_root else (output_root / "issue-reports")
directory.mkdir(parents=True, exist_ok=True)
package = directory / "report-issues.zip"
summary = {
"schemaVersion": "1.0",
"generatedAt": dt.datetime.now(dt.timezone.utc).isoformat(),
"purpose": "Roundtrip issue report; source repositories remain authoritative.",
"publishedLinkValidation": link_inventory,
"unresolvedSemanticReferences": unresolved,
"relationshipFindingPages": sorted(str(path.relative_to(output_root)) for path in output_root.rglob("relationship-ledger-findings.html")),
}
readme = "# Report- und Beziehungsbefunde\n\nDieses Paket ist nur ein Befundpaket. Es enthält keine Quelle und darf nicht importiert werden.\n\n"
readme += f"- Linkstatus: `{link_inventory.get('status', 'UNKNOWN')}`\n- Ungelöste semantische Referenzen: `{unresolved}`\n- Beziehungsbefundseiten: `{len(summary['relationshipFindingPages'])}`\n"
with zipfile.ZipFile(package, "w", compression=zipfile.ZIP_DEFLATED) as archive:
archive.writestr("README.md", readme)
archive.writestr("issue-summary.json", json.dumps(summary, ensure_ascii=False, indent=2) + "\n")
for relative in ("link-validation/SE-0109-published-link-inventory.json", "link-validation/SE-0109-published-link-inventory.md", "workbench-issues.html"):
source = output_root / relative
if source.is_file():
archive.write(source, relative)
for relative in summary["relationshipFindingPages"]:
archive.write(output_root / relative, relative)
return package
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--engineering-tools-root", type=_existing_directory, required=True)
parser.add_argument("--engineering-platform-root", type=_existing_directory,
help="Optionale Engineering-Platform-Quellwurzel")
parser.add_argument("--enterprise-architecture-root", type=_existing_directory, required=True)
parser.add_argument("--solution-architecture-root", type=_existing_directory, required=True)
parser.add_argument("--legacy-solution-architecture-root", type=_existing_directory,
help="Vollständige historische Solution Architecture als Übergangsquelle")
parser.add_argument("--runtime-source-root", type=_existing_directory, default=RUNTIME_ROOT)
parser.add_argument("--workspace-root", type=_existing_directory,
help="Optionale Inhaltsquelle; wird nicht als Code-Abhängigkeit importiert")
parser.add_argument("--output-root", type=Path, default=RUNTIME_ROOT / "web" / "reports")
parser.add_argument("--test-web-root", type=Path,
help="Veralteter Alias für --deployment-web-root")
parser.add_argument("--deployment-web-root", type=Path,
help="Web-Wurzel der gewählten Laufzeitumgebung")
parser.add_argument("--skip-test-publication", action="store_true",
help="Nur die Runtime-Quellveröffentlichung aktualisieren")
parser.add_argument("--target-runtime", choices=("test", "production"), default="test")
args = parser.parse_args()
if args.engineering_platform_root is None:
candidate = (args.engineering_tools_root.parent / "engineering-platform").resolve()
if candidate.is_dir():
args.engineering_platform_root = candidate
output_root = args.output_root.expanduser().resolve()
deployment_web_root = _deployment_web_root(args)
tools_package = args.engineering_tools_root / "97-documentation"
if not (tools_package / "ns_report_publisher" / "distribution.py").is_file():
parser.error(f"Dokumentations-Publisher fehlt: {tools_package}")
if not (args.runtime_source_root / "reports" / "SE-0057-semantic-publisher-input.json").is_file():
parser.error("Runtime-Eingabemodell für den semantischen Einstieg fehlt")
sys.path.insert(0, str(tools_package))
from ns_report_publisher.distribution import distribute_repository
from ns_report_publisher.repository_provider import resolve_repository
from ns_report_publisher.platform_builder import build_platform
from ns_report_publisher.workbench_dashboard import refresh_issue_center
output_root.parent.mkdir(parents=True, exist_ok=True)
stage_parent = Path(tempfile.mkdtemp(prefix=".reports-stage-", dir=output_root.parent))
staged_runtime = stage_parent / "runtime"
timings: dict[str, float] = {}
phase_started = time.perf_counter()
try:
# The semantic landing consumes Runtime evidence. It is copied into
# staging, never changed in the source Runtime during a failed build.
shutil.copytree(args.runtime_source_root / "reports", staged_runtime / "reports")
inputs = _repository_inputs(args)
results = {}
for repository_type, repository_root in inputs:
result = distribute_repository(
repository_type, repository_root, staged_runtime, args.target_runtime,
allow_unresolved_artifact_references=True, rebuild_platform=False,
)
results[result["descriptor"].repository_id] = result
# A first publication establishes peer catalogs. Publish every
# repository once more afterwards: an incoming explicit relationship
# can originate in a repository that is published later in the first
# pass. The second pass therefore materializes both directions of a
# cross-repository context navigation without inventing a reverse edge.
for repository_type, repository_root in inputs:
result = distribute_repository(
repository_type, repository_root, staged_runtime, args.target_runtime,
allow_unresolved_artifact_references=True, rebuild_platform=False,
)
results[result["descriptor"].repository_id] = result
build_platform(staged_runtime, target_runtime=args.target_runtime)
timings["repositoryPublication"] = round(time.perf_counter() - phase_started, 3)
materializer = args.runtime_source_root / "bin" / "publish-reports-entrypoint.py"
phase_started = time.perf_counter()
completed = subprocess.run(
[sys.executable, str(materializer),
"--runtime-root", str(staged_runtime),
"--engineering-tools-root", str(args.engineering_tools_root),
"--target-runtime", args.target_runtime,
"--output-root", str(staged_runtime / "web" / "reports")],
text=True,
)
if completed.returncode:
raise RuntimeError("Semantischer Reports-Einstieg oder Linkprüfung fehlgeschlagen")
timings["semanticEntrypoint"] = round(time.perf_counter() - phase_started, 3)
verified_reports = staged_runtime / "web" / "reports"
link_report = verified_reports / "link-validation" / "SE-0109-published-link-inventory.json"
link_markdown = verified_reports / "link-validation" / "SE-0109-published-link-inventory.md"
link_checker = _link_checker_path(args.engineering_tools_root)
link_command = [
sys.executable, str(link_checker), "--published-root", str(verified_reports),
"--published-only", "--allow-findings", "--work-item", "SE-0109", "--output", str(link_report),
"--markdown-output", str(link_markdown),
]
phase_started = time.perf_counter()
subprocess.run(link_command, check=True, text=True)
timings["publishedLinkValidation"] = round(time.perf_counter() - phase_started, 3)
link_inventory = json.loads(link_report.read_text(encoding="utf-8"))
relationship_page_count = _append_link_findings_to_relationship_pages(verified_reports, link_inventory)
workbench_finding_count = refresh_issue_center(verified_reports, link_inventory)
if not args.skip_test_publication:
# A first production promotion creates the installation web root
# through atomic publication. Test publication remains guarded so
# its established deploy-before-publish contract is unchanged.
if args.target_runtime != "production" and not deployment_web_root.is_dir():
raise RuntimeError(
"Web-Wurzel der Zielumgebung fehlt; Engineering Platform zuerst deployen oder "
"--skip-test-publication verwenden: " + str(deployment_web_root)
)
phase_started = time.perf_counter()
_publish_atomically(verified_reports, output_root, move_source=True)
timings["runtimePublication"] = round(time.perf_counter() - phase_started, 3)
if not args.skip_test_publication:
phase_started = time.perf_counter()
_publish_atomically(output_root, deployment_web_root / "reports", move_source=False)
timings["deploymentPublication"] = round(time.perf_counter() - phase_started, 3)
print(f"REPORT_REBUILD_STATUS=SUCCESS")
print(f"REPORT_REBUILD_REPOSITORIES={len(results)}")
print(f"REPORT_REBUILD_OUTPUT={output_root}")
if args.workspace_root is not None or args.legacy_solution_architecture_root is not None:
print("REPORT_REBUILD_EXCLUDED_INPUTS=workspace,legacy-solution-architecture")
print(f"REPORT_REBUILD_LINK_STATUS={link_inventory['status']}")
print(f"REPORT_REBUILD_LINK_REPORT={output_root / 'link-validation' / 'SE-0109-published-link-inventory.md'}")
print(f"REPORT_REBUILD_RELATIONSHIP_FINDING_PAGES={relationship_page_count}")
print(f"REPORT_REBUILD_WORKBENCH_FINDINGS={workbench_finding_count}")
unresolved = sum(len(item["quality"].get("unresolvedArtifactReferences", [])) for item in results.values())
issue_package = _write_issue_package(output_root, args.workspace_root, link_inventory, unresolved)
print(f"REPORT_REBUILD_UNRESOLVED_SEMANTIC_REFERENCES={unresolved}")
print(f"REPORT_REBUILD_ISSUE_PACKAGE={issue_package}")
print("REPORT_REBUILD_TIMINGS_SECONDS=" + json.dumps(timings, sort_keys=True))
if not args.skip_test_publication:
print(f"REPORT_REBUILD_DEPLOYMENT_ROOT={deployment_web_root / 'reports'}")
return 0
except Exception as exc:
print(f"REPORT_REBUILD_STATUS=FAILED", file=sys.stderr)
print(f"REPORT_REBUILD_ERROR={exc}", file=sys.stderr)
return 1
finally:
shutil.rmtree(stage_parent, ignore_errors=True)
if __name__ == "__main__":
raise SystemExit(main())