@adia-ai/a2ui-validator 0.0.1
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.
- package/CHANGELOG.md +36 -0
- package/README.md +54 -0
- package/catalog-validator.js +162 -0
- package/index.js +11 -0
- package/package.json +35 -0
- package/semantic/cache.js +54 -0
- package/semantic/index.js +163 -0
- package/semantic/judge.js +180 -0
- package/validator.js +1074 -0
package/validator.js
ADDED
|
@@ -0,0 +1,1074 @@
|
|
|
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
|
+
import { registry, wiringRegistry } from '@adia-ai/a2ui-utils';
|
|
12
|
+
|
|
13
|
+
// ── Check weights (must sum to 100 for component checks) ──
|
|
14
|
+
|
|
15
|
+
const WEIGHTS = {
|
|
16
|
+
validMessageFormat: 8,
|
|
17
|
+
hasRootComponent: 7,
|
|
18
|
+
allTypesRegistered: 9,
|
|
19
|
+
noOrphanedChildren: 9,
|
|
20
|
+
cardStructure: 6,
|
|
21
|
+
flatAdjacency: 5,
|
|
22
|
+
noBareDivs: 7,
|
|
23
|
+
noHardcodedColors: 3,
|
|
24
|
+
noInlineLayout: 5,
|
|
25
|
+
textContentSet: 5,
|
|
26
|
+
idUniqueness: 5,
|
|
27
|
+
interactiveHasLabel: 4,
|
|
28
|
+
imagesHaveAlt: 3,
|
|
29
|
+
headingHierarchy: 3,
|
|
30
|
+
tabStructure: 2,
|
|
31
|
+
gridVsColumn: 3,
|
|
32
|
+
landmarkStructure: 3,
|
|
33
|
+
intentAlignment: 13,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// Wiring check weights (scored separately, not affecting component score)
|
|
37
|
+
const WIRING_WEIGHTS = {
|
|
38
|
+
wiringControllersExist: 3,
|
|
39
|
+
wiringHostsExist: 3,
|
|
40
|
+
wiringHandlersExist: 3,
|
|
41
|
+
wiringSourcesExist: 2,
|
|
42
|
+
wiringDataPathsValid: 2,
|
|
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
|
+
checkImagesHaveAlt(allComponents),
|
|
112
|
+
checkHeadingHierarchy(allComponents),
|
|
113
|
+
checkTabStructure(allComponents),
|
|
114
|
+
checkGridVsColumn(allComponents),
|
|
115
|
+
checkLandmarkStructure(allComponents),
|
|
116
|
+
checkIntentAlignment(allComponents, options.intent),
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
// Wiring checks (only scored when wireComponents present)
|
|
120
|
+
if (wireMessages.length > 0) {
|
|
121
|
+
const componentIds = new Set(allComponents.map(c => c.id));
|
|
122
|
+
for (const wire of wireMessages) {
|
|
123
|
+
checks.push(...checkWiring(wire, componentIds));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Weighted score (component checks only — wiring checks reported but don't affect score)
|
|
128
|
+
let score = 0;
|
|
129
|
+
for (const check of checks) {
|
|
130
|
+
const weight = WEIGHTS[check.name] || 0;
|
|
131
|
+
score += (check.score * weight) / 100;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Normalize to 0-100
|
|
135
|
+
score = Math.round(score * 100);
|
|
136
|
+
|
|
137
|
+
// Hard-fail: bare HTML elements or unregistered types mean the output is fundamentally wrong
|
|
138
|
+
const hardFails = ['noBareDivs', 'allTypesRegistered', 'hasRootComponent', 'tabStructure'];
|
|
139
|
+
const hasHardFail = checks.some(c => hardFails.includes(c.name) && !c.passed);
|
|
140
|
+
|
|
141
|
+
// Severe intent mismatch: if intentAlignment score < 0.3 and intent was provided,
|
|
142
|
+
// apply a penalty multiplier to the total score — wrong pattern is fundamentally wrong
|
|
143
|
+
const intentCheck = checks.find(c => c.name === 'intentAlignment');
|
|
144
|
+
if (intentCheck && intentCheck.score < 0.3 && options.intent) {
|
|
145
|
+
// Scale the total score down: a 0.17 intent score → multiplier ~0.42
|
|
146
|
+
const intentPenalty = 0.25 + (intentCheck.score * 2.5); // range 0.25-1.0
|
|
147
|
+
score = Math.round(score * intentPenalty);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
score,
|
|
152
|
+
checks,
|
|
153
|
+
valid: score >= 70 && !hasHardFail,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── Individual checks ──
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 1. validMessageFormat — each message has `type` and required fields.
|
|
161
|
+
*/
|
|
162
|
+
function checkValidMessageFormat(messages) {
|
|
163
|
+
const issues = [];
|
|
164
|
+
|
|
165
|
+
for (let i = 0; i < messages.length; i++) {
|
|
166
|
+
const msg = messages[i];
|
|
167
|
+
if (!msg || typeof msg !== 'object') {
|
|
168
|
+
issues.push(`Message ${i}: not an object`);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (!msg.type) {
|
|
172
|
+
issues.push(`Message ${i}: missing "type" field`);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (msg.type === 'updateComponents') {
|
|
177
|
+
if (!msg.surfaceId) issues.push(`Message ${i}: updateComponents missing "surfaceId"`);
|
|
178
|
+
if (!Array.isArray(msg.components)) issues.push(`Message ${i}: updateComponents missing "components" array`);
|
|
179
|
+
} else if (msg.type === 'updateDataModel') {
|
|
180
|
+
if (!msg.surfaceId) issues.push(`Message ${i}: updateDataModel missing "surfaceId"`);
|
|
181
|
+
if (msg.data === undefined && msg.model === undefined) issues.push(`Message ${i}: updateDataModel missing "data" or "model"`);
|
|
182
|
+
}
|
|
183
|
+
// Unknown message types are allowed (extensibility)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const passed = issues.length === 0;
|
|
187
|
+
return {
|
|
188
|
+
name: 'validMessageFormat',
|
|
189
|
+
passed,
|
|
190
|
+
score: passed ? 1 : Math.max(0, 1 - issues.length / messages.length),
|
|
191
|
+
detail: passed ? 'All messages have valid format' : issues.join('; '),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* 2. hasRootComponent — at least one component with id: 'root'.
|
|
197
|
+
*/
|
|
198
|
+
function checkHasRootComponent(components) {
|
|
199
|
+
const hasRoot = components.some(c => c.id === 'root');
|
|
200
|
+
return {
|
|
201
|
+
name: 'hasRootComponent',
|
|
202
|
+
passed: hasRoot,
|
|
203
|
+
score: hasRoot ? 1 : 0,
|
|
204
|
+
detail: hasRoot ? 'Root component found' : 'No component with id "root" found',
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* 3. allTypesRegistered — all component types exist in the AdiaUI registry.
|
|
210
|
+
*/
|
|
211
|
+
function checkAllTypesRegistered(components) {
|
|
212
|
+
const unregistered = [];
|
|
213
|
+
|
|
214
|
+
// Native card children that are valid but not in the main registry lookup
|
|
215
|
+
const nativeTypes = new Set(['Section', 'Header', 'Footer']);
|
|
216
|
+
|
|
217
|
+
for (const comp of components) {
|
|
218
|
+
const type = comp.component;
|
|
219
|
+
if (!type) continue;
|
|
220
|
+
if (nativeTypes.has(type)) continue;
|
|
221
|
+
if (!registry.has(type)) {
|
|
222
|
+
unregistered.push(`"${type}" (id: ${comp.id})`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const passed = unregistered.length === 0;
|
|
227
|
+
return {
|
|
228
|
+
name: 'allTypesRegistered',
|
|
229
|
+
passed,
|
|
230
|
+
score: passed ? 1 : Math.max(0, 1 - unregistered.length / Math.max(1, components.length)),
|
|
231
|
+
detail: passed ? 'All component types are registered' : `Unregistered types: ${unregistered.join(', ')}`,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* 4. noOrphanedChildren — all IDs referenced in children arrays exist.
|
|
237
|
+
*/
|
|
238
|
+
function checkNoOrphanedChildren(components) {
|
|
239
|
+
const ids = new Set(components.map(c => c.id));
|
|
240
|
+
const orphans = [];
|
|
241
|
+
|
|
242
|
+
for (const comp of components) {
|
|
243
|
+
if (!Array.isArray(comp.children)) continue;
|
|
244
|
+
for (const childId of comp.children) {
|
|
245
|
+
if (!ids.has(childId)) {
|
|
246
|
+
orphans.push(`"${childId}" referenced by "${comp.id}"`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const passed = orphans.length === 0;
|
|
252
|
+
return {
|
|
253
|
+
name: 'noOrphanedChildren',
|
|
254
|
+
passed,
|
|
255
|
+
score: passed ? 1 : Math.max(0, 1 - orphans.length / Math.max(1, components.length)),
|
|
256
|
+
detail: passed ? 'All child references resolve' : `Orphaned children: ${orphans.join(', ')}`,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* 5. cardStructure — Cards have header/section/footer as direct children.
|
|
262
|
+
*
|
|
263
|
+
* Checks:
|
|
264
|
+
* - Card children should only be Header, Section, or Footer
|
|
265
|
+
* - Header and Footer should not be nested inside Section
|
|
266
|
+
* - Section content should be wrapped in Column
|
|
267
|
+
*/
|
|
268
|
+
function checkCardStructure(components) {
|
|
269
|
+
const byId = new Map(components.map(c => [c.id, c]));
|
|
270
|
+
const issues = [];
|
|
271
|
+
|
|
272
|
+
for (const comp of components) {
|
|
273
|
+
if (comp.component !== 'Card') continue;
|
|
274
|
+
if (!Array.isArray(comp.children)) continue;
|
|
275
|
+
|
|
276
|
+
for (const childId of comp.children) {
|
|
277
|
+
const child = byId.get(childId);
|
|
278
|
+
if (!child) continue;
|
|
279
|
+
|
|
280
|
+
const childType = child.component;
|
|
281
|
+
if (!['Header', 'Section', 'Footer'].includes(childType)) {
|
|
282
|
+
issues.push(`Card "${comp.id}" has direct child "${childId}" of type "${childType}" (expected Header/Section/Footer)`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Check that Sections don't contain Header or Footer
|
|
287
|
+
for (const childId of comp.children) {
|
|
288
|
+
const child = byId.get(childId);
|
|
289
|
+
if (!child || child.component !== 'Section') continue;
|
|
290
|
+
if (!Array.isArray(child.children)) continue;
|
|
291
|
+
|
|
292
|
+
for (const sectionChildId of child.children) {
|
|
293
|
+
const sectionChild = byId.get(sectionChildId);
|
|
294
|
+
if (!sectionChild) continue;
|
|
295
|
+
if (['Header', 'Footer'].includes(sectionChild.component)) {
|
|
296
|
+
issues.push(`Section "${childId}" contains "${sectionChild.component}" — must be direct child of Card`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const passed = issues.length === 0;
|
|
303
|
+
const cardCount = components.filter(c => c.component === 'Card').length;
|
|
304
|
+
return {
|
|
305
|
+
name: 'cardStructure',
|
|
306
|
+
passed,
|
|
307
|
+
score: cardCount === 0 ? 1 : (passed ? 1 : Math.max(0, 1 - issues.length / Math.max(1, cardCount))),
|
|
308
|
+
detail: passed ? (cardCount === 0 ? 'No cards to validate' : 'Card structure is correct') : issues.join('; '),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* 6. flatAdjacency — components are a flat list with ID references.
|
|
314
|
+
*
|
|
315
|
+
* Checks that updateComponents messages contain a flat array, not nested objects.
|
|
316
|
+
*/
|
|
317
|
+
function checkFlatAdjacency(messages) {
|
|
318
|
+
const issues = [];
|
|
319
|
+
|
|
320
|
+
for (const msg of messages) {
|
|
321
|
+
if (!msg || typeof msg !== 'object') continue;
|
|
322
|
+
if (msg.type !== 'updateComponents') continue;
|
|
323
|
+
if (!Array.isArray(msg.components)) continue;
|
|
324
|
+
|
|
325
|
+
for (let i = 0; i < msg.components.length; i++) {
|
|
326
|
+
const comp = msg.components[i];
|
|
327
|
+
if (!comp || typeof comp !== 'object') continue;
|
|
328
|
+
|
|
329
|
+
// Children should be string IDs, not nested objects
|
|
330
|
+
if (Array.isArray(comp.children)) {
|
|
331
|
+
for (const child of comp.children) {
|
|
332
|
+
if (typeof child !== 'string') {
|
|
333
|
+
issues.push(`Component "${comp.id}" has non-string child in children array`);
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const passed = issues.length === 0;
|
|
342
|
+
return {
|
|
343
|
+
name: 'flatAdjacency',
|
|
344
|
+
passed,
|
|
345
|
+
score: passed ? 1 : 0,
|
|
346
|
+
detail: passed ? 'All components use flat adjacency (string ID references)' : issues.join('; '),
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* 7. noBareDivs — no component: 'div' or other raw HTML tags.
|
|
352
|
+
*/
|
|
353
|
+
function checkNoBareDivs(components) {
|
|
354
|
+
const bareTypes = new Set(['div', 'span', 'main', 'aside', 'article', 'nav', 'ul', 'ol', 'li', 'table', 'tr', 'td', 'th']);
|
|
355
|
+
const offenders = [];
|
|
356
|
+
|
|
357
|
+
for (const comp of components) {
|
|
358
|
+
const type = comp.component;
|
|
359
|
+
if (!type) continue;
|
|
360
|
+
if (bareTypes.has(type.toLowerCase())) {
|
|
361
|
+
offenders.push(`"${type}" (id: ${comp.id})`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const passed = offenders.length === 0;
|
|
366
|
+
return {
|
|
367
|
+
name: 'noBareDivs',
|
|
368
|
+
passed,
|
|
369
|
+
score: passed ? 1 : 0,
|
|
370
|
+
detail: passed ? 'No bare HTML element types' : `Bare elements: ${offenders.join(', ')}`,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* 8. noHardcodedColors — no inline style with color: or background:.
|
|
376
|
+
*/
|
|
377
|
+
function checkNoHardcodedColors(components) {
|
|
378
|
+
const pattern = /(?:^|;)\s*(?:color|background|background-color)\s*:/i;
|
|
379
|
+
const offenders = [];
|
|
380
|
+
|
|
381
|
+
for (const comp of components) {
|
|
382
|
+
if (typeof comp.style === 'string' && pattern.test(comp.style)) {
|
|
383
|
+
offenders.push(`"${comp.id}" has hardcoded color in style`);
|
|
384
|
+
}
|
|
385
|
+
// Also check style as object
|
|
386
|
+
if (comp.style && typeof comp.style === 'object') {
|
|
387
|
+
for (const key of Object.keys(comp.style)) {
|
|
388
|
+
const lower = key.toLowerCase().replace(/[A-Z]/g, m => '-' + m.toLowerCase());
|
|
389
|
+
if (['color', 'background', 'background-color'].includes(lower)) {
|
|
390
|
+
offenders.push(`"${comp.id}" has hardcoded "${key}" in style object`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const passed = offenders.length === 0;
|
|
397
|
+
return {
|
|
398
|
+
name: 'noHardcodedColors',
|
|
399
|
+
passed,
|
|
400
|
+
score: passed ? 1 : Math.max(0, 1 - offenders.length / Math.max(1, components.length)),
|
|
401
|
+
detail: passed ? 'No hardcoded colors' : offenders.join('; '),
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* 9. noInlineLayout — no inline style with display:, flex:, or grid:.
|
|
407
|
+
*/
|
|
408
|
+
function checkNoInlineLayout(components) {
|
|
409
|
+
const pattern = /(?:^|;)\s*(?:display|flex|grid|flex-direction|justify-content|align-items|gap)\s*:/i;
|
|
410
|
+
const offenders = [];
|
|
411
|
+
|
|
412
|
+
for (const comp of components) {
|
|
413
|
+
if (typeof comp.style === 'string' && pattern.test(comp.style)) {
|
|
414
|
+
offenders.push(`"${comp.id}" has inline layout in style`);
|
|
415
|
+
}
|
|
416
|
+
if (comp.style && typeof comp.style === 'object') {
|
|
417
|
+
for (const key of Object.keys(comp.style)) {
|
|
418
|
+
const lower = key.toLowerCase().replace(/[A-Z]/g, m => '-' + m.toLowerCase());
|
|
419
|
+
if (['display', 'flex', 'grid', 'flex-direction', 'justify-content', 'align-items', 'gap'].includes(lower)) {
|
|
420
|
+
offenders.push(`"${comp.id}" has inline "${key}" in style object`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const passed = offenders.length === 0;
|
|
427
|
+
return {
|
|
428
|
+
name: 'noInlineLayout',
|
|
429
|
+
passed,
|
|
430
|
+
score: passed ? 1 : Math.max(0, 1 - offenders.length / Math.max(1, components.length)),
|
|
431
|
+
detail: passed ? 'No inline layout styles' : offenders.join('; '),
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* 10. textContentSet — Text components with native variants use textContent or text.
|
|
437
|
+
*
|
|
438
|
+
* Text components with variant h1-h5, body, caption should have textContent or text set.
|
|
439
|
+
*/
|
|
440
|
+
function checkTextContentSet(components) {
|
|
441
|
+
const nativeVariants = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'body', 'caption']);
|
|
442
|
+
const issues = [];
|
|
443
|
+
|
|
444
|
+
for (const comp of components) {
|
|
445
|
+
if (comp.component !== 'Text') continue;
|
|
446
|
+
if (!comp.variant || !nativeVariants.has(comp.variant)) continue;
|
|
447
|
+
|
|
448
|
+
const hasContent = comp.textContent != null || comp.text != null;
|
|
449
|
+
if (!hasContent) {
|
|
450
|
+
issues.push(`Text "${comp.id}" (variant: ${comp.variant}) has no textContent or text`);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const textCount = components.filter(c => c.component === 'Text').length;
|
|
455
|
+
const passed = issues.length === 0;
|
|
456
|
+
return {
|
|
457
|
+
name: 'textContentSet',
|
|
458
|
+
passed,
|
|
459
|
+
score: textCount === 0 ? 1 : (passed ? 1 : Math.max(0, 1 - issues.length / Math.max(1, textCount))),
|
|
460
|
+
detail: passed ? (textCount === 0 ? 'No text components to validate' : 'All text components have content') : issues.join('; '),
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* 11. idUniqueness — all component IDs are unique within a surface.
|
|
466
|
+
*/
|
|
467
|
+
function checkIdUniqueness(components) {
|
|
468
|
+
const seen = new Map(); // id → count
|
|
469
|
+
for (const comp of components) {
|
|
470
|
+
if (!comp.id) continue;
|
|
471
|
+
seen.set(comp.id, (seen.get(comp.id) || 0) + 1);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const dupes = [];
|
|
475
|
+
for (const [id, count] of seen) {
|
|
476
|
+
if (count > 1) dupes.push(`"${id}" appears ${count} times`);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const passed = dupes.length === 0;
|
|
480
|
+
return {
|
|
481
|
+
name: 'idUniqueness',
|
|
482
|
+
passed,
|
|
483
|
+
score: passed ? 1 : Math.max(0, 1 - dupes.length / Math.max(1, seen.size)),
|
|
484
|
+
detail: passed ? 'All IDs are unique' : `Duplicate IDs: ${dupes.join(', ')}`,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* 12. interactiveHasLabel — interactive components must have a text, label, or textContent property.
|
|
490
|
+
*/
|
|
491
|
+
function checkInteractiveHasLabel(components) {
|
|
492
|
+
const interactiveTypes = new Set(['Button', 'TextField', 'Select', 'Toggle', 'Check', 'Slider', 'Search']);
|
|
493
|
+
const unlabeled = [];
|
|
494
|
+
|
|
495
|
+
for (const comp of components) {
|
|
496
|
+
if (!interactiveTypes.has(comp.component)) continue;
|
|
497
|
+
const hasLabel = comp.text != null || comp.label != null || comp.textContent != null;
|
|
498
|
+
if (!hasLabel) {
|
|
499
|
+
unlabeled.push(`"${comp.id}"`);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const interactiveCount = components.filter(c => interactiveTypes.has(c.component)).length;
|
|
504
|
+
const passed = unlabeled.length === 0;
|
|
505
|
+
return {
|
|
506
|
+
name: 'interactiveHasLabel',
|
|
507
|
+
passed,
|
|
508
|
+
score: interactiveCount === 0 ? 1 : (passed ? 1 : Math.max(0, 1 - unlabeled.length / Math.max(1, interactiveCount))),
|
|
509
|
+
detail: passed
|
|
510
|
+
? (interactiveCount === 0 ? 'No interactive components to validate' : 'All interactive components have labels')
|
|
511
|
+
: `Missing labels: ${unlabeled.join(', ')}`,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* 13. imagesHaveAlt — Image components should have an alt property.
|
|
517
|
+
*/
|
|
518
|
+
function checkImagesHaveAlt(components) {
|
|
519
|
+
const missing = [];
|
|
520
|
+
|
|
521
|
+
for (const comp of components) {
|
|
522
|
+
if (comp.component !== 'Image') continue;
|
|
523
|
+
if (comp.alt == null) {
|
|
524
|
+
missing.push(`"${comp.id}"`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const imageCount = components.filter(c => c.component === 'Image').length;
|
|
529
|
+
const passed = missing.length === 0;
|
|
530
|
+
return {
|
|
531
|
+
name: 'imagesHaveAlt',
|
|
532
|
+
passed,
|
|
533
|
+
score: imageCount === 0 ? 1 : (passed ? 1 : Math.max(0, 1 - missing.length / Math.max(1, imageCount))),
|
|
534
|
+
detail: passed
|
|
535
|
+
? (imageCount === 0 ? 'No image components to validate' : 'All images have alt text')
|
|
536
|
+
: `Missing alt: ${missing.join(', ')}`,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* 14. headingHierarchy — Text components with variant h1-h5 should not skip levels.
|
|
542
|
+
*
|
|
543
|
+
* Warns but doesn't fail hard — score degrades per skip.
|
|
544
|
+
*/
|
|
545
|
+
function checkHeadingHierarchy(components) {
|
|
546
|
+
const headingLevels = [];
|
|
547
|
+
|
|
548
|
+
for (const comp of components) {
|
|
549
|
+
if (comp.component !== 'Text') continue;
|
|
550
|
+
const match = /^h([1-5])$/.exec(comp.variant);
|
|
551
|
+
if (match) headingLevels.push(Number(match[1]));
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (headingLevels.length === 0) {
|
|
555
|
+
return { name: 'headingHierarchy', passed: true, score: 1, detail: 'No headings to validate' };
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const sorted = [...new Set(headingLevels)].sort((a, b) => a - b);
|
|
559
|
+
const skips = [];
|
|
560
|
+
|
|
561
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
562
|
+
if (sorted[i] - sorted[i - 1] > 1) {
|
|
563
|
+
skips.push(`h${sorted[i - 1]} → h${sorted[i]}`);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const passed = skips.length === 0;
|
|
568
|
+
return {
|
|
569
|
+
name: 'headingHierarchy',
|
|
570
|
+
passed,
|
|
571
|
+
score: passed ? 1 : Math.max(0.5, 1 - skips.length * 0.25),
|
|
572
|
+
detail: passed ? 'Heading hierarchy is sequential' : `Skipped heading levels: ${skips.join(', ')}`,
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* 15. tabStructure — Tabs children must only be Tab components.
|
|
578
|
+
*
|
|
579
|
+
* Tabs is a button strip. Content panels are siblings, not children of Tab.
|
|
580
|
+
* WRONG: Tabs > Tab > Card (content inside tab buttons)
|
|
581
|
+
* RIGHT: Column > [Tabs, Card, Card] (tabs and panels as siblings)
|
|
582
|
+
*/
|
|
583
|
+
function checkTabStructure(components) {
|
|
584
|
+
const byId = new Map(components.map(c => [c.id, c]));
|
|
585
|
+
const issues = [];
|
|
586
|
+
|
|
587
|
+
for (const comp of components) {
|
|
588
|
+
if (comp.component !== 'Tabs') continue;
|
|
589
|
+
if (!Array.isArray(comp.children)) continue;
|
|
590
|
+
|
|
591
|
+
for (const childId of comp.children) {
|
|
592
|
+
const child = byId.get(childId);
|
|
593
|
+
if (!child) continue;
|
|
594
|
+
if (child.component !== 'Tab') {
|
|
595
|
+
issues.push(`Tabs "${comp.id}" has child "${childId}" of type "${child.component}" (only Tab allowed)`);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Also check Tab children — they should have no children or only text
|
|
601
|
+
for (const comp of components) {
|
|
602
|
+
if (comp.component !== 'Tab') continue;
|
|
603
|
+
if (Array.isArray(comp.children) && comp.children.length > 0) {
|
|
604
|
+
const childTypes = comp.children
|
|
605
|
+
.map(id => byId.get(id)?.component)
|
|
606
|
+
.filter(t => t && t !== 'Text');
|
|
607
|
+
if (childTypes.length > 0) {
|
|
608
|
+
issues.push(`Tab "${comp.id}" contains ${childTypes.join(', ')} — Tab is a button, not a container`);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const tabCount = components.filter(c => c.component === 'Tabs').length;
|
|
614
|
+
const passed = issues.length === 0;
|
|
615
|
+
return {
|
|
616
|
+
name: 'tabStructure',
|
|
617
|
+
passed,
|
|
618
|
+
score: tabCount === 0 ? 1 : (passed ? 1 : 0),
|
|
619
|
+
detail: passed
|
|
620
|
+
? (tabCount === 0 ? 'No tabs to validate' : 'Tab structure is correct')
|
|
621
|
+
: issues.join('; '),
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* 16. gridVsColumn — Column with 3+ Card children should be Grid.
|
|
627
|
+
*
|
|
628
|
+
* Catches the common LLM mistake of stacking repeating items (task cards,
|
|
629
|
+
* stat tiles, image cards) in a Column instead of a multi-column Grid.
|
|
630
|
+
* This is a layout quality check, not a hard error — some legitimate UIs
|
|
631
|
+
* have many cards in a single column (e.g. feed, timeline).
|
|
632
|
+
*/
|
|
633
|
+
function checkGridVsColumn(components) {
|
|
634
|
+
const byId = new Map(components.map(c => [c.id, c]));
|
|
635
|
+
const issues = [];
|
|
636
|
+
|
|
637
|
+
for (const comp of components) {
|
|
638
|
+
if (comp.component !== 'Column') continue;
|
|
639
|
+
if (!Array.isArray(comp.children) || comp.children.length < 3) continue;
|
|
640
|
+
|
|
641
|
+
// Count Card children
|
|
642
|
+
const cardChildren = comp.children.filter(childId => {
|
|
643
|
+
const child = byId.get(childId);
|
|
644
|
+
return child && child.component === 'Card';
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
if (cardChildren.length >= 3) {
|
|
648
|
+
issues.push(`Column "${comp.id}" has ${cardChildren.length} Card children — consider using Grid with columns="2"|"3" for multi-column layout`);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const passed = issues.length === 0;
|
|
653
|
+
return {
|
|
654
|
+
name: 'gridVsColumn',
|
|
655
|
+
passed,
|
|
656
|
+
score: passed ? 1 : 0.5,
|
|
657
|
+
detail: passed ? 'Layout containers appropriate' : issues.join('; '),
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* 17. landmarkStructure — at least one Header, Section, or Footer component should exist.
|
|
663
|
+
*/
|
|
664
|
+
function checkLandmarkStructure(components) {
|
|
665
|
+
const landmarkTypes = new Set(['Header', 'Section', 'Footer']);
|
|
666
|
+
const hasLandmark = components.some(c => landmarkTypes.has(c.component));
|
|
667
|
+
|
|
668
|
+
return {
|
|
669
|
+
name: 'landmarkStructure',
|
|
670
|
+
passed: hasLandmark,
|
|
671
|
+
score: hasLandmark ? 1 : 0,
|
|
672
|
+
detail: hasLandmark ? 'Landmark structure present' : 'No Header, Section, or Footer components found',
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* 17. intentAlignment — semantic alignment between intent and output.
|
|
678
|
+
*
|
|
679
|
+
* Three sub-checks weighted together:
|
|
680
|
+
* A. Keyword matching (40%) — intent keywords map to expected component types
|
|
681
|
+
* B. Pattern mismatch (35%) — dominant output types should be relevant to the intent
|
|
682
|
+
* C. Complexity match (25%) — compound intents need more than a handful of components
|
|
683
|
+
*
|
|
684
|
+
* Returns score 1 (no intent provided or all matched), degrades per miss.
|
|
685
|
+
*/
|
|
686
|
+
function checkIntentAlignment(components, intent) {
|
|
687
|
+
if (!intent || typeof intent !== 'string') {
|
|
688
|
+
return { name: 'intentAlignment', passed: true, score: 1, detail: 'No intent provided' };
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Map intent keywords → expected component types
|
|
692
|
+
const KEYWORD_TO_COMPONENT = {
|
|
693
|
+
'table': ['Table'],
|
|
694
|
+
'chart': ['Chart'],
|
|
695
|
+
'graph': ['Chart'],
|
|
696
|
+
'form': ['Input', 'Button'],
|
|
697
|
+
'input': ['Input', 'TextArea'],
|
|
698
|
+
'button': ['Button'],
|
|
699
|
+
'avatar': ['Avatar'],
|
|
700
|
+
'badge': ['Badge'],
|
|
701
|
+
'progress': ['Progress'],
|
|
702
|
+
'slider': ['Slider'],
|
|
703
|
+
'toggle': ['Switch', 'Toggle'],
|
|
704
|
+
'switch': ['Switch'],
|
|
705
|
+
'checkbox': ['CheckBox'],
|
|
706
|
+
'radio': ['Radio'],
|
|
707
|
+
'select': ['Select'],
|
|
708
|
+
'upload': ['Upload'],
|
|
709
|
+
'tabs': ['Tabs', 'Tab'],
|
|
710
|
+
'tab': ['Tabs', 'Tab'],
|
|
711
|
+
'accordion': ['Accordion', 'AccordionItem'],
|
|
712
|
+
'modal': ['Modal'],
|
|
713
|
+
'dialog': ['Modal'],
|
|
714
|
+
'drawer': ['Drawer'],
|
|
715
|
+
'toast': ['Toast'],
|
|
716
|
+
'alert': ['Alert'],
|
|
717
|
+
'tooltip': ['Tooltip'],
|
|
718
|
+
'popover': ['Popover'],
|
|
719
|
+
'breadcrumb': ['Breadcrumb'],
|
|
720
|
+
'pagination': ['Pagination'],
|
|
721
|
+
'timeline': ['Timeline', 'TimelineItem'],
|
|
722
|
+
'carousel': ['Swiper'],
|
|
723
|
+
'swiper': ['Swiper'],
|
|
724
|
+
'calendar': ['CalendarPicker'],
|
|
725
|
+
'color picker': ['ColorPicker'],
|
|
726
|
+
'otp': ['OtpInput'],
|
|
727
|
+
'code': ['Code'],
|
|
728
|
+
'image': ['Image'],
|
|
729
|
+
'icon': ['Icon'],
|
|
730
|
+
'divider': ['Divider'],
|
|
731
|
+
'skeleton': ['Skeleton'],
|
|
732
|
+
'embed': ['Embed'],
|
|
733
|
+
'command': ['Command'],
|
|
734
|
+
'stat': ['Stat'],
|
|
735
|
+
'tag': ['Tag'],
|
|
736
|
+
'menu': ['Menu'],
|
|
737
|
+
'toolbar': ['Toolbar'],
|
|
738
|
+
// Composite patterns — intent describes a UI pattern, not a single component
|
|
739
|
+
'inbox': ['Column', 'Row', 'Avatar', 'Text', 'CheckBox'],
|
|
740
|
+
'email inbox': ['Column', 'Row', 'Avatar', 'Text'],
|
|
741
|
+
'notification': ['Column', 'Row', 'Text', 'Badge'],
|
|
742
|
+
'shopping': ['Column', 'Row', 'Text', 'Button'],
|
|
743
|
+
'cart': ['Column', 'Row', 'Text', 'Button'],
|
|
744
|
+
'kanban': ['Grid', 'Column', 'Card'],
|
|
745
|
+
'dashboard': ['Grid', 'Card'],
|
|
746
|
+
'settings': ['Card', 'Column', 'Row'],
|
|
747
|
+
'profile': ['Card', 'Avatar', 'Text'],
|
|
748
|
+
'login': ['Card', 'Input', 'Button'],
|
|
749
|
+
'signup': ['Card', 'Input', 'Button', 'CheckBox'],
|
|
750
|
+
};
|
|
751
|
+
|
|
752
|
+
// ── Reverse mapping: component type → which intent keywords expect it ──
|
|
753
|
+
const COMPONENT_TO_KEYWORDS = {};
|
|
754
|
+
for (const [keyword, types] of Object.entries(KEYWORD_TO_COMPONENT)) {
|
|
755
|
+
for (const t of types) {
|
|
756
|
+
if (!COMPONENT_TO_KEYWORDS[t]) COMPONENT_TO_KEYWORDS[t] = [];
|
|
757
|
+
COMPONENT_TO_KEYWORDS[t].push(keyword);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// ── Semantic domain mapping: component types → semantic categories ──
|
|
762
|
+
const SEMANTIC_DOMAIN = {
|
|
763
|
+
'Toast': 'notification',
|
|
764
|
+
'Alert': 'notification',
|
|
765
|
+
'Badge': 'notification',
|
|
766
|
+
'Table': 'data-display',
|
|
767
|
+
'Chart': 'data-display',
|
|
768
|
+
'Stat': 'data-display',
|
|
769
|
+
'Form': 'data-entry',
|
|
770
|
+
'Input': 'data-entry',
|
|
771
|
+
'TextField': 'data-entry',
|
|
772
|
+
'TextArea': 'data-entry',
|
|
773
|
+
'Select': 'data-entry',
|
|
774
|
+
'ChoicePicker': 'data-entry',
|
|
775
|
+
'CheckBox': 'data-entry',
|
|
776
|
+
'Radio': 'data-entry',
|
|
777
|
+
'Toggle': 'data-entry',
|
|
778
|
+
'Switch': 'data-entry',
|
|
779
|
+
'Slider': 'data-entry',
|
|
780
|
+
'Upload': 'data-entry',
|
|
781
|
+
'Modal': 'overlay',
|
|
782
|
+
'Dialog': 'overlay',
|
|
783
|
+
'Drawer': 'overlay',
|
|
784
|
+
'Popover': 'overlay',
|
|
785
|
+
'Tooltip': 'overlay',
|
|
786
|
+
'Tabs': 'navigation',
|
|
787
|
+
'Tab': 'navigation',
|
|
788
|
+
'Breadcrumb': 'navigation',
|
|
789
|
+
'Pagination': 'navigation',
|
|
790
|
+
'Menu': 'navigation',
|
|
791
|
+
'Sidebar': 'navigation',
|
|
792
|
+
'Nav': 'navigation',
|
|
793
|
+
'Avatar': 'identity',
|
|
794
|
+
'Image': 'media',
|
|
795
|
+
'Embed': 'media',
|
|
796
|
+
'Card': 'layout',
|
|
797
|
+
'Column': 'layout',
|
|
798
|
+
'Row': 'layout',
|
|
799
|
+
'Grid': 'layout',
|
|
800
|
+
'Text': 'layout',
|
|
801
|
+
'Button': 'action',
|
|
802
|
+
'Icon': 'decoration',
|
|
803
|
+
'Divider': 'decoration',
|
|
804
|
+
};
|
|
805
|
+
|
|
806
|
+
// Intent → expected semantic domains
|
|
807
|
+
const INTENT_DOMAINS = {
|
|
808
|
+
'inbox': ['identity', 'data-entry', 'navigation'],
|
|
809
|
+
'email': ['identity', 'data-entry', 'navigation'],
|
|
810
|
+
'dashboard': ['data-display', 'navigation'],
|
|
811
|
+
'admin': ['data-display', 'navigation', 'data-entry'],
|
|
812
|
+
'analytics': ['data-display'],
|
|
813
|
+
'menu': ['navigation', 'action'],
|
|
814
|
+
'restaurant': ['layout', 'action', 'media'],
|
|
815
|
+
'form': ['data-entry', 'action'],
|
|
816
|
+
'login': ['data-entry', 'action'],
|
|
817
|
+
'signup': ['data-entry', 'action'],
|
|
818
|
+
'settings': ['data-entry', 'action'],
|
|
819
|
+
'profile': ['identity', 'media'],
|
|
820
|
+
'chat': ['data-entry', 'identity'],
|
|
821
|
+
'shopping': ['media', 'action'],
|
|
822
|
+
'cart': ['data-display', 'action'],
|
|
823
|
+
'kanban': ['layout'],
|
|
824
|
+
'calendar': ['data-display'],
|
|
825
|
+
};
|
|
826
|
+
|
|
827
|
+
// ── Domains that should NOT dominate for certain intents ──
|
|
828
|
+
const ANTI_DOMAINS = {
|
|
829
|
+
'inbox': ['notification', 'overlay'],
|
|
830
|
+
'email': ['notification', 'overlay'],
|
|
831
|
+
'dashboard': ['notification', 'overlay'],
|
|
832
|
+
'restaurant': ['notification', 'overlay'],
|
|
833
|
+
'menu': ['notification', 'overlay'],
|
|
834
|
+
'form': ['notification'],
|
|
835
|
+
'profile': ['notification'],
|
|
836
|
+
'shopping': ['notification'],
|
|
837
|
+
'calendar': ['notification'],
|
|
838
|
+
'admin': ['notification', 'overlay'],
|
|
839
|
+
'analytics': ['notification'],
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
const lower = intent.toLowerCase();
|
|
843
|
+
const outputTypes = new Set(components.map(c => c.component));
|
|
844
|
+
|
|
845
|
+
// Count component types (excluding layout containers for dominance check)
|
|
846
|
+
const typeCounts = {};
|
|
847
|
+
for (const c of components) {
|
|
848
|
+
typeCounts[c.component] = (typeCounts[c.component] || 0) + 1;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
const details = [];
|
|
852
|
+
let keywordScore = 1;
|
|
853
|
+
let patternScore = 1;
|
|
854
|
+
let complexityScore = 1;
|
|
855
|
+
|
|
856
|
+
// ─── Sub-check A: Keyword matching (existing logic, enhanced) ───
|
|
857
|
+
|
|
858
|
+
const matched = [];
|
|
859
|
+
const missed = [];
|
|
860
|
+
|
|
861
|
+
for (const [keyword, expectedTypes] of Object.entries(KEYWORD_TO_COMPONENT)) {
|
|
862
|
+
const pattern = new RegExp(`\\b${keyword.replace(/\s+/g, '\\s+')}\\b`);
|
|
863
|
+
if (!pattern.test(lower)) continue;
|
|
864
|
+
|
|
865
|
+
const foundTypes = expectedTypes.filter(t => outputTypes.has(t));
|
|
866
|
+
const minRequired = expectedTypes.length >= 3 ? Math.ceil(expectedTypes.length / 2) : 1;
|
|
867
|
+
|
|
868
|
+
if (foundTypes.length >= minRequired) {
|
|
869
|
+
matched.push(keyword);
|
|
870
|
+
} else {
|
|
871
|
+
const missingTypes = expectedTypes.filter(t => !outputTypes.has(t));
|
|
872
|
+
missed.push(`"${keyword}" → missing ${missingTypes.join(', ')}`);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
const keywordTotal = matched.length + missed.length;
|
|
877
|
+
if (keywordTotal > 0) {
|
|
878
|
+
keywordScore = matched.length / keywordTotal;
|
|
879
|
+
if (missed.length > 0) {
|
|
880
|
+
details.push(`Missing components for: ${missed.join('; ')}`);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// ─── Sub-check B: Pattern mismatch — dominant irrelevant types ───
|
|
885
|
+
|
|
886
|
+
// Find the semantic domains present in the output (weighted by count)
|
|
887
|
+
const domainCounts = {};
|
|
888
|
+
const nonLayoutTypes = components.filter(c =>
|
|
889
|
+
!['Column', 'Row', 'Grid', 'Card', 'Text', 'Header', 'Section', 'Footer', 'Button', 'Icon', 'Divider'].includes(c.component)
|
|
890
|
+
);
|
|
891
|
+
|
|
892
|
+
for (const c of nonLayoutTypes) {
|
|
893
|
+
const domain = SEMANTIC_DOMAIN[c.component];
|
|
894
|
+
if (domain) {
|
|
895
|
+
domainCounts[domain] = (domainCounts[domain] || 0) + 1;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// Check if any anti-domain dominates (>50% of non-layout components)
|
|
900
|
+
const nonLayoutCount = nonLayoutTypes.length;
|
|
901
|
+
if (nonLayoutCount > 0) {
|
|
902
|
+
for (const [intentKey, antiDomains] of Object.entries(ANTI_DOMAINS)) {
|
|
903
|
+
const keyPattern = new RegExp(`\\b${intentKey}\\b`);
|
|
904
|
+
if (!keyPattern.test(lower)) continue;
|
|
905
|
+
|
|
906
|
+
for (const antiDomain of antiDomains) {
|
|
907
|
+
const antiCount = domainCounts[antiDomain] || 0;
|
|
908
|
+
const ratio = antiCount / nonLayoutCount;
|
|
909
|
+
if (ratio > 0.4 && antiCount >= 2) {
|
|
910
|
+
// Heavy penalty — wrong pattern entirely
|
|
911
|
+
const penalty = Math.min(0.8, ratio);
|
|
912
|
+
patternScore = Math.min(patternScore, 1 - penalty);
|
|
913
|
+
details.push(`Semantic mismatch: "${antiDomain}" components dominate (${antiCount}/${nonLayoutCount}) but intent is "${intentKey}"`);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// Also check: if output is dominated by a single specific component type
|
|
919
|
+
// (e.g., 4 Toast components) and that type isn't mentioned in the intent
|
|
920
|
+
for (const [type, count] of Object.entries(typeCounts)) {
|
|
921
|
+
if (['Column', 'Row', 'Grid', 'Card', 'Text', 'Header', 'Section', 'Footer', 'Button', 'Icon', 'Divider'].includes(type)) continue;
|
|
922
|
+
const ratio = count / components.length;
|
|
923
|
+
if (ratio > 0.25 && count >= 3) {
|
|
924
|
+
// This type dominates — is it mentioned in the intent?
|
|
925
|
+
const keywords = COMPONENT_TO_KEYWORDS[type] || [];
|
|
926
|
+
const mentionedInIntent = keywords.some(kw => {
|
|
927
|
+
const kwPattern = new RegExp(`\\b${kw.replace(/\s+/g, '\\s+')}\\b`);
|
|
928
|
+
return kwPattern.test(lower);
|
|
929
|
+
});
|
|
930
|
+
if (!mentionedInIntent) {
|
|
931
|
+
const penalty = Math.min(0.7, ratio * 1.5);
|
|
932
|
+
patternScore = Math.min(patternScore, 1 - penalty);
|
|
933
|
+
details.push(`Dominant irrelevant type: ${count}× ${type} (${Math.round(ratio * 100)}% of output) not mentioned in intent`);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// ─── Sub-check C: Complexity adequacy ───
|
|
940
|
+
|
|
941
|
+
// Compound intents should produce enough components
|
|
942
|
+
const COMPOUND_PATTERNS = [
|
|
943
|
+
{ pattern: /\b(?:inbox|email\s+inbox|email\s+client)\b/, minComponents: 12 },
|
|
944
|
+
{ pattern: /\b(?:dashboard|admin\s+dashboard|analytics)\b/, minComponents: 10 },
|
|
945
|
+
{ pattern: /\b(?:restaurant\s+menu|food\s+menu)\b/, minComponents: 10 },
|
|
946
|
+
{ pattern: /\b(?:kanban|project\s+board)\b/, minComponents: 10 },
|
|
947
|
+
{ pattern: /\b(?:shopping|e-?commerce|product\s+list)\b/, minComponents: 10 },
|
|
948
|
+
{ pattern: /\b(?:settings|preferences)\b/, minComponents: 8 },
|
|
949
|
+
{ pattern: /\b(?:chat|messaging)\b/, minComponents: 8 },
|
|
950
|
+
{ pattern: /\b(?:profile|user\s+profile)\b/, minComponents: 8 },
|
|
951
|
+
{ pattern: /\b(?:login|sign\s*in)\b/, minComponents: 6 },
|
|
952
|
+
{ pattern: /\b(?:signup|sign\s*up|register)\b/, minComponents: 7 },
|
|
953
|
+
];
|
|
954
|
+
|
|
955
|
+
for (const { pattern, minComponents } of COMPOUND_PATTERNS) {
|
|
956
|
+
if (!pattern.test(lower)) continue;
|
|
957
|
+
if (components.length < minComponents) {
|
|
958
|
+
const ratio = components.length / minComponents;
|
|
959
|
+
complexityScore = Math.min(complexityScore, ratio);
|
|
960
|
+
details.push(`Too few components: ${components.length} for "${pattern.source}" (expected ≥${minComponents})`);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// Also: count distinct words in intent as a rough complexity measure
|
|
965
|
+
const intentWords = lower.split(/\s+/).filter(w => w.length > 3).length;
|
|
966
|
+
if (intentWords >= 4 && components.length < 8) {
|
|
967
|
+
const penalty = Math.max(0, (8 - components.length) / 8) * 0.5;
|
|
968
|
+
complexityScore = Math.min(complexityScore, 1 - penalty);
|
|
969
|
+
details.push(`Sparse output: ${components.length} components for multi-word intent (${intentWords} significant words)`);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// ─── Composite score ───
|
|
973
|
+
// A: keyword match (40%), B: pattern mismatch (35%), C: complexity (25%)
|
|
974
|
+
const score = Math.max(0, Math.min(1,
|
|
975
|
+
keywordScore * 0.40 +
|
|
976
|
+
patternScore * 0.35 +
|
|
977
|
+
complexityScore * 0.25
|
|
978
|
+
));
|
|
979
|
+
|
|
980
|
+
const passed = score >= 0.8;
|
|
981
|
+
|
|
982
|
+
return {
|
|
983
|
+
name: 'intentAlignment',
|
|
984
|
+
passed,
|
|
985
|
+
score,
|
|
986
|
+
detail: details.length > 0
|
|
987
|
+
? details.join('; ')
|
|
988
|
+
: (keywordTotal > 0
|
|
989
|
+
? `All ${matched.length} intent keywords have matching components`
|
|
990
|
+
: 'No keyword-to-component mappings matched intent'),
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// ═══════════════════════════════════════════════════════════════
|
|
995
|
+
// WIRING CHECKS (A007 §9.4)
|
|
996
|
+
// Only scored when wireComponents messages are present.
|
|
997
|
+
// ═══════════════════════════════════════════════════════════════
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* Validate a wireComponents message against the wiring registry and component tree.
|
|
1001
|
+
* @param {object} wire — wireComponents message
|
|
1002
|
+
* @param {Set<string>} componentIds — IDs from updateComponents
|
|
1003
|
+
* @returns {object[]} — Array of check results
|
|
1004
|
+
*/
|
|
1005
|
+
function checkWiring(wire, componentIds) {
|
|
1006
|
+
const checks = [];
|
|
1007
|
+
|
|
1008
|
+
// ── Controllers exist in registry ──
|
|
1009
|
+
if (wire.state?.controllers?.length) {
|
|
1010
|
+
const unknown = wire.state.controllers.filter(c => !wiringRegistry.controllers.has(c.type));
|
|
1011
|
+
checks.push({
|
|
1012
|
+
name: 'wiringControllersExist',
|
|
1013
|
+
passed: unknown.length === 0,
|
|
1014
|
+
score: unknown.length === 0 ? 1 : Math.max(0, 1 - unknown.length * 0.5),
|
|
1015
|
+
detail: unknown.length === 0
|
|
1016
|
+
? `All ${wire.state.controllers.length} controller types registered`
|
|
1017
|
+
: `Unknown controllers: ${unknown.map(c => c.type).join(', ')}`,
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// ── Controller hosts reference existing component IDs ──
|
|
1022
|
+
if (wire.state?.controllers?.length) {
|
|
1023
|
+
const missing = wire.state.controllers.filter(c => !componentIds.has(c.host));
|
|
1024
|
+
checks.push({
|
|
1025
|
+
name: 'wiringHostsExist',
|
|
1026
|
+
passed: missing.length === 0,
|
|
1027
|
+
score: missing.length === 0 ? 1 : 0,
|
|
1028
|
+
detail: missing.length === 0
|
|
1029
|
+
? 'All controller hosts reference valid component IDs'
|
|
1030
|
+
: `Missing hosts: ${missing.map(c => `${c.id}→${c.host}`).join(', ')}`,
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// ── Action handlers exist in registry ──
|
|
1035
|
+
if (wire.actions?.length) {
|
|
1036
|
+
const unknown = wire.actions.filter(a => !wiringRegistry.handlers.has(a.handler));
|
|
1037
|
+
checks.push({
|
|
1038
|
+
name: 'wiringHandlersExist',
|
|
1039
|
+
passed: unknown.length === 0,
|
|
1040
|
+
score: unknown.length === 0 ? 1 : Math.max(0, 1 - unknown.length * 0.5),
|
|
1041
|
+
detail: unknown.length === 0
|
|
1042
|
+
? `All ${wire.actions.length} action handlers registered`
|
|
1043
|
+
: `Unknown handlers: ${unknown.map(a => a.handler).join(', ')}`,
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// ── Action sources reference existing component IDs ──
|
|
1048
|
+
if (wire.actions?.length) {
|
|
1049
|
+
const missing = wire.actions.filter(a => !componentIds.has(a.source));
|
|
1050
|
+
checks.push({
|
|
1051
|
+
name: 'wiringSourcesExist',
|
|
1052
|
+
passed: missing.length === 0,
|
|
1053
|
+
score: missing.length === 0 ? 1 : 0,
|
|
1054
|
+
detail: missing.length === 0
|
|
1055
|
+
? 'All action sources reference valid component IDs'
|
|
1056
|
+
: `Missing sources: ${missing.map(a => `${a.event}→${a.source}`).join(', ')}`,
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// ── Data source paths are valid JSON Pointers ──
|
|
1061
|
+
if (wire.data?.sources?.length) {
|
|
1062
|
+
const invalid = wire.data.sources.filter(s => !s.path || !s.path.startsWith('/'));
|
|
1063
|
+
checks.push({
|
|
1064
|
+
name: 'wiringDataPathsValid',
|
|
1065
|
+
passed: invalid.length === 0,
|
|
1066
|
+
score: invalid.length === 0 ? 1 : 0,
|
|
1067
|
+
detail: invalid.length === 0
|
|
1068
|
+
? 'All data source paths are valid JSON Pointers'
|
|
1069
|
+
: `Invalid paths: ${invalid.map(s => `${s.id}:"${s.path}"`).join(', ')}`,
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
return checks;
|
|
1074
|
+
}
|