@adia-ai/a2ui 0.8.37

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.
Files changed (53) hide show
  1. package/CHANGELOG.md +1073 -0
  2. package/README.md +99 -0
  3. package/a2ui.schema.d.ts +192 -0
  4. package/controllers/accordion.js +73 -0
  5. package/controllers/base.js +68 -0
  6. package/controllers/data-stream.js +281 -0
  7. package/controllers/form.js +81 -0
  8. package/controllers/index.js +6 -0
  9. package/controllers/selection.js +82 -0
  10. package/controllers/state-machine.js +135 -0
  11. package/controllers/toggle.js +40 -0
  12. package/dockables/action.d.ts +55 -0
  13. package/dockables/action.js +152 -0
  14. package/dockables/base.d.ts +26 -0
  15. package/dockables/base.js +30 -0
  16. package/dockables/controller.d.ts +35 -0
  17. package/dockables/controller.js +97 -0
  18. package/dockables/data-source.d.ts +35 -0
  19. package/dockables/data-source.js +103 -0
  20. package/dockables/index.d.ts +21 -0
  21. package/dockables/index.js +6 -0
  22. package/dockables/lifecycle.d.ts +38 -0
  23. package/dockables/lifecycle.js +84 -0
  24. package/dockables/provider.d.ts +28 -0
  25. package/dockables/provider.js +59 -0
  26. package/index.d.ts +64 -0
  27. package/index.js +54 -0
  28. package/package.json +89 -0
  29. package/prop-apply.d.ts +13 -0
  30. package/prop-apply.js +113 -0
  31. package/registry.d.ts +17 -0
  32. package/registry.js +418 -0
  33. package/renderer.d.ts +67 -0
  34. package/renderer.js +715 -0
  35. package/stream.d.ts +62 -0
  36. package/stream.js +521 -0
  37. package/surface-manifest.d.ts +73 -0
  38. package/surface-manifest.js +294 -0
  39. package/surface.d.ts +72 -0
  40. package/surface.js +222 -0
  41. package/types.d.ts +26 -0
  42. package/validate/CHANGELOG.md +1005 -0
  43. package/validate/README.md +146 -0
  44. package/validate/index.d.ts +4 -0
  45. package/validate/index.js +12 -0
  46. package/validate/validator.d.ts +4 -0
  47. package/validate/validator.js +1232 -0
  48. package/wire-factory.d.ts +15 -0
  49. package/wire-factory.js +134 -0
  50. package/wiring-engine.d.ts +61 -0
  51. package/wiring-engine.js +209 -0
  52. package/wiring-registry.d.ts +80 -0
  53. package/wiring-registry.js +342 -0
