Richtlinien & Verfahren (Phase 1): Import, Rendering-Engine, Bibliothek, Coverage

Fundament des VDA-ISA-2027-Richtlinienmoduls (Spec §1–9):

- Datenmodell: PolicyDocument, PolicyRequirement, PolicyVariable (Variablen +
  Feature-Flags), PolicyBaselineParam, PolicyEvidence — inkl. RLS + Tenant-Guard
- Seed-Importer (import-policies.ts): liest die echten .md-Dateien (15 Richtlinien
  L00/R01–R14 + 11 Verfahren), mapping.json (120 Anforderungen/46 Controls),
  variables.schema.json (53 Variablen/Flags), Technische-Sicherheits-Baseline
  (31 BL-Parameter) und Nachweisregister; idempotent pro Mandant
- 6 im Vorlagenpaket beschädigte Variablen-Tokens (VA-08/09/10/12/13) repariert
  (dokumentiert im README des Übergabepakets)
- Rendering-Engine (policy-render.ts, Handlebars + marked): verschachtelte
  {{#if FLAG}}, {{VARIABLE}}, {{LINK:…}}-Deeplinks, Hidden-Anker + BL-Referenzen
  im Lesemodus entfernt (Wert bleibt), zentral verwaltete Abschnitte unterdrückt,
  wiederholtes „Umsetzung bei <Org>" reduziert (§7a); lenienter Fallback +
  Residue-Check über alle Flag-Kombinationen (analog _verify.py)
- UI: Bibliothek mit Typ-Chips/KPIs, Lesemodus-Popup (einklappbare Info-Tabelle,
  Control-Chips, Richtlinie↔Verfahren-Verlinkung), Coverage-Matrix
  (Control → Richtlinie → MUSS/SOLL → Verfahren → Anforderungs-IDs)
- Nav-Punkt „Richtlinien" aktiviert; de/en-Übersetzungen

Verifiziert: Import 28 Dokumente/120 Anforderungen; Rendering rückstandsfrei
über alle Flag-Kombinationen; Bibliothek, Lesemodus (R08 nested flags), Coverage
im Browser.

Später (Phase 2+): Bearbeiten/Freigabe-Workflow mit Versionierung, verwaltete
Tabellen (Krypto-/Risiko-/Klassifizierungsregister), Anwender-Handbuch,
DOCX/PDF-Export, Word-Upload, KI-Wizard, zentrale Baseline-/Variablen-Einstellseite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 13:35:59 +02:00
co-authored by Claude Opus 4.8
parent 2706424c08
commit 89b0e3a5a5
52 changed files with 7627 additions and 1 deletions
+165
View File
@@ -0,0 +1,165 @@
import Handlebars from "handlebars";
import { marked } from "marked";
/**
* Rendering-Engine für Richtlinien & Verfahren (VDA-ISA 2027 Vorlagenpaket).
*
* Pipeline (§7 der Spezifikation):
* 1. {{LINK:…}} vorab auflösen (Doppelpunkt/#-Ziele würde Handlebars sonst brechen).
* 2. Handlebars mit noEscape:true → verschachtelte {{#if FLAG}} und {{VARIABLE}}.
* 3. Lesemodus-Nachbearbeitung: Hidden-Anker entfernen, wiederholtes
* „Umsetzung bei <Org>" reduzieren, zentral verwaltete Abschnitte
* unterdrücken (§7a.3), BL-Referenzen entfernen (Wert bleibt, §7).
* 4. Markdown → HTML (marked).
*/
export type RenderContext = Record<string, string | boolean>;
/** Baut den Render-Kontext (Variablen + Flags) aus den PolicyVariable-Zeilen. */
export function buildContext(vars: { key: string; kind: string; value: string }[]): RenderContext {
const ctx: RenderContext = {};
for (const v of vars) ctx[v.key] = v.kind === "boolean" ? v.value === "true" : v.value;
return ctx;
}
// Abschnitte, die das Tool zentral verwaltet und daher im Lese-/Exportmodus ausblendet (§7a.3)
const SUPPRESS_SECTIONS = [
"Verwandte Dokumente",
"Nachweise",
"Überprüfung und Aktualisierung",
"Verbindlichkeit",
];
/** {{LINK:ZIEL}} → interner Deep-Link nach /policies. */
export function resolveLink(target: string): { href: string; label: string } {
const [code, anchor] = target.split("#");
const q = (c: string) => `/policies?doc=${encodeURIComponent(c)}${anchor ? `#${anchor}` : ""}`;
switch (code) {
case "NACHWEISREGISTER":
return { href: "/policies?doc=NACHWEIS", label: "Nachweisregister" };
case "ISA_MAPPING":
return { href: "/policies?view=coverage", label: "ISA-Mapping-Matrix" };
case "BASELINE":
return { href: "/policies?doc=BASELINE", label: "Technische Sicherheits-Baseline" };
default:
return { href: q(code), label: anchor ? `${code} §${anchor}` : code };
}
}
function resolveLinks(md: string): string {
return md.replace(/\{\{LINK:([^}]+)\}\}/g, (_m, target: string) => {
const { href, label } = resolveLink(target.trim());
return `[${label}](${href})`;
});
}
function stripHiddenAnchors(md: string): string {
return md.replace(/<!--[\s\S]*?-->/g, "");
}
/** §7a.1 — wiederkehrende Zwischenüberschrift „Umsetzung bei <Org>" auf ein dezentes „Umsetzung:" reduzieren. */
function reduceImplHeadings(md: string): string {
return md.replace(/^\*\*Umsetzung bei .+?\*\*\s*$/gm, "_Umsetzung:_");
}
/** §7a.3 — zentral verwaltete Abschnitte ausblenden (## bis zur nächsten ##-Überschrift). */
function suppressSections(md: string): string {
const lines = md.split("\n");
const out: string[] = [];
let skipping = false;
for (const line of lines) {
const h2 = line.match(/^##\s+(?:\d+\.?\s*)?(.+?)\s*$/);
if (h2) {
const title = h2[1].trim();
skipping = SUPPRESS_SECTIONS.some((s) => title.toLowerCase().startsWith(s.toLowerCase()));
}
if (!skipping) out.push(line);
}
return out.join("\n");
}
/**
* §7 — Baseline-Referenzen (BL-…) im Lesemodus entfernen; nur der konkrete
* Variablenwert bleibt. Behandelt Klammerzusätze „(BL-IAM-01)", Kombiformen
* „(BL-CRY-01/04)", einleitende Wendungen „nach/gemäß/laut BL-…" und Resttokens.
*/
function stripBaselineTokens(md: string): string {
// "(… BL-XXX-01 …)" — Klammern, die BL-Referenzen enthalten, ganz entfernen
md = md.replace(/\s*\([^()]*BL-[A-Z]+-\d+[^()]*\)/g, "");
// "nach/gemäß/laut BL-XXX-01" (auch /04-Kombi)
md = md.replace(/\s*(?:nach|gemäß|gemaess|laut|per)\s+BL-[A-Z]+-\d+(?:\/\d+)?/gi, "");
// verbleibende bloße Tokens
md = md.replace(/\s*BL-[A-Z]+-\d+(?:\/\d+)?/g, "");
return md;
}
/** Nachsichtiger Fallback-Renderer (rekursiv, verschachtelungsfähig) für defekte Vorlagen. */
function lenientRender(md: string, ctx: RenderContext): string {
const ifPat = /\{\{#if (\w+)\}\}((?:(?!\{\{#if )(?!\{\{\/if\}\})[\s\S])*?)\{\{\/if\}\}/g;
let prev = "";
while (prev !== md) {
prev = md;
md = md.replace(ifPat, (_m, flag: string, inner: string) => (ctx[flag] ? inner : ""));
}
return md.replace(/\{\{(\w+)\}\}/g, (_m, key: string) => (key in ctx ? String(ctx[key]) : ""));
}
export interface RenderOptions {
readMode?: boolean; // Default true — Lesefassung ohne Anker/BL/verwaltete Abschnitte
stripBaseline?: boolean; // Default = readMode; für das Baseline-Register selbst false
}
/** Vorlagen-Markdown → gerenderte Markdown-Lesefassung. */
export function renderPolicyMarkdown(raw: string, ctx: RenderContext, opts: RenderOptions = {}): string {
const readMode = opts.readMode ?? true;
const stripBaseline = opts.stripBaseline ?? readMode;
let md = resolveLinks(raw);
try {
const tpl = Handlebars.compile(md, { noEscape: true });
md = tpl(ctx);
} catch {
// Defensiv: beschädigte Tokens dürfen keine ganze Seite zerlegen —
// nachsichtiger, verschachtelungsfähiger Fallback (analog _verify.py).
md = lenientRender(md, ctx);
}
if (readMode) {
md = stripHiddenAnchors(md);
md = reduceImplHeadings(md);
md = suppressSections(md);
if (stripBaseline) md = stripBaselineTokens(md);
}
return md.replace(/[ \t]+$/gm, "").replace(/\n{3,}/g, "\n\n").trim();
}
/** Vorlagen-Markdown → HTML (Lesemodus). */
export function renderPolicyHtml(raw: string, ctx: RenderContext, opts: RenderOptions = {}): string {
const md = renderPolicyMarkdown(raw, ctx, opts);
return marked.parse(md, { async: false }) as string;
}
/**
* Zerlegt ein Dokument in Titel, Info-Tabelle (Kopf) und Rumpf (ab erster
* `## `-Überschrift). Ermöglicht die einklappbare Dokumenten-Info (§7a.5).
*/
export function splitPolicyDoc(raw: string): { title: string; infoMd: string; bodyMd: string } {
const titleM = raw.match(/^#\s+(.+?)\s*$/m);
const title = titleM ? titleM[1].trim() : "";
const idx = raw.search(/^##\s+/m);
const head = idx >= 0 ? raw.slice(0, idx) : raw;
const bodyMd = idx >= 0 ? raw.slice(idx) : "";
const tableM = head.match(/(\|[^\n]*\|\n(?:\|[^\n]*\|\n?)+)/);
return { title, infoMd: tableM ? tableM[1] : "", bodyMd };
}
/**
* Prüft eine gerenderte Fassung auf Rückstände (offene {{…}}, «MISS»-Marker).
* Referenz: `_verify.py`-Gate über alle Flag-Kombinationen.
*/
export function findRenderResidue(rendered: string): string[] {
const issues: string[] = [];
const open = rendered.match(/\{\{[^}]*\}\}/g);
if (open) issues.push(...open);
return issues;
}