{
  "generated_from": "QAF-Vergleich-Architektur.md",
  "generated_at": "2026-08-03",
  "statistics": {
    "total_modules": 190,
    "core_modules": 126,
    "ui_modules": 54,
    "route_action_modules": 10,
    "declared_lines_total": 76881,
    "core_lines": 57729,
    "ui_lines": 12478,
    "route_action_lines": 6674
  },
  "modules": [
    {
      "path": "lib/qaf-differences/index.ts",
      "category": "core",
      "declared_lines": 1437,
      "comment_summary": "qaf-differences module — public entrypoint (ADR 019). Owner: SupplierPulse / QAF The auditable QAF Diff Engine (KAR-799). UI-independent: usable from server actions, batch jobs, CLI and tests. Anything not re-exported here is internal and may change without notice — no deep imports into internal/. tdd-guard:skip — pure re-export barrel; logic is tested in internal/__tests__.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export type { | export type { NumericDelta, DifferBandsConfig } from './internal/differ' | export type { | export type { | export type { UnitPriceParams, UnitPriceResult } from './internal/g60/calculator' | export type { StationMeasurement } from './internal/g60/stopwatch-map' | export type { | export type { G60TabDbRow, G60CardDbRow } from './internal/g60/rehydrate' | export type { G60AnomalyRow, G60Lever, G60ProdIndexRow } from './internal/g60/view' | export type { | export type { | export type { | export type { G60ExportInput } from './internal/g60/export' | export type { Lever, LeverDiffInput, LeverKpiDef } from './internal/levers' | export type { Projection, ProjectionYear, ProjectionYearInput, ProjectionTotals } from './internal/projection' | export type { | export type { Movers, MoverRow, MoversInput, TopN, MoversReconciliation, MoversSelection } from './internal/movers' | export type { | export type { ManualPin } from './internal/matcher' | export type { FieldMappingOverride, RowIdentity, CarryForwardResult } from './internal/field-mapping-override' | export type { WorkbookSheetSummary, SemanticActiveRange } from './internal/workbook-adapter' | export type { | export type { ForeignFormKind, ForeignFormWorkbook, ForeignFormDetection } from './internal/foreign-form-detection' | export type { | export type { | export type { | export type { | export type { ColumnClassificationFacts, ColumnClassificationContext } from './internal/multi-qaf/column-classifier' | export type { | export type {",
      "source_heading_line": 18,
      "source_end_line": 70
    },
    {
      "path": "lib/qaf-differences/internal/ai-ready.ts",
      "category": "core",
      "declared_lines": 282,
      "comment_summary": "Die `ai_ready`-Fläche (Spezifikation Kap. 17, R-21). Diese Schicht ist die Übergabe an die Sprachschicht — und sie ist bewusst so gebaut, dass dort nichts mehr zu rechnen ist. Der Grund steht in der Spezifikation als Doktrin und hier als Bauform: **Zahlen entstehen nie im Modell.** Jede spätere Aussage ist die Umformulierung eines Fakts, der hier bereits fertig steht — mit Werten, Einheit, Differenz-Kennungen und Zellen. Ein Sprachmodell, das eine Zahl selbst bildet, bildet früher oder später eine falsche; ein Modell, das nur umformuliert, kann das nicht. Die zweite Doktrin ist genauso wichtig und weniger offensichtlich: **„Nichts gefunden\" und „nicht berechnet\" sind zweierlei.** Ohne diese Unterscheidung schreibt die Sprachschicht „keine Auffälligkeiten\", wo in Wahrheit ein Modul gar nicht gelaufen ist. Das ist der Fehler, der in einer Verhandlung am teuersten wird, weil er wie eine Entwarnung klingt. Pure Funktionen, keine I/O.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": false,
      "public_signatures": "export type SectionState = 'populated' | 'empty_verified' | 'not_computed' | export type FactConfidence = 'deterministic' | 'candidate' | export interface FactValue { | export interface Fact { | export interface AiReadingGuide { | export const AI_READING_GUIDE: AiReadingGuide = { | export interface AiReadySection { | export interface AiReady { | export interface FactInput { | export function buildFacts(inputs: readonly FactInput[], records: readonly DifferenceRecord[]): Fact[] { | export function factsFromLevers(levers: readonly Lever[], records: readonly DifferenceRecord[]): Fact[] { | export interface SectionInput { | export function sectionStates(sections: Record<string, SectionInput>): Record<string, AiReadySection> { | export interface ValidationCheck { | export function buildAiReady( | export interface AiReadyProblem { | export function validateAiReady(aiReady: AiReady): AiReadyProblem[] {",
      "source_heading_line": 71,
      "source_end_line": 108
    },
    {
      "path": "lib/qaf-differences/internal/all-differences.ts",
      "category": "core",
      "declared_lines": 234,
      "comment_summary": "Der Differenzkatalog und seine Rückverfolgung (Spezifikation Kap. 7.6, R-13). Jede festgestellte Differenz — Summenzeile, Materialposition, Station, Werkzeug, Prämisse — bekommt einen Satz mit eigener Kennung und ihren Fundstellen. Das ist die Ebene, auf der später jede Kachel, jede Folie und jede Aussage belegt wird: Aussage → Differenz-Kennungen → Zellen. Zwei Eigenschaften machen den Katalog überhaupt brauchbar: 1. Die Kennungen sind stabil. Zwei Läufe über dieselben Dateien vergeben dieselben — sonst zeigt jeder Verweis aus einer Folie nach dem nächsten Lauf woandershin. 2. Kein Satz ohne Fundstelle. Eine Differenz, die man nicht nachschlagen kann, ist in einer Verhandlung nichts wert. Pure Funktionen, keine I/O.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": false,
      "public_signatures": "export type DifferenceArea = 'IDT' | 'SUM' | 'MAT' | 'MFG' | 'SBM' | export const DIFFERENCE_AREAS: readonly DifferenceArea[] = ['IDT', 'SUM', 'MAT', 'MFG', 'SBM'] | export type DifferenceKind = | export interface DifferenceCell { | export interface DifferenceRecord { | export type RawDifference = Omit<DifferenceRecord, 'differenceId'> | export function assignDifferenceIds(differences: RawDifference[]): DifferenceRecord[] { | export interface TraceabilityEntry { | export interface TraceabilityIndex { | export function buildTraceabilityIndex( | export interface TraceabilityProblem { | export function validateTraceability( | export interface DifferenceCatalog { | export function buildCatalog(differences: RawDifference[]): DifferenceCatalog {",
      "source_heading_line": 109,
      "source_end_line": 140
    },
    {
      "path": "lib/qaf-differences/internal/baseline.ts",
      "category": "core",
      "declared_lines": 95,
      "comment_summary": "QAF Baseline selection (KAR-799, spec B5). \"Oldest vs newest\" is only the DEFAULT. When the order can't be established confidently (missing or equal quotation dates, unknown manual ids), we still PROPOSE a deterministic ALT/NEU but flag status = 'baseline_review' so the UI asks the user instead of silently choosing (spec B5). Net-new: the existing board uses fixed manual version slots. Pure — operates on file refs, not on parsed content.",
      "mentions_todo_or_future": true,
      "pure_or_no_io": true,
      "test_reference": false,
      "public_signatures": "export interface QafFileRef { | export type BaselineMode = 'oldest_vs_newest' | 'manual' | export type BaselineStatus = 'ok' | 'baseline_review' | 'insufficient' | export interface BaselineSelection { | export interface BaselineOptions { | export function selectBaseline(files: QafFileRef[], opts: BaselineOptions = {}): BaselineSelection {",
      "source_heading_line": 141,
      "source_end_line": 159
    },
    {
      "path": "lib/qaf-differences/internal/batch.ts",
      "category": "core",
      "declared_lines": 211,
      "comment_summary": "QAF batch orchestration (KAR-799, spec A2.1/B2.6/B2.7). Groups parsed QAF files by part number, picks a baseline per group, and runs the per-pair comparison. ── Warum die Sachnummer allein nicht reicht (31.07.2026) ────────────────── Ursprünglich war die Sachnummer der EINZIGE Gruppierungsschlüssel: \"Groups STRICTLY by part number. Different part numbers are never compared. Files without a recognizable part number are bucketed separately.\" Die Absicht ist richtig und bleibt — zwei fremde Teile dürfen nie verglichen werden. Sie greift aber zu hart, wenn EINE Datei eines offensichtlich zusammengehörenden Paares keine Sachnummer trägt. Realfall (Kais, 31.07.2026): zwei QAF-8.8-Dateien desselben Umfangs, dieselbe Anfragenummer, derselbe Lieferant, dasselbe Teil — die \"Aktuell\"-Datei trug die Sachnummer, die \"Vergabe\"-Datei ließ das Feld leer. Ergebnis: zwei Gruppen mit je einer Datei, kein Vergleich, zweimal \"Sachnummer fehlt\". Deshalb jetzt eine KASKADE statt einer einzigen Regel. Die Reihenfolge ist nach absteigender Sicherheit gewählt, und jede Stufe ist konservativ: 1. Sachnummer — unverändert, stärkstes Merkmal 2. Anfragenummer/Version — projekt- und lieferantenspezifisch vergeben, praktisch kollisionsfrei 3. Teil + Lieferant — schwächer, deshalb NUR wenn beide Felder gesetzt sind und beide übereinstimmen Drei Schutzregeln, die verhindern, dass aus der Lockerung ein stiller Fehlvergleich wird: * Stufen 2 und 3 laufen NUR über Dateien, die nach Stufe 1 allein geblieben sind. Eine Datei, die bereits über ihre Sachnummer einen Partner hat, wird nie neu zugeordnet. * Zwei Dateien mit VERSCHIEDENEN, jeweils gesetzten Sachnummern werden auch in Stufe 2/3 nie gepaart — der ursprüngliche Schutz bleibt hart. * Eine Stufe paart nur bei GENAU zwei Kandidaten mit demselben Schlüssel. Bei drei oder mehr wird nicht geraten. Jedes Ergebnis weist über `pairedBy` aus, worüber gepaart wurde. Ein Vergleich, der nicht über die Sachnummer zustande kam, muss das sagen — sonst wäre eine harte Regel gegen eine stille Annahme getauscht worden, und das wäre der schlechtere Fehler. Net-new. Pure.",
      "mentions_todo_or_future": true,
      "pure_or_no_io": true,
      "test_reference": false,
      "public_signatures": "export interface BatchGroupResult { | export type QafPairingBasis = 'partNumber' | 'requestVersion' | 'partAndSupplier' | export function buildBatchComparisons(files: QafFileParsed[]): BatchGroupResult[] {",
      "source_heading_line": 160,
      "source_end_line": 203
    },
    {
      "path": "lib/qaf-differences/internal/benchmark-input.ts",
      "category": "core",
      "declared_lines": 158,
      "comment_summary": "Adapter: persistierte QAF-Formen → Benchmark-Eingabe (KAR-993 P2). `supplier-benchmark.ts` kennt absichtlich KEIN Dateiformat — es rechnet nur auf `BenchmarkVariantInput[]`. Dieses Modul stellt die Brücke von den zwei Formen her, in denen der Ingest Kostenpositionen ablegt: Multi-QAF → `qaf_file.g60_meta.multiQafVirtualVariants.variants[]`, je Variante ein `VirtualVariantSummaryTotals` Standard → `qaf_summary_metric`-Zeilen (eine Datei = eine Variante) Diese Trennung ist der Grund, warum 'supplier_benchmark' format-agnostisch sein kann (Multi-QAF gegen Standard, Standard gegen Standard): die Formatkenntnis endet hier, die Engine sieht nur noch eine Variantenliste.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export interface VirtualVariantTotalsLike { | export interface SummaryMetricRowLike { | export function benchmarkVariantsFromVirtualVariants( | export function benchmarkVariantsFromSummaryMetrics(",
      "source_heading_line": 204,
      "source_end_line": 223
    },
    {
      "path": "lib/qaf-differences/internal/bilingual-message.ts",
      "category": "core",
      "declared_lines": 101,
      "comment_summary": "Bilingual PlausibilityIssue message support (KAR-906 / P3.2). Problem: `qaf_plausibility_issue.explanation` is a single TEXT column (see supabase-migration-qaf-differences.sql) — there is no `messageEn`/JSONB column to persist a second language into, and this task is explicitly scoped \"KEIN Schema-Change\". Several engine modules already compute a `messageDe`/`messageEn` pair internally (rule-engine.ts's RuleViolation, reconciliation.ts's ReconciliationResult, business-rules.ts's BusinessRuleResult, ...) but their `xToPlausibilityIssue` bridge functions historically dropped `messageEn` before persistence (see rule-engine.ts's KAR-889 \"Persistence bridge\" header comment: \"messageEn stays available on the pure RuleViolation ... just not persisted today\"). That meant the UI's language switch could never actually show an English plausibility message, even though the English text existed in memory for one request. This module closes that gap WITHOUT a migration: it encodes DE+EN into the same TEXT column using a marker prefix that cannot collide with any documented Fehlerreport message text, and decodes it back out at read time. Rows written before KAR-906 (or any row where only German is available) have no marker — `decodeBilingual` treats those as legacy plain text and falls back EN -> DE, per the task's explicit tolerance requirement (\"Bestands-Issues ohne EN tolerant\"). Pure, no I/O.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": false,
      "public_signatures": "export function encodeBilingual(de: string, en?: string): string { | export interface DecodedMessage { | export function decodeBilingual(raw: string | null | undefined): DecodedMessage { | export function missingFieldReason(labelDe: string, labelEn: string, markedNotApplicable: boolean): DecodedMessage { | export function joinMissingReasons(parts: readonly DecodedMessage[]): DecodedMessage {",
      "source_heading_line": 224,
      "source_end_line": 255
    },
    {
      "path": "lib/qaf-differences/internal/business-rules.ts",
      "category": "core",
      "declared_lines": 944,
      "comment_summary": "QAF Business-Rule-Reconciliation-Engine — unabhängige Nachrechnung der Leitfaden-Formeln auf ZEILEN-Ebene (KAR-901 / P2.2). Abgrenzung zu reconciliation.ts (KAR-887/P0.2): jenes Modul prüft nur AGGREGATE (Sigma(Zeilen-Feld) vs. ein Summary-Wert, oder Summary-interne Kaskaden wie Materialkosten+Fertigungskosten=Herstellkosten). Es rechnet nie eine EINZELNE Zeile selbst nach — ob z.B. die in einer konkreten Fertigungskosten-Zeile stehende FEK-Zahl tatsächlich aus den in DERSELBEN Zeile stehenden Eingangsfeldern folgt, bleibt dort ungeprüft. Master-Prompt §12.5 (\"Do not trust Excel-calculated results without independent validation\") verlangt genau das auch auf Zeilen-Ebene — dieses Modul liefert das, für jede Formel, deren Eingangsgrößen heute tatsächlich geparst werden. Läuft PRO DATEI (ALT und NEU getrennt, wie reconciliation.ts) — kein Cross-File-Diff. Pure, keine I/O. ═══════════════════════════════════════════════════════════════════════════ INVENTAR — welche Leitfaden-Formeln sind mit den heute geparsten Feldern (QAFRowValues aus lib/qaf-parser.ts, MaterialRowValues aus material-parser.ts, SbmRowValues aus sbm-parser.ts) vollständig nachrechenbar? Formel-für-Formel mit Seiten-Evidenz aus 02-leitfaden-teil1.md / 02-leitfaden-teil2.md (Brain: 01-Projekte/supplierpulse-qaf-adaptive-engine/). ═══════════════════════════════════════════════════════════════════════════ ── MANUFACTURING (QAFRow) ────────────────────────────────────────────────── [IMPLEMENTIERT] rule_calc_fek — Leitfaden [30] (S.30): \"FEK [BW] = [Anzahl dir. MA × Direkter Lohn × (1+SGK) + MSS] × Zykluszeit / Teile pro Zyklus / 3600 + Rüstkosten pro Stück.\" Felder: anzahlMA, lohnkosten, lohnzuschlagssaetze (SGK%), mss, zykluszeit, teileProZyklus, ruestkosten → fek. Alle vorhanden. [IMPLEMENTIERT] rule_calc_fk — Leitfaden [31] (S.31): \"Fertigungskosten FK [BW] = FEK + RFGK Stundensatz × Zykluszeit / Teile pro Zyklus / 3600.\" Felder: fek, rfgk, zykluszeit, teileProZyklus → fk. Alle vorhanden. [IMPLEMENTIERT] rule_calc_ausschuss_fertigung — Leitfaden [29] Abbildung 20 (S.29): \"Fertigungskosten FK [AW] × (1/(1 − Ausschuss pro Prozessschritt) − 1) = Ausschusskosten Fertigung [AW].\" Felder: fkAW, ausschuss → ausschusskosten. Alle vorhanden. [TODO nicht nachrechenbar] Rohstoffzuschlag RoZ0 (Energie) — Leitfaden [29] Abbildung 21: \"Anzahl pro Angebotsteil × Energieverbrauch pro h [kWh] × Rohstoffnotierung Ro [AW/kWh] = Rohstoffzuschlag RoZ0 [AW].\" Leitfaden [31] listet die Felder Rohstoffbezeichnung/Energieverbrauch pro h/ Rohstoffnotierung Ro [AW/kWh]/Anteil Energie an MSS/Rohstoffzuschlag RoZ0 für MANUFACTURING — QAFRowValues (lib/qaf-parser.ts) hat KEINES dieser Felder (nicht geparst). Fehlendes Feld: energyConsumptionPerHour / rawMaterialQuotationEnergy / rawMaterialSurchargeEnergy (existieren nicht). [TODO nicht nachrechenbar] Wechselkurs [AW/BW] = Angebotswährung / Beschaffungswährung (Leitfaden [31]) — angebotswaehrung/ beschaffungswaehrung sind Währungs-CODES (Text, z.B. \"EUR\"), keine unabhängige numerische Marktkurs-Quelle vorhanden, gegen die man `wechselkurs` nachrechnen könnte. Fehlendes Feld: externe FX-Rate-Quelle. [TODO nicht nachrechenbar] Fertigungskosten FK [AW] (aus FK[BW]) — Leitfaden [31] listet fkAW nur als \"berechnet: Fertigungskosten in Angebotswaehrung\", OHNE die Formel textuell auszuschreiben (anders als FEK/FK[BW]/Ausschusskosten Fertigung, die alle eine explizite Formel-Zeile haben). Eine Ableitung \"fk × wechselkurs × anzahlProAngebotsteil\" wäre reine Analogie zu MATERIAL — laut Task-Vorgabe (\"KEINE Spekulation\") nicht implementiert ohne textuelle Formel-Evidenz. ── MATERIAL (MaterialRow, material-parser.ts) ────────────────────────────── [IMPLEMENTIERT] rule_calc_material_cost_per_unit_aw — Leitfaden [19] (S.19): \"Kalkulatorische Materialkosten pro Mengeneinheit [AW] = Kosten pro ME × Wechselkurs + Verpackungskosten + Transportkosten + Zollkosten + MGK.\" Felder: costPerUnitBw, exchangeRate, packagingCostPerUnit, transportCostPerUnit, customsCbamCostPerUnit, overheadCost → costPerUnitAw. Alle vorhanden. [IMPLEMENTIERT] rule_calc_material_scrap_cost — Leitfaden [17] Abbildung 10 (S.17): \"Materialkosten [AW] × (1 / (1 − Material-Ausschuss [%]) − 1) = Ausschusskosten Material [AW].\" Felder: materialCost, scrapRate → scrapCost. Alle vorhanden. [IMPLEMENTIERT] rule_calc_raw_material_surcharge — Leitfaden [17] Abbildung 9 (S.17): \"Anzahl pro Angebotsteil × Bezugsgewicht pro Angebotsteil [kg] × Rohstoffnotierung Ro [AW/kg] = Rohstoffzuschlag RoZ0 [AW].\" Felder: quantityPerQuotedPart, referenceWeight, rawMaterialQuotation → rawMaterialSurcharge. Alle vorhanden. Betrifft nur Zeilen mit befüllten RMR-Feldern (die überwiegende Mehrheit der MATERIAL-Zeilen hat rawMaterialSurcharge = null und wird übersprungen, kein Fehlalarm — s. \"Gating\"-Prinzip unten). [IMPLEMENTIERT] rule_calc_material_cost — Leitfaden [19] (S.19): \"Kaufteilkosten = Materialkosten pro ME × Anzahl der Verwendungen. Rohmaterialkosten = [(Materialkosten pro ME × Einsatzmenge) − Rückvergütung] × Anzahl Verwendungen.\" Zwei Formelvarianten je nach Zeilentyp (Kaufteil vs. Rohmaterial) — Branch-Logik unten dokumentiert. Feinere Zwischenschritte aus Abbildung 7/8 (z.B. \"Kosten pro Stück (DAP)\") haben KEIN eigenes MaterialRow-Feld — nur der Kettenanfang (costPerUnitAw, s.o.) und das Endergebnis (materialCost) sind geparst, daher wird hier nur die END-zu-END-Formel geprüft, nicht jeder Zwischenschritt einzeln. [TODO nicht nachrechenbar] Wechselkurs [AW/BW] (Leitfaden [18]) — s.o., dieselbe fehlende FX-Rate-Quelle wie bei MANUFACTURING. ── SBM-DEVICES-FWZ (SbmRow, sbm-parser.ts) ───────────────────────────────── [IMPLEMENTIERT] rule_calc_sbm_tool_fixture_cost — 02-leitfaden-teil2.md [37] (S.37): \"Summe Werkzeug-/Vorrichtungskosten [AW] = Werkzeug- bzw. Vorrichtungskosten in Beschaffungswährung × Wechselkurs × Anzahl der Werkzeuge bzw. Vorrichtungen.\" Felder: toolFixtureCostBw, exchangeRate, toolFixtureCount → totalToolFixtureCostAw. Alle vorhanden (sbm-parser.ts existiert bereits im Repo, vorgezogen gegenüber dem Backlog-Item P1.7). [TODO nicht nachrechenbar] Wechselkurs [AW/BW] (teil2 [37]) — s.o. [TODO nicht implementiert — Redundanz, nicht fehlendes Feld] \"Summe Werkzeugkosten SBM [AW]\" (teil2 [37], bedingt nur bei Verrechnungsform = SBM) — dieselbe Formel wie rule_calc_sbm_tool_fixture_cost auf denselben Eingangsfeldern (toolFixtureCostBw/exchangeRate/toolFixtureCount), nur für einen anderen Ergebnisfeld-Namen (totalSbmToolCostAw statt totalToolFixtureCostAw) im bedingten SBM-Fall. Kein unabhängiger zusätzlicher Prüfwert — ein zweiter Check hier würde denselben Eingangsfehler doppelt melden, ohne neue Information zu liefern. ── Außerhalb des P2.2-Scope (laut Backlog 05-backlog-phasenplan.md, andere Phase-2-Items) ───────────────────────────────────────────────────────────── - RAW MATERIAL RISKS-Aggregation (S.42-45) = P2.3, eigener Parser fehlt. - LOGISTICS&CUSTOM-Formeln (S.39-41) = P2.4, eigener Parser fehlt. - SBM-Wechselkurs/WAF-Angebotspreis-Formel (teil3, \"Angebotspreis = (ΣMaterial+ΣFertigung)×(1+Zuschläge)+Logistik\") — WAF ist ein EIGENSTÄNDIGES externes Formular (Werkzeuganalyseformular), wird nicht als Teil des QAF-Workbooks geparst (kein waf-parser.ts im Repo) — ohne geparste Eingangsfelder nicht nachrechenbar. ── Formula-Engine-Synergie (KAR-900/P2.1) ────────────────────────────────── Reine Text-Anreicherung, KEINE neue Kopplung der Engines (Task-Vorgabe): businessRuleResultToPlausibilityIssue nimmt optional den FormulaFinding.explanation-Text des GLEICHEN Zielfelds entgegen (vom Aufrufer — compare.ts — über stepComparisons/fieldDiffs ermittelt, siehe dort) und hängt bei einer 'abweichung' einen Hinweissatz an: \"Nachrechnung weicht ab UND Formel wurde geändert\" — der stärkste verfügbare Manipulations-Indikator. Nur für MANUFACTURING-Felder möglich (fek/fk/ ausschusskosten) — MATERIAL/SBM-Zeilen werden nicht Schritt-für-Schritt gedifft (kein FieldDiff/formulaFinding existiert dafür, s. compare.ts QafFileParsed.materialRows/sbmRows-Dokumentation: \"does not produce MATERIAL/SBM stepComparisons\"). ── Gating-Prinzip (Anti-Spam, analog reconciliation.ts's SBM-\"beide Seiten leer\"-Kurzschluss) ───────────────────────────────────────────────────────── Dieses Modul läuft PRO ZEILE — bei N Zeilen × mehreren Checks könnte eine naive \"immer ein Ergebnis pro Zeile\" Implementierung Dutzende nicht_pruefbar-Hinweise für Formeln erzeugen, die für die betreffende Zeile schlicht NICHT GELTEN (z.B. RoZ0 für eine gewöhnliche Kaufteil-Zeile ohne Rohstoffrisikobeteiligung). Daher: ein Check wird nur DANN ausgewertet (liefert bestanden/abweichung/nicht_pruefbar), wenn das ZIELFELD der Zeile (der Excel-Ist-Wert) überhaupt befüllt ist — ist es leer, gibt es nichts nachzurechnen, die Zeile wird für DIESEN Check komplett übersprungen (kein Ergebnis, nicht einmal nicht_pruefbar). Ist das Zielfeld befüllt, aber ein Eingangsfeld fehlt/ist n.a., IST das ein berichtenswerter nicht_pruefbar-Fall (Excel zeigt einen Wert, den wir nicht verifizieren können). ── NIE null-als-0 ─────────────────────────────────────────────────────────── Ein fehlendes (null, nicht explizit n.a. markiertes) Eingangsfeld wird NIE als 0 in die Formel eingesetzt — auch nicht bei fachlich \"meist leer\" Feldern wie Rückvergütung (rebate). Eine leere Zelle kann echte 0 ODER eine Datenlücke bedeuten; ohne expliziten n.a.-Marker ist das nicht unterscheidbar, also: nicht_pruefbar statt Rateannahme.",
      "mentions_todo_or_future": true,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export type BusinessRuleCheckId = | export type BusinessRuleStatus = 'bestanden' | 'abweichung' | 'nicht_pruefbar' | export interface BusinessRuleResult { | export interface BusinessRulesConfig { | export const BUSINESS_RULES_CONFIG: BusinessRulesConfig = { | export interface BusinessRuleInput { | export function evaluateBusinessRules( | export function businessRuleResultToPlausibilityIssue( | export function checkBusinessRules(",
      "source_heading_line": 256,
      "source_end_line": 413
    },
    {
      "path": "lib/qaf-differences/internal/candidate-sheet-plausibility.ts",
      "category": "core",
      "declared_lines": 167,
      "comment_summary": "KAR-927 adversarial-review fix (F1/F2/F3, 12.07.2026) — shared BOUNDED, CHEAP header-region-presence scan used by material-parser.ts's parseMaterialSheet and sbm-parser.ts's parseSbmSheet to classify every OTHER name-matching worksheet (besides the one actually chosen/parsed). ── What this replaces (F1/F2) ────────────────────────────────────────────── The original KAR-927 implementation gated `ignoredCandidateSheets` on each candidate's OWN `coreFieldsFound` — computed by calling the module's full `parseXWorksheet` (worksheetToGrid over the WHOLE sheet, then a complete header-column mapping AND a full data-row extraction pass) on every name-matching candidate, discarding the result immediately after reading one boolean. Two adversarial-review findings on that approach: F2 (perf/cost): `isSbmSheetName`'s alias list intentionally includes the bare substring \"sbm\" (module-sheet-names.ts) — every real BMW template // allow-customer-string bundles non-data \"SBM_Matrix\"/\"SBM_Dropdown\" reference/lookup tabs that // allow-customer-string also match it (real-corpus finding, 12.07.2026). That means 2 EXTRA full worksheet parses fired on nearly every real ingest for a signal nobody asked for on the common path. F1 (correctness): the ALL-OR-NOTHING `coreFieldsFound` gate (every one of CORE_SBM_FIELD_KEYS/CORE_MATERIAL_FIELD_KEYS located) swallowed exactly the genuinely-broken second sheets KAR-927 exists to surface — e.g. a Multi-QAF file bundling a DE and an EN SBM-DEVICES-FWZ tab where the EN copy's Positionsnummer header was renamed/corrupted: `coreFieldsFound` came back false, so the whole candidate silently vanished from `ignoredCandidateSheets` — invisible again, the exact failure mode KAR-927 was supposed to fix. ── The fix ────────────────────────────────────────────────────────────── `ignoredCandidateSheets` is now unconditional (F1) — EVERY other name-matching worksheet is listed, tagged with a cheap `plausibleData` boolean instead of being filtered out. `plausibleData` is computed by scanning only the first `rowLimit` rows (the SAME window findMaterialHeaderRow/findSbmHeaderRow already use — HEADER_SCAN_MAX_ROWS in both callers, passed in here rather than hardcoded so the two callers cannot silently drift from their own header-row finder's window) x `colLimit` columns, read directly via `ws.getRow()`/`row.eachCell()` — NEVER `worksheetToGrid` (which walks ExcelJS's own row iterator over the ENTIRE sheet with no bound at all), no data-row extraction, no rule-engine run (F2). The check itself looks for the presence of ONE single strongest identity label per module (materialDesignation / toolFixtureType — both already members of that module's own CORE_*_FIELD_KEYS, reusing the exact same registry-driven `matchHeaderColumnSync` a real header-row scan uses, not a newly hardcoded label string) rather than requiring every core field — deliberately WEAKER than `coreFieldsFound`, which is what fixes F1: a sheet with a broken Positionsnummer column but an intact Werkzeug-/ Vorrichtungsart or Materialbezeichnung column now still reports `plausibleData: true`. Deliberately generic over the identity check (`isIdentityLabel` callback) so this file never imports canonical-model.ts/canonical-fields.ts itself — each caller supplies its OWN already-loaded registry context, preserving the \"heavy registry stays out of the static import graph\" bundle discipline material-parser.ts/sbm-parser.ts's own module headers document (this file is transitively reachable from the same 'use client' barrel).",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export interface IgnoredCandidateSheetEntry { | export type LegacyOrIgnoredCandidateSheetEntry = string | IgnoredCandidateSheetEntry | export interface IgnoredCandidateSheetsMeta { | export function normalizeIgnoredCandidateSheets( | export const CANDIDATE_SCAN_MAX_COLS = 80 | export function worksheetHasHeaderLabelInRegion(",
      "source_heading_line": 414,
      "source_end_line": 476
    },
    {
      "path": "lib/qaf-differences/internal/canonical-fields.ts",
      "category": "core",
      "declared_lines": 4400,
      "comment_summary": "Canonical QAF field registry — data (KAR-892 / P1.1 fundament). This is the versioned, code-config field catalogue the backlog item calls for (\"Feldregistry zunächst als versionierte TS-Datenstruktur ... generisch für beliebige Module\"). Pure data — canonical-model.ts holds the access functions (findByAlias/byModule/byCanonicalId/validateRegistry). ── Coverage per module (read this before trusting a field's completeness) ── SUMMARY FULL for the 13 identity fields (QafSummary) + 20 money metrics (SummaryMetricKey) Kadi-v2 now parses — labels/ aliases lifted straight from rule-engine.ts SUMMARY_FIELD_ LABELS and summary-metrics.ts SYNONYMS (already verified against real files). PLUS a CORE (not yet parsed by Kadi-v2 — no QafSummary/SummaryMetricKey slot exists) block for the REMAINING \"Allgemeine Prämissen\" / \"Prämissen Lieferant\" / \"Sonstige Daten\" fields (Leitfaden S.10-12), included because they belong to the same already-parsed SUMMARY sheet and the Leitfaden tabulates them completely — flagged `notes: 'not parsed by Kadi-v2 today'` per entry. KAR-910 (fehlerreport-analyse.md §5 Fälle #10/#12/#14/#16/#23) promoted 4 of these premise fields (sum_peak_volume_year/sum_production_start_sop/ sum_delivery_site/sum_shifts_per_week — QafSummary went from 9 to 13 fields) plus 1 net-new, Leitfaden-undocumented money metric (sum_cost_breakdown_aw1 — SUMMARY_METRIC_KEYS went from 19 to 20) from registry-only to actually parsed; see each entry's `notes` for the exact evidence. MANUFACTURING FULL — all 22 QAFFieldKey values, DE+EN labels taken verbatim from qaf-parser.ts HEADER_TO_KEY (both label variants are already shipped/verified against real BMW // allow-customer-string files, not a translation guess). G60 CORE — the 5 real-file-verified header-row anchors (structure-guard.ts TAB_HEADER_ANCHORS, verified against 2 real G60 workbooks 2026-07-09) + the 8 INPUT!B22-B29 SG&A/profit rate-card labels (also real-file-verified, see structure-guard.ts module header). Deliberately NOT the full ~14-column fixed-coordinate extraction (parser.ts extractFullTab) — those columns have no independently verified label text yet (P0.3 module header: \"keine echten BMW-G60-Testdateien im Repo\" for most of them); adding // allow-customer-string unverified labels here would misrepresent them as Leitfaden-sourced when they are not. G60 is a separate BMW-internal detail-costing tool, not a sheet documented // allow-customer-string in the general QAF-Leitfaden — its evidence is code (parser.ts/structure-guard.ts), not Leitfaden pages. MATERIAL CORE, but Leitfaden S.18-20 tabulates the sheet's fields completely, so this is effectively near-full for the fields BMW documents (28 Leitfaden entries + 1 // allow-customer-string Fehlerreport-only aggregate field added KAR-910, mat_packaging_cost_offer, see that entry's `notes`). Requirement (mandatory/conditional/optional) is NOT // allow-customer-string explicitly Pflicht-tagged per field in the Leitfaden for this module (unlike SBM, which has an explicit Pflicht column) — every MATERIAL entry below defaults to 'optional' unless the Leitfaden text explicitly says \"nur Rohmaterial\"/\"bedingt\" (-> 'conditional'). Do not read 'optional' here as \"BMW says this is optional\" — // allow-customer-string read it as \"not yet Fachlich klassifiziert\", pending P1.6. SBM CORE (35 Leitfaden entries + 2 added KAR-910 — sbm_anzahl_stufen_takte_aktiv/_leer, splitting the Aktiv/Leer sub-columns the Leitfaden's own screenshot [38] already shows under the combined sbm_anzahl_stufen_takte header group, see that entry's `notes`), same near-full situation as MATERIAL — Leitfaden S.34-37 DOES have an explicit Pflicht/optional/bedingt column, so `requirement` below is a genuine Leitfaden-sourced classification for this module. LOGISTICS, RMR, LC_CN, CO2E, WAF, LAF, LEK CORE representative subsets (12-17 entries each) of modules Kadi-v2 does not parse at all today (P1.6/P1.7/ P2.x territory) — chosen to cover each module's identity/ header fields plus its 1-2 defining cost/result fields, not every column the Leitfaden tabulates. `requirement` defaults to 'optional' (no Pflicht column documented for these modules in the extracted Leitfaden sections) unless the Leitfaden text says \"bedingt\". ── EN-label verification status (be honest about this, KAR-892 instruction) ─ SUMMARY + MANUFACTURING: EN labels are code-verified (HEADER_TO_KEY, already matched against real BMW QAF v8/v9 EN customer files). // allow-customer-string Every other module: the Leitfaden extraction used for this PR only tabulates GERMAN field names (the source document's field tables are DE-only outside the two already-shipped dictionaries) — EN labels below are a working English translation of the DE label/description, NOT verified against a real shipped English-language QAF template for that module. templateVersions for those modules stays scoped to the DE template tag only (no 'V8_EN'/'V9_EN' claim) to make this explicit. ── Documented BMW source-data typos/duplicates (Master-Prompt §6 requirement) ─ // allow-customer-string - \"manufactering\" (sheet-name typo, MANUFACTURING module) — already handled by qaf-parser.ts's sheet-name matcher (`n.includes('manufactering cost')`), recorded as a sheet-name alias (not a field alias, it is a sheet name not a field label) in the centralized DE/EN sheet-name registry: ./module-sheet-names.ts's MODULE_SHEET_NAME_ALIASES (KAR-905/P3.1 — every module's isXxxSheetName evidence lives there now, this file used to carry its own single-module copy before P3.1). - \"sucharge\" (G60 column Y typo for \"surcharge\") — alias on g60_raw_material_surcharge_y, sourced to structure-guard.ts. - \"Teilebennung\" (typo for \"Teilebenennung\", MATERIAL + duplicated 2x per fehlerreport-analyse §3.3) — alias on mat_part_designation. - \"Verrechungsform\" (typo for \"Verrechnungsform\", SBM) — alias on sbm_verrechnungsform. - \"Produdktionsstart\" (typo for \"Produktionsstart\", SUMMARY premises) — alias on sum_production_start_sop. - Real duplicate \"Mengeneinheit\" label in MATERIAL (fehlerreport-analyse §3.3/§4.2 R6: row #31 is the genuine \"Mengeneinheit\"/Unit-of-Measure field; row #36 is a label-copy error where the REAL field is \"Materialgemeinkosten MGK\" but the BMW file's own column literally // allow-customer-string reads \"Mengeneinheit\" too) — modeled as an intentional collision via `collisionGroup: 'material_mengeneinheit_label_duplicate'` on both mat_unit_of_measure and mat_overhead_cost, exactly the case KAR-892 asks findByAlias()/validateRegistry() to support without forbidding. tdd-guard:skip — pure constant data, same category as summary-metrics.ts's SYNONYMS/TEMPLATE_CONFIG and rule-engine.ts's STEP_FIELD_LABELS. Structural invariants (uniqueness, evidence, DE+EN on mandatory fields, backward- mapping completeness) are exercised by __tests__/canonical-model.test.ts.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export const CANONICAL_FIELDS: CanonicalField[] = [ | export const QAF_FIELD_KEY_TO_CANONICAL: Record<QAFFieldKey, string> = { | export const SUMMARY_METRIC_KEY_TO_CANONICAL: Record<SummaryMetricKey, string> = | export const MATERIAL_FIELD_KEY_TO_CANONICAL: Record<MaterialFieldKey, string> = { | export const SBM_FIELD_KEY_TO_CANONICAL: Record<SbmFieldKey, string> = { | export const RMR_FIELD_KEY_TO_CANONICAL: Record<RmrFieldKey, string> = { | export const LOG_FIELD_KEY_TO_CANONICAL: Record<LogisticsFieldKey, string> = { | export const LCCN_FIELD_KEY_TO_CANONICAL: Record<LccnFieldKey, string> = { | export const CO2E_FIELD_KEY_TO_CANONICAL: Record<Co2eFieldKey, string> = {",
      "source_heading_line": 477,
      "source_end_line": 604
    },
    {
      "path": "lib/qaf-differences/internal/canonical-fields.types.ts",
      "category": "core",
      "declared_lines": 132,
      "comment_summary": "Shared types for the canonical QAF field model (KAR-892 / P1.1). Split out from canonical-model.ts so the pure data file (canonical-fields.ts) can import just the types without creating a data <-> access-function circular import (canonical-model.ts imports the CANONICAL_FIELDS constant from canonical-fields.ts; canonical-fields.ts only needs the types, not the access functions). tdd-guard:skip — type declarations only, no logic (same category as types.ts).",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": false,
      "public_signatures": "export type QafModule = | export type CanonicalDataType = | export type CanonicalClassification = | export type CanonicalRequirement = \"mandatory\" | \"conditional\" | \"optional\" | export type CanonicalLevel = \"row\" | \"summary\" | export interface CanonicalFieldEvidence { | export interface CanonicalField { | export interface RegistryValidationResult {",
      "source_heading_line": 605,
      "source_end_line": 625
    },
    {
      "path": "lib/qaf-differences/internal/canonical-model.ts",
      "category": "core",
      "declared_lines": 227,
      "comment_summary": "Canonical QAF field model + alias registry (KAR-892 / P1.1, Phase-1 foundation). Problem this closes (backlog 05-backlog-phasenplan.md [P1.1]): Kadi-v2 has two separate DE/EN dictionaries for Fertigungskosten (qaf-parser.ts HEADER_TO_KEY), one synonym system only for Summary-Metrics (summary-metrics.ts SYNONYMS/NORM_SYN), and no alias system at all for G60 or the seven still-unparsed QAF-9.1 modules (MATERIAL, SBM-DEVICES-FWZ, LOGISTICS&CUSTOM, RAW MATERIAL RISKS, LC-CN, Carbon Footprint, WAF/LAF/LEK). This module is the language-independent, data-driven registry every future module (P1.6/P1.7/P2.x) registers into instead of inventing another ad-hoc dictionary — exactly the \"Fundament\" role the backlog item names. Scope discipline (backlog P1.1 + Master-Prompt §6, deliberately followed here — see canonical-fields.ts module header for the per-module coverage table): - Additive only. HEADER_TO_KEY (qaf-parser.ts) and SYNONYMS/NORM_SYN (summary-metrics.ts) are UNTOUCHED — this module ships a *parallel* mapping (QAF_FIELD_KEY_TO_CANONICAL / SUMMARY_METRIC_KEY_TO_CANONICAL in canonical-fields.ts) from the 22 existing QAFFieldKey values and the 19 existing SummaryMetricKey values onto stable canonical ids. Wiring the parsers themselves onto this registry is P1.2 — explicitly out of scope here (backlog: \"Bestehende SYNONYMS und HEADER_TO_KEY-Wörterbücher werden als Aliase in die neue Struktur migriert (kein Verhaltens-Change, nur Refactor der Datenhaltung)\" happens later, once this fundament is reviewed). - No DB persistence (backlog: \"DB-Persistenz der Registry ... ist ein separates, späteres Item\", ties to P1.5 engine-config governance). Versioned TS constant data, same pattern as summary-metrics.ts's QAF_LEGACY_DE_SUMMARY/QAF_V9_SUMMARY template configs. - Pure. No I/O, no Excel access — this module only classifies/looks up field metadata, it never reads a workbook. tdd-guard:skip — type declarations only (same category as types.ts); the access functions below (findByAlias/byModule/byCanonicalId/validateRegistry) and the label normalizer are covered by __tests__/canonical-model.test.ts.",
      "mentions_todo_or_future": true,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export type { | export const QAF_MODULES: readonly QafModule[] = [ | export function normalizeCanonicalLabel(raw: string): string { | export function byModule( | export function byCanonicalId( | export function findByAlias( | export function validateRegistry(",
      "source_heading_line": 626,
      "source_end_line": 670
    },
    {
      "path": "lib/qaf-differences/internal/capability/calculator-bridge.ts",
      "category": "core",
      "declared_lines": 202,
      "comment_summary": "Preis-Kalkulator bridge for Summary-mode comparisons (KAR-960/P3, Master- Prompt §22 Partial Module Population, task instruction 3a: \"Preis- Kalkulator ... Alt/Neu-Chips auch bei Summary-Vergleichen anbieten, gespeist aus Hebel A+B\"). Before this PR, getCalculatorParams (app/qaf-differences/actions.ts) only ever ran for `comparison_mode: 'g60'` — a Summary comparison's Preis- Kalkulator page always rendered `comparisons: []`, so NO chip ever appeared, regardless of how much the Fertigungskosten/Zusammenfassung sheets actually contained (the exact §22 violation the task names). This module builds the analogous CalculatorSideParams-shaped object from Hebel A (a selected manufacturing step's process parameters) + Hebel B (the file's resolved Summary master rates) — but, UNLIKE G60's calculatorParamsFrom (which always returns a fully-populated object or null), every field here is populated INDEPENDENTLY and stays `null` when no source produced a value (§22: \"the module must not be disabled ... unavailable values must be marked separately\", §23: \"never show empty 0,00 fields as real values\"). A caller (getCalculatorParams) widens CalculatorSideParams's field types to `number | null` to carry this distinction all the way to the chip-rendering check in qaf-price-calculator.tsx (`altVal !== null`), which already treats `null` as \"no chip\" — no UI change needed there beyond the wider type. §21 (process-specific over one global master): cycleSec/partsPerCycle/ employees/labourRatePerHour/machineRatePerHour/scrapRate all prefer the SELECTED manufacturing step's own per-process value; scrapRate falls back to the Summary master scrap rate ONLY when the step itself has none. sgaFkRate/sgaMatRate/profitFkRate/profitMatRate have no per-process equivalent in the standard Fertigungskosten sheet at all (G60-INPUT-only concept, see field-registry.ts cap_sum_master_* docs) — always the Summary master rate (same value for FK/MAT, a documented ASSUMPTION: the Summary sheet carries one SG&A/profit rate, not G60-INPUT's directed/sourced FK/ MAT split). materialPerUnit and inefficiency have NO source in this PR's scope (MATERIAL-module wiring and G60-only \"Ineffizienz\" respectively — see manufacturing-field-candidates.ts PROCESS_PARAMETER_FIELD_KEYS doc) — always null, honestly, never a fabricated 0. tdd-guard:skip — covered by __tests__/calculator-bridge.test.ts.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": true,
      "public_signatures": "export interface SummaryCalculatorSideParams { | export interface SummaryMasterRates { | export const EMPTY_SUMMARY_MASTER_RATES: SummaryMasterRates = { scrapRate: null, sgaRate: null, profitRate: null } | export function calculatorParamsFromSummaryStep( | export function summaryCalculatorStepOptions( | export function summaryCalculatorStepLabels(",
      "source_heading_line": 671,
      "source_end_line": 718
    },
    {
      "path": "lib/qaf-differences/internal/capability/capability-detector.ts",
      "category": "core",
      "declared_lines": 453,
      "comment_summary": "WorkbookCapabilityDetector (KAR-959/P2, Master-Prompt §11). Turns \"parsed workbook facets (post-KAR-958/P1, including FacetDegradation) + sheet roles (sheet-resolver.ts)\" into a WorkbookCapabilityMatrix — one CapabilityStatus per module, with confidence, source sheet, and (when the module is not fully AVAILABLE) an EmptyFieldReason WHY, reusing ../types.ts's KAR-958/P1 taxonomy rather than growing a second one. Two layers: 1. `detectWorkbookCapabilities` — the PURE detection algorithm. Takes already-resolved SheetRoleAssignment[] + a per-module FacetParseSignal map (a small, parser-shape-independent normalization of \"was this module attempted, how many fields mapped, core fields found, degradation?\"). Fully synthetic-fixture-testable, no workbook/ExcelJS dependency. 2. `computeWorkbookCapabilityMatrix` — the adapter for callers with REAL parser outputs (MaterialParseResult, SbmParseResult, ..., QafSummary, SummaryMetricsParse, the manufacturing QAFParseResult array). This is the one function app/qaf-differences/actions.ts calls (KAR-959 task instruction point 4, \"EINE minimale Integration\"). tdd-guard:skip — covered by __tests__/capability-detector.test.ts (synthetic facet fixtures) + the env-gated real-file validation test.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export interface FacetParseSignal { | export function facetSignalFromRowArray(module: QafModule, sheet: string | null, result: RowArrayFacetResult | null): FacetParseSignal { | export function facetSignalFromSingleRecord(module: QafModule, sheet: string | null, result: SingleRecordFacetResult | null): FacetParseSignal { | export function facetSignalFromManufacturingSteps( | export function facetSignalFromSummary(sheet: string | null, summary: QafSummary, summaryMetrics: SummaryMetricsParse | null): FacetParseSignal { | export const AVAILABLE_CONFIDENCE_THRESHOLD = 0.95 | export function detectWorkbookCapabilities(sheetRoles: ReadonlyArray<SheetRoleAssignment>, facets: Partial<Record<QafModule, FacetParseSignal>>): WorkbookCapabilityMatrix { | export interface WorkbookCapabilityInput { | export function computeWorkbookCapabilityMatrix(input: WorkbookCapabilityInput): WorkbookCapabilityMatrix {",
      "source_heading_line": 719,
      "source_end_line": 754
    },
    {
      "path": "lib/qaf-differences/internal/capability/cost-allocation-family.ts",
      "category": "core",
      "declared_lines": 441,
      "comment_summary": "\"Umlageschema-Kalkulation\" template family onboarding (KAR-961/P4, gate-audit.md capability-matrix.md finding #3 — see qaf-corpus reports, not committed here). Problem this closes: a small (per the KAR-961 task's corpus finding: 4 reference files, plus whatever the same generation produces going forward) but structurally DIFFERENT template generation — no INPUT rate-card + numbered cost tabs (G60), no SUMMARY/Fertigungskosten split (standard summary QAF), no multi-column variant grid (Multi-QAF) — was classifying as `unmatched`/`standard_summary` with `matchedProfile: null` and 0 extracted fields, purely because none of the three EXISTING detectors' fixed vocabularies matched its sheet names. Scope discipline (KAR-961 task instruction, \"konservativ, lieber PARTIAL als falsch\"): - This module ONLY detects the family and extracts what real-file inspection (label texts/sheet structure only — no cell VALUES beyond what is documented as ALREADY non-confidential structural evidence below, no monetary content, no supplier/contact names) confirmed is safely, unambiguously locatable: a handful of identity/meta labels on the \"Kalkulation\" sheet's header block (present, same layout, on both known real reference files). - Everything else this family plausibly carries (the SUMMENZEILE/sum row inside \"Übersicht\", the \"Kalkulation\" sheet's ~900-row material detail grid, the Overhead/Ertrag/MGK rate labels on \"Deckblatt\", the per-plant wage rates on \"Lohnsatz\", the ~5-20 \"Umlage*\"/SEKOF cost-distribution sheets themselves) is reported as a RECOGNIZED SHEET ROLE with NO parser yet (DERIVABLE, Master-Prompt §11) — never silently reported as MISSING (that would misrepresent \"not calibrated yet\" as \"genuinely absent\") and never guessed at (that would risk extracting the WRONG cell as if it were right — worse than reporting nothing). This is intentionally the FOUNDATION stage: blank/official template variants of this family were not available when this PR was written (task instruction: \"Blanko-Templates kommen evtl. später\"), so every threshold/column position below is real-file-confirmed on the ONLY two concrete instances inspected, not corpus-calibrated — the two sub-profiles, alias lists and META_LABELS below are a plain config/data table specifically so a later calibration pass (once more real or blank samples exist) can widen them without touching this module's algorithms (task instruction: \"Konfig-getriebene Profile, nicht Hardcode\"). Confidentiality discipline (KAR-961 task instruction): no real program/supplier name appears anywhere in this file — \"Umlageschema- Kalkulation\" is the descriptive German business term for the family's own cost-allocation-scheme structure (same term gate-audit.md's own finding used), not a customer/program identifier. Sheet names quoted below (Kalkulation/Deckblatt/Lohnsatz/Übersicht/Prämissenblatt/Umlage*) are the workbook's own STRUCTURAL tab names, not business content.",
      "mentions_todo_or_future": true,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export type CostAllocationSubProfile = 'wide' | 'narrow' | export const COST_ALLOCATION_FAMILY_ID = 'UMLAGESCHEMA_KALKULATION' | export interface CostAllocationFamilyProfile { | export const COST_ALLOCATION_FAMILY_PROFILES: readonly CostAllocationFamilyProfile[] = [ | export interface CostAllocationFamilySheets { | export interface CostAllocationFamilyDetection { | export function detectCostAllocationFamily(sheetNames: readonly string[]): CostAllocationFamilyDetection { | export function resolveCostAllocationSheetRoles( | export interface CostAllocationMetaField { | export type CostAllocationMetaKey = 'bearbeiter' | 'email' | 'telefon' | 'angebotsdatum' | export function extractCostAllocationMeta(kalkulationSheet: CellGetter): Record<CostAllocationMetaKey, CostAllocationMetaField> { | export function buildCostAllocationCapabilityMatrix(",
      "source_heading_line": 755,
      "source_end_line": 818
    },
    {
      "path": "lib/qaf-differences/internal/capability/field-candidates.ts",
      "category": "core",
      "declared_lines": 155,
      "comment_summary": "FieldCandidateCollector + FieldConflictResolver (KAR-959/P2, Master-Prompt §14). Master-Prompt §14: \"For every canonical field, collect all plausible source candidates before selecting a preferred value ... Preserve all candidates ... Apply documented precedence rules ... Flag inconsistencies ... Never silently discard a conflicting candidate.\" This module is that two-step pipeline: collectFieldCandidates groups already-extracted FieldCandidate entries by fieldId (it does not itself read a workbook — candidates are produced by capability-detector.ts's facet adapters, or by a future finer-grained per-field extractor, P3+); resolveFieldCandidates then applies a documented, deterministic priority rule to pick a preferred source WITHOUT discarding the others. STATUS (KAR-959 review finding #3, PR #326): this pipeline has ZERO production producers today — nothing in app/qaf-differences/actions.ts (or anywhere else outside this directory) calls collectFieldCandidates/ resolveFieldCandidates/resolveAllFieldCandidates or constructs a FieldCandidate. It is exercised exclusively by __tests__/field-candidates.test.ts. This is deliberate, not dead code to delete: P3 (KAR-960) is the tracked follow-up that wires a real per-field extractor to actually produce FieldCandidate[] and consume this resolver — kept in this PR ahead of that wiring per task instruction (the §14 pipeline is part of the Capability- Kern's architectural surface, landed once rather than twice). Internal-only export (see internal/ vs. index.ts barrel) — no external consumer needs it before P3, so it is intentionally NOT re-exported from lib/qaf-differences/ index.ts's public barrel until a real consumer exists. tdd-guard:skip — narrow, fully covered by __tests__/field-candidates.test.ts; no sibling-file exemption needed beyond the usual convention for this codebase's smaller pure modules.",
      "mentions_todo_or_future": true,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export function collectFieldCandidates<T = unknown>(candidates: ReadonlyArray<FieldCandidate<T>>): Map<string, FieldCandidate<T>[]> { | export const CANDIDATE_VALUE_RELATIVE_TOLERANCE = 1e-6 | export function candidateValuesEqual(a: unknown, b: unknown): boolean { | export function rankFieldCandidates<T>(candidates: ReadonlyArray<FieldCandidate<T>>): FieldCandidate<T>[] { | export function resolveFieldCandidates<T = unknown>(fieldId: string, candidates: ReadonlyArray<FieldCandidate<T>>): FieldResolution<T> { | export function resolveAllFieldCandidates<T = unknown>(candidates: ReadonlyArray<FieldCandidate<T>>): FieldResolution<T>[] {",
      "source_heading_line": 819,
      "source_end_line": 858
    },
    {
      "path": "lib/qaf-differences/internal/capability/field-registry.ts",
      "category": "core",
      "declared_lines": 299,
      "comment_summary": "CanonicalFieldRegistry — capability-scoped view (KAR-959/P2, Master-Prompt §13). Master-Prompt §13 asks for \"a complete inventory of every field found anywhere in the corpus\" with (among other things) id/module/DE+EN labels/unit/scope. KAR-892/P1.1 already shipped exactly that inventory — ../canonical-fields.ts's CANONICAL_FIELDS, ~250 fields across the 12 QAF modules, DE+EN labels, unit, classification, requirement, level — as a reviewed, evidence-cited, versioned TS data structure. Duplicating that registry here would be the \"kein Hardcode-Wildwuchs\" anti-pattern the task instruction explicitly warns against. This module is instead a thin, ADDITIVE layer on top of it: 1. `deriveFieldScope` — the one dimension canonical-fields.ts does not carry (global|variant|process, Master-Prompt §17) — computed deterministically from the existing `level` field, not hand-tagged. 2. `EXTRA_CAPABILITY_FIELDS` — a small number of fields the corpus analysis / task instruction names that are NOT yet in canonical-fields.ts (verified absent by grep before adding, see each entry's own comment) — kept in a SEPARATE array, never spliced into canonical-fields.ts's own CANONICAL_FIELDS export, so P1's already- reviewed registry file stays untouched by this PR. 3. Thin lookup helpers (byModule/byScope/allFields) that merge both sources — the single surface capability-detector.ts and field-candidates.ts consume. tdd-guard:skip for the EXTRA_CAPABILITY_FIELDS data block itself (pure data, same category as canonical-fields.ts); the derive/lookup functions below are covered by __tests__/field-registry.test.ts.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export type CapabilityFieldScopeKind = 'global' | 'process' | export function deriveFieldScope(field: Pick<CanonicalField, 'level'>): CapabilityFieldScopeKind { | export const CAPABILITY_FIELDS: readonly CanonicalField[] = [...CANONICAL_FIELDS, ...EXTRA_CAPABILITY_FIELDS] | export interface CapabilityFieldRegistryEntry extends CanonicalField { | export function fieldsForModule(module: QafModule): CapabilityFieldRegistryEntry[] { | export function fieldsByScope(scope: CapabilityFieldScopeKind, registry: readonly CanonicalField[] = CAPABILITY_FIELDS): CapabilityFieldRegistryEntry[] { | export function findFieldByAlias(label: string, lang?: 'de' | 'en'): CapabilityFieldRegistryEntry[] { | export function moduleSheetRole(module: QafModule): Exclude<SheetRole, 'unknown'> | null {",
      "source_heading_line": 859,
      "source_end_line": 898
    },
    {
      "path": "lib/qaf-differences/internal/capability/manufacturing-field-candidates.ts",
      "category": "core",
      "declared_lines": 113,
      "comment_summary": "Manufacturing-step FieldCandidate producer (KAR-960/P3, Hebel A — capability-matrix.md \"1. Prozess-Parameter (A) aus Fertigungskosten-Blatt statt nur G60-INPUT — ~346 Dateien\"). qaf-parser.ts's parseQAFTemplate ALREADY extracts every process-parameter column the capability-matrix names (Zykluszeit/Teile-pro-Zyklus/MA/Lohn/ SGK/MSS/Ausschuss — see qaf-parser.ts QAFRowValues) from the Fertigungskosten/Manufacturing-costs sheet, per manufacturing step row — that part of Hebel A already shipped before this PR. What was missing (the actual capability-matrix finding: \"heute zieht die App diese Felder aber nur aus G60-INPUT\") is that this already-extracted, already-canonical QAFRow[] data never flowed into the Capability-Kern's own vocabulary (ScopedValue<T>/FieldCandidate, KAR-959/P2 internal/capability/types.ts) — every G60-only consumer (the price calculator, capability-status displays) had no way to ask \"is a process-level cycle time available for THIS (non-G60) file\" without re-deriving it from the raw QAFRow shape itself. This module is that bridge: turns an already-parsed QAFRow[] into FieldCandidate[] scoped `process:<positionsnummer>` (Master-Prompt §21: \"Do not leave all machine-rate information empty ... Display the process-level rates ... Make process-level rates available to compatible modules\" — NEVER `global`, these are per-manufacturing-step values, not a G60-INPUT-style master rate; mixing the two scopes is exactly what §21 forbids). Pure, no I/O — the QAFRow[] input is whatever qaf-parser.ts (or a rehydrated qaf_manufacturing_step.raw_values row) already produced. tdd-guard:skip — covered by __tests__/manufacturing-field-candidates.test.ts.",
      "mentions_todo_or_future": true,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export const PROCESS_PARAMETER_FIELD_KEYS: readonly QAFFieldKey[] = [ | export function manufacturingStepFieldCandidates(",
      "source_heading_line": 899,
      "source_end_line": 931
    },
    {
      "path": "lib/qaf-differences/internal/capability/module-capability-resolver.ts",
      "category": "core",
      "declared_lines": 112,
      "comment_summary": "ModuleCapabilityResolver (KAR-959/P2, Master-Prompt §32). The replacement-gate API: instead of a rigid template-classification check like if (!isG60DetailQaf) { return emptyProductionData } or showPriceCalculator = workbookType === 'G60_DETAIL' (gate-audit.md's 17 blocking gates, B1-B17), Master-Prompt §32 asks for capability-based logic: if (capabilities.hasManufacturingDetail) { return extractAvailableManufacturingData(workbook) } showPriceCalculator = availableFields.length > 0 || derivableFields.length > 0 This module is that API surface, built on top of capability-detector.ts's WorkbookCapabilityMatrix. It does NOT touch any of the 17 cataloged gates itself (gate-audit.md §4's replacement order is explicitly P3/P4 — out of this PR's scope, task instruction point 3: \"die Gates selbst werden erst in P3/P4 umgestellt — du lieferst die API, auf die sie umziehen\"). tdd-guard:skip — covered by __tests__/module-capability-resolver.test.ts.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": true,
      "public_signatures": "export function moduleCapability(matrix: WorkbookCapabilityMatrix, module: QafModule): ModuleCapability | undefined { | export function isModuleAvailable(matrix: WorkbookCapabilityMatrix, module: QafModule): boolean { | export function isModuleUsable(matrix: WorkbookCapabilityMatrix, module: QafModule): boolean { | export function availableModules(matrix: WorkbookCapabilityMatrix): QafModule[] { | export function usableModules(matrix: WorkbookCapabilityMatrix): QafModule[] { | export function shouldShowSection(matrix: WorkbookCapabilityMatrix, modules: readonly QafModule[]): boolean { | export interface CapabilityCoverageSummary { | export function summarizeCoverage(matrix: WorkbookCapabilityMatrix): CapabilityCoverageSummary {",
      "source_heading_line": 932,
      "source_end_line": 961
    },
    {
      "path": "lib/qaf-differences/internal/capability/not-a-qaf-detector.ts",
      "category": "core",
      "declared_lines": 194,
      "comment_summary": "Not-a-QAF detection + Unknown-Pattern collection (KAR-962/P5, Master-Prompt §30, task scope point 3). Problem this closes: capability-matrix.md's real-corpus analysis found that 8 of the 12 \"unmatched standard\" files in the full 374-file corpus are not QAF quote forms at all — they are DERIVED analysis/tracking workbooks (a price-comparison matrix, a should-cost checklist/calculator, a cost-trend tracker, a supplier-family reconciliation report) that happen to weakly match `standard_summary`'s classification today. Ingesting one of these produces a confusing, essentially EMPTY comparison (no SUMMARY/ MANUFACTURING/MATERIAL/... module recognized anything) with no explanation of WHY — exactly the failure mode Master-Prompt §30 warns against (\"An unknown workbook must not produce an empty result... Prefer UNKNOWN_RELEVANT over silent omission\"). Design (deliberately structural, not filename/hash-based — the task's own \"beim Ingest sauber behandeln\" wants this to generalize to a FUTURE not-yet-seen non-QAF upload, not just the 3 confirmed dev-split examples this PR's real-corpus scan found): a workbook is flagged `not_a_qaf` (SUSPECTED — this is evidence for a user-facing warning, never a hard reject/throw like foreign-form-detection.ts's WAF/LAF/LEK gate, because the signal is weaker: \"recognizes nothing\" could also mean \"a genuinely new QAF family this PR's registries don't know about yet\" — Master-Prompt §0: \"a template/family label is a hint, never a blocking gate\") when: 1. It is NOT a G60-Detail file (own fixed-coordinate pipeline, checked by the caller BEFORE this function even runs — see actions.ts call site). 2. It does NOT match the Umlageschema-Kalkulation family (cost-allocation-family.ts's own detector — a real, if structurally unusual, QAF family that also recognizes none of the 8 standard modules by sheet name, so it MUST be excluded here to avoid a false positive). 3. Sheet-resolver.ts's resolveSheetRoles finds ZERO sheets carrying any of the 8 canonical QAF-module roles (summary/manufacturing/material/ sbm/rmr/logistics/lccn/co2e) anywhere in the workbook — `setup`/ `input`/`premise`/`unknown` alone do not count (a genuine QAF's Rüstkosten/INPUT/Prämissenblatt sheet can exist ALONGSIDE a recognized module sheet; it never exists ALONE). Confidentiality discipline: no real filename/supplier/program name appears anywhere in this module or its tests — every example below is invented. tdd-guard:skip — pure detection + a thin PlausibilityIssue bridge (same category as template-fingerprint.ts's own persistence-bridge section) — covered by __tests__/not-a-qaf-detector.test.ts.",
      "mentions_todo_or_future": true,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export interface NotAQafDetection { | export function detectNotAQaf( | export function notAQafToPlausibilityIssue(detection: NotAQafDetection, side: 'ALT' | 'NEU', fileLabel: string): PlausibilityIssue | null { | export interface UnknownPatternRecord { | export function structuralPatternFingerprint(sheetNames: readonly string[]): string { | export function collectUnknownPattern(detection: NotAQafDetection, detectedAt: string): UnknownPatternRecord | null {",
      "source_heading_line": 962,
      "source_end_line": 1013
    },
    {
      "path": "lib/qaf-differences/internal/capability/premise-field-candidates.ts",
      "category": "core",
      "declared_lines": 55,
      "comment_summary": "Prämissenblatt/Assumptions-sheet FieldCandidate producer (KAR-962/P5, P3-follow-up — Master-Prompt §21/§27). Problem this closes: KAR-960/P3's summary-field-candidates.ts scanned the Zusammenfassung/Summary sheet for the 4 Master-Sätze (Lohnsatz/SG&A/ Gewinn/Scrap) and found them there in only 14-22% of the corpus (capability-matrix.md's own \"B (Master-Sätze) systematic weak spot\" finding). KAR-962's own task instruction directs re-checking the \"Prämissenblatt\" (\"premises sheet\") as an alternate source — DE-language files carry one per capability-matrix.md's sheet-inventory note, and KAR-962/P5's own DuckDB dev-split scan (sheet-resolver.ts EXTRA_ROLE_ALIASES comment) confirms it structurally: a \"Prämissenblatt\"/\"Assumptions sheet\" tab is present in 172+45 of 227 dev-split workbooks (76%+20%) — far more than the Summary sheet's 14-22% Master-Sätze label hit rate. Scope discipline (task instruction: \"konservativ, wie P3-Muster: eindeutige Labels only, sonst INCONSISTENT/MISSING\"): this module is a THIN wrapper around summary-field-candidates.ts's already-conservative masterRateLabelScanCandidates core (same label sets, same bounded scan-window, same \"one hit -> one candidate, never guess between multiple\" discipline) — no new extraction algorithm, just a second SOURCE for the SAME 4 fields. A hit on the Prämissenblatt and a hit on the Zusammenfassung sheet for the same field become two SEPARATE FieldCandidate entries; field-candidates.ts's resolveFieldCandidates decides SINGLE/AGREEMENT/ INCONSISTENT across BOTH sources exactly as it already does for two hits within one sheet — this module does not special-case cross-sheet agreement/conflict itself. tdd-guard:skip — thin wrapper, covered by __tests__/premise-field-candidates.test.ts.",
      "mentions_todo_or_future": true,
      "pure_or_no_io": false,
      "test_reference": true,
      "public_signatures": "export const PREMISE_MASTER_RATE_LABEL_SCAN_CONFIDENCE = 0.7 | export function premiseMasterRateFieldCandidates(grid: unknown[][], sheet: string | null): FieldCandidate<number>[] {",
      "source_heading_line": 1014,
      "source_end_line": 1048
    },
    {
      "path": "lib/qaf-differences/internal/capability/production-view.ts",
      "category": "core",
      "declared_lines": 280,
      "comment_summary": "Pure view-model builders for the Summary-comparison \"Produktionssicht / Raten\" (§6) and \"Anomalien & Treiber (Master-Sätze)\" (§7) sections (KAR-960/P3, Master-Prompt §22/§23 — Gate-Audit Top-5 #5). Both sections used to be UNCONDITIONAL PlaceholderSections (qaf-comparison-detail.tsx, qaf-section-registry.ts SUMMARY_PLACEHOLDERS) with a blanket \"Summary-QAFs enthalten keine Prozessdaten\"/\"kein INPUT-Blatt mit Master-Sätzen\" text — factually wrong for the ~346-file LEGACY/V9 majority once Hebel A (manufacturing-field-candidates.ts) and Hebel B (summary-field-candidates.ts) actually extract this data. This module computes, from already-available per-comparison data, whether each section has anything real to show and — per §23 — a PRECISE, evidence-based reason when it does not (\"no source field was identified after scanning all relevant sheets\", not a per-QAF-TYPE blanket claim). Pure, no I/O, no React — exercised directly by __tests__/production-view.test.ts; qaf-comparison-detail.tsx only renders what these functions return.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export interface ProductionStepRates { | export interface ProductionRateRow { | export interface StepRowLike { | export function buildProductionRateRows( | export interface SectionAvailability { | export type ProductionFieldDeltaKind = 'unfavorable' | 'favorable' | 'unchanged' | 'unknown' | export function productionFieldDelta( | export function productionViewAvailability(rows: readonly ProductionRateRow[]): SectionAvailability { | export interface MasterRateSideValue { | export interface MasterRateRow { | export function buildMasterRateRows( | export function anomaliesAvailability(rows: readonly MasterRateRow[]): SectionAvailability { | export function scenarioAvailability(processRowCount: number): SectionAvailability {",
      "source_heading_line": 1049,
      "source_end_line": 1082
    },
    {
      "path": "lib/qaf-differences/internal/capability/sheet-resolver.ts",
      "category": "core",
      "declared_lines": 256,
      "comment_summary": "SemanticSheetResolver (KAR-959/P2, Master-Prompt §15). Classifies every worksheet in a workbook into a SheetRole, so the rest of the capability-Kern (capability-detector.ts) never has to re-derive \"which sheet carries MATERIAL data\" from a raw sheet-name string itself. Reuses the EXISTING module-sheet-names.ts DE/EN alias registry (KAR-905/P3.1, matchesModuleSheetName) as the PRIMARY signal — task instruction explicitly calls for building \"über die BESTEHENDE module-sheet-names.ts-Registry\" rather than a parallel one. That registry is untouched by this PR: it drives the PRODUCTION parsers' (material-parser.ts, sbm-parser.ts, ...) own sheet routing, and changing it would be a real behavior change to already-shipped extraction — outside this PR's \"capability-Kern foundation module, minimal integration\" scope (see PR body). Instead, corpus-observed sheet names it does NOT cover (Master-Prompt §15's own examples: \"Fertigungskosten\"/\"Manufactering costs\" [sic]/\"Manufacturing Cost\" are ALREADY covered by module-sheet-names.ts MANUFACTURING; \"LV Detail EU\" and \"Production Cost\" are NOT) are recognized through a second, purely additive, capability-layer-local alias table (EXTRA_ROLE_ALIASES below) that never touches production parser routing. ASSUMPTION (documented per Master-Prompt §15 \"ASSUMPTIONS explizit\"): classification here uses NAME-SIGNAL evidence only (sheet tab name substring match) — not the full multi-signal evidence list §15 asks for (\"Labels. Data types. Formula structure. References from other sheets. Variant structures. Cost structures. Units. Currency fields. Position inside the workbook. Hidden status. Summary dependencies.\"). Widening to multi-signal evidence is explicit future work (P3+, once FormulaLineageAnalyzer/DataQualityAnalyzer exist per Master-Prompt §31) — flagged honestly in every SheetRoleAssignment's `confidence`, which never exceeds NAME_SIGNAL_MAX_CONFIDENCE for that reason. A second ASSUMPTION: MATERIAL and RMR aliases genuinely collide on the substring \"material\" (module-sheet-names.ts RMR alias \"raw material risk\"/\"rohstoffrisiken\" both contain \"material\"; material-parser.ts's own isMaterialSheetName resolves this with an inline `if (n.includes('raw material')) return false` guard). This resolver generalizes that same precedent into a fixed MODULE_PRIORITY check order (RMR before MATERIAL) rather than re-deriving parser-specific exclusion logic — first alias match in priority order wins, one role per sheet.",
      "mentions_todo_or_future": true,
      "pure_or_no_io": false,
      "test_reference": true,
      "public_signatures": "export const NAME_SIGNAL_MAX_CONFIDENCE = 0.9 | export function resolveSheetRole(sheetName: string): SheetRoleAssignment { | export function resolveSheetRoles(sheets: ReadonlyArray<{ name: string }>): SheetRoleAssignment[] { | export function isPremiseSheetName(name: string): boolean { | export function sheetForRole(assignments: ReadonlyArray<SheetRoleAssignment>, role: Exclude<SheetRole, 'unknown'>): SheetRoleAssignment | undefined { | export const SHEET_RESOLVER_ASSUMPTIONS: readonly string[] = [",
      "source_heading_line": 1083,
      "source_end_line": 1131
    },
    {
      "path": "lib/qaf-differences/internal/capability/summary-field-candidates.ts",
      "category": "core",
      "declared_lines": 293,
      "comment_summary": "Zusammenfassung/Summary row FieldCandidate producer (KAR-960/P3, Hebel B — capability-matrix.md \"2. Master-Sätze + Summen (B+E) im „Zusammenfassung\"/„Summary\"-Blatt — ~346 Dateien\"). Two, deliberately SEPARATE, sources feed this module's FieldCandidate[] output: 1. `summaryMasterRateFieldCandidates` — a NEW positional label→value row scan for the 4 master rates the capability-matrix names (Lohnsatz, SG&A %, Gewinn %, Scrap %) that no existing Kadi-v2 parser extracts (field-registry.ts cap_sum_master_* doc comments — no Leitfaden-documented cell/row exists for any of the 4, confirmed by grep across canonical-fields.ts before this PR). CONSERVATIVE by construction (task instruction: \"nur eindeutige Label-Treffer, Mehrdeutigkeit → FieldCandidate mit INCONSISTENT statt Raten\"): every label hit across the WHOLE grid becomes its own FieldCandidate — one hit resolves to FieldConflictStatus.SINGLE downstream (field-candidates.ts resolveFieldCandidates), two-or-more hits resolve to AGREEMENT (same value) or INCONSISTENT (different values) instead of this module silently guessing which one is \"the\" real label. Real-corpus hit rate is UNKNOWN/likely low (no ground truth — see field-registry.ts doc comments, capability-matrix.md's own B-group finding of 14-22% \"VORHANDEN\") — the p3-validation.md real-file run (KAR-960 task instruction point 4) reports the actual number, this is not assumed to be high. 2. `summaryTotalsFieldCandidates` — wraps the ALREADY-parsed, V11-verified summary-metrics.ts SummaryMetricsParse (the 19 canonical money metrics — materialCosts/manufacturingCosts/totalCosts/ quotationPrice/scrapMaterial/scrapManufacturing among them, the capability-matrix's \"E — Summen\" group) into FieldCandidate[]. This is the \"hier die P2-FieldCandidate-Pipeline PRODUKTIV verdrahten\" half of the task instruction for the E group specifically: no new extraction logic (summary-metrics.ts's label-anchored ±3-row-window scan already does this reliably, see that module's own header), just a thin structural adapter so the SAME resolveAllFieldCandidates pipeline (KAR-959/P2, previously zero production producers) now also carries the Summen alongside the new master rates. Both scopes are `global` (Master-Prompt §21: workbook-wide, once-per-file values — never `process:<step>`, which manufacturing-field-candidates.ts (Hebel A) owns). tdd-guard:skip — covered by __tests__/summary-field-candidates.test.ts.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": true,
      "public_signatures": "export const MASTER_RATE_LABEL_SCAN_CONFIDENCE = 0.7 | export function summaryMasterRateFieldCandidates(grid: unknown[][], sheet: string | null): FieldCandidate<number>[] { | export function masterRateLabelScanCandidates(grid: unknown[][], sheet: string | null, confidence: number): FieldCandidate<number>[] { | export const SUMMARY_TOTALS_CANDIDATE_METRIC_KEYS: readonly SummaryMetricKey[] = [ | export function summaryTotalsFieldCandidates( | export function summaryFieldCandidates(",
      "source_heading_line": 1132,
      "source_end_line": 1182
    },
    {
      "path": "lib/qaf-differences/internal/capability/types.ts",
      "category": "core",
      "declared_lines": 242,
      "comment_summary": "Capability-Kern shared types (KAR-959/P2, Master-Prompt §11-§17 + §31-§32). Architectural fundament for the QAF-Corpus-Generalization-Programme (Epic KAR-957): replaces the 17 blocking template-gates catalogued in gate-audit.md (B1-B17) with capability-based logic (\"if capabilities.hasManufacturingDetail\" instead of \"if !isG60DetailQaf\") — Master-Prompt §32. This PR ships the API the gates migrate ONTO; the gates themselves stay untouched (P3/P4 follow-up, gate-audit.md §4 replacement order). Builds on KAR-958/P1's Empty-Field-Reason-Taxonomie (../types.ts EmptyFieldReason/FacetDegradation) rather than re-inventing it, and on KAR-892/P1.1's CanonicalField registry (../canonical-fields.ts, ../canonical-model.ts) rather than a parallel field dictionary — see field-registry.ts module header for how. tdd-guard:skip — type declarations only, same category as ../types.ts.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export type { QafModule } | export type FieldScope = 'global' | `variant:${string}` | `process:${string}` | export interface ScopedValue<T> { | export type SheetRole = | export interface SheetRoleAssignment { | export type CapabilityStatus = 'AVAILABLE' | 'PARTIAL' | 'DERIVABLE' | 'MISSING' | 'PARSE_FAILED' | export interface CapabilitySource { | export interface ModuleCapability { | export const CAPABILITY_MODULES: readonly QafModule[] = [ | export interface WorkbookCapabilityMatrix { | export const CAPABILITY_MODEL_VERSION = '1.0.0' | export interface FieldCandidate<T = unknown> { | export type FieldConflictStatus = 'NONE' | 'SINGLE' | 'AGREEMENT' | 'INCONSISTENT' | export interface FieldResolution<T = unknown> {",
      "source_heading_line": 1183,
      "source_end_line": 1216
    },
    {
      "path": "lib/qaf-differences/internal/cell-state.ts",
      "category": "core",
      "declared_lines": 114,
      "comment_summary": "Zellzustände (Spezifikation Kap. 6.3) — die Grundlage der zellgenauen Detektoren. Ein Betrag allein sagt nicht, woher er kommt. Ob eine Zelle rechnet, ob jemand das Ergebnis von Hand überschrieben hat, ob sie auf eine fremde Datei zeigt, die niemand mitliefert — das entscheidet, wie belastbar die Zahl ist. Genau diese Unterscheidung tragen die folgenden Zustände. Der Fall, um den es geht: Ein Preis, der im Vergabestand aus Einzelpreis mal Anzahl gerechnet wurde und jetzt als feste Zahl dasteht, sieht im Blatt gleich aus. Er ist aber nicht mehr nachvollziehbar — und das ist ein Verhandlungspunkt, kein Formatierungsdetail. Pure Funktionen, keine I/O.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": false,
      "public_signatures": "export type ValueState = 'formula_and_cached' | 'constant' | 'empty' | 'error' | 'external_link' | export const VALUE_STATES: readonly ValueState[] = [ | export interface CellSnapshot { | export function isErrorValue(value: unknown): boolean { | export function referencesExternalWorkbook(formula: string | null): boolean { | export function classifyCell(snapshot: CellSnapshot): ValueState { | export function looksLikeTextDate(snapshot: CellSnapshot): boolean { | export function isHardcodedComputation(formula: string | null): boolean {",
      "source_heading_line": 1217,
      "source_end_line": 1241
    },
    {
      "path": "lib/qaf-differences/internal/chart-specs.ts",
      "category": "core",
      "declared_lines": 243,
      "comment_summary": "Diagrammvorlagen der Anzeigeebene (Spezifikation Kap. 22). Die Diagramme rechnen so wenig wie die Tabellen: Sie bekommen fertige Balken, fertige Beschriftungen und einen fertigen Zustand. Was hier entschieden wird, entscheidet sich damit einmal — und nicht je Renderer neu. Zwei Regeln der Spezifikation sind hier zu Code geworden, und beide sind Reaktionen auf konkrete Fehlgriffe der Vorgängerfassung: 1. **Eine Brücke, die nicht schliesst, wird nicht gezeichnet.** Sie bekommt einen Fehlerzustand mit Diagnoseliste — niemals einen Balken namens „Übrige\". Ein solcher Balken versteckt genau den Betrag, dessen Herkunft niemand kennt, und lässt das Bild vollständig aussehen. 2. **Eine Auswahl nennt, wovon sie eine Auswahl ist.** „Top 5 von 56 · Rest −0,42\" gehört ins Bild, nicht nur ins JSON. Ohne diese Angabe liest sich eine Top-Liste wie eine vollständige. Pure Funktionen, keine I/O.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": false,
      "public_signatures": "export type WaterfallBarKind = 'start' | 'increase' | 'decrease' | 'end' | export interface WaterfallBar { | export type WaterfallState = 'ok' | 'not_reconciled' | 'not_computed' | export interface WaterfallSpec { | export interface WaterfallInput { | export function buildWaterfall(input: WaterfallInput): WaterfallSpec { | export interface MoversSelection { | export interface MoversSpec { | export function buildMovers( | export type SectionEmptyState = 'empty_no_findings' | 'not_applicable' | 'not_computed' | export interface EmptyStateSpec { | export function emptyState(state: SectionEmptyState, detail: { checksPassed?: number; reasonDe?: string } = {}): EmptyStateSpec {",
      "source_heading_line": 1242,
      "source_end_line": 1273
    },
    {
      "path": "lib/qaf-differences/internal/co2e-parser.ts",
      "category": "core",
      "declared_lines": 882,
      "comment_summary": "CARBON FOOTPRINT (CO2e) sheet parser + Emissionen-Material-Formel- Nachrechnung (KAR-904 / P2.5 — letztes Phase-2-Item). Built from the logistics-parser.ts/lccn-parser.ts pattern (KAR-903/P2.4, KAR-904/P2.5): dynamic import of the canonical registry, label-anchor + confidence matching, a controlled degradation path on an unusable sheet (never throws), sourceCells/normalized/rawText provenance from day one, and the coreFieldsFound tri-state + ForReconciliation/FromPersistedMeta helper pair built in from the START (the #274/KAR-898 lesson every P2.x module since has shipped on commit one instead of retrofitting). ── Struktur-Entscheidung: ZWEI Extraktions-Formen auf DEMSELBEN Blatt ───── [50]s eigene Reiter-Aufzaehlung ist explizit: \"Kopfzeile mit Typauswahl Standard/LC-CN/CO2e (Checkbox), darunter Reiter SUMMARY/LC-CN/MATERIAL/ MANUFACTURING COSTS\" — KEIN eigenstaendiger \"CO2e\"-Tab wird hier genannt. Gleichzeitig traegt Abbildung 34s eigene Bildunterschrift den Namen \"CO2e-Zusammenfassungsblatt in der QAF\" (S.53) UND [53]s Beschreibung legt die PCF-Kernfelder als rechtes Panel (\"CO2e CONTENT\") NEBEN dem Standard-Zusammenfassungsblatt an, waehrend [50] separat sagt die CO2e-Gruppierung wird zusaetzlich auf MATERIAL/MANUFACTURING COSTS eingeblendet. Das ist ein echter, dokumentierter Struktur-Widerspruch im Leitfaden selbst (kein von uns erfundener) — ohne eine reale CO2e-Datei (keine der 9 verfuegbaren BMW-Summary-QAFs traegt Type=CO2e, siehe // allow-customer-string reconciliation.ts's LOGISTICS-Praezedenzfall fuer dieselbe Evidenzluecke) ist nicht entscheidbar, ob ein reales Template einen eigenstaendigen \"CO2e\"-Tab-Namen fuehrt oder die Panel-Spalten direkt in SUMMARY/MATERIAL einblendet. Diese Modul-Entscheidung (dokumentiert, nicht stillschweigend): findCo2eWorksheet() sucht EIN Worksheet per tolerantem Namens-Match (\"co2e\"/\"carbon footprint\") — dieselbe additive Namens-Gate-Konvention jedes anderen P2.x-Sheet-Parsers — und BEIDE Extraktions-Formen (Zusammenfassungs-Panel-Label-Scan + Material-Zeilen-Header-Scan) laufen GEGEN DASSELBE Worksheet. Traegt ein reales File die Panel-Spalten stattdessen direkt auf SUMMARY oder die Zeilen-Spalten direkt auf MATERIAL (ohne einen eigenen \"CO2e\"-benannten Tab), findet dieses Modul sie NICHT — dokumentierte Limitation (analog rmr-parser.ts's \"Known layout limitation\"), kein stillschweigend falsches Ergebnis: coreFieldsFound bleibt in diesem Fall korrekt false fuer beide Teile. ── Field-Scope: alle 14 registrierten CO2E-Canonical-Ids ─────────────────── canonical-fields.ts's CO2E_FIELDS registriert 14 ids (KAR-892/P1.1): 4 level:'summary' (PCF-Kernfelder + CBAM, [52]) + 10 level:'row' (CO2e-Materialblatt-Feldbeschreibungen inkl. Nachhaltigkeit/PrC-PoC-Rec-Q/ CBAM-CN-Code, [55]-[56]). Beide Gruppen werden hier extrahiert — Co2eField Key = Co2eSummaryFieldKey | Co2eMaterialFieldKey deckt alle 14. ── Validierung (Kern-Umfang) — NUR wenn der Leitfaden-Extrakt die Formel EXPLIZIT nennt (task instruction, S.50-62 sorgfaeltig geprueft) ────────── IMPLEMENTIERT: validateCo2eMaterialEmissionsFormula — Emissionen Material = Emissionsfaktor + Upstream Emissionen Verpackung + Upstream Emissionen Transport (Leitfaden [54]). Wortlaut Abbildung 35 \"CO2e Berechnung fuer Kaufteile\" (verbatim): \"Emissionen Kaufteil (kg CO2e pro Mengeneinheit) + Emissionen Transport/Verpackung (kg CO2e pro Mengeneinheit) = Emissionen Material (kg CO2e pro Mengeneinheit)\"; [55] tabelliert \"Upstream Emissionen der Materialverpackung\" und \"Upstream Emissionen Transport\" als ZWEI separate Eingabefelder fuer den kombinierten \"Emissionen Transport/Verpackung\"-Term. Abbildung 36 (Rohmaterialien) nennt dieselbe Formel-FORM mit einer ANDEREN Bezeichnung fuer den ersten Summanden: \"Emissionsfaktor Rohmaterial (kg CO2e pro Mengeneinheit) + Emissionen Transport/Verpackung (kg CO2e pro Mengeneinheit) = Emissionen Material (kg CO2e pro Mengeneinheit)\". Dies ist eine ECHTE, im Leitfaden selbst vorhandene Terminologie-Inkonsistenz zwischen Abb.35 (\"Emissionen Kaufteil\") und Abb.36 (\"Emissionsfaktor Rohmaterial\") — Abb.36s Wortlaut deckt sich mit dem Feldtabellen-Namen \"Emissionsfaktor [kg CO2e / Mengeneinheit]\" ([55], = co2e_emission_factor), Abb.35s Wortlaut nicht. Wird hier NICHT stillschweigend geglaettet (analog zum \"Vulkanisieren\"- Kleinbuchstaben-Fall in lccn-parser.ts) — beide Abbildungen beschreiben dieselbe Formel-STRUKTUR (Summand1 + Transport/Verpackung = Emissionen Material), nur mit abweichender Benennung des ersten Summanden je Zeilentyp; validateCo2eMaterialEmissionsFormula liest in BEIDEN Faellen das einzige dafuer registrierte Feld (co2e_emission_factor, [55]s Feldtabellen-Name), da Abb.35/36 keine zwei separaten Eingabefelder dokumentieren, nur zwei unterschiedliche Bezeichnungen fuer dasselbe. Identische Formel-Form, gueltig fuer BEIDE Kaufteil- und Rohmaterial- Zeilen, daher EIN Check statt zwei. NICHT IMPLEMENTIERT (dokumentiert, nicht stillschweigend weggelassen): - Gesamtemissionen Material ([54]: \"Emissionen Material × Anzahl pro Angebotsteil\" bzw. Rohmaterial-Variante zusaetzlich \"× Einsatzmenge Rohmaterial × [1/(1−Ausschuss)]\") — braucht Anzahl/Einsatzmenge/ Ausschuss, die KEINE registrierten co2e_*-Canonical-Felder sind (Standard-MATERIAL-Sheet-Felder, material-parser.ts's Scope, nicht dieses Moduls). Cross-Modul-Verknuepfung ist \"bewusst Kern-Umfang\" explizit nicht Teil dieses PRs. - Rezyklatgehalt (Rec-Q) = PrC + PoC ([56]) — PrC (\"Abfall vor Gebrauch\") und PoC (\"Abfall nach Gebrauch\") sind laut canonical-fields.ts KEINE eigenen registrierten co2e_*-Felder (nur Rec-Q selbst, co2e_recycled_content_recq, ist registriert) — keine parsebare Eingangsgroesse zur Nachrechnung vorhanden. Wuerde die Formel dennoch \"nachgebaut\", muesste PrC/PoC fabriziert/geraten werden — verboten (Master-Prompt §2, Disziplin-Erinnerung der Task-Instruktion). - CO2e FERTIGUNG-Formeln ([58]-[59]: \"Fertigung [kg CO2e] = Σ(Einsatzmenge×Emissionsfaktor) + direkte Emissionen + Betriebsstoffe\") — deren Eingangsfelder (Energietraeger/Einsatzmenge/ Emissionsfaktor je Energietraeger, Betriebsstoffe) sind ebenfalls KEINE registrierten co2e_*-Canonical-Ids (siehe canonical-fields.ts CO2E_ FIELDS — 14 Eintraege insgesamt, keiner davon Fertigungs-Energietraeger- Felder) — nicht Teil dieses Moduls' 14-Feld-Scope. - \"Potential\"-Reiter ([61]-[62]) ist laut Leitfaden explizit rein informativ (\"Dieser Reiter gilt rein fuer informative Zwecke\") — wird NICHT geparst (task instruction).",
      "mentions_todo_or_future": true,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export interface Co2eSummaryValues { | export type Co2eSummaryFieldKey = keyof Co2eSummaryValues | export interface Co2eMaterialRowValues { | export type Co2eMaterialFieldKey = keyof Co2eMaterialRowValues | export type Co2eFieldKey = Co2eSummaryFieldKey | Co2eMaterialFieldKey | export const CORE_CO2E_SUMMARY_FIELD_KEYS: readonly Co2eSummaryFieldKey[] = ['pcf', 'pcfUnit'] | export const CORE_CO2E_MATERIAL_FIELD_KEYS: readonly Co2eMaterialFieldKey[] = ['emissionFactor', 'materialEmissions'] | export function isCo2eSheetName(name: string): boolean { | export function findCo2eWorksheet(wb: { worksheets: Worksheet[] }): Worksheet | null { | export async function matchCo2eSummaryLabelCell(cellText: string): Promise<LabelMatch<Co2eSummaryFieldKey> | null> { | export async function matchCo2eMaterialHeaderColumn(cellText: string): Promise<LabelMatch<Co2eMaterialFieldKey> | null> { | export interface Co2eSummaryParseMeta { | export interface Co2eSummaryParseResult { | export type Co2eMaterialRow = Co2eMaterialRowValues & { | export interface Co2eMaterialParseMeta { | export type Co2eMaterialParseResult = Co2eMaterialRow[] & Co2eMaterialParseMeta | export interface Co2eParseResult { | export async function parseCo2eSheet(wb: { worksheets: Worksheet[] }): Promise<Co2eParseResult | null> { | export function co2eSummaryForReconciliation(parsed: Co2eParseResult | null): Co2eSummaryValues | null { | export function co2eMaterialRowsForReconciliation(parsed: Co2eParseResult | null): Co2eMaterialRow[] | null { | export interface PersistedCo2eMeta { | export function co2eFromPersistedMeta(meta: PersistedCo2eMeta | null | undefined): Co2eParseResult | null | undefined { | export type Co2eValidationCheckId = 'co2e_material_emissions' | export type Co2eValidationStatus = 'bestanden' | 'abweichung' | 'nicht_pruefbar' | export interface Co2eValidationResult { | export interface Co2eValidationConfig { | export const CO2E_VALIDATION_CONFIG: Co2eValidationConfig = { | export function validateCo2eMaterialEmissionsFormula( | export interface Co2eValidationInput { | export function evaluateCo2eValidation(",
      "source_heading_line": 1274,
      "source_end_line": 1406
    },
    {
      "path": "lib/qaf-differences/internal/compare.ts",
      "category": "core",
      "declared_lines": 716,
      "comment_summary": "QAF comparison orchestrator (KAR-799, spec A2/B — connective tissue). Assembles the engine for ONE part number: match steps → diff each pair → derive structure changes → plausibility → deterministic root-cause. Different part numbers must never be compared (the caller groups by part number; the plausibility critical is a guard). Net-new. Pure.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": false,
      "public_signatures": "export interface QafFileParsed { | export interface StepComparison { | export interface QafComparisonResult { | export function compareQafPair(",
      "source_heading_line": 1407,
      "source_end_line": 1421
    },
    {
      "path": "lib/qaf-differences/internal/comparison-mode.ts",
      "category": "core",
      "declared_lines": 284,
      "comment_summary": "── Comparison-mode dispatch registry (KAR-943 adversarial-review F2) ────── Three defects in this package's history are the SAME bug class: a NEW `comparison_mode` value gets added to `qaf_comparison`, and every ad-hoc `if (cmp.comparison_mode === '...')` dispatch site in app/qaf-differences/ actions.ts has to independently remember to learn about it — nothing enforces that they all do. KAR-942 adversarial-review F1: replaceComparisonFile's isG60Comparison-only guard silently rejected-and-DELETED valid 'multi_qaf' replacement uploads. KAR-942 adversarial-review F3: exportQafComparisonXlsx only guarded 'g60', letting 'multi_qaf' fall through into a misleadingly EMPTY export. KAR-943's own adversarial-review F2: the exact same pattern for 'multi_qaf_variant_vs_standard' in BOTH of those same two functions. This module is the single source of truth for \"what does mode X expect at each dispatch point\" — actions.ts's replaceComparisonFile file-kind guard, exportQafComparisonXlsx's support guard, and recompareComparison's pins/swap applicability guards all read from here instead of repeating the mode list ad-hoc. `comparisonModeRule`'s internal `switch` + the TypeScript never-trick `assertExhaustiveComparisonMode` make a FUTURE `QafComparisonModeKey` addition without a matching case a COMPILE error, rather than a silent runtime fallthrough discovered by the next adversarial review. Lives in its own module (not inline in actions.ts) because actions.ts is a `'use server'` file — every top-level export from a `'use server'` module must be an async Server Action (Next.js constraint), which these pure, synchronous, DB-free functions/constants are not and should not become (`next build` catches this at compile time; `tsc --noEmit` alone does not). Deliberately NOT covered here: recompareComparison's per-mode rehydrate/re-run bodies (which tables to read, which engine function to call) — that is genuinely mode-SPECIFIC business logic, not a shared yes/no/expected-shape fact a data table can express; forcing it through this registry would just relocate the branching without adding any real exhaustiveness guarantee beyond what this registry+switch already gives the guards that ARE data-only facts. tdd-guard:skip — pure data/dispatch module, unit-tested exhaustively in comparison-mode.test.ts (registry shape + every mode/role combination).",
      "mentions_todo_or_future": true,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export type QafComparisonModeKey = | export type IngestedQafFileKind = 'g60' | 'summary' | 'multi_qaf' | export interface ComparisonModeRule { | export const COMPARISON_MODE_RULES: Record<QafComparisonModeKey, ComparisonModeRule> = { | export function normalizeComparisonMode(raw: string | null | undefined): QafComparisonModeKey { | export function comparisonModeRule(raw: string | null | undefined): ComparisonModeRule {",
      "source_heading_line": 1422,
      "source_end_line": 1469
    },
    {
      "path": "lib/qaf-differences/internal/comparison-user-inputs.ts",
      "category": "core",
      "declared_lines": 188,
      "comment_summary": "Vergleichs-scoped Autosave-Namespace (KAR-985) — additives JSONB-Bag auf qaf_comparison.user_inputs (Migration supabase-migration-qaf-comparison- user-inputs.sql), gleiches Muster wie qaf_file.g60_meta (siehe z. B. field-mapping-override.ts's Doc-Kommentar: \"misc JSONB bag\"): ein Top-Level-Namespace je Editor-Fläche, geladen/geschrieben als Ganzes, andere Namespaces bleiben beim Schreiben unangetastet. Zwei Editor-Flächen speisen dieses Bag: - \"12. Hochrechnung & Potenzial in €\" (qaf-projection.tsx): Jahres-/ Volumen-Tabelle, Δ-Override, Abwehrquote. - Preis-Kalkulator (qaf-price-calculator.tsx, KAR-847/848): die manuellen UnitPriceParams-Felder. Pure + unit-tested; keine React-/Supabase-Abhängigkeiten hier — die Server Action (app/qaf-differences/qaf-comparison-user-inputs-actions.ts) und der Client-Hook (components/qaf-differences/qaf-autosave.tsx) bauen auf diesen Funktionen auf, ohne sie zu duplizieren.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export interface QafProjectionUserInputs { | export interface QafCalculatorUserInputs { | export interface QafComparisonUserInputs { | export type QafComparisonUserInputsPatch = Partial<Pick<QafComparisonUserInputs, 'projection' | 'calculator'>> | export function validateQafComparisonUserInputsPatch(patch: unknown): string | null { | export function normalizeQafComparisonUserInputsPatch(patch: QafComparisonUserInputsPatch): QafComparisonUserInputsPatch { | export function mergeQafComparisonUserInputs(current: unknown, patch: QafComparisonUserInputsPatch): QafComparisonUserInputs { | export function readQafProjectionUserInputs(bag: unknown): QafProjectionUserInputs | null { | export function readQafCalculatorUserInputs(bag: unknown): QafCalculatorUserInputs | null {",
      "source_heading_line": 1470,
      "source_end_line": 1499
    },
    {
      "path": "lib/qaf-differences/internal/data-quality-cells.ts",
      "category": "core",
      "declared_lines": 260,
      "comment_summary": "Zellgenaue Datenqualitäts-Detektoren (Spezifikation Kap. 8.7, R-12). Die Detektoren daneben in `data-quality.ts` arbeiten auf geparsten Werten: Position, Name, Betrag. Diese hier brauchen mehr — sie fragen, wie ein Wert zustande kommt. Ob dort eine Formel steht oder eine von Hand eingetragene Zahl, ob der Bezug in eine Datei zeigt, die dem Vergleich nicht beiliegt, ob eine Spalte im aktuellen Stand neu ausgeblendet wurde. Der Unterschied ist nicht formal. Ein Preis, der im Vergabestand aus Einzelpreis mal Anzahl gerechnet wurde und jetzt als feste Zahl dasteht, sieht im Blatt gleich aus und ist doch nicht mehr nachvollziehbar. Pure Funktionen, keine I/O — die Zellzustände kommen als Momentaufnahme herein, damit die Regeln ohne Arbeitsmappe prüfbar bleiben.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": false,
      "public_signatures": "export function detectTextDates(cells: CellSnapshot[], scope: FindingScope): RawFinding[] { | export function detectErrorCells( | export function detectExternalLinks(cells: CellSnapshot[], scope: FindingScope): RawFinding[] { | export function detectNewlyHiddenColumns( | export interface SumFormulaCheck { | export function detectBrokenTemplateSums( | export interface BaseDeletionInput { | export function detectDeletedCalculationBase(inputs: BaseDeletionInput[]): RawFinding[] { | export function detectHardcodedRates(cells: CellSnapshot[], scope: FindingScope): RawFinding[] {",
      "source_heading_line": 1500,
      "source_end_line": 1525
    },
    {
      "path": "lib/qaf-differences/internal/data-quality.ts",
      "category": "core",
      "declared_lines": 468,
      "comment_summary": "Datenqualitäts-Detektoren (Spezifikation Kap. 7.7 und 8.7, R-12). Ein Vergleich, der nur Zahlen gegenüberstellt, sagt nichts darüber, ob die Zahlen tragen. Die Detektoren beantworten genau das: Wo ist eine Angabe verschwunden, wo widerspricht eine Summe ihren Bestandteilen, wo trägt eine Position einen Namen aber keinen Preis. Erst zusammen mit diesen Befunden wird aus einem Zahlenvergleich eine Verhandlungsgrundlage. Zwei Regeln gelten für jeden Detektor: 1. Kein Befund ohne Fundstelle. Ein Hinweis, den niemand nachschlagen kann, ist in einer Verhandlung wertlos. 2. Die Schwere kommt aus einer Matrix, nicht aus dem Einzelfall. Sonst wandert dieselbe Beobachtung je nach Kontext einmal nach oben und einmal nach unten, und die Rangfolge wird beliebig. Pure Funktionen, keine I/O.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": false,
      "public_signatures": "export type DetectorId = | export type Severity = 'critical' | 'high' | 'medium' | 'low' | 'info' | export type FindingContext = 'cost_path' | 'template' | 'metadata' | export type FindingScope = 'award' | 'current' | 'both' | export interface CellRef { | export interface DqFinding { | export const SEVERITY_MATRIX: Readonly<Record<DetectorId, Readonly<Record<FindingContext, Severity>>>> = { | export function severityOf(detector: DetectorId, context: FindingContext): Severity { | export type RawFinding = Omit<DqFinding, 'findingId' | 'severity'> & { severity?: Severity } | export function assignFindingIds(findings: RawFinding[]): DqFinding[] { | export interface DataQualityResult { | export function summarize(findings: DqFinding[], detectorsRun: DetectorId[]): DataQualityResult { | export interface NamedCostRow { | export function detectZeroCostPositions( | export interface TotalLineGroup { | export function groupByTotalLine( | export function detectStaleTotalLines( | export interface NamedParameterRow { | export function detectNamedRowsWithoutParameters( | export function detectDuplicateNames( | export interface AwardPlaceholderInput { | export function detectAwardPlaceholders(rows: AwardPlaceholderInput[], sheet: string = SHEET.sbm): RawFinding[] { | export interface RowShiftInput { | export function detectRowShifts(shifts: RowShiftInput[], sheet: string = SHEET.sbm): RawFinding[] { | export interface AnchorLabelInput { | export function detectAnchorLabelMismatch(anchors: AnchorLabelInput[], sheet: string = SHEET.summary): RawFinding[] {",
      "source_heading_line": 1526,
      "source_end_line": 1570
    },
    {
      "path": "lib/qaf-differences/internal/deep-links.ts",
      "category": "core",
      "declared_lines": 100,
      "comment_summary": "Teilbare Verweise auf einen Befund (Spezifikation Kap. 25, Invariante U-07). Jeder Befund muss als Link teilbar sein — in eine Besprechung, in ein Ticket, in ein Schreiben an den Lieferanten. Ein Bericht, aus dem man nur „Sektion Werkzeuge, irgendwo unten\" zitieren kann, wird in der Praxis nicht zitiert. Pure Funktionen, keine I/O.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": false,
      "public_signatures": "export const VIEW_SECTIONS = [ | export type ViewSection = (typeof VIEW_SECTIONS)[number] | export interface DeepLinkTarget { | export function isViewSection(value: string): value is ViewSection { | export function buildDeepLink(target: DeepLinkTarget, basePath = '/qaf-differences'): string { | export function parseDeepLink(url: string): DeepLinkTarget | null { | export function sectionForDifference(differenceId: string): ViewSection | null {",
      "source_heading_line": 1571,
      "source_end_line": 1588
    },
    {
      "path": "lib/qaf-differences/internal/differ.ts",
      "category": "core",
      "declared_lines": 251,
      "comment_summary": "QAF Diff layer (KAR-799, spec B13). Field-level delta with absolute, relative (%) and — for percent fields — percentage-point components, plus a status band. Pure functions. Extends (does not duplicate) lib/qaf/comparison.ts: that module computes absolute-only deltas; here we add relative % + percentage-points + status bands, reusing its NUMERIC_FIELDS list (DRY).",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": false,
      "public_signatures": "export interface DifferBandsConfig { | export const DEFAULT_DIFFER_BANDS_CONFIG: DifferBandsConfig = { | export function round4(n: number): number { | export interface NumericDelta { | export function computeNumericDelta( | export function computeFieldDiff( | export function computeFieldDiffWithNotApplicable( | export function diffSteps(",
      "source_heading_line": 1589,
      "source_end_line": 1608
    },
    {
      "path": "lib/qaf-differences/internal/differences-from-findings.ts",
      "category": "core",
      "declared_lines": 98,
      "comment_summary": "Datenqualitäts-Befunde als Differenzen (Spezifikation Kap. 7.6). Nicht jede Differenz stammt aus einer Zuordnung. Dass Auslegungsangaben verschwunden sind, dass eine Infozeile ihren Bestandteilen widerspricht, dass ein Werkzeug ohne zugehörige Stücklistenposition dasteht — das sind Unterschiede zwischen den Ständen, auch wenn keine Position dabei den Besitzer wechselt. Sie gehören in denselben Katalog. Sonst müsste eine Folie, die sich auf einen solchen Befund stützt, an der Rückverfolgung vorbei belegt werden — und genau diese Umgehung ist der Grund, warum in der Vorgängerfassung Aussagen ohne Quelle stehen konnten. Pure Funktionen, keine I/O.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": false,
      "public_signatures": "export function differencesFromFindings(findings: readonly DqFinding[]): RawDifference[] {",
      "source_heading_line": 1609,
      "source_end_line": 1626
    },
    {
      "path": "lib/qaf-differences/internal/differences-from-tooling.ts",
      "category": "core",
      "declared_lines": 127,
      "comment_summary": "Werkzeug-Zuordnungen in Differenzsätze überführen (Spezifikation Kap. 7.6). Die Werkzeuganalyse kennt ihre eigene Sprache — Zweitwerkzeug, neuer Umfang, verschobene Zeile. Der Differenzkatalog kennt nur ein geschlossenes Vokabular, dafür bereichsübergreifend. Diese Datei ist die Übersetzung zwischen beiden, und sie ist bewusst getrennt gehalten: Die Werkzeuganalyse soll nichts über den Katalog wissen müssen.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export function differencesFromTooling(",
      "source_heading_line": 1627,
      "source_end_line": 1639
    },
    {
      "path": "lib/qaf-differences/internal/engine-config.ts",
      "category": "core",
      "declared_lines": 336,
      "comment_summary": "QAF Engine-Konfiguration — zentrale, versionierte Regel-Governance (P1.5/KAR-896). Master-Prompt §18 fordert Regeln, die \"versioniert, reviewbar, testbar, auditierbar\" sind statt als verstreute Code-Konstanten zu leben. Bis zu diesem PR gab es dafür bereits VIER unabhängige, aber strukturell identische Vorstufen (KAR-887/888/889 — siehe Kommentare in reconciliation.ts, g60/structure-guard.ts, rule-engine.ts): ein benannter Config-Typ, eine modul-lokale DEFAULT-Konstante, ein expliziter Funktionsparameter, der darauf zurückfällt. Dieses Modul zieht diese vier (plus die bisher NICHT benannten differ.ts-Status-Bänder und die bereits parametrisierte matcher.ts MatchConfig) zu EINER versionierten Struktur zusammen — ohne die einzelnen Modul-Konstanten zu duplizieren oder zu verändern: jede Sektion referenziert exakt die bestehende Default-Konstante ihres Moduls, damit es keine zweite, potenziell abweichende Quelle der Wahrheit gibt. Bewusst NICHT Teil dieses PRs (Kais' Vorgabe, KAR-896 comment 10.07.2026): - Keine DB-Tabelle `qaf_engine_config` (das ist ein Folge-Issue-Kandidat für die tatsächliche Operator-Editierbarkeit; siehe P1.5-Backlog-Item, das explizit \"Read-Pfad zunächst, Admin-UI ist Folge-Item\" vorsieht). - Keine UI-Settings-Seite. Der Wert dieses PRs ist ausschließlich: eine Stelle, ein Versionsstempel, saubere Injektion statt verstreuter Hardcodierung, Reproduzierbarkeit über die bestehende qaf_comparison.engine_version-Persistenz (siehe persistence-mapper.ts/rehydrate.ts). Zwei bindende Kais-Fachentscheide (KAR-896 comment 10.07.2026), inhaltlich dokumentiert an ihrer jeweiligen Sektion-Quelle, hier nur querverwiesen: 1. ruleEngine.ruleEnforcement bleibt DAUERHAFT 'warn' im Default — R2 (Pflichtfeld-Blockade) blockiert produktiv nie automatisch. 'block' bleibt ein gültiger Optionswert (Typ + validateEngineConfig), wird aber ohne neue explizite Fachfreigabe nie Default. Siehe rule-engine.ts, RULE_ENGINE_CONFIG-Kommentar. 2. reconciliation = {relativeTolerance: 0.005, absoluteToleranceMinor: 1} (0.5 % relativ + 1 AW absolut) ist fachlich freigegeben als STARTWERT, nicht als endgültige Zahl. Siehe reconciliation.ts, RECONCILIATION_CONFIG-Kommentar. Pure. Keine DB-, Zeit- oder I/O-Abhängigkeit — testbar ohne Fixtures.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export interface EngineConfig { | export const DEFAULT_ENGINE_CONFIG: EngineConfig = { | export function validateEngineConfig(config: EngineConfig): string[] { | export type EngineConfigSectionKey = (typeof ENGINE_CONFIG_SECTION_KEYS)[number] | export interface EngineConfigDelta { | export function buildEngineConfigDelta( | export function isEngineConfigVersionAtLeast(version: string | undefined, min: string): boolean {",
      "source_heading_line": 1640,
      "source_end_line": 1686
    },
    {
      "path": "lib/qaf-differences/internal/explain-provenance.ts",
      "category": "core",
      "declared_lines": 610,
      "comment_summary": "Consolidated Zelle→Ergebnis Explain-Panel provenance (KAR-922/P6.2). Problem this closes: Master-Prompt §17 requires every comparison result to stay traceable to source file/sheet/cell/label/canonical field/raw+ normalized value/formula/comparison rule/mapping method/confidence/unit+ currency conversion/delta/validation result/QAF-guide requirement/engine version/template profile version/timestamp. KAR-886/891/893/894/895/896/ 900/902/903/904/910/911/912 already persist (or, for a documented subset, only compute at ingest-time — see the formula/confidence notes below) every one of these attributes across the MANUFACTURING/MATERIAL/SBM/RMR/ LOGISTICS/SUMMARY modules, but each one lives in a different shape (QAFRow.sourceCells vs. SummaryMetricValue.cell vs. MaterialRow.rawText, ...) and no single place assembles them into one \"explain this result\" view — KAR-911's ProvenanceTooltip only ever shows a handful of fields (source cell, module confidence, engine version, manual override), not the full §17 set. This module is that single place: one shared output shape (ExplainAttributes/ExplainSideAttributes) plus one thin adapter function per module (buildManufacturingExplainAttributes/buildMaterialExplain- Attributes/...), all delegating to the same generic core (buildRowModuleExplainAttributes/buildDetailSideAttributes) so there is exactly one place that decides what \"present\"/\"not applicable\"/\"not captured\" means for a given attribute — no per-module duplicate logic (task instruction: \"kein Ad-hoc-Duplikat je Modul — ein Interface, Modul-Adapter\"). Reine Präsentationsschicht: every value read here was already computed and persisted (or, where documented below, only ever computed at ingest-time and intentionally not persisted) by an earlier PR — no new engine computation, no new persistence, no new confidence scoring happens in this file. Tri-State-Disziplin (task instruction: \"NIE fabrizieren\"): every attribute is one of - 'present' — a real value the engine actually computed/read. - 'not_applicable' — this attribute structurally does not exist for this field/row (e.g. a text field has no formula; the engine performs no unit/currency conversion at all, ever, for any field). - 'not_captured' — the attribute conceptually exists but was not persisted for THIS value (predates the feature that introduced it, or was only ever computed in-memory at ingest-time and not persisted — e.g. SUMMARY per-metric confidence/formula). The UI renders this as \"nicht erfasst (älterer Lauf)\". Never a bare null with no explanation of which of the two it is. tdd-guard:skip — type declarations + a `note` constant at the top; every function below is exercised by __tests__/explain-provenance.test.ts.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": true,
      "public_signatures": "export type ExplainStatus = 'present' | 'not_applicable' | 'not_captured' | export interface ExplainValue<T> { | export type MappingMethodKind = 'manual_override' | 'automatic_header_mapping' | export interface ExplainSideAttributes { | export interface QafGuideRequirementValue { | export interface ExplainAttributes { | export function buildQafGuideRequirement(canonicalId: string | undefined): ExplainValue<QafGuideRequirementValue> { | export interface RuleFindingLike { | export interface DetailRowLike<K extends string> { | export interface DetailSideInput<K extends string> { | export function buildDetailSideAttributes<K extends string>(input: DetailSideInput<K>): ExplainSideAttributes { | export interface RowModuleSideInput<K extends string> { | export interface RowModuleDiffLike { | export interface RowModuleExplainInput<K extends string> { | export function buildRowModuleExplainAttributes<K extends string>(input: RowModuleExplainInput<K>): ExplainAttributes { | export type RowModuleAdapterInput<K extends string> = Omit<RowModuleExplainInput<K>, 'fieldKeyToCanonical' | 'sheetName'> | export function buildManufacturingExplainAttributes(input: RowModuleAdapterInput<QAFFieldKey>): ExplainAttributes { | export function buildMaterialExplainAttributes(input: RowModuleAdapterInput<MaterialFieldKey>): ExplainAttributes { | export function buildSbmExplainAttributes(input: RowModuleAdapterInput<SbmFieldKey>): ExplainAttributes { | export function buildRmrExplainAttributes(input: RowModuleAdapterInput<RmrFieldKey>): ExplainAttributes { | export function buildLogisticsExplainAttributes(input: RowModuleAdapterInput<LogisticsFieldKey>): ExplainAttributes { | export interface SummarySideInput { | export interface SummaryExplainInput { | export function buildSummaryExplainAttributes(input: SummaryExplainInput): ExplainAttributes {",
      "source_heading_line": 1687,
      "source_end_line": 1761
    },
    {
      "path": "lib/qaf-differences/internal/export-canonical.ts",
      "category": "core",
      "declared_lines": 118,
      "comment_summary": "Kanonische Serialisierung und Determinismus-Nachweis (Spezifikation Kap. 10, R-17/R-19; Befunde F-11/F-15). Zwei Mängel des V1-Ausgabeformats: F-11: Gleitkomma-Rohwerte ohne Rundungsregel. Eine Einmalzahlung stand als 5579440.0003921185 im Dokument — ein Artefakt der Fliesskommarechnung, kein Betrag. Wer so etwas liest, misstraut zu Recht auch den übrigen Zahlen. F-15: Kein Determinismus-Nachweis. Zwei Läufe über dieselben Dateien liessen sich nicht als identisch belegen, weil es keinen Inhalts-Hash gab. Ohne ihn ist „gleiche Eingabe, gleiches Ergebnis\" eine Behauptung. Pure Funktionen; der Hash nutzt die Web-Crypto-API und ist deshalb async.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": false,
      "public_signatures": "export function canonicalNumber(n: number): number | null { | export function canonicalizeNumbers<T>(value: T): T { | export const HASH_EXCLUDED_PATHS = ['meta.comparisonId', 'meta.generatedAt', 'validation.determinism'] as const | export function hashableDocument<T extends object>(doc: T): unknown { | export async function contentHash(doc: object): Promise<string> {",
      "source_heading_line": 1762,
      "source_end_line": 1782
    },
    {
      "path": "lib/qaf-differences/internal/export-envelope.ts",
      "category": "core",
      "declared_lines": 183,
      "comment_summary": "V2-Envelope des Ergebnis-JSON (Spezifikation Kap. 6, 13, 20). Das V1-Format war ein Deckel über einer Sektionsliste: `{format, version, meta, sections}`. Wer es konsumierte — Oberfläche, Export, später eine Sprachschicht — musste alles Übrige wissen: in welche Richtung ein Delta zeigt, ob Prozent ein Anteil oder eine Zahl ist, in welcher Einheit ein Betrag steht, ob eine leere Sektion nichts gefunden hat oder nie gerechnet wurde. Nichts davon stand im Dokument. Der V2-Envelope beantwortet diese Fragen im Dokument selbst: engine womit erzeugt (Version, Config, Registry) conventions Delta-Richtung, Toleranzen, Einheiten, Rundung, null-Semantik comparisonType Zeitvergleich? Objektvergleich? (R-23, Befund F-20) sectionState je Sektion: befüllt / leer / nicht anwendbar / nicht berechnet (R-24, Befund F-22) Pure Daten + pure Funktionen, keine I/O.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": false,
      "public_signatures": "export const QAF_COMPARE_ENGINE = { | export type QafComparisonType = | export type ComparisonTypeBasis = 'derived_from_identity' | 'undetermined' | export interface ComparisonTypeVerdict { | export interface ComparisonIdentity { | export function deriveComparisonType(identity: ComparisonIdentity): ComparisonTypeVerdict { | export type SectionStatus = 'populated' | 'empty_no_findings' | 'not_applicable' | 'not_computed' | export interface SectionState { | export function sectionStateOf( | export const QAF_CONVENTIONS = { | export type QafConventions = typeof QAF_CONVENTIONS",
      "source_heading_line": 1783,
      "source_end_line": 1813
    },
    {
      "path": "lib/qaf-differences/internal/export-validation.ts",
      "category": "core",
      "declared_lines": 149,
      "comment_summary": "Validierungs-Sektion des Ergebnis-JSON (Spezifikation Kap. 7.8, R-14; Befund F-05). V1 hatte keinen Validierungsblock. Es gab keine Kontrollsummen, keine benannten Prüfungen, keine Toleranzdefinition — und das Brücken-Residual wurde verschluckt statt gemeldet. Ein Lauf konnte also fehlerhaft sein, ohne dass irgendetwas im Dokument darauf hinwies. Der Grundsatz der Spezifikation: ein Lauf schlägt sichtbar fehl, statt still zu approximieren. Diese Sektion macht die Prüfungen benennbar — mit Messwert und Grenze, damit ein Konsument nicht nur „bestanden\" liest, sondern nachrechnen kann. Pure Funktionen, keine I/O.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": false,
      "public_signatures": "export type CheckStatus = 'pass' | 'fail' | 'not_applicable' | export interface ValidationCheck { | export interface ValidationResult { | export interface ValidationInput { | export function buildValidation(input: ValidationInput): ValidationResult {",
      "source_heading_line": 1814,
      "source_end_line": 1835
    },
    {
      "path": "lib/qaf-differences/internal/export.ts",
      "category": "core",
      "declared_lines": 208,
      "comment_summary": "QAF-Differences Excel export (KAR-799, spec B9 — reference quality). Builds the 8 reference sheets (README, Import_Log, Zusammenfassung_Vergleich, Fertigungskosten_Vergleich, Neu_Entfallen, Delta_Highlights, Top_Treiber_Prozess, Plausibilitätscheck) with frozen headers, autofilter, number/percent formats and brand-CI status fills. Server-only (ExcelJS imported dynamically). ARGB color literals are exempt from check:hardcoded-colors. Net-new: the existing qaf-template route only writes a blank single-sheet template. Pure w.r.t. inputs — produces a workbook buffer.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": false,
      "public_signatures": "export interface QafExportInput { | export function statusFill(status: string, deltaAbsolute?: number | null, field?: string): string | null { | export async function buildQafExportWorkbook(input: QafExportInput): Promise<ArrayBuffer> {",
      "source_heading_line": 1836,
      "source_end_line": 1852
    },
    {
      "path": "lib/qaf-differences/internal/field-mapping-override.ts",
      "category": "core",
      "declared_lines": 331,
      "comment_summary": "Manual field-mapping-override persistence (KAR-912 / P4.2 — backlog item \"[P4.2] Manuelle Mapping-Override-Persistenz für neue Sheet-Typen\"). ── Distinct from the existing manual matching mechanism (KAR-845) ───────── matcher.ts's matchStepsWithPins/ManualPin lets a user PAIR an ALT Fertigungskosten step to a NEU step (or mark it as removed) — a ROW-level correction of the matching cascade, applied at compare time. This module is a FIELD-level correction: qaf-parser.ts's header→field mapping (KAR-893/P1.2, matchHeaderColumnSync) is best-effort — a header the parser cannot map (neither exact nor normalized-alias match, see QAFParseMeta.unmappedHeaders) leaves that field `null`/empty for every row, and there was previously no way for a user to correct it. A FieldMappingOverride says \"canonical field K on the row identified by Positionsnummer P should have value V\" — independent of, and orthogonal to, step-matching. Both mechanisms can be in effect on the same comparison at once. ── Persistence + reproducibility (task instruction, KAR-899 lesson) ─────── Overrides are NOT re-derived from the original Excel file at recompute/replace time (qaf_manufacturing_step.raw_values is the only source of truth recompareComparison rehydrates from — the workbook itself is never re-read on a plain pin-recompare). Instead they are persisted as qaf_file.g60_meta.fieldMappingOverrides — the same \"misc JSONB bag\" every other detail-module facet (material/sbm/rmr/logistics/lccn/co2e/ manufacturingParseMeta/workbookSafety) already uses (see actions.ts ingestQafUpload's g60_meta insert) — and applyFieldMappingOverrides below is called at BOTH integration points that (re)produce a file's QAFRow[]: 1. app/qaf-differences/actions.ts's ingestQafUpload, when replacing a file (replaceComparisonFile carries the OLD file's overrides forward onto the NEW file's freshly-parsed rows before they are persisted). 2. app/qaf-differences/actions.ts's recompareComparison sideOf(), which rehydrates QAFRow[] from raw_values on every recompute — exactly the KAR-899 rehydration-staleness pattern already fixed once for material/sbm/rmr/logistics/workbookSafety (a correction that only lived in the live in-memory IngestResult would silently vanish the first time refreshPlausibility:true reran the pipeline without it). Both call sites pass the SAME persisted overrides array, so applying it is idempotent (see the \"survives being applied twice\" test) — no ordering hazard between the two integration points.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": true,
      "public_signatures": "export interface FieldMappingOverride { | export interface RowIdentity { | export function normalizeOverrideRowKey(positionsnummer: unknown): string { | export function isOverridableFieldKey(fieldKey: string): fieldKey is QAFFieldKey { | export function applyFieldMappingOverrides(rows: readonly QAFRow[], overrides: readonly FieldMappingOverride[]): QAFRow[] { | export function upsertFieldMappingOverride( | export function removeFieldMappingOverride( | export interface CarryForwardResult { | export function carryForwardFieldMappingOverrides( | export function fieldMappingOverrideDroppedIssues(",
      "source_heading_line": 1853,
      "source_end_line": 1905
    },
    {
      "path": "lib/qaf-differences/internal/foreign-form-detection.ts",
      "category": "core",
      "declared_lines": 452,
      "comment_summary": "WAF/LAF/LEK format detection — Detect & Reject (KAR-920 / backlog [P5.3]). Problem (05-backlog-phasenplan.md [P5.3] + 02-leitfaden-teil3.md [63]-[84]): WAF (Werkzeuganalyseformular), LAF (Logistikkostenanalyseformular) and LEK (Lieferantenentwicklungskosten) are separate Excel files with their own structure — NOT sheets inside a QAF workbook. WAF has its own price formula (Angebotspreis = (ΣMaterial+ΣFertigung)×(1+Zuschläge%)+Logistik, teil3.md:328 [83]); LEK has an enforced sheet-protection with an M50/P50 sum-reconciliation field (teil3.md:139/164 [74]/[75]); LAF has 3 own sheets (Packaging/Transport/LVP, teil3.md:12-133 [67]-[73]). If one of these is uploaded where a QAF is expected, the QAF parsers (summary-metrics.ts, qaf-parser.ts, material-parser.ts, ...) either fail with a confusing generic error or — worse — silently match a handful of headers and produce wrong numbers instead of rejecting outright. Scope (P5.3, explicitly NOT full parsing — see backlog \"Nicht im Scope\"): detection only, via a small catalog of form-title signature strings taken VERBATIM from the Leitfaden extraction (02-leitfaden-teil3.md), matched against (a) worksheet tab names and (b) the header region (first HEADER_SCAN_ROWS rows) of every sheet — never a full-workbook parse. A hit rejects the upload immediately with a bilingual, format-named error instead of letting it fall through into a QAF parser. False-positive discipline (explicit task constraint: \"ein echtes QAF darf NIE als WAF/LAF/LEK abgelehnt werden\"): every signature below is a multi-word, form-specific phrase documented in the Leitfaden as the literal printed form title or worksheet tab name — never a single generic word (\"Analyse\", \"Formular\", \"Transport\" alone would all be far too easy to collide with legitimate QAF content, e.g. SBM-DEVICES-FWZ's own \"Werkzeugart\" field). Verified server-only against the 20-file real QAF sample under qaf-compare-kar824/input/ (structure-only smoke check, not committed — real supplier/OEM files never enter this repository, see CORRECTIONS \"Rohdaten nicht vor Output-Validierung löschen\" / confidentiality practice): 19 files pass through undetected as expected; one file (WAF_de.xlsm) is CORRECTLY detected as a genuine WAF — its \"Analyse\" worksheet repeats the exact \"Werkzeug Analyse Formular\" title across its header row and its field layout matches teil3.md:291-341 [82]-[84] (Ansprechpartner/Stammdaten Bauteil/Kalkulatorische Material- kosten/Fertigungskosten) — a true positive, not a false one; see the PR body (KAR-920) for the full writeup. Pure core (ForeignFormWorkbook interface, no ExcelJS) + a thin ExcelJS bridge at the bottom — same \"pure detection logic / ExcelJS adapter at the edge\" split as g60/parser.ts + g60/bridge.ts, so detectForeignForm is unit-testable with synthetic fixtures, no ExcelJS/real workbook needed for the matching logic itself. Adversarial-review fix (KAR-920, 10.07.2026, F1 BLOCKING + F2): the FIRST version of detectForeignForm normalized and `.join(' ')`-concatenated (a) every header-region cell of a sheet into one blob, and (b) every sheet name into one blob, then ran a plain substring search over each blob. Two reviewer-verified PoCs broke that: F1 — a real QAF's Prämissen sheet legitimately carries multi-column free text down to row 16-20 (structurally verified across the 20-file real sample). A plausible Prämissen note — \"Sonderwerkzeuge werden separat über das Werkzeug Analyse Formular kalkuliert und sind hier nicht enthalten.\" — CONTAINS the \"Werkzeug Analyse Formular\" signature as a substring of one long sentence, so the old blob-substring check rejected a genuine QAF. That is strictly worse than the pre-P5.3 status quo (a confusing parse error) — it is a hard false rejection of valid data. F2 — `wb.sheetNames.join(' ')` let a signature phrase assemble across the JOIN BETWEEN two unrelated sheet names (e.g. 'Kosten Werkzeug' + 'Analyse Formular X' → joined blob contains \"WERKZEUG ANALYSE FORMULAR\" even though neither sheet name does). The fix drops both blobs entirely. Detection is now CELL-BOUND (isCloseCellMatch): a single sheet name or header-region cell only counts as a hit when its OWN normalized text contains the pattern AND is not substantially longer than the pattern, whereas the F1 Prämissen sentence is far longer than the phrase it happens to contain and is correctly excluded. The header-region path additionally requires MIN_HEADER_CELL_HITS independent cell hits (real form titles repeat across merged-cell segments — the real WAF_de.xlsm sample repeats its title across 12 adjacent cells; a free-text cross-reference like the F1 PoC appears exactly once even before the isCloseCellMatch guard is considered). The sheet-name path stays a single-hit check (Excel's 31-char tab-name limit already bounds it, and each sheet name is now matched independently — no join — which is the F2 fix on its own). Re-Review rider (PR #293, Sev 30, 10.07.2026): isCloseCellMatch's original \"substring + length-ratio ≤1.5\" guard (CELL_LENGTH_SLACK) still let a SHORT cross-reference through — e.g. a Prämissen cell reading exactly \"siehe Werkzeug Analyse Formular\" (a short pointer to the separate WAF file, legitimately repeated 2× in rows 1-20, satisfying MIN_HEADER_CELL_HITS) is only ~1.2× the pattern's length, well inside the old 1.5 slack, and would have been misdetected as a genuine WAF title. Real form-title cells (verified across the WAF_de.xlsm sample and every evidence-quoted teil3.md screenshot title) contain EXACTLY the signature phrase and nothing else — no length tolerance is needed, only the punctuation/whitespace normalization normalizeForSignatureMatch already performs (hyphens, parentheses, double spaces, ...). isCloseCellMatch is now an exact match on the normalized text (cell == phrase), which also drops the length-ratio guard as dead weight — a strictly stronger check, not a relaxation, so every existing genuine-title fixture still matches (their cells already contain the phrase alone, nothing appended). KAR-923 (PR #294 review finding, 10.07.2026) — compound-title robustness, with an HONEST verification-status caveat: PR #293's exact-match-only rule is real-file-verified for exactly ONE signature — WAF-Signature-1 (\"Werkzeug Analyse Formular\"), whose title cells in the real WAF_de.xlsm sample contain the phrase alone. LAF and WAF-Signature-2 (\"Werkzeuganalyseformular\") have NO real-file sample; this repository has never seen a real LAF upload. The Leitfaden's own quoted wording for both — \"LAF (Logistikkosten Analyse Formular), Version LAF 6.2_06_01\" (teil3.md:13 [67]) and \"Das Werkzeuganalyseformular\" (teil3.md:334 [84], screenshot title) — reads as ONE printed compound title, not a phrase-only cell. An exact-match-only rule would silently MISS a real LAF or WAF-Signature-2 file: it falls through undetected into the QAF parser, which is precisely the false-negative failure class P5.3/detectForeignForm exists to prevent (worse than a WAF/LAF being over-rejected, since a false negative produces silently wrong numbers instead of a clear error). isCloseCellMatch is therefore widened from \"cell == phrase\" to \"cell contains the phrase at a WORD boundary AND the cell isn't substantially longer than the phrase AND the cell contains no reference-marker word\": 1. Word-boundary containment (containsPatternAsWholeWords) — the phrase must appear as a contiguous run of whole normalized words, so \"WERKZEUG ANALYSE FORMULAR\" still never matches out-of-order or partial-word variants (unchanged from the F1/F2 fix). 2. Length slack (CLOSE_MATCH_MAX_EXTRA_CHARS = 40) — the normalized cell may exceed the pattern by at most 40 chars, enough to cover every documented compound-title shape (numbering \"3. \", articles \"Das \", parenthetical/version suffixes \"(...), Version LAF 6.2_06_01\" — all / comfortably under 40 extra normalized chars) while still rejecting the F1 PoC free-text SENTENCE (~106 normalized chars, more than 2× the slack) by a wide margin — no separate \"numbering/article/ parenthesis\" pattern allowlist is needed, the length bound alone already draws that line for every case this task documents. 3. Reference-marker exclusion (REFERENCE_MARKER_WORDS) — \"siehe\"/\"vgl.\"/ \"see\" are explicitly disallowed anywhere in the cell. This is a DELIBERATE, DOCUMENTED trade-off: the PR #293 Re-Review rider PoC (\"siehe Werkzeug Analyse Formular\", Sev 30) is short enough to pass the length slack on its own, so without this explicit exclusion the widened word-boundary rule would reopen that exact gap. Excluding known reference-marker words closes it again while still allowing every documented compound-title shape (none of which start with a cross-reference marker). Net effect: WAF-Signature-1 stays real-file-verified-exact (its title IS phrase-only, so it matches under either the old or the new rule). LAF and WAF-Signature-2 gain compound-title coverage that is Leitfaden-wording- plausible but UNVERIFIED against a real file — see CHANGELOG.md for the per-signature verification-status table. The MIN_HEADER_CELL_HITS ≥2 requirement (below) is unchanged and remains the second net: even a widened per-cell match still needs 2 independent header-region hits (or a sheet-name hit) to reject an upload. PR #295 review rider (KAR-923, 10.07.2026, F1 Sev ~45 + F2 Sev ~20-25): two cheap gaps in the widened isCloseCellMatch, fixed before merge. F1 — the sheet-name path (detectForeignForm's first loop) reused the widened isCloseCellMatch but stayed SINGLE-HIT (no MIN_HEADER_CELL_HITS net — that requirement only ever applied to header-region cells). Before KAR-923 that was safe because isCloseCellMatch was itself an exact match; after KAR-923's length slack, Excel's 31-char tab-name limit sits UNDER the slack ceiling (pattern.length + 40), so a single tab named e.g. \"s. Werkzeug Analyse Formular\" (29 chars, reviewer PoC) would satisfy word-boundary+slack on ONE hit and reject an otherwise harmless workbook. Fixed by introducing isExactSignatureMatch (cell == phrase, no slack) for the sheet-name path only; the header-cell path keeps the widened isCloseCellMatch + its MIN_HEADER_CELL_HITS ≥2 net. F2 — REFERENCE_MARKER_WORDS only covered SIEHE/VGL/SEE. Reviewer PoCs \"gemäß Werkzeug Analyse Formular Anlage 3\" and \"laut Werkzeug Analyse Formular kalkuliert\" pass word-boundary+slack and were, before this fix, only ever caught by MIN_HEADER_CELL_HITS (untested, and not a net at all on the now-exact sheet-name path). Extended with GEM/GEMÄSS/LAUT/S (see REFERENCE_MARKER_WORDS doc comment for the GEM-vs-GEMÄSS normalization detail and the \"s.\" abbreviation case).",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export type ForeignFormKind = 'LAF' | 'LEK' | 'WAF' | export interface ForeignFormWorkbook { | export const HEADER_SCAN_ROWS = 20 | export interface ForeignFormDetection { | export function detectForeignForm(wb: ForeignFormWorkbook): ForeignFormDetection | null { | export function foreignFormWorkbookFromExcelJs(wb: { worksheets: Worksheet[] }): ForeignFormWorkbook {",
      "source_heading_line": 1906,
      "source_end_line": 2073
    },
    {
      "path": "lib/qaf-differences/internal/format-de.ts",
      "category": "core",
      "declared_lines": 61,
      "comment_summary": "Deutsche Zahlenformate der V2-Anzeigeebene. Warum im Kern und nicht in der Komponente: Auch der Kern schreibt Text, den jemand liest — die Diagnosezeilen der Brücke zum Beispiel. Vor dieser Datei standen dort rohe Gleitkommazahlen („es bleibt ein Rest von 1.370000000000004\"), weil die Formatierung nur in der Komponente lag und der Kern nicht dorthin greifen darf. Eine Stelle für beide löst das; die Richtung stimmt, weil Komponenten aus `lib/` importieren dürfen und nicht umgekehrt. Pure Funktionen, keine I/O.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": false,
      "public_signatures": "export function formatNumberDe(value: number, decimals = 2): string { | export interface MoneyParts { | export function scaleMoney(value: number, unit: string, decimals = 2): MoneyParts {",
      "source_heading_line": 2074,
      "source_end_line": 2091
    },
    {
      "path": "lib/qaf-differences/internal/formula-engine.ts",
      "category": "core",
      "declared_lines": 420,
      "comment_summary": "Formel-Extraktion, -Normalisierung und -Vergleich (KAR-900 / Master-Prompt §12.4 / Backlog P2.1). Bis zu diesem Modul wurde im gesamten lib/qaf-differences-Baum ausschließlich der berechnete Zellwert gelesen (cell.value.result) — nie die Formel selbst. Ein Formel-Wechsel mit zufällig identischem Ergebnis blieb dadurch komplett unsichtbar (genau das Risiko, das Master-Prompt §12.4 als \"hidden formula change must be reported even when the displayed value is unchanged\" benennt). Scope (bewusst eng, siehe Backlog-Item P2.1 \"NICHT rein\"): kein vollständiger Excel-Formel-Parser, keine Formel-Ausführungs-Simulation/Neu-Berechnung, kein generisches DE/EN-Mapping für alle >400 Excel-Funktionen — nur ein struktureller Vergleich (gleiche Formel? geänderte Referenzen? geänderte Konstanten?) über die Teilmenge an Funktionen, die in QAF-Dateien realistischerweise vorkommt (SUMME/SUM, WENN/IF, RUNDEN/ROUND, Grundrechenarten). ── Shared-Formula-Slaves (Adversarial-Review-Fix, KAR-900, 10.07.2026) ──── KRITISCHER Fund (Konfidenz 88): die ursprüngliche Extraktion las `cell.value.formula` — das trägt eine Formel NUR beim MASTER einer Excel-\"Shared Formula\"-Range (Copy-down-Spalten, in BMW-Fertigungskosten- // allow-customer-string Sheets üblich). Jede SLAVE-Zelle derselben Range hat `model.formula === undefined` (nur `model.sharedFormula` zeigt auf den Master) — verifiziert in node_modules/exceljs/lib/doc/cell.js: FormulaValue._copyModel kopiert `formula` nur wenn `model.formula` truthy ist. Ergebnis vor dem Fix: jede Slave-Zelle lieferte `null` statt einer Formel — wechselt eine Zelle zwischen ALT und NEU (routinemäßiger Re-Save, Zeilen-Insert, Excel wählt intern einen anderen Anker) die Rolle Master↔Slave, feuerte compareFormulaPair fälschlich `formel_zu_konstante`/kritisch auf einer völlig unveränderten Datei. FIX: beide Extraktionsstellen (qaf-parser.ts, workbook-adapter.ts) lesen jetzt `cell.formula`/`cell.formulaType` (die Cell-Level-Getter, NICHT `cell.value`). `cell.formula` löst Shared-Slaves über ExcelJS' eigene `_getTranslatedFormula()`/`slideFormula()` auf: sie nimmt die Formel des Masters und verschiebt jede NICHT-$-verankerte Zellreferenz um den Zeilen/ Spalten-Offset zwischen Master- und Slave-Adresse — der zurückgegebene String ist bereits positionskorrekt FÜR DIE SLAVE-ZELLE SELBST (verifiziert in shared-formula.js: `slideFormula(formula, fromCell, toCell)`). TRANSLATION-NORMALISIERUNGS-ENTSCHEIDUNG (wie vom Review gefordert dokumentiert): Da compareFormulaPair IMMER dieselbe logische Zellposition zwischen ALT und NEU vergleicht (z.B. \"die fk-Zelle von Zeile 2\" auf beiden Seiten), und `cell.formula` für JEDE Zelle — ob Master, Slave oder gar keine Shared-Range beteiligt — bereits den für GENAU DIESE Position korrekten Formeltext liefert, ist KEINE zusätzliche zeilen-relative Normalisierung nötig: die bestehende normalizeFormula/stripReferenceAnchors- Pipeline (die nur $-Anker vereinheitlicht, s.u.) operiert bereits auf dem schon positions-übersetzten Text. Ob eine Zelle intern Master oder Slave ist, ist reine Speicheroptimierung von Excel — semantisch identisch. TRI-STATE für den seltenen Fall, dass ExcelJS eine Shared-Formula NICHT auflösen kann (Master-Zelle fehlt/korrupt — `_getTranslatedFormula` findet `worksheet.findCell(sharedFormula)` nicht): `FormulaProvenance.unresolved` (s.u.) statt stillschweigend wie \"keine Formel\" zu behandeln — compareFormulaPair behandelt das explizit wie 'no_formula_data' und fällt NIE in den altFormula&&!neuFormula-Zweig (der `formel_zu_konstante` auslösen würde). Aufgeteilt in zwei Hälften mit unterschiedlicher Bundle-Disziplin: - Extraktion (buildFormulaProvenance/formulaHash) nutzt node:crypto (sha256) — wird NUR aus Parse-Zeit-Code aufgerufen. qaf-parser.ts läuft im Browser (components/qaf/qaf-client.tsx importiert parseQAFTemplate direkt) und muss dieses Modul daher per DYNAMISCHEM import laden (Bundle-Lehre #272, exakt das bereits etablierte Muster für canonical-model.ts/ canonical-fields.ts in qaf-parser.ts — siehe dortigen Modul-Header). workbook-adapter.ts ist bereits dokumentiert \"Server-only\" und darf dieses Modul statisch importieren. - Vergleich (compareFormulaPair/formulaFindingToPlausibilityIssue) braucht KEIN crypto (vergleicht nur bereits berechnete Hash-Strings) — differ.ts/ summary-metrics.ts/compare.ts dürfen dieses Modul daher statisch importieren, exakt wie template-fingerprint.ts (ebenfalls statisches node:crypto, ebenfalls über den qaf-differences-Barrel re-exportiert) bereits vorexistiert, ohne den Client-Bundle zu belasten. Pure. Kein I/O außer der reinen crypto-Berechnung (kein Netzwerk, keine DB).",
      "mentions_todo_or_future": true,
      "pure_or_no_io": true,
      "test_reference": false,
      "public_signatures": "export const FUNCTION_NAME_DE_TO_EN: Readonly<Record<string, string>> = { | export function normalizeFormula(raw: string): string { | export function stripReferenceAnchors(normalized: string): string { | export function formulaHash(normalized: string): string { | export interface FormulaProvenance { | export function buildFormulaProvenance(raw: string): FormulaProvenance { | export function unresolvedFormulaProvenance(): FormulaProvenance { | export const SHARED_FORMULA_UNRESOLVED: unique symbol = Symbol('unresolved-shared-formula') | export type FormulaComparisonKind = | export interface FormulaComparisonResult { | export function compareFormulaPair(params: { | export function formulaFindingToPlausibilityIssue( | export interface FormulaEngineConfig { | export const FORMULA_ENGINE_CONFIG: FormulaEngineConfig = { enabled: true }",
      "source_heading_line": 2092,
      "source_end_line": 2177
    },
    {
      "path": "lib/qaf-differences/internal/g60/analyze.ts",
      "category": "core",
      "declared_lines": 253,
      "comment_summary": "G60 pair analysis (KAR-840 G60 stage 1) — 1:1 port of V11 b3_core.js analyze(): common cost tabs, six-bucket aggregation per side, global machine lever (median at15 ratio) with the PART-SPECIFIC ±5 % flag, the 11 per-tab metric rows, the production table (primary steps), the annual impact from the repricing volumes and the INPUT rate-card diff. Pure + unit-tested.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export const G60_BUCKETS = ['Material', 'Labor', 'Manufacturing', 'ScrapB', 'SGA', 'Profit'] as const | export type G60Bucket = (typeof G60_BUCKETS)[number] | export interface G60SideAggregate extends Record<G60Bucket, number> { | export interface G60MetricRow { | export interface G60ProdRow { | export interface G60DriverDiff { | export interface G60Analysis { | export function analyzeG60Pair(",
      "source_heading_line": 2178,
      "source_end_line": 2197
    },
    {
      "path": "lib/qaf-differences/internal/g60/bridge.ts",
      "category": "core",
      "declared_lines": 30,
      "comment_summary": "ExcelJS → G60Workbook bridge (KAR-840 G60 stage G2). Server-only: ExcelJS is imported dynamically (kept out of client bundles), matching the existing workbook-adapter. Read-only; formula cells resolve to their cached result.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export function g60WorkbookFromExcelJs(wb: Workbook): G60Workbook { | export async function g60WorkbookFromBuffer(buffer: Buffer | ArrayBuffer): Promise<G60Workbook> {",
      "source_heading_line": 2198,
      "source_end_line": 2208
    },
    {
      "path": "lib/qaf-differences/internal/g60/calculator.ts",
      "category": "core",
      "declared_lines": 86,
      "comment_summary": "Unit-price calculator (KAR-847): the single-row G60 cost formula (same math as scenario.ts recomputeTab, single-part view BA=AZ=1) for the standalone Preis-Kalkulator tab. Pure + unit-tested against hand-computed values.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export interface UnitPriceParams { | export interface UnitPriceResult { | export const EMPTY_UNIT_PRICE_PARAMS: UnitPriceParams = { | export function computeUnitPrice(p: UnitPriceParams): UnitPriceResult {",
      "source_heading_line": 2209,
      "source_end_line": 2221
    },
    {
      "path": "lib/qaf-differences/internal/g60/export.ts",
      "category": "core",
      "declared_lines": 220,
      "comment_summary": "G60 Excel export (KAR-913/P4.3) — the G60-Detailvergleich counterpart to the existing 8-sheet summary export (../export.ts). Deliberately a SEPARATE builder, not a mode branch inside buildQafExportWorkbook: G60's data shape (per-tab aggregates, INPUT rate card, production KPIs) has no equivalent in the summary QafComparisonResult, so sharing one function would mean a large optional-everything input type instead of two focused ones. Sheets: 1. README — Deckblatt: Engine-Version, ALT/NEU-Dateien, Rates-/Volumes-Prämissen (Master-Prompt §17/§18). 2. Tab_Übersicht — Delta-Übersicht über alle Tabs: die 11 Row-41- Kennzahlen je gemeinsamem Kostenreiter (identisch zu G60Analysis.rows, dem On-Screen-Datensatz der Metrik-Tabelle), inkl. Quellzell-Provenance (KAR-894/P1.3 sourceCells, sofern persistiert). 3. Produktion — Produktionssicht je Kostenreiter (Zykluszeit, Mitarbeiter, Scrap, MSS, Ineffizienz). 4. Kalkulator_Treiber — INPUT-Ratenkarte-Diff (G60Analysis.driverDiff) — die \"Kalkulator-Ergebnisse\" aus dem Backlog-Item. 5. Struktur_Plausibilität — G60-Struktur-Guard-Funde (P0.3/P1.4), bilingual (KAR-906-Muster). Deliberately NOT exported: live Szenario-Editor-Overrides. The scenario engine (scenario.ts) is session-only — no qaf_g60_scenario table exists, overrides never leave the browser (see qaf-g60-detail.tsx: \"Eingaben gelten je Sitzung\"). Exporting them would violate the \"reproducible from persisted data, on-screen result == download\" acceptance criterion, since there is no single canonical on-screen scenario state to reproduce. See PR body. Server-only (ExcelJS imported dynamically, same discipline as ../export.ts).",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export interface G60ExportInput { | export async function buildG60ExportWorkbook(input: G60ExportInput): Promise<ArrayBuffer> {",
      "source_heading_line": 2222,
      "source_end_line": 2256
    },
    {
      "path": "lib/qaf-differences/internal/g60/parser.ts",
      "category": "core",
      "declared_lines": 694,
      "comment_summary": "G60 detail-QAF parser core (KAR-840 G60 stage 1) — 1:1 port of the validated V11 b3_core.js extraction: INPUT rates/card, Stückzahlen volumes, cost tabs (^\\d+_2) with row-41 aggregates, EUR-row SGA/Profit split by directed/sourced, process steps and the full component rows used by the scenario engine later. Pure: operates on a CellGetter interface (A1 → value), so the whole engine is unit-testable without ExcelJS; the workbook bridge lives in the upload pipeline (stage G2).",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export type CellGetter = (addr: string) => unknown | export interface G60Workbook { | export function sheetFromCells(cells: Record<string, unknown>): CellGetter { | export function workbookFromSheets(sheets: Record<string, CellGetter>): G60Workbook { | export type G60MetricKey = keyof typeof MCOL | export const TAB_ROW_FIRST = 15 | export const SECONDS_PER_HOUR = 3600 | export function detailTabs(wb: G60Workbook): string[] { | export interface G60DetectionConfig { | export const DEFAULT_G60_DETECTION_CONFIG: G60DetectionConfig = { | export function detectG60(wb: G60Workbook, config: G60DetectionConfig = DEFAULT_G60_DETECTION_CONFIG): boolean { | export interface G60Rates { | export type G60RatesSourceCells = Record<keyof G60Rates, string> | export const G60_RATES_SOURCE_CELLS: G60RatesSourceCells = { | export function inputRates(wb: G60Workbook): G60Rates { | export interface G60CardEntry { | export type G60InputCard = Record<string, G60CardEntry> | export function inputCard(wb: G60Workbook): G60InputCard { | export interface G60Volumes { | export function volumes(wb: G60Workbook): G60Volumes { | export interface G60StepSourceCells { | export interface G60Step { | export interface G60TabAggregate extends Record<G60MetricKey, number> { | export interface G60ColumnOverrides { | export function extractTab( | export function primaryStep(rec: G60TabAggregate): G60Step | null { | export function primaryComponentRow<T extends { AO: number; AT: number }>(rows: readonly T[]): T | null { | export function g60TabLabel( | export interface G60ComponentRow { | export function extractFullTab(sheet: CellGetter, columnOverrides: G60ColumnOverrides = {}): G60ComponentRow[] {",
      "source_heading_line": 2257,
      "source_end_line": 2302
    },
    {
      "path": "lib/qaf-differences/internal/g60/persistence.ts",
      "category": "core",
      "declared_lines": 115,
      "comment_summary": "G60 persistence mapper (KAR-840 G60 stage G2) — pure transformation of a parsed G60 workbook into rows matching migration #105 (qaf_g60_tab, qaf_input_card, qaf_file.g60_meta). The server action inserts them; keeping this pure means the whole persistence shape is unit-tested without a DB.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export interface G60PersistenceContext { | export interface G60TabRow { | export interface G60InputCardRow { | export interface G60FileMeta { | export interface G60Rowset { | export function buildG60Rows(",
      "source_heading_line": 2303,
      "source_end_line": 2318
    },
    {
      "path": "lib/qaf-differences/internal/g60/rehydrate.ts",
      "category": "core",
      "declared_lines": 62,
      "comment_summary": "G60 rehydration (KAR-840 G60 stage G3): persisted #105 rows → the engine's G60ParseResult, so analyzeG60Pair re-runs from normalized DB data instead of re-parsing xlsx. Pure; round-trip tested against the direct parse.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export interface G60TabDbRow { | export interface G60CardDbRow { | export function rehydrateG60File(",
      "source_heading_line": 2319,
      "source_end_line": 2330
    },
    {
      "path": "lib/qaf-differences/internal/g60/scenario.ts",
      "category": "core",
      "declared_lines": 350,
      "comment_summary": "G60 scenario recompute engine (KAR-840 stage G4) — 1:1 port of V11 b3_core.js effParams/recomputeTab/scenarioModel/recomputeScenario/tabDetail. Delta anchoring (the V11 core idea): the engine recomputes each tab twice — once with no overrides (\"base\") and once with the scenario overrides — and applies only the DIFFERENCE on top of the cached (persisted) aggregates. With no overrides the scenario therefore reproduces the persisted numbers EXACTLY, even where our row model approximates the workbook's formulas (rem_pu holds the per-unit remainder constant). Pure and client-safe: runs in the browser for live editing.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": false,
      "public_signatures": "export interface G60ScenarioGlobal { | export interface G60RowOverride { | export interface G60TabOverrides { | export interface G60ScenarioOverrides { | export function effParams( | export interface G60TabTotals { | export function recomputeTab( | export interface G60ScenarioTab { | export interface G60ScenarioModel { | export function buildScenarioModel( | export interface G60ScenarioTabResult { | export interface G60ScenarioResult { | export function scenarioBaseline(model: G60ScenarioModel): G60ScenarioResult { | export function recomputeScenario(model: G60ScenarioModel, ov: G60ScenarioOverrides): G60ScenarioResult { | export interface G60DetailRow { | export interface G60TabDetail { | export function tabDetail(model: G60ScenarioModel, t: string, ov: G60ScenarioOverrides): G60TabDetail {",
      "source_heading_line": 2331,
      "source_end_line": 2362
    },
    {
      "path": "lib/qaf-differences/internal/g60/stopwatch-map.ts",
      "category": "core",
      "declared_lines": 82,
      "comment_summary": "Stopwatch → scenario mapping (KAR-843): suggest which measured station a G60 process row belongs to, by significant-token overlap of the free-text names (\"IM 2700t Spritzguss\" ↔ \"Spritzguss IM2700t\"). Pure + unit-tested; the user corrects suggestions via dropdown (same pattern as manual step matching, KAR-845).",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export interface StationMeasurement { | export function suggestStationForRow(",
      "source_heading_line": 2363,
      "source_end_line": 2375
    },
    {
      "path": "lib/qaf-differences/internal/g60/structure-guard.ts",
      "category": "core",
      "declared_lines": 727,
      "comment_summary": "G60 structure guard (KAR-888 / P0.3) — minimal label-anchor validation that stops the G60 parser from silently returning wrong or empty numbers when a cost tab's header row or the INPUT sheet's rate-card labels no longer match what parser.ts's fixed-coordinate extraction assumes (Master-Prompt §7 \"silent template modification without an updated version number\", §8 \"only the affected section is blocked, not the whole file\"). Deliberately NOT a rewrite of the extraction itself (that is the semantic label-anchored G60 rebuild scoped to P1.3) — this module only wraps the existing, unmodified parser.ts: it can say \"the coordinates I read do/don't look like what they should\", it can never re-locate a value that moved. Anchor vocabulary — VERIFIED against two real G60 files on the operator's // allow-customer-string server on 2026-07-09 (100_G60_DP_QAF_Basis_24_10_BMW.xlsm and // allow-customer-string 20260508_BMW_QAF_G60_DP_RePricing_HO.xlsm; header TEXTS only, read-only, // allow-customer-string no values extracted, files never copied into this repo — see PR #269 review). That verification corrected two things the original (pre-review) version of this module got wrong from Leitfaden-derived guessing alone: 1. The header row is NOT `TAB_ROW_FIRST − 1` (14) — both real files carry a genuine header only in a ROW 12:14 MERGED CELL, whose value lives on row 12. Row 14 (the merge's last row) reads back empty in both files, which would have hard-excluded every real cost tab (5/5 anchors blank at once). G60_HEADER_ROW is now the verified literal 12, decoupled from parser.ts's TAB_ROW_FIRST (that constant is about the DATA rows 15-38, not the header — the two were never actually related). 2. Column Y's real header is \" Raw material sucharge\\n RoZ0 [AW]\" — BMW's // allow-customer-string own template has a typo (\"sucharge\", not \"surcharge\") in BOTH files (basis AND repricing), so it is the template's actual spelling, not a one-off error. The synonym list below matches on the substring \"raw material\" (present regardless of the surcharge/sucharge spelling) so a future corrected template still matches too. Columns W (\"Material costs\"), AO (\"Cycle time\"), AQ (\"Number of direct employees\") and AT (\"Machine-hour rate\") matched the ENGLISH synonym already present before this verification without any change — both real files are English-only at these cells (no German variant observed), the DE synonyms are kept as a documented-but-unverified fallback for a German-language template variant, not because one was seen. The INPUT!B22-B29 rate-card anchors reuse the label CELLS the parser already reads via inputCard() (B{row} next to C{row}) but never validates; the guard checks only PRESENCE there (any non-blank text), not exact wording. Real-file check: B22-B29 read 'Overhead FK'/'Overhead MAT'/ 'Profit FK'/'Profit MAT' (the same four labels repeated for the direct/sourced halves) in both files — confirms presence-only is the right check here (an exact-match would have to know the labels repeat, which presence-only does not need to know).",
      "mentions_todo_or_future": true,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export interface G60StructureMismatch { | export interface G60ColumnRelocation { | export interface G60StructureFinding { | export interface G60StructureGuardConfig { | export const DEFAULT_RELOCATED_CONFIDENCE = 0.8 | export const G60_STRUCTURE_GUARD_CONFIG: G60StructureGuardConfig = { | export const G60_HEADER_ROW = 12 | export type G60AnchorStatus = 'matched' | 'relocated' | 'missing' | export interface G60ColumnLocation { | export function locateG60ColumnAnchors( | export function validateG60TabHeader( | export function validateG60InputRates( | export interface G60StructureReport { | export function validateG60Structure( | export interface G60GuardedParseResult extends G60ParseResult { | export function parseG60WorkbookGuarded( | export function g60TabExclusionToPlausibilityIssue(tabName: string, finding: G60StructureFinding, side: 'ALT' | 'NEU'): PlausibilityIssue { | export function g60InputStructureToPlausibilityIssue(finding: G60StructureFinding, side: 'ALT' | 'NEU'): PlausibilityIssue | null { | export function g60TabSoftMismatchToPlausibilityIssue(tabName: string, finding: G60StructureFinding, side: 'ALT' | 'NEU'): PlausibilityIssue | null { | export function g60TabColumnRelocationToPlausibilityIssue( | export interface G60StructureIssueInput { | export function buildG60StructureIssues(input: G60StructureIssueInput): PlausibilityIssue[] {",
      "source_heading_line": 2376,
      "source_end_line": 2446
    },
    {
      "path": "lib/qaf-differences/internal/g60/view.ts",
      "category": "core",
      "declared_lines": 296,
      "comment_summary": "G60 view-models (KAR-840 G60 stage G3): the analysis result → the shapes the existing charts/sections consume (buckets, bridge, movers) plus the G60-specific views (anomalies with CMEAN clear names, the three lever texts, the production index ALT=100). Pure + unit-tested. Deliberate improvement over V11 renderSec3: its Scrap/Cycle/#MA bars carried hard-coded placeholder values — here every index is the median over the real per-tab primary-step ratios.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export function buildG60Buckets(a: G60Analysis): BucketDatum[] { | export function buildG60Bridge(a: G60Analysis): BridgeData | null { | export function buildG60MoversInput(a: G60Analysis): MoversInput { | export const G60_CATEGORY_LABEL: Record<string, string> = { | export interface G60AnomalyRow { | export function buildG60Anomalies(a: G60Analysis): G60AnomalyRow[] { | export interface G60Lever { | export function buildG60Levers(a: G60Analysis): G60Lever[] { | export interface G60ProdIndexRow { | export function medianIneff(a: G60Analysis): number { | export function buildProdIndex(a: G60Analysis): G60ProdIndexRow[] {",
      "source_heading_line": 2447,
      "source_end_line": 2470
    },
    {
      "path": "lib/qaf-differences/internal/language-detection.ts",
      "category": "core",
      "declared_lines": 118,
      "comment_summary": "Per-file DE/EN/mixed language detection (KAR-905 / P3.1). Problem this closes (backlog 05-backlog-phasenplan.md [P3.1] task instruction, Master-Prompt §9): Kadi-v2 had no explicit notion of \"which language is this QAF file in\" — summary-metrics.ts's pickTemplate() comes close but answers a DIFFERENT question (QAF_LEGACY_DE_SUMMARY vs. QAF_V9_SUMMARY is a TEMPLATE-GENERATION axis, not a language axis: V9 SUMMARY sheets are used identically for DE-content and EN-content files — see module-sheet-names.ts's SUMMARY entry). This module is the actual language signal, deliberately kept separate from and complementary to pickTemplate/SummaryTemplateType. ── Why this is sheet-NAME-based, not content-based (documented scope) ────── The Leitfaden's own worked example (Abbildung 27, \"Sprachauswahl DE/EN\") shows a per-file radio-button language selection that changes the FIELD LABEL TEXT inside a sheet — it does not, per the module-sheet-names.ts inventory, change most sheets' TAB names (MATERIAL/SBM-DEVICES-FWZ/ LOGISTICS&CUSTOM/RAW MATERIAL RISKS/LC-CN/CO2e tab names are locale- invariant proper nouns/abbreviations). Only two signals are reliable sheet-name-level language markers today: 1. The MANUFACTURING tab name itself (\"Fertigungskosten\" DE vs. \"Manufacturing costs\"/\"Manufactering costs\" EN) — real-file-verified (qaf-parser.ts module header, canonical-fields.ts MANUFACTURING coverage note). 2. The SUMMARY tab being named \"Zusammenfassung\" — summary-metrics.ts's pre-existing pickTemplate() DE-legacy signal; there is no EN \"Zusammenfassung\" counterpart (V9's EN variant is also tab-named \"SUMMARY\", so absence of this signal is NOT an EN signal, only a \"not legacy-DE\" non-signal — see detectQafFileLanguage below, which therefore never emits an 'en' signal from the SUMMARY tab name alone). A stronger, content-based signal (which of labelDe/labelEn a MANUFACTURING header row matched more often) is NOT implemented here — task instruction explicitly scopes P3.1 to \"Summary-Template/Sheet-Namen\", and a content- level signal would need matchHeaderColumnSync (qaf-parser.ts) to expose per-column language, not just confidence tier — out of this PR's scope (kept as a documented limitation, not a silent guess). Consequence: a file with an unusual/renamed MANUFACTURING tab and a V9 SUMMARY tab produces NO signal at all → 'unknown', never a fabricated guess. A file with sheets pointing at different languages (e.g. a manually reassembled workbook mixing an EN Fertigungskosten... sorry, \"Manufacturing costs\" tab with content actually in German) produces 'mixed' — surfaced to the user via the fingerprint/UI badge, not silently resolved either way. Pure. No I/O — callers (template-fingerprint.ts's buildTemplateFingerprint) already have `sheets`/`summary.template` on hand from data they parsed. tdd-guard:skip — the detection logic itself IS test-driven (see __tests__/language-detection.test.ts); this skip marker only reflects that the module has no test-adjacent scaffolding to bypass (matches every other internal/*.ts module's convention of only marking pure-constant-data files, included here for consistency with sibling P2.x/P3.x module headers — this file is NOT pure constant data, remove if tdd-guard flags it as such).",
      "mentions_todo_or_future": true,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export type QafFileLanguage = 'de' | 'en' | 'mixed' | 'unknown' | export interface QafFileLanguageSignal { | export interface QafFileLanguageResult { | export interface QafFileLanguageInput { | export function detectQafFileLanguage(input: QafFileLanguageInput): QafFileLanguageResult {",
      "source_heading_line": 2471,
      "source_end_line": 2529
    },
    {
      "path": "lib/qaf-differences/internal/lccn-parser.ts",
      "category": "core",
      "declared_lines": 1135,
      "comment_summary": "LOCAL CONTENT CHINA (LC-CN) sheet parser + duale LC-Rate-Nachrechnung (KAR-904 / P2.5 — letztes Phase-2-Item). Built from the logistics-parser.ts/ rmr-parser.ts pattern (KAR-903/P2.4, KAR-902/P2.3): dynamic import of the canonical registry, label-anchor + confidence matching, a controlled degradation path on an unusable sheet (never throws), sourceCells/ normalized provenance from day one, and the coreFieldsFound tri-state + lccnForReconciliation/FromPersistedMeta helper pair built in from the START (the #274/KAR-898 lesson every P2.x module since has shipped on commit one instead of retrofitting). ── Struktur-Entscheidung: SINGLE-RECORD label-scan, not header+row grid ─── The Leitfaden's own worked screenshot (Abbildung 30, [49], \"Blatt LC-CN\") shows ONE Zusammenfassungsblatt with a fixed set of labeled Kostenblöcke (1a./1b./1c./2a./2b./SUMME HERSTELLKOSTEN/3a./3b./KALKULATION LC RATE) — exactly ONE record per file, not repeatable Positionsnummer rows like MATERIAL/SBM/RMR/LOGISTICS. [46]: \"Auf Registerblatt 'LC-CN' sind keine Eingaben erforderlich\" — the sheet is a pure, single, computed summary. This module therefore uses a whole-grid LABEL-SCAN (summary-parser.ts's labelScan pattern: locate a matching label cell, take the first non-empty value cell to its right, MAX_SCAN_RIGHT-bounded) against the canonical LC_CN registry's level:'summary' fields, instead of the header-row/ data-block shape every row-based P2.x parser before this one uses. ── Field-Scope: 15 of the 17 registered LC_CN canonical ids ──────────────── canonical-fields.ts's LC_CN_FIELDS already registers 17 ids (KAR-892/P1.1). 15 of them are level:'summary' — every one of the Blatt-LC-CN fields [49]'s own screenshot tabulates, extracted here. The remaining 2 (lccn_am_cif_share, lccn_am_process_clustering) are level:'row' — they are the \"LC-Gruppierung\" columns [47] documents as being added to the MATERIAL and MANUFACTURING COSTS sheets (\"Registerblätter MATERIAL und MANUFACTURING COSTS ... zusätzlich eingeblendet und befüllt\"), NOT columns on the LC-CN sheet itself. Extracting them would require extending material-parser.ts's/ a future manufacturing-costs row parser's OWN header scan with two more columns — a different module's scope, explicitly out of this PR's \"Kern- Umfang\" per the task instruction and already flagged as such in canonical-fields.ts's own lccn_am_process_clustering.notes (\"out of this PR's extraction scope\"). LccnFieldKey below therefore has 15 keys, and LCCN_FIELD_KEY_TO_CANONICAL (canonical-fields.ts) maps exactly those 15 — the other 2 canonical ids stay registered but unmapped by this module, consistent with belonging to a different sheet. ── Bindender Kais-Entscheid (10.07.2026, KAR-904-Kommentar) — LC-Rate ───── Der Leitfaden widerspricht sich bei der LC-Rate-Formel: [47] \"Formeln/Berechnungslogik\": LC Rate = (1 − Importierter Materialanteil / Gesamtkosten für LC Fertigung) × 100% [48] Feldbeschreibung \"LC Rate in %\": \"Verhältnis von Gesamt importierter Wert (CIF) / Gesamtkosten für LC Fertigung\" [47]s \"Importierter Materialanteil\" ist selbst definiert als \"CIF importiertes Material + CIF importiertes Material vom Unterlieferant\" [47] — wortgleich mit [48]s \"Gesamt importierter Wert (CIF)\" (\"Summe aus CIF-Kosten für lokales Material (Unterlieferant) und CIF-Kosten importiertes Material\" [48]). Beide Lesarten teilen sich also denselben Zähler (CIF = lccn_total_imported_value_cif) und denselben Nenner (Gesamtkosten = lccn_total_lc_manufacturing_cost) — sie sind rechnerische Komplemente (lokal = 100 − import), NICHT zwei unabhängig belegte Zahlen. Kais' Entscheid (bindend, siehe KAR-904-Kommentar): BEIDE Lesarten werden berechnet und klar beschriftet nebeneinander ausgewiesen (\"LC-Rate (lokal, Lesart S.47)\" / \"Import-Quote (Lesart S.48)\"), beide gegen die einzige tatsächlich im Excel vorhandene \"LC Rate in %\"-Zelle geprüft (das Blatt hat nur EIN LC-Rate-Feld, keine zwei — [49]s Screenshot listet nur \"LC Rate in %\" einmal unter \"KALKULATION LC RATE\"), mit explizitem Hinweis welche Lesart welches Ergebnis liefert. KEINE stille Wahl — siehe validateLccnLcRateReadings unten. ── 40/40-Hinweis (Leitfaden [46], AGGREGAT-Ebene, NICHT die volle Tier-2/Tier-3-Regel) ────────────────────────────────────────────────────── [46]: \"Ist bei einer lokal beschafften Subkomponente (Tier 2) die LC-Rate < 40%, muss der CIF-Wert (Tier 3) ausgewiesen werden (40/40 LC-Regel).\" Diese Regel operiert auf einzelnen Tier-2-Subkomponenten (MATERIAL-Zeilen-Ebene) — Daten, die dieser Parser (Zusammenfassungsblatt, ein Datensatz pro Datei) strukturell nicht hat. validateLccnLcRateReadings unten haengt an beide berechneten Lesarten einen rein INFORMATIVEN below40Hint an, wenn die jeweilige Lesart < 40% ergibt — ausdruecklich NICHT die vollstaendige Tier-2/Tier-3-Pruefung (die braucht Zeilen-Ebene, hier nicht verfuegbar). ── Validierungen (Kern-Umfang) ────────────────────────────────────────────── (a) validateLccnTotalProductionCost: SUMME HERSTELLKOSTEN = 1a+1b+1c+2a+2b — [49]s Screenshot nennt diese Formel EXPLIZIT: \"SUMME HERSTELLKOSTEN* (1a.+1b.+1c.+2a.+2b.)\". (b) validateLccnLcRateReadings: beide LC-Rate-Lesarten (lokal/import), siehe \"Bindender Kais-Entscheid\" oben. Namespace lccn_lc_rate_local / lccn_lc_rate_import. (c) validateLccnLcRateComplement: die zwei BERECHNETEN Lesarten summieren sich (bei denselben Inputs CIF/Gesamtkosten) rechnerisch immer zu ~100% — kein unabhaengiger zweiter Beleg (siehe Herleitung oben), aber ein sichtbarer, explizit ausgewiesener Konsistenz-Beleg exakt wie von Kais verlangt (\"als Komplemente gekennzeichnet\"). Gated auf dieselben Inputs wie (b) — eine Abweichung hier waere ein Implementierungsfehler (z.B. falscher Nenner), kein Fachbefund; wird trotzdem als expliziter Check gefuehrt statt stillschweigend angenommen, damit ein Regressions- fehler sichtbar wird statt sich in zwei separat \"bestanden\"-Checks zu verstecken. ── LC Checkliste Fertigungsprozess — Substantial vs. Other (Leitfaden Teil 3 S.91-95) ────────────────────────────────────────────────────────────────── Reference table only (analog SBM_DEVICE_CATEGORY_TABLE, sbm-parser.ts #274/KAR-898-Muster) — Fundament fuer ein kuenftiges Lookup-Issue, NICHT in diesem PR live gegen Zeilen gepruft. Grund: das LC-CN-Blatt selbst traegt laut [49]s Screenshot AUSSCHLIESSLICH Summen (1a.-3b., KALKULATION LC RATE) — keine Positionsnummer-/Prozessschritt-Zeilen. Die tatsaechliche \"Substantial vs. deklariert\"-Pruefung braucht die AM (Prozessclusterung)- Spalte auf MATERIAL/MANUFACTURING COSTS ([47], out of this module's Sheet- Scope, siehe oben) — classifyLcProcess() ist daher als getestetes, eigenstaendiges Lookup exportiert, aber in diesem PR an keine Zeile verdrahtet (dokumentiertes TODO fuer eine kuenftige PR, sobald die MATERIAL/MANUFACTURING-COSTS-Zeilen-Ebene die AM-Spalte parst).",
      "mentions_todo_or_future": true,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export interface LccnSummaryValues { | export type LccnFieldKey = keyof LccnSummaryValues | export const CORE_LCCN_FIELD_KEYS: readonly LccnFieldKey[] = [ | export function isLccnSheetName(name: string): boolean { | export function findLccnWorksheet(wb: { worksheets: Worksheet[] }): Worksheet | null { | export async function matchLccnLabelCell(cellText: string): Promise<LabelMatch | null> { | export interface LccnParseMeta { | export interface LccnParseResult { | export async function parseLccnWorksheet(ws: Worksheet): Promise<LccnParseResult> { | export async function parseLccnSheet(wb: { worksheets: Worksheet[] }): Promise<LccnParseResult | null> { | export function lccnForReconciliation(parsed: LccnParseResult | null): LccnParseResult | null { | export type PersistedLccnMeta = LccnParseResult | export function lccnFromPersistedMeta(meta: PersistedLccnMeta | null | undefined): LccnParseResult | null | undefined { | export type LccnValidationCheckId = | export type LccnValidationStatus = 'bestanden' | 'abweichung' | 'nicht_pruefbar' | export interface LccnValidationResult { | export interface LccnValidationConfig { | export const LCCN_VALIDATION_CONFIG: LccnValidationConfig = { | export function validateLccnTotalProductionCost( | export function validateLccnLcRateReadings( | export function validateLccnLcRateComplement( | export interface LccnValidationInput { | export function evaluateLccnValidation( | export function lccnValidationResultToPlausibilityIssue(r: LccnValidationResult): PlausibilityIssue | null { | export function checkLccnValidation( | export type LcProcessClassificationTag = 'substantial' | 'other' | export interface LcProcessClassificationEntry { | export const LC_PROCESS_CLASSIFICATION_TABLE: readonly LcProcessClassificationEntry[] = [ | export interface LcProcessClassificationResult { | export function classifyLcProcess(prozessschritt: string): LcProcessClassificationResult | null {",
      "source_heading_line": 2530,
      "source_end_line": 2666
    },
    {
      "path": "lib/qaf-differences/internal/legacy-workbook-shim.ts",
      "category": "core",
      "declared_lines": 446,
      "comment_summary": "Legacy-BIFF-Lesepfad (.xls, OLE2/CDF-Container). ExcelJS ist zip-only: `wb.xlsx.load()` wirft bei einer echten .xls-Datei (\"Can't find end of central directory\"), weil BIFF kein ZIP-Container ist. Im Realkorpus sind das keine Einzelfälle — 1076 von 2475 Dateien eines einzigen Uploads (27.07.2026), davon 199 zusätzlich mit dem Excel-Standardpasswort verschlüsselt. Dieses Modul liest solche Dateien mit SheetJS und stellt sie in der Form bereit, die die 16 Parser des Lesepfads erwarten. Die Parser importieren `exceljs` ausschließlich als TYP (verifiziert: keiner zur Laufzeit), deshalb genügt Formgleichheit — kein Parser wird angefasst. Abgebildet wird genau die Oberfläche, die der Lesepfad benutzt: Workbook worksheets, getWorksheet Worksheet name, rowCount, columnCount, getRow, getCell, eachRow Row number, getCell, eachCell Cell value, formula, result, formulaType, numFmt, master, address, text Styles (`fill` etc.) sind bewusst NICHT abgebildet: sie werden im Lesepfad nirgends gelesen, nur im Export geschrieben (xlsx-style-helpers.ts). Der Fallstrick, der hier den Ausschlag gibt: `resolveCell()` in workbook-adapter.ts folgt `cell.master`, d.h. JEDE Zelle eines verbundenen Bereichs liefert den Wert des Ankers. Die QAF-Vorlagen merge-duplizieren formelgetriebene Kopfzeilen über viele Spalten (KAR-928). Ohne diese Propagation liest der Shim dort leere Zellen, und qaf-type-detector.ts sieht ein strukturell anderes Blatt als beim xlsx-Pfad — gemessen: 55 statt 359 Textzellen im Kopfbereich, und ein Variantenband, das um Faktor 10 abweicht.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export function isLegacyBiffBuffer(buffer: Buffer | ArrayBuffer): boolean { | export interface LegacyWorkbookLimits { | export const DEFAULT_LEGACY_LIMITS: LegacyWorkbookLimits = { | export class LegacyWorkbookError extends Error {} | export async function loadLegacyWorkbook(",
      "source_heading_line": 2667,
      "source_end_line": 2701
    },
    {
      "path": "lib/qaf-differences/internal/levers.ts",
      "category": "core",
      "declared_lines": 75,
      "comment_summary": "Negotiation levers (KAR-840, V11 section 7 — summary variant). V11 rule: for every matched process-step pair, each of four process KPIs with |Δ%| ≥ 5 % becomes a lever with a FIXED ask phrasing (renderSec7). The G60 variant (global machine-rate factor, INPUT-card drivers) follows with the G60 engine stage. Pure + unit-tested.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export interface LeverKpiDef { | export const LEVER_KPI_DEFS: LeverKpiDef[] = [ | export interface LeverDiffInput { | export interface Lever { | export function buildLevers(diffs: LeverDiffInput[]): Lever[] { | export function hasLeverKpiSignal(diffs: Array<Pick<LeverDiffInput, 'field' | 'delta_percent'>>): boolean {",
      "source_heading_line": 2702,
      "source_end_line": 2718
    },
    {
      "path": "lib/qaf-differences/internal/logistics-parser.ts",
      "category": "core",
      "declared_lines": 927,
      "comment_summary": "LOGISTICS&CUSTOM sheet row parser + Reconciliation (KAR-903 / P2.4): standalone parser for the QAF \"LOGISTICS&CUSTOM\" detail sheet (Leitfaden 02-leitfaden-teil2.md [39]-[41]). Built 1:1 from the material-parser.ts / sbm-parser.ts / rmr-parser.ts pattern (KAR-897/P1.6, KAR-898/P1.7, KAR-902/P2.3): dynamic import of the canonical registry, label-anchor + confidence matching, a controlled degradation path on an unusable header (never throws), sourceCells/normalized/rawText provenance from day one, and the coreFieldsFound tri-state + logisticsRowsForReconciliation/ FromPersistedMeta helper pair built in from the START — the #274/KAR-898 lesson rmr-parser.ts's own module header already documents (sbm-parser.ts RETROFITTED this contract after an adversarial-review finding; this module ships it from commit one, exactly like rmr-parser.ts did). ── Standort-Struktur-Entscheidung: FLAT ROWS, not RMR's multi-block (task instruction: \"prüfe im Leitfaden-Extrakt, ob das Block- oder Spalten-Struktur ist\") ───────────────────────────────────────────────── The Leitfaden is explicit about the sheet's actual layout (Abbildung 25, [41]): ONE table, ONE header row, 15 numbered rows (\"Positionsnummer\" 1-15), where \"Anlieferstandort\" is simply ONE COLUMN VALUE per row — exactly the same flat row/column shape MATERIAL and SBM already use (material-parser.ts/ sbm-parser.ts), NOT RMR's genuinely different \"two stacked header+data blocks on one tab\" layout (rmr-parser.ts's own module header, \"Rohstoff- preisanteile werden in 2 Bloecken dargestellt\"). There is no textual or visual evidence anywhere in [39]-[41] of a second header row, a second title block, or per-Anlieferstandort sub-tables — every delivery site the Leitfaden's worked description names (\"Spartanburg\" etc., illustrative only, no real numbers given) is just one more row value like any other. Reusing RMR's multi-block scan loop here would therefore be a premature abstraction over a structure this sheet does not actually have (task instruction: \"keine premature Abstraktion wenn die Strukturen zu verschieden sind: dann dokumentiert eigenständig\") — this module intentionally mirrors material-parser.ts's SINGLE header/single contiguous data block shape instead, plus the day-1 tri-state contract RMR/SBM converged on. If a real file ever demonstrates a genuine second block on this tab, that would be a new, separately-evidenced follow-up (same \"not solved here, flagged for a future PR\" posture RMR's own module header takes for its own residual layout risk) — not retrofitted speculatively now. ── Scope discipline (backlog 05-backlog-phasenplan.md [P2.4] + explicit task override, see below) ────────────────────────────────────────────────────── - Parses + persists the row shape, PLUS the one row-level formula check the Leitfaden's own \"Formeln/Berechnungslogik\" section documents with every input field this parser actually captures: Logistikkosten je Anlieferstandort = Transportkosten + Verpackungskosten + Vorverpackungskosten (je Bauteil) — see validateLogisticsCostFormula below. - The Leitfaden's SECOND documented formula (\"Zollkosten pro Bauteil je Anlieferstandort = Zollwert x Zollsatz, ggf. + CBAM-Kosten\", [40]) is explicitly NOT recomputed here: \"Zollwert\" and \"Zollsatz\" are never their own documented columns in the [40] field table (only \"Zollkosten pro Bauteil je Anlieferstandort\" and \"Zollkosten je Anlieferstandort\" are, both already themselves calculated results) — there is no independently-parseable input pair to multiply. Recomputing it would require guessing at an unparsed Zollwert/Zollsatz split the Leitfaden never tabulates as its own field, which the task instruction explicitly forbids (\"Automatik-Logik NICHT nachbauen — nur erfassen, dokumentiert\"). customsCostPerPart/customsCostPerDeliverySite are captured as plain data fields only, exactly like rmr-parser.ts's bmwParticipationRate/threshold are captured without being evaluated. - The [39]-vs-[40] prose/formula tension around a possible Volumen factor is a documented, NOT silently resolved, source discrepancy — see \"Volumen-Diskrepanz\" note below. - VERP/LVVV surcharge-key AUTOMATION LOGIC (what triggers the auto-fill) is explicitly NOT rebuilt — surchargeKeyPackaging/surchargeKeyPrePackaging are captured as plain \"automatic/protected\" text fields (matching their canonical-fields.ts classification: \"protected\"), never independently derived or validated against the packaging-cost columns, per task instruction (\"VERP/LVVV-Zuschlagsschlüssel als Felder erfassen — Automatik-Logik NICHT nachbauen — nur erfassen, dokumentiert\"). - The Incoterm rechtslogik (what FCA/DAP/DDP actually mean contractually) is explicitly OUT of scope per the backlog item itself (\"NICHT rein: die vollstaendige Incoterm-Rechtslogik ... wird als Kontext-Information mitgefuehrt, nicht als eigene Validierungsregel geprueft\") — this module implements only a DOMAIN-LIST membership check (validateLogisticsIncoterm Domain below), never a legal/business consequence of the chosen Incoterm. - Cross-Sheet-Reconciliation against SUMMARY's customsSupplierToBMW/ transportSupplierToBMW metrics IS explicitly IN scope for this PR (the backlog's own P2.2-Kaskade-Anschluss note, task instruction) — see reconciliation.ts's logistics_transport_detail_sum/logistics_customs_ detail_sum checks, wired from actions.ts/compare.ts, NOT built in this file (this module only exposes logisticsRowsForReconciliation, the same \"produces the tri-state value, does not itself sum against a Summary metric\" boundary material-parser.ts/sbm-parser.ts/rmr-parser.ts all keep). Post-merge adversarial-review fix (confidence 82): which LogisticsRow field the transport check sums (transportCostPerPart, NOT the combined logisticsCostPerDeliverySite) is a deliberate, evidence-limited choice — see reconciliation.ts's own module header \"Evidenzlage + Korrektur\" for the full derivation and the empirical check against real BMW Summary-QAFs (none of the 9 available files // allow-customer-string carry a LOGISTICS&CUSTOM sheet, so the combined-vs-transport-only question could not be settled from real data). - Cross-file matching/diffing of LOGISTICS rows (ALT vs NEU) is explicitly OUT of scope for this PR — same boundary every prior sheet parser in this family documents for its own row domain. ── Volumen-Diskrepanz (Leitfaden [39] Prosa vs. [40] Formel — dokumentiert, nicht stillschweigend aufgeloest, Master-Prompt §2 \"never silently guess\") ─ [39]'s free-text description says: \"Logistikkosten je Anlieferstandort: Gesamtkosten automatisch aus Volumen x (Transportkosten + Zusatzverpackung + Einwegverpackung) berechnet\" — implying a Gesamtvolumen multiplication. [40]'s own \"Formeln/Berechnungslogik\" section, however, states plainly: \"Logistikkosten je Anlieferstandort = Transportkosten + Verpackungskosten + Vorverpackungskosten (je Bauteil)\" — no volume factor at all, and the backlog item [P2.4] itself quotes EXACTLY this second (no-volume) formula as the one to validate (\"Formel-Validierung 'Logistikkosten je Anlieferstandort = Transport+Verpackung+Vorverpackung' ... als Business-Rule\"). validateLogisticsCostFormula below implements the backlog's own literal formula (no Gesamtvolumen multiplication) — the authoritative \"Formeln/Berechnungslogik\" table entry, not the looser prose paragraph one page earlier. This is a deliberate, documented choice: if a real file later demonstrates the sheet is genuinely volume-scaled, that is new evidence for a follow-up, not a reason to silently pick one reading now.",
      "mentions_todo_or_future": true,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export interface LogisticsRowValues { | export type LogisticsFieldKey = keyof LogisticsRowValues | export type LogisticsRow = LogisticsRowValues & { | export const CORE_LOGISTICS_FIELD_KEYS: readonly LogisticsFieldKey[] = [ | export function isLogisticsSheetName(name: string): boolean { | export function findLogisticsWorksheet(wb: { worksheets: Worksheet[] }): Worksheet | null { | export async function matchLogisticsHeaderColumn(headerCell: string): Promise<ColumnMatch | null> { | export async function findLogisticsHeaderRow(grid: unknown[][]): Promise<number | null> { | export interface LogisticsParseMeta { | export type LogisticsParseResult = LogisticsRow[] & LogisticsParseMeta | export async function parseLogisticsWorksheet(ws: Worksheet): Promise<LogisticsParseResult> { | export async function parseLogisticsSheet(wb: { worksheets: Worksheet[] }): Promise<LogisticsParseResult | null> { | export function logisticsRowsForReconciliation(parsed: LogisticsParseResult | null): LogisticsRow[] | null { | export interface PersistedLogisticsMeta { | export function logisticsRowsFromPersistedMeta( | export type LogisticsValidationCheckId = 'log_calc_cost_per_delivery_site' | 'log_incoterm_domain' | export type LogisticsValidationStatus = 'bestanden' | 'abweichung' | 'nicht_pruefbar' | 'pruefen' | export interface LogisticsValidationResult { | export interface LogisticsValidationConfig { | export const LOGISTICS_VALIDATION_CONFIG: LogisticsValidationConfig = { | export function validateLogisticsCostFormula( | export const LOGISTICS_INCOTERM_VALUES: readonly string[] = ['FCA', 'DAP', 'DDP'] | export function validateLogisticsIncotermDomain( | export interface LogisticsValidationInput { | export function evaluateLogisticsValidation( | export function logisticsValidationResultToPlausibilityIssue(r: LogisticsValidationResult): PlausibilityIssue | null { | export function checkLogisticsValidation(",
      "source_heading_line": 2719,
      "source_end_line": 2860
    },
    {
      "path": "lib/qaf-differences/internal/manufacturing-cost-check.ts",
      "category": "core",
      "declared_lines": 161,
      "comment_summary": "Unabhängige Nachrechnung der Fertigungskosten je Station (Spezifikation Kap. 7.4, Detektor D06). Die Zelle im Formblatt sagt, was eine Station kostet. Die Parameter danebendran sagen, was sie kosten MÜSSTE. Solange niemand beides gegeneinander rechnet, fällt eine defekte Formel nicht auf — und im Anlassfall ist genau das passiert: die Summenzeile des Templates für Personal, Maschine und Gemeinkosten ist defekt, weil die Hilfsspalten nur in einem Teil der Zeilen Formeln tragen. Diese Datei rechnet die Kosten aus den Parametern nach: Kosten je Stück = (Personal · Lohn · (1 + Sozialkostensatz) + Maschinenstundensatz + Gemeinkostensatz je Stunde) · Zykluszeit / Teile je Takt / 3600 Stimmt das Ergebnis nicht mit der Zelle überein, ist das ein Befund — kein Anlass, still die eine oder andere Zahl zu bevorzugen. Pure Funktionen, keine I/O.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": false,
      "public_signatures": "export interface StationCostInputs { | export interface StationCostBreakdown { | export function computeStationCost(input: StationCostInputs): StationCostBreakdown | null { | export type CostCheckStatus = 'match' | 'mismatch' | 'not_computable' | export interface StationCostCheck { | export function checkStationCost( | export interface CostCheckSummary { | export function checkAllStationCosts(",
      "source_heading_line": 2861,
      "source_end_line": 2889
    },
    {
      "path": "lib/qaf-differences/internal/manufacturing-mapping.ts",
      "category": "core",
      "declared_lines": 259,
      "comment_summary": "Stationsmapping und Parameteridentität (Spezifikation Kap. 7.4, R-10/R-16). Die Kernaussage einer Fertigungsanalyse lautet oft „die Hauptlinie ist unverändert, das Delta kommt aus neuen Stationen\". Das ist eine starke Behauptung — und sie lässt sich belegen oder widerlegen, wenn man die Parameter vergleicht statt der Namen: Zykluszeit, Mitarbeiterzahl, Maschinen- und Gemeinkostensatz. Zwei Stationen mit identischen Parametern sind dieselbe Station, auch wenn jemand sie umbenannt hat. Zwei Stationen mit gleichem Namen, aber anderen Parametern sind es nicht — dort hat sich etwas geändert, und das gehört ausgewiesen statt eingeebnet. Pure Funktionen, keine I/O.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": false,
      "public_signatures": "export interface StationParameters { | export interface MappableStation extends StationParameters { | export type StationMatchType = 'exact' | 'renamed' | 'added' | 'removed' | export interface StationMapping { | export interface StationMappingResult { | export function parametersIdentical(a: StationParameters, b: StationParameters): boolean { | export function mapStations(",
      "source_heading_line": 2890,
      "source_end_line": 2913
    },
    {
      "path": "lib/qaf-differences/internal/manufacturing-stations.ts",
      "category": "core",
      "declared_lines": 146,
      "comment_summary": "Stationsbegriff, Takt und Kapazität der Fertigung (Spezifikation Kap. 7.4/8.8, R-10). Zwei Ebenen liegen im Fertigungsblatt übereinander: beschreibende Untervorgänge ohne Zeit und Kosten, und kostenwirksame Stationen. Wer beide zusammenwirft, zählt je nach Zählweise 31 oder 32 Stationen — und genau dieser Streit lässt sich nicht diskutieren, sondern nur definieren. Die Definition hier ist die der Spezifikation: kostenwirksam ist eine Zeile mit Zykluszeit, Mitarbeitereinsatz oder Kosten. Eine benannte Zeile ohne jeden Parameter ist keine Station — sie erzeugt aber einen Datenbefund, damit sie nicht einfach verschwindet. Pure Funktionen, keine I/O.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": false,
      "public_signatures": "export interface ManufacturingRow { | export type RowKind = 'station' | 'sub_operation' | 'named_without_parameters' | export interface ClassifiedRow extends ManufacturingRow { | export function classifyRows(rows: ManufacturingRow[]): ClassifiedRow[] { | export interface TaktInput { | export interface Takt { | export function computeTakt(input: TaktInput): Takt { | export interface StationCapacity { | export function computeCapacity(stations: ClassifiedRow[], takt: Takt): StationCapacity[] { | export function stationsOverPeak(capacity: StationCapacity[]): StationCapacity[] {",
      "source_heading_line": 2914,
      "source_end_line": 2940
    },
    {
      "path": "lib/qaf-differences/internal/manufacturing-sub-operations.ts",
      "category": "core",
      "declared_lines": 122,
      "comment_summary": "Verlagerte Untervorgänge (Spezifikation Kap. 7.4, R-10). Untervorgänge sind die beschreibende Ebene des Fertigungsblatts: „Bauteil auf Vorrichtung legen\", „zwei Schrauben anziehen\". Sie tragen keine eigene Zeit und keine eigenen Kosten — deshalb übersieht man leicht, dass ihr Verschwinden eine Frage aufwirft. Fallen Arbeitsschritte weg, während die Taktzeiten der Hauptlinie unverändert bleiben, ist das ein Verhandlungspunkt: Entweder waren die Schritte nie zeitrelevant, oder die Zeit steckt noch im Preis. Beides ist möglich, aber nur eines ist richtig — und wer die Frage nicht stellt, bekommt keine Antwort. Pure Funktionen, keine I/O.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": false,
      "public_signatures": "export interface SubOperation { | export interface SubOperationDiff { | export function diffSubOperations(award: SubOperation[], current: SubOperation[]): SubOperationDiff { | export interface TimeCreditQuestion { | export function timeCreditQuestion(diff: SubOperationDiff, mainLineUnchanged: boolean): TimeCreditQuestion {",
      "source_heading_line": 2941,
      "source_end_line": 2962
    },
    {
      "path": "lib/qaf-differences/internal/matcher.ts",
      "category": "core",
      "declared_lines": 350,
      "comment_summary": "QAF Matching cascade (KAR-799, spec B3 — the Pflichtkern). Aligns the process steps of two QAFs OF THE SAME PART NUMBER (ALT vs NEU) into a 5-stage cascade. Low confidence / requires_review is the NORMAL case for real QAFs and is a RUNTIME review signal, never a development stop (B15). Distinct from lib/qaf/process-mapping.ts (which maps QAF rows to process_step stations). Here we align ALT-QAF steps to NEU-QAF steps. Reuses scoreCandidate (Jaccard token overlap) and computeFieldDiff for the cost-divergence trigger. ── Cross-language (DE vs. EN) matching — audited, unchanged (KAR-905/P3.1) ── classifyPair below compares prozessbezeichnung/bezeichnungAnlage/ teilebenennung via namesEqual()/scoreCandidate() (Jaccard token overlap on normalizeProcessName's output) — plain FREETEXT the supplier typed, never translated by Kadi-v2 or by BMW's QAF template (the Leitfaden's language // allow-customer-string selector, Abbildung 27, only switches the surrounding FIELD LABELS, never row-level content the supplier wrote). When ALT is a DE-content file and NEU is an EN-content file (or vice versa), a genuinely identical process step will almost always carry two DIFFERENT strings in these three fields (e.g. \"Schweissen\"/\"Schweisszelle 1\" vs. \"Welding Station Alpha\"/\"Weld Cell One\") — Jaccard token overlap between two different-language strings that share no tokens scores at or near 0, exactly like an unrelated pair of DE strings would. This is NOT a matcher bug and is deliberately NOT \"fixed\" with any translation/transliteration layer (task instruction: \"KEINE Übersetzungs-Magie einbauen\" — Master-Prompt §2's \"never silently guess\" extends to inventing a cross-language equivalence Kadi-v2 cannot verify). The cascade already degrades gracefully for exactly this case, unchanged: classifyPair's posEqual branch does NOT require name agreement to produce a match — same Positionsnummer alone is enough to reach `possible_structure_ change` (method 'position_divergent', confidence 0.5, requiresReview: true) instead of falling through to \"new\"/\"removed\" structure findings. So a cross-language pair with genuinely untranslated free text still matches (via Positionsnummer), just with LOWER confidence and an explicit review flag — never a silently wrong high-confidence match, never a false \"this step disappeared\"/\"this step is new\" structure finding purely because the two files are in different languages. If a real file happens to reuse the exact same free text on both sides (a plausible case for a proper-noun part/machine designation a supplier keeps verbatim across language variants), `namesEqual` still resolves it at full confidence — the degradation only bites when the text ACTUALLY differs, exactly as it should. Verified end-to-end with a synthetic DE/EN workbook pair in __tests__/cross-language-e2e.test.ts (both scenarios: genuinely untranslated names -> possible_structure_change; coincidentally identical names -> safe_match). Field-level diffs (computeFieldDiff, differ.ts) are unaffected by any of this — they compare canonical/numeric QAFRow fields (fk, zykluszeit, …) by QAFFieldKey, never by label text, so they are already language-neutral (see en-header-verification.test.ts for the header-matching side of that same canonical-id-based neutrality).",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": true,
      "public_signatures": "export function matchSteps( | export interface ManualPin { | export function matchStepsWithPins(",
      "source_heading_line": 2963,
      "source_end_line": 3017
    },
    {
      "path": "lib/qaf-differences/internal/material-mapping.ts",
      "category": "core",
      "declared_lines": 640,
      "comment_summary": "Semantisches Positions-Mapping Material (Spezifikation Kap. 7.3/8.4). Befund F-06/F-07: Die gesamte Positionsebene fehlte. Der Vergleich sagte „Material +9,79\" und blieb die Antwort schuldig, WELCHE Position das treibt — genau die Frage, für die der Vergleich existiert. Diese Datei ordnet die Positionen beider Stände einander zu. Sie tut das in festgelegten Durchgängen, und jede Zuordnung trägt ihren Grund mit: die Spezifikation verbietet ausdrücklich, eine Zuordnung allein auf einen Ähnlichkeitswert zu stützen. Was hier entschieden wird, muss nachlesbar sein. Stand dieses Schnitts: Durchgang 1 (eindeutige Namensgleichheit) sowie die Restklassen neu/entfallen. Die schwierigeren Durchgänge — mehrfach gleiche Namen, Zusammenfassungen, Aufteilungen, Umbenennungen — folgen und sind hier bereits im Typ vorgesehen, damit sie nicht nachträglich angeflanscht werden.",
      "mentions_todo_or_future": true,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export type MaterialMatchType = | export interface MappablePosition { | export interface MaterialMapping { | export interface MaterialMappingResult { | export function buildMaterialMappings( | export type MaterialEffectBucket = | export interface ClassifiedMapping extends MaterialMapping { | export interface MaterialEffectResult { | export function classifyMaterialEffects(",
      "source_heading_line": 3018,
      "source_end_line": 3044
    },
    {
      "path": "lib/qaf-differences/internal/material-name.ts",
      "category": "core",
      "declared_lines": 115,
      "comment_summary": "Namensnormalisierung für den Material-Abgleich (Spezifikation Kap. 8.3). Grundlage jedes Positions-Mappings: zwei Schreibweisen desselben Bauteils müssen auf dieselbe Form fallen, ohne dass dabei Bedeutung verloren geht. Die Reihenfolge der Schritte ist Teil der Spezifikation und nicht beliebig — die Mengenextraktion etwa muss nach dem Kollabieren der Leerzeichen laufen, sonst greift ihr Muster bei \"2 x Schraube\" nicht. Bewusst NICHT enthalten: Ähnlichkeitsmaße. Diese Datei stellt Gleichheit her, wo sie besteht — sie beurteilt keine Nähe. Wer Kandidaten bewerten will, tut das in einem eigenen, nachvollziehbaren Schritt (Kap. 8.4).",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export interface NormalizedName { | export function normalizeMaterialName(raw: unknown): NormalizedName { | export function namesMatch(a: unknown, b: unknown): boolean { | export function tokenOverlap(a: NormalizedName, b: NormalizedName): number {",
      "source_heading_line": 3045,
      "source_end_line": 3063
    },
    {
      "path": "lib/qaf-differences/internal/material-parser.ts",
      "category": "core",
      "declared_lines": 837,
      "comment_summary": "MATERIAL sheet row parser (KAR-897 / P1.6): standalone parser for the QAF MATERIAL detail sheet (Leitfaden 02-leitfaden-teil1.md [15]-[28]), built from day one on the canonical field model (canonical-model.ts/ canonical-fields.ts, KAR-892/P1.1) instead of a hand-written exact-string dictionary — mirroring the label-anchor + confidence + degradation-path pattern qaf-parser.ts established for MANUFACTURING in KAR-893/P1.2 (see that file's module header for the tier-1/tier-2 matching rationale, which this module reuses verbatim). ── Scope discipline (backlog 05-backlog-phasenplan.md [P1.6]) ───────────── - Parses + persists the row shape only. Formula validation (Kalkulatorische Materialkosten = Kosten x Wechselkurs + Verpackung + Transport + Zoll + MGK) is a P2.2 business-rule item, not this PR — this module returns the raw + already-computed values from the sheet, it does not recompute or verify them. - RAW MATERIAL RISKS (the separate \"RAW MATERIAL RISK\" register sheet, P2.3) is explicitly NOT parsed here — see isMaterialSheetName below. MATERIAL rows reference a Rohstoffbezeichnung (rawMaterialDesignation) but do not resolve it against that sheet. - Cross-file matching/diffing of MATERIAL rows (ALT vs NEU) is explicitly OUT of scope for this PR — a follow-up needs a matcher.ts extension that understands the RMR split-row grouping this module exposes (groupMaterialRowsByPosition) as a single logical unit rather than two candidate rows. This module only parses + groups within ONE file. ── Rohstoffrisikobeteiligung / RMR split rows (Leitfaden [17], Master-Prompt §11) ─────────────────────────────────────────────────────────────────── A material with raw-material-risk participation is deliberately split across MULTIPLE rows that share the SAME Positionsnummer: one row carries the fixed material-cost share (left QAF area), one or more further rows carry the variable/raw-material-risk share (Mengeneinheit = \"Raw_material_risk_(kg)\", right \"ROHSTOFFMATERIALRISIKO\" area — logistics/ MGK/scrap columns are blank there, moved to the fixed row instead). This is NOT a duplicate/parse error — groupMaterialRowsByPosition below models the split explicitly so downstream code (reconciliation, a future matcher) treats it as one logical material position, not N independent rows. ── Client-bundle discipline (KAR-893 lesson, \"Bundle-Lehre aus #272\") ───── This module is exported through the qaf-differences barrel (index.ts), which several 'use client' components already import from (e.g. qaf-g60-scenario.tsx) — so canonical-model.ts/canonical-fields.ts (the ~115k-line, 13-module registry, of which only the 28 MATERIAL fields are ever read here) must stay OUT of the static import graph, exactly like qaf-parser.ts's loadManufacturingRegistry(). loadMaterialRegistry() is the same pattern: a dynamic import, cached after the first call (module-level Promise) so repeated parses don't re-import. Only TYPE imports (erased at compile time) are static below.",
      "mentions_todo_or_future": true,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export interface MaterialRowValues { | export type MaterialFieldKey = keyof MaterialRowValues | export type MaterialRow = MaterialRowValues & { | export const CORE_MATERIAL_FIELD_KEYS: readonly MaterialFieldKey[] = [ | export function isMaterialSheetName(name: string): boolean { | export function findMaterialWorksheet(wb: { worksheets: Worksheet[] }): Worksheet | null { | export async function matchMaterialHeaderColumn(headerCell: string): Promise<ColumnMatch | null> { | export async function findMaterialHeaderRow(grid: unknown[][]): Promise<number | null> { | export interface MaterialParseMeta { | export type MaterialParseResult = MaterialRow[] & MaterialParseMeta | export async function parseMaterialWorksheet(ws: Worksheet): Promise<MaterialParseResult> { | export async function parseMaterialSheet(wb: { worksheets: Worksheet[] }): Promise<MaterialParseResult | null> { | export function materialParseMetaToPlausibilityIssue( | export function materialRowsForReconciliation(parsed: MaterialParseResult | null): MaterialRow[] | null { | export interface PersistedMaterialMeta { | export function materialRowsFromPersistedMeta( | export const MATERIAL_UNIT_RAW_MATERIAL_RISK = 'Raw_material_risk_(kg)' | export const MATERIAL_UNIT_PURCHASED_PART = 'Unit_(purchased_part)' | export interface MaterialPositionGroup { | export function groupMaterialRowsByPosition(rows: readonly MaterialRow[]): MaterialPositionGroup[] { | export function materialRowLogicalKey(row: MaterialRow): string {",
      "source_heading_line": 3064,
      "source_end_line": 3134
    },
    {
      "path": "lib/qaf-differences/internal/module-degradation.ts",
      "category": "core",
      "declared_lines": 108,
      "comment_summary": "Confidence-/Provenance-UI read-model helpers (KAR-911/P4.1). Problem this module closes: material-parser.ts / sbm-parser.ts / rmr-parser.ts / logistics-parser.ts / lccn-parser.ts / co2e-parser.ts each already compute + persist a `coreFieldsFound` flag on `qaf_file.g60_meta` (see actions.ts's g60_meta insert — materialMeta/sbmMeta/rmrMeta/ logisticsMeta/lccnMeta/co2eMeta) whenever a detail sheet is PRESENT in the workbook but too degraded to trust (header row unreadable/renamed beyond the confidence floor). That signal was never surfaced anywhere in the UI — a degraded detail sheet looked identical to a QAF that simply never had that sheet at all (silently nothing, Master-Prompt §16 \"Do not hide uncertainty behind a single green status\"). This module does NOT compute any new confidence/validation — every `coreFieldsFound` boolean it reads was already decided by the respective parser. It only decides how to LABEL/GROUP that already-persisted signal for a UI read-model (qaf-comparison-detail.tsx via app/qaf-differences/[id]/page.tsx). Pure, no I/O, test-driven — see __tests__/module-degradation.test.ts.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export interface DegradedModule { | export interface DegradationMetaInput { | export function extractDegradedModules(meta: DegradationMetaInput | null | undefined): DegradedModule[] { | export function degradedModulesSentence(modules: readonly DegradedModule[]): { de: string; en: string } | null {",
      "source_heading_line": 3135,
      "source_end_line": 3161
    },
    {
      "path": "lib/qaf-differences/internal/module-sheet-names.ts",
      "category": "core",
      "declared_lines": 174,
      "comment_summary": "Centralized DE/EN worksheet-NAME synonym registry (KAR-905 / P3.1). Problem this closes (backlog 05-backlog-phasenplan.md [P3.1] + Master-Prompt §9): every QAF module parser (material-parser.ts, sbm-parser.ts, logistics-parser.ts, rmr-parser.ts, lccn-parser.ts, co2e-parser.ts) grew its own `isXxxSheetName(name)` function with the module's known worksheet-tab name(s) inlined as a literal string/substring check. That worked (each one individually correct — see below), but there was no SINGLE place to look up \"what worksheet names does Kadi-v2 recognize for module X, and why\" — every new module name variant (a typo, a locale spelling) meant hunting down and editing N different files. This module is that single place; every isXxxSheetName below is a thin wrapper around matchesModuleSheetName(). ── Why most of these lists are locale-INVARIANT (the actual P3.1 finding) ── The backlog item's own hypothesis was \"each module needs a DE alias list and an EN alias list, like Fertigungskosten/Manufacturing costs already has.\" Checked against the Leitfaden extraction (02-leitfaden-teil1/2/3.md) for every module's own worksheet-name citations: MOST BMW QAF tab names are // allow-customer-string fixed, English-language proper nouns/abbreviations that the Leitfaden cites VERBATIM even in its own German running text (\"Blatt MATERIAL\", \"Blatt SBM-DEVICES-FWZ\", \"Blatt 'LOGISTICS&CUSTOM'\", \"Blatt 'RAW MATERIAL RISKS'\", \"Registerblatt 'LC-CN'\") — the tab itself is not translated when a supplier fills out the DE vs. EN content variant (Abbildung 27 \"Sprachauswahl DE/EN\" only switches the row/column LABEL text inside a sheet, not the sheet's own tab name). Only ONE module tab genuinely has two different literal tab names depending on file language: MANUFACTURING (\"Fertigungskosten\" DE vs. \"Manufacturing costs\"/\"Manufactering costs\" EN — already correctly handled pre-existing in qaf-parser.ts, ported here verbatim). The pre-existing per-module isXxxSheetName functions were therefore not actually buggy for EN files on the sheet-NAME axis — this PR keeps their (correct) matching behavior unchanged while centralizing the string source and making the DE/EN-invariance finding explicit and evidence-cited, instead of leaving it as an unstated assumption baked separately into six files. KAR-915 update (10.07.2026): this finding was INCOMPLETE for RMR — the real V8.8 DE NCAR-Korpus file's tab is literally \"Rohstoffrisiken\" (a genuine DE tab-name translation this P3.1 extraction pass missed, most likely because the Leitfaden text this pass was sourced from only ever quotes the sheet in its EN form). RMR's own list below now carries a DE-only 'rohstoffrisiken' alias alongside the locale-invariant 'raw material risk' one — see that list's own comment for the parse- correctness impact this had before the fix (findRmrWorksheet() returning null on every \"Rohstoffrisiken\"-tabbed file, not just V8.8 ones). tdd-guard:skip — pure constant data + a thin lookup function, same category as canonical-fields.ts (data) — covered by __tests__/module-sheet-names.test.ts.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export type QafSheetModule = 'SUMMARY' | 'MANUFACTURING' | 'MATERIAL' | 'SBM' | 'LOGISTICS' | 'RMR' | 'LC_CN' | 'CO2E' | export interface ModuleSheetNameAlias { | export const MODULE_SHEET_NAME_ALIASES: Record<QafSheetModule, readonly ModuleSheetNameAlias[]> = { | export function matchesModuleSheetName(name: string, module: QafSheetModule): boolean { | export function moduleSheetNameLanguageSignal(name: string, module: QafSheetModule): 'de' | 'en' | null {",
      "source_heading_line": 3162,
      "source_end_line": 3214
    },
    {
      "path": "lib/qaf-differences/internal/movers.ts",
      "category": "core",
      "declared_lines": 145,
      "comment_summary": "Biggest price movers (KAR-840, V11 section 4 — Tornado). Pairwise app data has two granularities, mirroring V11: - 'prozessschritt': one row per matched manufacturing step (fk delta from qaf_manufacturing_diff) — the analytic tornado. Rows keep the caller's uncertain-match flag so the chart can show the same '*' caveat as the detail table for the identical underlying diffs. - 'vergleichsobjekt': summary QAFs WITHOUT any comparable steps fall back to a single row for the whole comparison object (V11 summary branch). Steps that existed but all netted to zero do NOT fall back — that would mislabel the situation as \"no comparable positions\". Pure + unit-tested; the chart handles top-N filtering.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export type TopN = 5 | 12 | 20 | 'all' | export interface MoverRow { | export interface MoversInput { | export interface MoversReconciliation { | export interface Movers { | export function buildMovers(input: MoversInput): Movers { | export interface MoversSelection { | export function selectMovers(rows: MoverRow[], topN: TopN): MoversSelection { | export function topMovers(rows: MoverRow[], topN: TopN): MoverRow[] {",
      "source_heading_line": 3215,
      "source_end_line": 3240
    },
    {
      "path": "lib/qaf-differences/internal/multi-qaf/aggregate-impact.ts",
      "category": "core",
      "declared_lines": 970,
      "comment_summary": "Multi-QAF Aggregat-Impact mit Validitäts-Gates (KAR-944 / Multi-QAF- Programm P3.3, Epic KAR-925 — the LAST P3 item). Problem this closes (30-backlog-phasenplan.md P3.3 + Master-Prompt §19): \"Only calculate an aggregate Multi-QAF impact when the basis is valid. Check: compatible currencies, available volumes, non-duplicated variants, clear variant matches, consistent units, valid baseline, no blocked critical mappings. Clearly distinguish: unit-price delta per variant, annual impact per variant, lifetime impact per variant, aggregate annual impact, aggregate lifetime impact, unweighted structural comparison. Do not aggregate unmatched or uncertain variants silently. Show the assumptions and included variant population.\" Every prior P2/P3 module (material-differ.ts/profile-differ.ts) already computes a per-finding, currency-gated commercial impact — this module never re-derives those numbers, it only SUMS them across a variant population that has cleared six explicit gates, and reports the sum PER CURRENCY (never cross-currency), PER TIMEFRAME (annual/lifetime, never conflated), with the excluded population and the reason for every exclusion. ── Gate design (six gates, each individually evaluated and reported in `gates`, never silently folded into a single boolean) ──────────────────── compatible_currencies — no included variant had a material/ profile contribution DROPPED because its own currency gate (material-differ.ts's `mixed_currency`/`currency_unknown` VariantImpact.reason, profile-differ.ts's `ProfileBindingCurrencyState`, or this module's own ALT/NEU VariantDefinition.currency check for item 2) fired. A variant with SOME contribution dropped for this reason still stays in the population with whatever OTHER currency-bucketed contributions it does have — this gate documents the gap, it never removes the variant (the buckets themselves already guarantee no cross-currency addition ever happens). volumes_available — every population variant carries BOTH `annualVolume` and `lifetimeVolume` (NEU side — see \"Which side's volume\" below). KAR-940's own module header documents that real files often have missing volume data; a variant missing EITHER metric is excluded from that ONE timeframe's aggregate (never both, unless both are actually missing) — see `excludedVariants`. no_duplicates_or_ambiguous — no `ambiguous`/`split_suspected`/ `merge_suspected` matchResult entry touches a variant in the base population (KAR-936 classes, variant-matcher.ts). Structurally guaranteed by construction (this module only ever reads `matched` entries into the base population) — this gate exists to make the EXCLUSION visible, not to filter anything itself. consistent_units — reuses variant-reconciliation.ts's own `currency` check (`VariantDefinition. currency` vs. the variant's own material- row currency, KAR-940/P2.6) rather than inventing a second unit-consistency signal: an `'abweichung'` on EITHER side of a matched pair means that variant's own currency label cannot be trusted as the bucket key for its profile-derived contributions (item 2/4 below), so the pair is excluded from the population entirely (not merely from one component, reason `inconsistent_units`). KAR-944 adversarial review F1 fix: a `'nicht_pruefbar'` currency check whose `nichtPruefbarReason` is `'fingerprint_unavailable'` (the container's own `templateFingerprint. structuralHash` is `null`, so variant- reconciliation.ts's fail-closed identity guard could not even ATTEMPT the check — `matchToContainer`) or `'unmatched_variant'` (the guard positively rejected the pairing) is FAIL-CLOSED, not a silent pass — it excludes the pair too (reason `consistent_units_not_verifiable`, distinct from `inconsistent_units` so \"checked and wrong\" stays visibly different from \"could not be checked\"). \"Not verified\" is never treated as \"verified ok\" (Master-Prompt §19: never silently aggregate the uncertain). The other two `nicht_pruefbar` reasons this same check can report — `'mixed_currency'` (the Buckets-Doktrin's own normal case) and `'fehlende_daten'` (a variant simply has no material rows at all) — are NOT this bug and stay excluded from this fail-closed rule (see `IDENTITY_GUARD_NICHT_PRUEFBAR_REASONS`'s own comment for why). valid_baseline — both containers free of their OWN review-relevant/critical warnings (`reviewStatusForMultiQafContainer`, reflected here via the caller-supplied `inputs.reviewRequired`) — Master-Prompt §19's \"valid baseline\" bullet. A failing baseline suppresses ONLY the SUMMED `annual.aggregate`/`lifetime.aggregate` figures (population forced empty, with an `aggregate_suppressed_baseline_invalid` assumption) — the per-variant unit-price/ annual/lifetime tables and the structural fallback stay populated regardless (§19 \"getrennte Ausweise\" are informational facts about individual variants, not the trusted container-wide total this gate protects). `options. acknowledgeReviewRequired: true` computes the summed aggregate anyway (\"trotz Review\") but the RESULT's own top-level `reviewRequired` stays `true` regardless — acknowledging computes the number, it never silences the review signal. no_blocked_critical_mappings — a matched pair referencing a variant a container warning marks with one of `BLOCKED_CRITICAL_MAPPING_CODES` (profile binding unresolved/ambiguous, canonical- key collision, a material-matrix row- identity collision, a fuzzy material match below the aggregation-gate) is excluded — Master-Prompt §19 \"no blocked critical mappings\". Code membership in `BLOCKED_CRITICAL_MAPPING_CODES` is sufficient BY ITSELF (KAR-944 adversarial review F2 fix — no producer in this package has ever set `severity: 'critical'`, and the real `variant_profile_binding_unresolved`/ `material_matrix_row_identity_collision` producers never set `reviewRelevant` either, so the old severity/reviewRelevant pre-filter silently dropped both codes on every real path). `reviewRelevant === true` is a SEPARATE, generic criterion, independent of `code` — so a future producer's ambiguous-mapping flag blocks automatically without this module's own list needing a matching update first. ── Which side's volume/currency? (NEU, documented, never silently ALT) ──── `VariantDefinition.annualVolume`/`lifetimeVolume`/`currency` are read from the NEU side throughout — the forward-looking, current-state figures a commercial-impact reader cares about. A genuine ALT<->NEU volume or currency CHANGE is already its own separate, dedicated finding elsewhere (container-differ.ts's `activeStateChanges`/dimension diffs, the `consistent_units` gate's currency check) — this module never re-derives that comparison, it only picks ONE side as the scaling/bucketing basis and says so explicitly (this comment + the `assumptions[]` entry `'volume_and_currency_basis'`). ── Sign convention (IDENTICAL to every other differ in this package — never re-derived, never inverted) ─────────────────────────────────────────────── Every number this module sums is already `NEU − ALT` (differ.ts's `computeNumericDelta`, material-differ.ts's `factor * deltaAbsolute`, profile-differ.ts's `deltaAbsolute`) — this module performs NO sign manipulation of its own. Positive = Verteuerung (a cost increase from ALT to NEU); negative = Ersparnis (a cost decrease). A caller scaling by volume (`delta * annualVolume`/`delta * lifetimeVolume`) preserves this sign unchanged. ── \"material-differ-Impacts\" scope (KAR-944 task spec, deliberately narrower than material-differ.ts's full output) ─────────────────────────── Only the THREE 11.1 shared-component finding groups that ever carry a non-empty `impact.impacts` array are summed here: `unitCostValueChanges`, `logisticsOrDutyChanges`, `materialOverheadChanges` (material-differ.ts's own doc: `unitCostCurrencyChanges`/`exchangeRateChanges`/`formulaChanges` deliberately carry an affected-variant list ONLY, no numeric impact — see that module's own field docs for why). 11.2 `variantAllocation.findings` (quantity-factor/inclusion changes) are NOT folded in — the task spec names \"material-differ-Impacts\" (the `SharedComponentImpactSummary`-shaped 11.1 numbers), not the 11.2 allocation deltas, and conflating the two would contradict material-differ.ts's own \"two levels, two separate finding groups (never conflated)\" doctrine. A finding whose OWN `reviewRelevant` is `true` (its underlying row is not `validationStatus: 'ok'`) is excluded from the sum — its numbers are shown on the finding itself already, never rolled into a trusted total (material-differ.ts's own `withReviewRelevantImpact` doctrine, mirrored here one level up). ── \"Fertigungs-Deltas aus profile-differ Item-2/4\" scope ────────────────── Item 2 (`totalChanges`, a shared profile's own total moved) and item 4 (`bindingValueImpacts`, a variant's binding moved or its bound profile's total moved together with a rebind) — never item 1 (`componentValueChanges`, a component-level process-parameter note with no volume-scaled money figure of its own) and never item 3 (volume-band thresholds, #309's job, not a per-variant money delta). `SharedCostProfile.values` carries NO currency field (profile-differ.ts's own module header) — item 2's per-variant contribution therefore uses the variant's OWN `VariantDefinition.currency` (ALT vs. NEU, gated: differing/unknown -> excluded, folded into `compatible_currencies`) as its bucket key, exactly mirroring item 4's own `ProfileBindingCurrencyState` gate (which already reads `VariantDefinition.currency` for the identical reason). tdd-guard: covered by __tests__/aggregate-impact.test.ts (synthetic, one scenario per gate pass/fail + per-currency bucketing + volume-missing exclusion + ambiguous-exclusion + sign-direction + structural fallback) and __tests__/aggregate-impact.real-files.test.ts (env-gated: Datei 3 vs. Datei 4 empty-aggregate-with-reason, self-compare full-population-zero).",
      "mentions_todo_or_future": true,
      "pure_or_no_io": false,
      "test_reference": true,
      "public_signatures": "export const AGGREGATE_IMPACT_RESULT_VERSION = 1 as const | export type AggregateImpactGateId = | export interface AggregateImpactGateOutcome { | export type AggregateImpactExclusionReason = | export interface AggregateImpactExclusion { | export type VariantUnitPriceDeltaSource = 'material_shared_component' | 'profile_total_change' | 'profile_binding_value_impact' | export interface VariantUnitPriceDeltaComponent { | export interface CurrencyBucket { | export interface VariantUnitPriceDelta { | export interface VariantTimeframeImpact { | export interface AggregateImpactCurrencyTotal { | export interface AggregateImpactTimeframe { | export interface AggregateImpactStructuralFallback { | export interface AggregateImpactAssumption { | export interface AggregateImpactResult { | export interface AggregateImpactInputs { | export interface AggregateImpactOptions { | export function computeAggregateImpact(",
      "source_heading_line": 3241,
      "source_end_line": 3462
    },
    {
      "path": "lib/qaf-differences/internal/multi-qaf/bridge.ts",
      "category": "core",
      "declared_lines": 270,
      "comment_summary": "VirtualQafVariant <-> canonical comparison-engine bridge (KAR-929 / Multi-QAF-Programm P1.1). Master-Prompt §6: \"The virtual variant must be compatible with the existing canonical standard QAF comparison engine wherever technically possible.\" — and the task instruction this module answers directly: \"wo 1:1-Kompatibilität heute nicht sinnvoll ist, dokumentiere das Mapping explizit als Funktion toCanonicalInputs(virtualVariant) mit TODO-freiem, ehrlichem Scope.\" ../compare.ts's QafFileParsed is the canonical engine's actual input shape (ref/summary/steps mandatory, everything else — summaryMetrics, materialRows, sbmRows, ... — optional and independently tri-stateable, per that interface's own doc comments). This module maps a VirtualQafVariant onto a Partial<QafFileParsed> and is explicit, field by field, about which of the two mapped shapes (QafSummary, SummaryMetricsParse) get a REAL value and which stay honestly absent: MAPPED (this PR): - QafSummary.partNumber <- definition.compositeCanonicalKey (the deterministic, reorder/rename-stable identity from identity.ts — the correct \"same part number\" comparison key for a virtual variant, since a Multi-QAF workbook has no single customer part number of its own the way a standard QAF file does). - QafSummary.variant <- definition.originalVariantNumber - QafSummary.project <- definition.dimensions.project (when present — many real files DO carry a \"project\"-shaped dimension, e.g. 10-analyse-ncar.md's Achscode/Baureihe columns; absent when the container never populated that dimension key). - QafSummary.partName <- definition.originalLabels[0] (best human-readable label available, NOT a real customer part name). - QafSummary.peakVolumeYear <- definition.peakVolume, stringified (SummaryField is string-typed like every other QafSummary field — see types.ts doc: \"this module never types SUMMARY identity/premise values, only the separate SummaryMetricKey money-metric block does\"). - SummaryMetricsParse.metrics.{materialCosts, manufacturingCosts, totalProductionCosts, devicesAndTools, scrapMaterial, otherSurcharges, quotationBasePrice, quotationPrice} <- the matching VirtualVariantSummaryTotals field (direct 1:1 business- meaning correspondence). - SummaryMetricsParse.currency <- definition.currency. DELIBERATELY LEFT ABSENT (not fabricated, not TODO — genuinely no Multi-QAF-side source exists yet, or the canonical field's business meaning does not reduce to anything this PR's domain model carries): - QafSummary.quotationDate/supplier/requestVersion/changeIndex/ supplierNo/productionStartSop/deliverySite/shiftsPerWeek: no per-variant source — these are workbook-level facts (MultiQafContainer.sharedMetadata carries some of them at the CONTAINER level, e.g. supplier; a P2.1 VirtualQafVariant-generation pass, once it actually reads a real workbook, is the place that decides whether/how to thread container-level metadata down into each variant's canonical inputs — this module only defines the function shape, it has no real container to read from yet since P1.2 does not exist). - SummaryMetricsParse.metrics.{packagingTransportIncluded, customsIncluded, scrapManufacturing, totalCosts, rawMaterialPriceShareMaterial, rawMaterialPriceShareEnergy, customsSupplierToBMW, transportSupplierToBMW, oneTimeDevelopment, oneTimeTools, totalOneTimePayment, costBreakdownAw1}: no VirtualVariantSummaryTotals field carries these — Master-Prompt §13 lists a narrower \"for every virtual variant, independently verify\" set than the full 20-key canonical registry, and this module does not invent values for the gap. `scrapManufacturing` in particular is NOT filled from VirtualVariantSummaryTotals.scrap (that single field maps only onto `scrapMaterial` — see that field's own doc comment in types.ts): splitting scrap by material/manufacturing origin needs the manufacturing-profile parser (P1.4) this PR does not build, so fabricating scrapManufacturing = 0 or = scrap would silently claim false precision. - VirtualVariantSummaryTotals.offerBasePriceInclAllocation (KAR-951 F1 fix, added after this module's original mapping list above): NOT mapped onto `SummaryMetricsParse.metrics.quotationBasePrice` (the canonical field `offerBasePrice` already maps onto) or onto `quotationPrice` — the two are DIFFERENT Summary-sheet rows with different business meaning (final base price including the vorrichtungs-Umlage surcharge vs. the plain base/offer price; see that field's own doc comment in types.ts) and the canonical 20-key registry (../summary-metrics.ts) has no key for \"base price including allocation\" to map it onto — inventing one, or silently overwriting `quotationBasePrice` with it, would misrepresent which of the two real numbers the canonical engine actually received. Honestly absent from `toCanonicalInputs()`'s output, same as every other field in this \"no canonical slot exists\" list. - QafFileParsed.ref/steps: QafFileRef needs an id/fileName/ quotationDate this module has no authority to invent (a P1.2+ ingest caller assigns those once a real workbook exists); `steps` (QAFRow[] Fertigungskosten process rows) has NO per-variant equivalent in the Multi-QAF domain model at all — manufacturing cost is a SHARED profile (SharedCostProfile) selected via a VariantProfileBinding, not an independent per-variant process list (Master-Prompt §12: \"Multi-QAF manufacturing logic may use shared profiles instead of one independent process list per variant\"). Turning a SharedCostProfile into synthetic QAFRow entries needs the manufacturing-profile parser (P1.4) plus a translation step neither of which exist yet — returning `steps: []` here would be indistinguishable from \"this variant genuinely has zero manufacturing steps\", a false claim; omitting the key entirely (Partial<QafFileParsed>) is the honest representation. - QafFileParsed.materialRows/sbmRows/rmrRows/logisticsRows/ lccnValues/co2eMaterialRows: same reasoning as `steps` — these require translating VariantMatrixRow/SharedCostProfile shapes into the specific row types each detail-module parser (material-parser.ts etc.) produces, which is P2.1's job once P1.2-P1.6 exist to feed it real data.",
      "mentions_todo_or_future": true,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export const SYNTHETIC_IDENTITY_FIELDS: readonly QafSummaryKey[] = ['partNumber', 'partName'] | export function virtualVariantSummary(v: VirtualQafVariant): QafSummary { | export function virtualVariantSummaryMetrics(v: VirtualQafVariant): SummaryMetricsParse { | export function toCanonicalInputs(v: VirtualQafVariant): Partial<QafFileParsed> {",
      "source_heading_line": 3463,
      "source_end_line": 3574
    },
    {
      "path": "lib/qaf-differences/internal/multi-qaf/column-classifier.ts",
      "category": "core",
      "declared_lines": 376,
      "comment_summary": "Multi-QAF column classifier (KAR-930 / Multi-QAF-Programm P1.2, Epic KAR-925). Master-Prompt §10 \"each candidate column must be classified as one of\" list — this module implements the classification DECISION only (pure, no ExcelJS). header-parser.ts builds the per-column facts (identity/ volume/label/formula evidence) from a real header-row/formula grid and calls classifyColumn once per candidate column, in the two-pass order documented on classifyColumn below. Real-file evidence for the delta_column vs. comparison_scenario split (10-analyse-mx.md D, the module's own \"most important regression case\"): two of that file's non-standard columns are STRUCTURALLY IDENTICAL two-cell-difference formulas — they differ only in WHAT they reference. One references a benchmark pseudo-slot (a literal, no-own-data-basis column) and a real variant — a HELPER relating a benchmark to a variant, classified delta_column. The other references two REAL, already- identified product-variant columns — a standalone comparison between two genuine variants, classified comparison_scenario. This module resolves that distinction from the ALREADY-CLASSIFIED kind of each referenced column (see classifyColumn's `context.resolvedKinds`), never from the formula shape alone (both shapes are byte-identical `=X-Y`). unknown is a valid, deliberate result (types.ts ColumnClassificationKind doc + Master-Prompt §7 \"never force an interpretation\") — this module never invents a kind it cannot support with at least one evidence entry.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": false,
      "public_signatures": "export function parseDifferenceFormula(formula: string): { colA: string; colB: string } | null { | export function parsePercentFormula(formula: string): { colA: string; colB: string } | null { | export function parseSumRangeFormula(formula: string): { colFrom: string; colTo: string } | null { | export function isCommentLabel(label: string | null): boolean { | export interface ColumnClassificationFacts { | export interface ColumnClassificationContext { | export function classifyColumn(facts: ColumnClassificationFacts, context: ColumnClassificationContext): ColumnClassification {",
      "source_heading_line": 3575,
      "source_end_line": 3610
    },
    {
      "path": "lib/qaf-differences/internal/multi-qaf/compare-flow.ts",
      "category": "core",
      "declared_lines": 428,
      "comment_summary": "Multi-QAF Compare-Flow MQ<->MQ end-to-end (KAR-942 / Multi-QAF-Programm P3.1, Epic KAR-925) — DAS Wiring-Item. Problem this closes (30-backlog-phasenplan.md P3.1): P2.1-P2.6 shipped six independent, unit-tested MODULES (container-assembly.ts assembles ONE MultiQafContainer; variant-matcher.ts matches two containers' variants; container-differ.ts/material-differ.ts/profile-differ.ts each diff a DIFFERENT facet of an already-matched pair; variant-reconciliation.ts verifies ONE container's own internal consistency) — but nothing ever CALLED them together, and nothing ever PERSISTED a result. Two `qaf_file` rows with `template_type: 'MULTI_QAF'` sat in the batch/project with no `qaf_comparison` row pointing at them at all (actions.ts's `analyzeQafBatchFromStorage`, `kind === 'multi_qaf'` branch, KAR-935 P2.1: \"no comparison run (Compare is P2.3+ scope)\"). This module is that orchestration: given two already-assembled `MultiQafContainer`s (rehydrated from `qaf_file.g60_meta.multiQafContainer` via serialization.ts — this module never re-parses a workbook, same \"compute once, read from storage thereafter\" discipline templateFingerprint/manufacturingParseMeta already establish for the standard/G60 paths), runs the full P2 pipeline and returns ONE composed, versioned, JSON-serializable `MultiQafComparisonResult` ready to persist. ── Pipeline (mirrors this module's own `runMultiQafCompareFlow` body) ───── 1. allMultiQafContainerVariants(alt)/(neu) — active + inactive, container- differ.ts's own \"both buckets must be matchable\" default. 2. Persisted `VariantMatchOverride[]` (if any — KAR-912 field-mapping- override.ts precedent, applied to variant PAIRS instead of single file fields; see this module's own doc below for exactly where a caller persists these) are applied via `matchVariantsWithOverrides`; otherwise a plain `matchVariants` cascade runs. 3. `diffContainers` (structural, KAR-937/P2.3), `diffMaterial` (value- level material, KAR-938/P2.4), `diffProfiles` (value-level manufacturing/setup-cost profiles, KAR-939/P2.5) all run off the SAME `matchResult` — the shared-matchResult contract every one of those three modules' own doc comments already requires (an id that does not resolve against the container's own `activeVariants`/ `inactiveVariants` throws, fail-closed). 4. `reconcileContainer` runs once per SIDE (alt, neu independently — it verifies ONE container's own internal consistency, Master-Prompt §13; it is not an alt-vs-neu diff and never becomes one here). 5. `reviewRequired`/`reviewRequiredReasons` are lifted verbatim from `diffContainers`' own computed floor (that function's own doc: \"never overridable downward\") plus ONE additive reason this module contributes (`match_overrides_dropped_on_drift`) — never re-derived from scratch, so this module can never disagree with container- differ.ts about what its OWN signal means. 6. `aggregate-impact.ts`'s `computeAggregateImpact` (KAR-944/P3.3, the LAST P3 item) runs LAST, over the exact `matchResult`/`containerDiff`/ `materialDiff`/`profileDiff`/`reconciliation`/`reviewRequired`/ `reviewRequiredReasons` this function already computed above — never a second, independent recomputation of any of those. ── Result-shape version bump (KAR-944, extended KAR-951) ─────────────── `MULTI_QAF_COMPARISON_RESULT_VERSION` moved 1 -> 2 for the `aggregateImpact` field (KAR-944) and now 2 -> 3 for the new `summaryTotalsDiff` field (KAR-951 — see module header \"Step 7\" below). `types.ts`'s own `MULTI_QAF_MODEL_VERSION` doc states field additions never need a version bump (every reader already tolerates missing optional/array/record fields) — this module bumps anyway because `deserializeMultiQafComparisonResult` does a STRICT `modelVersion !==` throw (unlike a container's own deep, per-field `normalizeDeserializedContainer`), so a bump is the only way to make the old/new shape distinction visible to a caller at all. All three versions are ACCEPTED by `deserializeMultiQafComparisonResult`, which normalizes a pre-existing envelope's absent `aggregateImpact`/ `summaryTotalsDiff` to `null` (never `undefined` — every reader can rely on the field always being present) rather than throwing on an old, already-persisted record. ── Step 7 — summaryTotalsDiff (KAR-951, urgent livetest fix) ────────────── `diffSummaryTotals` (summary-totals-differ.ts) runs alongside materialDiff/profileDiff (same `matchResult` input, same \"matched pairs only, everything else passed through on `uncertainMatches`\" contract) — it is the missing ALT<->NEU diff of `VirtualVariantSummaryTotals` the KAR-951 livetest exposed (module header there for the full root-cause writeup: two global Summary-sheet surcharge parameters changed, every variant's computed Summary total moved, and NO existing differ noticed because none of them read the Summary sheet's own computed totals at all). It is a PEER of materialDiff/profileDiff, never an input to `computeAggregateImpact` (summary-totals-differ.ts's own module header \"§19 boundary\" — no double-counting between the Summary-sheet-based sums this step produces and the material-/profile-component-based aggregate impact `computeAggregateImpact` already produces). Its own `changedMetricSumsByCurrency` is therefore reported to a caller SEPARATELY, explicitly labeled \"summary-basiert\" (UI convention, see qaf-multi-qaf-detail.tsx), never merged into `aggregateImpact`. `'summary_totals_changed_without_material_or_profile_evidence'` — this module's own ADDITIVE reviewRequired reason (alongside `match_overrides_dropped_on_drift`) — fires when a matched variant pair has at least one `state: 'changed'` summary-totals finding while BOTH materialDiff and profileDiff carry ZERO findings that reference that same variant pair (see `hasNoMaterialOrProfileEvidenceFor` below): a changed Summary number with no explaining material/profile-level detail is exactly the \"change without an explaining detail beleg\" case a human needs to look at (task instruction). ── Override persistence (KAR-912 `fieldMappingOverrides`-in-`g60_meta` pattern, applied here) ──────────────────────────────────────────────────── `fieldMappingOverrides` (field-mapping-override.ts) persists on `qaf_file.g60_meta` because a field correction is a property of ONE file. A `VariantMatchOverride` (variant-matcher.ts) is a property of a PAIR (which alt variant maps to which neu variant) — it has no single owning file, so it belongs on the COMPARISON, not either file. This PR persists it inside `qaf_comparison.engine_version.variantMatchOverrides` (the same \"misc JSONB bag on the one column that already exists, no migration\" discipline `engine_version.baselineStatus`/`engine_version. engineConfigOverrides` already established for that column) — the actions.ts wiring for THIS PR reads/re-applies it on every `multi_qaf` recompute (`matchVariantsWithOverrides`, dropped-on-drift entries written back exactly like `carryForwardFieldMappingOverrides` does), but setting one is P4 UI scope (no `setVariantMatchOverride`-shaped action ships in this PR — see KAR-942 task scope: \"keine neue UI-Sektion (P4)\"). The plumbing is therefore exercised end-to-end by this module's own tests via `RunMultiQafCompareFlowOptions.variantMatchOverrides`, ready for a P4 setter action to write into that same JSONB key. ── Persistence shape (actions.ts wiring) ─────────────────────────────────── `qaf_comparison` carries no dedicated multi-QAF result column (no operator-applied migration in this PR — see this module's own PR description) — the composed `MultiQafComparisonResult` is persisted the SAME way `container`/`virtualVariants` already are on `qaf_file.g60_meta` (KAR-935 P2.1): a versioned envelope object assigned directly into `qaf_comparison.engine_version.multiQafComparisonResult`, spread alongside the existing `ENGINE_VERSION`/`baselineStatus` keys `resolvePersistedEngineConfig` already tolerates unknown keys on (rehydrate.ts only reads the keys it knows; an extra key is inert to every existing reader). `reviewRequired` additionally sets `qaf_comparison.baseline_status` to `'baseline_review'` (existing CHECK-constrained enum value, already used by the G60 pairing path for \"please confirm before trusting this comparison\" — reused here for the SAME meaning rather than inventing a new column/value) when `MultiQafComparisonResult.reviewRequired` is true, `'ok'` otherwise — this is the \"reviewRequired aggregiert bis in die Comparison-Row\" requirement, expressed via the one enum this table already has for exactly that semantic, never a new column. tdd-guard:skip — this file's own body is a thin, deterministic composition of six already-unit-tested pure functions (allMultiQafContainerVariants, matchVariants/matchVariantsWithOverrides, diffContainers, diffMaterial, diffProfiles, reconcileContainer) plus a versioned JSON envelope — covered by compare-flow.test.ts (synthetic containers) and compare-flow.real- files.test.ts (env-gated real-file regression), same category as container-assembly.ts's own orchestration body.",
      "mentions_todo_or_future": true,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export const MULTI_QAF_COMPARISON_RESULT_VERSION = 3 as const | export interface MultiQafComparisonResult { | export interface RunMultiQafCompareFlowOptions { | export function runMultiQafCompareFlow( | export function multiQafBaselineStatusFor(result: MultiQafComparisonResult): 'ok' | 'baseline_review' { | export function serializeMultiQafComparisonResult(result: MultiQafComparisonResult): string { | export function deserializeMultiQafComparisonResult(json: string): MultiQafComparisonResult {",
      "source_heading_line": 3611,
      "source_end_line": 3757
    },
    {
      "path": "lib/qaf-differences/internal/multi-qaf/container-assembly.ts",
      "category": "core",
      "declared_lines": 1189,
      "comment_summary": "Multi-QAF container assembly + VirtualQafVariant generation (KAR-935 / Multi-QAF-Programm P2.1, Epic KAR-925). Problem this closes (30-backlog-phasenplan.md P2.1 + Master-Prompt §6): the 6 P1 modules (identity.ts/serialization.ts/bridge.ts KAR-929, header-parser.ts/column-classifier.ts KAR-930, material-matrix-parser.ts KAR-931, profile-parser.ts KAR-932, formula-lineage.ts KAR-933, template-fingerprint.ts KAR-934) each parse ONE facet of a Multi-QAF workbook in isolation — none of them produce a single, complete `MultiQafContainer`. This module is the orchestrator that calls them in the right order and assembles ONE `MultiQafContainer` (assembleMultiQafContainer), plus the first `VirtualQafVariant[]` generation pass over that finished container (generateVirtualVariants) — the two Master-Prompt §6 deliverables this PR closes. It is still NOT the ingest wiring itself (P2.1's own task brief: \"erstes Ingest-Wiring hinter Flag\") — the actions.ts call site is a thin try/catch around `resolveMultiQafContainerIngest` below, fail-closed to today's rejection when assembly throws. ── Orchestration order (Master-Prompt §5 architecture diagram) ──────────── 1. multiQafTemplateFingerprintInputFromExcelJs (template-fingerprint.ts, KAR-934) — reused VERBATIM for its own SUMMARY-then-MATERIAL sheet-fallback cascade (module header there: \"Datei 3/NAFTA... stores its variant/dimension block [in MATERIAL] instead, at a different column offset than SUMMARY's own default\"). This is the container's SOLE source of variant IDENTITY (activeVariants/inactiveVariants, variantDimensions) — never re-derived independently here. 2. fingerprintMultiQafTemplate + toMultiQafContainerFingerprint (template-fingerprint.ts) — templateFingerprint field, WITH classification (KAR-934 adversarial review F2 lesson: `family` alone is not sufficient). 3. Material-matrix parsing (material-matrix-parser.ts, KAR-931) — run SEPARATELY on the MATERIAL/BOM sheet's own re-parsed variant header block (material-matrix-parser.ts's own precondition: \"resolved per-sheet, not copied verbatim from the Summary sheet's own column letters\"), via the SAME widened-column-scan retry step 1 uses internally (template-fingerprint.ts's locateVariantHeaderBlockOnSheet, exported for this reuse — KAR-935 additive change, zero behavior change for its own pre-existing caller). 4. Shared cost profiles + variant->profile bindings (profile-parser.ts, KAR-932) — profile BLOCK detection is workbook-level and independent of variant identity; binding RESOLUTION needs Summary-sheet-local variant column positions, which are only available when step 1's identity source WAS the Summary sheet (see \"Known scope limits\" below for the Material-fallback case). 5. Formula lineage (formula-lineage.ts, KAR-933) — feeds summaryAggregation.formulaLineageNotes + merges its own warnings. 6. generateVirtualVariants(container) — a PURE function over the now-finished container's own fields (never re-reads the workbook), per Master-Prompt §6's own VirtualQafVariant field list. Called once internally to populate summaryAggregation.perVariant, and exported for a caller (P2.3+ compare wiring) that wants the full array. ── Known scope limits (honest, not invented precision — Master-Prompt §7) ────────────────────────────────────────────────────────────────────────── - Cross-sheet variant IDENTITY MATCHING (step 3, matchMaterialVariantsToContainer) is a TWO-PASS match: an EXACT match on the RAW (pre-disambiguation) compositeCanonicalKey first (with originalVariantNumber/originalColumn ONLY as a within-collision-group tiebreaker — KAR-935 adversarial-review F2 fix), THEN — KAR-936/P2.2 — variant-matcher.ts's core-dimension-subset/fuzzy-label-similarity cascade (matchVariants) as a FALLBACK over whatever the exact pass left unmatched on both sides. KAR-935's own real-file regression observed the exact-match's real-world limit directly: for 3 of the 4 real Multi-QAF files, the Material sheet's own re-parsed header block picks up DIFFERENT \"extra/unknown dimension\" labels than the Summary sheet's own re-parse (neighboring header rows differ per-sheet), so the two sides' RAW keys never agree byte-for-byte even though the semantically stable dimensions (steeringSide/motor/scu...) and the workbook's own printed variant number DO agree. The KAR-936 fallback closes exactly this gap: variant-matcher.ts's stage 2 (core_subset_exact) matches on the dimension keys BOTH sides actually populated, ignoring the sheet-local \"extra\" ones — only a UNIQUE fallback match (matchVariants' own 'matched' result) translates a Material-side key; an 'ambiguous'/'split_suspected'/'merge_suspected' fallback result is left unmatched (with the same `material_variant_unmatched_to_summary` warning as before) exactly like an unresolved exact-match miss always was — never silently forced into an aggregation. Every unique fallback translation gets its own `material_variant_fuzzy_matched_to_summary` (info, reviewRelevant) warning so a human can still sanity-check it. The 4th file (MATERIAL-fallback pattern, 10-analyse-nafta.md) is the one case where the exact pass alone already matches cleanly, because step 1's own identity source AND step 3's own re-parse read the exact SAME physical sheet — the KAR-936 fallback pass is a no-op for it (nothing left unmatched to hand to it). - Variant->profile binding resolution (step 4) needs SUMMARY-sheet-local variant column positions. When step 1's identity source was the MATERIAL sheet (the Summary sheet itself materialized zero variants — 10-analyse-nafta.md's own documented shape), there is no reliable Summary-column mapping for the container's own variant identities; binding resolution is skipped for that file with an explicit `variant_profile_binding_summary_columns_unavailable` warning rather than guessed from column position. - sharedMetadata (types.ts: \"supplier, project, quotation date... with its source cell\") has no P1 parser producing it — stays `{}` (honest absence, not fabricated). language stays 'unknown' for the same reason (no Multi-QAF-specific language-signal parser exists yet; ../language-detection.ts's own detector is shaped around a standard- QAF summaryTemplate this container does not have). - VirtualVariantSummaryTotals.scrap/otherSurcharges/offerBasePrice/ offerBasePriceInclAllocation/offerPrice (KAR-951, the last one added by the KAR-951 F1 adversarial-review fix): now extracted via a label-anchored Summary-row scan (profile-parser.ts locateSummaryMoneyRow/extractSummaryMoneyRow, step 4b below) under the SAME \"Summary-local variant columns\" precondition as variantProfileBindings above — stay `null` only when that precondition doesn't hold (identity sourced from the MATERIAL sheet) or the row/cell genuinely has no value, never fabricated. `offerBasePriceInclAllocation` is its OWN row/metric, never merged onto `offerBasePrice`/`offerPrice` — see VirtualVariantSummaryTotals.offerBasePriceInclAllocation's own doc comment (types.ts) for why the KAR-951 livetest corpus needed a THIRD distinct anchor here, not a fix to one of the first two. tdd-guard: covered by __tests__/container-assembly.test.ts (synthetic ExcelJS workbooks covering all 4 structural patterns) and __tests__/container-assembly.real-files.test.ts (env-gated, the 4 real Multi-QAF files, aggregate-only assertions consistent with the P1 modules' own already-measured real-file counts).",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export interface MultiQafContainerAssemblyOptions { | export async function assembleMultiQafContainer( | export function generateVirtualVariants(container: MultiQafContainer): VirtualQafVariant[] { | export type MultiQafContainerReviewStatus = 'ok' | 'review_required' | export function reviewStatusForMultiQafContainer(container: MultiQafContainer): MultiQafContainerReviewStatus { | export type MultiQafContainerIngestOutcome = | export async function resolveMultiQafContainerIngest(",
      "source_heading_line": 3758,
      "source_end_line": 3885
    },
    {
      "path": "lib/qaf-differences/internal/multi-qaf/container-differ.ts",
      "category": "core",
      "declared_lines": 1148,
      "comment_summary": "Multi-QAF Container-Level-Vergleich (KAR-937 / Multi-QAF-Programm P2.3, Epic KAR-925). Problem this closes (30-backlog-phasenplan.md P2.3 + Master-Prompt §14): \"In addition to comparing virtual variants, compare the Multi-QAF containers themselves... This comparison must not be reduced to a list of cell differences.\" Every module up to this one (container-assembly.ts KAR-935, variant-matcher.ts KAR-936) either PARSES one container or MATCHES two containers' variant identities against each other — none of them compares the two finished MultiQafContainer STRUCTURES. This module is that comparison: diffContainers(alt, neu, matchResult, options) takes an ALT and a NEU MultiQafContainer plus an already-computed matchResult (variant-matcher.ts's matchVariants/matchVariantsWithOverrides output) and produces a MultiQafContainerDiff — separate, evidence-carrying finding groups for variants/dimensions/shared-material/profiles/template, never a flat cell-diff list. ── Scope discipline (Master-Prompt §7 \"never force an interpretation\") ──── - VALUE comparisons are explicitly OUT of scope here: * shared-material unit-cost / quantity-factor VALUES -> P2.4 * manufacturing-/setup-cost profile VALUE totals -> P2.5 * formula-vs-cached-value reconciliation per virtual variant -> P2.6 This module only ever asks \"does X exist / did X's IDENTITY or STRUCTURAL SHAPE change\" — never \"did X's number change\". Every finding type below is named to make that boundary visible at the call site (e.g. SharedMaterialRowChangedFinding has no cost field at all). - A field the container does not actually carry (external-link inventory — MultiQafSourceWorkbookMeta has no such field) is reported as `{ tracked: false, note: '...' }`, never silently treated as \"unchanged\" — a caller must be able to tell \"we checked, no change\" apart from \"we cannot check this from a MultiQafContainer alone\". This module does NOT read the underlying workbook — it only ever compares the two already-assembled MultiQafContainer objects it is given. ── Fail-closed matching contract (Master-Prompt §9, inherited from variant-matcher.ts, not re-implemented here) ────────────────────────────── `matchVariants`/`matchVariantsWithOverrides` already guarantee \"every left/right index is covered EXACTLY ONCE across the returned results\" — this module trusts that invariant rather than re-deriving it: iterating `matchResult` once and branching on `.kind` is by construction incapable of reporting the same variant as BOTH e.g. 'added' and part of an 'ambiguous' finding, because matchVariants never emits two result entries for the same left/right index. `matchResult` is expected to have been computed with LEFT drawn from `alt`'s own variants and RIGHT drawn from `neu`'s own variants (typically the full active+inactive sets — see `allMultiQafContainerVariants` below) — every `leftId`/`rightId` string the matcher emits is resolved by this module via a Map keyed on `stableInternalId` against `alt`/`neu`'s OWN full variant sets (not against whatever subset array the caller happened to pass into matchVariants), so a caller that pre-filtered its matcher input still gets correct identity refs here as long as the ids resolve — an id that does NOT resolve throws (fail-closed: a mismatched matchResult/container pairing is a caller bug, never silently ignored). ── Determinism ───────────────────────────────────────────────────────── Every finding array below is sorted on a stable key (variant id / profile id / canonical component identity / sheet name, all plain string sorts) — same inputs always produce a byte-identical (JSON.stringify-equal) MultiQafContainerDiff. No wall-clock/random data is ever included. ── What each Master-Prompt §14 bullet maps onto below ────────────────── added/removed/renamed/reordered variants -> variants.added/removed/ renamed/reordered uncertain matches -> variants.uncertainMatches new/removed variant dimensions -> dimensions.added/removed changed shared material rows -> sharedMaterial.* changed shared profiles -> profiles.added/removed + profiles.volumeBandThresholdChanges changed profile bindings -> profiles.bindingChanges changed matrix structure -> sharedMaterial.structure changed template version -> template.family added/removed helper sheets, hidden sheets -> template.sheetSet changed external links -> template.externalLinks (not_tracked — see above) active vs. inactive column changes -> variants.activeStateChanges orphaned variants / variants in detail sheets absent from summary / variants in summary missing detail support -> variants.detailLinkage* (derived from the container's OWN structural signal for \"which variant ids does the shared material matrix actually reference\" — VariantMatrixRow.quantityFactorByVariant/effectiveCostByVariant keys, themselves populated via material-matrix-parser.ts's formula-lineage- driven SUMPRODUCT/SUMIF-band tracing, KAR-931/KAR-933 — the closest thing a finished MultiQafContainer exposes to \"formula lineage data\" without re-reading the workbook; see `detailLinkageStatus` below for the exact tri-state derivation and its honest 'unknown' branch when the matrix was never populated at all, plus `material_matrix_variant_never_referenced` warnings (container- assembly.ts) folded in as supporting evidence text). tdd-guard: covered by __tests__/container-differ.test.ts (synthetic, one scenario per finding group + determinism) and __tests__/container-differ.real-files.test.ts (env-gated: self-diff of each of the 4 real files against an identical copy of itself -> zero findings in every group, plus one real cross-file diff).",
      "mentions_todo_or_future": true,
      "pure_or_no_io": false,
      "test_reference": true,
      "public_signatures": "export interface VariantIdentityRef { | export function allMultiQafContainerVariants(container: MultiQafContainer): readonly VariantDefinition[] { | export interface VariantDimensionValueChange { | export interface VariantAddedFinding { | export interface VariantRemovedFinding { | export interface VariantRenamedFinding { | export interface VariantReorderedFinding { | export interface VariantActiveStateChangeFinding { | export type DetailLinkageStatus = 'linked' | 'orphaned' | 'not_applicable' | 'unknown' | export interface DetailLinkageEvidence { | export function detailLinkageStatus(container: MultiQafContainer, variantId: string, activeState: VariantActiveState): DetailLinkageStatus { | export interface VariantDetailLinkageTransition { | export interface DetailWithoutSummarySetDiff { | export interface SummaryWithoutDetailFinding { | export interface MultiQafVariantContainerDiff { | export interface DimensionKeyRenamedFinding { | export interface VariantDimensionKeyDiff { | export interface SharedMaterialRowIdentityRef { | export interface SharedMaterialRowChangedFinding { | export interface SharedMaterialStructureDiff { | export interface MultiQafSharedMaterialContainerDiff { | export interface ProfileIdentityRef { | export interface ProfileValueMetadataChange { | export interface VolumeBandThresholdChangeFinding { | export interface ProfileBindingChangeFinding { | export interface MultiQafProfileContainerDiff { | export interface TemplateFamilyDiff { | export interface SheetSetDiff { | export interface NotTrackedFact { | export interface MultiQafTemplateContainerDiff {",
      "source_heading_line": 3886,
      "source_end_line": 4016
    },
    {
      "path": "lib/qaf-differences/internal/multi-qaf/export.ts",
      "category": "core",
      "declared_lines": 1218,
      "comment_summary": "Multi-QAF / Variante↔Standard XLSX export (KAR-949). Two ExcelJS workbook builders, sibling to ../export.ts's buildQafExportWorkbook (Standard-Vergleich) and ./g60/export.ts's buildG60ExportWorkbook (G60-Detailvergleich) — NEITHER of those two files is touched by this module, so their byte-for-byte output is unaffected (task requirement: \"Standard- und G60-Export byte-identisch\"). Both builders are PURE w.r.t. their inputs (a caller-supplied generatedAtLabel string plus already-persisted/rehydrated domain objects — no Date, no re-parse of a workbook, no DB access) and, like every PlausibilityIssue/MultiQafWarning/AggregateImpactGateOutcome consumer in this package, honest about uncertainty: every \"unknown\"/\"nicht ermittelbar\"/\"nicht prüfbar\" state gets its OWN visible status text — an empty cell that could be misread as \"0\" or \"no difference\" is a bug (task invariant \"Ehrlichkeits-Invarianten im Export\"). `result`/`container` arguments are nullable throughout and every sheet builder tolerates a `null` (an older/corrupt persisted record, or a comparison that was never actually run) by emitting one explanatory hint row instead of throwing — same \"render an honest 'nicht verfügbar' section instead of crashing\" contract app/qaf-differences/[id]/page.tsx's own try/catch-wrapped deserialize already establishes for this data. Every status/reason/gate/check code is exported in TWO columns side by side: the stable machine code (Code) AND a curated DE/EN plain-language sentence (Klartext) — task requirement \"Klartexte + stabile Codes beide exportieren\". The label maps below are a lib-local, presentation-only duplicate of components/qaf-differences/qaf-multi-qaf-code-labels.ts's vocabulary (never imported from here: this package is UI-independent — see lib/qaf-differences/index.ts's own module header \"usable from server actions, batch jobs, CLI and tests\" — lib/ must not depend on components/). An unrecognized code safely falls back to itself as both DE/EN (never a crash, never a blank cell) — same `labelFor` discipline code-labels.ts already establishes. tdd-guard:skip — sheet-builder composition of already-persisted/rehydrated data into ExcelJS rows; covered by export.test.ts (synthetic fixtures, incl. the honesty-invariant cases) and the env-gated real-files tests.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export interface MultiQafExportInput { | export async function buildMultiQafExportWorkbook(input: MultiQafExportInput): Promise<ArrayBuffer> { | export interface VariantVsStandardExportInput { | export async function buildVariantVsStandardExportWorkbook(input: VariantVsStandardExportInput): Promise<ArrayBuffer> {",
      "source_heading_line": 4017,
      "source_end_line": 4060
    },
    {
      "path": "lib/qaf-differences/internal/multi-qaf/formula-lineage.ts",
      "category": "core",
      "declared_lines": 1153,
      "comment_summary": "Formula-Lineage-Modul Summary↔Detail (KAR-933 / Multi-QAF-Programm P1.5, Epic KAR-925). Problem this closes (30-backlog-phasenplan.md P1.5 + \"Querschnitts- Wahrheiten\": \"Anbindung Summary↔Detail NUR per Formel-Lineage entscheidbar (nicht Spaltenposition)\"): a Multi-QAF summary sheet's variant columns and a detail sheet's (BOM/material/manufacturing) variant columns are NOT guaranteed to sit at the same column INDEX — the real Multi-QAF corpus analysis shows the summary's Q..AP band (26 columns) maps onto the BOM sheet's N..AM band (26 columns, offset by -3), and the BOM sheet additionally carries 2 further populated columns (AN/AO, a different vehicle programme) that are NEVER referenced by any summary formula at all (\"additional/orphaned variant\"). The only way to tell which detail column feeds which summary column — and which detail columns are genuinely orphaned vs. merely differently positioned — is to follow the actual formula reference chain, never column position. This module builds that chain (a workbook-internal formula reference graph, aggregated to column granularity) and exposes query helpers over it. Scope (Master-Prompt §16 + task instruction, deliberately narrow): - Reference TOPOLOGY only. No formula evaluation/recalculation — this module never computes a formula's numeric result, only which other cells/ranges it MENTIONS. Re-uses formula-engine.ts's finding that ExcelJS's `cell.formula` (not `cell.value.formula`) already resolves Shared-Formula-Slaves to their translated, position-correct text — see that module's header \"Shared-Formula-Slaves\" for the underlying ExcelJS mechanics this module inherits for free by reading the same getter. - Workbook-INTERNAL only. A formula's EXTERNAL references (`[n]Sheet!…`, the live \"23 tote External-Formeln\" pattern documented in the real Multi-QAF corpus analysis) are detected and reported (`external: true` on the parsed reference), but NEVER resolved or followed — Master- Prompt §16: \"never open or execute external workbooks automatically… never refresh external links.\" An external reference is always a dead-end for lineage-tracing purposes, regardless of what index `n` claims to point at. - Named ranges/defined names are NOT resolved (30-backlog- phasenplan.md: \"Defined Names sind zu 70–90% #REF!-Müll… Defined Names ignorieren, außer explizit whitelisted\") — this module only recognizes literal A1-style cell/range addresses, never bareword identifiers. - No header/identity parsing (that is P1.2/P1.3/P1.4's job — see those phase items in 30-backlog-phasenplan.md). This module's own notion of \"candidate detail column\" is deliberately narrow and self-contained: it is derived ONLY from SUMIF/SUMIFS/SUMPRODUCT-shaped formulas found while scanning a sheet (their range span IS, structurally, the sheet's own claim about which columns belong to its variant-column universe — the real Multi-QAF corpus analysis's `SUMIF(N19:AO19,…)` spans exactly the real 28-column BOM band, independent of and wider than the 26 columns any summary formula happens to reference). A sheet with no SUMIF/SUMPRODUCT-band formula at all yields zero candidates from this module — `isOrphanedDetailColumn` then always answers false for that sheet, which is the correct, honestly-scoped answer until a P1.2/P1.3/ P1.4 parser supplies a richer candidate set. Purpose (task instruction KAR-933): (a) distinguish linked vs. orphaned detail columns (real: 26 of 28 BOM columns linked, 2 orphaned — the real Multi-QAF corpus analysis (30-backlog-phasenplan.md)); (b) supply base signals for the P1.2 column classifier (delta formulas, SUMIF/SUMPRODUCT-band references) via `classifyFormulaShape`; (c) later profile-binding resolution (P1.4) reuses this module's reference parser/tracer rather than re-implementing it. Pure functions over an already-loaded ExcelJS workbook (or worksheet array) — no ingest wiring, no DB, no persistence shape here (unlike ../multi-qaf/types.ts's container fields, this module's own output is not JSON-persisted; MultiQafSummaryAggregation.formulaLineageNotes is a plain `string[]` a caller can populate FROM this module's output via `formulaLineageNotesFromGraph`, see bottom of this file). Performance guard (30-backlog-phasenplan.md \"ws.dimensions lügt IMMER\"): every sheet scan is bounded by the same window workbook-adapter.ts's shared `semanticActiveRange` helper uses (never ws.dimensions/rowCount/columnCount as ground truth — see SEMANTIC_ACTIVE_RANGE_MAX_ROWS/_COLS below, the same constants that helper is built on), and formula-cell indexing additionally caps the absolute number of formula cells read per sheet (MAX_FORMULA_CELLS_PER_SHEET) — hitting either cap stops that sheet's scan early and emits a warning, it never throws or hangs. `indexSheetFormulas` computes its own bounded-range edge in the SAME pass it indexes formula cells (KAR-933 adversarial review F6: calling the shared `semanticActiveRange` helper first and then re-walking the sheet a second time to index formulas was a redundant full-sheet scan — see that function's doc comment). Fail-closed guard/cap philosophy (KAR-933 adversarial review F1-F4): EVERY guard here (MAX_LINEAGE_DEPTH, MAX_FORMULA_CELLS_PER_SHEET, and the active-range scan window) can lose information — a chain that runs deeper than the depth cap, a sheet with more formula cells than the per-sheet cap, or content past the scan window's own edge. Losing information must NEVER silently turn into a wrong \"orphaned\" (or \"not even a candidate\") verdict for a detail column that is genuinely linked — that is actively worse than not knowing. Every place a guard trips is tracked (`scanIncompleteSheets`/`rangeTruncatedSheets`/`depthCappedSheets` on `ColumnLineageGraph`) and `isOrphanedDetailColumn` downgrades any NEGATIVE verdict (orphaned / not-a-candidate) on an affected sheet to an explicit `{status: 'unknown', reason: …}` instead — a positive `'linked'` verdict (a `ColumnLineageEdge` was actually found) is never downgraded, since finding an edge is a fact regardless of what else on the sheet a guard may have missed. See `isOrphanedDetailColumn`'s doc comment for the full tri-state contract.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": false,
      "public_signatures": "export interface ParsedCellReference { | export function parseFormulaReferences(formula: string): ParsedCellReference[] { | export type FormulaShapeKind = 'delta' | 'sumif_sumproduct_band' | 'direct_reference' | 'aggregate' | 'other' | export interface FormulaShapeClassification { | export function classifyFormulaShape(formula: string): FormulaShapeClassification { | export interface FormulaLineageWarning { | export interface ColumnLineageEdge { | export interface DetailSheetCandidateColumns { | export interface ColumnLineageGraph { | export type DetailColumnLineageStatus = 'orphaned' | 'linked' | 'unknown' | export type DetailColumnLineageUnknownReason = | export interface DetailColumnLineageResult { | export const MAX_FORMULA_CELLS_PER_SHEET = 8000 | export const MAX_LINEAGE_DEPTH = 40 | export interface BuildColumnLineageOptions { | export function buildColumnLineage(wb: { worksheets: Worksheet[] }, options?: BuildColumnLineageOptions): ColumnLineageGraph { | export interface DetailColumnHit { | export function detailColumnsFeedingSummaryColumn(graph: ColumnLineageGraph, summaryColumn: number): readonly DetailColumnHit[] { | export function summaryColumnsFedByDetailColumn(graph: ColumnLineageGraph, sheet: string, column: number): readonly number[] { | export function isOrphanedDetailColumn(graph: ColumnLineageGraph, sheet: string, column: number): DetailColumnLineageResult { | export function formulaLineageNotesFromGraph(graph: ColumnLineageGraph): string[] {",
      "source_heading_line": 4061,
      "source_end_line": 4180
    },
    {
      "path": "lib/qaf-differences/internal/multi-qaf/header-parser.ts",
      "category": "core",
      "declared_lines": 884,
      "comment_summary": "Multi-QAF variant header-block parser (KAR-930 / Multi-QAF-Programm P1.2, Epic KAR-925). Reads a Multi-QAF workbook's Summary-sheet (or, called again with a different grid, a BOM/detail sheet — this module is sheet-agnostic, see HeaderParserGridInput.sheet) variant header block: a Multi-Row-Header-Block of dimension rows, an optional numbered Slot-Index row, and separate volume rows — and produces VariantDefinition[] (types.ts, this PR's domain model) plus a full ColumnClassification[] scan report (Master- Prompt §10). Pure — no ExcelJS import at the top level; the ExcelJS bridge at the bottom mirrors workbook-adapter.ts's own \"pure core / thin adapter\" split (qaf-type-detector.ts is the closest sibling in this codebase). PRECONDITION (task instruction, NOT enforced by this module): a caller only invokes this parser once qaf-type-detector.ts's detectMultiQaf has already returned 'confirmed_multi_qaf' or 'probable_multi_qaf' for the same workbook. This module has no opinion on that — it is a pure function over whatever grid it is handed, wiring it to the detector's verdict is P2's job (module header of types.ts: \"no ingest wiring\" for this PR). ── Real-file evidence for the algorithm below ────────────────────────── Slot-index-row detection (10-analyse-mx.md C): Row 2 carries a fortlaufend (with GAPS in column position, never in VALUE) index 1..25 across the variant columns — except column AB, which also carries a numeric Row-2 value (1) but is NOT a continuation of the sequence (it appears AFTER column Z's value 10, spatially, while itself being smaller) — a manually inserted benchmark pseudo-slot, not a 26th real slot. locateSlotIndexRow below finds the row with the LONGEST strictly-increasing left-to-right run of positive-integer cell values (a simple greedy scan: accept a value only if it is greater than the last ACCEPTED value) — this single rule naturally reproduces both real findings at once: AB's out-of-order '1' fails the \"greater than last accepted (10)\" test and is correctly demoted to an outlier, while every genuinely ascending slot (including across the real column gaps at AA and AC–AE, which simply carry no value at all and are skipped) is accepted. Multi-row dimension block (10-analyse-ncar.md D, 10-analyse-clarwe-eu.md C.1): variant dimensions spread across up to 9 physical header rows, each carrying a row-label (Fahrzeug/Motor/Antrieb/Lenkung/...) — this module reads the row-label from the LAST non-empty cell strictly left of the scanned column band (covers both the \"column A/B\" convention and 10-analyse-mx.md C's \"column P immediately left of the Q.. band\" finding in one rule) and maps it to a KNOWN_VARIANT_DIMENSION_KEYS entry via a DE/EN synonym table, falling back to a slugified version of the raw label (or `row<N>` when no label at all) for anything unrecognized — never a closed vocabulary (types.ts KNOWN_VARIANT_DIMENSION_KEYS doc: \"NOT a closed union\"). 10-analyse-ncar.md D's file has NO slot-index row at all — its 8 variants are identified purely from the dimension-row block + a per-column \"BMW Variant ID\" row. locateSlotIndexRow correctly returns null for that // allow-customer-string shape (no row reaches MIN_SLOT_INDEX_RUN), and this module's variant assembly falls back to identity-only detection for every column, exactly the same code path 10-analyse-clarwe-eu.md C.3's two orphaned-but-real BOM columns (AN/AO, full identity + volume, NO index value of any kind) also take. Volume rows (10-analyse-nafta.md C \"Stückzahl lifetime/Ø Jahr/peak\", 10-analyse-ncar.md D rows 14-16): located the same way as dimension rows (row-label match), but via a DEDICATED DE/EN synonym table per volume field, kept separate from the generic dimension-key table so a volume row is never accidentally folded into VariantDefinition.dimensions. Column-classification LABEL (facts.label, column-classifier.ts): read from a DIFFERENT source than the identity dimension rows on purpose — the first non-empty, non-numeric text cell found for that column in the UNCLAIMED body rows (rows not already consumed by the index/dimension/ volume rows above, same bounded row window). This is a deliberate design choice, not a real-file position (10-analyse-mx.md D never states which exact row the analysts read the benchmark column's descriptive label from) — keeping the classification label signal structurally separate from the identity-dimension signal is what prevents a benchmark/comment annotation from ever being misinterpreted as variant identity (a benchmark column with a text annotation must never grant it identity through this path). The identity verdict itself (hasIdentityByColumn, computed once in parseVariantHeaderBlock) is the SINGLE source of truth shared by both assembleVariant (VariantDefinition.activeState/confidence) and classifyColumn (facts.hasIdentity) — KAR-930 review F2 fix: these two used to be computed independently (assembleVariant re-derived its own verdict via identity.ts's hasVariantIdentity over dimensions AND originalLabels, which — unlike hasIdentityByColumn — counts the variant-code row's value too), so a reserved slot pre-populated with only a placeholder code could get materialized as an active/inactive VariantDefinition while columnClassifications correctly still called it reserved_placeholder.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export interface HeaderParserGridInput { | export interface HeaderParserOptions { | export const DEFAULT_COL_FROM = AW_SCAN_TO - 1 // column Q | export const DEFAULT_COL_TO = VARIANT_BAND_COL_TO | export const DEFAULT_ROW_FROM = 1 | export const DEFAULT_ROW_TO = 40 | export interface SlotIndexRowResult { | export function locateSlotIndexRow( | export interface VolumeRowMatch { | export function locateVolumeRows( | export interface HeaderParserResult { | export function parseVariantHeaderBlock(input: HeaderParserGridInput, options: HeaderParserOptions = {}): HeaderParserResult { | export function headerParserInputFromWorksheet(ws: Worksheet): HeaderParserGridInput {",
      "source_heading_line": 4181,
      "source_end_line": 4278
    },
    {
      "path": "lib/qaf-differences/internal/multi-qaf/identity.ts",
      "category": "core",
      "declared_lines": 276,
      "comment_summary": "Multi-QAF variant identity: composite canonical key + active-state derivation (KAR-929 / Multi-QAF-Programm P1.1). Master-Prompt §9: \"A Multi-QAF variant cannot be identified only by its column number or labels such as V1. Create a canonical variant identity from the available normalized dimensions... Matching must support: same variant in a different column, renamed variant, reordered variants, ... aliases, spelling differences, German and English labels...\" This module builds that identity (buildCompositeCanonicalKey) and derives the active/inactive/reserved classification (deriveActiveState) — both pure functions over already-extracted VariantDimensions/volume data, no ExcelJS/workbook access (that is P1.2's job). Reuses the existing normalizer rather than reinventing umlaut-folding/ case-folding (same discipline canonical-model.ts's normalizeCanonicalLabel already established for QAF field labels): normalizeProcessName lowercases, folds ä/ö/ü/ß, and collapses whitespace — exactly what makes \"Rechtslenker\" and \"rechtslenker\" (or \"Links-/Rechtslenker\" spacing variants) compare equal, and what makes a German dimension VALUE and its English counterpart compare equal ONLY when they are literally the same token (e.g. both files spell a drive type \"AWD\") — cross-language SYNONYMS for variant-dimension VALUES (as opposed to field LABELS, which canonical- model.ts's registry already covers) are not attempted here: the 4 real files never showed a DE/EN-translated dimension VALUE pair (only DE/EN field-LABEL pairs, e.g. \"Antrieb\"/\"Drive type\"), so there is no evidence base yet to build a dimension-value synonym table against — a future P2.2 (Varianten-Identität + Matching) item once real cross-language variant-value evidence exists, not invented speculatively here.",
      "mentions_todo_or_future": true,
      "pure_or_no_io": true,
      "test_reference": false,
      "public_signatures": "export function normalizeDimensionValue(raw: string): string { | export function makeDimensionValue(raw: string, sourceCell: MultiQafCellRef | null = null): VariantDimensionValue { | export function buildCompositeCanonicalKey(dimensions: VariantDimensions, fallback?: string | null): string { | export function hasVariantIdentity(dimensions: VariantDimensions, originalLabels: readonly string[]): boolean { | export function deriveActiveState( | export function detectCanonicalKeyCollisions(variants: readonly CollisionCandidate[]): MultiQafWarning[] { | export function disambiguateCanonicalKeys<T extends DisambiguationCandidate>(variants: readonly T[]): readonly T[] {",
      "source_heading_line": 4279,
      "source_end_line": 4317
    },
    {
      "path": "lib/qaf-differences/internal/multi-qaf/material-differ.ts",
      "category": "core",
      "declared_lines": 1231,
      "comment_summary": "Multi-QAF zweistufiger Material-Vergleich (KAR-938 / Multi-QAF-Programm P2.4, Epic KAR-925). Problem this closes (30-backlog-phasenplan.md P2.4 + Master-Prompt §11): \"The material comparison must operate at two levels\" — 11.1 shared component level (position/label/currency/unit cost/transport/duty/ exchange rate/overhead/formulas/row identity, i.e. the MASTER-DATA row itself) and 11.2 variant allocation level (quantity factor, inclusion/ exclusion, added/removed/substituted component, effective cost contribution — i.e. how ONE variant participates in that row). §11.2 also states the KERN-REGEL this module exists to enforce: \"A shared unit-cost change may affect many variants... Do not duplicate the same shared price change as an unexplained independent defect for every variant. Show: one shared master-data change, the affected variants, the commercial impact per variant, the aggregate impact where meaningful.\" container-differ.ts (KAR-937/#309, P2.3) diffs the same `container.sharedMaterialMaster` array but is explicitly, deliberately VALUE-BLIND (see its own module header: \"shared-material unit-cost/ quantity-factor VALUES -> P2.4\"). This module is that P2.4: it never reports a row/column merely existing or not (that stays #309's job — `SharedMaterialRowChangedFinding` there already covers the row's own `validationStatus` transition and its referenced-variant-id-SET change); this module ONLY asks \"did a row's own field VALUES change\" (11.1) and \"did a specific variant's ALLOCATION against a row change\" (11.2). Row pairing (`pairRows` below) is computed independently here — not imported from #309, whose own exported types (`SharedMaterialRowIdentityRef`) carry no VALUE data to diff at all — but uses the IDENTICAL keying algorithm (`canonicalComponentIdentity`, last-parsed-row-wins on a within-side duplicate) so the two modules never disagree about which ALT row pairs with which NEU row. ── Two levels, two separate finding groups (never conflated) ────────────── 11.1 Shared-Komponenten-Ebene (`sharedComponents`, keyed by canonicalComponentIdentity, computed ONCE per row-pair regardless of how many variants reference it): unitCostValueChanges (value and currency reported SEPARATELY — a currency swap and a value swap are different findings, KAR-938 task spec), exchangeRateChanges, logisticsOrDutyChanges, materialOverheadChanges, formulaChanges (reuses formula-engine.ts's compareFormulaPair verbatim — \"geänderte Formel bei gleichem cached Wert\" is its own reportable kind there already, `formel_geaendert_wert_gleich`), rowIdentityChanges (a position-stable row whose LABEL changed — Master-Prompt §11.1's own \"row identity\" bullet, see `pairRows`' rename-correlation pass). 11.2 Varianten-Allokations-Ebene (`variantAllocation`, one finding per (matched variant pair, row) where the ALLOCATION itself — not the row's own values — changed): `factor_changed` (both sides applicable, numeric value differs — this is where an explicit-0 factor toggling to/from a nonzero factor is reported, NEVER conflated with `included`/`excluded` below), `included`/`excluded` (blank<->applicable transition regardless of which numeric value the applicable side carries, including an explicit 0 — \"blank≠0\"), `component_added`/`component_removed` (the row itself is new/gone — one finding per matched variant that references it, so a caller never has to cross-reference #309's row-level added/removed sets to know which of ITS OWN variants are affected). ── Mixed-Currency-Doktrin (KAR-935 precedent, respected here) ───────────── A shared value's commercial IMPACT on a variant (`factor × valueDelta`) is only ever computed when: (a) the row's own currency is IDENTICAL on both sides (a currency SWAP is its own separate, un-quantified finding — never silently converted), and (b) the variant's own quantity factor is IDENTICAL on both sides (a SIMULTANEOUS factor change makes \"how much of this delta is this variant's\" ambiguous — reported as its own `factor_changed` finding at 11.2 instead, never double-counted into the 11.1 impact number). Neither condition holding => `VariantImpact.impact` is `null` with a `reason`, NEVER a fabricated number — same \"never force an interpretation\" discipline Master-Prompt §7 states and VirtualVariantSummaryTotals.materialCostsByCurrency (types.ts, KAR-935) already established for the summary-aggregation path. Aggregates (`MaterialImpactAggregate`) are per-CURRENCY buckets for exactly this reason — a finding touching two currencies never produces one mixed total. The SAME gate applies to the 11.2 `VariantAllocationFinding. effectiveCostDeltaAbsolute/Percent` (`altEffectiveCost`/`neuEffectiveCost` live in the row's own `unitCost.currency`, so a `factor_changed` finding on a row whose currency also swapped would otherwise diff two different currencies against each other) — adversarial review KAR-938/#310 F2. The currency check itself is a THREE-way gate, not a boolean: `same_currency` (impact computed), `currency_changed` (a genuine ALT<->NEU swap — its own `mixed_currency` reason/warning), and `currency_unknown` (the currency was simply never captured on at least one side — its own separate `currency_unknown` reason/warning, NEVER reported as a swap that never happened) — adversarial review KAR-938/#310 F3. ── Fail-closed (Master-Prompt §7, KAR-936 matched-only gate) ────────────── Every 11.2 finding, every impact computation, and every substitution candidate is scoped to variant pairs the CALLER's own `matchResult` already classified `'matched'` (variant-matcher.ts) — an `'ambiguous'`/ `'split_suspected'`/`'merge_suspected'` variant pair is NEVER guessed at here; its ids are instead carried through verbatim on `uncertainMatches` (same \"pass through, never resolve\" discipline container-differ.ts already established for its own `variants.uncertainMatches`) so a caller can render \"N variants not compared\" rather than silently seeing zero findings for them. A row whose OWN `validationStatus` is not `'ok'` (KAR-931's `missing_unit_cost`/`missing_factor_for_all_variants`/ `formula_error_cached`/`orphaned_no_summary_link`/`needs_review`) still produces findings (never silently dropped — a human needs to see the row changed) but every such finding is `reviewRelevant: true` and its impact's `aggregates` is forced empty — an uncertain row's numbers are shown but never rolled into a trusted total (same `reviewRelevant`-not-`severity` axis MultiQafWarning.reviewRelevant's own doc comment establishes). ── Determinism ───────────────────────────────────────────────────────── Every finding array is sorted on a stable key (canonicalComponentIdentity, then variant id, then finding kind — all plain string sorts); every currency-bucket aggregate is sorted by currency code. Same inputs always produce a byte-identical (JSON.stringify-equal) MaterialDiffResult. ── Toleranzen (engine-config-Muster, keine Magic Numbers) ───────────────── Value-delta magnitude bands reuse differ.ts's `computeNumericDelta` verbatim (same 'konstant'/'anstieg'/'senkung'/'auffaellig_10'/ 'auffaellig_25'/'kritisch_50' status-band machinery every other QAF value comparison in this codebase already uses) — `MaterialDifferConfig.bands` defaults to `DEFAULT_DIFFER_BANDS_CONFIG` (differ.ts) so a caller who never touches config gets byte-identical thresholds to every other diff surface. `substitutionLabelSimilarityThreshold` is this module's own named section (no precedent to reuse — no other module in this tree does removed/added label-similarity correlation), analogous in SHAPE to RECONCILIATION_CONFIG/DifferBandsConfig (named type + named DEFAULT_ constant + explicit function parameter that falls back to it). tdd-guard: covered by __tests__/material-differ.test.ts (synthetic, one scenario per finding kind + determinism) and __tests__/material-differ.real-files.test.ts (env-gated: self-diff of each of the 4 real files against an identical copy -> zero findings in every group, plus one in-memory single-row-mutation proof).",
      "mentions_todo_or_future": true,
      "pure_or_no_io": false,
      "test_reference": true,
      "public_signatures": "export interface MaterialDifferConfig { | export const DEFAULT_MATERIAL_DIFFER_CONFIG: MaterialDifferConfig = { | export interface MaterialComponentRef { | export type VariantImpactUncomputableReason = 'mixed_currency' | 'currency_unknown' | 'factor_changed' | 'value_unknown' | export interface VariantImpact { | export interface MaterialImpactAggregate { | export interface SharedComponentImpactSummary { | export interface UnitCostValueChangeFinding { | export interface UnitCostCurrencyChangeFinding { | export interface ExchangeRateChangeFinding { | export type MoneyFieldKind = 'logistics_or_duty' | 'material_overhead' | export interface MoneyFieldChangeFinding { | export interface FormulaChangeFinding { | export interface RowIdentityChangeFinding { | export interface MaterialSharedComponentDiff { | export type VariantAllocationChangeKind = | export interface VariantAllocationFinding { | export interface SubstitutionSuspectedFinding { | export interface MaterialVariantAllocationDiff { | export interface MaterialDiffOptions { | export interface MaterialDiffResult { | export function diffMaterial(",
      "source_heading_line": 4318,
      "source_end_line": 4462
    },
    {
      "path": "lib/qaf-differences/internal/multi-qaf/material-matrix-parser.ts",
      "category": "core",
      "declared_lines": 1008,
      "comment_summary": "Multi-QAF material-matrix parser (KAR-931 / Multi-QAF-Programm P1.3, Epic KAR-925). Reads the shared material/BOM matrix of a Multi-QAF workbook — the \"gemeinsame Materialliste + Mengenfaktor je Variante\" pattern all 4 analyzed real files independently confirm (10-analyse-clarwe-eu.md D, 10-analyse-mx.md F, 10-analyse-nafta.md D, 10-analyse-ncar.md E) — into VariantMatrixRow[] (types.ts, this PR's target shape). Pure — no ExcelJS import at the top level; the ExcelJS bridge at the bottom mirrors header-parser.ts's own \"pure core / thin adapter\" split. The top-level orchestration function (parseMaterialMatrix) is ASYNC — it resolves 3 label synonyms against the canonical-fields.ts registry via a cached dynamic import (loadCoreLabelSynonyms, mirrors material-parser.ts's loadMaterialRegistry) instead of a hand-copied literal, so the alias list has one source of truth (KAR-931 adversarial-review fix F3). All other exported functions (locateMaterialMatrixHeader, etc.) stay synchronous. PRECONDITION (task instruction, not enforced here): a caller has already run header-parser.ts's parseVariantHeaderBlock on THIS SAME sheet (Material / BOM Detail EU / Vgl Material — whichever sheet this parser is pointed at) and passes its VariantDefinition[] result in as `variants`. This module never guesses which columns are variant-factor columns from the grid itself — variants[i].originalColumn/originalColumnIndex are the sole source of \"which column is variant i's factor column ON THIS SHEET\" (real evidence for why this must be resolved per-sheet, not copied verbatim from the Summary sheet's own column letters: 10-analyse-mx.md F — Material's slot columns are R..AC, NOT the Summary sheet's own Q..AJ letters, though slot INDEX order matches; formula-lineage.ts's own module header — Summary's Q..AP band maps onto BOM's N..AM band, offset by -3). ── Real-file evidence for the algorithm below ────────────────────────── Row identity (Master-Prompt §11.1, 10-analyse-clarwe-eu.md J.10: \"Position number + part designation, not raw row index, since rows can be inserted/deleted/reordered between file versions\"): canonicalComponentIdentity is built from (normalized) position number + normalized component label, mirroring material-parser.ts's own materialRowLogicalKey convention for the STANDARD MATERIAL sheet (`${pos}::${designation}`) — deliberately the SAME convention, not a diverging one, per this PR's task instruction to look at material-parser.ts's field-extraction/registry anchors for consistency. Padding-row filter (10-analyse-clarwe-eu.md J.6/I.4/D: \"Materialzeilen: 125 befüllte Zeilen ... Rest bis 159 = Summen-/Padding-Bereich\" + \"24x #DIV/0! in BOM Detail EU ... Ursache: L84=AVERAGE(N84:AM84) über eine komplett leere Materialzeile ... liegt in einer Template-Padding-Zeile ohne reale Materialposition\"): a row with NEITHER a position number NOR a component label is not a material position at all — skipped outright, even when it carries cached #DIV/0!/#N/A error values in its row-control columns (harmless template artifact per that same finding). Total-/Summen-row filter (KAR-931 adversarial-review fix F1, 10-analyse-clarwe-eu.md D: row 159 = \"Summe Materialkosten\" — a LABELED row with NO position number): the padding filter above does NOT catch this real pattern, since it requires BOTH position AND label to be blank. A row is additionally excluded from material-row extraction (and classified as a total/aggregate row instead) when its label matches a DE/EN Summe/Gesamt/Total pattern, OR its cells in the VARIANT columns themselves carry a SUMIF/SUMPRODUCT/aggregate-shaped formula (classifyFormulaShape) — the same structural signature the SUMPRODUCT- Verifikation pass below searches for. Excluding it here (rather than silently absorbing its cached totals as bogus per-component quantity factors) is what lets locateVariantTotalCell below actually find the real total formula: lastMaterialRow now correctly stops BEFORE this row instead of advancing past it. Empty-cell semantics for the per-variant factor matrix (task instruction: \"leere Zelle = 0 oder nicht-enthalten? EMPIRISCH klären... und dokumentieren\") — EMPIRICALLY RESOLVED as \"not applicable\", NOT 0: - 10-analyse-nafta.md D: one material row has a non-zero factor in the column subset belonging to one platform family and is completely BLANK in the sibling platform family's columns — only the first family's variants use this component at all; the blankness in the other family's columns is a structural \"does not apply\" signal, not \"applies with quantity 0\" (real-file evidence, deliberately not reproducing the file's own platform/component identifiers here). - 10-analyse-mx.md F: row 51 has SIX columns holding an EXPLICIT LITERAL `0` (R51=T51=U51=W51=X51=Z51=AA51=0) while its OTHER columns (S51/V51/Y51/AB51/AC51) are non-zero — i.e. the same file uses BOTH a literal `0` AND a blank cell as DIFFERENT, deliberately distinguishable states elsewhere in its factor matrix; collapsing blank into 0 would erase that distinction the source file itself maintains. - types.ts's own VariantMatrixRow doc comment already specifies this contract (\"a variant with NO entry for a given row means 'not applicable'... distinct from an entry with value `0`\") — this module implements exactly that: a blank/empty cell OMITS the variant's key from quantityFactorByVariant/effectiveCostByVariant entirely; a cell holding a parseable literal `0` INCLUDES the key with value `0`. Note this differs from what SUMPRODUCT/SUMIF do at the AGGREGATE (column-total) level — Excel's own SUM functions treat a blank operand as 0 for arithmetic purposes, which is why the reconciliation pass below (SUMPRODUCT-Verifikation) reproduces the SAME arithmetic (a row without a quantityFactorByVariant entry for this variant contributes nothing to the recomputed sum) even though the STRUCTURAL matrix representation keeps \"not applicable\" and \"0\" apart. Both are correct at their own level; conflating them would be the actual bug. unitCost.value is populated ONLY from an explicit, header-labeled \"AW\" (Angebotswährung/offer-currency) unit-cost column — deliberately NO automatic BW/exchange-rate fallback derivation, even though 10-analyse-clarwe-eu.md D documents `I=G/H` (AW = BW / Wechselkurs) for that one family: 10-analyse-nafta.md D documents `K=IFERROR(I*J,\"\")` for its own AW-equivalent column — a MULTIPLICATION, not a division — meaning the two real families do not even agree on which arithmetic direction \"Wechselkurs\" represents. Guessing the operator for a file whose AW column header this module fails to recognize would silently produce a wrong-by-a-factor-of-(rate squared) unit cost roughly as often as a correct one — exactly the \"nie raten\" case Master-Prompt §2 forbids. A row without a located AW-cost column reports `validationStatus: 'missing_unit_cost'` instead (honest absence, never fabricated). See PR body for the follow-up this implies once real-file header text for every family is confirmed. SUMPRODUCT-Verifikation (task instruction #4): all 4 real files place a per-variant-column TOTAL formula in a row just below the material data (10-analyse-mx.md F: `R121='=SUMPRODUCT($O$19:$O$120,R19:R120)'`; 10-analyse-nafta.md D: `N56=SUMPRODUCT($K$20:$K$54,N20:N54)`; 10-analyse-ncar.md E: `Material!N81=SUMPRODUCT($K$19:$K$80,N19:N80)`; 10-analyse-clarwe-eu.md D: row 159 \"Summe Materialkosten\" per column) — the SAME structural shape (a SUMIF/SUMPRODUCT/aggregate-shaped formula cell in this variant's own column, within a bounded window below the data) across all 4 families, found here via `classifyFormulaShape`/`parseFormulaReferences` (formula-lineage.ts) rather than a new formula analyzer, per task instruction. Recomputed independently from this module's own extracted rows (never trusting the sheet's own total blindly) and compared with the SAME tolerance philosophy reconciliation.ts already uses (RECONCILIATION_CONFIG, imported, not re-derived). The documented ClarWE template defect (10-analyse-clarwe-eu.md C.3/I.4: the row-level SUMIF-Mengensumme spans N:AO — 28 columns, including the 2 orphaned columns — while the row-level Mengenfaktor-AVERAGE spans only N:AM — 26 columns) is NEVER reproduced as this module's own ground truth (quantityFactorByVariant is always derived from `variants`-driven per-cell reads, never from the sheet's own average/sum row-control columns) — it is only DETECTED, when both control columns are present, and surfaced as a `formula_column_range_inconsistency` warning (the exact code name types.ts's own MultiQafWarning doc comment already anticipates for this finding). Performance guard (same discipline as formula-lineage.ts's own header): bounded row/column scan window (MATERIAL_MATRIX_MAX_ROWS/_COLS, reusing workbook-adapter.ts's SEMANTIC_ACTIVE_RANGE_MAX_ROWS/_COLS — never ws.dimensions) and a bounded total-row search window (MATERIAL_MATRIX_TOTAL_ROW_SCAN_WINDOW). Hitting the row/column scan window's own edge with plausible material content still present is FAIL-CLOSED, not silently truncated: `truncated: true` on the result, a `material_matrix_scan_truncated` warning, and every reconciliation result downgraded to `nicht_pruefbar` (a truncated scan cannot safely claim rows beyond its own edge do not exist).",
      "mentions_todo_or_future": true,
      "pure_or_no_io": true,
      "test_reference": false,
      "public_signatures": "export interface MaterialMatrixGridInput { | export interface MaterialMatrixOptions { | export const MATERIAL_MATRIX_MAX_ROWS = SEMANTIC_ACTIVE_RANGE_MAX_ROWS | export const MATERIAL_MATRIX_MAX_COLS = SEMANTIC_ACTIVE_RANGE_MAX_COLS | export const MATERIAL_MATRIX_HEADER_SCAN_MAX_ROWS = 30 | export const MATERIAL_MATRIX_TOTAL_ROW_SCAN_WINDOW = 15 | export function locateMaterialMatrixHeader( | export type MaterialMatrixReconciliationStatus = 'bestanden' | 'abweichung' | 'nicht_pruefbar' | export interface MaterialMatrixReconciliationResult { | export interface MaterialMatrixParseResult { | export async function parseMaterialMatrix(input: MaterialMatrixGridInput, variants: readonly VariantDefinition[], options: MaterialMatrixOptions = {}): Promise<MaterialMatrixParseResult> { | export function materialMatrixInputFromWorksheet(ws: Worksheet): MaterialMatrixGridInput { | export function materialMatrixScanBoundsFromWorksheet(ws: Worksheet): { rowTo: number; colTo: number } {",
      "source_heading_line": 4463,
      "source_end_line": 4616
    },
    {
      "path": "lib/qaf-differences/internal/multi-qaf/profile-differ.ts",
      "category": "core",
      "declared_lines": 974,
      "comment_summary": "Multi-QAF Fertigungs-Vergleich mit 8 getrennten Befund-Typen (KAR-939 / Multi-QAF-Programm P2.5, Epic KAR-925). Problem this closes (30-backlog-phasenplan.md P2.5 + Master-Prompt §12): \"Multi-QAF manufacturing logic may use shared profiles instead of one independent process list per variant... Report separately: (1) a changed manufacturing process, (2) a changed profile total, (3) a changed volume threshold, (4) a variant moved to another profile, (5) a newly created profile, (6) a removed profile, (7) an inconsistent profile mapping, (8) a summary value not reconciling with the selected profile. Do not assume that all variants use the same manufacturing total.\" container-differ.ts (KAR-937/#309, P2.3) already diffs the SAME `container.sharedManufacturingProfiles`/`setupCostProfiles`/ `sharedToolingData`/`variantProfileBindings` arrays but is explicitly, deliberately VALUE-BLIND there (see its own module header: \"manufacturing- /setup-cost profile VALUE totals -> P2.5\"). This module is that P2.5. ── #309/#310 boundary — who reports what, no double-reporting ──────────── - Profile STRUCTURAL identity, added/removed PROFILE OBJECTS (without values), and BINDING CHANGE existence (bound/unbound/rebound, without a value comparison) are #309's `profiles.added/removed/bindingChanges` — this module reuses the IDENTICAL structural-identity algorithm (`profilesByStructuralIdentity`, imported verbatim from container-differ.ts, never re-derived) so the two modules can never disagree about \"is this the same profile\" across a cosmetic row shift, but adds the VALUES #309 deliberately omits (`added`/`removed` below carry `values`/`totalPerUnit`; #309's own `ProfileIdentityRef` does not). - Volume-band THRESHOLD changes (Master-Prompt §12 item 3) are ALREADY fully owned by #309's `profiles.volumeBandThresholdChanges` (its own `isVolumeBandStructuralKey` whitelist, exported and reused verbatim here). This module's own `componentValueChanges`/`totalChanges` (items 1/2 below) EXCLUDE every threshold-structural key from their own `values`-Record diff for exactly this reason — a volume-band's threshold moving is reported ONCE, by #309, never a second time here as a generic \"component value changed\" finding. This module deliberately has NO finding group of its own for item 3; a caller wanting item 3 combines this module's output with #309's `profiles.volumeBandThresholdChanges`. - `container-differ.ts`'s `allMultiQafContainerVariants` and its fail-closed matchResult-id-resolution contract are reused/mirrored verbatim (see `requireVariant` below) — this module never re-derives variant identity, matching, or profile structural identity from scratch. ── Master-Prompt §12 item -> finding group mapping ──────────────────────── 1 changed manufacturing process (component-level, e.g. Zykluszeit/ Löhne/Maschinenstundensatz-shaped keys) -> `componentValueChanges` 2 changed profile total (`values.totalPerUnit`) -> `totalChanges` 3 changed volume threshold -> NOT here, see #309 `profiles.volumeBandThresholdChanges` (boundary note above) 4 variant moved to another profile (VALUE impact, #309's `bindingChanges` already reports the move itself structurally) -> `bindingValueImpacts` 5 newly created profile (with values) -> `added` 6 removed profile (with last known values) -> `removed` 7 inconsistent profile mapping (unbound / unresolved reference / ambiguous reference / dangling profileId) -> `inconsistentBindings` 8 summary value not reconciling with the selected profile -> `summaryReconciliation` ── \"Never assume all variants share one total\" (task instruction) ──────── Every finding below is scoped to a MATCHED variant pair's OWN binding (`container.variantProfileBindings`, resolved per-variant, never a container-wide default) — Datei 3's real-corpus shape (10-analyse-nafta.md E: all 5 variants legitimately resolve to the SAME absolute cell) and Datei 1/4's per-band/per-shape shapes (10-analyse-mx.md G, 10-analyse-ncar.md F) are both handled for free by this per-variant resolution — no group here ever assumes a single container-wide total. ── Fail-closed self-diff-safety discipline (why groups 7/8 are SET-DIFFS, not absolute per-side snapshots) ────────────────────────────────────────── profile-parser.real-files.test.ts's own real-corpus evidence (KAR-932/profile-parser.ts \"Datei 2\": binding rate genuinely < 1 on a real, otherwise-healthy file — some Summary variant columns are non-standard benchmark/delta-helper columns with no resolvable profile binding at all, an HONEST, EXPECTED outcome, not a parser defect) means an ABSOLUTE per-side inventory of \"inconsistent\"/\"non-reconciling\" entries is NOT necessarily empty even for a single real file compared against an exact copy of itself. Reporting such an inventory directly (unfiltered) would make the task's own required self-diff anchor test (\"Selbst-Diff aller 4 Dateien -> 0 Befunde in allen 8 Gruppen\") mathematically impossible to satisfy for any file with even one such honest pre-existing gap. `inconsistentBindings` and `summaryReconciliation` are therefore both SET-DIFFS (added/removed), exactly mirroring #309's own `detailWithoutSummarySetDiff`/`unattributedDetailColumnEntries` pattern — an entry is only ever reported when it exists on ONE side and not the other (a genuine change), never when it exists identically on both (a pre-existing, unchanged condition). This guarantees self-diff safety by CONSTRUCTION (identical containers on both sides can never produce a one-sided entry), independent of how many honest pre-existing gaps any single real file happens to carry. ── Set-diff key granularity (KAR-939 adversarial review F2/F3 — merge- blocker class) ──────────────────────────────────────────────────────── A set-diff is only as good as its dedup key: if the key stays identical across a genuine underlying change, that change silently vanishes (never appears in `added` NOR `removed`) instead of surfacing as one-sided. - `inconsistentBindings` (item 7): `unresolved_reference`/ `ambiguous_reference` entries carry `profileId: null` (there IS no resolved target — that is the whole point of these two reasons), so the key used to be `reason::variantId::''` regardless of WHICH unresolved/ambiguous target was involved. A variant flipping from one non-resolvable target to a DIFFERENT non-resolvable target (same reason, same variant) was therefore invisible. Fixed by folding a `targetRef` (a normalized slug of the warning's own message, minus the \"Variante X: \" variant-id prefix already carried by `variantId` — see `normalizeWarningTargetRef`) into both the entry and the key. - `summaryReconciliation` (item 8): the key already included `status` but never `deltaAbsolute` — two entries sharing `variantId::profileId ::status` (typically two `'abweichung'` entries, or two `nicht_pruefbar`/`same_source_no_independent_check` entries) with DIFFERENT underlying deltas were treated as identical and dropped from both `added`/`removed`. Raw floats are deliberately NOT added to the key (float noise would break the self-diff anchor) — instead a THIRD set-diff category, `changed`, catches exactly this case: same full key on both sides, but `computeNumericDelta`+`DEFAULT_DIFFER_BANDS_CONFIG` bands the ALT-vs-NEU delta pair as anything other than `'konstant'`/ `'nicht_berechenbar'`. `changed` and `added`/`removed` are mutually exclusive by construction (an entry only ever qualifies for `changed` when its full key ALREADY matched on both sides, i.e. it was excluded from `added`/`removed`) — never a double report of the same drift. ── Reconciliation source (item 8) — an explicit, defense-in-depth check, not a raw-grid re-read ──────────────────────────────────────────────────── This module (like #309/#310) operates purely on two already-assembled MultiQafContainer objects — it never re-reads the underlying workbook. \"Summary value\" (task instruction: \"VirtualQafVariant-Fertigungs-Total\") is therefore read from `generateVirtualVariants(container)` (container-assembly.ts, reused verbatim, never re-derived) rather than from a raw Summary-sheet cell profile-parser.ts's own `reconcileProfileToSummary` reads (that function needs a live `Worksheet`, unavailable here). ── Item 8 honesty fix (KAR-939 adversarial review F1/F4 — merge-blocker class) ───────────────────────────────────────────────────────────────── PRE-FIX shape (kept here as a documented cautionary tale, not current behavior): \"bound profile value\" read `formulaAndCachedValue.cachedValue` PREFERRED, falling back to `values.totalPerUnit`, and compared it against `generateVirtualVariants`' own total, which itself always derives from `values.totalPerUnit` of that SAME profile via that SAME binding (`container-assembly.ts`'s `profileTotalAsMoney` reads `profile.values. totalPerUnit` directly). Because profile-parser.ts's own `buildProfileFromCandidate` populates BOTH `values.totalPerUnit` and `formulaAndCachedValue.cachedValue` from the IDENTICAL parse-time source (`candidate.cachedValue`), the preferred `cachedValue` branch is, on every real freshly-parsed profile, numerically identical to `values.totalPerUnit` — so the comparison was a NO-OP BY CONSTRUCTION on any real file: it could only ever report a finding if a caller had ALREADY mutated one of the two representations in memory (exactly the shape this module's own mutation-proof test manufactures). A self-diff test built on real, non-mutated containers can therefore NEVER exercise this comparison's failure path — \"0 findings\" proved nothing about item 8's own logic, a fake-green anchor. FIX (current behavior): `boundProfileValue` now reads `values.totalPerUnit` as its PRIMARY (and only) source for the reconciliation-vs-virtual-variant comparison — the SAME source item 4 (`bindingValueImpacts`) and `generateVirtualVariants` itself already use (F4: one documented value-source strategy for both items, no more silent cachedValue substitution that could make item 4 and item 8 report contradictory deltas for the same underlying change). Given that shared source, this primary comparison is PROVABLY tautological today (both operands are the same profile's `values.totalPerUnit`, read twice) whenever a binding resolves to an existing profile with a resolvable virtual-variant total — so instead of silently treating that as a passed check ('bestanden', no finding — the old behavior), it is reported EXPLICITLY as `status: 'nicht_pruefbar'`, `nichtPruefbarReason: 'same_source_no_independent_check'`. This is the HONEST statement of what item 8 can verify today: because a self-diff of two identical containers produces the identical `nicht_pruefbar`/`same_source_no_independent_check` entry on BOTH sides (same key), the set-diff still reports ZERO findings for an unchanged file — self-diff safety holds by construction, same as before, but now for an honest reason instead of an accidental one. The ONE place item 8 can actually catch something TODAY is a SEPARATE, genuinely independent signal: `cachedValueDrift` — a direct, in-profile comparison of `formulaAndCachedValue.cachedValue` against `values. totalPerUnit` OF THE SAME PROFILE OBJECT (no `generateVirtualVariants` involved at all). Because these two fields are populated identically at parse time, `cachedValueDrift` is `null` for every real, freshly-parsed container — it only fires when something has updated ONE of a profile's two total-representations without the other: a caller mutating an in-memory container (this module's own mutation-proof test), a FUTURE pipeline stage that persists/rehydrates `values.totalPerUnit` from a different source than the original parse (e.g. a recalculated total) while `formulaAndCachedValue.cachedValue` stays the original parsed evidence, or stale/partial persistence. When `cachedValueDrift` is non-null, the entry's `status` becomes `'abweichung'` for real (never silently substituted as an alternative primary source — F4) and its `deltaAbsolute` mirrors that drift. This is the SAME \"compare formula/cached value separately\" philosophy Master-Prompt §13 states and formula-engine.ts already implements for material rows, applied here to a profile's own total instead — now scoped to exactly the one place it can be genuine. ── Currency-3-state-gate boundary (KAR-938/#310 doctrine, task instruction \"wo Währungen dranhängen\") ──────────────────────────────────────────────── `SharedCostProfile` (types.ts) carries NO currency field at all — its `values` Record is unitless raw numbers. The mixed-currency 3-state gate therefore has NOTHING to gate on within `componentValueChanges`/ `totalChanges` (items 1/2) — applying it there would fabricate a currency axis this domain model does not carry. The ONE place in this module where two independently-sourced currencies genuinely can diverge is item 4 (`bindingValueImpacts`): a matched variant pair's OWN `VariantDefinition.currency` can legitimately differ ALT-vs-NEU (a currency reassignment independent of any profile change) while both sides still carry a bound-profile numeric total — computing a raw delta there without gating would silently compare two amounts implicitly expressed in different currencies. `bindingValueImpacts` therefore carries its own `currencyState` 3-state gate (`same_currency`/`currency_changed`/ `currency_unknown`), mirroring material-differ.ts's own currency-gate PATTERN exactly (not imported — that helper is module-private there, same \"small helper duplicated per module\" precedent material-differ.ts and container-differ.ts already both independently establish for their own respective `isUncertainKind` helpers). ── Determinism ───────────────────────────────────────────────────────── Every finding array is sorted on a stable key (profile structural key / variant id / finding kind — all plain string sorts). Same inputs always produce a byte-identical (JSON.stringify-equal) ProfileDiffResult. ── Tolerances (engine-config pattern, no magic numbers) ──────────────────── `componentValueChanges`/`totalChanges`/`bindingValueImpacts` reuse differ.ts's `computeNumericDelta` + `DEFAULT_DIFFER_BANDS_CONFIG` verbatim (same status-band machinery every other QAF value comparison in this codebase uses). `summaryReconciliation` reuses reconciliation.ts's `RECONCILIATION_CONFIG` VALUES (relativeTolerance/absoluteToleranceMinor) via a local `withinReconciliationTolerance` helper that mirrors that module's own private `withinTolerance` formula exactly (not imported — unexported there; material-matrix-parser.ts's own header already documents this as \"the same tolerance philosophy reconciliation.ts already uses\", the identical discipline followed here). tdd-guard: covered by __tests__/profile-differ.test.ts (synthetic, one scenario per finding group + #309-boundary/no-double-report tests + currency-3-state + nicht_pruefbar + determinism + self-diff safety) and __tests__/profile-differ.real-files.test.ts (env-gated: self-diff of each of the 4 real files against an identical copy -> zero findings in every group, plus an in-memory profile-total mutation proof).",
      "mentions_todo_or_future": true,
      "pure_or_no_io": false,
      "test_reference": true,
      "public_signatures": "export interface ProfileDifferConfig { | export const DEFAULT_PROFILE_DIFFER_CONFIG: ProfileDifferConfig = { | export interface ProfileDiffOptions { | export type ProfileBindingBucket = 'manufacturing' | 'setupCost' | 'commonTooling' | export interface ProfileValueEntry { | export interface ProfileComponentValueChangedFinding { | export interface ProfileTotalChangedFinding { | export type ProfileBindingDirection = 'bound' | 'unbound' | 'rebound' | export type ProfileBindingCurrencyState = 'same_currency' | 'currency_changed' | 'currency_unknown' | export interface ProfileBindingValueImpactFinding { | export interface ProfileWithValuesRef extends ProfileIdentityRef { | export type InconsistentBindingReason = 'unbound' | 'unresolved_reference' | 'ambiguous_reference' | 'unresolvable_profile_id' | export interface InconsistentBindingEntry { | export interface InconsistentBindingSetDiff { | export type SummaryReconciliationStatus = 'abweichung' | 'nicht_pruefbar' | export type SummaryReconciliationNichtPruefbarReason = 'missing_value' | 'same_source_no_independent_check' | export interface SummaryReconciliationCachedValueDrift { | export interface SummaryReconciliationEntry { | export interface SummaryReconciliationChangedEntry { | export interface SummaryReconciliationSetDiff { | export function reconciliationEntries(container: MultiQafContainer, cfg: ReconciliationConfig): SummaryReconciliationEntry[] { | export interface ProfileDiffResult { | export function diffProfiles(alt: MultiQafContainer, neu: MultiQafContainer, matchResult: readonly VariantMatchResult[], options: ProfileDiffOptions = {}): ProfileDiffResult {",
      "source_heading_line": 4617,
      "source_end_line": 4871
    },
    {
      "path": "lib/qaf-differences/internal/multi-qaf/profile-parser.ts",
      "category": "core",
      "declared_lines": 1362,
      "comment_summary": "Fertigungs-/Rüst-Profile-Parser + Bindungs-Auflösung (KAR-932 / Multi-QAF- Programm P1.4, Epic KAR-925). Problem this closes (30-backlog-phasenplan.md P1.4 + Master-Prompt §12): a Multi-QAF workbook's manufacturing/setup-cost logic is usually NOT one independent process list per variant — it is a small number of SHARED profile blocks (a manufacturing-cost total, an L-Shape/I-Shape family total, a volume-tranche total, a setup-cost total) that each variant's Summary row points AT, rather than computes independently. This module (a) finds those profile blocks and their Total cells in a Fertigungskosten -shaped sheet, and (b) resolves, per variant, WHICH profile its Summary Fertigungskosten cell is bound to and HOW (a hardcoded cell reference, a literal value that happens to equal a profile total, or a live formula). ── Core empirical findings this module is built from (10-analyse-*.md) ── (a) The binding mechanism is USUALLY a hardcoded absolute cell reference in the variant's own Summary Fertigungskosten cell, e.g. `Zusammenfassung!U15 = '=Fertigungskosten!$U$34'` (10-analyse-ncar.md F/E-parallel) — REFERENCE RESOLUTION, never a volume-threshold re-derivation. This module never re-implements the \">50k/>20k/<20k\" selection LOGIC (that selection was made once, by hand, when the workbook was built) — it only reads which cell each variant's formula already points at. `parseFormulaReferences`/`classifyFormulaShape` (formula-lineage.ts, this module's core tool per task instruction) do the reference parsing; this module adds NOTHING to that parser, it only interprets the parsed references against the profile-block inventory below. (b) Two independent binding SHAPES coexist even within one file (10-analyse-mx.md G): L-Shape is a LIVE `SUMIF($R12:$R42,$K54,U12:U42)` profile total; I-Shape is a HARDCODED LITERAL (`U55 = 41.219...`, no formula at all) that bypasses the process-row chain entirely. Both produce a `SharedCostProfile` here (kind `lShape`/`iShape`); the literal one is found via the SAME-COLUMN heuristic documented on `locateLiteralTotalCandidates` below. (c) A 3-block volume-band pattern recurs in 2/4 files (10-analyse-nafta.md E, 10-analyse-ncar.md F/E-parallel): three process-row blocks labeled \">50.000 Stk/a\"/\">20.000 Stk/a\"/\"<20.000 Stk/a\", each aggregated via its own `SUMIF` into its own Total cell. The label text is READ AS METADATA (`values.threshold` on the resulting `volumeBand`-kind profile — the volume floor above which this tier applies; the bottom \"<X\" catch-all tier's own threshold is 0, matching the convention synthetic-fixtures.ts's buildMultiRowHeaderFixture already established) — never rebuilt as selection logic; 10-analyse-nafta.md E proves only ONE of the three tranches may be populated in a given file (the other two SUMIFs simply evaluate to an empty/zero range), which this module handles for free since it only ever reports what a SUMIF-shaped Total cell actually contains. (d) 10-analyse-nafta.md E: all 5 real variants reference the SAME absolute cell (`Fertigungskosten!$U$29`) — manufacturing cost is, in that file, a single variant-INDEPENDENT value; cost differences come from Material alone. `resolveVariantProfileBindings` below does not assume one profile per variant — multiple variants legitimately resolving to the identical `profileId` is the expected, correctly-modeled outcome. (e) 10-analyse-clarwe-eu.md E/F: manufacturing cost is an ADDITIVE two-source formula, `='LV Detail EU'!$U$52+'Rüstkosten EU'!<col>15` — a CONSTANT part (LV Detail EU, same cell for all 26 variants — itself a `SharedCostProfile`, since it has its own SUMIF Total) plus a variant-specific part (Rüstkosten EU, itself a per-variant setup-cost lookup driven by a volume-band lookup table, 10-analyse-clarwe-eu.md E's \"Anzahl Lose\"-Tabelle — see `locateVolumeBandLookupTable`). This is the real-file evidence for the `formula` bindingEvidence.kind (types.ts VariantProfileBinding doc): the Summary cell's own formula has MORE than one reference / is not a pure passthrough, so the binding is recorded as a live formula rather than a single reference, with a best-effort profileId when at least one referenced cell matches a detected profile Total. (f) 10-analyse-nafta.md G / 10-analyse-ncar.md F: BOTH files carry a HIDDEN \"Rüstkosten EU\" sheet referencing a completely foreign vehicle programme set (unrelated to either file's own active variants) that reads FROM the visible Fertigungskosten sheet but is never itself referenced BY the Summary sheet (\"Volltext-Grep … nach 'Rüstkosten' ergab null Treffer\") — a genuine orphan, evidence for the \"hidden sheets may contain profile logic but must be excluded when nothing points AT them\" rule (Master-Prompt §17 + 30-backlog-phasenplan.md \"Hidden Sheets nur einbinden, wenn eingehende Referenzen existieren\"). `sharedCostProfileCandidateSheets` below implements exactly this Incoming-Reference-Check, reusing formula-lineage.ts's `buildColumnLineage` (never re-deriving its own reference-following logic for this — task instruction: \"Kern- Werkzeug\"). (g) 10-analyse-clarwe-eu.md itself has NO sheet literally named \"Fertigungskosten\"/\"Manufacturing cost\" (it splits into \"LV Detail EU\" + \"Rüstkosten EU\") — Master-Prompt §8 (\"do not build four filename-specific parsers... a template profile must not be the sole parsing mechanism\") therefore rules out a sheet-NAME-only candidate filter. `sharedCostProfileCandidateSheets` combines the module-sheet-names.ts MANUFACTURING alias (fast path, catches \"Fertigungskosten\"/\"Manufacturing cost(s)\") with a STRUCTURAL fallback (any sheet formula-lineage shows is transitively referenced from the Summary sheet's own cost formulas, minus sheets already claimed by another known module) — this is what admits \"LV Detail EU\" without inventing a \"LV Detail EU\"-specific name pattern. ── Scope discipline ──────────────────────────────────────────────────── Pure functions over an already-extracted grid (the block parser) or an already-loaded workbook (the ExcelJS bridge functions, same tier as formula-lineage.ts's own `buildColumnLineage`) — no ingest wiring, no DB, no container assembly (bucketing the returned `SharedCostProfile[]` into MultiQafContainer.sharedManufacturingProfiles/setupCostProfiles/ sharedToolingData by `.kind` is a P2.1 ingest-wiring concern, deliberately left to that caller rather than half-built here). `qaf-parser.ts`'s standard single-variant Fertigungskosten step parser is NOT imported or modified — this module solves a structurally different problem (shared profile BLOCKS across many variant columns, not one variant's own process list) and reuses only the DE/EN label-anchor vocabulary already centralized in module-sheet-names.ts (`MANUFACTURING` alias) and summary-metrics.ts (`METRIC_LABELS_DE.manufacturingCosts`), never the step-parser's row-walking logic itself. `location`/`currency`/`commonTooling` (types.ts SharedCostProfileKind) are never produced by this module — no real-file evidence for a DEDICATED profile block of those kinds was found across the 4 analyzed files (a location/currency signal, where present, lives on individual material/ process rows, not as its own shared-profile block) — left for a future item with real evidence, never fabricated here. Fail-closed guard/cap philosophy (same discipline as formula-lineage.ts): a SHARED_FORMULA_UNRESOLVED cell, a missing formula grid, or a value with no resolvable binding NEVER become a guessed 'literal'/'cellReference'/ 'formula' classification — they produce NO VariantProfileBinding entry at all (an \"unbound\" variant) plus an explicit MultiQafWarning, never a silently wrong profileId. tdd-guard: covered by __tests__/profile-parser.test.ts (synthetic fixtures) and __tests__/profile-parser.real-files.test.ts (env-gated aggregate-only real-file regression).",
      "mentions_todo_or_future": true,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export interface ProfileParserGridInput { | export interface ProfileBlockScanOptions { | export const DEFAULT_PROFILE_COL_FROM = 0 | export const DEFAULT_PROFILE_COL_TO = 79 | export const DEFAULT_PROFILE_ROW_FROM = 1 | export const DEFAULT_PROFILE_ROW_TO = 120 | export const DEFAULT_LABEL_SCAN_WINDOW = 20 | export const MIN_VOLUME_BAND_LOOKUP_ROWS = 2 | export const MAX_VOLUME_BAND_LOOKUP_ROWS = 12 | export const LITERAL_BINDING_ABS_TOLERANCE = 1e-6 | export const LITERAL_BINDING_REL_TOLERANCE = 1e-4 | export const MIN_PLAUSIBLE_VOLUME_BAND_THRESHOLD = 1000 | export function locateVolumeBandLookupTable(input: ProfileParserGridInput, options: ProfileBlockScanOptions = {}): SharedCostProfile | null { | export function parseSharedCostProfileBlocks(input: ProfileParserGridInput, options: ProfileBlockScanOptions = {}): SharedCostProfile[] { | export function locateManufacturingSummaryRow( | export type SummaryMoneyRowKind = 'scrapMaterial' | 'otherSurcharges' | 'offerBasePrice' | 'offerBasePriceInclAllocation' | 'offerPrice' | export const SUMMARY_MONEY_ROW_KINDS: readonly SummaryMoneyRowKind[] = [ | export function locateSummaryMoneyRow( | export interface SummaryMoneyRowExtraction { | export function extractSummaryMoneyRow( | export interface VariantColumnRef { | export interface ResolveVariantProfileBindingsResult { | export function resolveVariantProfileBindings( | export interface ProfileSummaryReconciliationResult { | export function reconcileProfileToSummary( | export type ManufacturingProfileFindingKind = | export function profileParserInputFromWorksheet(ws: Worksheet): ProfileParserGridInput { | export interface SharedCostProfileCandidateSheets { | export function sharedCostProfileCandidateSheets( | export interface SharedCostProfilesFromWorkbookResult {",
      "source_heading_line": 4872,
      "source_end_line": 5028
    },
    {
      "path": "lib/qaf-differences/internal/multi-qaf/serialization.ts",
      "category": "core",
      "declared_lines": 230,
      "comment_summary": "Multi-QAF container JSON serialization (KAR-929 / Multi-QAF-Programm P1.1). Persistence philosophy (module header of types.ts + ../persistence- mapper.ts precedent): additive, JSON-serializable, versioned. Every domain type in this package already uses only plain arrays/records/primitives (no Map/Set/Date/class instances), so serialization itself is a thin `JSON.stringify`/`JSON.parse` wrapped in a versioned envelope (`{ modelVersion, container }` / `{ modelVersion, variants }`) — the real work here is the envelope contract (reject an unrecognized modelVersion rather than silently reinterpreting it) and undefined-tolerant reads (a hand-crafted or future-partial payload still deserializes into a FULLY-SHAPED container, defaulting every missing array/record field to empty rather than leaving it `undefined` — same \"rows persisted before KAR-886 lack sourceCells/normalized, readers must tolerate undefined\" discipline ../../qaf-parser.ts already documents for QAFRow, applied here pre-emptively since no real persisted payload exists yet to be lenient about — P4.1 (Persistenz-Schema) is the actual DB-write path this prepares for, explicitly out of scope for this PR). P1.1 has ever shipped only MULTI_QAF_MODEL_VERSION 1 — there is no migration function yet because there is nothing to migrate FROM. When a breaking v2 shape ships, the modelVersion mismatch branch below is where a migrateV1ToV2 step gets added; until then, an unrecognized modelVersion is a hard error (never a guess).",
      "mentions_todo_or_future": true,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export function serializeMultiQafContainer(container: MultiQafContainer): string { | export function deserializeMultiQafContainer(json: string): MultiQafContainer { | export function serializeVirtualQafVariants(variants: readonly VirtualQafVariant[]): string { | export function deserializeVirtualQafVariants(json: string): VirtualQafVariant[] {",
      "source_heading_line": 5029,
      "source_end_line": 5061
    },
    {
      "path": "lib/qaf-differences/internal/multi-qaf/summary-totals-differ.ts",
      "category": "core",
      "declared_lines": 458,
      "comment_summary": "Multi-QAF per-variant Summary-Kennzahlen diff (KAR-951 / Multi-QAF-Programm, urgent livetest fix, Epic KAR-925). Problem this closes (livetest KAR-951 task brief, verified 2026-07-13): a Multi-QAF ALT/NEU pair where only 2 GLOBAL surcharge parameters changed on the Summary sheet (P21/P22, e.g. \"Overhead\"/\"zus. Standort-OH\" percentage factors) produced ZERO findings anywhere in the app, even though the resulting computed Summary metrics (SUMME Overhead / ANGEBOTSBASISPREIS / ...) moved for every one of the 26 matched variants. Root cause: `VirtualVariantSummaryTotals` (types.ts — materialCosts, materialCostsByCurrency, manufacturingCosts, totalProductionCosts, toolingAndFixtureCost, setupCostAllocation, scrap, otherSurcharges, offerBasePrice, offerPrice, one per matched variant) was assembled by container-assembly.ts but never diffed ALT<->NEU by ANY module — compare-flow.ts only orchestrates containerDiff/materialDiff/profileDiff/ reconciliation, none of which touch the Summary sheet's OWN computed totals at all. A silent \"no differences\" on a genuinely changed file is the worst failure class (Master-Prompt §3) — this module is that missing diff. ── §19 boundary — a SEPARATE surface, never merged into aggregate-impact.ts ────────────────────────────────────────────────────────────────────────── aggregate-impact.ts (KAR-944/P3.3) builds its \"aggregate commercial impact\" EXCLUSIVELY from material-/profile-COMPONENT-level deltas (materialDiff/profileDiff) — it has no concept of the Summary sheet's own already-computed totals at all. This module's `changedMetricSumsByCurrency` is a DIFFERENT, additional surface: \"what does the Summary sheet's own computed total say\", summed ONLY over metrics this module itself found `changed` — it is never fed into computeAggregateImpact and never merged with its output, so a caller can never double-count the same commercial delta once via material/profile components and once via this module's own Summary-total sum. compare-flow.ts's own module header documents exactly where `summaryTotalsDiff` sits in the pipeline (a peer of materialDiff/ profileDiff, not a dependency of aggregateImpact). ── Fail-closed (Master-Prompt §7, KAR-936 matched-only gate) ────────────── Only `matchResult` entries of `kind: 'matched'` are diffed; every other kind ('ambiguous'/'split_suspected'/'merge_suspected'/'unmatched_left'/ 'unmatched_right') is never guessed at — 'ambiguous'/'split_suspected'/ 'merge_suspected' pairs are carried through verbatim on `uncertainMatches` (same \"pass through, never resolve\" discipline material-differ.ts's own `uncertainMatches` already establishes); unmatched-left/right variants have no counterpart to diff against at all and produce no finding here (they already surface via containerDiff.variants.added/removed). ── 3-state per metric (task instruction) ─────────────────────────────────── Every metric on every matched pair gets exactly one of: 'changed' — both sides have a known value (not null) AND (the numeric delta is non-'konstant' OR the currency itself changed). 'unchanged' — both sides known, numerically 'konstant', same currency (or both currencies equally unknown). 'nicht_ermittelbar' — at least one side's value is null (never silently read as 'unchanged' — `missingSide` records which side, or 'both'). A metric is NEVER reported 'changed' merely because currency metadata is unknown on one side (that stays 'currency_unknown' on the finding, an honest \"can't tell\" state, not a fabricated swap) — only a genuine ALT<->NEU currency STRING difference (both sides known, unequal) sets `currencyChanged: true`. ── Vorzeichen-Konvention (aggregate-impact.ts's own doc header) ─────────── deltaAbsolute/deltaPercent are always NEU − ALT, via differ.ts's `computeNumericDelta` — the SAME function (same EPSILON/band thresholds) material-differ.ts/profile-differ.ts already use, per task instruction \"identische Konvention... keine neue Epsilon-Erfindung\". ── Mixed-Currency-Doktrin (KAR-935/KAR-938 precedent, respected here) ───── A metric's own deltaAbsolute/deltaPercent/status ARE still computed from the two raw numbers even when currencyChanged is true — same \"field-level delta is currency-agnostic, only the AGGREGATE gates on currency parity\" split material-differ.ts's `buildMoneyFieldFinding`/`computeSharedImpact` already establishes (module header there). What NEVER happens is `changedMetricSumsByCurrency` summing two findings whose currencies disagree into one number — that sum only includes findings whose `currencyGate === 'same_currency'`, keyed by that shared currency, exactly mirroring `MaterialImpactAggregate`'s own per-currency-bucket shape. `materialCostsByCurrency` is compared per-currency-bucket directly (its own finding array, `materialCostsByCurrencyFindings`) — bucket VALUES are already same-currency by construction (each bucket IS one currency), so no separate currency-swap detection applies there; a currency present on only one side is its own 'nicht_ermittelbar'-shaped finding (bucket added/ removed), never summed against a different bucket. tdd-guard: covered by __tests__/summary-totals-differ.test.ts (synthetic — changed/unchanged/null-one-side/null-both-sides/currency-swap/mixed- currency-materialCostsByCurrency, Q7x/T95-style invented fixture ids) and __tests__/summary-totals-differ.real-files.test.ts (env-gated: the KAR-951 livetest ALT/NEU pair — aggregate/counter assertions only, never a literal cell value/code/filename from that corpus — plus the 4-file self-compare regression corpus already used by material-differ.real-files.test.ts / profile-differ.real-files.test.ts).",
      "mentions_todo_or_future": true,
      "pure_or_no_io": false,
      "test_reference": true,
      "public_signatures": "export interface SummaryTotalsDifferConfig { | export const DEFAULT_SUMMARY_TOTALS_DIFFER_CONFIG: SummaryTotalsDifferConfig = { bands: DEFAULT_DIFFER_BANDS_CONFIG } | export interface SummaryTotalsDiffOptions { | export const SUMMARY_TOTALS_METRIC_KEYS = [ | export type SummaryTotalsMetricKey = (typeof SUMMARY_TOTALS_METRIC_KEYS)[number] | export type SummaryTotalsMetricState = 'changed' | 'unchanged' | 'nicht_ermittelbar' | export type SummaryTotalsCurrencyGate = 'same_currency' | 'currency_changed' | 'currency_unknown' | export type SummaryTotalsMissingSide = 'alt' | 'neu' | 'both' | export interface SummaryTotalsMetricFinding { | export interface SummaryTotalsMaterialCurrencyFinding { | export interface SummaryTotalsCurrencySum { | export interface SummaryTotalsDiffResult { | export function diffSummaryTotals(",
      "source_heading_line": 5062,
      "source_end_line": 5165
    },
    {
      "path": "lib/qaf-differences/internal/multi-qaf/template-fingerprint.ts",
      "category": "core",
      "declared_lines": 684,
      "comment_summary": "Multi-QAF template-fingerprint family extension (KAR-934 / Multi-QAF- Programm P1.6, Epic KAR-925 — last P1 item). Problem this closes (30-backlog-phasenplan.md P1.6 + Master-Prompt §8 \"Template flexibility\"): ../template-fingerprint.ts (KAR-895) already classifies a workbook as known/modified/unknown against the STANDARD QAF profile set (QAF_V9_SUMMARY, QAF_LEGACY_DE_SUMMARY, G60_DETAIL) — it has no concept of the Multi-QAF template families qaf-type-detector.ts (KAR-926) and header-parser.ts (KAR-930) already independently detect/parse. This module is that extension: given an ALREADY-COMPUTED MultiQafDetectionResult and an ALREADY-COMPUTED HeaderParserResult (both from this package's own existing modules, never re-derived here — task instruction: \"nutze die Signale/Konstanten aus qaf-type-detector.ts und header-parser.ts, NICHT duplizieren\"), it classifies the workbook against 3 known Multi-QAF families: - m_qaf_1_0 / m_qaf_2_0 — an explicit \"M-QAF Version 1.0\"/\"M-QAF Version 2.0\" marker was found (qaf-type-detector.ts's own S1 signal). - qaf_8_1_custom_multi — no M-QAF marker at all, but the detector still found genuine non-marker structural evidence (S2/S3/S4) — the structural-only shape 10-analyse-clarwe-eu.md documents (\"never found an M-QAF Version marker, the Multi-QAF-ness is purely structural\", types.ts's own doc comment on this family value). - unknown_multi_qaf — the detector shows Multi-QAF-shaped evidence but NONE of the above three match (types.ts's own doc comment on this family value) — Master-Prompt §8: \"Never silently treat an unknown Multi-QAF template as identical to a known one,\" so this module NEVER guesses the nearest of the three known families for this case. Master-Prompt §8's other constraint (\"A template profile improves confidence — it must never be the sole parsing mechanism\") is honored by construction: this module only ever CLASSIFIES a container that qaf-type-detector.ts and header-parser.ts already independently parsed — it never gates whether those modules run, and its own classification/ confidence output is advisory metadata (same \"advisory, not a parse gate\" role as ../template-fingerprint.ts's own known/modified/unknown facet for standard QAF, see that module's own header + templateFingerprintToPlausib ilityIssue). PRECONDITION (same discipline as header-parser.ts's own PRECONDITION, NOT enforced by this module): a caller only invokes this once qaf-type-detector.ts's detectMultiQaf has already returned 'confirmed_multi_qaf', 'probable_multi_qaf', or 'ambiguous' for the same workbook — this module has no opinion on that gate, it is a pure function over whatever MultiQafTemplateFingerprintInput it is handed. Scope discipline (task instruction, backlog P1.6 \"Foundation stage\"): pure functions + a thin ExcelJS convenience bridge at the bottom (same \"pure core / thin adapter\" split every sibling multi-qaf/ module already uses) — no ingest wiring, no DB/migration, no qaf_template_profile table (same P5-deferred discipline ../template-fingerprint.ts's own header already documents for the standard-QAF side). Converting this module's richer MultiQafTemplateFingerprintResult into the leaner MultiQafTemplateFingerprint container field (types.ts) is provided (toMultiQafContainerFingerprint) so the two shapes stay explicitly linked, but nothing here mutates or assembles a MultiQafContainer itself — that remains a later ingest-wiring item's job, same as every other P1.x parser module in this directory. Additivity to the STANDARD QAF side (task requirement 1): this module imports from, but never MODIFIES, ../template-fingerprint.ts — the only change there is exporting the already-existing `stableStringify` helper (pure refactor, zero behavior change for any existing caller/test). Every existing known/modified/unknown classification for a standard QAF file is therefore byte-identical before and after this PR by construction (same code path, untouched). tdd-guard:skip for the profile CONSTANTS below (pure data, same category as ../template-fingerprint.ts's KNOWN_TEMPLATE_PROFILES) — the functions are covered by __tests__/template-fingerprint.test.ts (synthetic) and __tests__/template-fingerprint.real-files.test.ts (env-gated, the 4 real Multi-QAF files + a handful of real standard QAF files as a neutrality check).",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export interface MultiQafTemplateFingerprintInput { | export interface MultiQafFamilyProfile { | export const MQAF_V1_PROFILE: MultiQafFamilyProfile = { id: 'm_qaf_1_0', label: 'M-QAF Version 1.0' } | export const MQAF_V2_PROFILE: MultiQafFamilyProfile = { id: 'm_qaf_2_0', label: 'M-QAF Version 2.0' } | export const QAF_81_CUSTOM_MULTI_PROFILE: MultiQafFamilyProfile = { | export const KNOWN_MULTI_QAF_TEMPLATE_PROFILES: readonly MultiQafFamilyProfile[] = [ | export interface MultiQafTemplateSemanticSignature { | export type { MultiQafFamilyClassification } from './types' | export interface MultiQafTemplateFingerprintResult { | export function fingerprintMultiQafTemplate(input: MultiQafTemplateFingerprintInput): MultiQafTemplateFingerprintResult { | export function toMultiQafContainerFingerprint(result: MultiQafTemplateFingerprintResult): MultiQafTemplateFingerprint { | export function locateVariantHeaderBlockOnSheet(ws: Worksheet): HeaderParserResult { | export interface MultiQafTemplateFingerprintFromExcelJsOptions { | export function multiQafTemplateFingerprintInputFromExcelJs( | export function fingerprintMultiQafTemplateFromExcelJs(",
      "source_heading_line": 5166,
      "source_end_line": 5252
    },
    {
      "path": "lib/qaf-differences/internal/multi-qaf/types.ts",
      "category": "core",
      "declared_lines": 798,
      "comment_summary": "Multi-QAF Container Domain-Model (KAR-929 / Multi-QAF-Programm P1.1, Epic KAR-925). Problem this closes (30-backlog-phasenplan.md P1.1): Kadi-v2's existing canonical model (../types.ts QafSummary, ../summary-metrics.ts SummaryMetricsParse, ../compare.ts QafFileParsed) is structurally single-variant — one workbook, one part number, one value column per metric (see qaf-type-detector.ts module header: \"locateAwColumn — genau EINE Werte-Spalte, strukturell Single-Variant\"). A Multi-QAF workbook is a CONTAINER of several product variants sharing one material master, one set of manufacturing/setup-cost profiles, and a scattering of non-variant auxiliary columns — none of which the existing types can represent. This module is that container model: pure types + pure core functions (composite-key construction, active-state derivation, JSON serialization). No parsing (P1.2+), no DB migration, no ingest wiring — see module READMEs P1.2 (header-parser)/P1.3 (material-matrix)/P1.4 (manufacturing-profiles)/P1.5 (formula-lineage)/P1.6 (fingerprint) for who actually POPULATES these types from a real workbook. Shape source: Master-Prompt §6 (00-master-prompt-multi-qaf.md) is the literal field list this module implements — field names below map 1:1 onto that section's bullet points, translated into TypeScript identifiers. The 4 real-file analyses (10-analyse-clarwe-eu.md/-mx.md/-nafta.md/ -ncar.md, sections C-K) are the empirical evidence for WHY each field needs the shape it has, cited inline below at each type that a specific real-file finding drove. Design discipline carried over from qaf-type-detector.ts and canonical-model.ts (both already-shipped Multi-QAF-programme modules): - variantDimensions is NOT a closed union. Master-Prompt §6: \"The dimensions must remain extensible. Do not hardcode only the dimensions present in these four files.\" VariantDefinition.dimensions is a plain string-keyed Record; KNOWN_VARIANT_DIMENSION_KEYS below is a documentation/typeahead aid only, never a validation whitelist (see identity.ts buildCompositeCanonicalKey, which iterates it for stable ordering but happily appends unknown keys too). - JSON-serializable, additive, versioned — same persistence philosophy as ../persistence-mapper.ts (plain objects/arrays, no Map/Set/Date instances; Maps that would be natural in a richer type system (quantityFactorByVariant, effectiveCostByVariant) are modeled as plain `Record<string, T>` keyed by VariantDefinition.stableInternalId instead, so serialization.ts needs no Map<->object bridge at all). - Reuses existing engine types where the business meaning is identical rather than re-declaring near-duplicates: `MoneyValue` (../types.ts) for every currency-tagged amount, `FormulaProvenance` (../formula- engine.ts) for the optional resolved-formula slot on MultiQafFormulaValue. tdd-guard:skip — type declarations only, no logic (same category as ../types.ts and ../canonical-fields.types.ts). Logic lives in identity.ts, serialization.ts and bridge.ts, all covered by __tests__/.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export const MULTI_QAF_MODEL_VERSION = 1 as const | export interface MultiQafCellRef { | export function columnLetterToIndex(letters: string): number { | export function columnIndexToLetter(index: number): string { | export interface MultiQafFormulaValue { | export type MultiQafMoneyAmount = MoneyValue | export const KNOWN_VARIANT_DIMENSION_KEYS = [ | export type KnownVariantDimensionKey = (typeof KNOWN_VARIANT_DIMENSION_KEYS)[number] | export interface VariantDimensionValue { | export type VariantDimensions = Readonly<Record<string, VariantDimensionValue>> | export interface VariantDimensionDescriptor { | export type VariantActiveState = 'active' | 'inactive' | 'reserved' | export interface VariantDefinition { | export type VariantMatrixRowValidationStatus = | export interface VariantMatrixRow { | export type SharedCostProfileKind = | export interface SharedCostProfile { | export type ProfileBindingEvidenceKind = 'cellReference' | 'literal' | 'formula' | export interface VariantProfileBinding { | export type ColumnClassificationKind = | export interface ColumnClassificationEvidence { | export interface ColumnClassification { | export interface VirtualVariantMaterialRow { | export interface VirtualVariantSummaryTotals { | export interface VirtualQafVariant { | export type MultiQafWarningSeverity = 'info' | 'warning' | 'critical' | export interface MultiQafWarning { | export interface MultiQafSummaryAggregation { | export type MultiQafTemplateFamily = | export type MultiQafFamilyClassification = 'known' | 'modified' | 'unknown'",
      "source_heading_line": 5253,
      "source_end_line": 5342
    },
    {
      "path": "lib/qaf-differences/internal/multi-qaf/variant-matcher.ts",
      "category": "core",
      "declared_lines": 1328,
      "comment_summary": "Multi-QAF Varianten-Matching mit Review-Workflow (KAR-936 / Multi-QAF- Programm P2.2, Epic KAR-925). Two call sites this module serves (both share the exact same cascade — \"one matcher, two callers\" rather than two near-duplicate implementations): (A) Cross-container matching (the Master-Prompt §9 core case): matching the variants of TWO MultiQafContainers (ALT vs. NEU) for a future P2.3 container-level compare. `left`/`right` below map onto ALT/NEU. (B) Cross-sheet matching WITHIN one container — the gap container- assembly.ts's own module header names explicitly (\"Known scope limits... only the P2.2 fuzzy matcher can [recover those files' material totals]\"): container-assembly.ts's matchMaterialVariantsToContainer matches the Material sheet's own independently re-parsed variants against the Summary-sourced container variants. Its exact-RAW-key first pass stays untouched; this module is wired in as a FALLBACK second pass for whatever that exact pass could not resolve (see that function's own updated doc comment for exactly how). `left`/ `right` there map onto container-variants/material-variants. ── Matching cascade (Master-Prompt §9's tolerance list, matcher.ts's 5-stage ALT/NEU cascade is the direct architectural precedent — same \"each stage catches what the previous stage could not, in strictly decreasing confidence order\" discipline) ───────────────────────────────── 1. raw_exact — identical RAW (pre-disambiguation) composite- canonical-key (identity.ts buildCompositeCanonicalKey) on ALL of a variant's own dimensions. Confidence 1. Tolerates: reorder, rename-that-normalizes-equal, column move (the key never encodes column/position — identity.ts's own stability guarantee). 2. core_subset_exact — the dimension keys BOTH sides actually populated (their intersection — \"Kern-Dimensionen\") agree EXACTLY; dimension keys present on only one side are ignored rather than forcing a mismatch. This is the real-file fix (KAR-935 module-header \"Known scope limits\": Material-sheet re-parses pick up DIFFERENT extra/unknown dimension labels than Summary's own re-parse for 3 of 4 real files, so their RAW keys never agree byte-for-byte even though the STABLE dimensions do). Confidence scales with how much of each side's own dimension set the shared subset actually covers (`coverage`) — never a flat constant, so a match resting on 1 of 5 dimensions is visibly less certain than one resting on 4 of 5. 3. fuzzy_label_similarity — same core-dimension-subset scope as stage 2, but each dimension VALUE is compared via normalizeDimensionValue (already applied at VariantDimensionValue construction) + a small DE/EN value-synonym table (see below) + conservative Levenshtein-based similarity (lib/duplicates/fuzzy.ts — reused, not reinvented) for genuine typos. A per-dimension comparison below the similarity floor counts as a hard 0, dragging the pair's aggregate score down rather than being silently ignored — tolerates \"additional descriptive row\"/spelling differences/aliases without conflating them with an actually-different variant. 4. Structural anchor (originalVariantNumber/slot equality, else relative closeness of whichever volume field is populated) — NEVER a stage of its own (never creates a candidate pair on its own: Master-Prompt §9 \"produce a match confidence and explanation\" implies the confidence must come from IDENTITY evidence, and volume is payload/business data that legitimately differs between an ALT/NEU snapshot of the same variant — using it as identity would make matches unstable across a routine volume update). Used ONLY to break a genuine tie between two otherwise-equal-ranked candidates competing for the same slot in the greedy assignment below — see matchVariants' own doc comment. ── Result classes (Master-Prompt §9: \"Do not automatically match ambiguous variants for commercial aggregation... allow authorized users to review and override uncertain mappings\") ───────────────────────────────────────────── matched — unique winner, one stage + confidence + evidence[]. ambiguous — 2+ candidates tied even after the structural-anchor tiebreak (a genuine \"cannot tell them apart from identity evidence alone\" case, incl. duplicate- looking variants — see detectSplits/detectMerges' own doc comment for why a duplicate never gets mistaken for a split) — reviewRelevant, NEVER auto-aggregated. split_suspected — one LEFT variant's identity plausibly spread across 2+ RIGHT variants (1→n) — reviewRelevant. merge_suspected — 2+ LEFT variants plausibly collapsed into one RIGHT variant (n→1) — reviewRelevant. unmatched_left — no right-side candidate at all (\"removed\"). unmatched_right — no left-side candidate at all (\"added\"). ── Review-workflow types (VariantMatchOverride, matchVariantsWithOverrides) ──────────────────────────────────────────────────────────────────────── Architecturally identical to field-mapping-override.ts's FieldMappingOverride/carryForwardFieldMappingOverrides pair, applied to variant PAIRS instead of a single row's field value: an override is re-identified by the variant's own compositeCanonicalKey + a dimensions snapshot (NEVER by column position/index — Master-Prompt §9's own identity doctrine applied to the override mechanism itself, the same \"never re-point at an unrelated row by position alone\" caution field- mapping-override.ts's own module header documents for its rowKey guard). An override whose snapshot no longer matches any current variant (a genuine identity drift — a re-parse changed the dimension set enough that buildCompositeCanonicalKey now produces something else) is DROPPED, not silently re-applied to a different, unrelated variant — reported back via `droppedOverrides` so a caller can surface it the same way fieldMappingOverrideDroppedIssues does. A winning override always beats whatever the automatic cascade would have produced for that variant. No dedicated serialize/deserialize pair is needed for either VariantMatchOverride or VariantMatchResult (unlike serialization.ts's MultiQafContainer functions): both are already plain JSON-safe data (no Map/Set/Date — same \"additive, JSON-serializable\" discipline types.ts's own module header establishes for VariantDimensions), so a caller persists them with a plain JSON.stringify/JSON.parse round-trip, exactly like field-mapping-override.ts's FieldMappingOverride is stored directly in qaf_file.g60_meta.fieldMappingOverrides with no wrapper function. Persistence WIRING to the database (a comparison-scoped qaf_file.g60_meta.matchOverrides bag, analogous to fieldMappingOverrides) is explicitly NOT this module's job — task brief: \"Persistenz-Wiring an die DB ist NICHT dieses PR (kommt mit P2.3-Compare-Flow)\". This module only defines the pure logic + types + (JSON-trivial) serialization shape a P2.3 caller wires up. tdd-guard: covered by __tests__/variant-matcher.test.ts (synthetic, every Master-Prompt §9 tolerance scenario) and __tests__/variant-matcher.real-files.test.ts (env-gated: self-match + the container-assembly.ts cross-sheet fallback wiring).",
      "mentions_todo_or_future": true,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export interface VariantMatchConfig { | export const DEFAULT_VARIANT_MATCH_CONFIG: VariantMatchConfig = { | export interface VariantMatchEvidence { | export type VariantMatchStage = 'raw_exact' | 'core_subset_exact' | 'fuzzy_label_similarity' | 'manual_override' | export interface VariantMatchCandidateInfo { | export interface VariantMatchedResult { | export interface VariantAmbiguousResult { | export interface VariantSplitSuspectedResult { | export interface VariantMergeSuspectedResult { | export interface VariantUnmatchedLeftResult { | export interface VariantUnmatchedRightResult { | export type VariantMatchResult = | export function matchVariants( | export interface VariantMatchOverrideIdentitySnapshot { | export interface VariantMatchOverride { | export function identitySnapshotsEqual(a: VariantMatchOverrideIdentitySnapshot, b: VariantMatchOverrideIdentitySnapshot): boolean { | export function identitySnapshotFor(v: VariantDefinition): VariantMatchOverrideIdentitySnapshot { | export function findAllByIdentitySnapshot(variants: readonly VariantDefinition[], snapshot: VariantMatchOverrideIdentitySnapshot): number[] { | export function replaceOverrideForVariant( | export function removeActiveOverridesForVariant( | export interface ApplyVariantMatchOverridesResult { | export function matchVariantsWithOverrides(",
      "source_heading_line": 5343,
      "source_end_line": 5485
    },
    {
      "path": "lib/qaf-differences/internal/multi-qaf/variant-reconciliation.ts",
      "category": "core",
      "declared_lines": 1390,
      "comment_summary": "Multi-QAF Rekonziliation je VirtualQafVariant (KAR-940 / Multi-QAF-Programm P2.6, Epic KAR-925 — the LAST P2 item). Problem this closes (30-backlog-phasenplan.md P2.6 + Master-Prompt §13): \"For every virtual variant, independently verify: material total, manufacturing total, manufacturing-cost total, tooling and fixture cost, setup-cost allocation, scrap, surcharges, offer base price, offer price, currency, volumes, any additional cost elements present in the file. Reconcile: detail sheets to summary, matrix calculation to variant material total, manufacturing profile to variant manufacturing total, setup-cost sheet to applied setup cost, shared profile assignments to summary formulas. Compare formulas and cached values separately. A matching cached value does not make a changed formula equivalent.\" UNLIKE container-differ.ts (KAR-937/#309) / material-differ.ts (KAR-938/#310) / profile-differ.ts (KAR-939/#311), this module does NOT compare two containers (ALT vs NEU). It verifies the INTERNAL consistency of a SINGLE `MultiQafContainer` — every §13 bullet, done honestly, is a question about whether one container's own summary agrees with its own detail data, not about what changed between two files. ── The honest coverage matrix (KERN-DELIVERABLE — read before touching any check below) ────────────────────────────────────────────────────────────── KAR-939 adversarial review F1 (see profile-differ.ts's own \"Item 8 honesty fix\" module-header block) already discovered, for the ALT/NEU profile-diff case, that `generateVirtualVariants` derives EVERY `VirtualVariantSummary Totals` field directly from the SAME container fields (`sharedMaterialMaster` / `SharedCostProfile.values.totalPerUnit`) that any naive \"detail vs. summary\" check would also re-read. The single-container case this module covers has the IDENTICAL problem, one level up: `MultiQafContainer` carries NO independently-parsed \"Summary sheet\" total anywhere in its domain model (types.ts) — there is no second, separately-parsed source to reconcile most §13 bullets against. Reporting these as a fake 'bestanden' would be exactly the KAR-939 F1 mistake repeated a fourth time. This module NEVER does that — every check below is mapped, honestly, to one of four buckets: REAL / independently verifiable today: - `currency` VariantDefinition.currency (Summary/ header-row parse) vs. materialRows[]. effectiveCost.currency (Material-sheet parse) — two genuinely different parsers/sheets. Can report bestanden OR abweichung OR nicht_pruefbar/ mixed_currency for real. - `material_total_matrix_independent` material-matrix-parser.ts's own SUMPRODUCT-vs-located-total-cell check (a real second source: the sheet's OWN pre-existing total formula, independent of this module's row re-extraction) — but only reachable via `container. warnings` (codes `material_matrix_reconciliation_ mismatch` / `_not_verifiable`), since the raw grid is not retained on the container. A 'bestanden' here is an INFERENCE from warning ABSENCE (container-assembly.ts's own `reconciliationWarnings()` silently drops 'bestanden' results) — real, but weaker evidence than a directly-observed pass; documented on the check's own message, never silently upgraded to a stronger claim. SAME-SOURCE / structurally tautological (KAR-939-F1 pattern, reported as `nicht_pruefbar`/`same_source_no_independent_check`, NEVER a fake 'bestanden'), UNLESS a genuine in-object formula/cached-value drift fires (which promotes the SAME check to a real `abweichung`): - `material_total_detail_to_summary` summaryTotals.materialCosts IS `sumMoneyByCurrency(materialRows. map(effectiveCost))` — literally the same re-aggregation a \"detail sheets to summary\" check would perform. - `manufacturing_total` / `setup_cost_allocation` / `tooling_fixture_ cost` reuses profile-differ.ts's EXPORTED `reconciliationEntries` (never re-derived — see \"Helper reuse\" below): `boundProfileValue` (`values.totalPerUnit`) and `virtualVariantTotal` (`generateVirtualVariants`'s `profileTotalAsMoney`) read that SAME `totalPerUnit` via the SAME binding. The one real signal inside this same-source pair is `cachedValueDrift` (`formulaAnd CachedValue.cachedValue` vs. `values.totalPerUnit` OF THE SAME PROFILE OBJECT) — `null` on every real freshly-parsed profile, but promotes the check to a genuine `abweichung` when it fires. - `total_production_costs_cascade` `totalProductionCosts` is `materialCosts.value + manufacturingCosts.value`, computed by `generateVirtualVariants` itself from the SAME two fields this check would recompute from — unlike standard-QAF's `reconciliation.ts` cascade checks (three genuinely separately-parsed Summary cells), the Multi-QAF analog is tautological by construction. - `formula_identity_material_rows` / `formula_identity_profiles` records `buildFormulaProvenance` signatures for audit-trail purposes (Master-Prompt §13 \"compare formulas and cached values separately\") and promotes to `abweichung` on a genuine cachedValue-vs-parsed-value drift (row-level analog of the profile- level `cachedValueDrift` pattern); otherwise `nicht_pruefbar/ same_source_no_independent_check` when a formula IS present (same reasoning as above), or `nicht_pruefbar/fehlende_daten` when NO row/profile carries a formula at all (literal-only — genuinely informative: \"this total is not formula-derived\"). SAME-VALUE / no independent counter-source at all (KAR-951 F2 fix, review finding — `nicht_pruefbar`/`same_source_no_independent_check` when the field HAS a value, `nicht_pruefbar`/`fehlende_daten` when it doesn't; never a fake `bestanden`, same discipline the SAME-SOURCE bucket above establishes, just with zero identically-derived pair instead of a tautological one): - `scrap`, `surcharges`, `offer_base_price`, `offer_price` (`VirtualVariantSummaryTotals. scrap`/`otherSurcharges`/ `offerBasePrice`/`offerPrice`, container-assembly.ts's own KAR-951 fix: extracted from the Summary sheet's own label-anchored row scan (profile-parser.ts `locateSummaryMoneyRow`) when that row/variant-column exists, `null` otherwise. Either way there is only ONE Summary-sheet-sourced value — no second, independently-parsed detail-sheet source to reconcile it against exists in this package yet, so a non-null value is `same_source_no_ independent_check` (an honest \"this exists, but we cannot verify it\"), not the `fehlende_daten` \"this doesn't exist\" this module used to report even when the UI's own Summary-Kennzahlen section already showed a real amount — see `summaryMoneyFieldCheck`'s own doc comment). - `volumes` `VariantDefinition.annualVolume`/ `peakVolume`/`lifetimeVolume` have no second, independently-parsed counter-source in the current domain model (a volumeBand threshold-membership cross-check against a `SharedCostProfile` `kind: 'volumeBand'` profile would be a plausible FUTURE independent signal, but inventing an unvalidated \"peak >= annual\"-style plausibility rule today would be exactly the kind of fabricated check the honesty doctrine forbids — documented as a real gap, not silently worked around). Master-Prompt §13's own list literally has BOTH \"manufacturing total\" AND \"manufacturing-cost total\" as separate bullets (verbatim duplication in the source document, not a digest error) — there is only ONE manufacturing-cost field in the domain model (`VirtualVariantSummaryTotals.manufacturingCosts`), so both bullets collapse onto the single `manufacturing_total` check. The \"any additional cost elements present in the file\" bullet is realized by `total_production_costs_cascade` — the one additional summary field the domain model actually carries beyond the core 9. The \"Reconcile:\" cascade list maps onto the checks above as follows: \"detail sheets to summary\" -> `material_total_detail_to_summary`; \"matrix calculation to variant material total\" -> `material_total_matrix_ independent`; \"manufacturing profile to variant manufacturing total\" -> `manufacturing_total`; \"setup-cost sheet to applied setup cost\" -> `setup_cost_allocation`; \"shared profile assignments to summary formulas\" -> `manufacturing_total`/`setup_cost_allocation`/`tooling_fixture_cost` together (all three ARE exactly \"a shared profile assignment reconciled against the summary formula slot it feeds\", bucketed by profile kind — one mechanism, three bucket instances, not three separate cascades). ── Formulas vs. cached values, kept separate (Master-Prompt §13 \"Compare formulas and cached values separately. A matching cached value does not make a changed formula equivalent.\") ───────────────────────────────────── Within ONE container there is no second formula to diff an identity against (that is ALT/NEU territory — `formula-engine.ts`'s own `compareFormulaPair` is deliberately NOT used here, see that function's own doc). The single-container analog this module implements instead: for every `formulaAndCachedValue`-bearing field with a non-null `.formula`, `buildFormulaProvenance(formula)` computes a stable hash/normalized signature, recorded on the check's own `formulaSignatures` field as audit- trail evidence — a SEPARATE fact from the value-drift check that sits next to it, never silently substituted for it. ── Helper reuse (task requirement: \"gemeinsame Helper exportieren statt kopieren wo sinnvoll\") ───────────────────────────────────────────────────── `reconciliationEntries` (exported from profile-differ.ts for exactly this reuse) computes the manufacturing/setupCost/commonTooling \"bound profile value vs. VirtualVariant total\" pair, including `cachedValueDrift`, ONCE per container — this module calls it once and filters by variantId/bucket rather than re-deriving that same-source-vs-drift logic a third time (`reconciliation.ts` has its own first copy for standard-QAF, profile-differ.ts a second for the ALT/NEU case, this module's the third consumer of profile-differ.ts's shared implementation, not a fourth from-scratch copy). `generateVirtualVariants` (container-assembly.ts) is reused verbatim, never re-derived. `material-matrix-parser.ts`'s own row-level SUMPRODUCT reconciliation is NOT re-invoked — it requires raw grid access `MultiQafContainer` does not retain; this module reads its ALREADY-COMPUTED outcome via `container.warnings` instead (see coverage matrix above). The local `withinTolerance`/`numOrNull`/`round4` one-liners below are a FOURTH intentional per-module copy of the same tiny formulas (`reconciliation.ts`, `material-matrix-parser.ts`, and profile-differ.ts each already keep their own unexported copy) — established codebase precedent (see profile-differ.ts's own module header), not an oversight. Fail-closed: `reconcileVirtualVariant` refuses to reconcile a `variant` that does not verifiably originate from the given `container` (fingerprint + identity mismatch) rather than silently computing a possibly-wrong cross-reference — every check reports `nicht_pruefbar` in that case, with reason `unmatched_variant` (a comparison WAS possible and disagreed, or the variantId isn't among the container's own variants) or `fingerprint_unavailable` (KAR-940 adversarial review F2: a `null` fingerprint on EITHER side means no comparison could even be attempted — two un-fingerprinted sides are NOT treated as a match; `matchToContainer` rejects that case before the `!==` comparison ever runs). Determinism: every check list is sorted by `checkId`, every `reconcileContainer` result list by `variantId` — identical input always produces an identical output object. `reconcileContainer` builds its `VariantReconciliationContext` (`reconciliationEntries` + `profileById`) ONCE per container and threads it through every variant's `reconcileVirtualVariant` call — KAR-940 adversarial review F3: the naive per-variant re-derivation was O(N²) for N variants, not the O(N) this module documents. No ingest/UI wiring in this PR (the P2-compare-flow that wires P2.1-2.6 together is the FIRST P3 item).",
      "mentions_todo_or_future": true,
      "pure_or_no_io": false,
      "test_reference": true,
      "public_signatures": "export type VariantReconciliationStatus = | export type VariantReconciliationNichtPruefbarReason = | export type VariantReconciliationCheckId = | export interface VariantReconciliationToleranceBand { | export type VariantReconciliationEvidenceSource = | export interface VariantReconciliationCheck { | export interface VariantReconciliationResult { | export const ALL_VARIANT_RECONCILIATION_CHECK_IDS: readonly VariantReconciliationCheckId[] = | export function deltaInvariantHolds(check: VariantReconciliationCheck): boolean { | export interface VariantReconciliationContext { | export function reconcileVirtualVariant( | export function reconcileContainer(",
      "source_heading_line": 5486,
      "source_end_line": 5744
    },
    {
      "path": "lib/qaf-differences/internal/multi-qaf/variant-vs-standard.ts",
      "category": "core",
      "declared_lines": 554,
      "comment_summary": "Multi-QAF-Variante <-> Standard-QAF-Vergleich (KAR-943 / Multi-QAF-Programm P3.2, Epic KAR-925). Master-Prompt §15 Szenario B: EINE explizit gewählte Multi-QAF-Variante wird als virtuelles Standard-QAF durch die BESTEHENDE Standard-Vergleichs-Engine geschickt — NIE ein ganzer Container gegen ein Standard-QAF (that is a category error the bridge itself cannot paper over: a MultiQafContainer has N variants, a standard `compareQafPair` call is for exactly ONE part number). compare-flow.ts's `runMultiQafCompareFlow` (KAR-942) is the sibling MQ<->MQ orchestrator; THIS module is the MQ-variant<->standard one — deliberately a SEPARATE function/result type, not an overload of either, because the two comparisons answer genuinely different questions with a genuinely different honest-coverage matrix (see below). ── Why this is NOT \"just call compareQafPair with a Partial<QafFileParsed>\" ──────────────────────────────────────────────────────────────────────────── bridge.ts's `toCanonicalInputs(variant)` returns `Partial<QafFileParsed>` with ONLY `summary`/`summaryMetrics` populated — it deliberately never fabricates `ref`/`steps` (see that module's own header: `steps` has NO per-variant equivalent in the Multi-QAF domain model at all, manufacturing cost is a SHARED profile selected via a VariantProfileBinding, not an independent per-variant process list, Master-Prompt §12). The KAR-929 review warning this module exists to resolve: a downstream consumer that takes that Partial and feeds it to `compareQafPair` UNCHECKED either crashes (TypeScript would need a cast past `QafFileParsed`'s mandatory `ref`/`steps`) or, worse, silently computes something WRONG if a caller \"fixed\" the type error by fabricating `steps: []`: `checkPlausibility`'s currency check (`distinctCurrencies(altSteps)` vs. `distinctCurrencies( neuSteps)`) would then report a spurious `currency_change` on EVERY SINGLE comparison (empty set vs. the real side's actual currencies almost always differ in SIZE alone), and `matchStepsWithPins` would report the standard file's entire process list as `new_step`/`removed_step` — neither is a real finding, both are artifacts of feeding the step-matching engine an empty array that means \"no steps to compare\", not \"steps genuinely unchanged\". This module NEVER does that: it only ever calls the specific standard-engine functions that operate on `QafSummary`/`SummaryMetricsParse` alone (`checkSummaryIdentityPlausibility`, extracted from `checkPlausibility` in this same PR for exactly this reuse; `diffSummaryMetrics`, called unmodified — its own `undefined`/`null`-tolerant, \"skip both-absent, `neu`/ `entfallen` for one-sided\" contract already IS the honest degradation this task requires, no wrapping needed). ── Module-Bedienbarkeits-Matrix (task-mandated \"which standard-compare modules run for real vs. degrade honestly\") ────────────────────────────── REAL (this module actually calls the standard engine's own function): - Summary-Identitäts-Vergleich checkVariantVsStandardIdentity, this module's own thin wrapper around checkSummaryIdentityPlausibility (variant_changed/quotation_date_order — the fields bridge.ts's virtualVariantSummary maps for REAL). part_number_mismatch/part_name_changed are EXCLUDED (KAR-943 adversarial- review F1): bridge.ts's own SYNTHETIC_IDENTITY_FIELDS marks partNumber/partName as synthetic placeholders, never a real customer identity — comparing them 1:1 against a real standard QAF's identity would false-alarm on practically every legitimate comparison. partNumber's exclusion is replaced by an always- present informational finding (`part_number_comparison_not_ applicable`); see checkVariantVsStandardIdentity's own doc comment below. - SummaryMetrics-Vergleich diffSummaryMetrics (the 8 VirtualVariantSummaryTotals-backed canonical metrics bridge.ts maps for real diff genuinely; the 12 metrics bridge.ts leaves ABSENT surface honestly as one-sided 'neu'/'entfallen' findings via that function's own existing, unmodified contract — never a fabricated 0-diff). STRUCTURALLY DEGRADED (this comparison never had a source for these — `DEGRADED_MODULES` below, own finding class, never crashes, never silently reports \"no findings\" as if it had checked): - manufacturing_steps Fertigungs-Prozessvergleich (matchStepsWithPins/ diffSteps) — no per-variant process list exists (bridge.ts's own \"steps\" reasoning above). - material/sbm/rmr/logistics/lccn/co2e no per-variant detail-sheet row source exists in the Multi-QAF domain model (bridge.ts's own \"DELIBERATELY LEFT ABSENT\" list for the same reason). - reconciliation/business_rules/rule_engine every one of these reads `steps` (checkReconciliation/evaluateBusinessRules/ evaluateRuleEngine all take a `steps` field) — same structural gap as manufacturing_steps. (The variant's OWN internal consistency IS independently checked, just not by THIS module — see \"Relationship to variant-reconciliation.ts\" below.) - root_cause computeRootCause's `StepDiffSummary[]` input is derived from `stepComparisons` — no source. - workbook_safety `WorkbookSafetyResult` is captured once at INGEST for a real uploaded file (runPreParseWorkbookSafetyCheck) — a virtual variant is a computed projection, not a re-parsed workbook, so it has none of its own (the container's OWN workbookSafety, if any, already surfaced when that Multi-QAF file was ingested — not re-derived or re-reported here). ── Relationship to variant-reconciliation.ts (KAR-940) ───────────────────── `reconcileVirtualVariant`/`reconcileContainer` already independently verify ONE container's own internal consistency (Master-Prompt §13) — that is a DIFFERENT question (\"does this variant's own summary agree with its own detail data\") from what this module answers (\"how does this variant compare against a chosen standard QAF\"). This module does not re-run or duplicate that reconciliation; a caller that also wants it calls `reconcileContainer` separately (compare-flow.ts's `runMultiQafCompareFlow` already does the analogous thing for the MQ<->MQ case, calling `reconcileContainer` per side alongside — not instead of — the cross-container diffs). ── Fail-closed reviewRequired propagation (task requirement 3) ───────────── `reviewRequired` is `true` when EITHER: (a) `reviewStatusForMultiQafContainer(container) === 'review_required'` (container-assembly.ts's own gate — ANY container warning with `severity: 'critical'` OR `reviewRelevant: true`, e.g. a canonical-key collision or an ambiguous cost-profile binding anywhere in the container, not necessarily tied to THIS variant); OR (b) THIS variant's own `warnings` (container-assembly.ts's `variantWarningsFor` — includes the KAR-936 aggregation-gate findings `material_variant_fuzzy_match_below_aggregation_gate` / `material_variant_fuzzy_matched_to_summary`, both `reviewRelevant: true`) carry a `severity: 'critical'` or `reviewRelevant: true` entry — i.e. this SPECIFIC variant's material data is fuzzy-matched or below the KAR-936 financial-aggregation gate; OR (c) `summaryIdentityIssues` (post-F1-filter, see checkVariantVsStandardIdentity above) carries a `severity: 'kritisch'` entry (KAR-943 adversarial-review F3 — this was silently ignored before this fix, the one class of \"the comparison itself found something critical\" that never flipped `reviewRequired`). Never suppressed, never downgraded — the comparison still RUNS and returns a full result (never blocks), but `reviewRequired`/`variantWarnings`/ `containerReviewWarnings` make the uncertainty visible on the result itself, same \"run but flag\" discipline `diffContainers`'/ `runMultiQafCompareFlow`'s own `reviewRequired` already establish. tdd-guard:skip — this file's own orchestration body is a thin, deterministic composition of already-unit-tested pure functions (generateVirtualVariants, toCanonicalInputs, checkSummaryIdentityPlausibility, diffSummaryMetrics, reviewStatusForMultiQafContainer) plus a fixed, versioned degraded-module catalog — covered by variant-vs-standard.test.ts (synthetic container + synthetic QafFileParsed) and variant-vs-standard.real-files.test.ts (env-gated real-file regression), same category as compare-flow.ts's own tdd-guard:skip.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export const MULTI_QAF_VARIANT_VS_STANDARD_RESULT_VERSION = 1 as const | export type VariantVsStandardDegradedModuleKey = | export interface VariantVsStandardDegradedModuleFinding { | export const DEGRADED_MODULES: readonly VariantVsStandardDegradedModuleFinding[] = [ | export type VariantVsStandardReviewRequiredReason = | export function checkVariantVsStandardIdentity(standardSummary: QafSummary, variantSummary: QafSummary): readonly PlausibilityIssue[] { | export function hasCriticalIdentityIssue(issues: readonly PlausibilityIssue[]): boolean { | export interface VariantVsStandardComparisonResult { | export interface RunVariantVsStandardCompareOptions { | export function runVariantVsStandardCompare( | export function serializeVariantVsStandardComparisonResult(result: VariantVsStandardComparisonResult): string { | export function deserializeVariantVsStandardComparisonResult(json: string): VariantVsStandardComparisonResult {",
      "source_heading_line": 5745,
      "source_end_line": 5905
    },
    {
      "path": "lib/qaf-differences/internal/negotiation-levers.ts",
      "category": "core",
      "declared_lines": 225,
      "comment_summary": "Verhandlungshebel aus Regeln (Spezifikation Kap. 7.9, R-20; Befund F-16). In der Vorgängerfassung standen unter „Hebel\" Sätze ohne Betrag und ohne Quelle. Damit kann niemand in eine Verhandlung gehen: Ein Hinweis, dessen Wirkung unbeziffert bleibt, wandert in der Vorbereitung nach hinten, egal wie berechtigt er ist. Deshalb gilt hier eine Regel, die der ganzen Datei ihre Form gibt: **Der Betrag eines Hebels ist die Summe seiner Belege.** Er wird nicht danebengeschrieben, sondern aus den Differenzen gerechnet, auf die der Hebel verweist. Eine Zahl, die sich nicht aus ihren Belegen ergibt, ist bei der ersten Rückfrage weg — und mit ihr die Glaubwürdigkeit des ganzen Berichts. Die Texte kommen aus Vorlagen mit eingesetzten Parametern. Kein Freitext, keine KI — die Formulierung muss über hundert Vergleiche hinweg dieselbe sein, sonst liest sie niemand mehr als Kategorie, sondern als Einzelmeinung. Pure Funktionen, keine I/O.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": false,
      "public_signatures": "export type LeverRule = | export type LeverUnit = 'EUR' | 'EUR_per_piece' | export interface Lever { | export const LEVER_RULES: Readonly<Record<LeverRule, RuleSpec>> = { | export interface LeverInput { | export function buildLevers(inputs: readonly LeverInput[], records: readonly DifferenceRecord[]): Lever[] { | export interface LeverProblem { | export function validateLevers(levers: readonly Lever[], records: readonly DifferenceRecord[]): LeverProblem[] {",
      "source_heading_line": 5906,
      "source_end_line": 5934
    },
    {
      "path": "lib/qaf-differences/internal/normalizer.ts",
      "category": "core",
      "declared_lines": 139,
      "comment_summary": "QAF Normalizer (KAR-799, spec A5.3 / B-Normalizer). Unifies raw values WITHOUT altering business meaning: - German/English number formats → number | null - empty (\"\") kept distinct from real 0 - position numbers stay text (\"2a\" stays \"2a\") - process/machine names normalized for matching (original kept elsewhere) - currencies trimmed/uppercased All functions are pure. Distinct from qaf-parser::toNum, which only coerces already-numeric ExcelJS cells — this handles locale-formatted strings.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": false,
      "public_signatures": "export function isBlank(v: unknown): boolean { | export function parseLocaleNumber(raw: unknown): number | null { | export const NOT_APPLICABLE_MARKERS: readonly string[] = [ | export function isNotApplicableValue(raw: unknown): boolean { | export function normalizeProcessName(raw: unknown): string { | export function normalizePosition(raw: unknown): string { | export function normalizeCurrency(raw: unknown): string | null {",
      "source_heading_line": 5935,
      "source_end_line": 5956
    },
    {
      "path": "lib/qaf-differences/internal/overview-run.ts",
      "category": "core",
      "declared_lines": 238,
      "comment_summary": "Der Lauf hinter der Übersichts-Sektion (Spezifikation Kap. 21, View-Phase V1). Wozu: Die Anzeigeebene war fertig, aber an keine Datenquelle angeschlossen — `buildKpiTiles`, `buildWaterfall` und `validationBanner` hatten zusammen null Aufrufe ausserhalb von Tests. Dieses Modul ist das fehlende Bindeglied: aus zwei geöffneten Arbeitsmappen entsteht, was die Übersicht zeigt. Drei Entscheidungen prägen die Datei: 1. **Die Brückenschritte kommen aus der Zeilenrollen-Registry**, nicht aus einer Liste hier. `additiveRowSpecs()` ist laut eigener Dokumentation „die einzige zulässige Grundlage für Preisbrücke und Additivitätsprüfung. Wer eine Brücke aus etwas anderem baut, baut wieder F-01.\" Eine zweite Liste hier wäre genau diese zweite Wahrheit. 2. **Gerechnet wird nichts selbst.** Deltas, Zustände und die Frage, ob die Brücke schliesst, entscheiden `diffSummaryMetrics` und `buildWaterfall`. Bleibt ein Rest, zeichnet die Anzeige die Diagnose — dieses Modul korrigiert das nicht weg. 3. **Fehlende Blätter sind ein Befund, kein Grund zu schweigen.** Ohne Preisblock gibt es keinen Lauf, aber eine benannte Ursache. Pure Funktionen, keine I/O: die Mappen kommen geöffnet herein.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export interface OverviewRunInput { | export interface OverviewRun { | export function preisblockAusMappe(mappe: Mappe): SummaryMetricsParse | null { | export function buildOverviewRun(input: OverviewRunInput): OverviewRun {",
      "source_heading_line": 5957,
      "source_end_line": 5984
    },
    {
      "path": "lib/qaf-differences/internal/persistence-mapper.ts",
      "category": "core",
      "declared_lines": 301,
      "comment_summary": "QAF persistence mapper (KAR-799, spec B10 / B4). Pure transformation: engine comparison result -> DB-shaped row objects that match the qaf_* migration columns. Step matches and field diffs are intentionally NOT produced here: their DB rows use UUID foreign keys (alt_step_id / neu_step_id / step_match_id) that only exist after the step + match rows are inserted, so the server action builds them inline once it has resolved those ids. This mapper covers the rows whose shape is fully known ahead of insertion. Net-new. Keeping this pure means the whole persistence shape is unit-tested without a database.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export interface PersistenceContext { | export interface ComparisonRow { | export interface StructureChangeRow { | export interface PlausibilityIssueRow { | export interface RootCauseRow { | export interface SummaryDiffRow { | export interface SummaryMetricRow { | export interface ComparisonRowset { | export function buildComparisonRowset(result: QafComparisonResult, ctx: PersistenceContext): ComparisonRowset { | export interface ManufacturingStepRow { | export function buildManufacturingStepRow( | export const RECOMPARE_REPLACED_CORE_TABLES = [ | export function recompareReplacedTables(refreshPlausibility: boolean): readonly string[] { | export function buildSummaryMetricRows(",
      "source_heading_line": 5985,
      "source_end_line": 6014
    },
    {
      "path": "lib/qaf-differences/internal/plausibility.ts",
      "category": "core",
      "declared_lines": 271,
      "comment_summary": "QAF Plausibility check (KAR-799, spec B12). Deterministic sanity checks across the summary identity and the process steps of an ALT/NEU comparison. Each finding carries a severity so the UI and export can surface \"Hinweis / Prüfen / Kritisch\". Pure. Net-new. Some summary-metric checks (e.g. FK detail sum vs summary total) need the summary-metric parser and are deferred to a later step.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": false,
      "public_signatures": "export type PlausibilitySeverity = 'hinweis' | 'pruefen' | 'kritisch' | export interface PlausibilityIssue { | export interface PlausibilityInput { | export interface ManufacturingParseDegradation { | export function manufacturingParseDegradationToPlausibilityIssue( | export function manufacturingIgnoredCandidatesToPlausibilityIssue( | export function checkSummaryIdentityPlausibility(altSummary: QafSummary, neuSummary: QafSummary): PlausibilityIssue[] { | export function checkPlausibility(input: PlausibilityInput): PlausibilityIssue[] {",
      "source_heading_line": 6015,
      "source_end_line": 6034
    },
    {
      "path": "lib/qaf-differences/internal/product-line-tags.ts",
      "category": "core",
      "declared_lines": 25,
      "comment_summary": "Product-line tag derivation (KAR-848): derivative codes like G60, G5x, // allow-customer-string NA0 from the uploaded file names — auto-tags a comparison; the user can edit them afterwards. Pure + unit-tested.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export function deriveProductLineTags(fileNames: string[]): string[] {",
      "source_heading_line": 6035,
      "source_end_line": 6044
    },
    {
      "path": "lib/qaf-differences/internal/projection.ts",
      "category": "core",
      "declared_lines": 67,
      "comment_summary": "Projection / annual extrapolation (KAR-840, V11 section 5 — buildProjection + projTotals). Deterministic: gross = Δ/unit × volume; the defend quota (Abwehrquote) reduces only cost INCREASES (V11 gross>0 guard) — savings are never \"defended away\". Editable state (years, volumes, override, quota) lives in the client component; this module is pure math.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": false,
      "public_signatures": "export interface ProjectionYearInput { | export interface ProjectionYear extends ProjectionYearInput { | export interface ProjectionTotals { | export interface Projection { | export function defaultProjectionYears(startYear: number, n = 5): ProjectionYearInput[] { | export function computeProjection(",
      "source_heading_line": 6045,
      "source_end_line": 6061
    },
    {
      "path": "lib/qaf-differences/internal/qaf-type-detector.ts",
      "category": "core",
      "declared_lines": 642,
      "comment_summary": "Multi-QAF type detection + safe degradation (KAR-926 / Multi-QAF-Programm P0.1, Epic KAR-925). Problem this closes (20-capability-matrix-ist.md, Ist-Capability-Probe against 4 real Multi-QAF files, 12.07.2026): Kadi-v2's existing engine has NO concept of \"Multi-QAF\"/\"Variante\" at all — every parser assumes exactly one value column per metric (locateAwColumn, summary-metrics.ts:318) and exactly one candidate sheet per module (.find(), not .filter()). Fed a real Multi-QAF workbook (one file, N product variants side by side), the commercial-delta layer does not fail loudly — it silently reads a plausible -looking but WRONG cell (a location string, a piece count, an unresolved formula reference) with a reported confidence of 0.6, and the MATERIAL parser returns 0 rows from a 7000+-row sheet (coreFieldsFound:false) on 3 of 4 real sample files. That is the single worst failure class Master- Prompt §7 names: \"parsed but semantically incorrect\" wrong numbers with no visible error. This module is the FIRST guard against it — detection only (P0.1); actually parsing Multi-QAF variant containers is P1+ (backlog 30-backlog-phasenplan.md), explicitly out of scope here. Design discipline (Master-Prompt §7, task instruction): NEVER classify from the file NAME. Every signal below reads workbook STRUCTURE — cell text, formula text, populated-cell counts in a bounded region — never `upload.name`. Four independently-weighted signals, each individually gated at strong/weak/none, combined into one of four classifications: confirmed_multi_qaf — S1 hit, OR >=2 strong signals. probable_multi_qaf — exactly 1 strong signal, EXCEPT S3 alone (S3 has only negative calibration against the real corpus, never proven as a positive signal on its own — see F3 fix note at the classifier below; S3 strong still counts toward confirmed_multi_qaf IN COMBINATION with another strong signal). ambiguous — no strong signal but >=1 weak signal, OR S3 is the ONLY strong signal (demoted, not promoted to probable — see above). standard_qaf — no signal at all (the overwhelming common case). Conservative by design: \"im Zweifel standard_qaf NICHT antasten\" — a false interception of a genuine standard QAF is the worst possible outcome of this PR (worse than under-detecting a real Multi-QAF file, which just falls through to today's already-known-unsafe behaviour, not a NEW regression). Every threshold below is deliberately calibrated against the real 4-Multi-QAF + real-standard-QAF-corpus regression fixture (see __tests__/qaf-type-detector.real-files.test.ts, env-gated) — not guessed. The single most important false-positive guard (task instruction, verified against summary-metrics.ts): a normal standard QAF has EXACTLY ONE quotation-currency (\"AW\") value column, selected out of a small M..R (columns 12-17, HEADER_ROW=9 0-based / row 10) candidate band by locateAwColumn — plus at most 3 populated header cells in that SAME row/ column region (the AW1/AW2/AW3 currency-code header labels a clean template prints regardless of which one is actually selected). That is structurally identical in SHAPE to this module's own \"populated cells in a row\" signal (S2/S4) — so this module's variant-column-band scan deliberately starts at column S (index 18), one column PAST summary-metrics.ts's AW_SCAN_TO (index 17), and scans a DIFFERENT set of rows (1..HEADER_SCAN_ROWS, not just row 10) — a standard QAF's AW1-3 header band can never itself trigger S2/S4 because it never enters the scanned column range at all. See real-file regression test for the actual proof (0 false positives across the real standard-QAF corpus). Evidence sources (20-capability-matrix-ist.md + 10-analyse-*.md, all server-only, real Multi-QAF files under /root/aria/work/qaf-compare-kar824/input/ — never committed; the 4 real files are referred to below only as File 1-4, never by name — see __tests__/qaf-type-detector.real-files.test.ts's own confidentiality- discipline note for why): S1 (Zusammenfassung!B4 or B5, \"M-QAF Version 1.0\"/\"M-QAF Version 2.0\"): confirmed on File 1 (B5, \"M-QAF Version 2.0\"), File 2 (B5, \"M-QAF Version 1.0\"), File 3 (B4, \"M-QAF Version 1.0 by tkS\") — 3 of 4 files. Cell position genuinely varies (B4 vs B5) between files, so this module scans a bounded header region (rows 1..HEADER_SCAN_ROWS, all columns) rather than a single hardcoded cell — same \"cell-bound, not a single fixed address\" discipline foreign-form-detection.ts already established for its own header-title signatures. S2/S4 (variant-column-band population / multi-row header block): File 1 Zusammenfassung Row 2 carries a numbered slot index 1..25 (with gaps) across columns Q-Z/AF-AT — 25 populated cells in one row. File 4 has NO M-QAF marker at all but its Zusammenfassung header block spans rows 3-12 (vehicle/drive/ratio/FIT-rate/LL-RL/volume.../variant-code), each row populated across 26 columns (Q-AP) — the same \"many populated cells in a row\" shape as File 1's numeric index row, just with text/formula content instead of a bare index number (this module deliberately does not require the value to be numeric — see module header \"Design discipline\" citing both examples together in the original task brief). File 3's MATERIAL sheet header block is 9 rows deep (4-12) — the canonical multi-row-header-block example this module's S4 threshold is named after. S3 (Mengenfaktor-Matrix / SUMPRODUCT-SUMIF-Spuren): File 1's MATERIAL sheet sums each variant's BOM total via `R121=SUMPRODUCT($O$19:$O$120, R19:R120)`, repeated per variant column (R, S, T, ... — 12+ distinct columns). File 3's MATERIAL sheet: `N81=SUMPRODUCT($K$19:$K$80, N19:N80)`, same per-variant-column repetition. File 4's material- equivalent sheet uses `M19=SUMIF(N19:AO19,\">0\",$N$11:$AO$11)*L19` — a SINGLE column (M) referencing a wide row-range, so S3 correctly stays weak/none for File 4 (it reaches confirmed via S2+S4 instead, not S3 — see 20-backlog-phasenplan.md P0.1's own \"hat KEINEN M-QAF-Marker, muss über S2/S3/S4 kommen\" note for that file). Pure core (MultiQafDetectionInput, no ExcelJS) + a thin ExcelJS bridge at the bottom — same \"pure detection logic / ExcelJS adapter at the edge\" split as foreign-form-detection.ts / g60/parser.ts+bridge.ts.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export interface MultiQafDetectionConfig { | export const DEFAULT_MULTI_QAF_DETECTION_CONFIG: MultiQafDetectionConfig = { | export interface MultiQafDetectionInput { | export type MultiQafSignalId = 'versionMarker' | 'variantColumnBand' | 'materialFactorMatrix' | 'multiRowHeaderBlock' | export type MultiQafSignalStrength = 'strong' | 'weak' | 'none' | export interface MultiQafSignalFinding { | export type MultiQafClassification = 'confirmed_multi_qaf' | 'probable_multi_qaf' | 'ambiguous' | 'standard_qaf' | export interface MultiQafDetectionResult { | export const M_QAF_VERSION_MARKER_PATTERN = /M-QAF\\s+Version\\s+([12])(?:\\.0)?/i | export function detectMultiQaf( | export function multiQafRejectionMessage(result: MultiQafDetectionResult): { reasonDe: string; reasonEn: string } | null { | export function multiQafDetectionToPlausibilityIssue( | export function multiQafDetectionMetaFragment( | export const HEADER_SCAN_ROWS = 20 | export const VARIANT_BAND_COL_FROM = AW_SCAN_TO + 1 // column S | export const VARIANT_BAND_COL_TO = 80 // generous headroom past every real sample's last variant column in the corpus | export const MATERIAL_FORMULA_SCAN_ROWS = 200 | export function multiQafDetectionInputFromExcelJs(wb: { worksheets: Worksheet[] }): MultiQafDetectionInput {",
      "source_heading_line": 6062,
      "source_end_line": 6178
    },
    {
      "path": "lib/qaf-differences/internal/reconciliation.ts",
      "category": "core",
      "declared_lines": 922,
      "comment_summary": "QAF Summen-Rekonziliation Summary <-> Fertigungskosten-Zeilen (KAR-887 / P0.2). Deterministic, pure cross-check layer between the summary/Zusammenfassung sheet's aggregate money metrics (summary-metrics.ts) and the individual Fertigungskosten process-step rows (lib/qaf-parser.ts). Today nothing verifies that Sigma(row.fkAW) actually equals summary.manufacturingCosts, or that the summary's own cost cascade (Materialkosten + Fertigungskosten = SUMME HERSTELLKOSTEN etc.) is internally consistent — Master-Prompt §12.5 (\"summary totals reconcile with detail sheets\") and the Leitfaden's formula chain ([12]/[13], 02-leitfaden-teil1.md) require exactly that. Runs PER FILE (ALT and NEU independently, not diffed against each other — distinct from plausibility.ts which cross-checks ALT vs NEU). Pure, no I/O. ── Implemented checks ────────────────────────────────────────────────────── (a) Detail-sum checks (row-level Sigma vs. a single summary metric): - fk_detail_sum: Sigma(row.fkAW) vs. manufacturingCosts - scrap_manufacturing_detail_sum: Sigma(row.ausschusskosten) vs. scrapManufacturing Uses the AW-denominated fields (fkAW/ausschusskosten), NOT the BW fields (fk) the backlog item's prose names in passing — summaryMetrics.manufac- turingCosts is read from the AW-currency column (summary-metrics.ts locateAwColumn), while `fk` is \"Fertigungskosten FK [BW]\" (Beschaffungs- waehrung) and `fkAW` is explicitly \"Fertigungskosten in Angebotswaehrung\" (Leitfaden [31]) — summing the BW field against an AW total would silently compare two different currencies whenever BW != AW or wechselkurs != 1. ausschusskosten is already AW-only in the row model (no BW counterpart). (b) Kaskaden-Rekonziliation (summary-internal, no detail rows needed — every field below is one of the 19 SUMMARY_METRIC_KEYS in summary-metrics.ts): - herstellkosten_cascade: materialCosts + manufacturingCosts = totalProductionCosts (Leitfaden [12]) - gesamtkosten_cascade: totalProductionCosts + devicesAndTools + scrapMaterial + scrapManufacturing = totalCosts (Leitfaden [13]) - angebotsbasispreis_cascade: totalCosts + otherSurcharges = quotationBasePrice (Leitfaden [13]) - angebotspreis_cascade: quotationBasePrice + rawMaterialPriceShareMaterial + rawMaterialPriceShareEnergy + customsSupplierToBMW + transportSupplierToBMW = quotationPrice (Leitfaden [13]) ── Materialkosten-Detail-Rekonziliation (KAR-897/P1.6 follow-up) ────────── Sigma(row.materialCost) vs. summary.materialCosts — the check this file's header used to list under \"Deliberately NOT implemented\" (P0.2 explicitly deferred it \"bis P1.6 existiert\"). Runs ONLY when the caller actually attempted a MATERIAL parse for this side (input.materialRows !== undefined — see ReconciliationInput doc comment below): a caller that never touches materialRows (every pre-P1.6 test/call site, rehydrate.ts, which does not reconstruct MATERIAL rows) gets no new check and no new issue at all — the P0.2-era \"fully consistent summary -> zero issues\" guarantee stays intact for anyone who hasn't opted into MATERIAL parsing. ── Deliberately NOT implemented (documented TODO, not guessed) ──────────── ── SBM-Detail-Rekonziliation (KAR-898/P1.7) ──────────────────────────────── Sigma(row.totalToolFixtureCostAw) vs. summary.devicesAndTools — the check this file's header used to list under \"Deliberately NOT implemented\" (P0.2 explicitly deferred it \"nach P1.7\"). Same tri-state/currency-guard/ n.a.-marker contract as material_detail_sum above (sbmRows: undefined = no parse attempted, check skipped entirely; null = no SBM sheet found in the workbook at all OR the sheet was found but its header was too degraded to trust, sbm-parser.ts's sbmRowsForReconciliation() collapses both into this one value; SbmRow[] = the parsed rows, a CONFIRMED count including 0). One deliberate behavioural deviation from material_detail_sum: when sbmRows is a CONFIRMED-empty array (sbmRows.length === 0, i.e. the sheet WAS parsed successfully — intact header, genuinely zero data rows) AND summary.devicesAndTools is also absent/zero, the result is `bestanden` (0 == 0), NOT `nicht_pruefbar` — devicesAndTools is legitimately empty on the majority of real quotations (most parts carry no Sonderbetriebsmittel, but the SBM-DEVICES-FWZ tab is still part of the standard template with an intact, just-empty body), so treating that combination as a review-worthy finding would flood every plain comparison with a \"keine SBM-Zeilen vorhanden\" hint issue (task instruction: \"kein False-Positive-Spam\"). Critically, this shortcut applies ONLY to a CONFIRMED-empty array, never to `sbmRows === null` — a degraded/unreadable header is evaluated and reported `nicht_pruefbar` UNCONDITIONALLY, even when the summary side is also empty (adversarial-review finding on the original KAR-898 PR, confidence 82: an earlier version folded `sbmRows === null` into the same \"both sides empty\" shortcut, which let a degraded-but-possibly-costly sheet silently pass as clean whenever the summary happened to have no devicesAndTools value either — see evaluateSbmDetailSumCheck below for the corrected ordering). Everything else (including the already-known reconciliation staleness gap tracked separately under KAR-899) is intentionally left exactly as lookalike to material_detail_sum, not hardened further here (task instruction: \"baue hier exakt dasselbe (bekannt lueckenhafte) Verhalten wie material_detail_sum, nicht mehr und nicht weniger\"). AW-Waehrungsdimension (\"fkAW-Lehre\"): SBM rows carry their own quotationCurrency (Angebotswaehrung AW, Leitfaden [37]) independent of the MANUFACTURING/MATERIAL rows' currency — summing across a currency mismatch vs. the summary's currency is refused (nicht_pruefbar), identical guard to sumMaterialCost below. ── LOGISTICS-Detail-Rekonziliation (KAR-903/P2.4) ────────────────────────── Sigma(row.transportCostPerPart) vs. summary.transportSupplierToBMW (logistics_transport_detail_sum) and Sigma(row.customsCostPerDeliverySite) vs. summary.customsSupplierToBMW (logistics_customs_detail_sum). ── Evidenzlage + Korrektur (post-merge adversarial-review, Konfidenz 82) ── Die ursprüngliche Version dieses Kommentars zitierte einen Satz aus Leitfaden [39] (\"im Summary-Blatt als 'Enthaltene Zölle, CBAM' bzw. // allow-customer-string 'Logistikkosten Lieferant-BMW' aggregiert einfließend\"), der im // allow-customer-string Quellmaterial NICHT existiert — ein fabriziertes Zitat, ersatzlos entfernt (Verstoß gegen \"never silently guess\", Master-Prompt §2). Das darauf aufbauende Feld-Mapping war zusätzlich sachlich falsch: die tatsächlich real-file-verifizierten Summary-Labels (summary-metrics.ts SYNONYMS/ METRIC_LABELS_DE, gegen echte BMW-Dateien gematcht) heißen // allow-customer-string \"Transportkosten Lieferant - BMW\" (transportSupplierToBMW) und \"Zölle // allow-customer-string Lieferant - BMW\" (customsSupplierToBMW) — NICHT \"Logistikkosten // allow-customer-string Lieferant-BMW\" bzw. \"Enthaltene Zölle, CBAM\" (letzteres ist tatsächlich das // allow-customer-string Label eines ANDEREN Metrics, customsIncluded). Der Leitfaden selbst dokumentiert an keiner Stelle explizit, ob \"Transportkosten Lieferant - BMW\" im Summary-Blatt nur den Transport-Anteil oder die vollen // allow-customer-string Logistikkosten (Transport+Verpackung+Vorverpackung, [40]) abbildet — die 9 real verfügbaren BMW-Summary-QAFs unter // allow-customer-string /root/aria/work/qaf-compare-kar824/input/ (Brose FT_links/HT_links, Kiekert, Autoliv) enthalten AUSNAHMSLOS KEIN LOGISTICS&CUSTOM-Sheet (Sheet-Namen empirisch geprüft, 09.07.2026) — die Frage ist mit den aktuell verfügbaren Dateien nicht entscheidbar. Konservative, label-konsistente Wahl (kein Raten): dieser Check summiert `transportCostPerPart` (Transportkosten pro Bauteil Lieferant, [40]) — das Wort \"Transportkosten\" im Summary-Label deckt sich mit diesem Feld, nicht mit der breiteren \"Logistikkosten\"-Summe (die weiterhin rein row-intern, OHNE Summary-Pendant, per `log_calc_cost_per_delivery_site` in logistics-parser.ts geprüft wird — siehe dort). labelDe/labelEn der beiden Checks werden direkt aus METRIC_LABELS_DE abgeleitet statt erneut von Hand getippt, damit dieselbe Verwechslung nicht wieder unbemerkt auftreten kann. Sollte ein künftiges reales LOGISTICS&CUSTOM-File die kombinierte Semantik bestätigen, ist das eine neue, separat zu belegende Änderung — keine stillschweigende Korrektur im Nachhinein. Same tri-state/currency-guard/n.a.-marker contract as sbm_detail_sum above (logisticsRows: undefined = no parse attempted, both checks skipped entirely; null = an LOGISTICS parse WAS attempted but the file has none (or its header was too degraded to trust) — evaluated UNCONDITIONALLY as nicht_pruefbar, the SAME coreFieldsFound-Schutz ordering evaluateSbmDetail SumCheck's KAR-898-follow-up fix established (confidence 82 finding: a degraded/null basis must never share the \"both sides empty -> bestanden\" shortcut with a CONFIRMED-empty parsed array, task instruction \"beide-leer- Semantik wie sbm_detail_sum NUR mit dem in #274 gefixten coreFieldsFound- Schutz\"); LogisticsRow[] = the parsed rows, a CONFIRMED count including 0). AW-Waehrungsdimension (\"fkAW-Lehre\"): LOGISTICS rows carry their own quotationCurrency (Angebotswaehrung AW, Leitfaden [40]) independent of the MANUFACTURING/MATERIAL/SBM rows' currency — summing across a currency mismatch vs. the summary's currency is refused (nicht_pruefbar), identical guard to sumSbmToolFixtureCost/sumMaterialCost above. Both-sides-CONFIRMED-empty -> bestanden shortcut: same \"unauffaellig, not nicht_pruefbar\" reasoning sbm_detail_sum documents (most parts carry no customs cost at all, e.g. an intra-EU delivery route; the LOGISTICS&CUSTOM tab is still part of the standard template with an intact, just-empty body) — applies independently to EACH of the two checks (a file can legitimately have Transportkosten but zero Zollkosten, or vice versa). Severity bleibt 'pruefen' fuer beide Checks (DETAIL_CHECK_IDS-Zugehoerigkeit unten, wie jeder andere Detail-Sum-Check in diesem Modul) — nie 'kritisch'. Das war bereits vor dieser Korrektur so; die unbestaetigte Semantik ist ein zusaetzlicher Grund, hier bewusst NICHT zu eskalieren. ── Deliberately NOT implemented (documented TODO, not guessed) ──────────── - G60-Aggregat-Rekonziliation: G60 has its own aggregate logic/parser (internal/g60/*) and is explicitly out of scope for P0.2 (\"G60 hat eigene Aggregatlogik, siehe P1.3\"). - Rohstoffzuschlag RoZ0-Detail (Leitfaden [31]) has no persisted per-row field in QAFRowValues today — not summable, not guessed at.",
      "mentions_todo_or_future": true,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export type ReconciliationCheckId = | export type ReconciliationStatus = 'bestanden' | 'abweichung' | 'nicht_pruefbar' | export interface ReconciliationResult { | export interface ReconciliationInput { | export interface ReconciliationConfig { | export const RECONCILIATION_CONFIG: ReconciliationConfig = { | export function evaluateReconciliation( | export function reconciliationResultToPlausibilityIssue(r: ReconciliationResult): PlausibilityIssue | null { | export function checkReconciliation(",
      "source_heading_line": 6179,
      "source_end_line": 6340
    },
    {
      "path": "lib/qaf-differences/internal/reference-case.ts",
      "category": "core",
      "declared_lines": 381,
      "comment_summary": "Referenzfall der Anzeigeebene (Spezifikation Kap. 27, View-Phase V1; U-06). Wozu: Die V2-Anzeigeebene ist gebaut, aber noch an keine Datenquelle angeschlossen — es gibt in der App keinen Weg von zwei Dateien zu einem Lauf. Bis den Weg 1 der Integration liefert, ist dieser Fall die einzige Möglichkeit, die Ansichten überhaupt zu sehen und gegen U-01…U-08 abzunehmen. Zwei Entscheidungen prägen die Datei: 1. **Hier stehen Eingaben, keine Ergebnisse.** Die Kacheln, die Brücke und die Zellnachweise entstehen aus `buildKpiTiles`, `buildWaterfall` und `evidenceFor` — denselben Funktionen, die auch ein echter Lauf benutzt. Hartkodierte Ergebnisse würden zeigen, was sich jemand ausgedacht hat, statt was die Anzeigeebene tatsächlich tut. 2. **Zwei der drei Fälle zeigen absichtlich Unangenehmes.** Einer schliesst nicht — die Eigenschaft, für die dieses Modul gebaut wurde: dass es sich weigert zu zeichnen, wenn ein Betrag unerklärt bleibt (Befund F-01). Der andere hat kleine Schritte auf hohem Niveau, die übliche Lage bei einem echten QAF, in der die Brücke an ihre Darstellungsgrenze kommt. Eine Referenzansicht, die nur den dankbaren Fall zeigt, ist Werbung. FIXTURE-DATEN-REGEL: jeder Name, jede Zahl, jeder Blatt- und Zellbezug unten ist freie Erfindung. Diese Datei wird mit ausgeliefert; ein anonymisierter Abzug eines echten Laufs hat hier nichts zu suchen.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export interface ReferenceCase { | export const REFERENCE_CASE_OK: ReferenceCase = { | export const REFERENCE_CASE_BROKEN: ReferenceCase = { | export const REFERENCE_CASE_FLAT: ReferenceCase = { | export const REFERENCE_CASES: readonly ReferenceCase[] = [ | export interface ReferenceView { | export function buildReferenceView(fall: ReferenceCase): ReferenceView {",
      "source_heading_line": 6341,
      "source_end_line": 6373
    },
    {
      "path": "lib/qaf-differences/internal/rehydrate.ts",
      "category": "core",
      "declared_lines": 383,
      "comment_summary": "KAR-840 PR1: Rehydrate a QafExportInput from persisted qaf_* rows. The 8-sheet Excel export (buildQafExportWorkbook) was written to consume the in-memory QafComparisonResult produced during analyze. To offer the export as an on-demand download from the detail page, we reconstruct the engine inputs from what is already stored — no re-upload, no re-parse of the raw workbook: - qaf_manufacturing_step.raw_values holds the full QAFRow per step - qaf_part holds the identity fields (part number, supplier, dates, …) The reconstructed QafFileParsed is fed back through compareQafPair, so the downloaded workbook reproduces the on-screen result (deterministic engine, same version). Identity-based plausibility (part name / variant / quotation date) can't be re-derived from the single last-write-wins qaf_part row, so the caller passes the persisted analyze-time issues via plausibilityOverride. Provenance note: cell addresses (SummaryField.cell) are not persisted, so the rehydrated summary carries cell:null. That only affects the \"source cell\" hint; all values and every derived diff/plausibility result are identical. KAR-886: qaf_manufacturing_step.raw_values is the *entire* persisted QAFRow, including its optional sourceCells/normalized provenance — rehydrateFile passes r.steps straight through with no field-by-field reconstruction, so that provenance survives a rehydrate round-trip for free. Rows persisted before KAR-886 simply lack the keys (undefined), which the engine already tolerates since nothing in compareQafPair/matcher/differ reads them.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export interface PartRow { | export interface FileRow { | export interface RehydratedFile { | export function partToSummary(part: PartRow | null): QafSummary { | export interface SummaryMetricDbRow { | export function summaryMetricsFromRows( | export function rehydrateFile(r: RehydratedFile): QafFileParsed { | export interface RehydrateExportArgs { | export function resolvePersistedEngineConfig(engineVersion: unknown): EngineConfig { | export function resolveReplaceMultiQafDetectionConfig(engineVersion: unknown): MultiQafDetectionConfig { | export function resolvePersistedRuleEnforcement(engineVersion: unknown): RuleEnforcement { | export function rehydrateExportInput(args: RehydrateExportArgs): QafExportInput { | export async function buildExportWorkbookFromRows(args: RehydrateExportArgs): Promise<ArrayBuffer> {",
      "source_heading_line": 6374,
      "source_end_line": 6413
    },
    {
      "path": "lib/qaf-differences/internal/rmr-parser.ts",
      "category": "core",
      "declared_lines": 1238,
      "comment_summary": "RAW MATERIAL RISKS sheet row parser + Rohstoffzuschlag-Validierung (KAR-902 / P2.3): standalone parser for the QAF \"RAW MATERIAL RISKS\" register sheet (Leitfaden 02-leitfaden-teil2.md [42]-[45]). Built 1:1 from the material-parser.ts / sbm-parser.ts pattern (KAR-897/P1.6, KAR-898/P1.7): dynamic import of the canonical registry, label-anchor + confidence matching, a controlled degradation path on an unusable header (never throws), sourceCells/normalized/rawText provenance from day one, and the coreFieldsFound tri-state + rmrRowsForReconciliation/FromPersistedMeta helper pair built in from the START (the #274/KAR-898 lesson from 4d158f6 — sbm-parser.ts's module header documents that this tri-state contract was RETROFITTED there after an adversarial-review finding; this module ships it from commit one instead of repeating that lesson). ── Scope discipline (backlog 05-backlog-phasenplan.md [P2.3] + explicit task override, see below) ────────────────────────────────────────────────────── - Parses + persists the row shape only, plus ONE row-level Rohstoffzuschlag plausibility check (see \"Rohstoffzuschlag-Validierung\" section below) — the task instruction's \"Rohstoffzuschlag-Plausibilität soweit die Leitfaden-Regeln es mit geparsten Feldern hergeben\". - Cross-Sheet-Reconciliation MATERIAL <-> RAW MATERIAL RISKS (backlog P2.3's own acceptance criterion: \"Summen-Abgleich Bezugsgewicht\" between MATERIAL rows carrying a Rohstoffbezeichnung/RMR-Kennzeichnung and this sheet's aggregated rows) is EXPLICITLY NOT built in this PR — the task instruction overrides the backlog item's own scope here: \"Verbindung zu den MATERIAL-RMR-Split-Zeilen aus KAR-897 (gleiche Rohstoffbezeichnung -> als Kontext-Feld erfassen, KEINE automatische Cross-Sheet- Verknuepfung in diesem PR — dokumentieren)\". rawMaterialDesignation is therefore captured as a plain context field only (exactly like material-parser.ts's own rawMaterialDesignation field is captured without resolving it against this very sheet) — no join, no sum-match, no PlausibilityIssue is produced from comparing the two sheets. This is a deliberate, documented deviation from the backlog's acceptance criterion \"Cross-Reconciliation mit MATERIAL-Sheet-Rohstoffzeilen funktioniert fuer die Beispieldaten aus Leitfaden S.45\" — tracked as a TODO for a future PR, not silently dropped. - Schwellwert-Preisanpassungslogik ueber Zeit (BQ%/SW%-Mechanik, Leitfaden [43]: \"Preisanpassung erfolgt nur, wenn sich vereinbarte Rohstoffnotierung seit letzter Preisanpassung jenseits des vereinbarten Schwellenwertes veraendert hat\") is explicitly OUT of scope per the backlog item itself (\"NICHT rein: die vollstaendige Schwellwert- Preisanpassungslogik ueber Zeit ... als 'Spaeter' markiert\") — this requires a HISTORICAL baseline Rohstoffnotierung to compare the current snapshot against, which a single-file parse structurally cannot supply (bmwParticipationRate/threshold are captured as plain data fields, never evaluated against a prior value). See \"Rohstoffzuschlag-Validierung\" section below for the one check that IS built. - Cross-file matching/diffing of RMR rows (ALT vs NEU) is explicitly OUT of scope for this PR — same boundary material-parser.ts/sbm-parser.ts document for their own row domains. ── Rohstoffzuschlag-Validierung (Leitfaden [42]-[45]) ────────────────────── The Leitfaden's own prose ([44]) only says RoZ0 \"wird berechnet\" without spelling out the formula textually (unlike e.g. MATERIAL's rule_calc_ raw_material_surcharge in business-rules.ts, which quotes an explicit formula sentence from Abbildung 9). Abbildung 26 (S.45, [45]) DOES supply a fully worked 14-row example table (12 \"Rohstoff Preisanteil Material\" rows + 2 \"Rohstoff Preisanteil Energie\" rows) with Rohstoffnotierung Ro, Bezugsgewicht (or Energieverbrauch for the energy rows) and Rohstoffzuschlag RoZ0 all given — every single row is consistent with RoZ0 = Ro x Bezugsgewicht (rounded to 2 decimals), including the Steel Scrap Coil EU row (Ro = -0.320, Bezugsgewicht = 1.00 kg, RoZ0 = -0.32 — a NEGATIVE quotation multiplied by a positive weight yields a negative surcharge, i.e. a credit/Gutschrift, per [45]'s own \"Ausnahmen/ Sonderfaelle\" note: \"Rohstoffnotierung Ro kann negativ sein ... Scrap/ Reststoffe als Gutschrift-Logik\"). validateRmrRawMaterialSurcharge below implements exactly this — derived from the worked example numbers, not from an explicit formula SENTENCE, so its evidence citation says so plainly (page + \"Abbildung 26 worked-example derivation\", not a quoted formula). Negative Ro/RoZ0 is NEVER flagged as an error by this check — it is multiplied through like any other signed number, which already produces the correct signed expected value; there is no separate \"reject negative\" branch to accidentally add. ── Gating (same anti-spam principle as business-rules.ts, KAR-901) ──────── The check only evaluates a row when rawMaterialSurcharge (the Excel-shown RoZ0) is actually filled — most files may carry rows still awaiting BMW // allow-customer-string Facheinkauf's Vergabe-Detaillierung ([44]: \"Zum Zeitpunkt der Vergabe ... zu befuellen\"), so an empty RoZ0 is a normal intermediate state, not a finding. ── Client-bundle discipline (KAR-893 lesson, \"Bundle-Lehre aus #272\") ───── Exported through the qaf-differences barrel (index.ts), which several 'use client' components already import from — canonical-model.ts/ canonical-fields.ts must stay OUT of the static import graph, exactly like material-parser.ts's loadMaterialRegistry(). loadRmrRegistry() is the same pattern: a dynamic import, cached after the first call. Only TYPE imports (erased at compile time) are static below. ── Known layout limitation (documented, not silently wrong) ─────────────── Abbildung 26 shows the RMR sheet as TWO stacked blocks under one tab (\"Rohstoff Preisanteil Energie\" then \"Rohstoff Preisanteil Material\"), which may or may not share one contiguous header row in a real file. This parser — like material-parser.ts/sbm-parser.ts — locates exactly ONE header row (best score in the first HEADER_SCAN_MAX_ROWS rows) and reads a single contiguous data block until the first fully blank row. If a real file lays the two blocks out as two separate header/data blocks, only the block whose header the scan locates first/best is parsed; the other is silently not read (same single-header-block limitation the two reference parsers accept for their own sheets — not solved here, flagged for a future PR if a real file demonstrates the two-block layout).",
      "mentions_todo_or_future": true,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export interface RmrRowValues { | export type RmrFieldKey = keyof RmrRowValues | export type RmrBlockType = 'material' | 'energie' | 'unbekannt' | export type RmrRow = RmrRowValues & { | export const CORE_RMR_FIELD_KEYS: readonly RmrFieldKey[] = [ | export function isRmrSheetName(name: string): boolean { | export function findRmrWorksheet(wb: { worksheets: Worksheet[] }): Worksheet | null { | export async function matchRmrHeaderColumn(headerCell: string): Promise<ColumnMatch | null> { | export async function findRmrHeaderRow(grid: unknown[][]): Promise<number | null> { | export interface RmrParseMeta { | export type RmrParseResult = RmrRow[] & RmrParseMeta | export async function parseRmrWorksheet(ws: Worksheet): Promise<RmrParseResult> { | export async function parseRmrSheet(wb: { worksheets: Worksheet[] }): Promise<RmrParseResult | null> { | export function rmrParseMetaToPlausibilityIssue( | export function rmrRowsForReconciliation(parsed: RmrParseResult | null): RmrRow[] | null { | export interface PersistedRmrMeta { | export function rmrRowsFromPersistedMeta(meta: PersistedRmrMeta | null | undefined): RmrRow[] | null | undefined { | export type RmrValidationCheckId = 'rmr_raw_material_surcharge' | export type RmrValidationStatus = 'bestanden' | 'abweichung' | 'nicht_pruefbar' | export interface RmrValidationResult { | export interface RmrValidationConfig { | export const RMR_VALIDATION_CONFIG: RmrValidationConfig = { | export function validateRmrRawMaterialSurcharge( | export interface RmrValidationInput { | export function evaluateRmrValidation( | export function rmrValidationResultToPlausibilityIssue(r: RmrValidationResult): PlausibilityIssue | null { | export function checkRmrValidation(",
      "source_heading_line": 6414,
      "source_end_line": 6541
    },
    {
      "path": "lib/qaf-differences/internal/root-cause.ts",
      "category": "core",
      "declared_lines": 151,
      "comment_summary": "QAF Root-Cause engine (KAR-799, spec B8). Deterministic, data-based, reproducible — NO free AI generation. Ranks the biggest absolute and relative cost drivers, separates structure and currency effects, and produces a 2–4 sentence management summary built ONLY from the computed deltas. Net-new. Pure.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": false,
      "public_signatures": "export interface StepDiffSummary { | export interface RootCauseInput { | export interface RootCauseDriver { | export interface RootCauseResult { | export function computeRootCause(input: RootCauseInput): RootCauseResult {",
      "source_heading_line": 6542,
      "source_end_line": 6557
    },
    {
      "path": "lib/qaf-differences/internal/rule-engine.ts",
      "category": "core",
      "declared_lines": 831,
      "comment_summary": "QAF Fehlerreport-Regel-Engine (R1-R6), KAR-889 / P0.4. Deterministic, pure validation layer derived empirically from the BMW // allow-customer-string Fehlerreport-Analyse (brain: 01-Projekte/supplierpulse-qaf-adaptive-engine/ 03-fehlerreport-analyse.md §4.2). Distinct from plausibility.ts (cross-file sanity checks like negative costs, currency change, part-number mismatch): this module classifies field-level \"provided correctly / not\" state PER FILE using a field-state model, then applies R1-R4/R6 to derive violations. Pure, no LLM, no I/O. The source Fehlerreport judges a supplier's QAF submission via a colored ✓/x column that Kadi-v2 never sees (that report is BMW-side metadata, not // allow-customer-string part of the QAF file itself — spec §4.1: \"Farb-Binärität statt 4-Klassen-Modell\", only 2 of 4 legend colors are ever used in practice). Kadi-v2 has no such external judgment to read; the closest deterministic analog it CAN derive from its own parsed QAF data is whether a field was filled in at all and, if numeric, whether the cell content parsed as a number or an explicit \"n.a.\" marker (KAR-886/891 provenance: sourceCells/ normalized/rawText). That derived state is what R1-R4/R6 operate on below. R5 (\"kein 1:1-Diff bei unterschiedlichen Quelldateien\") is a program-level test-fixture-handling constraint for comparing two *Fehlerreports* against each other — not an engine rule over parsed QAF data — and is intentionally NOT implemented here (matches backlog P0.4 scope: \"wird dort dokumentiert, nicht als Engine-Rule kodiert\"). [unklar] flagged item from the source analysis (§6.1): whether R3 (\"Nicht-Pflichtfeld inkorrekt\") should ever become blocking is an open BMW-fachliche Frage. R3 is therefore hard-wired to `warn` regardless of // allow-customer-string `ruleEnforcement` — only R2 (Pflichtfeld-Blockade) respects the enforcement switch. Do not change this without Fachfreigabe.",
      "mentions_todo_or_future": true,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export type RuleId = 'R1' | 'R2' | 'R3' | 'R4' | 'R6' | export type RuleEnforcement = 'warn' | 'block' | export type ComparisonSide = 'ALT' | 'NEU' | export interface RuleEngineConfig { | export const RULE_ENGINE_CONFIG: RuleEngineConfig = { | export interface RuleViolation { | export interface RuleEngineFileInput { | export interface RuleEngineInput { | export function classifyFieldState(input: { | export function canonicalizeFieldLabel(label: string): string { | export function disambiguateLabels(section: string, labels: readonly string[]): string[] { | export function evaluateRuleEngine( | export function blockedStepFields( | export function ruleViolationToPlausibilityIssue(v: RuleViolation): PlausibilityIssue {",
      "source_heading_line": 6558,
      "source_end_line": 6604
    },
    {
      "path": "lib/qaf-differences/internal/sbm-parser.ts",
      "category": "core",
      "declared_lines": 1023,
      "comment_summary": "SBM-DEVICES-FWZ sheet row parser (KAR-898 / P1.7): standalone parser for the QAF SBM-DEVICES-FWZ detail sheet (Sonderbetriebsmittel / Vorrichtungen / Folgewerkzeuge, Leitfaden 02-leitfaden-teil2.md [34]-[38]). Built 1:1 from the material-parser.ts pattern (KAR-897/P1.6): dynamic import of the canonical registry, label-anchor + confidence matching, a controlled degradation path on an unusable header (never throws), sourceCells/ normalized/rawText provenance from day one — see that module's header for the tier-1/tier-2 matching rationale, reused verbatim here. ── Scope discipline (backlog 05-backlog-phasenplan.md [P1.7]) ───────────── - Parses + persists the row shape only. The Betriebsmittelkategorie lookup below (classifySbmToolDeviceType) is REFERENCE DATA attached per row, not a blocking validation — \"ist diese Werkzeugart korrekt SBM vs. Vorrichtung klassifiziert\" full enforcement is an explicit Phase-2 follow-up per the backlog item (\"vollstaendige Validierung ... ist ein Business-Rule-Folgeitem fuer Phase 2 falls Kapazitaet\"). - Positionsnummer <-> MANUFACTURING COSTS matching is explicitly NOT built here (Leitfaden [33]: \"Positionsnummern ... sind NICHT zwangslaeufig 1:1 zeilenidentisch, sondern muessen ... manuell/logisch zugeordnet werden\", Abbildung 23). This module only captures positionNumber as a plain data field (a future matching-dimension) — no automatic row-index or position-based join to QAFRow is performed, mirroring material-parser.ts's explicit \"cross-file matching is out of scope\" boundary for its own Positionsnummer field. - Cross-file matching/diffing of SBM rows (ALT vs NEU) is explicitly OUT of scope for this PR, same boundary material-parser.ts documents for MATERIAL rows (see that module's header, \"Cross-file matching/diffing ... is explicitly OUT of scope for this PR\"). ── Bedingte Pflichtfeld-Logik (Leitfaden [34]) ───────────────────────────── Verrechnungsform is the only field the Leitfaden marks unconditionally Pflicht (besides Positionsnummer); Werkzeug-/Vorrichtungsart only becomes Pflicht once Verrechnungsform is set (\"Vorab leer ... solange nicht ausgefuellt wird es ... ROT dargestellt\"). The Excel-side red-cell-fill enforcement itself is NOT reproduced here (Master-Prompt/backlog risk note: \"Formatierungssemantik muesste zusaetzlich ausgelesen werden fuer volle Treue\" — cell-fill colour is not read by this parser, only values). ── Client-bundle discipline (KAR-893 lesson, \"Bundle-Lehre aus #272\") ───── Exported through the qaf-differences barrel (index.ts), which several 'use client' components already import from — canonical-model.ts/ canonical-fields.ts must stay OUT of the static import graph, exactly like material-parser.ts's loadMaterialRegistry(). loadSbmRegistry() is the same pattern: a dynamic import, cached after the first call. Only TYPE imports (erased at compile time) are static below.",
      "mentions_todo_or_future": true,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export interface SbmRowValues { | export type SbmFieldKey = keyof SbmRowValues | export type SbmRow = SbmRowValues & { | export const CORE_SBM_FIELD_KEYS: readonly SbmFieldKey[] = ['positionNumber', 'toolFixtureType', 'totalToolFixtureCostAw'] | export function isSbmSheetName(name: string): boolean { | export function findSbmWorksheet(wb: { worksheets: Worksheet[] }): Worksheet | null { | export async function matchSbmHeaderColumn(headerCell: string): Promise<ColumnMatch | null> { | export async function findSbmHeaderRow(grid: unknown[][]): Promise<number | null> { | export interface SbmParseMeta { | export type SbmParseResult = SbmRow[] & SbmParseMeta | export async function parseSbmWorksheet(ws: Worksheet): Promise<SbmParseResult> { | export async function parseSbmSheet(wb: { worksheets: Worksheet[] }): Promise<SbmParseResult | null> { | export function sbmParseMetaToPlausibilityIssue( | export function sbmRowsForReconciliation(parsed: SbmParseResult | null): SbmRow[] | null { | export interface PersistedSbmMeta { | export function sbmRowsFromPersistedMeta(meta: PersistedSbmMeta | null | undefined): SbmRow[] | null | undefined { | export const SBM_VERRECHNUNGSFORM_VALUES = [ | export type SbmVerrechnungsform = (typeof SBM_VERRECHNUNGSFORM_VALUES)[number] | export type SbmDeviceClassificationTag = 'SBM' | 'Folgewerkzeug' | 'Vorrichtung' | export interface SbmDeviceCategoryEntry { | export const SBM_DEVICE_CATEGORY_TABLE: readonly SbmDeviceCategoryEntry[] = [ | export interface SbmDeviceClassificationResult { | export function classifySbmToolDeviceType(werkzeugart: string): SbmDeviceClassificationResult | null {",
      "source_heading_line": 6605,
      "source_end_line": 6675
    },
    {
      "path": "lib/qaf-differences/internal/section-titles.ts",
      "category": "core",
      "declared_lines": 110,
      "comment_summary": "Abschnittsüberschriften, die ihr Ergebnis tragen (Spezifikation Kap. 24, Action-Title-Regel). Der Unterschied zwischen einer Kategorie und einer Aussage: vorher A1. Fertigungskosten-Deltas nachher A1. Fertigung — 25 → 31 Stationen · Δ +2,4727 EUR/Stück Nach der ersten Zeile weiss der Leser Bescheid. Vorher musste er hineinschauen, um zu erfahren, ob überhaupt etwas passiert ist. Warum das hier steht und nicht in jeder Komponente einzeln: Eine wiedererkennbare Oberfläche entsteht nicht dadurch, dass jeder Abschnitt für sich gut aussieht, sondern dadurch, dass alle dieselbe Sprache sprechen — dieselbe Reihenfolge, dieselben Trennzeichen, dieselbe Behandlung des Sonderfalls „nichts verändert\". Achtzehn Abschnitte mit achtzehn eigenen Formulierungen sind achtzehn Gelegenheiten, es verschieden zu machen. Pure Funktionen, keine I/O.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": false,
      "public_signatures": "export interface CountChange { | export function countPhrase(c: CountChange): string { | export interface DeltaFact { | export function deltaPhrase(d: DeltaFact): string { | export function severityPhrase(counts: { high?: number; medium?: number; low?: number; info?: number }): string { | export function actionTitle(labelDe: string, facts: ReadonlyArray<string | null | undefined>): string { | export function unchangedPhrase(nounDe: string): string {",
      "source_heading_line": 6676,
      "source_end_line": 6702
    },
    {
      "path": "lib/qaf-differences/internal/summary-metrics.ts",
      "category": "core",
      "declared_lines": 618,
      "comment_summary": "Summary-sheet metric extraction + diff (KAR-840 engine foundation). Ports the verified V11 summary normalizer: the 19 canonical money metrics of the Zusammenfassung/SUMMARY sheet are located via label anchoring in column G (expected-row prior ±3), the value column is resolved from the quotation currency code (C26 legacy / C25 V9) matched against the row-10 column headers M..R (fallback P). scrapManufacturing has no own label and is read from the row below scrapMaterial; the one-time payment block hangs below its 'Einmalzahlungen' anchor. Every value keeps its A1 provenance. Pure: operates on the same 0-based row-major grid as summary-parser.ts. The diff side reuses the shared status bands from differ.ts so summary rows and step rows speak the same status language (qaf_summary_diff.status).",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": false,
      "public_signatures": "export const SUMMARY_METRIC_KEYS = [ | export type SummaryMetricKey = (typeof SUMMARY_METRIC_KEYS)[number] | export type SummaryTemplateType = 'QAF_LEGACY_DE_SUMMARY' | 'QAF_V9_SUMMARY' | export type MetricLocateMethod = 'labelMatch' | 'fixedRow' | 'aggregate' | 'labelCollision' | export interface SummaryMetricValue { | export interface SummaryMetricsParse { | export interface SummaryMetricDiff { | export const METRIC_LABELS_DE: Record<SummaryMetricKey, string> = { | export const AW_SCAN_TO = 17 // column R | export function parseSummaryMetrics( | export function diffSummaryMetrics(",
      "source_heading_line": 6703,
      "source_end_line": 6730
    },
    {
      "path": "lib/qaf-differences/internal/summary-parser.ts",
      "category": "core",
      "declared_lines": 265,
      "comment_summary": "QAF Zusammenfassung/Summary parser (KAR-799, spec A3 / B-Parser). Extracts identity + context fields from the summary sheet. Strategy (spec): known standard cell first, then a robust label-scan fallback (find the label text, take the first non-empty value to its right). Works across DE \"Zusammenfassung\" and EN \"Summary\" variants and shifted layouts. Net-new: lib/qaf-parser.ts parses only the Fertigungskosten sheet; no summary parser existed. Pure: operates on a 2-D grid (row-major, 0-based). The ExcelJS→grid adapter lives in the upload/parse pipeline; gridFromCells mirrors it for tests. Field names and label synonyms are customer-neutral (no customer token in code) — matching is done on the distinctive part of each label.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export function parseA1(addr: string): { row: number; col: number } { | export function a1(row: number, col: number): string { | export function cellToString(v: unknown): string | null { | export function parseSummary(grid: unknown[][]): QafSummary { | export function gridFromCells(cells: Record<string, unknown>): unknown[][] {",
      "source_heading_line": 6731,
      "source_end_line": 6752
    },
    {
      "path": "lib/qaf-differences/internal/summary-row-registry.ts",
      "category": "core",
      "declared_lines": 123,
      "comment_summary": "Zeilenrollen-Registry des QAF-Preisblocks (V2-Spezifikation R-05/R-07/R-22). Warum das hier steht und nicht in summary-metrics.ts: summary-metrics.ts weiß, WO eine Metrik im Blatt liegt (Label-Anchoring + Zeilen-Prior je Template). Diese Registry sagt, WAS die Zeile im Preisblock BEDEUTET — additiv, Memo-Unterzeile, Zwischensumme, Endsumme, Einmalzahlung. Beides zu mischen war die Ursache von Befund F-01: die Preisbrücke summierte Zeilen nach dem Kriterium \"hat ein Delta\" statt nach dem Kriterium \"ist additiv\", buchte damit die Memo-Zeile \"Enthaltene Verpackung und Transport\" ein, ließ den echten additiven Block 7 aus und glich die Differenz über einen Restposten \"Übrige\" aus. Mit expliziten Rollen ist diese Fehlklasse strukturell ausgeschlossen. Additivitätsvertrag laut Template (Zelltexte L24/L29 der Zusammenfassung): ANGEBOTSBASISPREIS = 1. + 2. + 3. + 4. + 5. ANGEBOTSPREIS = Summe 1. bis 8. Die Blöcke 4 und 6 belegen je zwei Blattzeilen (Ausschuss Material/Fertigung, Rohstoff-Preisanteil Material/Energie), deshalb ist blockNo kein Primärschlüssel. Pure Daten + pure Selektoren, keine I/O, kein Blattzugriff.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": false,
      "public_signatures": "export type SummaryRowRole = 'additive' | 'memo' | 'subtotal' | 'total' | 'one_time' | 'one_time_total' | 'info' | export type SummaryLabelSource = 'label' | 'positional' | export interface SummaryRowSpec { | export const SUMMARY_ROW_REGISTRY: readonly SummaryRowSpec[] = [ | export function registrySpecFor(metricKey: SummaryMetricKey): SummaryRowSpec | undefined { | export function roleOfMetric(metricKey: SummaryMetricKey): SummaryRowRole | undefined { | export function additiveRowSpecs(): SummaryRowSpec[] {",
      "source_heading_line": 6753,
      "source_end_line": 6781
    },
    {
      "path": "lib/qaf-differences/internal/summary-view.ts",
      "category": "core",
      "declared_lines": 623,
      "comment_summary": "Summary view-model (KAR-840): KPI tiles + QAF form lines from persisted qaf_summary_diff rows. Ports V11's kpi()/formLines() for the pairwise case — the six form lines mirror the official QAF sheet positions, so their labels stay in the sheet's English wording. Pure; UI-independent and unit-tested. Single-metric lines pass the PERSISTED delta_percent through verbatim (same source the KPI tiles read) — only the combined scrap line computes locally, so one metric can never show two different percentages on one screen.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export interface SummaryDiffRowData { | export interface KpiValue { | export interface SummaryKpis { | export interface FormLine { | export function pickCurrency(rows: SummaryDiffRowData[]): string | null { | export function buildSummaryKpis(rows: SummaryDiffRowData[]): SummaryKpis | null { | export type BucketKey = 'Material' | 'Labor' | 'Manufacturing' | 'FWZ' | 'ScrapB' | 'SGA' | 'Profit' | export interface BucketDatum { | export const BUCKET_LABEL_DE: Record<BucketKey | 'Other', string> = { | export function buildBuckets(rows: SummaryDiffRowData[]): BucketDatum[] | null { | export interface BridgeStep { | export interface BridgeCompositionPart { | export interface BridgeData { | export const BRIDGE_MIN_STEP = 0.00005 | export const BRIDGE_MIN_RESIDUAL = 0.0005 | export function buildBridge(rows: SummaryDiffRowData[]): BridgeData | null { | export interface SummaryLine { | export function buildSummaryLines(rows: SummaryDiffRowData[]): SummaryLine[] { | export interface NegotiationRow { | export interface Negotiation { | export function buildNegotiation(rows: SummaryDiffRowData[]): Negotiation { | export interface OneTimeRow { | export function buildOneTimeRows(rows: SummaryDiffRowData[]): OneTimeRow[] { | export interface MetricsTableRow { | export function buildMetricsTable(rows: SummaryDiffRowData[]): MetricsTableRow[] { | export function buildFormLines(rows: SummaryDiffRowData[]): FormLine[] {",
      "source_heading_line": 6782,
      "source_end_line": 6820
    },
    {
      "path": "lib/qaf-differences/internal/supplier-benchmark.ts",
      "category": "core",
      "declared_lines": 510,
      "comment_summary": "Lieferanten-Benchmark (KAR-993 P1). Anlass: Kais verglich am 30.07.2026 zwei Multi-QAFs verschiedener Lieferanten über den `multi_qaf`-Modus — einen VERSIONS-Vergleich. Ergebnis war ein 24-seitiger Bericht, mit dem er \"nicht viel anfangen\" konnte, weil jede Kennzahl darin die logische Folge des falschen Modus war: 4 von 19 Varianten \"gematcht\" (die Treffer hingen an EINER Dimension bei 10 % Abdeckung), kein Jahres-Impact (USD gegen EUR — korrekt verweigert), 184 Material-Zeilen \"neu\" gegen 60 \"entfallen\". ── Warum dieses Modul ohne Varianten-Matching arbeitet ──────────────────── Ein Benchmark stellt zwei Angebote gegenüber, die NICHT dasselbe Teil beschreiben müssen. Damit ist jedes Zeile-gegen-Zeile-Matching sinnlos — und genau diese Mechanik (variant-matcher.ts, 4-stufige Kaskade) ist die fragilste im ganzen Paket. Der Benchmark vergleicht deshalb VERTEILUNGEN über alle Varianten einer Seite (Median + Spanne) gegen die der anderen. Nichts wird zugeordnet, also kann auch nichts falsch zugeordnet werden. Das ist der Grund, warum er ohne Kalibrierung für beliebige Multi-QAF- Generationen funktioniert — die Anforderung \"es soll flexibel sein auch mit anderen Multi-QAF-Beispielen\" (Kais, TG 9362). ── Warum ausschließlich Verhältnisse ────────────────────────────────────── Absolutbeträge zweier Angebote sind nicht vergleichbar: verschiedene Teile, Währungen (im Anlassfall USD gegen EUR), Volumina und Bezugsjahre. Ein VERHÄLTNIS aus zwei Positionen derselben Datei ist einheitenfrei und deshalb über Lieferanten hinweg lesbar — \"84,5 % Materialanteil gegen 86,2 %\" ist eine belastbare Aussage, \"235 gegen 180\" ist keine. Die harte Regel, die daraus folgt: ein Verhältnis wird NUR gebildet, wenn Zähler und Nenner in DERSELBEN Währung stehen. Sonst wäre die Zahl einheitenfrei aussehend, aber inhaltlich falsch — die gefährlichste Sorte Ergebnis. Wo das nicht gilt, liefert dieses Modul `null` MIT Begründung (`unavailableReason`), nie 0 und nie eine stille Auslassung. Dieselbe Doktrin, die container-assembly.ts bereits für `materialCostsByCurrency` anwendet (\"never force an interpretation\"). Reine Berechnung, kein I/O, keine DB, keine Parser-Abhängigkeit: Eingabe ist eine normalisierte Variantenliste, auf die sowohl ein Multi-QAF- Container (VirtualVariantSummaryTotals) als auch ein Standard-Summary-QAF abgebildet werden kann. Deshalb ist der Benchmark format-agnostisch, ohne dass dieses Modul die Formate kennt.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export type BenchmarkUnavailableReason = | export interface BenchmarkRatio { | export interface BenchmarkVariantInput { | export interface BenchmarkSideInput { | export interface BenchmarkCostStructure { | export interface BenchmarkMarkupChain { | export interface BenchmarkSideResult { | export interface SupplierBenchmarkResult { | export interface BenchmarkNotice { | export function buildSupplierBenchmark(",
      "source_heading_line": 6821,
      "source_end_line": 6872
    },
    {
      "path": "lib/qaf-differences/internal/table-specs.ts",
      "category": "core",
      "declared_lines": 207,
      "comment_summary": "Tabellenvorlagen der Anzeigeebene (Spezifikation Kap. 22, Invariante U-08). Material, Fertigung und Werkzeuge zeigen dieselbe Tabelle mit denselben Spalten, denselben Filtern und derselben Sortierung. Drei eigene Implementierungen wären drei Gelegenheiten, es verschieden zu machen — und der Nutzer müsste dreimal lernen, wie er filtert. Zwei Entscheidungen prägen die Datei: 1. **Die Sortierung ist die Blattreihenfolge.** Nicht der Betrag. Wer eine Position sucht, sucht sie dort, wo sie in der Datei steht; eine nach Grösse sortierte Tabelle zwingt zum Suchen. Sortieren nach Betrag bleibt möglich, ist aber nicht der Ausgangszustand. 2. **Filter verstecken, sie löschen nicht.** Jede gefilterte Ansicht nennt, wie viele Zeilen sie ausblendet und welche Summe damit aus dem Bild fällt. Eine Tabelle, die stillschweigend die Hälfte weglässt, ist die bequemste Art, eine Verhandlung zu verlieren. Pure Funktionen, keine I/O.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": false,
      "public_signatures": "export interface TableRow { | export interface TableFilter { | export type TableSort = 'sheet_order' | 'delta_desc' | 'delta_asc' | export interface TableSpec { | export function buildTable( | export function rowsFromRecords( | export interface TableProblem { | export function validateTable(spec: TableSpec, allRows: readonly TableRow[], decimals = 2): TableProblem[] {",
      "source_heading_line": 6873,
      "source_end_line": 6901
    },
    {
      "path": "lib/qaf-differences/internal/template-fingerprint.ts",
      "category": "core",
      "declared_lines": 1040,
      "comment_summary": "Template fingerprint + known/modified/unknown classification (KAR-895 / P1.4). Problem this closes (backlog 05-backlog-phasenplan.md [P1.4]): today's \"which template is this?\" logic is a set of independent binary heuristics — summary-metrics.ts's pickTemplate() is a sheet-name regex that ALWAYS returns one of two template constants (never \"I don't recognize this\"), qaf-parser.ts's findHeaderRow() either finds ≥5 known headers or throws, and g60/parser.ts's detectG60() is a yes/no gate. None of them can say \"this looks like a known template but three fields moved\" versus \"I have genuinely never seen a structure like this\" (Master-Prompt §7's silent template modification risk). This module adds that middle ground without touching any of the three existing recognizers — see \"Scope discipline\". Scope discipline (backlog P1.4 + task instructions, deliberately followed): - Foundation stage only. Built-in profile CONSTANTS (QAF_V9_SUMMARY, QAF_LEGACY_DE_SUMMARY, G60_DETAIL), no qaf_template_profile DB table, no profile-onboarding/auto-adaptation workflow (that is P5, out of scope here per the task brief overriding the backlog item's original \"Migrations-Impact: ja\" note — this PR persists the RESULT, not a profile registry). - Deterministic, inspectable set/hash comparison — no ML/fuzzy scoring. - Pure. No I/O, no Excel access, no DB access. Callers (workbook-adapter.ts / actions.ts) assemble TemplateFingerprintInput from data they already parsed; this module only classifies it. - Additive: does not change what qaf-parser.ts, summary-metrics.ts or g60/structure-guard.ts return — it reads their OUTPUT (matched field keys, template type, structure-guard findings) and never re-parses a workbook itself. Provenance discipline (rehydrate/export determinism, task instruction): the fingerprint is computed ONCE at ingest (actions.ts ingestQafUpload) and persisted (qaf_file.g60_meta.templateFingerprint — see PR body for why that JSONB field, not a new column). Rehydrate/render code must always READ the persisted TemplateFingerprintResult back, never call buildTemplateFingerprint() again on old data — recomputing later would silently reinterpret an already-reviewed comparison if this module's classification thresholds change (same reproducibility reasoning as ENGINE_VERSION.g60Parser, see types.ts). tdd-guard:skip for the profile/config CONSTANTS below (pure data, same category as summary-metrics.ts's TEMPLATE_CONFIG) — the functions (computeTemplateFingerprintStructure/hashTemplateFingerprintStructure/ classifyTemplateFingerprint/buildTemplateFingerprint/ templateFingerprintToPlausibilityIssue) are covered by __tests__/template-fingerprint.test.ts.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export interface TemplateFingerprintSheetSummary { | export interface TemplateFingerprintSummaryInput { | export function summaryLocatedKeys(metrics: SummaryMetricsParse['metrics']): SummaryMetricKey[] { | export interface TemplateFingerprintManufacturingInput { | export interface TemplateFingerprintMaterialInput { | export interface TemplateFingerprintSbmInput { | export interface TemplateFingerprintRmrInput { | export interface TemplateFingerprintLccnInput { | export interface TemplateFingerprintCo2eInput { | export interface TemplateFingerprintG60Input { | export interface TemplateFingerprintInput { | export interface TemplateFingerprintSummaryFacet { | export interface TemplateFingerprintManufacturingFacet { | export interface TemplateFingerprintMaterialFacet { | export interface TemplateFingerprintSbmFacet { | export interface TemplateFingerprintRmrFacet { | export interface TemplateFingerprintLccnFacet { | export interface TemplateFingerprintCo2eFacet { | export interface TemplateFingerprintG60Facet { | export interface TemplateFingerprintStructure { | export const MANUFACTURING_EXPECTED_CANONICAL_IDS: readonly string[] = Object.values( | export const MATERIAL_EXPECTED_CANONICAL_IDS: readonly string[] = Object.values( | export const SBM_EXPECTED_CANONICAL_IDS: readonly string[] = Object.values(SBM_FIELD_KEY_TO_CANONICAL).sort() | export const RMR_EXPECTED_CANONICAL_IDS: readonly string[] = Object.values(RMR_FIELD_KEY_TO_CANONICAL).sort() | export const LCCN_EXPECTED_CANONICAL_IDS: readonly string[] = Object.values(LCCN_FIELD_KEY_TO_CANONICAL).sort() | export const CO2E_EXPECTED_CANONICAL_IDS: readonly string[] = Object.values(CO2E_FIELD_KEY_TO_CANONICAL).sort() | export const SUMMARY_OPTIONAL_CANONICAL_IDS: readonly string[] = ['sum_cost_breakdown_aw1'] | export const SUMMARY_EXPECTED_CANONICAL_IDS_BY_TEMPLATE: Record<SummaryTemplateType, readonly string[]> = { | export function computeTemplateFingerprintStructure(input: TemplateFingerprintInput): TemplateFingerprintStructure { | export function stableStringify(value: unknown): string {",
      "source_heading_line": 6902,
      "source_end_line": 6989
    },
    {
      "path": "lib/qaf-differences/internal/tooling-analysis.ts",
      "category": "core",
      "declared_lines": 440,
      "comment_summary": "Werkzeuge und Einmalzahlungen (Spezifikation Kap. 7.5, R-11; Detektoren D05/D08). Die Einmalzahlung ist die zweite Preisdimension eines Angebots und wird regelmäßig stiefmütterlich behandelt: eine Summe, ein Delta, fertig. Damit bleibt unbeantwortet, wofür die Differenz anfällt — und genau darin liegt der Unterschied zwischen „neuer Umfang wurde beauftragt\" und „derselbe Umfang kostet mehr\". Der schärfste Befund des Anlassfalls hängt hier: Zweitwerkzeuge in erheblicher Höhe bei gleichzeitig gelöschten Kavitäten- und Standzeitangaben. Jedes für sich wäre erklärbar; zusammen ist es eine Frage, die gestellt gehört. Pure Funktionen, keine I/O.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": false,
      "public_signatures": "export interface ToolSpecification { | export interface MappableTool extends ToolSpecification { | export function sbmRowsToMappableTools(rows: readonly SbmRow[]): MappableTool[] { | export type ToolStatus = 'unchanged' | 'changed' | 'new' | 'removed' | export type ToolClassification = 'new_scope' | 'second_tool' | 'price_change' | 'fixture' | 'unchanged' | export interface ToolMapping { | export interface OneTimeBridgeStep { | export interface ToolingResult { | export function stripSequenceSuffix(part: string): string | null { | export function analyseTooling(",
      "source_heading_line": 6990,
      "source_end_line": 7016
    },
    {
      "path": "lib/qaf-differences/internal/types.ts",
      "category": "core",
      "declared_lines": 437,
      "comment_summary": "Core domain types for the QAF Diff Engine (KAR-799). The engine is UI-independent (batch/CLI/test-usable). These types model the auditable comparison of two QAFs of the SAME part number: provenance, normalized values, the 5-stage matching cascade, and field-level diffs. Reuses QAFRow from the existing parser — we do not re-model process rows. tdd-guard:skip — type declarations + constants; logic lives in the normalizer/differ/matcher modules which are test-driven.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": true,
      "public_signatures": "export type QafSheet = 'Zusammenfassung' | 'Fertigungskosten' | export interface SourceRef { | export interface MoneyValue { | export const PERCENT_FIELDS: ReadonlyArray<keyof QAFRow> = [ | export function isPercentField(field: keyof QAFRow): boolean { | export type FieldState = | export type DiffStatus = | export interface FieldDiff { | export type MatchStatus = | export type MatchMethod = | export interface StepMatch { | export interface MatchConfig { | export const DEFAULT_MATCH_CONFIG: MatchConfig = { | export interface SummaryField { | export interface QafSummary { | export type QafSummaryKey = keyof QafSummary | export const ENGINE_VERSION = { | export type EmptyFieldReason = | export interface FacetDegradation { | export const MIN_SIGNAL_MAPPED_COLUMNS = 2",
      "source_heading_line": 7017,
      "source_end_line": 7049
    },
    {
      "path": "lib/qaf-differences/internal/v2-export.ts",
      "category": "core",
      "declared_lines": 124,
      "comment_summary": "Der V2-Ergebnis-Envelope (Spezifikation Kap. 6, 13, 20; Golden File als Massstab). Wozu: Der Ergebnis-JSON der App ist bis heute ein Abzug der Web-Ansicht — zehn flache Sektionen, deren Namen aus der Anzeigekomponente stammen. Das Golden File führt 24 fachliche Bereiche. Die Bausteine für fast alle liegen seit den Ausbaustufen davor im Kern; verbunden waren sie nie. Diese Datei ist der Zusammensetzer. Sie rechnet nichts selbst: Jeder Bereich kommt aus dem Modul, das ihn verantwortet, oder er steht ausdrücklich auf „nicht berechnet\". **Ein fehlender Bereich fehlt nicht — er sagt, dass er fehlt.** Das ist der Unterschied zwischen einem unvollständigen und einem unehrlichen Dokument: Wer `material_analysis` nicht findet, weiss nicht, ob es keine Befunde gab oder ob niemand hingesehen hat. Deshalb trägt jeder Bereich seinen Zustand (Kap. 20, U-04), auch wenn er leer ist. Pure Funktionen, keine I/O.",
      "mentions_todo_or_future": true,
      "pure_or_no_io": true,
      "test_reference": false,
      "public_signatures": "export const V2_AREAS = [ | export type V2Area = (typeof V2_AREAS)[number] | export interface V2ExportInput { | export interface V2Export { | export function buildV2Export(input: V2ExportInput): V2Export { | export function missingAreas(doc: V2Export): V2Area[] {",
      "source_heading_line": 7050,
      "source_end_line": 7075
    },
    {
      "path": "lib/qaf-differences/internal/view-specs.ts",
      "category": "core",
      "declared_lines": 277,
      "comment_summary": "Die Datenverträge der Anzeigeebene (Spezifikation Teil D, Kap. 22 und 26). Leitgedanke der Spezifikation: ein kanonischer Datenbaum, ein Renderer je Sektion. Die Anzeige rechnet nichts selbst. Jede angezeigte Zahl trägt einen Verweis in den Kern, und alle Renderer — Webansicht, Foliensatz, Arbeitsmappe, Offline-Ansicht — lesen dieselbe Vorlage. Der Grund ist nicht Ordnungsliebe. Sobald eine der vier Ausgaben selbst rechnet, laufen sie auseinander, und zwar unbemerkt: Niemand vergleicht eine Folie Zeile für Zeile mit dem Bildschirm. Genau das ist in der Vorgängerfassung passiert. Zwei Invarianten sind hier zu Code geworden: U-02 Keine Anzeigezahl ohne Verweis in den Kern. U-03 Anzeigesummen sind Kernsummen — im Client wird nicht vor dem Summieren gerundet. Pure Funktionen, keine I/O.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": false,
      "public_signatures": "export interface SourceRef { | export type StatusChip = 'critical' | 'warning' | 'neutral' | 'good' | export interface KpiTile { | export interface KpiTileInput { | export function statusFor(deltaRatio: number | null, thresholds = DEFAULT_THRESHOLDS): StatusChip { | export function buildKpiTiles(inputs: readonly KpiTileInput[], records: readonly DifferenceRecord[]): KpiTile[] { | export function displaySum(values: readonly (number | null)[], decimals: number): number { | export interface EvidenceRow { | export function evidenceFor( | export interface SectionHeader { | export function sectionHeaders(sections: Record<string, { labelDe: string; state: SectionState }>): SectionHeader[] { | export interface ValidationBanner { | export function validationBanner(failedChecks: readonly string[]): ValidationBanner { | export interface ViewProblem { | export function validateViewSpec(",
      "source_heading_line": 7076,
      "source_end_line": 7110
    },
    {
      "path": "lib/qaf-differences/internal/workbook-adapter.ts",
      "category": "core",
      "declared_lines": 443,
      "comment_summary": "ExcelJS → engine adapter (KAR-799, spec B-Parser / A5.2). Bridges a real .xlsx workbook to the pure summary parser: resolves an ExcelJS worksheet to a 0-based row-major grid (formula results, hyperlink text), and loads the Zusammenfassung/Summary sheet from a buffer. Server-only — ExcelJS is imported dynamically (kept out of client bundles), matching qaf-parser.ts. Net-new. The Fertigungskosten step parser already exists (qaf-parser.ts); this adds the missing summary-sheet bridge. Read-only — never mutates the file.",
      "mentions_todo_or_future": true,
      "pure_or_no_io": true,
      "test_reference": false,
      "public_signatures": "export function resolveCell(cell: Cell): unknown { | export async function loadExcelWorkbook(buffer: Buffer | ArrayBuffer): Promise<Workbook> { | export interface WorkbookSheetSummary { | export function summarizeWorkbookSheets(wb: { worksheets: Worksheet[] }): WorkbookSheetSummary[] { | export const SEMANTIC_ACTIVE_RANGE_MAX_ROWS = 300 | export const SEMANTIC_ACTIVE_RANGE_MAX_COLS = 80 | export interface SemanticActiveRange { | export function semanticActiveRange( | export function worksheetToGrid(ws: Worksheet): unknown[][] { | export function worksheetToFormulaGrid(ws: Worksheet): (string | null | typeof SHARED_FORMULA_UNRESOLVED)[][] { | export async function parseSummaryFromWorkbook(buffer: Buffer | ArrayBuffer): Promise<QafSummary> { | export async function parseSummarySheet( | export function parseSummarySheetFromWorkbook(wb: { | export async function parseQafFile(file: File): Promise<{",
      "source_heading_line": 7111,
      "source_end_line": 7137
    },
    {
      "path": "lib/qaf-differences/internal/workbook-cells.ts",
      "category": "core",
      "declared_lines": 178,
      "comment_summary": "Zellzustände aus einer Arbeitsmappe holen (Spezifikation Kap. 6.3). Die Detektoren in `data-quality-cells.ts` arbeiten auf Momentaufnahmen und nicht auf der Arbeitsmappe selbst — so bleiben ihre Regeln ohne Datei prüfbar. Diese Datei ist die Brücke dorthin und die einzige Stelle, die wissen muss, wie ExcelJS Formeln, Ergebnisse und ausgeblendete Spalten ablegt.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export interface WorksheetLike { | export function columnLetter(index: number): string { | export function toSnapshot(cell: CellLike, sheet: string, ref: string): CellSnapshot { | export function readCells( | export function hiddenColumns(ws: WorksheetLike): string[] {",
      "source_heading_line": 7138,
      "source_end_line": 7154
    },
    {
      "path": "lib/qaf-differences/internal/workbook-safety.ts",
      "category": "core",
      "declared_lines": 675,
      "comment_summary": "Untrusted-Excel hardening (KAR-914 / P4.4, Master-Prompt §20 \"Security and file safety\"). Pure structural safety checks that run in ingestQafUpload (actions.ts): (a) BEFORE loadExcelWorkbook — a zip-ratio guard reads the ZIP Central Directory (entry names, declared sizes, compression method, local header offset; XLSX/XLSM is a ZIP container) via a minimal hand-rolled ZIP reader (node:buffer + node:zlib only, no new dependency), and rejects a suspected zip bomb before the expensive full unzip+XML-parse that ExcelJS's wb.xlsx.load() performs. Adversarial-review fix (KAR-914 F1, 10.07.2026): the FIRST version of this guard trusted the Central Directory's DECLARED uncompressedSize field for the cap/ratio decision. That is exactly as unsafe as doing no check at all — a crafted ZIP can declare any uncompressedSize it likes while its DEFLATE stream really inflates to gigabytes; ExcelJS's own unzip (JSZip → pako) only compares data_length against the declared size in its \"end\" event, i.e. AFTER the real inflate output is already fully materialized in memory — by then the OOM has already happened, so a second declared-size check ahead of it is no defense whatsoever. The fix: `computeRealUncompressedSize` REALLY decompresses every DEFLATE entry via `node:zlib`'s `inflateRawSync` with `maxOutputLength` set to the REMAINING budget. Node's zlib caps the decompressor's own internal output buffer and throws ERR_BUFFER_TOO_LARGE the INSTANT more output would be produced than the cap allows — verified empirically: a stream that would inflate to 50 MB throws immediately when capped at 1 MB, the 50 MB is never allocated. That is the actual OOM-safe primitive this module relies on; nothing here ever allocates more than `maxUncompressedBytes` for decompressed data, regardless of what any entry's metadata claims. The budget is a single SHARED, DECREMENTING pool across every entry (not a per-entry cap), so many medium entries that individually stay under the cap but sum past it are still caught. (b) AFTER the workbook load, BEFORE any cell iteration — a sheet- dimension cap reads ExcelJS's own rowCount/columnCount counters (no additional read pass) and rejects a workbook with an oversized sheet, matching the same \"reject before partial parse\" contract as the zip-ratio guard. (c) External workbook links (xl/externalLinks/…) and (d) macro presence (xl/vbaProject.bin) are detected from the same Central Directory entry-name list the zip-ratio guard already reads — no separate pass, no ExcelJS model inspection needed. These are NON-blocking: Kadi-v2 never follows external links and ExcelJS never executes macros (verified: ExcelJS's xlsx reader only parses sheet/style/shared-string XML — it has no VBA interpreter and never reads externalLinks targets), so both are surfaced as an advisory qaf_plausibility_issue (KAR-906 bilingual pattern) for reviewer awareness rather than a hard rejection. (e) Resource-limit: parse wall-clock time is already bounded by `export const maxDuration = 300` (app/qaf-differences/page.tsx) — no separate timer here (avoids duplicating a limit Next.js/Vercel already enforces at the route level). Relationship to KAR-801: KAR-801 tracks the export/download HTTP route context (see actions.ts:70-76's comment); this module hardens the UPLOAD/INGEST path only. The wire-size cap (QAF_MAX_FILE_BYTES, 20 MB compressed, qaf-upload-constants.ts) already existed before this PR and is unchanged — it bounds the wire size only, which is exactly the gap this module closes (decompressed size / ratio / dimensions). Pure, no I/O beyond in-memory Buffer/zlib operations, no ExcelJS import (works on the raw Buffer for (a)/(c)/(d), and on the caller-supplied WorkbookSheetSummary[] for (b) — see workbook-adapter.ts's summarizeWorkbookSheets).",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": false,
      "public_signatures": "export interface ZipEntryInfo { | export interface ZipStructureInspection { | export class ZipStructureError extends Error {} | export function inspectZipStructure(buffer: Buffer): ZipStructureInspection { | export interface WorkbookSafetyVerdict { | export interface ZipBombLimits { | export const DEFAULT_ZIP_BOMB_LIMITS: ZipBombLimits = { | export interface RealSizeCheckResult { | export function computeRealUncompressedSize( | export function evaluateZipBombRisk( | export interface SheetDimensionLimits { | export const DEFAULT_SHEET_DIMENSION_LIMITS: SheetDimensionLimits = { | export function evaluateSheetDimensions( | export function detectMacroPresence(inspection: ZipStructureInspection): boolean { | export function detectExternalWorkbookLinks(inspection: ZipStructureInspection): boolean { | export interface WorkbookSafetyResult { | export interface WorkbookPreParseCheck { | export interface LegacyBiffLimits { | export const DEFAULT_BIFF_LIMITS: LegacyBiffLimits = { | export function runPreParseLegacyBiffCheck( | export function runPreParseWorkbookSafetyCheck( | export function workbookSafetyToPlausibilityIssues(",
      "source_heading_line": 7155,
      "source_end_line": 7243
    },
    {
      "path": "lib/qaf-differences/internal/xlsx-style-helpers.ts",
      "category": "core",
      "declared_lines": 138,
      "comment_summary": "Shared XLSX styling toolkit for the QAF-Differences ExcelJS export builders (KAR-949 review Finding 2 — cleanup). Before this module existed, the BMW-CI ARGB palette (PETROL/WHITE/FILL_*) // allow-customer-string and the ColDef-based `addSheet` header-row builder were copy-pasted byte-for-byte between ./export.ts (KAR-799) and ./g60/export.ts (KAR-913/P4.3) — a second copy already existed BEFORE ./multi-qaf/export.ts (KAR-949) added a third. This module is the single source of truth for both: the two sibling builders now import from here instead of redefining their own copies (behavior is unchanged — same ARGB literals, same addSheet signature/body). ./multi-qaf/export.ts's OWN array-row sheet-building idiom (newSheet/titleRow/headerRow/subHeaderRow/numFmt/fillCell/statusBandFill) was not previously duplicated anywhere else — it is included here too so a future 4th export builder reuses it instead of copying it again, per the review finding's explicit ask for \"a shared module the three builders import\". Divergences deliberately kept LOCAL rather than silently unified (task instruction: document divergent values as local overrides, don't homogenize them): - g60/export.ts's `FILL_CRIT` is the SAME ARGB value as `FILL_RISE` but under a distinct name (\"Kritisch/Blockiert\" vs \"cost increase\") — kept as a local `const FILL_CRIT = FILL_RISE` alias in g60/export.ts. - multi-qaf/export.ts's `FILL_NEUTRAL` is the SAME ARGB value as `FILL_REMOVED` but under a distinct name (covers nicht_ermittelbar/nicht_pruefbar/unknown states, not \"removed\") — kept as a local `const FILL_NEUTRAL = FILL_REMOVED` alias in multi-qaf/export.ts. - internal/export.ts's own `statusFill` (directional Ampel-fill with HIGHER_IS_BETTER_FIELDS awareness) and g60/export.ts's own `g60DeltaFill` are NOT moved here: both are genuinely different logic (not copies of each other), each scoped to its own comparison semantics. Server-only (ExcelJS types only — no runtime ExcelJS import needed here since callers pass in an already-constructed Workbook/Worksheet/Row). tdd-guard:skip — styling-only helper composition (fill colors, column widths, row builders); covered indirectly by every export.test.ts that exercises the three builders which import this module.",
      "mentions_todo_or_future": true,
      "pure_or_no_io": false,
      "test_reference": true,
      "public_signatures": "export const PETROL = 'FF037493' // BMW DCT petrol (ARGB) // allow-customer-string | export const WHITE = 'FFFFFFFF' | export const FILL_RISE = 'FFF8BBD0' // Status-Red strong | export const FILL_FALL = 'FFC8E6C9' // Status-Green | export const FILL_NEW = 'FFA8DFFF' // Primary-Tint (blau) | export const FILL_REMOVED = 'FFD7D8DA' // Status-Grey | export const FILL_WARN = 'FFFFE082' // Status-Yellow | export interface ColDef { | export function addSheet(wb: Workbook, name: string, cols: ColDef[]): Worksheet { | export function newSheet(wb: Workbook, name: string, colWidths: number[]): Worksheet { | export function titleRow(ws: Worksheet, text: string): Row { | export function headerRow(ws: Worksheet, headers: readonly string[]): Row { | export function subHeaderRow(ws: Worksheet, headers: readonly string[]): Row { | export function blank(ws: Worksheet): void { | export function numFmt(row: Row, col: number, fmt: string): void { | export function fillCell(row: Row, col: number, argb: string): void { | export function statusBandFill(status: string): string | null {",
      "source_heading_line": 7244,
      "source_end_line": 7305
    },
    {
      "path": "components/qaf-differences/export-utils.ts",
      "category": "ui",
      "declared_lines": 243,
      "comment_summary": "Export helpers for the QAF comparison sections (KAR-840 export layer). Client-side only: a section (charts incl. their HTML legends, tables, captions) is snapshotted to PNG via the SVG-foreignObject trick with the page styles inlined (V11 tableToPng — :root rules resolve the CSS-var chart colors), plus the versioned JSON view-model export. No external libs; data:/blob: URIs are CSP-allowed. Reuses lib/notes/pdf downloadBlob.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export type ExportKind = 'png' | 'json' | export function exportFilename(kind: ExportKind, partNumber: string | null, now: Date, section?: string): string { | export interface ViewModelExportInput { | export interface ViewModelExport { | export function buildViewModelExport(input: ViewModelExportInput): ViewModelExport { | export async function withContentHash(vm: ViewModelExport): Promise<ViewModelExport> { | export function escapeXmlText(s: string): string { | export function downloadJson(data: unknown, filename: string): void { | export async function elementToPngBlob(el: HTMLElement, scale = 2): Promise<Blob> { | export async function exportSectionPng(el: HTMLElement, filename: string): Promise<void> {",
      "source_heading_line": 7306,
      "source_end_line": 7327
    },
    {
      "path": "components/qaf-differences/format.ts",
      "category": "ui",
      "declared_lines": 50,
      "comment_summary": "Shared display/parse helpers for the QAF comparison sections (KAR-840). Single source for the de-DE money formatter (was copy-pasted per chart) and the NaN-safe numeric input parser used by interactive fields.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export interface MoneyFormatOptions { | export function moneyFormatter(currency: string | null, opts: MoneyFormatOptions = {}): (n: number) => string { | export function numberFormatter(opts: MoneyFormatOptions = {}): (n: number | null | undefined) => string { | export function parseNumericInput(raw: string): number | null {",
      "source_heading_line": 7328,
      "source_end_line": 7340
    },
    {
      "path": "components/qaf-differences/qaf-autosave.tsx",
      "category": "ui",
      "declared_lines": 176,
      "comment_summary": "",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export type AutosaveStatus = 'idle' | 'saving' | 'saved' | 'error' | export function useDebouncedAutosave<T>( | export function QafAutosaveIndicator({",
      "source_heading_line": 7341,
      "source_end_line": 7348
    },
    {
      "path": "components/qaf-differences/qaf-bridge-chart.tsx",
      "category": "ui",
      "declared_lines": 310,
      "comment_summary": "",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export interface WaterfallRow { | export function buildWaterfallData(bridge: BridgeData): WaterfallRow[] {",
      "source_heading_line": 7349,
      "source_end_line": 7355
    },
    {
      "path": "components/qaf-differences/qaf-comparison-detail.tsx",
      "category": "ui",
      "declared_lines": 1744,
      "comment_summary": "tdd-guard:skip — presentational detail view for one QAF comparison (KAR-799). Pure render of RLS-scoped data passed from the server page; no data fetching here.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": false,
      "public_signatures": "export function QafComparisonDetail({",
      "source_heading_line": 7356,
      "source_end_line": 7364
    },
    {
      "path": "components/qaf-differences/qaf-cost-structure-chart.tsx",
      "category": "ui",
      "declared_lines": 145,
      "comment_summary": "",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export interface SeriesVisibility { | export function toggleSeries(hidden: SeriesVisibility, dataKey: string): SeriesVisibility { | export interface DeltaAnnotation { | export function resolveDeltaAnnotation(delta: number | null | undefined): DeltaAnnotation | null {",
      "source_heading_line": 7365,
      "source_end_line": 7373
    },
    {
      "path": "components/qaf-differences/qaf-differences-client.tsx",
      "category": "ui",
      "declared_lines": 539,
      "comment_summary": "",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export function QafDifferencesClient({ projects, recentComparisons }: Props) {",
      "source_heading_line": 7374,
      "source_end_line": 7379
    },
    {
      "path": "components/qaf-differences/qaf-explain-panel.tsx",
      "category": "ui",
      "declared_lines": 271,
      "comment_summary": "Consolidated Zelle→Ergebnis Explain-Panel (KAR-922/P6.2). Renders the full Master-Prompt §17 traceability attribute set for ONE result value — everything lib/qaf-differences/internal/explain-provenance.ts already assembled server-side (buildManufacturingExplainAttributes/ buildSummaryExplainAttributes/...). This component consumes ONLY the already-serialized ExplainAttributes prop — no registry/parser import here (Client-Bundle-Disziplin, same rule qaf-provenance.tsx/qaf-comparison- detail.tsx already follow, see PR body's manifest comparison). 'use client' only for useI18n() + the local expand/collapse state — same small-interactive-component category as qaf-provenance.tsx/ qaf-plausibility-message.tsx. Pure helpers are extracted below and unit- tested instead (see __tests__/qaf-explain-panel.test.ts) — no RTL in this repo, same pattern qaf-provenance.tsx/qaf-projection.tsx use. tdd-guard:skip — presentational; pure helpers below are separately tested.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export function formatExplainValue<T>(v: ExplainValue<T>, locale: Locale, render: (value: T) => string): string { | export function formatExplainString(v: ExplainValue<string>, locale: Locale): string { | export function formatExplainNumber(v: ExplainValue<number>, locale: Locale): string { | export function formatExplainFormula(v: ExplainValue<{ raw: string; normalized: string }>, locale: Locale): string { | export function formatExplainEngineVersion(v: ExplainValue<Record<string, unknown>>, locale: Locale): string { | export function formatExplainBilingual(v: ExplainValue<DecodedMessage>, locale: Locale): string { | export function formatExplainMappingMethod(v: ExplainValue<MappingMethodKind>, locale: Locale): string { | export function formatExplainQafGuideRequirement(v: ExplainValue<QafGuideRequirementValue>, locale: Locale): string { | export function fmtExplainTimestamp(iso: string): string { | export interface ExplainAttributeRow { | export function buildExplainRows(attrs: ExplainAttributes, locale: Locale): ExplainAttributeRow[] { | export interface QafExplainPanelProps { | export function QafExplainPanel({ attrs, altLabel = 'ALT', neuLabel = 'NEU' }: QafExplainPanelProps) {",
      "source_heading_line": 7380,
      "source_end_line": 7412
    },
    {
      "path": "components/qaf-differences/qaf-export-button.tsx",
      "category": "ui",
      "declared_lines": 26,
      "comment_summary": "tdd-guard:skip — thin wrapper around the shared QafXlsxExportButton (KAR-949 review Finding 3 — cleanup): keeps this file/import path stable for existing call sites, all UI/Blob-Glue logic now lives in qaf-xlsx-export-button.tsx (this file used to be a byte-identical copy of it, differing only in the action/label).",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "",
      "source_heading_line": 7413,
      "source_end_line": 7421
    },
    {
      "path": "components/qaf-differences/qaf-export-controls.tsx",
      "category": "ui",
      "declared_lines": 79,
      "comment_summary": "",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export function SectionPngButton({ exportId, partNumber }: { exportId: string; partNumber: string | null }) { | export function GlobalExportButtons({",
      "source_heading_line": 7422,
      "source_end_line": 7428
    },
    {
      "path": "components/qaf-differences/qaf-field-mapping-override.tsx",
      "category": "ui",
      "declared_lines": 293,
      "comment_summary": "",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export interface StepOptionWithSourceCells extends StepOption { | export const FIELD_LABEL_BY_KEY: Record<QAFFieldKey, string> = Object.fromEntries(",
      "source_heading_line": 7429,
      "source_end_line": 7435
    },
    {
      "path": "components/qaf-differences/qaf-g60-anomalies-table.tsx",
      "category": "ui",
      "declared_lines": 115,
      "comment_summary": "",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "",
      "source_heading_line": 7436,
      "source_end_line": 7438
    },
    {
      "path": "components/qaf-differences/qaf-g60-detail.tsx",
      "category": "ui",
      "declared_lines": 381,
      "comment_summary": "G60 comparison detail view (KAR-840 stage G3) — the V11 detail-QAF analysis: headline KPIs, six-bucket cost structure + bridge (shared charts), the per-tab metric table with PART-SPECIFIC flags, tornado over cost tabs, production index, anomalies & drivers (INPUT card) and the G60 levers. tdd-guard:skip — presentational composition; every view-model it renders (internal/g60/view.ts, analyze.ts) is pure and unit-tested.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export function QafG60Detail({",
      "source_heading_line": 7439,
      "source_end_line": 7451
    },
    {
      "path": "components/qaf-differences/qaf-g60-export-button.tsx",
      "category": "ui",
      "declared_lines": 26,
      "comment_summary": "tdd-guard:skip — thin wrapper around the shared QafXlsxExportButton (KAR-949 review Finding 3 — cleanup): keeps this file/import path stable for existing call sites, all UI/Blob-Glue logic now lives in qaf-xlsx-export-button.tsx (this file used to be a byte-identical copy of qaf-export-button.tsx, differing only in the action/label).",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "",
      "source_heading_line": 7452,
      "source_end_line": 7460
    },
    {
      "path": "components/qaf-differences/qaf-g60-metrics-table.tsx",
      "category": "ui",
      "declared_lines": 176,
      "comment_summary": "",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export function isConspicuous(r: G60MetricRow): boolean { | export function groupByTab(rows: G60MetricRow[], onlyConspicuous: boolean, query: string): TabGroup[] {",
      "source_heading_line": 7461,
      "source_end_line": 7467
    },
    {
      "path": "components/qaf-differences/qaf-g60-prod-chart.tsx",
      "category": "ui",
      "declared_lines": 90,
      "comment_summary": "",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export interface ProdChartRow { | export function logSafe(rows: G60ProdIndexRow[], useLog: boolean): ProdChartRow[] {",
      "source_heading_line": 7468,
      "source_end_line": 7474
    },
    {
      "path": "components/qaf-differences/qaf-g60-scenario.tsx",
      "category": "ui",
      "declared_lines": 478,
      "comment_summary": "",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export interface ScenarioTabInput {",
      "source_heading_line": 7475,
      "source_end_line": 7480
    },
    {
      "path": "components/qaf-differences/qaf-g60-section-info.ts",
      "category": "ui",
      "declared_lines": 54,
      "comment_summary": "Per-section explanations for the G60 detail view (KAR-842 C, V11 SEC_INFO pattern). Kais: \"Logik hinter der Formel und Nutzen? Beispiel?\" — every section gets plain language + formula + a worked example. tdd-guard:skip — static copy, no logic.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export const G60_SECTION_INFO: Record<string, SectionInfo> = {",
      "source_heading_line": 7481,
      "source_end_line": 7491
    },
    {
      "path": "components/qaf-differences/qaf-movers-chart.tsx",
      "category": "ui",
      "declared_lines": 134,
      "comment_summary": "",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export const TOP_N_OPTIONS: TopN[] = [5, 12, 20, 'all'] | export function tornadoHeight(rowCount: number): number { | export function yAxisWidth(labels: string[]): number {",
      "source_heading_line": 7492,
      "source_end_line": 7499
    },
    {
      "path": "components/qaf-differences/qaf-multi-qaf-code-labels.ts",
      "category": "ui",
      "declared_lines": 311,
      "comment_summary": "Central bilingual (DE/EN) plain-language label maps for internal machine codes the Multi-QAF engine persists (KAR-950 — Multi-QAF Detail-UI Aufräum-Pass, task item 6/7). Presentation-layer only: every map here translates an already-persisted `code`/`gate`/`reason`/`checkId` string into a short, jargon-free sentence for the visible UI — it never changes WHAT the engine computed, only HOW it reads. The engine's own raw message/messageDe/messageEn strings (which sometimes embed source file names like \"variant-matcher.ts\" or \"differ.ts/computeNumericDelta\" for internal traceability, e.g. aggregate-impact.ts's own `assumptions`/ `excludedVariants` messages) are never rendered verbatim in the visible UI flow any more — callers pass them as a `title` attribute instead (still inspectable/greppable in the DOM, never part of the readable flow text — task requirement: \"Quellcode-Dateinamen niemals im sichtbaren UI-Fließtext\"). Every map has a safe, non-crashing fallback for a code this file does not (yet) know — see `labelFor` below — so a future engine-side code addition degrades to \"raw code, still visible\" instead of a runtime error or a silently blank label. tdd-guard:skip — static label data + trivial lookup helpers, no business logic.",
      "mentions_todo_or_future": true,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export interface BilingualLabel { | export function labelFor(map: Record<string, BilingualLabel>, code: string): BilingualLabel { | export function gateLabelFor(gate: string): BilingualLabel & { description: BilingualLabel | null } { | export const WARNING_CODE_LABEL: Record<string, BilingualLabel> = { | export const RECONCILIATION_CHECK_LABEL: Record<VariantReconciliationCheckId, BilingualLabel> = { | export const NICHT_PRUEFBAR_REASON_LABEL: Record<VariantReconciliationNichtPruefbarReason, BilingualLabel> = { | export const REVIEW_REASON_LABEL: Record<string, BilingualLabel> = { | export const GATE_LABEL: Record<AggregateImpactGateId, BilingualLabel & { description: BilingualLabel }> = { | export const EXCLUSION_REASON_LABEL: Record<AggregateImpactExclusionReason, BilingualLabel> = { | export const ASSUMPTION_LABEL: Record<string, BilingualLabel> = {",
      "source_heading_line": 7500,
      "source_end_line": 7534
    },
    {
      "path": "components/qaf-differences/qaf-multi-qaf-detail.tsx",
      "category": "ui",
      "declared_lines": 1810,
      "comment_summary": "Multi-QAF comparison detail view (KAR-947 — Master-Prompt §18/§19). The real detail UI for `comparison_mode: 'multi_qaf'`, reading EXCLUSIVELY from the already-persisted `MultiQafComparisonResult` (compare-flow.ts) + both sides' already-persisted `MultiQafContainer`s (serialization.ts) — no re-parse, same \"compute once, read from storage thereafter\" discipline every other Multi-QAF module already establishes. Additive: the Standard (qaf-comparison-detail.tsx) and G60 (qaf-g60-detail.tsx) detail views are byte-identical, untouched by this file. Structural convention mirrors KAR-844 (qaf-section-registry.ts, qaf-g60-detail.tsx as the typ-specific-detail-view precedent): numbered sections (qaf-multi-qaf-section-info.ts), each wrapped in the shared `Section` component with its own expandable ⓘ info text. Every Result- version-tolerant field (a comparison computed before a given feature shipped) renders an explicit \"nicht verfügbar für diesen Vergleich\" placeholder — never a crash, never a silently-empty section that could be misread as \"zero differences\". tdd-guard:skip — presentational composition over already-unit-tested pure engine output (compare-flow.ts + its P2 modules); real-corpus render coverage lives in lib/qaf-differences/internal/multi-qaf/__tests__/detail-ui.real-files.test.tsx.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export interface QafMultiQafDetailProps { | export function QafMultiQafDetail({ comparisonId, altContainer, neuContainer, altFileName, neuFileName, result, persistedOverrides }: QafMultiQafDetailProps) {",
      "source_heading_line": 7535,
      "source_end_line": 7562
    },
    {
      "path": "components/qaf-differences/qaf-multi-qaf-hero.tsx",
      "category": "ui",
      "declared_lines": 103,
      "comment_summary": "Hero-Summary / Übersichtskopf for the Multi-QAF comparison detail view (KAR-952 — Viewer-Adoption Item 2, Adoptions-Spec §D2). A verdict-before- data KPI row at the very top of the page — mirrors both the reference viewer's own \"Vertrauens-/Eignungs-Aussage kommt VOR den Daten, nicht implizit aus den Daten ableitbar\" principle (§A Darstellungsprinzip 2) and the \"gute\" Standard-QAF-PDF's Hero-KPI convention (§C Gap-Analyse: \"Keine Executive-Summary/Hero-KPI-Zeile am Seitenanfang\" was the #1 PDF-2 priority finding). Reads EXCLUSIVELY from the already-persisted `MultiQafComparisonResult` — no new engine computation, no new field. Honesty invariant: `result.aggregateImpact === null` (a comparison saved before KAR-944) renders \"noch nicht berechnet\", NEVER a silent 0 — same distinction `AggregateSection` already draws lower on the page (Section 7), just surfaced here first. tdd-guard:skip — presentational composition over already-computed result fields.",
      "mentions_todo_or_future": true,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export function QafMultiQafHero({ result }: { result: MultiQafComparisonResult | null }) {",
      "source_heading_line": 7563,
      "source_end_line": 7583
    },
    {
      "path": "components/qaf-differences/qaf-multi-qaf-match-stats.ts",
      "category": "ui",
      "declared_lines": 30,
      "comment_summary": "Shared, tiny pure helper for the Varianten-Matching headline counters (KAR-952 — Viewer-Adoption). Used by both the new Hero-Summary (qaf-multi-qaf-hero.tsx, Adoptions-Spec §D2) and the Vergleichbarkeits- Leiter (Section 1 add-on in qaf-multi-qaf-detail.tsx, §D7) — a single source for \"matched/added/removed/uncertain\" so the two new call sites can never silently drift apart from each other on the exact same `VariantMatchResult.kind` filters `MatchingSection` already uses inline. Deliberately NOT re-exported from/into `MatchingSection` itself — that component's own local filtered ARRAYS (not just counts) are unchanged by this file, avoiding any risk to its already-covered rendering logic. tdd-guard:skip — trivial array-filter counting, no business logic.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export interface MatchCounts { | export function matchCounts(matchResult: readonly VariantMatchResult[]): MatchCounts {",
      "source_heading_line": 7584,
      "source_end_line": 7602
    },
    {
      "path": "components/qaf-differences/qaf-multi-qaf-material-chart.tsx",
      "category": "ui",
      "declared_lines": 164,
      "comment_summary": "",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export interface MaterialCostDriverBar { | export function collectMaterialCostDrivers(diff: MaterialDiffResult): readonly MaterialCostDriverBar[] { | export function topDriversByCurrency(rows: readonly MaterialCostDriverBar[], n = 8): ReadonlyMap<string, MaterialCostDriverBar[]> { | export function driverChartLabel(currency: string, shownCount: number, totalCount: number): string { | export function QafMultiQafMaterialCostDriverChart({ diff }: { diff: MaterialDiffResult }) {",
      "source_heading_line": 7603,
      "source_end_line": 7612
    },
    {
      "path": "components/qaf-differences/qaf-multi-qaf-material-section.tsx",
      "category": "ui",
      "declared_lines": 402,
      "comment_summary": "",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export function QafMultiQafMaterialSection({ diff }: { diff: MaterialDiffResult }) {",
      "source_heading_line": 7613,
      "source_end_line": 7618
    },
    {
      "path": "components/qaf-differences/qaf-multi-qaf-nav.tsx",
      "category": "ui",
      "declared_lines": 97,
      "comment_summary": "Sticky section navigation + persistent status badge for the Multi-QAF comparison detail view (KAR-952 — Viewer-Adoption Items 3+6, Adoptions- Spec §D3/§D6). Adopts two reference-viewer principles onto our existing 8 `Section`s: (a) \"Sektions-Navigation statt reiner Scroll-Liste\" — jump anchors so a reviewer can go straight to e.g. \"Rekonziliation\" without scrolling past everything above it; ALL content stays fully reachable (plain in-page anchors, nothing hidden behind a tab-only view — the reference viewer's flat \"reines Klick\"-nav without any hide-the-rest behavior), and (b) \"Persistenter Status/Gate-Chrome unabhängig vom Navigationszustand\" — a compact, always-visible review-status pill. Deliberately plain anchor links (`<a href=\"#section-…\">`), NOT a client- side `onClick`/`scrollIntoView` handler: native browser in-page navigation already gives free `Deep-Links (URL-Hash je Sektion)` (Adoptions-Spec §D3 \"sind ein Plus\") with zero JS, and needs no `'use client'` directive — keeps this component a plain Server Component, consistent with this file's own \"push data fetching UP, interactivity DOWN\" rule (CLAUDE.md). Active-section highlighting on scroll (IntersectionObserver) was considered and intentionally left out — a real UX nicety, but it would force this into a Client Component for a browser API this repo's jsdom test environment does not currently polyfill; see PR body \"Follow-ups\". `position: sticky` needs no coordination with a page-level header here: `app/qaf-differences/[id]/page.tsx` has no `CollapsibleHeader` — its own `AppShell` sidebar/mobile-nav are `fixed` (not `sticky`, not top-of- viewport on this page), so `top-0` is safe (verified by reading both files before adding this). tdd-guard:skip — presentational composition, no logic beyond a status pill.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": true,
      "public_signatures": "export interface QafMultiQafNavSection { | export function QafMultiQafNav({",
      "source_heading_line": 7619,
      "source_end_line": 7652
    },
    {
      "path": "components/qaf-differences/qaf-multi-qaf-section-info.ts",
      "category": "ui",
      "declared_lines": 62,
      "comment_summary": "Canonical section structure + ⓘ info-texts for the Multi-QAF comparison detail view (KAR-947, following the KAR-844 numbered-section convention — see qaf-section-registry.ts for the Standard/G60 precedent this mirrors). Own, SEPARATE numbering from QAF_SECTION_TITLES: a Multi-QAF container comparison has no bucket/bridge/movers/scenario concept in the Master- Prompt §18 sense — reusing those numbers here would imply a 1:1 content correspondence that does not exist. Additive only: this file introduces no change to qaf-section-registry.ts or qaf-g60-section-info.ts. tdd-guard:skip — static registry, no logic.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export const MULTI_QAF_SECTION_TITLES = { | export type MultiQafSectionKey = keyof typeof MULTI_QAF_SECTION_TITLES | export const MULTI_QAF_SECTION_INFO: Record<MultiQafSectionKey, SectionInfo> = {",
      "source_heading_line": 7653,
      "source_end_line": 7670
    },
    {
      "path": "components/qaf-differences/qaf-multi-qaf-source-refs.tsx",
      "category": "ui",
      "declared_lines": 62,
      "comment_summary": "",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export interface SourceRefLike { | export function MultiQafSourceRefs({ refs, label }: { refs: readonly SourceRefLike[]; label?: string }) {",
      "source_heading_line": 7671,
      "source_end_line": 7677
    },
    {
      "path": "components/qaf-differences/qaf-multi-qaf-status-taxonomy.ts",
      "category": "ui",
      "declared_lines": 27,
      "comment_summary": "Shared status-color taxonomy for the Multi-QAF detail UI (KAR-952 — Viewer-Adoption, Adoptions-Spec §A Darstellungsprinzip 1: \"Eine Taxonomie, überall wiederverwendet (Makro- und Mikro-Ebene gleiche Farben/Labels)\"). ONE canonical status→class mapping, reused on the pair-level Vergleichbar- keits-Leiter (Section 1) AND on item-level pills (Section 2 classification, Section 6 reconciliation, Section 4 material status, Section 7 gates) — instead of N separately hand-maintained local color functions that could silently drift apart from each other. Semantics: 'ok' = grün (bestanden/kompatibel/bekannt), 'attention' = gelb (Teilweise/Review-relevant, braucht einen Blick, aber kein hartes Nein), 'blocked' = rot (Abweichung/inkompatibel/hart negativ), 'neutral' = grau (unklar/nicht verfügbar/entfallen). 'neutral' is DELIBERATELY never mapped to 'ok' — an honestly-unknown state (allen voran `nicht_pruefbar`, see `reconciliationStatusClass`'s own doc in qaf-multi-qaf-detail.tsx) must never read as \"geprüft ok\" (Master-Prompt fail-closed invariant). tdd-guard:skip — static class-string lookup table, no logic.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": true,
      "public_signatures": "export type StatusTaxonomyLevel = 'ok' | 'attention' | 'blocked' | 'neutral' | export const STATUS_TAXONOMY_CLASS: Record<StatusTaxonomyLevel, string> = {",
      "source_heading_line": 7678,
      "source_end_line": 7701
    },
    {
      "path": "components/qaf-differences/qaf-multi-qaf-variant-match-override.tsx",
      "category": "ui",
      "declared_lines": 152,
      "comment_summary": "",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export interface VariantMatchOverrideOption { | export interface VariantMatchOverrideProps { | export function QafMultiQafVariantMatchOverride({",
      "source_heading_line": 7702,
      "source_end_line": 7709
    },
    {
      "path": "components/qaf-differences/qaf-plausibility-message.tsx",
      "category": "ui",
      "declared_lines": 53,
      "comment_summary": "Locale-aware rendering of a persisted qaf_plausibility_issue.explanation value (KAR-906/P3.2). Small client component so a mostly-server-rendered parent (qaf-comparison-detail.tsx, qaf-g60-detail.tsx) does not itself need 'use client' just to read the UI locale — Next.js allows a Server Component to render a Client Component child. decodeBilingual is tolerant of legacy plain-German text with no bilingual marker (pre-KAR-906 rows) — those decode to { de: text, en: text }, so the EN branch below falls back to the same DE text automatically, matching the task's explicit \"Bestands-Issues ohne EN tolerant\" requirement. tdd-guard:skip — presentational primitive (locale pick + decode call), same category as qaf-section.tsx's Pill/PlaceholderSection; decodeBilingual itself is unit-tested in lib/qaf-differences/internal/__tests__/ bilingual-message.test.ts.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": true,
      "public_signatures": "export function PlausibilityMessage({ explanation, explanationEn, fallback }: PlausibilityMessageProps) {",
      "source_heading_line": 7710,
      "source_end_line": 7729
    },
    {
      "path": "components/qaf-differences/qaf-price-calculator.tsx",
      "category": "ui",
      "declared_lines": 377,
      "comment_summary": "",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "",
      "source_heading_line": 7730,
      "source_end_line": 7732
    },
    {
      "path": "components/qaf-differences/qaf-projection.tsx",
      "category": "ui",
      "declared_lines": 323,
      "comment_summary": "",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export function formatMEur(value: number, currency: string | null): string { | export function updateYearRow( | export function addYearRow(rows: ProjectionYearInput[], fallbackStartYear = 2026): ProjectionYearInput[] { | export function prependYearRow(rows: ProjectionYearInput[], fallbackStartYear = 2026): ProjectionYearInput[] { | export function removeYearRow(rows: ProjectionYearInput[], index: number): ProjectionYearInput[] { | export function NumericInput({",
      "source_heading_line": 7733,
      "source_end_line": 7743
    },
    {
      "path": "components/qaf-differences/qaf-provenance.tsx",
      "category": "ui",
      "declared_lines": 285,
      "comment_summary": "Confidence-/Provenance-UI primitives for the QAF comparison detail view (KAR-911/P4.1). Every value rendered here was already computed and persisted by the engine (source_cells/normalized on qaf_manufacturing_step — KAR-886/P0.1; manufacturingParseMeta.parseConfidence — KAR-893/P1.2; qaf_comparison.engine_version — KAR-799; templateFingerprint.deviations — KAR-895/P1.4; workbookSafety — KAR-914/P4.4; module coreFieldsFound — module-degradation.ts). This file only renders it — no new confidence scoring, no new business rules. 'use client' only for the locale switch (useI18n, same reason qaf-plausibility-message.tsx is a small client component under a server-rendered parent). Small, interactive (hover/tap-driven tooltips) — same category as components/ui/formula-tooltip.tsx, which is also untested. Pure helpers are extracted below and unit-tested instead (see __tests__/qaf-provenance.test.ts), same pattern qaf-projection.tsx uses for formatMEur/updateYearRow. tdd-guard:skip — presentational; pure helpers below are separately tested.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export function pickLocaleText(locale: Locale, de: string, en: string | null | undefined): string { | export function fmtConfidence(n: number | null | undefined): string { | export const WORKBOOK_SAFETY_BADGE_LABEL: Record<string, { de: string; en: string }> = { | export function workbookSafetyBadgeLabel(locale: Locale, issueType: string): string { | export function BilingualText({ de, en }: { de: ReactNode; en?: ReactNode }) { | export interface ManualOverrideProvenance { | export interface ProvenanceTooltipProps { | export function fmtOverrideWhen(iso: string): string { | export function ProvenanceTooltip({ | export interface WorkbookSafetyIssueLike { | export function WorkbookSafetyBadges({ issues }: { issues: readonly WorkbookSafetyIssueLike[] }) { | export function ModuleDegradationNotice({ sentence }: { sentence: { de: string; en: string } | null }) { | export interface TemplateCoverageContentProps { | export function TemplateCoverageContent({ classification, deviations, deviationsEn }: TemplateCoverageContentProps) {",
      "source_heading_line": 7744,
      "source_end_line": 7779
    },
    {
      "path": "components/qaf-differences/qaf-replace-file-button.tsx",
      "category": "ui",
      "declared_lines": 70,
      "comment_summary": "",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "",
      "source_heading_line": 7780,
      "source_end_line": 7782
    },
    {
      "path": "components/qaf-differences/qaf-review-toggle.tsx",
      "category": "ui",
      "declared_lines": 40,
      "comment_summary": "",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "",
      "source_heading_line": 7783,
      "source_end_line": 7785
    },
    {
      "path": "components/qaf-differences/qaf-section-registry.ts",
      "category": "ui",
      "declared_lines": 125,
      "comment_summary": "Canonical QAF section structure (KAR-844). Kais: every analysis shows the SAME numbered sections in the SAME order (\"Wiedererkennungswert\") — sections a QAF type cannot fill appear as compact explained placeholders instead of silently missing. Numbers and titles live here only. tdd-guard:skip — static registry, no logic.",
      "mentions_todo_or_future": true,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export const QAF_SECTION_TITLES = { | export type QafSectionKey = keyof typeof QAF_SECTION_TITLES | export const G60_PLACEHOLDERS = { | export const NO_DATA_REASON = | export const SCENARIO_RATE_DEGRADED_REASON = | export const SUMMARY_SECTION_INFO: Partial<Record<QafSectionKey, SectionInfo>> = {",
      "source_heading_line": 7786,
      "source_end_line": 7802
    },
    {
      "path": "components/qaf-differences/qaf-section.tsx",
      "category": "ui",
      "declared_lines": 141,
      "comment_summary": "Shared section frame + pill for the QAF comparison detail views (summary + G60). tdd-guard:skip — presentational primitives, no logic.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export function PlaceholderSection({ title, reason }: { title: string; reason: string }) { | export function UnavailableNotice({ reason }: { reason: string }) { | export function variantLabel(v: VariantDefinition | undefined | null): string { | export function Pill({ label, className }: { label: React.ReactNode; className: string }) { | export interface SectionInfo { | export function Section({",
      "source_heading_line": 7803,
      "source_end_line": 7816
    },
    {
      "path": "components/qaf-differences/qaf-step-matching.tsx",
      "category": "ui",
      "declared_lines": 182,
      "comment_summary": "",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export interface MatchRow { | export interface StepOption {",
      "source_heading_line": 7817,
      "source_end_line": 7823
    },
    {
      "path": "components/qaf-differences/qaf-supplier-benchmark-create.tsx",
      "category": "ui",
      "declared_lines": 166,
      "comment_summary": "",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export function QafSupplierBenchmarkCreate({ projectId }: Props) {",
      "source_heading_line": 7824,
      "source_end_line": 7829
    },
    {
      "path": "components/qaf-differences/qaf-supplier-benchmark-detail.tsx",
      "category": "ui",
      "declared_lines": 230,
      "comment_summary": "Darstellung des Lieferanten-Benchmarks (KAR-993 P2). Reine Präsentation ohne Client-State: die Kennzahlen werden serverseitig gerechnet (loadSupplierBenchmark) und hier nur formatiert. Deshalb kein 'use client' — die Komponente rendert im Server-Baum der Detail-Seite mit. Darstellungs-Doktrin, übernommen aus dem bestehenden Vergleich: eine nicht ermittelbare Kennzahl wird als solche BENANNT (mit Grund), nicht als 0, als Strich oder als leere Zelle gezeigt. Ein Benchmark, der stillschweigend Lücken glättet, wäre genau das Werkzeug, das Kais am 24-seitigen Multi-QAF-Bericht kritisiert hat.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export function QafSupplierBenchmarkDetail({ result }: { result: SupplierBenchmarkResult }) {",
      "source_heading_line": 7830,
      "source_end_line": 7845
    },
    {
      "path": "components/qaf-differences/qaf-swap-roles-button.tsx",
      "category": "ui",
      "declared_lines": 40,
      "comment_summary": "",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "",
      "source_heading_line": 7846,
      "source_end_line": 7848
    },
    {
      "path": "components/qaf-differences/qaf-tags-editor.tsx",
      "category": "ui",
      "declared_lines": 92,
      "comment_summary": "",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "",
      "source_heading_line": 7849,
      "source_end_line": 7851
    },
    {
      "path": "components/qaf-differences/qaf-title-editor.tsx",
      "category": "ui",
      "declared_lines": 91,
      "comment_summary": "",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "",
      "source_heading_line": 7852,
      "source_end_line": 7854
    },
    {
      "path": "components/qaf-differences/qaf-upload-constants.ts",
      "category": "ui",
      "declared_lines": 79,
      "comment_summary": "Shared QAF upload constants (client + server actions). Lives outside the 'use server' module because server-action files may only export async functions. tdd-guard:skip — constants + one predicate, exercised via the action/client tests.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": true,
      "public_signatures": "export const QAF_UPLOAD_BUCKET = 'qaf-uploads' | export const QAF_MAX_FILE_BYTES = 20 * 1024 * 1024 | export const QAF_MAX_BATCH_FILES = 12 | export function isAllowedQafFileName(name: string): boolean { | export function isLegacyXlsFileName(name: string): boolean { | export const LEGACY_XLS_UNSUPPORTED_MESSAGE_DE = | export function contentTypeFor(file: File): string { | export function uploadWithProgress(url: string, file: File, onLoaded: (bytes: number) => void): Promise<void> {",
      "source_heading_line": 7855,
      "source_end_line": 7872
    },
    {
      "path": "components/qaf-differences/qaf-v2-evidence-panel.tsx",
      "category": "ui",
      "declared_lines": 135,
      "comment_summary": "Das Zellnachweis-Panel des V2-Vergleichs (Spezifikation Kap. 22, U-01). Eine Zeile aufzuklappen zeigt überall dasselbe: welche Datei, welches Blatt, welche Zelle, welche Formel, welcher Zustand, welche Befunde. Genau deshalb gibt es diese Komponente einmal und nicht je Sektion — vier Panels wären vier Gelegenheiten, es verschieden zu machen. Die Auflösung selbst steckt in `evidenceFor` (geprüft in lib/qaf-differences/internal/__tests__/view-specs.test.ts); hier wird nur gerendert. Die Beschriftungen sind unten als reine Funktionen herausgezogen und eigens geprüft. tdd-guard:skip — darstellend; die reinen Helfer darunter sind getestet.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": true,
      "public_signatures": "export function cellReference(row: Pick<EvidenceRow, 'sheet' | 'cell'>): string { | export function fileLabel(role: EvidenceRow['fileRole']): string { | export function valueStateLabel(state: ValueState): string { | export function isWeakState(state: ValueState): boolean { | export function QafV2EvidencePanel({ rows, labelDe = 'Zellnachweis', defaultOpen = false }: Props) {",
      "source_heading_line": 7873,
      "source_end_line": 7893
    },
    {
      "path": "components/qaf-differences/qaf-v2-header.tsx",
      "category": "ui",
      "declared_lines": 154,
      "comment_summary": "Kopfbereich des V2-Vergleichs (Spezifikation Kap. 21). Was immer sichtbar bleibt: Was wird hier eigentlich verglichen, ist es vergleichbar, aus welchen Dateien, und hält der Lauf seine eigenen Prüfungen aus. Der letzte Punkt ist der wichtigste und in der Vorgängerfassung der schwächste: Ein Lauf mit gescheiterten Prüfungen sah aus wie jeder andere. Jetzt steht ein Banner darüber, und solange es steht, ist die KI-Zusammenfassung gesperrt — sichtbar, mit Grund. tdd-guard:skip — darstellend; die reinen Helfer darunter sind getestet.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": true,
      "public_signatures": "export type ComparisonType = | export function comparisonTypeLabel(type: ComparisonType): string { | export type ComparabilityStatus = 'comparable' | 'limited' | 'not_comparable' | 'undetermined' | export function comparabilityView(status: ComparabilityStatus): ComparabilityView { | export function shortHash(digest: string | null): string | null { | export function QafV2Header({",
      "source_heading_line": 7894,
      "source_end_line": 7914
    },
    {
      "path": "components/qaf-differences/qaf-v2-kpi-tile.tsx",
      "category": "ui",
      "declared_lines": 132,
      "comment_summary": "KPI-Kachel des V2-Vergleichs (Spezifikation Kap. 22, U-01). Jede Kachel nennt ihre Quelle. Nicht als Zugabe, sondern weil eine Zahl ohne nachschlagbare Herkunft in einer Verhandlung nichts wert ist — und weil die Vorgängerfassung genau daran gescheitert ist. Das Vorzeichen steht immer, auch bei Grün. Farbe allein trägt die Aussage nicht: nicht für jemanden, der Rot und Grün nicht unterscheidet, und nicht in einem Ausdruck. tdd-guard:skip — darstellend; die reinen Helfer darunter sind getestet.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": true,
      "public_signatures": "export function decimalsFor(unit: string): number { | export function formatValue(value: number | null, unit: string): string { | export function formatDelta(value: number | null, unit: string): string { | export function formatRatio(ratio: number | null): string { | export function statusClass(status: StatusChip): string { | export function QafV2KpiTile({ tile, onOpenDetail }: Props) {",
      "source_heading_line": 7915,
      "source_end_line": 7934
    },
    {
      "path": "components/qaf-differences/qaf-v2-reference-section.tsx",
      "category": "ui",
      "declared_lines": 59,
      "comment_summary": "Eine Sektion der Referenzansicht: Kopf, Kacheln, Brücke, Zellnachweis. Das ist die Übersichts-Sektion aus Kap. 21 in der Zusammensetzung, die View-Phase V1 verlangt — erstmals als Ganzes und nicht als vier einzelne Bausteine. Was sie zeigt, kommt aus `buildReferenceView` und damit aus denselben Funktionen, die ein echter Lauf benutzt. tdd-guard:skip — zusammensetzend; die Bausteine und der Referenzfall sind eigens geprüft, die Zusammensetzung über Snapshot-Tests.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": true,
      "public_signatures": "export function QafV2ReferenceSection({ view }: Props) {",
      "source_heading_line": 7935,
      "source_end_line": 7948
    },
    {
      "path": "components/qaf-differences/qaf-v2-waterfall.tsx",
      "category": "ui",
      "declared_lines": 258,
      "comment_summary": "Preisbrücken-Wasserfall des V2-Vergleichs (Spezifikation Kap. 22). Die entscheidende Eigenschaft dieser Komponente ist, was sie NICHT tut: Bleibt ein Restposten, zeichnet sie kein Diagramm, sondern zeigt die Diagnose. Ein Balken „Übrige\" versteckt genau den Betrag, dessen Herkunft niemand kennt, und lässt das Bild vollständig aussehen — das war der Befund F-01 an der Vorgängerfassung. Gerechnet wird nichts: `buildWaterfall` liefert Balken, Kanten und Zustand fertig (geprüft in lib/qaf-differences/internal/__tests__/chart-specs.test.ts). tdd-guard:skip — darstellend; die reinen Helfer darunter sind getestet.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": true,
      "public_signatures": "export interface BarGeometry { | export function barGeometry(bar: WaterfallBar, min: number, max: number): BarGeometry { | export function valueRange(bars: readonly WaterfallBar[]): { min: number; max: number } { | export function axisTicks(max: number, count = 5): number[] { | export function isHairline(geo: BarGeometry): boolean { | export function runningLevel(bar: WaterfallBar): number { | export function barClass(kind: WaterfallBar['kind']): string { | export function QafV2Waterfall({ spec, unit, onSelectStep }: Props) {",
      "source_heading_line": 7949,
      "source_end_line": 7971
    },
    {
      "path": "components/qaf-differences/qaf-variant-vs-standard-create.tsx",
      "category": "ui",
      "declared_lines": 374,
      "comment_summary": "",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export function QafVariantVsStandardCreate({ projectId }: Props) {",
      "source_heading_line": 7972,
      "source_end_line": 7977
    },
    {
      "path": "components/qaf-differences/qaf-variant-vs-standard-detail.tsx",
      "category": "ui",
      "declared_lines": 378,
      "comment_summary": "Multi-QAF-Variante ↔ Standard-QAF comparison detail view (KAR-948 — Master-Prompt §15 Szenario B / §18). Reads EXCLUSIVELY from the already-persisted `VariantVsStandardComparisonResult` (variant-vs- standard.ts, KAR-943) + the NEU side's already-persisted `MultiQafContainer` (serialization.ts) — no re-parse, same \"compute once, read from storage thereafter\" discipline qaf-multi-qaf-detail.tsx already establishes for the sibling Multi-QAF container-vs-container view. Additive: the Standard (qaf-comparison-detail.tsx), G60 (qaf-g60-detail.tsx) and Multi-QAF (qaf-multi-qaf-detail.tsx) detail views are byte-identical, untouched by this file. Structural convention mirrors qaf-multi-qaf-detail.tsx (itself mirroring KAR-844's qaf-g60-detail.tsx precedent): numbered sections (qaf-variant-vs-standard-section-info.ts), each wrapped in the shared `Section` component with its own expandable ⓘ info text. Every field is rendered defensively — a missing/undeserializable container, an unresolvable variantId, or an absent result renders an explicit \"nicht verfügbar für diesen Vergleich\" placeholder, never a crash and never a silently-empty section that could be misread as \"keine Unterschiede\" (task requirement — see the dedicated Section 4 \"Für Varianten-Vergleiche nicht verfügbar\" below, which is the ONE place the 12 structurally degraded standard-engine modules are named, with their reason, instead of 12 empty 0-diff sections). tdd-guard:skip — presentational composition over already-unit-tested pure engine output (variant-vs-standard.ts); real-corpus render coverage lives in lib/qaf-differences/internal/multi-qaf/__tests__/ variant-vs-standard-detail-ui.real-files.test.tsx.",
      "mentions_todo_or_future": true,
      "pure_or_no_io": true,
      "test_reference": true,
      "public_signatures": "export interface QafVariantVsStandardDetailProps { | export function QafVariantVsStandardDetail({ container, multiQafFileName, standardFileName, variantId, result }: QafVariantVsStandardDetailProps) {",
      "source_heading_line": 7978,
      "source_end_line": 8011
    },
    {
      "path": "components/qaf-differences/qaf-variant-vs-standard-section-info.ts",
      "category": "ui",
      "declared_lines": 39,
      "comment_summary": "Canonical section structure + ⓘ info-texts for the Multi-QAF-Variante ↔ Standard-QAF comparison detail view (KAR-948, Master-Prompt §15 Szenario B / §18). Own, SEPARATE numbering + registry from both QAF_SECTION_TITLES (standard/G60) and MULTI_QAF_SECTION_TITLES (Multi-QAF container ↔ container, KAR-947) — this comparison answers a structurally different question (ONE bridged variant vs. ONE standard QAF) with a genuinely different, smaller honest-coverage matrix (2 real modules, 12 structurally degraded — variant-vs-standard.ts's own module header). Reusing either sibling registry's numbering would imply a content correspondence that does not exist. Additive only: introduces no change to either sibling file. tdd-guard:skip — static registry, no logic.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export const VARIANT_VS_STANDARD_SECTION_TITLES = { | export type VariantVsStandardSectionKey = keyof typeof VARIANT_VS_STANDARD_SECTION_TITLES | export const VARIANT_VS_STANDARD_SECTION_INFO: Record<VariantVsStandardSectionKey, SectionInfo> = {",
      "source_heading_line": 8012,
      "source_end_line": 8032
    },
    {
      "path": "components/qaf-differences/qaf-xlsx-export-button.tsx",
      "category": "ui",
      "declared_lines": 105,
      "comment_summary": "tdd-guard:skip — Client-Download-Button; die Export-Erzeugung (rehydrate + buildXxxExportWorkbook) ist server-seitig + unit-getestet, hier nur UI/Blob-Glue. Shared XLSX-export-button component (KAR-949 review Finding 3 — cleanup). Before this file existed, qaf-export-button.tsx (KAR-799), qaf-g60-export-button.tsx (KAR-913/P4.3), qaf-multi-qaf-export-button.tsx and qaf-variant-vs-standard-export-button.tsx (both KAR-949) were four byte-identical copies of this component (~70 lines each incl. base64ToBlob), differing only in which server action they call and the button label text. All four export server actions (exportQafComparisonXlsx / exportQafG60ComparisonXlsx / exportMultiQafComparisonXlsx / exportVariantVsStandardComparisonXlsx) share the exact same signature `(comparisonId: string, generatedAtLabel: string) => Promise<ActionResult<QafExportDownload>>` (app/qaf-differences/actions.ts) — the download filename is always server-computed (`res.data.filename`), never client-supplied, so this component takes only the action + a button label, not a filename.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": true,
      "public_signatures": "export type QafXlsxExportAction = ( | export interface QafXlsxExportButtonProps {",
      "source_heading_line": 8033,
      "source_end_line": 8060
    },
    {
      "path": "app/qaf-differences/[id]/kalkulator/page.tsx",
      "category": "route_action",
      "declared_lines": 106,
      "comment_summary": "Per-comparison Preis-Kalkulator (KAR-848: 1:1 to its comparison). Auth + AppShell via segment layout. tdd-guard:skip — composition; math is tested.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": true,
      "public_signatures": "",
      "source_heading_line": 8061,
      "source_end_line": 8066
    },
    {
      "path": "app/qaf-differences/[id]/page.tsx",
      "category": "route_action",
      "declared_lines": 889,
      "comment_summary": "tdd-guard:skip — Next.js page: auth guard, loads one comparison + its children, composes the detail UI (KAR-799). All reads are RLS-scoped to the owning project.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export const metadata = {",
      "source_heading_line": 8067,
      "source_end_line": 8075
    },
    {
      "path": "app/qaf-differences/[id]/v2/page.tsx",
      "category": "route_action",
      "declared_lines": 138,
      "comment_summary": "tdd-guard:skip — Next.js page: auth guard, lädt die persistierten Zeilen, komponiert die Ansicht. Die V2-Übersicht eines bestehenden Vergleichs (Spezifikation Kap. 21, View-Phase V1). Woher die Daten kommen: nicht aus den Originaldateien. `qaf_file` führt keinen Storage-Pfad, die hochgeladenen Mappen sind nach dem Ingest nicht mehr adressierbar. `summaryMetricsFromRows` baut den Preisblock stattdessen aus den persistierten `qaf_summary_metric`-Zeilen zurück — derselbe Weg, den ein Re-Compare schon benutzt. Was dadurch fehlt, steht sichtbar auf der Seite: Aus der Datenbank kommen Werte und Zellen zurück, aber keine Formeln und keine Blattnamen. Kennzahlen und Preisbrücke sind vollständig, der Zellnachweis noch nicht.",
      "mentions_todo_or_future": true,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export const metadata = {",
      "source_heading_line": 8076,
      "source_end_line": 8093
    },
    {
      "path": "app/qaf-differences/actions.ts",
      "category": "route_action",
      "declared_lines": 4772,
      "comment_summary": "",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export type ActionResult<T = unknown> = { ok: true; data: T } | { ok: false; error: string } | export interface BatchAnalysisSummary { | export interface QafUploadTarget { | export async function createQafUploadTargets( | export async function discardQafUploads(projectId: string, paths: string[]): Promise<ActionResult<null>> { | export async function analyzeQafBatchFromStorage( | export interface QafExportDownload { | export async function exportQafComparisonXlsx( | export async function exportQafG60ComparisonXlsx( | export async function exportMultiQafComparisonXlsx( | export async function exportVariantVsStandardComparisonXlsx( | export interface ManualPinById { | export async function recompareComparison( | export async function setVariantMatchOverride( | export async function clearVariantMatchOverride(comparisonId: string, altVariantId: string): Promise<ActionResult<null>> { | export async function createVariantVsStandardComparison( | export interface MultiQafContainerFileOption { | export async function listMultiQafContainerFiles(projectId: string): Promise<ActionResult<MultiQafContainerFileOption[]>> { | export interface StandardQafFileOption { | export async function listStandardQafFilesForProject(projectId: string): Promise<ActionResult<StandardQafFileOption[]>> { | export interface MultiQafContainerVariantOptions { | export async function getMultiQafVariantOptions(projectId: string, fileId: string): Promise<ActionResult<MultiQafContainerVariantOptions>> { | export async function replaceComparisonFile( | export async function setManualFieldMappingOverride( | export async function clearManualFieldMappingOverride(fileId: string, fieldKey: string, rowKey: string): Promise<ActionResult<null>> { | export interface MeasuredStation { | export async function getMeasuredCycleTimes(projectId: string): Promise<ActionResult<MeasuredStation[]>> { | export async function saveComparisonTitle(comparisonId: string, title: string): Promise<ActionResult<null>> { | export interface CalculatorComparison { | export interface CalculatorSideParams {",
      "source_heading_line": 8094,
      "source_end_line": 8131
    },
    {
      "path": "app/qaf-differences/copilot-export-actions.ts",
      "category": "route_action",
      "declared_lines": 220,
      "comment_summary": "",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export type ActionResult<T = unknown> = { ok: true; data: T } | { ok: false; error: string } | export async function exportQafComparisonCopilotDocx( | export async function exportQafComparisonCopilotMd(",
      "source_heading_line": 8132,
      "source_end_line": 8139
    },
    {
      "path": "app/qaf-differences/layout.tsx",
      "category": "route_action",
      "declared_lines": 13,
      "comment_summary": "tdd-guard:skip — layout composition file; no isolated unit test worthwhile",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": true,
      "public_signatures": "",
      "source_heading_line": 8140,
      "source_end_line": 8144
    },
    {
      "path": "app/qaf-differences/page.tsx",
      "category": "route_action",
      "declared_lines": 52,
      "comment_summary": "tdd-guard:skip — Next.js page: auth guard, loads data, composes the client UI (KAR-799)",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export const maxDuration = 300 | export const metadata = {",
      "source_heading_line": 8145,
      "source_end_line": 8153
    },
    {
      "path": "app/qaf-differences/qaf-comparison-user-inputs-actions.ts",
      "category": "route_action",
      "declared_lines": 123,
      "comment_summary": "",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export type ActionResult<T = unknown> = { ok: true; data: T } | { ok: false; error: string } | export async function saveQafComparisonUserInputs( | export async function loadQafComparisonUserInputs(comparisonId: string): Promise<unknown> {",
      "source_heading_line": 8154,
      "source_end_line": 8161
    },
    {
      "path": "app/qaf-differences/referenz/page.tsx",
      "category": "route_action",
      "declared_lines": 68,
      "comment_summary": "tdd-guard:skip — Next.js page: auth guard, baut die Referenzfälle, komponiert die Ansicht. Referenzansicht der V2-Anzeigeebene (Spezifikation Kap. 27, View-Phase V1; Abnahme U-06). Warum es diese Seite gibt: Die V2-Anzeigeebene ist gebaut, aber an keine Datenquelle angeschlossen — in der App führt noch kein Weg von zwei Dateien zu einem Lauf. Ohne diese Seite liesse sich keine der Ansichten ansehen oder gegen U-01…U-08 abnehmen. Sie zeigt ausdrücklich Beispieldaten und verschwindet in dem Moment, in dem echte Läufe gerendert werden können.",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export const metadata = {",
      "source_heading_line": 8162,
      "source_end_line": 8176
    },
    {
      "path": "app/qaf-differences/supplier-benchmark-actions.ts",
      "category": "route_action",
      "declared_lines": 293,
      "comment_summary": "",
      "mentions_todo_or_future": false,
      "pure_or_no_io": false,
      "test_reference": false,
      "public_signatures": "export interface BenchmarkFileOption { | export async function listBenchmarkableFiles( | export async function createSupplierBenchmarkComparison( | export async function loadSupplierBenchmark(",
      "source_heading_line": 8177,
      "source_end_line": 8184
    }
  ]
}