@@ -0,0 +1,1232 @@
1
+ /**
2
+ * A2UI Schema Validator — 15 quality checks with weighted scoring.
3
+ *
4
+ * Validates A2UI message sequences against structural rules from the
5
+ * best-practices spec. Each check returns { name, passed, score, detail }.
6
+ * Final score is a weighted average (0-100). Valid if score >= 70.
7
+ *
8
+ * No DOM dependencies — usable from browser and Node.
9
+ */
10
+
11
+ // Intra-package import (ADR-0048 P3): the runtime IS this package's root, so
12
+ // the validator's only dependency is a relative one — which is what lets
13
+ // @adia-ai/a2ui ship with zero `dependencies`.
14
+ // Imported from the two leaf modules, not the `../index.js` barrel: that
15
+ // barrel pulls in renderer.js/surface.js, and this module's contract is
16
+ // "no DOM dependencies" (see the docstring above).
17
+ import { registry } from '../registry.js';
18
+ import { wiringRegistry } from '../wiring-registry.js';
19
+
20
+ // ── Check weights (must sum to 100 for component checks) ──
21
+
22
+ const WEIGHTS = {
23
+ validMessageFormat: 8,
24
+ hasRootComponent: 6,
25
+ allTypesRegistered: 8,
26
+ noOrphanedChildren: 8,
27
+ cardStructure: 5,
28
+ flatAdjacency: 4,
29
+ noBareDivs: 6,
30
+ noHardcodedColors: 2,
31
+ noInlineLayout: 4,
32
+ textContentSet: 4,
33
+ idUniqueness: 4,
34
+ interactiveHasLabel: 4,
35
+ imagesHaveAlt: 2,
36
+ headingHierarchy: 2,
37
+ tabStructure: 2,
38
+ gridVsColumn: 2,
39
+ landmarkStructure: 2,
40
+ intentAlignment: 7,
41
+ ontologyAlignment: 15,
42
+ componentsHaveRenderableContent: 5, // §165 (v0.5.4) — the 3rd safety layer
43
+ };
44
+
45
+ /**
46
+ * Validate an A2UI message sequence.
47
+ *
48
+ * @param {object[]} messages — Array of A2UI messages (e.g. updateComponents)
49
+ * @param {{ intent?: string }} [options] — Optional validation context
50
+ * @returns {{ score: number, checks: object[], valid: boolean }}
51
+ */
52
+ export function validateSchema(messages, options = {}) {
53
+ if (!Array.isArray(messages) || messages.length === 0) {
54
+ return {
55
+ score: 0,
56
+ checks: [{ name: 'validMessageFormat', passed: false, score: 0, detail: 'No messages provided' }],
57
+ valid: false,
58
+ };
59
+ }
60
+
61
+ // ── Fallback short-circuit ──
62
+ // The pipeline emits a structurally-valid Card+Alert+Button surface when the
63
+ // LLM call fails, returns empty, or produces unparseable output. That surface
64
+ // would otherwise score ~89/100 because it passes every structural check —
65
+ // making generation failures look like successful generations and silently
66
+ // poisoning eval baselines. Detected via the `_fallback: true` marker stamped
67
+ // by `fallbackMessage()` in engines/monolithic/_shared.js.
68
+ // See diagnosis report 2026-04-19.
69
+ const fallbackMsg = messages.find(m => m && m._fallback === true);
70
+ if (fallbackMsg) {
71
+ return {
72
+ score: 0,
73
+ checks: [{
74
+ name: 'fallbackSurface',
75
+ passed: false,
76
+ score: 0,
77
+ detail: `Generation failure surface: ${fallbackMsg._fallbackReason || 'unknown reason'}`,
78
+ }],
79
+ valid: false,
80
+ isFallback: true,
81
+ fallbackReason: fallbackMsg._fallbackReason || null,
82
+ };
83
+ }
84
+
85
+ // Collect all components across all updateComponents messages
86
+ const allComponents = [];
87
+ for (const msg of messages) {
88
+ if (msg?.type === 'updateComponents' && Array.isArray(msg.components)) {
89
+ for (const c of msg.components) {
90
+ if (c && typeof c === 'object') allComponents.push(c);
91
+ }
92
+ }
93
+ }
94
+
95
+ // Collect wireComponents messages
96
+ const wireMessages = messages.filter(m => m?.type === 'wireComponents');
97
+
98
+ const checks = [
99
+ checkValidMessageFormat(messages),
100
+ checkHasRootComponent(allComponents),
101
+ checkAllTypesRegistered(allComponents),
102
+ checkNoOrphanedChildren(allComponents),
103
+ checkCardStructure(allComponents),
104
+ checkFlatAdjacency(messages),
105
+ checkNoBareDivs(allComponents),
106
+ checkNoHardcodedColors(allComponents),
107
+ checkNoInlineLayout(allComponents),
108
+ checkTextContentSet(allComponents),
109
+ checkIdUniqueness(allComponents),
110
+ checkInteractiveHasLabel(allComponents),
111
+ checkComponentsHaveRenderableContent(allComponents),
112
+ checkImagesHaveAlt(allComponents),
113
+ checkHeadingHierarchy(allComponents),
114
+ checkTabStructure(allComponents),
115
+ checkGridVsColumn(allComponents),
116
+ checkLandmarkStructure(allComponents),
117
+ checkIntentAlignment(allComponents, options.intent),
118
+ checkOntologyAlignment(allComponents, options.context),
119
+ ];
120
+
121
+ // Wiring checks (only scored when wireComponents present)
122
+ if (wireMessages.length > 0) {
123
+ const componentIds = new Set(allComponents.map(c => c.id));
124
+ for (const wire of wireMessages) {
125
+ checks.push(...checkWiring(wire, componentIds));
126
+ }
127
+ }
128
+
129
+ // Weighted score (component checks only — wiring checks reported but don't affect score)
130
+ let score = 0;
131
+ for (const check of checks) {
132
+ const weight = WEIGHTS[check.name] || 0;
133
+ score += (check.score * weight) / 100;
134
+ }
135
+
136
+ // Normalize to 0-100
137
+ score = Math.round(score * 100);
138
+
139
+ // Hard-fail: bare HTML elements or unregistered types mean the output is fundamentally wrong
140
+ const hardFails = ['noBareDivs', 'allTypesRegistered', 'hasRootComponent', 'tabStructure', 'ontologyAlignment'];
141
+ const hasHardFail = checks.some(c => hardFails.includes(c.name) && !c.passed);
142
+
143
+ // Severe intent mismatch: if intentAlignment score < 0.3 and intent was provided,
144
+ // apply a penalty multiplier to the total score — wrong pattern is fundamentally wrong
145
+ const intentCheck = checks.find(c => c.name === 'intentAlignment');
146
+ if (intentCheck && intentCheck.score < 0.3 && options.intent) {
147
+ // Scale the total score down: a 0.17 intent score → multiplier ~0.42
148
+ const intentPenalty = 0.25 + (intentCheck.score * 2.5); // range 0.25-1.0
149
+ score = Math.round(score * intentPenalty);
150
+ }
151
+
152
+ return {
153
+ score,
154
+ checks,
155
+ valid: score >= 70 && !hasHardFail,
156
+ };
157
+ }
158
+
159
+ // ── Individual checks ──
160
+
161
+ /**
162
+ * 1. validMessageFormat — each message has `type` and required fields.
163
+ */
164
+ function checkValidMessageFormat(messages) {
165
+ const issues = [];
166
+
167
+ for (let i = 0; i < messages.length; i++) {
168
+ const msg = messages[i];
169
+ if (!msg || typeof msg !== 'object') {
170
+ issues.push(`Message ${i}: not an object`);
171
+ continue;
172
+ }
173
+ if (!msg.type) {
174
+ issues.push(`Message ${i}: missing "type" field`);
175
+ continue;
176
+ }
177
+
178
+ if (msg.type === 'updateComponents') {
179
+ if (!msg.surfaceId) issues.push(`Message ${i}: updateComponents missing "surfaceId"`);
180
+ if (!Array.isArray(msg.components)) issues.push(`Message ${i}: updateComponents missing "components" array`);
181
+ } else if (msg.type === 'updateDataModel') {
182
+ if (!msg.surfaceId) issues.push(`Message ${i}: updateDataModel missing "surfaceId"`);
183
+ if (msg.data === undefined && msg.model === undefined) issues.push(`Message ${i}: updateDataModel missing "data" or "model"`);
184
+ }
185
+ // Unknown message types are allowed (extensibility)
186
+ }
187
+
188
+ const passed = issues.length === 0;
189
+ return {
190
+ name: 'validMessageFormat',
191
+ passed,
192
+ score: passed ? 1 : Math.max(0, 1 - issues.length / messages.length),
193
+ detail: passed ? 'All messages have valid format' : issues.join('; '),
194
+ };
195
+ }
196
+
197
+ /**
198
+ * 2. hasRootComponent — at least one component with id: 'root'.
199
+ */
200
+ function checkHasRootComponent(components) {
201
+ const hasRoot = components.some(c => c.id === 'root');
202
+ return {
203
+ name: 'hasRootComponent',
204
+ passed: hasRoot,
205
+ score: hasRoot ? 1 : 0,
206
+ detail: hasRoot ? 'Root component found' : 'No component with id "root" found',
207
+ };
208
+ }
209
+
210
+ /**
211
+ * 3. allTypesRegistered — all component types exist in the AdiaUI registry.
212
+ */
213
+ function checkAllTypesRegistered(components) {
214
+ const unregistered = [];
215
+
216
+ // Native card children that are valid but not in the main registry lookup
217
+ const nativeTypes = new Set(['Section', 'Header', 'Footer']);
218
+
219
+ for (const comp of components) {
220
+ const type = comp.component;
221
+ if (!type) continue;
222
+ if (nativeTypes.has(type)) continue;
223
+ if (!registry.has(type)) {
224
+ unregistered.push(`"${type}" (id: ${comp.id})`);
225
+ }
226
+ }
227
+
228
+ const passed = unregistered.length === 0;
229
+ return {
230
+ name: 'allTypesRegistered',
231
+ passed,
232
+ score: passed ? 1 : Math.max(0, 1 - unregistered.length / Math.max(1, components.length)),
233
+ detail: passed ? 'All component types are registered' : `Unregistered types: ${unregistered.join(', ')}`,
234
+ };
235
+ }
236
+
237
+ /**
238
+ * 4. noOrphanedChildren — all IDs referenced in children arrays exist.
239
+ */
240
+ function checkNoOrphanedChildren(components) {
241
+ const ids = new Set(components.map(c => c.id));
242
+ const orphans = [];
243
+
244
+ for (const comp of components) {
245
+ if (!Array.isArray(comp.children)) continue;
246
+ for (const childId of comp.children) {
247
+ if (!ids.has(childId)) {
248
+ orphans.push(`"${childId}" referenced by "${comp.id}"`);
249
+ }
250
+ }
251
+ }
252
+
253
+ const passed = orphans.length === 0;
254
+ return {
255
+ name: 'noOrphanedChildren',
256
+ passed,
257
+ score: passed ? 1 : Math.max(0, 1 - orphans.length / Math.max(1, components.length)),
258
+ detail: passed ? 'All child references resolve' : `Orphaned children: ${orphans.join(', ')}`,
259
+ };
260
+ }
261
+
262
+ /**
263
+ * 5. cardStructure — Cards have header/section/footer as direct children.
264
+ *
265
+ * Checks:
266
+ * - Card children should only be Header, Section, or Footer
267
+ * - Header and Footer should not be nested inside Section
268
+ * - Section content should be wrapped in Column
269
+ */
270
+ function checkCardStructure(components) {
271
+ const byId = new Map(components.map(c => [c.id, c]));
272
+ const issues = [];
273
+
274
+ for (const comp of components) {
275
+ if (comp.component !== 'Card') continue;
276
+ if (!Array.isArray(comp.children)) continue;
277
+
278
+ for (const childId of comp.children) {
279
+ const child = byId.get(childId);
280
+ if (!child) continue;
281
+
282
+ const childType = child.component;
283
+ if (!['Header', 'Section', 'Footer'].includes(childType)) {
284
+ issues.push(`Card "${comp.id}" has direct child "${childId}" of type "${childType}" (expected Header/Section/Footer)`);
285
+ }
286
+ }
287
+
288
+ // Check that Sections don't contain Header or Footer
289
+ for (const childId of comp.children) {
290
+ const child = byId.get(childId);
291
+ if (!child || child.component !== 'Section') continue;
292
+ if (!Array.isArray(child.children)) continue;
293
+
294
+ for (const sectionChildId of child.children) {
295
+ const sectionChild = byId.get(sectionChildId);
296
+ if (!sectionChild) continue;
297
+ if (['Header', 'Footer'].includes(sectionChild.component)) {
298
+ issues.push(`Section "${childId}" contains "${sectionChild.component}" — must be direct child of Card`);
299
+ }
300
+ }
301
+ }
302
+ }
303
+
304
+ const passed = issues.length === 0;
305
+ const cardCount = components.filter(c => c.component === 'Card').length;
306
+ return {
307
+ name: 'cardStructure',
308
+ passed,
309
+ score: cardCount === 0 ? 1 : (passed ? 1 : Math.max(0, 1 - issues.length / Math.max(1, cardCount))),
310
+ detail: passed ? (cardCount === 0 ? 'No cards to validate' : 'Card structure is correct') : issues.join('; '),
311
+ };
312
+ }
313
+
314
+ /**
315
+ * 6. flatAdjacency — components are a flat list with ID references.
316
+ *
317
+ * Checks that updateComponents messages contain a flat array, not nested objects.
318
+ */
319
+ function checkFlatAdjacency(messages) {
320
+ const issues = [];
321
+
322
+ for (const msg of messages) {
323
+ if (!msg || typeof msg !== 'object') continue;
324
+ if (msg.type !== 'updateComponents') continue;
325
+ if (!Array.isArray(msg.components)) continue;
326
+
327
+ for (let i = 0; i < msg.components.length; i++) {
328
+ const comp = msg.components[i];
329
+ if (!comp || typeof comp !== 'object') continue;
330
+
331
+ // Children should be string IDs, not nested objects
332
+ if (Array.isArray(comp.children)) {
333
+ for (const child of comp.children) {
334
+ if (typeof child !== 'string') {
335
+ issues.push(`Component "${comp.id}" has non-string child in children array`);
336
+ break;
337
+ }
338
+ }
339
+ }
340
+ }
341
+ }
342
+
343
+ const passed = issues.length === 0;
344
+ return {
345
+ name: 'flatAdjacency',
346
+ passed,
347
+ score: passed ? 1 : 0,
348
+ detail: passed ? 'All components use flat adjacency (string ID references)' : issues.join('; '),
349
+ };
350
+ }
351
+
352
+ /**
353
+ * 7. noBareDivs — no component: 'div' or other raw HTML tags.
354
+ */
355
+ function checkNoBareDivs(components) {
356
+ const bareTypes = new Set(['div', 'span', 'main', 'aside', 'article', 'nav', 'ul', 'ol', 'li', 'table', 'tr', 'td', 'th']);
357
+ const offenders = [];
358
+
359
+ for (const comp of components) {
360
+ const type = comp.component;
361
+ if (!type) continue;
362
+ // Registered A2UI types are exempt: the lowercase collision used to
363
+ // hard-fail every document containing the legitimate `Nav` or `Table`
364
+ // constructs (they lowercase onto the bare-HTML set).
365
+ if (registry.has(type)) continue;
366
+ if (bareTypes.has(type.toLowerCase())) {
367
+ offenders.push(`"${type}" (id: ${comp.id})`);
368
+ }
369
+ }
370
+
371
+ const passed = offenders.length === 0;
372
+ return {
373
+ name: 'noBareDivs',
374
+ passed,
375
+ score: passed ? 1 : 0,
376
+ detail: passed ? 'No bare HTML element types' : `Bare elements: ${offenders.join(', ')}`,
377
+ };
378
+ }
379
+
380
+ /**
381
+ * 8. noHardcodedColors — no inline style with color: or background:.
382
+ */
383
+ function checkNoHardcodedColors(components) {
384
+ const pattern = /(?:^|;)\s*(?:color|background|background-color)\s*:/i;
385
+ const offenders = [];
386
+
387
+ for (const comp of components) {
388
+ if (typeof comp.style === 'string' && pattern.test(comp.style)) {
389
+ offenders.push(`"${comp.id}" has hardcoded color in style`);
390
+ }
391
+ // Also check style as object
392
+ if (comp.style && typeof comp.style === 'object') {
393
+ for (const key of Object.keys(comp.style)) {
394
+ const lower = key.toLowerCase().replace(/[A-Z]/g, m => '-' + m.toLowerCase());
395
+ if (['color', 'background', 'background-color'].includes(lower)) {
396
+ offenders.push(`"${comp.id}" has hardcoded "${key}" in style object`);
397
+ }
398
+ }
399
+ }
400
+ }
401
+
402
+ const passed = offenders.length === 0;
403
+ return {
404
+ name: 'noHardcodedColors',
405
+ passed,
406
+ score: passed ? 1 : Math.max(0, 1 - offenders.length / Math.max(1, components.length)),
407
+ detail: passed ? 'No hardcoded colors' : offenders.join('; '),
408
+ };
409
+ }
410
+
411
+ /**
412
+ * 9. noInlineLayout — no inline style with display:, flex:, or grid:.
413
+ */
414
+ function checkNoInlineLayout(components) {
415
+ const pattern = /(?:^|;)\s*(?:display|flex|grid|flex-direction|justify-content|align-items|gap)\s*:/i;
416
+ const offenders = [];
417
+
418
+ for (const comp of components) {
419
+ if (typeof comp.style === 'string' && pattern.test(comp.style)) {
420
+ offenders.push(`"${comp.id}" has inline layout in style`);
421
+ }
422
+ if (comp.style && typeof comp.style === 'object') {
423
+ for (const key of Object.keys(comp.style)) {
424
+ const lower = key.toLowerCase().replace(/[A-Z]/g, m => '-' + m.toLowerCase());
425
+ if (['display', 'flex', 'grid', 'flex-direction', 'justify-content', 'align-items', 'gap'].includes(lower)) {
426
+ offenders.push(`"${comp.id}" has inline "${key}" in style object`);
427
+ }
428
+ }
429
+ }
430
+ }
431
+
432
+ const passed = offenders.length === 0;
433
+ return {
434
+ name: 'noInlineLayout',
435
+ passed,
436
+ score: passed ? 1 : Math.max(0, 1 - offenders.length / Math.max(1, components.length)),
437
+ detail: passed ? 'No inline layout styles' : offenders.join('; '),
438
+ };
439
+ }
440
+
441
+ /**
442
+ * 10. textContentSet — Text components with native variants use textContent or text.
443
+ *
444
+ * Text components with variant h1-h5, body, caption should have textContent or text set.
445
+ */
446
+ function checkTextContentSet(components) {
447
+ const nativeVariants = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'body', 'caption']);
448
+ const issues = [];
449
+
450
+ for (const comp of components) {
451
+ if (comp.component !== 'Text') continue;
452
+ if (!comp.variant || !nativeVariants.has(comp.variant)) continue;
453
+
454
+ const hasContent = comp.textContent != null || comp.text != null;
455
+ if (!hasContent) {
456
+ issues.push(`Text "${comp.id}" (variant: ${comp.variant}) has no textContent or text`);
457
+ }
458
+ }
459
+
460
+ const textCount = components.filter(c => c.component === 'Text').length;
461
+ const passed = issues.length === 0;
462
+ return {
463
+ name: 'textContentSet',
464
+ passed,
465
+ score: textCount === 0 ? 1 : (passed ? 1 : Math.max(0, 1 - issues.length / Math.max(1, textCount))),
466
+ detail: passed ? (textCount === 0 ? 'No text components to validate' : 'All text components have content') : issues.join('; '),
467
+ };
468
+ }
469
+
470
+ /**
471
+ * 11. idUniqueness — all component IDs are unique within a surface.
472
+ */
473
+ function checkIdUniqueness(components) {
474
+ const seen = new Map(); // id → count
475
+ for (const comp of components) {
476
+ if (!comp.id) continue;
477
+ seen.set(comp.id, (seen.get(comp.id) || 0) + 1);
478
+ }
479
+
480
+ const dupes = [];
481
+ for (const [id, count] of seen) {
482
+ if (count > 1) dupes.push(`"${id}" appears ${count} times`);
483
+ }
484
+
485
+ const passed = dupes.length === 0;
486
+ return {
487
+ name: 'idUniqueness',
488
+ passed,
489
+ score: passed ? 1 : Math.max(0, 1 - dupes.length / Math.max(1, seen.size)),
490
+ detail: passed ? 'All IDs are unique' : `Duplicate IDs: ${dupes.join(', ')}`,
491
+ };
492
+ }
493
+
494
+ /**
495
+ * 12. interactiveHasLabel — interactive components must carry an
496
+ * accessible name: text, label, textContent, or aria-label. aria-label
497
+ * joined 2026-07-12 (TKT-0003): rejecting it forced the docs-transpiler
498
+ * to propagate a VISIBLE `label` onto controls already labelled by their
499
+ * wrapping Field — every transpiled form rendered its labels twice
500
+ * ("Workspace name Workspace name"). An aria-label IS an accessible
501
+ * name; a control inside a labelled Field needs no second visible one.
502
+ */
503
+ function checkInteractiveHasLabel(components) {
504
+ const interactiveTypes = new Set(['Button', 'TextField', 'Select', 'Toggle', 'Check', 'Slider', 'Search']);
505
+ const unlabeled = [];
506
+
507
+ for (const comp of components) {
508
+ if (!interactiveTypes.has(comp.component)) continue;
509
+ const hasLabel = comp.text != null || comp.label != null || comp.textContent != null
510
+ || comp['aria-label'] != null;
511
+ if (!hasLabel) {
512
+ unlabeled.push(`"${comp.id}"`);
513
+ }
514
+ }
515
+
516
+ const interactiveCount = components.filter(c => interactiveTypes.has(c.component)).length;
517
+ const passed = unlabeled.length === 0;
518
+ return {
519
+ name: 'interactiveHasLabel',
520
+ passed,
521
+ score: interactiveCount === 0 ? 1 : (passed ? 1 : Math.max(0, 1 - unlabeled.length / Math.max(1, interactiveCount))),
522
+ detail: passed
523
+ ? (interactiveCount === 0 ? 'No interactive components to validate' : 'All interactive components have labels')
524
+ : `Missing labels: ${unlabeled.join(', ')}`,
525
+ };
526
+ }
527
+
528
+ /**
529
+ * §165 (v0.5.4): componentsHaveRenderableContent — the 3rd safety layer.
530
+ *
531
+ * Catches the bug class diagnosed 2026-05-14 (commit `964885e70` postmortem):
532
+ * components that ARE registered (Layer 1 passes) AND structurally valid
533
+ * (Layer 2 passes) AND still render as blank rectangles because they have
534
+ * NO content-bearing props.
535
+ *
536
+ * Bug-class examples that this check catches:
537
+ * { id: "password", component: "Input" } ← no label/placeholder
538
+ * { id: "remember", component: "CheckBox", name: "remember" } ← no label
539
+ * { id: "btn-magic", component: "Button" } ← no text/icon
540
+ * { id: "title", component: "Text" } ← no textContent
541
+ * { id: "logo", component: "Image" } ← no src
542
+ *
543
+ * Broader scope than `interactiveHasLabel` (check #12):
544
+ * - covers Input/CheckBox/Switch (the registry-aliased forms missed by #12's
545
+ * `Check/Toggle` set)
546
+ * - accepts icon-only buttons (with `icon` OR `aria-label`)
547
+ * - accepts container nodes with children (Card/Section/Header pass on
548
+ * structural-not-leaf grounds; the check enforces content for LEAF
549
+ * interactive types)
550
+ *
551
+ * Score semantics: per-violation deduction. Each empty interactive primitive
552
+ * subtracts 1/total from the score. 0 violations = 1.0; all-empty = 0.
553
+ */
554
+ function checkComponentsHaveRenderableContent(components) {
555
+ // Interactive types: must have at least one content-bearing prop OR be
556
+ // non-leaf (has children that provide content).
557
+ const INTERACTIVE_LEAF_TYPES = new Set([
558
+ 'Button', 'Input', 'TextField', 'TextArea',
559
+ 'CheckBox', 'Check', 'Toggle', 'Switch', 'Radio',
560
+ 'Select', 'ChoicePicker', 'Slider', 'Range', 'Rating',
561
+ 'Search', 'OtpInput', 'CalendarPicker', 'DateTimeInput',
562
+ 'ColorPicker', 'Upload',
563
+ ]);
564
+ // Content-bearing props that satisfy the "renderable" requirement.
565
+ const CONTENT_PROPS = ['label', 'placeholder', 'text', 'aria-label',
566
+ 'icon', 'textContent', 'value', 'alt', 'src'];
567
+
568
+ const hasContent = (comp) => {
569
+ for (const k of CONTENT_PROPS) {
570
+ const v = comp[k];
571
+ if (v !== undefined && v !== null && v !== '') return true;
572
+ }
573
+ // Non-leaf interactives (e.g. CalendarPicker with named children) pass
574
+ if (Array.isArray(comp.children) && comp.children.length > 0) return true;
575
+ return false;
576
+ };
577
+
578
+ const violations = [];
579
+ let interactiveCount = 0;
580
+ for (const comp of components) {
581
+ if (!INTERACTIVE_LEAF_TYPES.has(comp.component)) continue;
582
+ interactiveCount++;
583
+ if (!hasContent(comp)) {
584
+ violations.push(`"${comp.id}" (${comp.component})`);
585
+ }
586
+ }
587
+
588
+ // Also check Text components — empty Text is a degenerate case worth surfacing.
589
+ // Text-specific: needs textContent OR children (Text + nested Strong etc.)
590
+ let textCount = 0;
591
+ let emptyTextCount = 0;
592
+ for (const comp of components) {
593
+ if (comp.component !== 'Text') continue;
594
+ textCount++;
595
+ const t = comp.textContent;
596
+ if ((t === undefined || t === null || t === '') &&
597
+ !(Array.isArray(comp.children) && comp.children.length > 0)) {
598
+ emptyTextCount++;
599
+ violations.push(`"${comp.id}" (Text: empty)`);
600
+ }
601
+ }
602
+
603
+ // Also check Image — needs src (catalog-mandatory)
604
+ let imageCount = 0;
605
+ let imageNoSrc = 0;
606
+ for (const comp of components) {
607
+ if (comp.component !== 'Image') continue;
608
+ imageCount++;
609
+ if (!comp.src) {
610
+ imageNoSrc++;
611
+ violations.push(`"${comp.id}" (Image: no src)`);
612
+ }
613
+ }
614
+
615
+ const total = interactiveCount + textCount + imageCount;
616
+ const passed = violations.length === 0;
617
+ return {
618
+ name: 'componentsHaveRenderableContent',
619
+ passed,
620
+ score: total === 0
621
+ ? 1
622
+ : (passed ? 1 : Math.max(0, 1 - violations.length / total)),
623
+ detail: passed
624
+ ? (total === 0 ? 'No interactive/Text/Image components to validate'
625
+ : 'All interactive/Text/Image components have renderable content')
626
+ : `Empty components (registered + valid but render blank): ${violations.join(', ')}`,
627
+ };
628
+ }
629
+
630
+ /**
631
+ * 13. imagesHaveAlt — Image components should have an alt property.
632
+ */
633
+ function checkImagesHaveAlt(components) {
634
+ const missing = [];
635
+
636
+ for (const comp of components) {
637
+ if (comp.component !== 'Image') continue;
638
+ if (comp.alt == null) {
639
+ missing.push(`"${comp.id}"`);
640
+ }
641
+ }
642
+
643
+ const imageCount = components.filter(c => c.component === 'Image').length;
644
+ const passed = missing.length === 0;
645
+ return {
646
+ name: 'imagesHaveAlt',
647
+ passed,
648
+ score: imageCount === 0 ? 1 : (passed ? 1 : Math.max(0, 1 - missing.length / Math.max(1, imageCount))),
649
+ detail: passed
650
+ ? (imageCount === 0 ? 'No image components to validate' : 'All images have alt text')
651
+ : `Missing alt: ${missing.join(', ')}`,
652
+ };
653
+ }
654
+
655
+ /**
656
+ * 14. headingHierarchy — Text components with variant h1-h5 should not skip levels.
657
+ *
658
+ * Warns but doesn't fail hard — score degrades per skip.
659
+ */
660
+ function checkHeadingHierarchy(components) {
661
+ const headingLevels = [];
662
+
663
+ for (const comp of components) {
664
+ if (comp.component !== 'Text') continue;
665
+ const match = /^h([1-5])$/.exec(comp.variant);
666
+ if (match) headingLevels.push(Number(match[1]));
667
+ }
668
+
669
+ if (headingLevels.length === 0) {
670
+ return { name: 'headingHierarchy', passed: true, score: 1, detail: 'No headings to validate' };
671
+ }
672
+
673
+ const sorted = [...new Set(headingLevels)].sort((a, b) => a - b);
674
+ const skips = [];
675
+
676
+ for (let i = 1; i < sorted.length; i++) {
677
+ if (sorted[i] - sorted[i - 1] > 1) {
678
+ skips.push(`h${sorted[i - 1]} → h${sorted[i]}`);
679
+ }
680
+ }
681
+
682
+ const passed = skips.length === 0;
683
+ return {
684
+ name: 'headingHierarchy',
685
+ passed,
686
+ score: passed ? 1 : Math.max(0.5, 1 - skips.length * 0.25),
687
+ detail: passed ? 'Heading hierarchy is sequential' : `Skipped heading levels: ${skips.join(', ')}`,
688
+ };
689
+ }
690
+
691
+ /**
692
+ * 15. tabStructure — Tabs children must only be Tab components, and a Tab
693
+ * must not nest another Tab.
694
+ *
695
+ * Tab IS the panel. `<tab-ui>` renders its default slot as the tab panel and
696
+ * `<tabs-ui>` toggles `[hidden]` + `role="tabpanel"` on the inactive ones
697
+ * (`tabs.class.js`); the strip BUTTON is drawn by the parent from the child's
698
+ * [text]/[icon]/[value]. The catalog says so too — "the tab's default slot is
699
+ * the panel content that the parent auto-hides when inactive"
700
+ * (`components/tabs/tab.yaml` → `catalog-a2ui_0_9_rules.txt` §Tab).
701
+ *
702
+ * This check formerly hard-failed any Tab with a non-Text child ("Tab is a
703
+ * button, not a container", cda69a0aa, Apr 2026) — doctrine that predates the
704
+ * Tab catalog entry and contradicts the component. It made the canonical
705
+ * tabs/modal demo pages unconvertible (gh#754). Content inside Tab is CORRECT.
706
+ *
707
+ * WRONG: Tabs > Card (arbitrary markup directly in the strip)
708
+ * WRONG: Tab > Tab (catalog: "Do not nest <tab-ui> inside another")
709
+ * RIGHT: Tabs > Tab > Column (panel content lives in its Tab)
710
+ */
711
+ function checkTabStructure(components) {
712
+ const byId = new Map(components.map(c => [c.id, c]));
713
+ const issues = [];
714
+
715
+ for (const comp of components) {
716
+ if (comp.component !== 'Tabs') continue;
717
+ if (!Array.isArray(comp.children)) continue;
718
+
719
+ for (const childId of comp.children) {
720
+ const child = byId.get(childId);
721
+ if (!child) continue;
722
+ if (child.component !== 'Tab') {
723
+ issues.push(`Tabs "${comp.id}" has child "${childId}" of type "${child.component}" (only Tab allowed)`);
724
+ }
725
+ }
726
+ }
727
+
728
+ // Tab children ARE the panel content — any construct is allowed except a
729
+ // nested Tab (catalog: "Do not nest <tab-ui> inside another <tab-ui>").
730
+ for (const comp of components) {
731
+ if (comp.component !== 'Tab') continue;
732
+ if (!Array.isArray(comp.children)) continue;
733
+ for (const childId of comp.children) {
734
+ if (byId.get(childId)?.component === 'Tab') {
735
+ issues.push(`Tab "${comp.id}" nests Tab "${childId}" — tabs do not nest inside a tab panel`);
736
+ }
737
+ }
738
+ }
739
+
740
+ const tabCount = components.filter(c => c.component === 'Tabs').length;
741
+ const passed = issues.length === 0;
742
+ return {
743
+ name: 'tabStructure',
744
+ passed,
745
+ score: tabCount === 0 ? 1 : (passed ? 1 : 0),
746
+ detail: passed
747
+ ? (tabCount === 0 ? 'No tabs to validate' : 'Tab structure is correct')
748
+ : issues.join('; '),
749
+ };
750
+ }
751
+
752
+ /**
753
+ * 16. gridVsColumn — Column with 3+ Card children should be Grid.
754
+ *
755
+ * Catches the common LLM mistake of stacking repeating items (task cards,
756
+ * stat tiles, image cards) in a Column instead of a multi-column Grid.
757
+ * This is a layout quality check, not a hard error — some legitimate UIs
758
+ * have many cards in a single column (e.g. feed, timeline).
759
+ */
760
+ function checkGridVsColumn(components) {
761
+ const byId = new Map(components.map(c => [c.id, c]));
762
+ const issues = [];
763
+
764
+ for (const comp of components) {
765
+ if (comp.component !== 'Column') continue;
766
+ if (!Array.isArray(comp.children) || comp.children.length < 3) continue;
767
+
768
+ // Count Card children
769
+ const cardChildren = comp.children.filter(childId => {
770
+ const child = byId.get(childId);
771
+ return child && child.component === 'Card';
772
+ });
773
+
774
+ if (cardChildren.length >= 3) {
775
+ issues.push(`Column "${comp.id}" has ${cardChildren.length} Card children — consider using Grid with columns="2"|"3" for multi-column layout`);
776
+ }
777
+ }
778
+
779
+ const passed = issues.length === 0;
780
+ return {
781
+ name: 'gridVsColumn',
782
+ passed,
783
+ score: passed ? 1 : 0.5,
784
+ detail: passed ? 'Layout containers appropriate' : issues.join('; '),
785
+ };
786
+ }
787
+
788
+ /**
789
+ * 17. landmarkStructure — at least one Header, Section, or Footer component should exist.
790
+ */
791
+ function checkLandmarkStructure(components) {
792
+ const landmarkTypes = new Set(['Header', 'Section', 'Footer']);
793
+ const hasLandmark = components.some(c => landmarkTypes.has(c.component));
794
+
795
+ return {
796
+ name: 'landmarkStructure',
797
+ passed: hasLandmark,
798
+ score: hasLandmark ? 1 : 0,
799
+ detail: hasLandmark ? 'Landmark structure present' : 'No Header, Section, or Footer components found',
800
+ };
801
+ }
802
+
803
+ /**
804
+ * 17. intentAlignment — semantic alignment between intent and output.
805
+ *
806
+ * Three sub-checks weighted together:
807
+ * A. Keyword matching (40%) — intent keywords map to expected component types
808
+ * B. Pattern mismatch (35%) — dominant output types should be relevant to the intent
809
+ * C. Complexity match (25%) — compound intents need more than a handful of components
810
+ *
811
+ * Returns score 1 (no intent provided or all matched), degrades per miss.
812
+ */
813
+ function checkIntentAlignment(components, intent) {
814
+ if (!intent || typeof intent !== 'string') {
815
+ return { name: 'intentAlignment', passed: true, score: 1, detail: 'No intent provided' };
816
+ }
817
+
818
+ // Map intent keywords → expected component types
819
+ const KEYWORD_TO_COMPONENT = {
820
+ 'table': ['Table'],
821
+ 'chart': ['Chart'],
822
+ 'graph': ['Chart'],
823
+ 'form': ['Input', 'Button'],
824
+ 'input': ['Input', 'TextArea'],
825
+ 'button': ['Button'],
826
+ 'avatar': ['Avatar'],
827
+ 'badge': ['Badge'],
828
+ 'progress': ['Progress'],
829
+ 'slider': ['Slider'],
830
+ 'toggle': ['Switch', 'Toggle'],
831
+ 'switch': ['Switch'],
832
+ 'checkbox': ['CheckBox'],
833
+ 'radio': ['Radio'],
834
+ 'select': ['Select'],
835
+ 'upload': ['Upload'],
836
+ 'tabs': ['Tabs', 'Tab'],
837
+ 'tab': ['Tabs', 'Tab'],
838
+ 'accordion': ['Accordion', 'AccordionItem'],
839
+ 'modal': ['Modal'],
840
+ 'dialog': ['Modal'],
841
+ 'drawer': ['Drawer'],
842
+ 'toast': ['Toast'],
843
+ 'alert': ['Alert'],
844
+ 'tooltip': ['Tooltip'],
845
+ 'popover': ['Popover'],
846
+ 'breadcrumb': ['Breadcrumb'],
847
+ 'pagination': ['Pagination'],
848
+ 'timeline': ['Timeline', 'TimelineItem'],
849
+ 'carousel': ['Swiper'],
850
+ 'swiper': ['Swiper'],
851
+ 'calendar': ['CalendarPicker'],
852
+ 'color picker': ['ColorPicker'],
853
+ 'otp': ['OtpInput'],
854
+ 'code': ['Code'],
855
+ 'image': ['Image'],
856
+ 'icon': ['Icon'],
857
+ 'divider': ['Divider'],
858
+ 'skeleton': ['Skeleton'],
859
+ 'embed': ['Embed'],
860
+ 'command': ['Command'],
861
+ 'stat': ['Stat'],
862
+ 'tag': ['Tag'],
863
+ 'menu': ['Menu'],
864
+ 'toolbar': ['Toolbar'],
865
+ // Composite patterns — intent describes a UI pattern, not a single component
866
+ 'inbox': ['Column', 'Row', 'Avatar', 'Text', 'CheckBox'],
867
+ 'email inbox': ['Column', 'Row', 'Avatar', 'Text'],
868
+ 'notification': ['Column', 'Row', 'Text', 'Badge'],
869
+ 'shopping': ['Column', 'Row', 'Text', 'Button'],
870
+ 'cart': ['Column', 'Row', 'Text', 'Button'],
871
+ 'kanban': ['Grid', 'Column', 'Card'],
872
+ 'dashboard': ['Grid', 'Card'],
873
+ 'settings': ['Card', 'Column', 'Row'],
874
+ 'profile': ['Card', 'Avatar', 'Text'],
875
+ 'login': ['Card', 'Input', 'Button'],
876
+ 'signup': ['Card', 'Input', 'Button', 'CheckBox'],
877
+ };
878
+
879
+ // ── Reverse mapping: component type → which intent keywords expect it ──
880
+ const COMPONENT_TO_KEYWORDS = {};
881
+ for (const [keyword, types] of Object.entries(KEYWORD_TO_COMPONENT)) {
882
+ for (const t of types) {
883
+ if (!COMPONENT_TO_KEYWORDS[t]) COMPONENT_TO_KEYWORDS[t] = [];
884
+ COMPONENT_TO_KEYWORDS[t].push(keyword);
885
+ }
886
+ }
887
+
888
+ // ── Semantic domain mapping: component types → semantic categories ──
889
+ const SEMANTIC_DOMAIN = {
890
+ 'Toast': 'notification',
891
+ 'Alert': 'notification',
892
+ 'Badge': 'notification',
893
+ 'Table': 'data-display',
894
+ 'Chart': 'data-display',
895
+ 'Stat': 'data-display',
896
+ 'Form': 'data-entry',
897
+ 'Input': 'data-entry',
898
+ 'TextField': 'data-entry',
899
+ 'TextArea': 'data-entry',
900
+ 'Select': 'data-entry',
901
+ 'ChoicePicker': 'data-entry',
902
+ 'CheckBox': 'data-entry',
903
+ 'Radio': 'data-entry',
904
+ 'Toggle': 'data-entry',
905
+ 'Switch': 'data-entry',
906
+ 'Slider': 'data-entry',
907
+ 'Upload': 'data-entry',
908
+ 'Modal': 'overlay',
909
+ 'Dialog': 'overlay',
910
+ 'Drawer': 'overlay',
911
+ 'Popover': 'overlay',
912
+ 'Tooltip': 'overlay',
913
+ 'Tabs': 'navigation',
914
+ 'Tab': 'navigation',
915
+ 'Breadcrumb': 'navigation',
916
+ 'Pagination': 'navigation',
917
+ 'Menu': 'navigation',
918
+ 'Sidebar': 'navigation',
919
+ 'Nav': 'navigation',
920
+ 'Avatar': 'identity',
921
+ 'Image': 'media',
922
+ 'Embed': 'media',
923
+ 'Card': 'layout',
924
+ 'Column': 'layout',
925
+ 'Row': 'layout',
926
+ 'Grid': 'layout',
927
+ 'Text': 'layout',
928
+ 'Button': 'action',
929
+ 'Icon': 'decoration',
930
+ 'Divider': 'decoration',
931
+ };
932
+
933
+ // Intent → expected semantic domains
934
+ const INTENT_DOMAINS = {
935
+ 'inbox': ['identity', 'data-entry', 'navigation'],
936
+ 'email': ['identity', 'data-entry', 'navigation'],
937
+ 'dashboard': ['data-display', 'navigation'],
938
+ 'admin': ['data-display', 'navigation', 'data-entry'],
939
+ 'analytics': ['data-display'],
940
+ 'menu': ['navigation', 'action'],
941
+ 'restaurant': ['layout', 'action', 'media'],
942
+ 'form': ['data-entry', 'action'],
943
+ 'login': ['data-entry', 'action'],
944
+ 'signup': ['data-entry', 'action'],
945
+ 'settings': ['data-entry', 'action'],
946
+ 'profile': ['identity', 'media'],
947
+ 'chat': ['data-entry', 'identity'],
948
+ 'shopping': ['media', 'action'],
949
+ 'cart': ['data-display', 'action'],
950
+ 'kanban': ['layout'],
951
+ 'calendar': ['data-display'],
952
+ };
953
+
954
+ // ── Domains that should NOT dominate for certain intents ──
955
+ const ANTI_DOMAINS = {
956
+ 'inbox': ['notification', 'overlay'],
957
+ 'email': ['notification', 'overlay'],
958
+ 'dashboard': ['notification', 'overlay'],
959
+ 'restaurant': ['notification', 'overlay'],
960
+ 'menu': ['notification', 'overlay'],
961
+ 'form': ['notification'],
962
+ 'profile': ['notification'],
963
+ 'shopping': ['notification'],
964
+ 'calendar': ['notification'],
965
+ 'admin': ['notification', 'overlay'],
966
+ 'analytics': ['notification'],
967
+ };
968
+
969
+ const lower = intent.toLowerCase();
970
+ const outputTypes = new Set(components.map(c => c.component));
971
+
972
+ // Count component types (excluding layout containers for dominance check)
973
+ const typeCounts = {};
974
+ for (const c of components) {
975
+ typeCounts[c.component] = (typeCounts[c.component] || 0) + 1;
976
+ }
977
+
978
+ const details = [];
979
+ let keywordScore = 1;
980
+ let patternScore = 1;
981
+ let complexityScore = 1;
982
+
983
+ // ─── Sub-check A: Keyword matching (existing logic, enhanced) ───
984
+
985
+ const matched = [];
986
+ const missed = [];
987
+
988
+ for (const [keyword, expectedTypes] of Object.entries(KEYWORD_TO_COMPONENT)) {
989
+ const pattern = new RegExp(`\\b${keyword.replace(/\s+/g, '\\s+')}\\b`);
990
+ if (!pattern.test(lower)) continue;
991
+
992
+ const foundTypes = expectedTypes.filter(t => outputTypes.has(t));
993
+ const minRequired = expectedTypes.length >= 3 ? Math.ceil(expectedTypes.length / 2) : 1;
994
+
995
+ if (foundTypes.length >= minRequired) {
996
+ matched.push(keyword);
997
+ } else {
998
+ const missingTypes = expectedTypes.filter(t => !outputTypes.has(t));
999
+ missed.push(`"${keyword}" → missing ${missingTypes.join(', ')}`);
1000
+ }
1001
+ }
1002
+
1003
+ const keywordTotal = matched.length + missed.length;
1004
+ if (keywordTotal > 0) {
1005
+ keywordScore = matched.length / keywordTotal;
1006
+ if (missed.length > 0) {
1007
+ details.push(`Missing components for: ${missed.join('; ')}`);
1008
+ }
1009
+ }
1010
+
1011
+ // ─── Sub-check B: Pattern mismatch — dominant irrelevant types ───
1012
+
1013
+ // Find the semantic domains present in the output (weighted by count)
1014
+ const domainCounts = {};
1015
+ const nonLayoutTypes = components.filter(c =>
1016
+ !['Column', 'Row', 'Grid', 'Card', 'Text', 'Header', 'Section', 'Footer', 'Button', 'Icon', 'Divider'].includes(c.component)
1017
+ );
1018
+
1019
+ for (const c of nonLayoutTypes) {
1020
+ const domain = SEMANTIC_DOMAIN[c.component];
1021
+ if (domain) {
1022
+ domainCounts[domain] = (domainCounts[domain] || 0) + 1;
1023
+ }
1024
+ }
1025
+
1026
+ // Check if any anti-domain dominates (>50% of non-layout components)
1027
+ const nonLayoutCount = nonLayoutTypes.length;
1028
+ if (nonLayoutCount > 0) {
1029
+ for (const [intentKey, antiDomains] of Object.entries(ANTI_DOMAINS)) {
1030
+ const keyPattern = new RegExp(`\\b${intentKey}\\b`);
1031
+ if (!keyPattern.test(lower)) continue;
1032
+
1033
+ for (const antiDomain of antiDomains) {
1034
+ const antiCount = domainCounts[antiDomain] || 0;
1035
+ const ratio = antiCount / nonLayoutCount;
1036
+ if (ratio > 0.4 && antiCount >= 2) {
1037
+ // Heavy penalty — wrong pattern entirely
1038
+ const penalty = Math.min(0.8, ratio);
1039
+ patternScore = Math.min(patternScore, 1 - penalty);
1040
+ details.push(`Semantic mismatch: "${antiDomain}" components dominate (${antiCount}/${nonLayoutCount}) but intent is "${intentKey}"`);
1041
+ }
1042
+ }
1043
+ }
1044
+
1045
+ // Also check: if output is dominated by a single specific component type
1046
+ // (e.g., 4 Toast components) and that type isn't mentioned in the intent
1047
+ for (const [type, count] of Object.entries(typeCounts)) {
1048
+ if (['Column', 'Row', 'Grid', 'Card', 'Text', 'Header', 'Section', 'Footer', 'Button', 'Icon', 'Divider'].includes(type)) continue;
1049
+ const ratio = count / components.length;
1050
+ if (ratio > 0.25 && count >= 3) {
1051
+ // This type dominates — is it mentioned in the intent?
1052
+ const keywords = COMPONENT_TO_KEYWORDS[type] || [];
1053
+ const mentionedInIntent = keywords.some(kw => {
1054
+ const kwPattern = new RegExp(`\\b${kw.replace(/\s+/g, '\\s+')}\\b`);
1055
+ return kwPattern.test(lower);
1056
+ });
1057
+ if (!mentionedInIntent) {
1058
+ const penalty = Math.min(0.7, ratio * 1.5);
1059
+ patternScore = Math.min(patternScore, 1 - penalty);
1060
+ details.push(`Dominant irrelevant type: ${count}× ${type} (${Math.round(ratio * 100)}% of output) not mentioned in intent`);
1061
+ }
1062
+ }
1063
+ }
1064
+ }
1065
+
1066
+ // ─── Sub-check C: Complexity adequacy ───
1067
+
1068
+ // Compound intents should produce enough components
1069
+ const COMPOUND_PATTERNS = [
1070
+ { pattern: /\b(?:inbox|email\s+inbox|email\s+client)\b/, minComponents: 12 },
1071
+ { pattern: /\b(?:dashboard|admin\s+dashboard|analytics)\b/, minComponents: 10 },
1072
+ { pattern: /\b(?:restaurant\s+menu|food\s+menu)\b/, minComponents: 10 },
1073
+ { pattern: /\b(?:kanban|project\s+board)\b/, minComponents: 10 },
1074
+ { pattern: /\b(?:shopping|e-?commerce|product\s+list)\b/, minComponents: 10 },
1075
+ { pattern: /\b(?:settings|preferences)\b/, minComponents: 8 },
1076
+ { pattern: /\b(?:chat|messaging)\b/, minComponents: 8 },
1077
+ { pattern: /\b(?:profile|user\s+profile)\b/, minComponents: 8 },
1078
+ { pattern: /\b(?:login|sign\s*in)\b/, minComponents: 6 },
1079
+ { pattern: /\b(?:signup|sign\s*up|register)\b/, minComponents: 7 },
1080
+ ];
1081
+
1082
+ for (const { pattern, minComponents } of COMPOUND_PATTERNS) {
1083
+ if (!pattern.test(lower)) continue;
1084
+ if (components.length < minComponents) {
1085
+ const ratio = components.length / minComponents;
1086
+ complexityScore = Math.min(complexityScore, ratio);
1087
+ details.push(`Too few components: ${components.length} for "${pattern.source}" (expected ≥${minComponents})`);
1088
+ }
1089
+ }
1090
+
1091
+ // Also: count distinct words in intent as a rough complexity measure
1092
+ const intentWords = lower.split(/\s+/).filter(w => w.length > 3).length;
1093
+ if (intentWords >= 4 && components.length < 8) {
1094
+ const penalty = Math.max(0, (8 - components.length) / 8) * 0.5;
1095
+ complexityScore = Math.min(complexityScore, 1 - penalty);
1096
+ details.push(`Sparse output: ${components.length} components for multi-word intent (${intentWords} significant words)`);
1097
+ }
1098
+
1099
+ // ─── Composite score ───
1100
+ // A: keyword match (40%), B: pattern mismatch (35%), C: complexity (25%)
1101
+ const score = Math.max(0, Math.min(1,
1102
+ keywordScore * 0.40 +
1103
+ patternScore * 0.35 +
1104
+ complexityScore * 0.25
1105
+ ));
1106
+
1107
+ const passed = score >= 0.8;
1108
+
1109
+ return {
1110
+ name: 'intentAlignment',
1111
+ passed,
1112
+ score,
1113
+ detail: details.length > 0
1114
+ ? details.join('; ')
1115
+ : (keywordTotal > 0
1116
+ ? `All ${matched.length} intent keywords have matching components`
1117
+ : 'No keyword-to-component mappings matched intent'),
1118
+ };
1119
+ }
1120
+
1121
+ // ═══════════════════════════════════════════════════════════════
1122
+ // WIRING CHECKS (A007 §9.4)
1123
+ // Only scored when wireComponents messages are present.
1124
+ // ═══════════════════════════════════════════════════════════════
1125
+
1126
+ /**
1127
+ * Validate a wireComponents message against the wiring registry and component tree.
1128
+ * @param {object} wire — wireComponents message
1129
+ * @param {Set<string>} componentIds — IDs from updateComponents
1130
+ * @returns {object[]} — Array of check results
1131
+ */
1132
+ function checkOntologyAlignment(components, context) {
1133
+ if (!context || !context.domain || !context.domain.entities) {
1134
+ return { name: 'ontologyAlignment', passed: true, score: 1, detail: 'No ontology context provided' };
1135
+ }
1136
+
1137
+ // Check if at least one entity or task from the context appears in the components
1138
+ const entities = context.domain.entities.map(e => e.toLowerCase());
1139
+ const tasks = (context.tasks?.primary || []).map(t => t.toLowerCase());
1140
+ const terms = [...entities, ...tasks];
1141
+
1142
+ if (terms.length === 0) {
1143
+ return { name: 'ontologyAlignment', passed: true, score: 1, detail: 'Empty ontology context' };
1144
+ }
1145
+
1146
+ const jsonStr = JSON.stringify(components).toLowerCase();
1147
+
1148
+ const foundTerms = terms.filter(t => jsonStr.includes(t));
1149
+
1150
+ // Need at least one term match. If 0, it hallucinates completely different data.
1151
+ const score = foundTerms.length > 0 ? 1 : 0;
1152
+
1153
+ return {
1154
+ name: 'ontologyAlignment',
1155
+ passed: score > 0,
1156
+ score: score,
1157
+ detail: score > 0
1158
+ ? `Ontology respected (Found terms: ${foundTerms.join(', ')})`
1159
+ : `Ontology violation: None of the planned domain entities or tasks were found in the UI tree.`,
1160
+ };
1161
+ }
1162
+
1163
+ function checkWiring(wire, componentIds) {
1164
+ const checks = [];
1165
+
1166
+ // ── Controllers exist in registry ──
1167
+ if (wire.state?.controllers?.length) {
1168
+ const unknown = wire.state.controllers.filter(c => !wiringRegistry.controllers.has(c.type));
1169
+ checks.push({
1170
+ name: 'wiringControllersExist',
1171
+ passed: unknown.length === 0,
1172
+ score: unknown.length === 0 ? 1 : Math.max(0, 1 - unknown.length * 0.5),
1173
+ detail: unknown.length === 0
1174
+ ? `All ${wire.state.controllers.length} controller types registered`
1175
+ : `Unknown controllers: ${unknown.map(c => c.type).join(', ')}`,
1176
+ });
1177
+ }
1178
+
1179
+ // ── Controller hosts reference existing component IDs ──
1180
+ if (wire.state?.controllers?.length) {
1181
+ const missing = wire.state.controllers.filter(c => !componentIds.has(c.host));
1182
+ checks.push({
1183
+ name: 'wiringHostsExist',
1184
+ passed: missing.length === 0,
1185
+ score: missing.length === 0 ? 1 : 0,
1186
+ detail: missing.length === 0
1187
+ ? 'All controller hosts reference valid component IDs'
1188
+ : `Missing hosts: ${missing.map(c => `${c.id}→${c.host}`).join(', ')}`,
1189
+ });
1190
+ }
1191
+
1192
+ // ── Action handlers exist in registry ──
1193
+ if (wire.actions?.length) {
1194
+ const unknown = wire.actions.filter(a => !wiringRegistry.handlers.has(a.handler));
1195
+ checks.push({
1196
+ name: 'wiringHandlersExist',
1197
+ passed: unknown.length === 0,
1198
+ score: unknown.length === 0 ? 1 : Math.max(0, 1 - unknown.length * 0.5),
1199
+ detail: unknown.length === 0
1200
+ ? `All ${wire.actions.length} action handlers registered`
1201
+ : `Unknown handlers: ${unknown.map(a => a.handler).join(', ')}`,
1202
+ });
1203
+ }
1204
+
1205
+ // ── Action sources reference existing component IDs ──
1206
+ if (wire.actions?.length) {
1207
+ const missing = wire.actions.filter(a => !componentIds.has(a.source));
1208
+ checks.push({
1209
+ name: 'wiringSourcesExist',
1210
+ passed: missing.length === 0,
1211
+ score: missing.length === 0 ? 1 : 0,
1212
+ detail: missing.length === 0
1213
+ ? 'All action sources reference valid component IDs'
1214
+ : `Missing sources: ${missing.map(a => `${a.event}→${a.source}`).join(', ')}`,
1215
+ });
1216
+ }
1217
+
1218
+ // ── Data source paths are valid JSON Pointers ──
1219
+ if (wire.data?.sources?.length) {
1220
+ const invalid = wire.data.sources.filter(s => !s.path || !s.path.startsWith('/'));
1221
+ checks.push({
1222
+ name: 'wiringDataPathsValid',
1223
+ passed: invalid.length === 0,
1224
+ score: invalid.length === 0 ? 1 : 0,
1225
+ detail: invalid.length === 0
1226
+ ? 'All data source paths are valid JSON Pointers'
1227
+ : `Invalid paths: ${invalid.map(s => `${s.id}:"${s.path}"`).join(', ')}`,
1228
+ });
1229
+ }
1230
+
1231
+ return checks;
1232
+ }