@urbicon-ui/blocks 6.38.0 → 6.40.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/dist/components/Calendar/calendar.variants.d.ts +63 -63
- package/dist/components/Chat/A2UIView/A2UINode.svelte +703 -0
- package/dist/components/Chat/A2UIView/A2UINode.svelte.d.ts +10 -0
- package/dist/components/Chat/A2UIView/A2UIView.svelte +293 -0
- package/dist/components/Chat/A2UIView/A2UIView.svelte.d.ts +4 -0
- package/dist/components/Chat/A2UIView/a2ui-data.d.ts +65 -0
- package/dist/components/Chat/A2UIView/a2ui-data.js +283 -0
- package/dist/components/Chat/A2UIView/a2ui-prompt.d.ts +18 -0
- package/dist/components/Chat/A2UIView/a2ui-prompt.js +147 -0
- package/dist/components/Chat/A2UIView/a2ui-registry.d.ts +64 -0
- package/dist/components/Chat/A2UIView/a2ui-registry.js +390 -0
- package/dist/components/Chat/A2UIView/a2ui-render.d.ts +96 -0
- package/dist/components/Chat/A2UIView/a2ui-render.js +140 -0
- package/dist/components/Chat/A2UIView/a2ui-validate.d.ts +75 -0
- package/dist/components/Chat/A2UIView/a2ui-validate.js +773 -0
- package/dist/components/Chat/A2UIView/a2ui-view.variants.d.ts +80 -0
- package/dist/components/Chat/A2UIView/a2ui-view.variants.js +62 -0
- package/dist/components/Chat/A2UIView/a2ui.types.d.ts +119 -0
- package/dist/components/Chat/A2UIView/a2ui.types.js +85 -0
- package/dist/components/Chat/A2UIView/index.d.ts +102 -0
- package/dist/components/Chat/A2UIView/index.js +7 -0
- package/dist/components/Chat/ChatMessage/ChatMessage.svelte +4 -1
- package/dist/components/Chat/index.d.ts +2 -0
- package/dist/components/Chat/index.js +1 -0
- package/dist/components/CopyButton/copy-button.variants.d.ts +4 -4
- package/dist/i18n/index.d.ts +398 -2
- package/dist/primitives/Alert/alert.variants.d.ts +8 -8
- package/dist/primitives/JourneyTimeline/journey-timeline.variants.d.ts +22 -22
- package/dist/primitives/JourneyTimeline/journey-timeline.variants.js +3 -1
- package/dist/primitives/Spinner/spinner.variants.d.ts +16 -16
- package/dist/primitives/Stepper/stepper.variants.d.ts +11 -11
- package/dist/primitives/Toast/toast.variants.d.ts +12 -12
- package/dist/primitives/Tooltip/tooltip.variants.d.ts +3 -3
- package/dist/style/semantic.css +11 -3
- package/package.json +3 -3
|
@@ -0,0 +1,773 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The A2UI processor: incremental, whitelist-only, fail-loud validation that
|
|
3
|
+
* turns a stream of Server→Client envelopes into per-surface component maps and
|
|
4
|
+
* data models. Pure TS, no Svelte — deliberately NOT reactive (the house
|
|
5
|
+
* pattern of the streaming-markdown engine: plain Maps mutated in place; the
|
|
6
|
+
* Svelte layer bumps a version counter to re-derive the render tree).
|
|
7
|
+
*
|
|
8
|
+
* Security posture (untrusted-payload path):
|
|
9
|
+
* - Whitelist-only: only registry-declared props reach the render layer, so
|
|
10
|
+
* handler injection (`onclick`, …) and prop smuggling are structurally
|
|
11
|
+
* impossible — there is no payload spread.
|
|
12
|
+
* - `__proto__`/`constructor`/`prototype` are rejected as component ids, prop
|
|
13
|
+
* keys, pointer segments and action-context keys.
|
|
14
|
+
* - Never merges or spreads payload objects; everything flows through `Map`s.
|
|
15
|
+
* - `collectGraphIssues` bounds traversal (depth 32, nodes 512) to cap DoS.
|
|
16
|
+
*
|
|
17
|
+
* Issue routing: envelope-level faults (bad version, unknown envelope type,
|
|
18
|
+
* op-before-createSurface) land in `globalIssues`; everything scoped to a known
|
|
19
|
+
* surface lands in that surface's `issues`. Graph-level faults (cycle, depth,
|
|
20
|
+
* node count, dangling refs) are computed on demand by `collectGraphIssues`,
|
|
21
|
+
* because "dangling" is a warning mid-stream and an error once settled.
|
|
22
|
+
*/
|
|
23
|
+
import { A2UI_ISSUE_CODES } from './a2ui.types.js';
|
|
24
|
+
import { cloneData, deleteAtPointer, getAtPointer, setAtPointer } from './a2ui-data.js';
|
|
25
|
+
import { A2UI_ICON_NAMES, A2UI_IGNORED_PROPS, A2UI_REGISTRY, A2UI_SUPPORTED_VERSIONS, A2UI_SVG_PATH_RE, UNSUPPORTED_A2UI_COMPONENTS } from './a2ui-registry.js';
|
|
26
|
+
const PROTO_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
27
|
+
// DoS caps. The per-surface render walk is already bounded (depth 32, nodes
|
|
28
|
+
// 512), but that leaves the *number* of surfaces and the per-message component
|
|
29
|
+
// count unbounded — an attacker-streamed flood of tiny createSurface pairs (the
|
|
30
|
+
// render tree re-derives every surface on each version bump) or one giant
|
|
31
|
+
// updateComponents array would otherwise pin the main thread. Capping surface
|
|
32
|
+
// count keeps the whole-processor re-derive O(surfaces·nodes) with a constant,
|
|
33
|
+
// and capping the component list bounds each message's validation work.
|
|
34
|
+
const MAX_SURFACES = 64;
|
|
35
|
+
const MAX_COMPONENTS_PER_UPDATE = 1024;
|
|
36
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
37
|
+
function isPlainObject(value) {
|
|
38
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
39
|
+
}
|
|
40
|
+
function isContainer(value) {
|
|
41
|
+
return value !== null && typeof value === 'object';
|
|
42
|
+
}
|
|
43
|
+
function issue(severity, code, message, extra) {
|
|
44
|
+
return { severity, code, message, ...extra };
|
|
45
|
+
}
|
|
46
|
+
const OP_KEYS = ['createSurface', 'updateComponents', 'updateDataModel', 'deleteSurface'];
|
|
47
|
+
// ── Dynamic-value shape guards ───────────────────────────────────────────────
|
|
48
|
+
function isDataBinding(value) {
|
|
49
|
+
return isPlainObject(value) && typeof value.path === 'string';
|
|
50
|
+
}
|
|
51
|
+
function isFunctionCall(value) {
|
|
52
|
+
return isPlainObject(value) && typeof value.call === 'string';
|
|
53
|
+
}
|
|
54
|
+
function isDynamicRef(value) {
|
|
55
|
+
return isDataBinding(value) || isFunctionCall(value);
|
|
56
|
+
}
|
|
57
|
+
function isDynamicString(value) {
|
|
58
|
+
return typeof value === 'string' || isDynamicRef(value);
|
|
59
|
+
}
|
|
60
|
+
function isDynamicNumber(value) {
|
|
61
|
+
return (typeof value === 'number' && Number.isFinite(value)) || isDynamicRef(value);
|
|
62
|
+
}
|
|
63
|
+
function isDynamicBoolean(value) {
|
|
64
|
+
return typeof value === 'boolean' || isDynamicRef(value);
|
|
65
|
+
}
|
|
66
|
+
function isDynamicStringList(value) {
|
|
67
|
+
return ((Array.isArray(value) && value.every((item) => typeof item === 'string')) || isDynamicRef(value));
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Faults that only a dynamic *reference* value can carry, surfaced at validation
|
|
71
|
+
* time so they reach `onValidationError` (the render layer resolves with only
|
|
72
|
+
* `.value` and drops the issue — this is the closed feedback loop the agent
|
|
73
|
+
* needs). A function-call binding is unsupported (renders to nothing → warning);
|
|
74
|
+
* a `{ path }` binding whose pointer contains a prototype-pollution segment is a
|
|
75
|
+
* hard error (position-independent, so scope need not be resolved here). Plain
|
|
76
|
+
* literals return no issues.
|
|
77
|
+
*/
|
|
78
|
+
function dynamicRefIssues(value, path, surfaceId) {
|
|
79
|
+
if (isFunctionCall(value)) {
|
|
80
|
+
const call = value.call;
|
|
81
|
+
return [
|
|
82
|
+
issue('warning', A2UI_ISSUE_CODES.FUNCTION_CALL_UNSUPPORTED, `Function-call binding "${String(call)}" is not supported; it resolves to nothing`, { surfaceId, path })
|
|
83
|
+
];
|
|
84
|
+
}
|
|
85
|
+
if (isDataBinding(value)) {
|
|
86
|
+
const raw = value.path;
|
|
87
|
+
const tokens = (raw.startsWith('/') ? raw.slice(1) : raw).split('/');
|
|
88
|
+
for (const token of tokens) {
|
|
89
|
+
const segment = token.replaceAll('~1', '/').replaceAll('~0', '~');
|
|
90
|
+
if (PROTO_KEYS.has(segment)) {
|
|
91
|
+
return [
|
|
92
|
+
issue('error', A2UI_ISSUE_CODES.PROTOTYPE_POLLUTION, `Prohibited property name "${segment}" in binding path "${raw}"`, { surfaceId, path })
|
|
93
|
+
];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
function validateProp(componentName, key, spec, value, path, surfaceId) {
|
|
100
|
+
const issues = [];
|
|
101
|
+
const mismatch = (detail) => {
|
|
102
|
+
issues.push(issue('error', A2UI_ISSUE_CODES.TYPE_MISMATCH, detail, { surfaceId, path }));
|
|
103
|
+
return { store: false, issues };
|
|
104
|
+
};
|
|
105
|
+
switch (spec.kind) {
|
|
106
|
+
case 'string':
|
|
107
|
+
if (spec.dynamic ? isDynamicString(value) : typeof value === 'string')
|
|
108
|
+
return { store: true, issues: dynamicRefIssues(value, path, surfaceId) };
|
|
109
|
+
return mismatch(`"${key}" on ${componentName} must be a string${spec.dynamic ? ' or { path } binding' : ''}`);
|
|
110
|
+
case 'number':
|
|
111
|
+
if (spec.dynamic ? isDynamicNumber(value) : typeof value === 'number' && Number.isFinite(value))
|
|
112
|
+
return { store: true, issues: dynamicRefIssues(value, path, surfaceId) };
|
|
113
|
+
return mismatch(`"${key}" on ${componentName} must be a finite number${spec.dynamic ? ' or { path } binding' : ''}`);
|
|
114
|
+
case 'boolean':
|
|
115
|
+
if (spec.dynamic ? isDynamicBoolean(value) : typeof value === 'boolean')
|
|
116
|
+
return { store: true, issues: dynamicRefIssues(value, path, surfaceId) };
|
|
117
|
+
return mismatch(`"${key}" on ${componentName} must be a boolean${spec.dynamic ? ' or { path } binding' : ''}`);
|
|
118
|
+
case 'stringList':
|
|
119
|
+
if (isDynamicStringList(value))
|
|
120
|
+
return { store: true, issues: dynamicRefIssues(value, path, surfaceId) };
|
|
121
|
+
return mismatch(`"${key}" on ${componentName} must be a string array or { path } binding`);
|
|
122
|
+
case 'enum':
|
|
123
|
+
if (typeof value === 'string' && spec.values?.includes(value))
|
|
124
|
+
return { store: true, issues };
|
|
125
|
+
issues.push(issue('error', A2UI_ISSUE_CODES.UNKNOWN_ENUM, `"${key}" on ${componentName} must be one of: ${(spec.values ?? []).join(', ')}`, { surfaceId, path }));
|
|
126
|
+
return { store: false, issues };
|
|
127
|
+
case 'childId':
|
|
128
|
+
if (typeof value === 'string')
|
|
129
|
+
return { store: true, issues };
|
|
130
|
+
return mismatch(`"${key}" on ${componentName} must be a component id (string)`);
|
|
131
|
+
case 'childList':
|
|
132
|
+
return validateChildList(componentName, key, value, path, surfaceId);
|
|
133
|
+
case 'action':
|
|
134
|
+
return validateAction(componentName, key, value, path, surfaceId);
|
|
135
|
+
case 'options':
|
|
136
|
+
return validateOptions(componentName, key, value, path, surfaceId);
|
|
137
|
+
case 'icon':
|
|
138
|
+
return validateIcon(value, path, surfaceId);
|
|
139
|
+
case 'accessibility':
|
|
140
|
+
if (isPlainObject(value))
|
|
141
|
+
return { store: true, issues };
|
|
142
|
+
return mismatch(`"${key}" on ${componentName} must be an object { label?, description? }`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function validateChildList(componentName, key, value, path, surfaceId) {
|
|
146
|
+
if (Array.isArray(value)) {
|
|
147
|
+
if (value.every((item) => typeof item === 'string'))
|
|
148
|
+
return { store: true, issues: [] };
|
|
149
|
+
return {
|
|
150
|
+
store: false,
|
|
151
|
+
issues: [
|
|
152
|
+
issue('error', A2UI_ISSUE_CODES.TYPE_MISMATCH, `"${key}" on ${componentName} must be an array of component ids`, {
|
|
153
|
+
surfaceId,
|
|
154
|
+
path
|
|
155
|
+
})
|
|
156
|
+
]
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
if (isPlainObject(value) &&
|
|
160
|
+
typeof value.componentId === 'string' &&
|
|
161
|
+
typeof value.path === 'string') {
|
|
162
|
+
return { store: true, issues: [] };
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
store: false,
|
|
166
|
+
issues: [
|
|
167
|
+
issue('error', A2UI_ISSUE_CODES.TYPE_MISMATCH, `"${key}" on ${componentName} must be an array of ids or a template { componentId, path }`, { surfaceId, path })
|
|
168
|
+
]
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
function validateAction(componentName, key, value, path, surfaceId) {
|
|
172
|
+
const issues = [];
|
|
173
|
+
if (!isPlainObject(value)) {
|
|
174
|
+
issues.push(issue('error', A2UI_ISSUE_CODES.TYPE_MISMATCH, `"${key}" on ${componentName} must be an action object`, {
|
|
175
|
+
surfaceId,
|
|
176
|
+
path
|
|
177
|
+
}));
|
|
178
|
+
return { store: false, issues };
|
|
179
|
+
}
|
|
180
|
+
if (isPlainObject(value.event)) {
|
|
181
|
+
const event = value.event;
|
|
182
|
+
if (typeof event.name !== 'string') {
|
|
183
|
+
issues.push(issue('error', A2UI_ISSUE_CODES.MISSING_FIELD, `action.event.name is required on ${componentName}`, {
|
|
184
|
+
surfaceId,
|
|
185
|
+
path: `${path}/event/name`
|
|
186
|
+
}));
|
|
187
|
+
return { store: false, issues };
|
|
188
|
+
}
|
|
189
|
+
if ('context' in event) {
|
|
190
|
+
if (!isPlainObject(event.context)) {
|
|
191
|
+
issues.push(issue('error', A2UI_ISSUE_CODES.TYPE_MISMATCH, `action.event.context on ${componentName} must be an object`, {
|
|
192
|
+
surfaceId,
|
|
193
|
+
path: `${path}/event/context`
|
|
194
|
+
}));
|
|
195
|
+
return { store: false, issues };
|
|
196
|
+
}
|
|
197
|
+
for (const contextKey of Object.keys(event.context)) {
|
|
198
|
+
if (PROTO_KEYS.has(contextKey)) {
|
|
199
|
+
issues.push(issue('error', A2UI_ISSUE_CODES.PROTOTYPE_POLLUTION, `Prohibited action context key "${contextKey}" on ${componentName}`, { surfaceId, path: `${path}/event/context/${contextKey}` }));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// Store even with a context-key error: the renderer builds context on a
|
|
204
|
+
// null-prototype object, so the flagged key is inert. The error still relays.
|
|
205
|
+
return { store: issues.every((i) => i.code !== A2UI_ISSUE_CODES.PROTOTYPE_POLLUTION), issues };
|
|
206
|
+
}
|
|
207
|
+
if (isPlainObject(value.functionCall)) {
|
|
208
|
+
if (typeof value.functionCall.call === 'string')
|
|
209
|
+
return { store: true, issues };
|
|
210
|
+
issues.push(issue('error', A2UI_ISSUE_CODES.MISSING_FIELD, `action.functionCall.call is required on ${componentName}`, {
|
|
211
|
+
surfaceId,
|
|
212
|
+
path: `${path}/functionCall/call`
|
|
213
|
+
}));
|
|
214
|
+
return { store: false, issues };
|
|
215
|
+
}
|
|
216
|
+
issues.push(issue('error', A2UI_ISSUE_CODES.TYPE_MISMATCH, `"${key}" on ${componentName} must have an "event" or "functionCall"`, {
|
|
217
|
+
surfaceId,
|
|
218
|
+
path
|
|
219
|
+
}));
|
|
220
|
+
return { store: false, issues };
|
|
221
|
+
}
|
|
222
|
+
function validateOptions(componentName, key, value, path, surfaceId) {
|
|
223
|
+
const bad = (detail) => ({
|
|
224
|
+
store: false,
|
|
225
|
+
issues: [issue('error', A2UI_ISSUE_CODES.TYPE_MISMATCH, detail, { surfaceId, path })]
|
|
226
|
+
});
|
|
227
|
+
if (!Array.isArray(value))
|
|
228
|
+
return bad(`"${key}" on ${componentName} must be an array of { label, value } options`);
|
|
229
|
+
const issues = [];
|
|
230
|
+
const seenValues = new Set();
|
|
231
|
+
for (let i = 0; i < value.length; i++) {
|
|
232
|
+
const option = value[i];
|
|
233
|
+
if (!isPlainObject(option) ||
|
|
234
|
+
typeof option.value !== 'string' ||
|
|
235
|
+
!isDynamicString(option.label)) {
|
|
236
|
+
return bad(`option ${i} on ${componentName} must be { label, value:string }`);
|
|
237
|
+
}
|
|
238
|
+
// Duplicate option values must be flagged AND deduped downstream: a keyed
|
|
239
|
+
// `{#each}` on option.value would otherwise throw `each_key_duplicate` (a
|
|
240
|
+
// hard render crash that breaks the "never throw" contract).
|
|
241
|
+
if (seenValues.has(option.value)) {
|
|
242
|
+
issues.push(issue('warning', A2UI_ISSUE_CODES.DUPLICATE_OPTION, `Duplicate option value "${option.value}" on ${componentName} (the later option is dropped)`, { surfaceId, path: `${path}/${i}/value` }));
|
|
243
|
+
}
|
|
244
|
+
seenValues.add(option.value);
|
|
245
|
+
for (const labelIssue of dynamicRefIssues(option.label, `${path}/${i}/label`, surfaceId)) {
|
|
246
|
+
issues.push(labelIssue);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return { store: true, issues };
|
|
250
|
+
}
|
|
251
|
+
function validateIcon(value, path, surfaceId) {
|
|
252
|
+
if (typeof value === 'string') {
|
|
253
|
+
if (A2UI_ICON_NAMES.includes(value))
|
|
254
|
+
return { store: true, issues: [] };
|
|
255
|
+
return {
|
|
256
|
+
store: true,
|
|
257
|
+
issues: [
|
|
258
|
+
issue('warning', A2UI_ISSUE_CODES.ICON_UNMAPPED, `Icon "${value}" is not mapped; a fallback glyph is drawn`, {
|
|
259
|
+
surfaceId,
|
|
260
|
+
path
|
|
261
|
+
})
|
|
262
|
+
]
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
if (isPlainObject(value)) {
|
|
266
|
+
if (typeof value.svgPath === 'string') {
|
|
267
|
+
if (A2UI_SVG_PATH_RE.test(value.svgPath))
|
|
268
|
+
return { store: true, issues: [] };
|
|
269
|
+
return {
|
|
270
|
+
store: true,
|
|
271
|
+
issues: [
|
|
272
|
+
issue('warning', A2UI_ISSUE_CODES.ICON_INVALID_SVG, 'Icon svgPath failed the path grammar guard; a fallback glyph is drawn', {
|
|
273
|
+
surfaceId,
|
|
274
|
+
path
|
|
275
|
+
})
|
|
276
|
+
]
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
if (typeof value.path === 'string')
|
|
280
|
+
return { store: true, issues: dynamicRefIssues(value, path, surfaceId) }; // dynamic binding
|
|
281
|
+
}
|
|
282
|
+
return {
|
|
283
|
+
store: false,
|
|
284
|
+
issues: [
|
|
285
|
+
issue('error', A2UI_ISSUE_CODES.TYPE_MISMATCH, 'Icon name must be a mapped name, { svgPath } or { path }', { surfaceId, path })
|
|
286
|
+
]
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
// ── Component validation ─────────────────────────────────────────────────────
|
|
290
|
+
function validateComponent(surface, raw, envelopeIndex, compIndex, idsSeen) {
|
|
291
|
+
const base = `/messages/${envelopeIndex}/updateComponents/components/${compIndex}`;
|
|
292
|
+
const surfaceId = surface.surfaceId;
|
|
293
|
+
if (!isPlainObject(raw)) {
|
|
294
|
+
surface.issues.push(issue('error', A2UI_ISSUE_CODES.TYPE_MISMATCH, 'Component entry must be an object', {
|
|
295
|
+
surfaceId,
|
|
296
|
+
path: base
|
|
297
|
+
}));
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const comp = raw;
|
|
301
|
+
const id = comp.id;
|
|
302
|
+
if (typeof id !== 'string' || id === '') {
|
|
303
|
+
surface.issues.push(issue('error', A2UI_ISSUE_CODES.MISSING_ID, 'Component is missing a string "id"', {
|
|
304
|
+
surfaceId,
|
|
305
|
+
path: `${base}/id`
|
|
306
|
+
}));
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (PROTO_KEYS.has(id)) {
|
|
310
|
+
surface.issues.push(issue('error', A2UI_ISSUE_CODES.PROTOTYPE_POLLUTION, `Prohibited component id "${id}"`, {
|
|
311
|
+
surfaceId,
|
|
312
|
+
path: `${base}/id`
|
|
313
|
+
}));
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (idsSeen.has(id)) {
|
|
317
|
+
surface.issues.push(issue('error', A2UI_ISSUE_CODES.DUPLICATE_ID, `Duplicate component id "${id}" in one updateComponents (last wins)`, {
|
|
318
|
+
surfaceId,
|
|
319
|
+
path: `${base}/id`
|
|
320
|
+
}));
|
|
321
|
+
}
|
|
322
|
+
idsSeen.add(id);
|
|
323
|
+
const componentName = comp.component;
|
|
324
|
+
if (typeof componentName !== 'string') {
|
|
325
|
+
surface.issues.push(issue('error', A2UI_ISSUE_CODES.MISSING_FIELD, `Component "${id}" is missing a string "component"`, { surfaceId, path: `${base}/component` }));
|
|
326
|
+
surface.components.set(id, { id, component: '', props: new Map(), sourceIndex: envelopeIndex });
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
const spec = A2UI_REGISTRY[componentName];
|
|
330
|
+
if (!spec) {
|
|
331
|
+
if (UNSUPPORTED_A2UI_COMPONENTS.has(componentName)) {
|
|
332
|
+
surface.issues.push(issue('error', A2UI_ISSUE_CODES.UNSUPPORTED_COMPONENT, `Component "${componentName}" is not part of the rendered subset`, {
|
|
333
|
+
surfaceId,
|
|
334
|
+
path: `${base}/component`
|
|
335
|
+
}));
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
surface.issues.push(issue('error', A2UI_ISSUE_CODES.UNKNOWN_COMPONENT, `Unknown component "${componentName}"`, {
|
|
339
|
+
surfaceId,
|
|
340
|
+
path: `${base}/component`
|
|
341
|
+
}));
|
|
342
|
+
}
|
|
343
|
+
surface.components.set(id, {
|
|
344
|
+
id,
|
|
345
|
+
component: componentName,
|
|
346
|
+
props: new Map(),
|
|
347
|
+
sourceIndex: envelopeIndex
|
|
348
|
+
});
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
const props = new Map();
|
|
352
|
+
for (const key of Object.keys(comp)) {
|
|
353
|
+
if (key === 'id' || key === 'component')
|
|
354
|
+
continue;
|
|
355
|
+
if (PROTO_KEYS.has(key)) {
|
|
356
|
+
surface.issues.push(issue('error', A2UI_ISSUE_CODES.PROTOTYPE_POLLUTION, `Prohibited property "${key}" on "${id}"`, { surfaceId, path: `${base}/${key}` }));
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
if (A2UI_IGNORED_PROPS.has(key)) {
|
|
360
|
+
surface.issues.push(issue('warning', A2UI_ISSUE_CODES.IGNORED_PROP, `Property "${key}" on ${componentName} is not supported and is ignored`, {
|
|
361
|
+
surfaceId,
|
|
362
|
+
path: `${base}/${key}`
|
|
363
|
+
}));
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
const propSpec = spec.props[key];
|
|
367
|
+
if (!propSpec) {
|
|
368
|
+
surface.issues.push(issue('error', A2UI_ISSUE_CODES.UNKNOWN_PROP, `Unknown property "${key}" on ${componentName}`, { surfaceId, path: `${base}/${key}` }));
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
const result = validateProp(componentName, key, propSpec, comp[key], `${base}/${key}`, surfaceId);
|
|
372
|
+
for (const propIssue of result.issues)
|
|
373
|
+
surface.issues.push(propIssue);
|
|
374
|
+
if (result.store)
|
|
375
|
+
props.set(key, comp[key]);
|
|
376
|
+
}
|
|
377
|
+
for (const [key, propSpec] of Object.entries(spec.props)) {
|
|
378
|
+
if (propSpec.required && !props.has(key)) {
|
|
379
|
+
surface.issues.push(issue('error', A2UI_ISSUE_CODES.MISSING_FIELD, `Required property "${key}" is missing on ${componentName} "${id}"`, {
|
|
380
|
+
surfaceId,
|
|
381
|
+
path: `${base}/${key}`
|
|
382
|
+
}));
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (componentName === 'ChoicePicker') {
|
|
386
|
+
if (props.get('displayStyle') === 'chips' || props.get('filterable') === true) {
|
|
387
|
+
surface.issues.push(issue('warning', A2UI_ISSUE_CODES.CHOICEPICKER_FALLBACK, `ChoicePicker "${id}" chips/filterable are rendered with a fallback`, {
|
|
388
|
+
surfaceId,
|
|
389
|
+
path: base
|
|
390
|
+
}));
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (componentName === 'DateTimeInput') {
|
|
394
|
+
// Both flags default to false in the spec — a DateTimeInput without either
|
|
395
|
+
// would have no input UI at all. We read tolerantly (render a date input)
|
|
396
|
+
// and report loudly so the agent fixes the payload.
|
|
397
|
+
if (props.get('enableDate') !== true && props.get('enableTime') !== true) {
|
|
398
|
+
surface.issues.push(issue('warning', A2UI_ISSUE_CODES.DATETIME_NO_MODE, `DateTimeInput "${id}" sets neither enableDate nor enableTime; rendering a date input`, {
|
|
399
|
+
surfaceId,
|
|
400
|
+
path: base
|
|
401
|
+
}));
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
surface.components.set(id, { id, component: componentName, props, sourceIndex: envelopeIndex });
|
|
405
|
+
}
|
|
406
|
+
// ── Envelope handlers ────────────────────────────────────────────────────────
|
|
407
|
+
function flagExtraKeys(object, allowed, sink, surfaceId, base, container) {
|
|
408
|
+
for (const key of Object.keys(object)) {
|
|
409
|
+
if (allowed.has(key))
|
|
410
|
+
continue;
|
|
411
|
+
const code = PROTO_KEYS.has(key)
|
|
412
|
+
? A2UI_ISSUE_CODES.PROTOTYPE_POLLUTION
|
|
413
|
+
: A2UI_ISSUE_CODES.UNKNOWN_PROP;
|
|
414
|
+
sink.push(issue('error', code, `Unexpected key "${key}" in ${container}`, {
|
|
415
|
+
surfaceId,
|
|
416
|
+
path: `${base}/${key}`
|
|
417
|
+
}));
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
const CREATE_SURFACE_KEYS = new Set([
|
|
421
|
+
'surfaceId',
|
|
422
|
+
'catalogId',
|
|
423
|
+
'theme',
|
|
424
|
+
'sendDataModel'
|
|
425
|
+
]);
|
|
426
|
+
const UPDATE_COMPONENTS_KEYS = new Set(['surfaceId', 'components']);
|
|
427
|
+
const UPDATE_DATA_MODEL_KEYS = new Set(['surfaceId', 'path', 'value']);
|
|
428
|
+
const DELETE_SURFACE_KEYS = new Set(['surfaceId']);
|
|
429
|
+
function createProcessor() {
|
|
430
|
+
const surfaces = new Map();
|
|
431
|
+
const globalIssues = [];
|
|
432
|
+
function handleCreateSurface(env, base) {
|
|
433
|
+
const cs = env.createSurface;
|
|
434
|
+
if (!isPlainObject(cs)) {
|
|
435
|
+
globalIssues.push(issue('error', A2UI_ISSUE_CODES.MISSING_FIELD, 'createSurface must be an object', {
|
|
436
|
+
path: `${base}/createSurface`
|
|
437
|
+
}));
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
const surfaceId = cs.surfaceId;
|
|
441
|
+
if (typeof surfaceId !== 'string' || surfaceId === '') {
|
|
442
|
+
globalIssues.push(issue('error', A2UI_ISSUE_CODES.MISSING_FIELD, 'createSurface.surfaceId is required', {
|
|
443
|
+
path: `${base}/createSurface/surfaceId`
|
|
444
|
+
}));
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
const existing = surfaces.get(surfaceId);
|
|
448
|
+
if (existing) {
|
|
449
|
+
existing.issues.push(issue('error', A2UI_ISSUE_CODES.DUPLICATE_SURFACE, `Surface "${surfaceId}" already exists; delete it before recreating`, {
|
|
450
|
+
surfaceId,
|
|
451
|
+
path: `${base}/createSurface/surfaceId`
|
|
452
|
+
}));
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
if (surfaces.size >= MAX_SURFACES) {
|
|
456
|
+
globalIssues.push(issue('error', A2UI_ISSUE_CODES.MAX_SURFACES, `Surface count exceeds the limit of ${MAX_SURFACES}; "${surfaceId}" was not created`, { surfaceId, path: `${base}/createSurface/surfaceId` }));
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
const surface = {
|
|
460
|
+
surfaceId,
|
|
461
|
+
components: new Map(),
|
|
462
|
+
dataModel: undefined,
|
|
463
|
+
issues: []
|
|
464
|
+
};
|
|
465
|
+
surfaces.set(surfaceId, surface);
|
|
466
|
+
if (typeof cs.catalogId !== 'string') {
|
|
467
|
+
surface.issues.push(issue('error', A2UI_ISSUE_CODES.MISSING_FIELD, 'createSurface.catalogId is required', {
|
|
468
|
+
surfaceId,
|
|
469
|
+
path: `${base}/createSurface/catalogId`
|
|
470
|
+
}));
|
|
471
|
+
}
|
|
472
|
+
if ('theme' in cs) {
|
|
473
|
+
surface.issues.push(issue('warning', A2UI_ISSUE_CODES.SURFACE_PROP_IGNORED, 'theme is ignored', {
|
|
474
|
+
surfaceId,
|
|
475
|
+
path: `${base}/createSurface/theme`
|
|
476
|
+
}));
|
|
477
|
+
}
|
|
478
|
+
if ('sendDataModel' in cs) {
|
|
479
|
+
surface.issues.push(issue('warning', A2UI_ISSUE_CODES.SURFACE_PROP_IGNORED, 'sendDataModel is ignored', {
|
|
480
|
+
surfaceId,
|
|
481
|
+
path: `${base}/createSurface/sendDataModel`
|
|
482
|
+
}));
|
|
483
|
+
}
|
|
484
|
+
flagExtraKeys(cs, CREATE_SURFACE_KEYS, surface.issues, surfaceId, `${base}/createSurface`, 'createSurface');
|
|
485
|
+
}
|
|
486
|
+
function handleUpdateComponents(env, base, index) {
|
|
487
|
+
const uc = env.updateComponents;
|
|
488
|
+
if (!isPlainObject(uc)) {
|
|
489
|
+
globalIssues.push(issue('error', A2UI_ISSUE_CODES.MISSING_FIELD, 'updateComponents must be an object', {
|
|
490
|
+
path: `${base}/updateComponents`
|
|
491
|
+
}));
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
const surfaceId = uc.surfaceId;
|
|
495
|
+
if (typeof surfaceId !== 'string') {
|
|
496
|
+
globalIssues.push(issue('error', A2UI_ISSUE_CODES.MISSING_FIELD, 'updateComponents.surfaceId is required', {
|
|
497
|
+
path: `${base}/updateComponents/surfaceId`
|
|
498
|
+
}));
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
const surface = surfaces.get(surfaceId);
|
|
502
|
+
if (!surface) {
|
|
503
|
+
globalIssues.push(issue('error', A2UI_ISSUE_CODES.NO_SURFACE, `updateComponents for unknown surface "${surfaceId}" (createSurface must come first)`, {
|
|
504
|
+
surfaceId,
|
|
505
|
+
path: `${base}/updateComponents`
|
|
506
|
+
}));
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
flagExtraKeys(uc, UPDATE_COMPONENTS_KEYS, surface.issues, surfaceId, `${base}/updateComponents`, 'updateComponents');
|
|
510
|
+
const components = uc.components;
|
|
511
|
+
if (!Array.isArray(components) || components.length === 0) {
|
|
512
|
+
surface.issues.push(issue('error', A2UI_ISSUE_CODES.MISSING_FIELD, 'updateComponents.components must be a non-empty array', {
|
|
513
|
+
surfaceId,
|
|
514
|
+
path: `${base}/updateComponents/components`
|
|
515
|
+
}));
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
let limit = components.length;
|
|
519
|
+
if (limit > MAX_COMPONENTS_PER_UPDATE) {
|
|
520
|
+
surface.issues.push(issue('error', A2UI_ISSUE_CODES.MAX_COMPONENTS, `updateComponents carries ${components.length} components; only the first ${MAX_COMPONENTS_PER_UPDATE} are processed`, { surfaceId, path: `${base}/updateComponents/components` }));
|
|
521
|
+
limit = MAX_COMPONENTS_PER_UPDATE;
|
|
522
|
+
}
|
|
523
|
+
const idsSeen = new Set();
|
|
524
|
+
for (let i = 0; i < limit; i++) {
|
|
525
|
+
validateComponent(surface, components[i], index, i, idsSeen);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
function handleUpdateDataModel(env, base) {
|
|
529
|
+
const udm = env.updateDataModel;
|
|
530
|
+
if (!isPlainObject(udm)) {
|
|
531
|
+
globalIssues.push(issue('error', A2UI_ISSUE_CODES.MISSING_FIELD, 'updateDataModel must be an object', {
|
|
532
|
+
path: `${base}/updateDataModel`
|
|
533
|
+
}));
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
const surfaceId = udm.surfaceId;
|
|
537
|
+
if (typeof surfaceId !== 'string') {
|
|
538
|
+
globalIssues.push(issue('error', A2UI_ISSUE_CODES.MISSING_FIELD, 'updateDataModel.surfaceId is required', {
|
|
539
|
+
path: `${base}/updateDataModel/surfaceId`
|
|
540
|
+
}));
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
const surface = surfaces.get(surfaceId);
|
|
544
|
+
if (!surface) {
|
|
545
|
+
globalIssues.push(issue('error', A2UI_ISSUE_CODES.NO_SURFACE, `updateDataModel for unknown surface "${surfaceId}" (createSurface must come first)`, {
|
|
546
|
+
surfaceId,
|
|
547
|
+
path: `${base}/updateDataModel`
|
|
548
|
+
}));
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
flagExtraKeys(udm, UPDATE_DATA_MODEL_KEYS, surface.issues, surfaceId, `${base}/updateDataModel`, 'updateDataModel');
|
|
552
|
+
const pathValue = udm.path;
|
|
553
|
+
if (pathValue !== undefined && typeof pathValue !== 'string') {
|
|
554
|
+
surface.issues.push(issue('error', A2UI_ISSUE_CODES.TYPE_MISMATCH, 'updateDataModel.path must be a string', {
|
|
555
|
+
surfaceId,
|
|
556
|
+
path: `${base}/updateDataModel/path`
|
|
557
|
+
}));
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
const hasValue = 'value' in udm;
|
|
561
|
+
const whole = pathValue === undefined || pathValue === '' || pathValue === '/';
|
|
562
|
+
if (whole) {
|
|
563
|
+
surface.dataModel = hasValue ? cloneData(udm.value) : undefined;
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
const pointer = pathValue;
|
|
567
|
+
if (!isContainer(surface.dataModel)) {
|
|
568
|
+
const firstSegment = pointer.replace(/^\//, '').split('/')[0];
|
|
569
|
+
surface.dataModel = /^(?:0|[1-9]\d*|-)$/.test(firstSegment) ? [] : {};
|
|
570
|
+
}
|
|
571
|
+
if (hasValue) {
|
|
572
|
+
const { issue: setIssue } = setAtPointer(surface.dataModel, pointer, cloneData(udm.value));
|
|
573
|
+
if (setIssue)
|
|
574
|
+
surface.issues.push({ ...setIssue, surfaceId });
|
|
575
|
+
}
|
|
576
|
+
else {
|
|
577
|
+
deleteAtPointer(surface.dataModel, pointer);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
function handleDeleteSurface(env, base) {
|
|
581
|
+
const ds = env.deleteSurface;
|
|
582
|
+
if (!isPlainObject(ds)) {
|
|
583
|
+
globalIssues.push(issue('error', A2UI_ISSUE_CODES.MISSING_FIELD, 'deleteSurface must be an object', {
|
|
584
|
+
path: `${base}/deleteSurface`
|
|
585
|
+
}));
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
const surfaceId = ds.surfaceId;
|
|
589
|
+
if (typeof surfaceId !== 'string') {
|
|
590
|
+
globalIssues.push(issue('error', A2UI_ISSUE_CODES.MISSING_FIELD, 'deleteSurface.surfaceId is required', {
|
|
591
|
+
path: `${base}/deleteSurface/surfaceId`
|
|
592
|
+
}));
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
if (!surfaces.has(surfaceId)) {
|
|
596
|
+
globalIssues.push(issue('error', A2UI_ISSUE_CODES.NO_SURFACE, `deleteSurface for unknown surface "${surfaceId}"`, { surfaceId, path: `${base}/deleteSurface` }));
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
flagExtraKeys(ds, DELETE_SURFACE_KEYS, globalIssues, surfaceId, `${base}/deleteSurface`, 'deleteSurface');
|
|
600
|
+
surfaces.delete(surfaceId);
|
|
601
|
+
}
|
|
602
|
+
function apply(envelope, index) {
|
|
603
|
+
const base = `/messages/${index}`;
|
|
604
|
+
if (!isPlainObject(envelope)) {
|
|
605
|
+
globalIssues.push(issue('error', A2UI_ISSUE_CODES.INVALID_ENVELOPE, 'Envelope must be a JSON object', {
|
|
606
|
+
path: base
|
|
607
|
+
}));
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
const env = envelope;
|
|
611
|
+
if (typeof env.version !== 'string' || !A2UI_SUPPORTED_VERSIONS.includes(env.version)) {
|
|
612
|
+
globalIssues.push(issue('error', A2UI_ISSUE_CODES.INVALID_VERSION, `Unsupported envelope version (expected one of: ${A2UI_SUPPORTED_VERSIONS.join(', ')})`, {
|
|
613
|
+
path: `${base}/version`
|
|
614
|
+
}));
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
const present = OP_KEYS.filter((key) => key in env);
|
|
618
|
+
if (present.length !== 1) {
|
|
619
|
+
globalIssues.push(issue('error', A2UI_ISSUE_CODES.UNKNOWN_ENVELOPE_TYPE, `Envelope must contain exactly one of: ${OP_KEYS.join(', ')} (found ${present.length})`, { path: base }));
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
const allowedTop = new Set(['version', present[0]]);
|
|
623
|
+
flagExtraKeys(env, allowedTop, globalIssues, undefined, base, 'envelope');
|
|
624
|
+
switch (present[0]) {
|
|
625
|
+
case 'createSurface':
|
|
626
|
+
handleCreateSurface(env, base);
|
|
627
|
+
break;
|
|
628
|
+
case 'updateComponents':
|
|
629
|
+
handleUpdateComponents(env, base, index);
|
|
630
|
+
break;
|
|
631
|
+
case 'updateDataModel':
|
|
632
|
+
handleUpdateDataModel(env, base);
|
|
633
|
+
break;
|
|
634
|
+
case 'deleteSurface':
|
|
635
|
+
handleDeleteSurface(env, base);
|
|
636
|
+
break;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
return { surfaces, globalIssues, apply };
|
|
640
|
+
}
|
|
641
|
+
export function createA2uiProcessor() {
|
|
642
|
+
return createProcessor();
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Normalize a render payload into an envelope list. Accepts an envelope array,
|
|
646
|
+
* a single envelope, or the golden-file `{ messages: [...] }` wrapper. Returns
|
|
647
|
+
* an `issue` (and no envelopes) only for wholly unusable input.
|
|
648
|
+
*/
|
|
649
|
+
export function normalizeA2uiPayload(payload) {
|
|
650
|
+
if (Array.isArray(payload))
|
|
651
|
+
return { envelopes: payload };
|
|
652
|
+
if (isPlainObject(payload)) {
|
|
653
|
+
if (Array.isArray(payload.messages))
|
|
654
|
+
return { envelopes: payload.messages };
|
|
655
|
+
return { envelopes: [payload] };
|
|
656
|
+
}
|
|
657
|
+
return {
|
|
658
|
+
envelopes: [],
|
|
659
|
+
issue: issue('error', A2UI_ISSUE_CODES.INVALID_ENVELOPE, 'A2UI payload must be an envelope, an array of envelopes, or { messages: [...] }')
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Walk the component graph from `root` and collect structural faults that only
|
|
664
|
+
* exist once components are assembled: cycles, excessive depth/nodes, dangling
|
|
665
|
+
* child references, non-array template paths, and mis-placed `weight`.
|
|
666
|
+
*
|
|
667
|
+
* NOTE — contract extension. The design's published `a2ui-validate.ts` surface
|
|
668
|
+
* is `createA2uiProcessor` + `normalizeA2uiPayload`. This function is an
|
|
669
|
+
* additive export (no existing signature changed): the renderer (WP-B) needs
|
|
670
|
+
* render-time graph faults whose severity depends on the `streaming` flag, and
|
|
671
|
+
* the test suite exercises them here. Traversal is bounded by `maxNodes` so a
|
|
672
|
+
* template over a huge array cannot DoS the walk itself.
|
|
673
|
+
*/
|
|
674
|
+
export function collectGraphIssues(surface, options) {
|
|
675
|
+
const streaming = options?.streaming ?? false;
|
|
676
|
+
const maxDepth = options?.maxDepth ?? 32;
|
|
677
|
+
const maxNodes = options?.maxNodes ?? 512;
|
|
678
|
+
const surfaceId = surface.surfaceId;
|
|
679
|
+
const components = surface.components;
|
|
680
|
+
const issues = [];
|
|
681
|
+
if (!components.has('root')) {
|
|
682
|
+
if (!streaming) {
|
|
683
|
+
issues.push(issue('error', A2UI_ISSUE_CODES.MISSING_ROOT, 'No component with id "root" was defined', {
|
|
684
|
+
surfaceId
|
|
685
|
+
}));
|
|
686
|
+
}
|
|
687
|
+
return issues;
|
|
688
|
+
}
|
|
689
|
+
const reportedDangling = new Set();
|
|
690
|
+
let nodeCount = 0;
|
|
691
|
+
let overflowReported = false;
|
|
692
|
+
let aborted = false;
|
|
693
|
+
const childTargets = (comp, scopePrefix) => {
|
|
694
|
+
const out = [];
|
|
695
|
+
const single = comp.props.get('child');
|
|
696
|
+
if (typeof single === 'string')
|
|
697
|
+
out.push({ id: single, scope: scopePrefix });
|
|
698
|
+
const children = comp.props.get('children');
|
|
699
|
+
if (Array.isArray(children)) {
|
|
700
|
+
for (const childId of children)
|
|
701
|
+
if (typeof childId === 'string')
|
|
702
|
+
out.push({ id: childId, scope: scopePrefix });
|
|
703
|
+
}
|
|
704
|
+
else if (children && typeof children === 'object') {
|
|
705
|
+
const template = children;
|
|
706
|
+
if (typeof template.componentId === 'string' && typeof template.path === 'string') {
|
|
707
|
+
const absPath = template.path.startsWith('/')
|
|
708
|
+
? template.path
|
|
709
|
+
: `${scopePrefix ?? ''}/${template.path}`;
|
|
710
|
+
const list = getAtPointer(surface.dataModel, absPath);
|
|
711
|
+
if (Array.isArray(list)) {
|
|
712
|
+
for (let i = 0; i < list.length; i++) {
|
|
713
|
+
out.push({ id: template.componentId, scope: `${absPath}/${i}` });
|
|
714
|
+
if (out.length > maxNodes)
|
|
715
|
+
break; // bound expansion
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
else if (list !== undefined) {
|
|
719
|
+
issues.push(issue('warning', A2UI_ISSUE_CODES.TEMPLATE_PATH_NOT_ARRAY, `Template path "${absPath}" did not resolve to an array`, {
|
|
720
|
+
surfaceId
|
|
721
|
+
}));
|
|
722
|
+
}
|
|
723
|
+
// list === undefined → data not present yet; render nothing (graceful).
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
return out;
|
|
727
|
+
};
|
|
728
|
+
const visit = (id, depth, stack, parentComponent, scopePrefix) => {
|
|
729
|
+
if (aborted)
|
|
730
|
+
return;
|
|
731
|
+
nodeCount++;
|
|
732
|
+
if (nodeCount > maxNodes) {
|
|
733
|
+
if (!overflowReported) {
|
|
734
|
+
issues.push(issue('error', A2UI_ISSUE_CODES.MAX_NODES, `Rendered node count exceeds the limit of ${maxNodes}`, { surfaceId }));
|
|
735
|
+
overflowReported = true;
|
|
736
|
+
}
|
|
737
|
+
aborted = true;
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
if (depth > maxDepth) {
|
|
741
|
+
issues.push(issue('error', A2UI_ISSUE_CODES.MAX_DEPTH, `Component tree depth exceeds the limit of ${maxDepth}`, { surfaceId }));
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
const comp = components.get(id);
|
|
745
|
+
if (!comp) {
|
|
746
|
+
if (!reportedDangling.has(id)) {
|
|
747
|
+
reportedDangling.add(id);
|
|
748
|
+
issues.push(issue(streaming ? 'warning' : 'error', A2UI_ISSUE_CODES.DANGLING_REF, `Reference to undefined component "${id}"`, { surfaceId }));
|
|
749
|
+
}
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
if (comp.props.has('weight') && parentComponent !== 'Row' && parentComponent !== 'Column') {
|
|
753
|
+
issues.push(issue('warning', A2UI_ISSUE_CODES.WEIGHT_CONTEXT, `"weight" on "${id}" is only honored as a direct child of Row or Column`, {
|
|
754
|
+
surfaceId
|
|
755
|
+
}));
|
|
756
|
+
}
|
|
757
|
+
const nextStack = new Set(stack);
|
|
758
|
+
nextStack.add(id);
|
|
759
|
+
for (const target of childTargets(comp, scopePrefix)) {
|
|
760
|
+
if (aborted)
|
|
761
|
+
return;
|
|
762
|
+
if (nextStack.has(target.id)) {
|
|
763
|
+
issues.push(issue('error', A2UI_ISSUE_CODES.CYCLE, `Cycle detected at component "${target.id}"`, {
|
|
764
|
+
surfaceId
|
|
765
|
+
}));
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
visit(target.id, depth + 1, nextStack, comp.component, target.scope);
|
|
769
|
+
}
|
|
770
|
+
};
|
|
771
|
+
visit('root', 0, new Set(), null, undefined);
|
|
772
|
+
return issues;
|
|
773
|
+
}
|