#!/usr/bin/env python3 """ Traceability-Werkzeug fuer demo-epb. Liest alle Markdown-Items in reqs/ und arch/ ein, validiert Links bidirektional und erzeugt eine HTML-Traceability-Matrix. Doorstop-kompatibles Format (YAML-Frontmatter + Markdown-Body), aber ohne Doorstop-Dependency — bleibt portabel. Subcommands: check Validiert Konsistenz, exit 1 bei Fehlern publish DIR Schreibt HTML + JSON nach DIR/ Run: python3 tools/traceability.py check python3 tools/traceability.py publish docs/traceability/ """ from __future__ import annotations import html import json import re import sys from dataclasses import dataclass, field from pathlib import Path REPO = Path(__file__).resolve().parent.parent SOURCES = [ ("SYS", "reqs/sys", "System Requirements"), ("SWE", "reqs/swe", "Software Requirements"), ("SA", "arch/sys", "System Architecture"), ("SWA", "arch/swe", "Software Architecture"), ] # Welche Quellen verlinken auf welche? # (key) -> (target_prefix) : Items mit key linken auf Items mit target_prefix EXPECTED_LINKS = { "SA": ["SYS"], "SWE": ["SYS"], "SWA": ["SWE"], } # Reverse: welche Quellen MUESSEN von welchen Quellen referenziert werden? # (target) -> [prefix that should link to target] (coverage check) COVERAGE = { "SYS": ["SA", "SWE"], # jede SYS-Req muss durch SA und SWE abgedeckt sein "SWE": ["SWA"], # jede SWE-Req muss durch SWA abgedeckt sein } @dataclass class Item: id: str prefix: str path: Path title: str asil: str links: list[str] = field(default_factory=list) FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL) LINKS_RE = re.compile(r"^\s*-\s+([A-Z]+-\d+)", re.MULTILINE) def parse_item(path: Path, prefix: str) -> Item | None: text = path.read_text() m = FRONTMATTER_RE.match(text) if not m: return None fm = m.group(1) # Crude YAML parsing — we only need a few fields. def field_value(name: str) -> str | None: rx = re.search(rf"^{name}:\s*(.*?)$", fm, re.MULTILINE) return rx.group(1).strip().strip("'\"") if rx else None asil = field_value("asil") or "?" title = field_value("header") or path.stem # links: collect IDs from the `links:` block. links: list[str] = [] in_links = False for line in fm.splitlines(): if line.startswith("links:"): in_links = True continue if in_links: if line.startswith(" - "): m2 = re.match(r" - ([A-Z]+-\d+)", line) if m2: links.append(m2.group(1)) elif not line.startswith(" ") and line.strip(): in_links = False return Item(id=path.stem, prefix=prefix, path=path, title=title, asil=asil, links=links) def collect_all() -> dict[str, Item]: items: dict[str, Item] = {} for prefix, rel, _label in SOURCES: d = REPO / rel if not d.exists(): continue for f in sorted(d.glob("*.md")): it = parse_item(f, prefix) if it is None: continue items[it.id] = it return items def cmd_check(items: dict[str, Item]) -> int: errors: list[str] = [] # 1. Each link target must exist for it in items.values(): for link in it.links: if link not in items: errors.append(f"{it.id} links to non-existent {link}") # 2. Forward expectation: items of certain prefixes must link to others for it in items.values(): expected_prefixes = EXPECTED_LINKS.get(it.prefix, []) if not expected_prefixes: continue actual_prefixes = {items[l].prefix for l in it.links if l in items} for ep in expected_prefixes: if ep not in actual_prefixes: errors.append( f"{it.id} ({it.prefix}) has no link to a {ep}-* item" ) # 3. Coverage: each item of certain prefix must be referenced by certain types incoming: dict[str, set[str]] = {iid: set() for iid in items} for it in items.values(): for link in it.links: if link in incoming: incoming[link].add(it.prefix) for it in items.values(): required = COVERAGE.get(it.prefix, []) for rp in required: if rp not in incoming[it.id]: errors.append( f"{it.id} ({it.prefix}) is not referenced by any {rp}-* item" ) print(f"\nItems found: {len(items)}") print(f" SYS: {sum(1 for i in items.values() if i.prefix == 'SYS')}") print(f" SWE: {sum(1 for i in items.values() if i.prefix == 'SWE')}") print(f" SA: {sum(1 for i in items.values() if i.prefix == 'SA')}") print(f" SWA: {sum(1 for i in items.values() if i.prefix == 'SWA')}") print() if errors: print(f"FAIL: {len(errors)} traceability error(s):") for e in errors: print(f" - {e}") return 1 print("OK — Traceability vollstaendig.") return 0 def asil_color(asil: str) -> str: return { "D": "#d62728", "C": "#ff7f0e", "B": "#2ca02c", "A": "#1f77b4", "QM": "#888", }.get(asil, "#aaa") def cmd_publish(items: dict[str, Item], out_dir: Path) -> int: out_dir.mkdir(parents=True, exist_ok=True) # Build forward and reverse maps children: dict[str, list[str]] = {iid: [] for iid in items} for it in items.values(): for link in it.links: if link in children: children[link].append(it.id) rows = [] sys_items = [i for i in items.values() if i.prefix == "SYS"] for sys in sorted(sys_items, key=lambda i: i.id): sas = [c for c in children[sys.id] if items[c].prefix == "SA"] swes = [c for c in children[sys.id] if items[c].prefix == "SWE"] swas = sorted(set(c for s in swes for c in children[s] if items[c].prefix == "SWA")) rows.append({ "sys": sys, "sa": sas, "swe": swes, "swa": swas }) # HTML parts = [ "
", "", "Generiert aus {sum(1 for _ in items)} Items " f"(SYS: {len([i for i in items.values() if i.prefix=='SYS'])}, " f"SWE: {len([i for i in items.values() if i.prefix=='SWE'])}, " f"SA: {len([i for i in items.values() if i.prefix=='SA'])}, " f"SWA: {len([i for i in items.values() if i.prefix=='SWA'])}).
", "| System-Requirement | System-Arch (SA) | " "Software-Req (SWE) | Software-Arch (SWA) | — | " bits = [] for i in ids: it = items[i] c = asil_color(it.asil) bits.append( f"" + "".join(bits) + " | " for r in rows: sys = r["sys"] c = asil_color(sys.asil) first = (f"{html.escape(sys.id)} "
f"{html.escape(sys.asil)} "
f"{html.escape(sys.title)} | ")
parts.append("
|---|---|---|---|