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,649 @@
1
+ /**
2
+ * structured.js — Structured Content Emitter (JSON)
3
+ * Vector Mirror v2.5 (P1-04 migrated)
4
+ *
5
+ * Adapter module: formats gridMap + arbitrated into MCP `structuredContent`.
6
+ *
7
+ * P1-04 changes:
8
+ * - Consumes the tri-state shape: `arbitrated.{passing, failing, unchecked, diff, totals}`.
9
+ * - status ∈ {'PASS', 'FAIL', 'PARTIAL'} — 'PARTIAL' when unchecked>0 ∧ failing===0.
10
+ * - `iteration.total_issues` = failing + unchecked.
11
+ * - Surfaces `unchecked` entries with reasonCode/hint/suggestedCorrection.
12
+ *
13
+ * Tag-Mapping (BAUPLAN 10.6):
14
+ * dx -> circle:cx, rect:x, text:x
15
+ * dy -> circle:cy, rect:y, text:y
16
+ * dw -> circle:r(/2), rect:width
17
+ * dh -> circle:r(/2), rect:height
18
+ * §1.5 Transform-Fallback (Sprint §1.5): Für Tags OHNE native dx/dy/dw/dh-
19
+ * Mapping (path/polygon/polyline/a/switch/textpath/use/foreignobject) emittiert
20
+ * der Emitter NICHT mehr den toxischen Literal-Durchstich (alter Nullish-
21
+ * Coalescing-Fallback auf den Roh-Delta-Key → ungültiges <path dx=...>), sondern:
22
+ * - Position (dx|dy): EINEN aggregierten transform-Fix (front-prepended
23
+ * translate via prependTranslate).
24
+ * - Größe (dw|dh): KEINEN Fix, sondern reason='SIZE_FIX_UNSUPPORTED_FOR_TAG'.
25
+ * tspan ist Sonderfall: nutzt sein natives dx/dy als RELATIVEN Shift (SVG 1.1
26
+ * §10.5), NICHT transform und NICHT absolute Koordinaten.
27
+ *
28
+ * DEPENDS: core/element_vocabulary.js (isTagDeltaAttributable), lib/transforms.js
29
+ * (prependTranslate) — beide Hexagonal-rein.
30
+ */
31
+ import { isTagDeltaAttributable } from '../../core/element_vocabulary.js';
32
+ import { countTruncation } from '../../core/honesty.js';
33
+ import { prependTranslate } from '../../lib/transforms.js';
34
+
35
+ /**
36
+ * Maps a delta key (dx/dy/dw/dh) to the SVG attribute for a given tag.
37
+ * BAUPLAN 10.6: Tag-Mapping table.
38
+ *
39
+ * §1.5: Nur noch für DELTA_ATTRIBUTABLE_TAGS aufgerufen (whitelist-Pfad). Der
40
+ * toxische Roh-Delta-Key-Fallback (Nullish-Coalescing) ist ELIMINIERT — Non-Whitelist-Tags laufen NIE
41
+ * mehr durch diese Funktion (buildFix verzweigt vorher auf isTagDeltaAttributable).
42
+ * Gibt undefined zurück, falls ein Tag/deltaKey ohne Mapping doch hier landet
43
+ * (defensiv: buildFix behandelt undefined als "kein Fix", kein Literal-Durchstich).
44
+ */
45
+ function deltaToAttribute(tag, deltaKey) {
46
+ const map = {
47
+ circle: { dx: 'cx', dy: 'cy', dw: 'r', dh: 'r' },
48
+ rect: { dx: 'x', dy: 'y', dw: 'width', dh: 'height' },
49
+ text: { dx: 'x', dy: 'y' },
50
+ ellipse: { dx: 'cx', dy: 'cy', dw: 'rx', dh: 'ry' },
51
+ line: { dx: 'x1', dy: 'y1' },
52
+ image: { dx: 'x', dy: 'y', dw: 'width', dh: 'height' },
53
+ };
54
+ return map[tag]?.[deltaKey];
55
+ }
56
+
57
+ /**
58
+ * Builds a fix object from a correction delta + element.
59
+ * OBL-011: Attribut-Extraktion + fix-Objekt.
60
+ */
61
+ function buildFix(el, deltaKey, deltaValue) {
62
+ if (deltaValue === undefined || deltaValue === 0) return null;
63
+ const attr = deltaToAttribute(el.tag, deltaKey);
64
+ // §1.5: Defensive — buildFix wird nur noch für whitelist-Tags aufgerufen
65
+ // (isTagDeltaAttributable-Gate in buildElementFixes). Falls doch ein Tag ohne
66
+ // Mapping hier landet, KEIN Literal-Durchstich mehr (alter Roh-Key-Bug).
67
+ if (attr === undefined) return null;
68
+ const bbox = el.bbox || { x: 0, y: 0, w: 0, h: 0 };
69
+
70
+ let current = 0;
71
+ let targetDelta = deltaValue;
72
+
73
+ if (deltaKey === 'dx') {
74
+ if (attr === 'cx') current = el.cx ?? bbox.x + bbox.w / 2;
75
+ else current = bbox.x;
76
+ } else if (deltaKey === 'dy') {
77
+ if (attr === 'cy') current = el.cy ?? bbox.y + bbox.h / 2;
78
+ else current = bbox.y;
79
+ } else if (deltaKey === 'dw') {
80
+ if (attr === 'r' || attr === 'rx') {
81
+ current = bbox.w / 2;
82
+ targetDelta = deltaValue / 2;
83
+ } else {
84
+ current = bbox.w;
85
+ }
86
+ } else if (deltaKey === 'dh') {
87
+ if (attr === 'r' || attr === 'ry') {
88
+ current = bbox.h / 2;
89
+ targetDelta = deltaValue / 2;
90
+ } else {
91
+ current = bbox.h;
92
+ }
93
+ }
94
+
95
+ current = Math.round(current);
96
+ const target = current + Math.round(targetDelta);
97
+ return { attribute: attr, current: String(current), target: String(target) };
98
+ }
99
+
100
+ /**
101
+ * §1.5 Transform-Fallback (R-A/R-B): Aggregiert dx UND dy zu EINEM transform-Fix
102
+ * für Tags ohne native Positions-Attribute (path/polygon/polyline/a/switch/use/...).
103
+ *
104
+ * Das translate wird FRONT-PREPENDED (prependTranslate): dx/dy sind Welt-px-Deltas
105
+ * vom Spotter (CTM-projizierte BBox), die außen wrappen müssen, damit sie unabhängig
106
+ * von einem Autor-scale()/rotate() in Welt-Koordinaten wirken (SOTA Präzision 1).
107
+ *
108
+ * current = bestehender transform-Wert (oder '' wenn keiner), target = der neue
109
+ * transform-String. EIN Fix-Objekt für beide Achsen (R-B: NICHT pro-Achse).
110
+ *
111
+ * @returns {{attribute:'transform', current:string, target:string, warning:string}|null}
112
+ * null, wenn weder dx noch dy einen Effekt haben.
113
+ */
114
+ function buildTransformFix(el, dx, dy) {
115
+ const dxNum = dx ?? 0;
116
+ const dyNum = dy ?? 0;
117
+ if (dxNum === 0 && dyNum === 0) return null;
118
+ const existing = el.transform ?? '';
119
+ return {
120
+ attribute: 'transform',
121
+ current: existing,
122
+ target: prependTranslate(existing, Math.round(dxNum), Math.round(dyNum)),
123
+ // praezision_2 (low-effort): statischer Hinweis — eine CSS transform-Property
124
+ // (inline/extern) würde dieses Attribut überstimmen. KEINE Kaskaden-Analyse.
125
+ warning: 'CSS transform property may override this attribute',
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Parst den ersten numerischen Token eines SVG dx/dy-Attribut-Werts (das eine
131
+ * Längen-LISTE sein kann, z.B. "4 2 1"). Liefert die Skalar-Basis für den
132
+ * relativen tspan-Shift; 0, wenn kein/ungültiger Wert.
133
+ */
134
+ function parseNativeShift(raw) {
135
+ if (raw == null) return 0;
136
+ const m = String(raw)
137
+ .trim()
138
+ .match(/-?\d+(?:\.\d+)?/);
139
+ if (!m) return 0;
140
+ const n = Number(m[0]);
141
+ return Number.isFinite(n) ? n : 0;
142
+ }
143
+
144
+ /**
145
+ * §1.5 tspan-Sonderfall (R-C / praezision_4 + Patch P2): tspan trägt native dx/dy,
146
+ * aber als RELATIVEN Shift (SVG 1.1 §10.5) — KEIN transform und KEINE absolute
147
+ * Koordinate. Der Fix modelliert den Shift RELATIV zum bestehenden dx-Attribut
148
+ * (Patch P2): current = Autor-dx (Skalar-Erstwert, 0 wenn keiner), target =
149
+ * current + delta. So ÜBERSCHREIBT der Fix das Autor-dx nicht, sondern verschiebt
150
+ * relativ dazu. Hat tspan kein dx → current='0' (Default, korrekt).
151
+ *
152
+ * Eigene Achsen-fixes (dx → attribute 'dx', dy → attribute 'dy'), weil das Schema
153
+ * single-attribute ist; pro-Achse ist hier korrekt (dx/dy sind native tspan-Attribute).
154
+ *
155
+ * @param {string} deltaKey - 'dx' | 'dy'.
156
+ * @param {number} deltaValue - Spotter-Delta.
157
+ * @param {string|number|undefined} nativeBaseRaw - bestehender dx-Attribut-Wert (nur dx-Achse).
158
+ * @returns {{attribute:'dx'|'dy', current:string, target:string}|null}
159
+ */
160
+ function buildTspanShiftFix(deltaKey, deltaValue, nativeBaseRaw) {
161
+ if (deltaValue === undefined || deltaValue === 0) return null;
162
+ const attr = deltaKey === 'dx' ? 'dx' : deltaKey === 'dy' ? 'dy' : null;
163
+ if (attr === null) return null;
164
+ // Patch P2: native_dx surfaced nur für die dx-Achse (Renderer liest dx-Attribut).
165
+ // dy-Achse bleibt Relativbasis 0 (kein dy-Attribut surfaced; konservativ korrekt).
166
+ const base = attr === 'dx' ? parseNativeShift(nativeBaseRaw) : 0;
167
+ return {
168
+ attribute: attr,
169
+ current: String(Math.round(base)),
170
+ target: String(Math.round(base + deltaValue)),
171
+ };
172
+ }
173
+
174
+ /**
175
+ * §1.5 Orchestrierung (R-A..R-E): berechnet für ein failing-Element alle fixes
176
+ * UND einen optionalen unsupported-reason. 4-Weg-Verzweigung:
177
+ * 1. whitelist (isTagDeltaAttributable): bestehender Pfad, pro-Achse buildFix.
178
+ * 2. tspan: native dx/dy als relativer Shift (buildTspanShiftFix), pro-Achse.
179
+ * 3. non-whitelist + dx|dy: EIN aggregierter transform-Fix (buildTransformFix).
180
+ * 4. non-whitelist + dw|dh: KEIN Fix, reason='SIZE_FIX_UNSUPPORTED_FOR_TAG'.
181
+ *
182
+ * @returns {{fixes:Array, reason:(string|undefined)}}
183
+ * fixes: 0..n Fix-Objekte (Reihenfolge dx,dy,dw,dh). reason: gesetzt, wenn ein
184
+ * dw/dh-Delta auf einem Tag ohne Size-Mapping vorlag (Größen-Fix nicht möglich).
185
+ */
186
+ function buildElementFixes(el, issue) {
187
+ const tag = String(el.tag).toLowerCase();
188
+ const fixes = [];
189
+ let reason;
190
+
191
+ if (isTagDeltaAttributable(tag)) {
192
+ // Pfad 1: native Attribut-Mapping (circle/rect/text/ellipse/line/image).
193
+ for (const key of ['dx', 'dy', 'dw', 'dh']) {
194
+ if (issue[key] !== undefined) {
195
+ const f = buildFix(el, key, issue[key]);
196
+ if (f) fixes.push(f);
197
+ }
198
+ }
199
+ return { fixes, reason };
200
+ }
201
+
202
+ if (tag === 'tspan') {
203
+ // Pfad 2: tspan native relativer Shift (R-C). Größen-Deltas sind für tspan
204
+ // nicht modellierbar → wie andere Non-Whitelist-Tags als unsupported melden.
205
+ for (const key of ['dx', 'dy']) {
206
+ if (issue[key] !== undefined) {
207
+ const f = buildTspanShiftFix(key, issue[key], el.native_dx);
208
+ if (f) fixes.push(f);
209
+ }
210
+ }
211
+ if (issue.dw !== undefined || issue.dh !== undefined) {
212
+ reason = 'SIZE_FIX_UNSUPPORTED_FOR_TAG';
213
+ }
214
+ return { fixes, reason };
215
+ }
216
+
217
+ // Pfad 3+4: non-whitelist (path/polygon/polyline/a/switch/textpath/use/foreignobject).
218
+ const transformFix = buildTransformFix(el, issue.dx, issue.dy);
219
+ if (transformFix) fixes.push(transformFix);
220
+ if (issue.dw !== undefined || issue.dh !== undefined) {
221
+ // Pfad 4: Größen-Fix ohne Mapping → unsupported statt erfundenem scale().
222
+ reason = 'SIZE_FIX_UNSUPPORTED_FOR_TAG';
223
+ }
224
+ return { fixes, reason };
225
+ }
226
+
227
+ /**
228
+ * Computes convergence from previous and current issue counts.
229
+ * BAUPLAN 10.5: Stateless — client provides previousIssueCount.
230
+ *
231
+ * §1.6 KONVERGENZ-EHRLICHKEIT (Epistemischer Vertrag): currentIssueCount MUSS
232
+ * totalIssues = failing + unchecked sein — niemals failingCount allein. Sonst
233
+ * meldet der Übergang "1 Failing → 1 Unchecked" IMPROVING (failing sinkt 1→0),
234
+ * obwohl die Wahrheit "Pipeline blind" lautet (Constraint nicht mehr auswertbar).
235
+ * Das wäre eine Fortschritts-Lüge an den LLM. Vergleichswert (previousIssueCount)
236
+ * und aktueller Wert müssen dieselbe Metrik (totalIssues) sein, damit der
237
+ * Closed-Loop wahrheitsgemäß bleibt. Die Klassifikations-Logik selbst ist
238
+ * metrik-agnostisch und bleibt unangetastet (DNA bewahren).
239
+ */
240
+ function computeConvergence(previousIssueCount, currentIssueCount) {
241
+ if (currentIssueCount === 0) return 'SOLVED';
242
+ if (previousIssueCount === undefined || previousIssueCount === null) {
243
+ // §H9 K-12: ohne Historie KEINE Trend-Behauptung — STAGNATING/DIVERGING
244
+ // sind Verlaufs-Urteile über zwei Messpunkte; bei der ersten Messung gibt
245
+ // es nur einen. BASELINE ist das ehrliche Vokabular für "erste Messung,
246
+ // N Issues, kein Trend behauptbar" (Schema-Enum additiv nachgezogen,
247
+ // schema.js). SOLVED bei 0 Issues bleibt: Zustands-, keine Trend-Aussage.
248
+ return 'BASELINE';
249
+ }
250
+ if (currentIssueCount < previousIssueCount) return 'IMPROVING';
251
+ if (currentIssueCount === previousIssueCount) return 'STAGNATING';
252
+ return 'DIVERGING';
253
+ }
254
+
255
+ /**
256
+ * Derives the tri-state document status.
257
+ * PASS — kein Fehler, alles geprueft
258
+ * FAIL — mind. 1 failing
259
+ * PARTIAL — kein failing, aber unchecked > 0 (LLM darf nicht "alles ok" annehmen)
260
+ */
261
+ function deriveStatus(failingCount, uncheckedCount) {
262
+ if (failingCount > 0) return 'FAIL';
263
+ if (uncheckedCount > 0) return 'PARTIAL';
264
+ return 'PASS';
265
+ }
266
+
267
+ /**
268
+ * Formats gridMap + arbitrated into structured JSON (MCP structuredContent).
269
+ * BAUPLAN 10.4: Full schema with status, iteration, scene, corrections, diff, unchecked.
270
+ */
271
+ export function formatStructured(gridMap, arbitrated, opts = {}) {
272
+ const { canvas, elements } = gridMap;
273
+ const failing = arbitrated.failing || [];
274
+ const unchecked = arbitrated.unchecked || [];
275
+ const diffEntries = arbitrated.diff || [];
276
+
277
+ // §E1 fail-closed-Vertrag (REGEL-8): jedes failing-issue MUSS am Emissions-
278
+ // Rand durch honesty.js#gateCorrections gelaufen sein (Caller-Pflicht,
279
+ // pipeline.js analyze/compare). Fehlt das _gated-Vertragsfeld, ist die
280
+ // Reliability-Entscheidung NICHT getroffen → Wurf, statt ungegated zu
281
+ // emittieren. So ist eine ungegatete Delta-Emission mechanisch unmoeglich.
282
+ for (const issue of failing) {
283
+ if (issue._gated === undefined) {
284
+ throw new Error(
285
+ 'formatStructured: ungegatetes failing-issue (kein _gated) — ' +
286
+ 'gateCorrections am Emissions-Rand fehlt (REGEL-8 fail-closed).',
287
+ );
288
+ }
289
+ }
290
+
291
+ const failingCount = failing.length;
292
+ const uncheckedCount = unchecked.length;
293
+ const totalIssues = failingCount + uncheckedCount;
294
+
295
+ const prevCount = opts.previousIssueCount;
296
+ const sequence = prevCount !== undefined && prevCount !== null ? 2 : 1;
297
+ // §1.6: Konvergenz auf totalIssues (failing + unchecked), NICHT failingCount.
298
+ // Failing→Unchecked ist nie IMPROVING — der Constraint wurde nur unsichtbar,
299
+ // nicht gelöst. previousIssueCount ist (im Closed-Loop) ebenfalls ein
300
+ // totalIssues-Wert → Vergleich ist metrik-konsistent und lügt nicht.
301
+ const convergence = computeConvergence(prevCount, totalIssues);
302
+ // §1.3 Schicht 2: analysisId vom Caller (analyze→neu, compare→referenced).
303
+ // Pflicht-Feld im Output-Schema. Kein Fallback hier — Caller muss liefern.
304
+ const { analysisId } = opts;
305
+
306
+ // §1.4
307
+ // VOR dem slice(0,7)-Cap: alle warning-tragenden Elemente auf Position >=7
308
+ // in truncated_warnings hoisten. So bleibt das Reliability-Signal sichtbar,
309
+ // auch wenn das Element selbst aus scene.elements wegen Token-Limit
310
+ // (BAUPLAN OBL-008) verschwindet. REGEL-3 ueberstimmt BAUPLAN-Konvention.
311
+ // Position ist 0-basierte Original-Position vor dem Slicing.
312
+ const SCENE_MAX_ELEMENTS = 7;
313
+ const truncatedWarnings = [];
314
+ for (let i = SCENE_MAX_ELEMENTS; i < elements.length; i++) {
315
+ const el = elements[i];
316
+ if (el && Array.isArray(el.warnings) && el.warnings.length > 0) {
317
+ truncatedWarnings.push({
318
+ element_id: el.id,
319
+ warnings: el.warnings,
320
+ position: i,
321
+ });
322
+ }
323
+ }
324
+
325
+ const sceneElements = elements.slice(0, SCENE_MAX_ELEMENTS).map((el) => {
326
+ const failingIssue = failing.find((i) => i.id === el.id);
327
+ const uncheckedIssue = unchecked.find((i) => i.id === el.id);
328
+ const diffIssue = diffEntries.find((i) => i.id === el.id);
329
+ let status = 'ok';
330
+ if (failingIssue) status = 'fail';
331
+ else if (uncheckedIssue || diffIssue) status = 'warn';
332
+ return {
333
+ id: el.id,
334
+ tag: el.tag,
335
+ cell: el.span || el.cell,
336
+ color: el.color,
337
+ status,
338
+ // §1.2b L-002b: Reliability-Propagation auf MCP-Caller-Ebene
339
+ // (Datenkette Renderer -> grid.mappedElements -> hier -> MCP structuredContent).
340
+ // grid.js reicht bereits durch — wir reichen weiter, damit der Caller das
341
+ // 3D-Signal sieht (REGEL-3 Spotter-Anti-Luege).
342
+ bbox_reliability: el.bbox_reliability,
343
+ // §1.5 Block D: Parent-Kontext für tspan/textPath durchreichen (R-C).
344
+ // Nur emittieren, wenn vorhanden (Common-Case: Top-Level-Elemente ohne
345
+ // relevanten Parent tragen das Feld nicht → Output-Volumen + Schema-
346
+ // Optional-Vertrag konsistent). Datenkette: playwright.js → grid.js →
347
+ // hier. ACHTUNG: grid.js muss parent_id/parent_tag ebenfalls durchreichen
348
+ // (analog bbox_reliability), sonst ist el.parent_id hier undefined.
349
+ ...(el.parent_id != null ? { parent_id: el.parent_id } : {}),
350
+ ...(el.parent_tag != null ? { parent_tag: el.parent_tag } : {}),
351
+ ...(el.warnings ? { warnings: el.warnings } : {}),
352
+ // §E4 Paint-Extent-Ehrlichkeit (F-AT-004, DoD-3): visual_bbox/has_paint_overflow
353
+ // bis zur MCP-Caller-Boundary durchreichen (analog bbox_reliability/warnings).
354
+ // Datenkette: playwright.js → grid.js → hier. Nur emittiert, wenn vorhanden
355
+ // (filterlose Elemente tragen die Felder NICHT — Negativ-Kontrolle).
356
+ ...(el.has_paint_overflow != null
357
+ ? { has_paint_overflow: el.has_paint_overflow }
358
+ : {}),
359
+ ...(el.visual_bbox != null ? { visual_bbox: el.visual_bbox } : {}),
360
+ // §HEAL-R6 / T1 Paint-Presence (F-AT-6-01, DoD-2): bis zur MCP-Caller-Boundary
361
+ // durchreichen (analog bbox_reliability/visual_bbox). Datenkette:
362
+ // playwright.js → grid.js → hier. paint_visible nur bei painted===false.
363
+ ...(el.fill_paint_factor != null
364
+ ? { fill_paint_factor: el.fill_paint_factor }
365
+ : {}),
366
+ ...(el.stroke_paint_factor != null
367
+ ? { stroke_paint_factor: el.stroke_paint_factor }
368
+ : {}),
369
+ ...(el.paint_visible != null ? { paint_visible: el.paint_visible } : {}),
370
+ // §D5 / R6-STATE (state_dependent): bis zur MCP-Caller-Boundary durchreichen
371
+ // (analog paint_visible). Datenkette: playwright.js → grid.js → hier. Ohne
372
+ // diese Zeile lügt der analyze-Pfad still weiter.
373
+ ...(el.state_dependent != null
374
+ ? { state_dependent: el.state_dependent }
375
+ : {}),
376
+ // §F-AT-6-09 / R6-MEDIA (media_dependent): bis zur MCP-Caller-Boundary
377
+ // durchreichen (analog state_dependent). Datenkette: playwright.js → grid.js →
378
+ // hier. Ohne diese Zeile lügt der analyze-Pfad still weiter.
379
+ ...(el.media_dependent != null
380
+ ? { media_dependent: el.media_dependent }
381
+ : {}),
382
+ // §HEAL-5 / Zeit-Achse (motion_dependent): bis zur MCP-Caller-Boundary
383
+ // durchreichen (exakt nach media_dependent-Vorlage). Datenkette:
384
+ // playwright.js → grid.js → hier. Ohne diese Zeile lügt der analyze-Pfad
385
+ // still weiter.
386
+ ...(el.motion_dependent != null
387
+ ? { motion_dependent: el.motion_dependent }
388
+ : {}),
389
+ // §H10 R11-06 / Paint-Zeit-Achse (paint_time_variant): bis zur MCP-
390
+ // Caller-Boundary durchreichen (exakt nach motion_dependent-Vorlage).
391
+ ...(el.paint_time_variant != null
392
+ ? { paint_time_variant: el.paint_time_variant }
393
+ : {}),
394
+ };
395
+ });
396
+
397
+ // §E1 WELLE-β-002/D-006/D-015: die Reliability-Entscheidung ist EINMAL am
398
+ // Emissions-Rand getroffen (honesty.js#gateCorrections, Caller pipeline.js)
399
+ // und sitzt als _gated-Vertragsfeld auf jedem issue. Das gegatete issue ist
400
+ // bei _gated===false bereits um dx/dy/dw/dh BEREINIGT — hier nur noch lesen,
401
+ // keine zweite Reliability-Map (D-015 echt 3→1). detail-Prosa + constraint-
402
+ // Verweis bleiben (Caller weiss, dass ein Bruch vorliegt, ohne irrefuehrende
403
+ // dx/dy zu bekommen).
404
+ const corrections = failing.map((issue) => {
405
+ const el = elements.find((e) => e.id === issue.id);
406
+ const correction = {
407
+ element: `#${issue.id || 'unknown'}`,
408
+ tag: el?.tag,
409
+ constraint: issue.constraintType || 'UNKNOWN',
410
+ reference: issue.reference ? `#${issue.reference}` : null,
411
+ };
412
+ // β-002 Gate (SSOT): _gated ist allowDeltas(reliability) — bei
413
+ // not_measurable/approximate/unbekannter id false → keine dx/dy/dw/dh/fix.
414
+ const allowDeltas = issue._gated;
415
+ if (allowDeltas) {
416
+ if (issue.dx !== undefined) correction.dx = issue.dx;
417
+ if (issue.dy !== undefined) correction.dy = issue.dy;
418
+ if (issue.dw !== undefined) correction.dw = issue.dw;
419
+ if (issue.dh !== undefined) correction.dh = issue.dh;
420
+ }
421
+
422
+ if (el && allowDeltas) {
423
+ // §1.5: 4-Weg-Verzweigung (whitelist | tspan | transform | size-unsupported).
424
+ // buildElementFixes kapselt die Logik; hier nur noch Shape-Mapping auf das
425
+ // bestehende fix/fixes-Contract + optionaler unsupported-reason.
426
+ const { fixes, reason } = buildElementFixes(el, issue);
427
+ if (fixes.length === 1) {
428
+ correction.fix = fixes[0];
429
+ } else if (fixes.length > 1) {
430
+ correction.fix = fixes[0];
431
+ correction.fixes = fixes;
432
+ }
433
+ if (reason !== undefined) {
434
+ correction.reason = reason;
435
+ }
436
+ }
437
+ return correction;
438
+ });
439
+
440
+ const diffSummary = diffEntries.map((d) => {
441
+ const entry = { type: d.type, id: d.id || 'unknown' };
442
+ if (d.from) entry.from = d.from;
443
+ if (d.to) entry.to = d.to;
444
+ return entry;
445
+ });
446
+
447
+ const uncheckedSummary = unchecked.map((u) => {
448
+ const out = {
449
+ element: u.id !== undefined ? `#${u.id}` : null,
450
+ constraint: u.constraintType || 'UNKNOWN',
451
+ reasonCategory: u.reasonCategory,
452
+ reasonCode: u.reasonCode,
453
+ hint: u.hint,
454
+ };
455
+ if (u.suggestedCorrection) out.suggestedCorrection = u.suggestedCorrection;
456
+ return out;
457
+ });
458
+
459
+ // §1.4
460
+ // Wenn truncated_warnings nicht-leer ist, KANN status nicht PASS sein —
461
+ // der Caller wuerde sonst "alles ok" annehmen, obwohl warning-tragende
462
+ // Elemente unsichtbar wegen slice(0,7)-Cap sind. PARTIAL signalisiert:
463
+ // "kein Fehler im sichtbaren Bereich, aber nicht alles ueberprueft/gezeigt".
464
+ // FAIL bleibt erhalten (failing > 0 trumpt truncated_warnings).
465
+ let derivedStatus = deriveStatus(failingCount, uncheckedCount);
466
+ if (derivedStatus === 'PASS' && truncatedWarnings.length > 0) {
467
+ derivedStatus = 'PARTIAL';
468
+ }
469
+ const out = {
470
+ status: derivedStatus,
471
+ iteration: {
472
+ sequence,
473
+ previous_issues: prevCount ?? 0,
474
+ // §1.6: current_issues = totalIssues (failing + unchecked), damit die
475
+ // Iterations-Metrik mit dem Konvergenz-Verdikt konsistent ist und der
476
+ // Closed-Loop (Caller speist current_issues als previousIssueCount zurück)
477
+ // dieselbe Metrik vergleicht. total_issues/returned_issues bleiben gleich.
478
+ current_issues: totalIssues,
479
+ total_issues: totalIssues,
480
+ returned_issues: totalIssues, // structured ships everything; no cap here
481
+ // §E1 D-008: ehrlicher ELEMENT-Trunkierungs-Zaehler. Der issue-Cap
482
+ // existiert nicht (structured ships all issues), aber scene.elements wird
483
+ // bei >SCENE_MAX_ELEMENTS via slice(0,7) gekuerzt — suppressed zaehlt die
484
+ // dadurch verborgenen Elemente (war hartkodiert 0 = Luege bei >7).
485
+ // Symmetrisch zum truncated_warnings-Hoist. ≤7 → 0 (byte-stabil).
486
+ suppressed: countTruncation(elements, SCENE_MAX_ELEMENTS).suppressed,
487
+ convergence,
488
+ analysisId,
489
+ },
490
+ scene: {
491
+ width: canvas.width,
492
+ height: canvas.height,
493
+ grid: `${gridMap.grid.cellsX}x${gridMap.grid.cellsY}`,
494
+ elements: sceneElements,
495
+ // §HEAL-R6 / T1 "das Kabel" (F-AT-6-08): das classifyCanvas-Verdikt aus dem
496
+ // Caller (pipeline.js). Anti-LECK-3: der Caller speist es aus
497
+ // resolved.sanitize_loss DIREKT, NIE aus gridMap.canvas (das das Feld nicht
498
+ // trägt → wäre immer 'valid' → neue stille Lüge). Nur emittieren, wenn der
499
+ // Caller es liefert (optional-by-default → kein Schema-Bruch).
500
+ ...(opts.canvasValidity !== undefined
501
+ ? { canvas_validity: opts.canvasValidity }
502
+ : {}),
503
+ // §H10 R11-01: Existenz-Register — css-unsichtbar geskippte Elemente
504
+ // (id+Achse) sind NICHT Teil der Emissions-Menge, dürfen aber nicht
505
+ // verschwinden. Selbes additive optional-Muster wie canvas_validity.
506
+ ...(Array.isArray(gridMap.hidden) && gridMap.hidden.length > 0
507
+ ? { hidden_elements: gridMap.hidden }
508
+ : {}),
509
+ },
510
+ corrections,
511
+ unchecked: uncheckedSummary,
512
+ diff: diffSummary,
513
+ };
514
+ // §1.4
515
+ // einen Eintrag hat. Optional-by-default haelt das Schema-Optional-Vertrag
516
+ // konsistent und reduziert Output-Volumen im Common-Case (<=7 Elemente).
517
+ if (truncatedWarnings.length > 0) {
518
+ out.meta = { truncated_warnings: truncatedWarnings };
519
+ }
520
+ return out;
521
+ }
522
+
523
+ /**
524
+ * Formats gridMap for inspect output (no constraints, no diff).
525
+ *
526
+ * §HEAL-R6 / T1 "das Kabel" (F-AT-6-08): opts.canvasValidity ist das
527
+ * classifyCanvas-Verdikt aus dem Caller (pipeline.js, aus resolved.sanitize_loss
528
+ * DIREKT — Anti-LECK-3). Optional — fehlt es, bleibt scene.canvas_validity weg.
529
+ */
530
+ export function formatInspectStructured(gridMap, opts = {}) {
531
+ const { canvas, elements } = gridMap;
532
+ // §1.4
533
+ // formatStructured — inspectOutput konsumiert dasselbe elementSchema +
534
+ // metaSchema und braucht denselben REGEL-3-Hoist (β-003).
535
+ const SCENE_MAX_ELEMENTS = 7;
536
+ const truncatedWarnings = [];
537
+ for (let i = SCENE_MAX_ELEMENTS; i < elements.length; i++) {
538
+ const el = elements[i];
539
+ if (el && Array.isArray(el.warnings) && el.warnings.length > 0) {
540
+ truncatedWarnings.push({
541
+ element_id: el.id,
542
+ warnings: el.warnings,
543
+ position: i,
544
+ });
545
+ }
546
+ }
547
+ const out = {
548
+ scene: {
549
+ width: canvas.width,
550
+ height: canvas.height,
551
+ grid: `${gridMap.grid.cellsX}x${gridMap.grid.cellsY}`,
552
+ // §E1 D-008 (F-TF-008): ehrlicher ELEMENT-Trunkierungs-Zaehler, symmetrisch
553
+ // zum analyze-Vorbild (formatStructured, iteration.suppressed). inspect hat
554
+ // KEINEN iteration-Block, daher sitzt der Zaehler hier im scene-Block — er
555
+ // beschreibt scene.elements, die unten via slice(0,7) gekuerzt werden.
556
+ // UNKONDITIONAL emittiert (≤7 → 0 = byte-stabil); behebt die stille
557
+ // Mess-Luege (maschineller Konsument verlor >7 Elemente ohne Zaehler).
558
+ suppressed: countTruncation(elements, SCENE_MAX_ELEMENTS).suppressed,
559
+ elements: elements.slice(0, SCENE_MAX_ELEMENTS).map((el) => ({
560
+ id: el.id,
561
+ tag: el.tag,
562
+ cell: el.span || el.cell,
563
+ color: el.color,
564
+ status: 'ok',
565
+ // §1.2b L-002b: Reliability-Propagation auch im inspect-Output, weil
566
+ // inspectOutput dasselbe elementSchema (Pflicht-Promotion L-003) konsumiert.
567
+ // Ohne diese Zeile lehnt MCP-Inspector ab (E2E SC-7).
568
+ bbox_reliability: el.bbox_reliability,
569
+ ...(el.warnings ? { warnings: el.warnings } : {}),
570
+ // §E4 Paint-Extent-Ehrlichkeit (F-AT-004, DoD-3): auch im inspect-Output
571
+ // durchreichen (dasselbe elementSchema). Datenkette: playwright.js → grid.js
572
+ // → hier. Nur wenn vorhanden (filterlose Elemente tragen die Felder NICHT).
573
+ ...(el.has_paint_overflow != null
574
+ ? { has_paint_overflow: el.has_paint_overflow }
575
+ : {}),
576
+ ...(el.visual_bbox != null ? { visual_bbox: el.visual_bbox } : {}),
577
+ // §HEAL-R6 / T1 Paint-Presence (F-AT-6-01, DoD-2): auch im inspect-Output
578
+ // durchreichen (dasselbe elementSchema). Ohne diese Zeilen lügt der inspect-
579
+ // Pfad still weiter (paint_visible verschwände). Datenkette: playwright.js →
580
+ // grid.js → hier. Nur wenn vorhanden (paint_visible nur bei painted===false).
581
+ ...(el.fill_paint_factor != null
582
+ ? { fill_paint_factor: el.fill_paint_factor }
583
+ : {}),
584
+ ...(el.stroke_paint_factor != null
585
+ ? { stroke_paint_factor: el.stroke_paint_factor }
586
+ : {}),
587
+ ...(el.paint_visible != null ? { paint_visible: el.paint_visible } : {}),
588
+ // §D5 / R6-STATE (state_dependent): auch im inspect-Output durchreichen
589
+ // (dasselbe elementSchema). Ohne diese Zeile lügt der inspect-Pfad still
590
+ // weiter. Datenkette: playwright.js → grid.js → hier.
591
+ ...(el.state_dependent != null
592
+ ? { state_dependent: el.state_dependent }
593
+ : {}),
594
+ // §F-AT-6-09 / R6-MEDIA (media_dependent): auch im inspect-Output
595
+ // durchreichen (dasselbe elementSchema). Datenkette: playwright.js →
596
+ // grid.js → hier.
597
+ ...(el.media_dependent != null
598
+ ? { media_dependent: el.media_dependent }
599
+ : {}),
600
+ // §HEAL-5 / Zeit-Achse (motion_dependent): auch im inspect-Output
601
+ // durchreichen (dasselbe elementSchema; exakt nach media_dependent-
602
+ // Vorlage). Datenkette: playwright.js → grid.js → hier.
603
+ ...(el.motion_dependent != null
604
+ ? { motion_dependent: el.motion_dependent }
605
+ : {}),
606
+ // §H10 R11-06 / Paint-Zeit-Achse (paint_time_variant): auch im
607
+ // inspect-Output durchreichen (exakt nach motion_dependent-Vorlage).
608
+ ...(el.paint_time_variant != null
609
+ ? { paint_time_variant: el.paint_time_variant }
610
+ : {}),
611
+ })),
612
+ // §HEAL-R6 / T1 "das Kabel" (F-AT-6-08): classifyCanvas-Verdikt aus dem
613
+ // Caller (pipeline.js, aus resolved.sanitize_loss DIREKT — Anti-LECK-3).
614
+ // Selbes additive optional-Muster wie formatStructured.
615
+ ...(opts.canvasValidity !== undefined
616
+ ? { canvas_validity: opts.canvasValidity }
617
+ : {}),
618
+ // §H10 R11-01: Existenz-Register auch im inspect-Output (Kanal-Parität,
619
+ // selbes additive optional-Muster wie formatStructured).
620
+ ...(Array.isArray(gridMap.hidden) && gridMap.hidden.length > 0
621
+ ? { hidden_elements: gridMap.hidden }
622
+ : {}),
623
+ },
624
+ };
625
+ if (truncatedWarnings.length > 0) {
626
+ out.meta = { truncated_warnings: truncatedWarnings };
627
+ }
628
+ return out;
629
+ }
630
+
631
+ /**
632
+ * Formats palette output.
633
+ */
634
+ export function formatPaletteStructured(elements) {
635
+ return {
636
+ colors: elements.slice(0, 7).map((el) => ({
637
+ id: el.id,
638
+ // §F-AT-7-02 (SK4): die Palette führt den ECHTEN parsed fill, NICHT el.color.
639
+ // Seit der stroke-Farb-Heilung kann el.color bei einem stroke-only-Element die
640
+ // STROKE-Farbe sein (visible_color_source='stroke') — el.color hier zu nutzen
641
+ // erzeugte eine neue Lüge (stroke-only → palette.fill:rot, obwohl der fill
642
+ // 'none'/transparent ist). grid.js bewahrt den parsed fill separat in
643
+ // fill_color; Fallback auf el.color nur, falls fill_color fehlt (Nicht-Grid-
644
+ // Aufrufer) — dort ist el.color weiterhin fill-derived (kein visible_color_source).
645
+ fill: el.fill_color != null ? el.fill_color : el.color,
646
+ stroke: el.stroke && el.stroke !== 'transparent' ? el.stroke : null,
647
+ })),
648
+ };
649
+ }