vector-mirror 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,689 @@
1
+ /**
2
+ * prose.js — Spotter Report Formatter (Text)
3
+ * Vector Mirror v2.5 (P1-04 migrated)
4
+ *
5
+ * Adapter module: presentation-layer formatting.
6
+ * P1-04 changes:
7
+ * - Consumes tri-state shape: `arbitrated.{failing, unchecked, diff, totals}`.
8
+ * §E1: `arbitrated.failing` ist die EINE (gegatete) Wahrheitsquelle — der
9
+ * Caller (pipeline.js) reicht die honesty.js#gateCorrections-Liste dort
10
+ * rein (symmetrisch zu formatStructured). Kein separater 3.-Arg-Kanal mehr;
11
+ * so leakt KEIN Pixel-Delta (Objekt-Feld NOCH detail-STRING) und der
12
+ * fail-closed-Assert (REGEL-8) bewacht die konsumierte Liste lueckenlos.
13
+ * - Caps applied HERE (top-3 failing, top-3 unchecked, top-2 diff). Total
14
+ * suppressed-count printed; full set lives in structured output.
15
+ */
16
+
17
+ const FAILING_CAP = 3;
18
+ const UNCHECKED_CAP = 3;
19
+ const DIFF_CAP = 2;
20
+ const SANITIZE_LOSS_CAP = 3;
21
+ // §H9 P1: Längen-Deckel für das Wert-Echo in der Verlust-Zeile (Fremdtext).
22
+ const SANITIZE_LOSS_VALUE_CAP = 40;
23
+
24
+ /**
25
+ * §H9 P1 Echo-Hygiene: der gestrippte Attribut-WERT ist FREMDTEXT (Autor-/
26
+ * Angreifer-kontrolliert) und darf die Report-Grammatik nicht fälschen. EIN
27
+ * Schritt, EIN Prinzip — das Echo bleibt ein Zitat, nie Struktur:
28
+ * Whitespace (inkl. \n\r\t) zu einzelnen Spaces kollabieren (keine geforgte
29
+ * ✗-/STATUS-Zeile), doppelte Anführungszeichen neutralisieren (die
30
+ * `="…"`-Echo-Grammatik bleibt eindeutig), Länge kappen (keine 2000-Zeichen-
31
+ * Bombe). Wahrheit bleibt: ein gekürztes Echo ist ehrlich markiert (…).
32
+ */
33
+ function sanitizeValueEcho(value) {
34
+ const flat = String(value).replace(/\s+/g, ' ').replace(/"/g, "'");
35
+ return flat.length > SANITIZE_LOSS_VALUE_CAP
36
+ ? `${flat.slice(0, SANITIZE_LOSS_VALUE_CAP)}…`
37
+ : flat;
38
+ }
39
+
40
+ /**
41
+ * §H9 K-03/K-24/K-05: EINE Wahrheits-Quelle für die Verlust-Warnzeile
42
+ * (formatReport + formatErrorWithLoss — der frühere Wortlaut war physisch
43
+ * dupliziert UND behauptete hartkodiert "(id/name)" als Ursache, die der
44
+ * Emitter gar nicht kennen konnte). Die Zeile nennt die ECHTEN, vom Caller
45
+ * R9-stabilisierten Ursachen aus sanitizeLoss (tag + reason; bei gestripptem
46
+ * Attribut den konkreten Wert mit, z.B. die verlorene Autor-id). Cap klein
47
+ * (Top 3 + Rest-Zähler). Fehlt die Liste, wird KEINE Ursache geraten —
48
+ * lieber ehrlich generisch als plausibel lügen.
49
+ *
50
+ * @param {Array<{tag:string, reason:string, value?:string}>} [sanitizeLoss]
51
+ * @returns {string} die ⚠-Warnzeile.
52
+ */
53
+ function sanitizeLossLine(sanitizeLoss) {
54
+ const loss = Array.isArray(sanitizeLoss) ? sanitizeLoss : [];
55
+ const tokens = loss
56
+ .slice(0, SANITIZE_LOSS_CAP)
57
+ .map((l) =>
58
+ l.value !== undefined
59
+ ? `${l.tag} (${l.reason}="${sanitizeValueEcho(l.value)}")`
60
+ : `${l.tag} (${l.reason})`,
61
+ );
62
+ const rest = loss.length - tokens.length;
63
+ const head =
64
+ tokens.length > 0
65
+ ? `Sanitizer hat entfernt: ${tokens.join(' · ')}${rest > 0 ? ` · +${rest} weitere` : ''}`
66
+ : 'Sanitizer hat Inhalt entfernt';
67
+ return (
68
+ `⚠ Hinweis: ${head} — ` +
69
+ 'Messung referenzierender Elemente kann von der Quelle abweichen (canvas_validity=lossy)'
70
+ );
71
+ }
72
+
73
+ export function formatReport(gridMap, arbitrated, opts = {}) {
74
+ const lines = [];
75
+
76
+ // §HEAL-R6 / T1 "das Kabel" (F-AT-6-08): die Canvas-Validität aus dem Caller
77
+ // (pipeline.js, aus resolved.sanitize_loss DIREKT via classifyCanvas —
78
+ // Anti-LECK-3). 'lossy' ⇒ DOMPurify hat Inhalt entfernt; die
79
+ // Messung referenzierender Elemente kann von der Quelle abweichen. Wie
80
+ // paintDead/overflow ist das ein HINWEIS, der NIE unter "✓ Alles korrekt"
81
+ // verschwinden darf — reine Durchreichung des Caller-Verdikts, KEINE Mess-Logik.
82
+ // §H10 R11-13: das Verdikt ist VIERWERTIG (valid|default_replaced|degenerate|
83
+ // lossy) — JEDER nicht-valide Wert zählt als Hinweis (Kanal-Parität zu
84
+ // structured.scene.canvas_validity), nicht nur lossy.
85
+ const canvasValidity = opts.canvasValidity;
86
+ const canvasLossy = canvasValidity === 'lossy';
87
+
88
+ // §E1 R3 (Symmetrie + REGEL-8 dicht): prose hat — wie formatStructured — GENAU
89
+ // EINE failing-Wahrheitsquelle: `arbitrated.failing`. Der Caller (pipeline.js)
90
+ // reicht dort die GEGATETE Liste (honesty.js#gateCorrections) rein; es gibt
91
+ // keinen zweiten (ungegateten) Kanal und keinen klobigen 3.-Arg-Vertrag mehr.
92
+ // Der fail-closed-Assert (REGEL-8) bewacht damit die NATUERLICHE Aufruf-Form:
93
+ // jedes konsumierte failing-issue MUSS _gated tragen, sonst Wurf — eine
94
+ // ungegatete Emission ist mechanisch unmoeglich (kein `|| []`-Kollaps mehr,
95
+ // der den Assert umgeht). Gegatete Derivate sind um dx/dy/dw/dh UND um die
96
+ // Korrektur-Vorschreibung im detail-String bereinigt → kein Leak in beiden
97
+ // Kanaelen (FAILING-Top-Liste + Element-Baum).
98
+ const failing = arbitrated.failing || [];
99
+ for (const issue of failing) {
100
+ if (issue._gated === undefined) {
101
+ throw new Error(
102
+ 'formatReport: ungegatetes failing-issue (kein _gated) — ' +
103
+ 'gateCorrections am Emissions-Rand fehlt (REGEL-8 fail-closed).',
104
+ );
105
+ }
106
+ }
107
+ const unchecked = arbitrated.unchecked || [];
108
+ const diff = arbitrated.diff || [];
109
+
110
+ const failingCount = failing.length;
111
+ const uncheckedCount = unchecked.length;
112
+ const diffCount = diff.length;
113
+
114
+ // \u00A7HEAL-R6 / T1 PROSA-EHRLICHKEIT (F-AT-6-01, DoD-2): die Tinten-tot-Existenz
115
+ // (paint_visible:false / PAINT_NOT_VISIBLE) ist im structured-Kanal bereits
116
+ // ehrlich gemeldet \u2014 der PROSA-Kanal l\u00FCgt aber weiter ("red \u2713 / Alles korrekt").
117
+ // KEINE neue Mess-Logik: das Signal kommt fertig aus gridMap.elements (Renderer
118
+ // \u2192 grid.js-Durchreichung). KEIN erfundener Constraint-Fehler \u2014 nur die
119
+ // Existenz unsichtbarer Tinte sichtbar machen, konsistent zur Hinweis-Mechanik.
120
+ const { canvas, elements } = gridMap;
121
+ const paintDeadCount = elements.filter(isPaintDead).length;
122
+ // §HEAL-R6 Variante 1 PROSA-EHRLICHKEIT (F-AT-6-07, DoD-2-Schwanz): ein
123
+ // paint_visible:'indeterminate'-Element (räumlicher Operator present, Tinten-
124
+ // Präsenz raster-frei NICHT entscheidbar) darf NIE unter "✓ Alles korrekt"
125
+ // verschwinden — sonst lügt die Prosa weiter. Symmetrisch zu paintDeadCount,
126
+ // reine Durchreichung (gridMap.elements). Disjunkt zu isPaintDead.
127
+ const paintIndeterminateCount = elements.filter(isPaintIndeterminate).length;
128
+ // \u00A7HEAL-R6 / T2 PROSA-EHRLICHKEIT (F-AT-6-02/03, DoD-3): has_paint_overflow ist
129
+ // im structured-Kanal ehrlich (Tinte \u2014 Filter/stroke/Marker \u2014 ragt \u00FCber die als
130
+ // reliable gemeldete geom-bbox hinaus), der PROSA-Kanal verschwieg es aber
131
+ // ("\u2713 Alles korrekt"). KEINE neue Mess-Logik: das Signal kommt fertig aus
132
+ // gridMap.elements (Renderer \u2192 grid.js). Symmetrisch zur paintDead-Mechanik: ein
133
+ // Overflow z\u00E4hlt als Hinweis und darf NIE unter "\u2713 Alles korrekt" verschwinden.
134
+ const paintOverflowCount = elements.filter(isPaintOverflow).length;
135
+ // \u00A7D5 / R6-STATE PROSA-EHRLICHKEIT: ein zustands-abh\u00E4ngiges Element (interaktiver
136
+ // Alt-Zustand existiert) darf NIE unter \u201E\u2713 Alles korrekt" verschwinden \u2014 sonst
137
+ // l\u00FCgt die Prosa weiter. Symmetrisch zu paintOverflowCount, reine Durchreichung.
138
+ const stateDependentCount = elements.filter(isStateDependent).length;
139
+ // §F-AT-6-09 / R6-MEDIA PROSA-EHRLICHKEIT: ein viewport-abhängiges Element (ein
140
+ // anderer Viewport rendert es anders) darf NIE unter „✓ Alles korrekt" verschwinden
141
+ // — sonst lügt die Prosa weiter. Symmetrisch zu stateDependentCount, reine Durchreichung.
142
+ const mediaDependentCount = elements.filter(isMediaDependent).length;
143
+ // §HEAL-5 / Zeit-Achse PROSA-EHRLICHKEIT: ein zeit-variantes Element (die
144
+ // Geometrie ist an anderem t anders) darf NIE unter „✓ Alles korrekt"
145
+ // verschwinden — sonst lügt die Prosa weiter. Symmetrisch zu
146
+ // mediaDependentCount, reine Durchreichung.
147
+ const motionDependentCount = elements.filter(isMotionDependent).length;
148
+ // §H10 R11-06 / Paint-Zeit-Achse PROSA-EHRLICHKEIT: ein paint-zeit-variantes
149
+ // Element (Farbe/Opacity an anderem t anders) darf NIE unter „✓ Alles
150
+ // korrekt" verschwinden. Symmetrisch zu motionDependentCount, reine
151
+ // Durchreichung.
152
+ const paintTimeVariantCount = elements.filter(isPaintTimeVariant).length;
153
+ // \u00A7F-AT-7-02 PROSA-EHRLICHKEIT (SK5): ein stroke-Farb-Element (sichtbare Farbe aus
154
+ // dem stroke) bzw. ein Mehrfach-Quellen-Element darf NIE unter \u201E\u2713 Alles korrekt"
155
+ // verschwinden \u2014 sonst l\u00FCgt die Prosa weiter (\u201Etransparent \u2713"). Symmetrisch zu
156
+ // stateDependentCount/mediaDependentCount, reine Durchreichung (gridMap.elements).
157
+ const colorFromStrokeCount = elements.filter(isColorFromStroke).length;
158
+ const multiplePaintSourcesCount =
159
+ elements.filter(isMultiplePaintSources).length;
160
+
161
+ // 1. STATUS \u2014 paintDead + paintOverflow z\u00E4hlen als Hinweis (d\u00FCrfen NIE unter
162
+ // "\u2713 Alles korrekt" verschwinden). Folgt der bestehenden Hinweis-Logik (wie diffCount).
163
+ // \u00A7HEAL-R6 / T1: ein lossy-Canvas z\u00E4hlt als zus\u00E4tzlicher Hinweis (1) und darf
164
+ // \u2014 wie paintDead/overflow \u2014 NIE unter "\u2713 Alles korrekt" oder die reine
165
+ // "ungepr\u00FCft"-Zeile verschwinden. \u00A7H10 R11-13: gleiche Mechanik f\u00FCr ALLE
166
+ // nicht-validen canvas_validity-Werte (degenerate/default_replaced) \u2014 was
167
+ // structured flaggt, flaggt die Prosa (Parit\u00E4t, kein Schwere-Urteil).
168
+ // canvasNoteCount ist 0|1.
169
+ const canvasNoteCount =
170
+ canvasValidity !== undefined && canvasValidity !== 'valid' ? 1 : 0;
171
+ if (
172
+ failingCount === 0 &&
173
+ uncheckedCount === 0 &&
174
+ diffCount === 0 &&
175
+ paintDeadCount === 0 &&
176
+ paintIndeterminateCount === 0 &&
177
+ paintOverflowCount === 0 &&
178
+ stateDependentCount === 0 &&
179
+ mediaDependentCount === 0 &&
180
+ motionDependentCount === 0 &&
181
+ paintTimeVariantCount === 0 &&
182
+ colorFromStrokeCount === 0 &&
183
+ multiplePaintSourcesCount === 0 &&
184
+ canvasNoteCount === 0
185
+ ) {
186
+ lines.push('STATUS: \u2713 Alles korrekt');
187
+ } else if (
188
+ failingCount === 0 &&
189
+ uncheckedCount > 0 &&
190
+ diffCount === 0 &&
191
+ paintDeadCount === 0 &&
192
+ paintIndeterminateCount === 0 &&
193
+ paintOverflowCount === 0 &&
194
+ stateDependentCount === 0 &&
195
+ mediaDependentCount === 0 &&
196
+ motionDependentCount === 0 &&
197
+ paintTimeVariantCount === 0 &&
198
+ colorFromStrokeCount === 0 &&
199
+ multiplePaintSourcesCount === 0 &&
200
+ canvasNoteCount === 0
201
+ ) {
202
+ lines.push(
203
+ `STATUS: ${uncheckedCount} ungepr\u00FCft (Spotter blind, siehe structured)`,
204
+ );
205
+ } else {
206
+ const parts = [];
207
+ if (failingCount > 0) parts.push(`${failingCount} Fehler`);
208
+ if (uncheckedCount > 0) parts.push(`${uncheckedCount} ungepr\u00FCft`);
209
+ // Tinten-tot + Overflow + Diff + Sanitize-Verlust sind alle "Hinweise" (keine
210
+ // Spotter-Korrektur) \u2192 in EINEN Hinweis-Z\u00E4hler falten, damit die Zeile nicht
211
+ // mehrere Hinweis-Begriffe tr\u00E4gt. paintDead/paintOverflow/lossy bekommen
212
+ // zus\u00E4tzlich einen sprechenden Suffix-Vermerk.
213
+ const hinweisCount =
214
+ diffCount +
215
+ paintDeadCount +
216
+ paintIndeterminateCount +
217
+ paintOverflowCount +
218
+ stateDependentCount +
219
+ mediaDependentCount +
220
+ motionDependentCount +
221
+ paintTimeVariantCount +
222
+ colorFromStrokeCount +
223
+ multiplePaintSourcesCount +
224
+ canvasNoteCount;
225
+ if (hinweisCount > 0)
226
+ parts.push(`${hinweisCount} Hinweis${hinweisCount > 1 ? 'e' : ''}`);
227
+ const noteParts = [];
228
+ if (paintDeadCount > 0) noteParts.push(`${paintDeadCount} unsichtbar`);
229
+ if (paintIndeterminateCount > 0)
230
+ noteParts.push(`${paintIndeterminateCount} Sichtbarkeit unbestimmt`);
231
+ if (paintOverflowCount > 0)
232
+ noteParts.push(`${paintOverflowCount} Tinten-\u00DCberlauf`);
233
+ if (stateDependentCount > 0)
234
+ noteParts.push(`${stateDependentCount} zustands-abh\u00E4ngig`);
235
+ if (mediaDependentCount > 0)
236
+ noteParts.push(`${mediaDependentCount} viewport-abh\u00E4ngig`);
237
+ if (motionDependentCount > 0)
238
+ noteParts.push(`${motionDependentCount} zeit-variant`);
239
+ if (paintTimeVariantCount > 0)
240
+ noteParts.push(`${paintTimeVariantCount} paint-zeit-variant`);
241
+ if (colorFromStrokeCount > 0)
242
+ noteParts.push(`${colorFromStrokeCount} Farbe aus Rand`);
243
+ if (multiplePaintSourcesCount > 0)
244
+ noteParts.push(`${multiplePaintSourcesCount} mehrere Farbquellen`);
245
+ // §H10 R11-13: je nicht-valider canvas_validity-Wert ein sprechendes Token
246
+ // (Kanal-Parität: was structured flaggt, flaggt die Prosa). Unbekannte
247
+ // künftige Enum-Werte passieren NIE still (generisches Echo).
248
+ if (canvasNoteCount > 0) {
249
+ if (canvasLossy) noteParts.push('Sanitize-Verlust');
250
+ else if (canvasValidity === 'degenerate')
251
+ noteParts.push('Canvas degeneriert');
252
+ else if (canvasValidity === 'default_replaced')
253
+ noteParts.push(`Canvas-Default ${canvas.width}×${canvas.height}`);
254
+ else noteParts.push(`Canvas ${canvasValidity}`);
255
+ }
256
+ const suffix =
257
+ failingCount > 0 || uncheckedCount > 0
258
+ ? ' (Spotter-Korrektur aktiv)'
259
+ : noteParts.length > 0
260
+ ? ` (${noteParts.join(', ')}, siehe structured)`
261
+ : '';
262
+ lines.push(`STATUS: ${parts.join(', ')}${suffix}`);
263
+ }
264
+
265
+ // \u00A7HEAL-R6 / T1: die laute Verlust-Zeile. Direkt nach STATUS, damit sie der
266
+ // LLM nie \u00FCbersieht. \u00A7H9 K-03/K-24/K-05: sie nennt die ECHTEN Ursachen aus
267
+ // opts.sanitizeLoss (eine Wahrheits-Quelle: sanitizeLossLine) statt der
268
+ // fr\u00FCheren hartkodierten Behauptung "(id/name)". Erscheint NUR bei lossy
269
+ // (Negativ-Kontrolle: valid \u2192 keine Zeile).
270
+ // §H10 R11-13: degenerate/default_replaced tragen ihre eigene Wahrheits-Zeile
271
+ // (gemessener Sachverhalt + canvas_validity-Zeiger auf structured) — gleiche
272
+ // Mechanik, Hinweis statt Tadel (CSS-Default ist spec-konform).
273
+ if (canvasLossy) {
274
+ lines.push(sanitizeLossLine(opts.sanitizeLoss));
275
+ } else if (canvasValidity === 'degenerate') {
276
+ lines.push(
277
+ '⚠ Hinweis: viewBox degeneriert (nicht parsebar oder Breite/Höhe ≤0) — ' +
278
+ 'gemeldete Koordinaten beziehen sich auf einen Renderer-Fallback (canvas_validity=degenerate)',
279
+ );
280
+ } else if (canvasValidity === 'default_replaced') {
281
+ lines.push(
282
+ `⚠ Hinweis: SVG ohne width/height/viewBox — CSS-Default ${canvas.width}×${canvas.height} ` +
283
+ 'ersetzt die fehlende Deklaration (canvas_validity=default_replaced)',
284
+ );
285
+ } else if (canvasNoteCount > 0) {
286
+ lines.push(
287
+ `⚠ Hinweis: Canvas nicht valide (canvas_validity=${canvasValidity}, siehe structured)`,
288
+ );
289
+ }
290
+
291
+ // 2a. FAILING (Top 3)
292
+ const shownFailing = failing.slice(0, FAILING_CAP);
293
+ for (const issue of shownFailing) {
294
+ lines.push(
295
+ `\u2717 ${issue.detail || `${issue.constraintType || 'CONSTRAINT'} #${issue.id || '?'}`}`,
296
+ );
297
+ }
298
+
299
+ // 2b. UNCHECKED (Top 3) — visible reason, optional suggestion
300
+ const shownUnchecked = unchecked.slice(0, UNCHECKED_CAP);
301
+ for (const u of shownUnchecked) {
302
+ const tail = u.suggestedCorrection
303
+ ? ` (vielleicht: ${u.suggestedCorrection})`
304
+ : '';
305
+ const ref = u.id !== undefined ? `#${u.id}: ` : '';
306
+ lines.push(`? ${ref}${u.hint}${tail}`);
307
+ }
308
+
309
+ // 2c. DIFF (Top 2) — szene-aenderungen
310
+ const shownDiff = diff.slice(0, DIFF_CAP);
311
+ for (const d of shownDiff) {
312
+ switch (d.type) {
313
+ case 'VERSCHOBEN':
314
+ lines.push(`\u25B3 #${d.id}: ${d.from} \u2192 ${d.to}`);
315
+ break;
316
+ case 'FARB\u00C4NDERUNG':
317
+ lines.push(`\u25B3 #${d.id}: Farbe ${d.from} \u2192 ${d.to}`);
318
+ break;
319
+ case 'FORM\u00C4NDERUNG':
320
+ lines.push(`\u25B3 #${d.id}: ${d.from} \u2192 ${d.to} (Form)`);
321
+ break;
322
+ case 'NEU':
323
+ lines.push(`+ #${d.id}: neu in ${d.cell} (${d.color})`);
324
+ break;
325
+ case 'ENTFERNT':
326
+ lines.push(`- #${d.id}: entfernt (war in ${d.cell})`);
327
+ break;
328
+ }
329
+ }
330
+
331
+ // 3. Suppression-Hinweis: nur wenn wir oben gekuerzt haben
332
+ const suppressed =
333
+ failingCount -
334
+ shownFailing.length +
335
+ (uncheckedCount - shownUnchecked.length) +
336
+ (diffCount - shownDiff.length);
337
+ if (suppressed > 0) lines.push(` (${suppressed} weitere siehe structured)`);
338
+
339
+ // 4. SZENE
340
+ lines.push('');
341
+ const vbInfo = canvas.viewBox ? ` (viewBox: ${canvas.viewBox})` : '';
342
+ lines.push(
343
+ `SZENE: ${canvas.width}\u00D7${canvas.height}, ${elements.length} Elemente${vbInfo}`,
344
+ );
345
+
346
+ // \u00A7H10 R11-01: Existenz-Register \u2014 css-unsichtbare Elemente sind nicht Teil
347
+ // der Szene (Emissions-Menge byte-stabil), d\u00FCrfen aber nicht verschwiegen
348
+ // werden (Kanal-Parit\u00E4t zu scene.hidden_elements; Stil der suppressed-Zeile).
349
+ const hiddenCount = Array.isArray(gridMap.hidden) ? gridMap.hidden.length : 0;
350
+ if (hiddenCount > 0)
351
+ lines.push(
352
+ ` (${hiddenCount} Element${hiddenCount > 1 ? 'e' : ''} css-unsichtbar \u2014 siehe structured)`,
353
+ );
354
+
355
+ // §HEAL-7/B (F-TF-003): Verdikt-/Korrektur-Attribution per OBJEKT-IDENTITÄT.
356
+ // checkAllConstraints misst per .find() das ERSTE Element einer id — ein
357
+ // failing-issue gehört daher GENAU diesem Objekt. Der reine id-String-
358
+ // Vergleich heftete Korrektur + ✗ auch an UNGEMESSENE Namensvettern
359
+ // (Boden-Wahrheit f003: beide rect#x trugen [dy=2470px] ✗). firstById
360
+ // spiegelt die Mess-Semantik: nur das gemessene Objekt trägt Korrektur/✗.
361
+ // Zweite Verteidigungslinie — die Pipeline-Wache (MEASUREMENT_AMBIGUOUS)
362
+ // lässt ambige ids gar nicht erst in failing. unchecked (⚠) bleibt bewusst
363
+ // id-basiert: eine VERWEIGERTE Messung impliziert ALLE Namensvettern (kein
364
+ // Objekt wurde gemessen, der Vorbehalt gilt dem Namen).
365
+ const firstById = new Map();
366
+ for (const e of elements) {
367
+ if (!firstById.has(e.id)) firstById.set(e.id, e);
368
+ }
369
+ const isMeasuredCarrier = (el) => firstById.get(el.id) === el;
370
+
371
+ // 5. ELEMENT-BAUM (Top 7)
372
+ const maxShow = Math.min(elements.length, 7);
373
+ for (let i = 0; i < maxShow; i++) {
374
+ const el = elements[i];
375
+ const prefix = i < maxShow - 1 ? '\u251C\u2500' : '\u2514\u2500';
376
+ // \u00A7HEAL-R6 / T1: Tinten-tot ist ORTHOGONAL zum Constraint-Verdikt. getStatus
377
+ // bleibt die Constraint-Wahrheit (\u2717 fail / \u26A0 unchecked / \u2713 ok); ein bare \u2713
378
+ // wird bei Tinten-Tot zu \u26A0 degradiert (kein falsches "korrekt"), ein echtes \u2717
379
+ // bleibt \u2717 (Constraint-Fehler dominiert). Der sprechende Vermerk h\u00E4ngt IMMER
380
+ // an, wenn paint-tot \u2014 so verschwindet die Unsichtbarkeit in KEINEM Fall.
381
+ const paintDead = isPaintDead(el);
382
+ // \u00A7HEAL-R6 Variante 1: Tinten-Unbestimmtheit ist ORTHOGONAL zum Constraint-
383
+ // Verdikt UND zu paintDead. STRIKT getrennt: ein indeterminate-Element ist NICHT
384
+ // tot (KEINE "unsichtbar"-Behauptung), aber auch NICHT als korrekt-sichtbar zu
385
+ // melden \u2014 ehrlich unbestimmt. Wie paintDead degradiert es ein bare \u2713 zu \u26A0.
386
+ const paintIndeterminate = isPaintIndeterminate(el);
387
+ // \u00A7HEAL-R6 / T2: Tinten-\u00DCberlauf ist ORTHOGONAL zum Constraint-Verdikt UND zu
388
+ // paintDead (ein Element kann beides ODER nur eines tragen). Wie paintDead
389
+ // degradiert ein bare \u2713 zu \u26A0 (kein falsches "korrekt"), ein echtes \u2717 bleibt \u2717.
390
+ const paintOverflow = isPaintOverflow(el);
391
+ // \u00A7D5 / R6-STATE: Zustands-Abh\u00E4ngigkeit ist ORTHOGONAL zum Constraint-Verdikt
392
+ // UND zu paintDead/overflow. Wie diese degradiert sie ein bare \u2713 zu \u26A0 (kein
393
+ // falsches \u201Ekorrekt"), ein echtes \u2717 bleibt \u2717. KEINE \u201Eunsichtbar"-Behauptung.
394
+ const stateDependent = isStateDependent(el);
395
+ // §F-AT-6-09 / R6-MEDIA: Viewport-Abhängigkeit ist ORTHOGONAL zum Constraint-
396
+ // Verdikt UND zu paintDead/overflow/state. Wie diese degradiert sie ein bare
397
+ // Häkchen zu ⚠ (kein falsches "korrekt"), ein echtes ✗ bleibt ✗.
398
+ const mediaDependent = isMediaDependent(el);
399
+ // \u00A7HEAL-5 / Zeit-Achse: Zeit-Varianz ist ORTHOGONAL zum Constraint-Verdikt
400
+ // UND zu paintDead/overflow/state/media. Wie diese degradiert sie ein bare
401
+ // H\u00E4kchen zu \u26A0 (kein falsches "korrekt"), ein echtes \u2717 bleibt \u2717.
402
+ const motionDependent = isMotionDependent(el);
403
+ // §H10 R11-06 / Paint-Zeit-Achse: Paint-Zeit-Varianz ist ORTHOGONAL zum
404
+ // Constraint-Verdikt UND zu motionDependent (Geometrie-Zeit ≠ Paint-Zeit).
405
+ // Wie diese degradiert sie ein bare ✓ zu ⚠, ein echtes ✗ bleibt ✗.
406
+ const paintTimeVariant = isPaintTimeVariant(el);
407
+ // \u00A7F-AT-7-02: die sichtbare Farbe stammt aus dem stroke bzw. mehrere Farbquellen
408
+ // malen sichtbar \u2014 ORTHOGONAL zum Constraint-Verdikt. Wie die anderen Notes
409
+ // degradiert ein bare \u2713 zu \u26A0 (kein falsches \u201Ekorrekt"), ein echtes \u2717 bleibt \u2717.
410
+ const colorFromStroke = isColorFromStroke(el);
411
+ const multiplePaintSources = isMultiplePaintSources(el);
412
+ let status = getStatus(el, failing, unchecked, isMeasuredCarrier(el));
413
+ if (
414
+ (paintDead ||
415
+ paintIndeterminate ||
416
+ paintOverflow ||
417
+ stateDependent ||
418
+ mediaDependent ||
419
+ motionDependent ||
420
+ paintTimeVariant ||
421
+ colorFromStroke ||
422
+ multiplePaintSources) &&
423
+ status === '\u2713'
424
+ )
425
+ status = '\u26A0';
426
+ // Vermerk OHNE eigenes Glyph \u2014 das Status-Glyph (\u26A0) am Zeilenende tr\u00E4gt das
427
+ // Symbol. Der Text macht die Ursache(n) explizit. Beide Vermerke k\u00F6nnen
428
+ // gleichzeitig anh\u00E4ngen (orthogonal). visual_bbox-Zahl, wenn messbar, sonst
429
+ // 'not_measurable' (ehrlich: \u00DCberlauf da, Schranke unsicher). paintDead und
430
+ // paintIndeterminate sind disjunkt (false \u2260 'indeterminate'); der unbestimmte
431
+ // Vermerk behauptet NIE Unsichtbarkeit, nur Unbestimmtheit.
432
+ const paintNote = paintDead
433
+ ? ' unsichtbar (PAINT_NOT_VISIBLE)'
434
+ : paintIndeterminate
435
+ ? ' Sichtbarkeit unbestimmt (PAINT_PRESENCE_INDETERMINATE)'
436
+ : '';
437
+ const overflowNote = paintOverflow
438
+ ? ` Tinten-\u00DCberlauf (has_paint_overflow, visual_bbox=${formatVisualBbox(el.visual_bbox)})`
439
+ : '';
440
+ // \u00A7D5 / R6-STATE: ehrlicher Vermerk, dass interaktive Alt-Zust\u00E4nde existieren
441
+ // (orthogonal, h\u00E4ngt zus\u00E4tzlich an wie die anderen Notes).
442
+ const stateNote = stateDependent
443
+ ? ' zustands-abh\u00E4ngig (STATE_DEPENDENT)'
444
+ : '';
445
+ // \u00A7F-AT-6-09 / R6-MEDIA: ehrlicher Vermerk, dass ein anderer Viewport dieses
446
+ // Element anders rendert (orthogonal, h\u00E4ngt zus\u00E4tzlich an wie die anderen Notes).
447
+ const mediaNote = mediaDependent
448
+ ? ' viewport-abh\u00E4ngig (MEDIA_DEPENDENT)'
449
+ : '';
450
+ // \u00A7HEAL-5 / Zeit-Achse: ehrlicher Vermerk, dass die Geometrie an anderem t
451
+ // anders ist (orthogonal, h\u00E4ngt zus\u00E4tzlich an wie die anderen Notes).
452
+ const motionNote = motionDependent
453
+ ? ' zeit-variant (MOTION_DEPENDENT)'
454
+ : '';
455
+ // §H10 R11-06: ehrlicher Vermerk, dass Farbe/Sichtbarkeit an anderem t
456
+ // anders ist (orthogonal, hängt zusätzlich an wie die anderen Notes).
457
+ const paintTimeNote = paintTimeVariant
458
+ ? ' paint-zeit-variant (PAINT_TIME_VARIANT)'
459
+ : '';
460
+ // \u00A7F-AT-7-02 (SK5): ehrlicher Vermerk, dass die sichtbare Farbe aus dem stroke
461
+ // stammt (der fill tr\u00E4gt keine sichtbare Farbe bei) bzw. dass mehrere Quellen
462
+ // sichtbar malen \u2014 orthogonal, h\u00E4ngt zus\u00E4tzlich an wie die anderen Notes.
463
+ const colorNote = colorFromStroke
464
+ ? ' Farbe aus Rand (COLOR_FROM_STROKE)'
465
+ : multiplePaintSources
466
+ ? ' mehrere Farbquellen (MULTIPLE_PAINT_SOURCES)'
467
+ : '';
468
+ const pos = el.span || `${el.cell}, ${el.direction}`;
469
+ const text = el.textContent ? ` "${el.textContent}"` : '';
470
+ // \u00A7F-AT-7-02 (SK5): bei COLOR_FROM_STROKE IST el.color bereits die stroke-Farbe
471
+ // (grid.js-Projektion) \u2014 das redundante `[Rand: red]`-Suffix w\u00E4re eine doppelte
472
+ // Nennung derselben Farbe und wird unterdr\u00FCckt. Bei MULTIPLE_PAINT_SOURCES ist
473
+ // el.color der fill und der stroke eine ECHT separate sichtbare Farbe \u2192 das
474
+ // Suffix bleibt (es tr\u00E4gt eine zus\u00E4tzliche Wahrheit, keine Redundanz).
475
+ const strokeInfo =
476
+ el.stroke && el.stroke !== 'transparent' && !colorFromStroke
477
+ ? ` [Rand: ${el.stroke}]`
478
+ : '';
479
+ const opacityWarn = el.opacity < 0.3 ? ' (fast unsichtbar)' : '';
480
+
481
+ // §E1 D-006/R2: Korrektur-Hinweis aus der GEGATETEN failing-Liste. Bei
482
+ // not_measurable/approximate ist dx/dy/dw/dh entfernt → formatCorrection
483
+ // liefert '' → kein Leak. getStatus laeuft ebenfalls auf der gegateten
484
+ // Liste: das Status-Verdikt (✗) ist reliability-UNABHAENGIG, weil das Gate
485
+ // die issue-Identitaet + WAS-detail erhaelt — nur die Pixel-Vorschreibung
486
+ // faellt weg (Anti-Ueber-Gaten: not_measurable bleibt ✗, ohne Pixel-Hinweis).
487
+ // §HEAL-7/B: nur das GEMESSENE Objekt (erstes seiner id) bekommt ein
488
+ // failing-issue zugeordnet — Namensvettern nie (Objekt-Identität).
489
+ // §H10 R11-28: Attribution id-only, byte-symmetrisch zu structured.js —
490
+ // ein Verdikt heftet an das gemessene Subjekt, nie an detail-erwähnte
491
+ // Dritte (die Referenz-Beteiligung bleibt via FAILING-Top-Zeile sichtbar).
492
+ const issue = isMeasuredCarrier(el)
493
+ ? failing.find((iss) => iss.id === el.id)
494
+ : undefined;
495
+ // G2 (D-006): Korrektur-Hinweis wird aus den STRUKTURIERTEN Feldern
496
+ // (dx/dy/dw/dh) gebaut, NICHT mehr per String-Parse aus dem detail-Feld.
497
+ const correctionText = formatCorrection(issue);
498
+ const spotterHint = correctionText ? ` [${correctionText}]` : '';
499
+
500
+ lines.push(
501
+ `${prefix} ${el.tag}#${el.id}: ${pos}, ${el.color}${strokeInfo}${opacityWarn}${paintNote}${overflowNote}${stateNote}${mediaNote}${motionNote}${paintTimeNote}${colorNote}${text}${spotterHint} ${status}`,
502
+ );
503
+ }
504
+
505
+ if (elements.length > maxShow)
506
+ lines.push(` (${elements.length - maxShow} weitere Elemente)`);
507
+
508
+ return lines.join('\n');
509
+ }
510
+
511
+ /**
512
+ * §HEAL-R6 / T1 Error-Pfad-Ehrlichkeit (F-AT-6-05): die LLM-zugewandte Prosa für
513
+ * ein Error-Resultat, das einen Sanitize-Verlust trägt (canvas_validity=lossy).
514
+ * Die bestehende `Fehler: …`-Zeile bleibt (Render schlug fehl), aber die laute
515
+ * Verlust-Zeile hängt an — sonst verschwände der Verlust still im Error-Pfad.
516
+ * Dieselbe Hinweis-Formulierung wie in formatReport (§H9: jetzt WIRKLICH eine
517
+ * Wahrheits-Quelle — sanitizeLossLine — mit den echten Ursachen).
518
+ *
519
+ * @param {string} message - die resolved.message (Render-Fehler-Text).
520
+ * @param {Array<{tag:string, reason:string, value?:string}>} [sanitizeLoss]
521
+ * @returns {string}
522
+ */
523
+ export function formatErrorWithLoss(message, sanitizeLoss) {
524
+ return [`Fehler: ${message}`, sanitizeLossLine(sanitizeLoss)].join('\n');
525
+ }
526
+
527
+ export function formatArrangeReport(attributes, warnings) {
528
+ const ids = Object.keys(attributes);
529
+ const lines = [
530
+ `ARRANGE: ${ids.length} Element${ids.length !== 1 ? 'e' : ''} platziert`,
531
+ ];
532
+ for (const id of ids) {
533
+ const attrs = attributes[id];
534
+ const parts = Object.entries(attrs).map(([k, v]) =>
535
+ typeof v === 'number' ? `${k}=${Math.round(v)}` : `${k}="${v}"`,
536
+ );
537
+ lines.push(` #${id}: ${parts.join(', ')}`);
538
+ }
539
+ if (warnings.length > 0) {
540
+ lines.push('');
541
+ lines.push(`WARNUNGEN: ${warnings.length}`);
542
+ for (const w of warnings) lines.push(` \u26A0 ${w}`);
543
+ }
544
+ return lines.join('\n');
545
+ }
546
+
547
+ /**
548
+ * formatCorrection — baut den Korrektur-Hinweis aus den STRUKTURIERTEN
549
+ * Feldern eines failing-issue (dx/dy/dw/dh), nicht aus dem detail-String.
550
+ * G2 (D-006): Single Source of Truth ist das strukturierte Delta. Liefert
551
+ * '' wenn keine Korrektur-Felder gesetzt sind (z.B. non-spatiale COLOR-fails).
552
+ */
553
+ function formatCorrection(issue) {
554
+ if (!issue) return '';
555
+ const parts = [];
556
+ if (typeof issue.dx === 'number') parts.push(`dx=${issue.dx}px`);
557
+ if (typeof issue.dy === 'number') parts.push(`dy=${issue.dy}px`);
558
+ if (typeof issue.dw === 'number') parts.push(`dw=${issue.dw}px`);
559
+ if (typeof issue.dh === 'number') parts.push(`dh=${issue.dh}px`);
560
+ return parts.join(', ');
561
+ }
562
+
563
+ // §HEAL-R6 / T1: ein Element ist „tinten-tot", wenn der Renderer paint_visible:false
564
+ // gesetzt hat ODER (redundant-robust) die PAINT_NOT_VISIBLE-Warning trägt. KEINE
565
+ // Mess-Logik — reines Durchreichen des bereits gemessenen Signals (Renderer →
566
+ // grid.js → hier). Beide Quellen geprüft, damit das Signal nicht an einer fehlenden
567
+ // Durchreichung verloren geht (fail-loud statt fail-silent).
568
+ function isPaintDead(el) {
569
+ if (!el) return false;
570
+ if (el.paint_visible === false) return true;
571
+ return Array.isArray(el.warnings) && el.warnings.includes('PAINT_NOT_VISIBLE');
572
+ }
573
+
574
+ // §HEAL-R6 Variante 1: ein Element ist „tinten-unbestimmt", wenn der Renderer
575
+ // paint_visible:'indeterminate' gesetzt hat (ein räumlicher Operator — clip-path/
576
+ // mask/pattern/filter / nicht-endliche CTM — ist present, aber raster-frei NICHT
577
+ // als tot/lebendig entscheidbar). Analog isPaintDead reine Durchreichung des
578
+ // gemessenen Signals (Renderer → grid.js → hier), KEINE Mess-Logik. Beide Quellen
579
+ // (Feld + Warning) geprüft (fail-loud). STRIKT getrennt von isPaintDead: ein
580
+ // indeterminate-Element wird NIE als „unsichtbar/tot" markiert — nur als unbestimmt.
581
+ function isPaintIndeterminate(el) {
582
+ if (!el) return false;
583
+ if (el.paint_visible === 'indeterminate') return true;
584
+ return (
585
+ Array.isArray(el.warnings) &&
586
+ el.warnings.includes('PAINT_PRESENCE_INDETERMINATE')
587
+ );
588
+ }
589
+
590
+ // §HEAL-R6 / T2: ein Element hat „Tinten-Überlauf", wenn der Renderer
591
+ // has_paint_overflow:true gesetzt hat (Filter/stroke/Marker malen über die als
592
+ // reliable gemeldete geom-bbox hinaus). KEINE Mess-Logik — reines Durchreichen
593
+ // (Renderer → grid.js → hier). Symmetrisch zu isPaintDead.
594
+ function isPaintOverflow(el) {
595
+ return !!el && el.has_paint_overflow === true;
596
+ }
597
+
598
+ // §D5 / R6-STATE: ein Element ist „zustands-abhängig", wenn der Renderer
599
+ // state_dependent:true gesetzt hat (interaktiver Pseudo-Selektor self/Vorfahr ODER
600
+ // SMIL-Event-Token zielt darauf) ODER (redundant-robust) die STATE_DEPENDENT-
601
+ // Warning trägt. KEINE Mess-Logik — reines Durchreichen des bereits gemessenen
602
+ // Signals (Renderer → grid.js → hier). Beide Quellen geprüft (fail-loud statt
603
+ // fail-silent). Symmetrisch zu isPaintDead/isPaintIndeterminate/isPaintOverflow.
604
+ function isStateDependent(el) {
605
+ if (!el) return false;
606
+ if (el.state_dependent === true) return true;
607
+ return Array.isArray(el.warnings) && el.warnings.includes('STATE_DEPENDENT');
608
+ }
609
+
610
+ // §F-AT-6-09 / R6-MEDIA: ein Element ist „viewport-abhängig", wenn der Renderer
611
+ // media_dependent:true gesetzt hat (Selektor in einem Viewport-@media trifft es)
612
+ // ODER (redundant-robust) die MEDIA_DEPENDENT-Warning trägt. KEINE Mess-Logik —
613
+ // reines Durchreichen des bereits gemessenen Signals (Renderer → grid.js → hier).
614
+ // Beide Quellen geprüft (fail-loud). Symmetrisch zu isStateDependent.
615
+ function isMediaDependent(el) {
616
+ if (!el) return false;
617
+ if (el.media_dependent === true) return true;
618
+ return Array.isArray(el.warnings) && el.warnings.includes('MEDIA_DEPENDENT');
619
+ }
620
+
621
+ // §HEAL-5 / Zeit-Achse: ein Element ist „zeit-variant", wenn der Renderer
622
+ // motion_dependent:true gesetzt hat (clock-rooted SMIL-GEOMETRIE zielt darauf)
623
+ // ODER (redundant-robust) die MOTION_DEPENDENT-Warning trägt. KEINE Mess-Logik —
624
+ // reines Durchreichen des bereits gemessenen Signals (Renderer → grid.js →
625
+ // hier). Beide Quellen geprüft (fail-loud). Symmetrisch zu isMediaDependent.
626
+ function isMotionDependent(el) {
627
+ if (!el) return false;
628
+ if (el.motion_dependent === true) return true;
629
+ return Array.isArray(el.warnings) && el.warnings.includes('MOTION_DEPENDENT');
630
+ }
631
+
632
+ // §H10 R11-06 / Paint-Zeit-Achse: ein Element ist „paint-zeit-variant", wenn der
633
+ // Renderer paint_time_variant:true gesetzt hat (clock-rooted SMIL auf einem
634
+ // NICHT-Geometrie-Kanal: fill/opacity/…) ODER (redundant-robust) die
635
+ // PAINT_TIME_VARIANT-Warning trägt. KEINE Mess-Logik — reines Durchreichen
636
+ // (Renderer → grid.js → hier). Symmetrisch zu isMotionDependent.
637
+ function isPaintTimeVariant(el) {
638
+ if (!el) return false;
639
+ if (el.paint_time_variant === true) return true;
640
+ return (
641
+ Array.isArray(el.warnings) && el.warnings.includes('PAINT_TIME_VARIANT')
642
+ );
643
+ }
644
+
645
+ // §F-AT-7-02 STILLE STROKE-FARB-LÜGE (Heilung, SK5): die sichtbare Farbe stammt aus
646
+ // dem stroke (fill trägt keine sichtbare Farbe bei). Reine Durchreichung des
647
+ // gemessenen Signals (Renderer → grid.js → hier), KEINE Mess-Logik. Beide Quellen
648
+ // (internes Feld + Warning) geprüft (fail-loud). Symmetrisch zu isStateDependent.
649
+ function isColorFromStroke(el) {
650
+ if (!el) return false;
651
+ if (el.visible_color_source === 'stroke') return true;
652
+ return (
653
+ Array.isArray(el.warnings) && el.warnings.includes('COLOR_FROM_STROKE')
654
+ );
655
+ }
656
+
657
+ // §F-AT-7-02 (SK5): fill UND stroke malen beide sichtbar — „eine Farbe" ist dann ein
658
+ // Urteil, keine Messung. Reine Durchreichung (Renderer → grid.js → hier). Beide
659
+ // Quellen geprüft (fail-loud). Disjunkt zu isColorFromStroke. Symmetrisch zu oben.
660
+ function isMultiplePaintSources(el) {
661
+ if (!el) return false;
662
+ if (el.visible_color_source === 'multiple') return true;
663
+ return (
664
+ Array.isArray(el.warnings) && el.warnings.includes('MULTIPLE_PAINT_SOURCES')
665
+ );
666
+ }
667
+
668
+ // visual_bbox ist entweder ein {x,y,w,h}-Objekt ODER das Literal 'not_measurable'
669
+ // (Überlauf existiert, sichere Schranke aber nicht ableitbar). Beides ehrlich in
670
+ // die Prosa schreiben — die Zahl NIE verschweigen, das Sentinel NIE als Zahl tarnen.
671
+ function formatVisualBbox(vb) {
672
+ if (vb && typeof vb === 'object') {
673
+ const r = (n) => Math.round(n * 10) / 10;
674
+ return `{x:${r(vb.x)},y:${r(vb.y)},w:${r(vb.w)},h:${r(vb.h)}}`;
675
+ }
676
+ return vb === 'not_measurable' ? 'not_measurable' : '?';
677
+ }
678
+
679
+ // \u00A7HEAL-7/B: measuredCarrier (Objekt-Identit\u00E4t, erstes Element seiner id) \u2014
680
+ // das \u2717-Verdikt heftet NUR an das gemessene Objekt, nie an Namensvettern.
681
+ // Das \u26A0 (unchecked) bleibt id-basiert (verweigerte Messung gilt dem Namen).
682
+ // \u00A7H10 R11-28: id-only wie structured.js \u2014 keine detail-String-Attribution
683
+ // (mentionsElementId entfernt: jedes failing-issue tr\u00E4gt id unkonditional,
684
+ // der Disjunkt produzierte ausschlie\u00DFlich \u00DCber-Attribution an Erw\u00E4hnte).
685
+ function getStatus(el, failing, unchecked, measuredCarrier) {
686
+ if (measuredCarrier && failing.some((i) => i.id === el.id)) return '\u2717';
687
+ if (unchecked.some((u) => u.id === el.id)) return '\u26A0';
688
+ return '\u2713';
689
+ }