@yoyoboot/yoyo-admin-schema 10.5.5

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 (49) hide show
  1. package/dist/action-contract.d.ts +50 -0
  2. package/dist/action-contract.d.ts.map +1 -0
  3. package/dist/action-contract.js +185 -0
  4. package/dist/action-registry.d.ts +12 -0
  5. package/dist/action-registry.d.ts.map +1 -0
  6. package/dist/action-registry.js +23 -0
  7. package/dist/compiler.d.ts +17 -0
  8. package/dist/compiler.d.ts.map +1 -0
  9. package/dist/compiler.js +1300 -0
  10. package/dist/contract-drift.d.ts +18 -0
  11. package/dist/contract-drift.d.ts.map +1 -0
  12. package/dist/contract-drift.js +32 -0
  13. package/dist/diagnostics.d.ts +19 -0
  14. package/dist/diagnostics.d.ts.map +1 -0
  15. package/dist/diagnostics.js +8 -0
  16. package/dist/extension-registry.d.ts +13 -0
  17. package/dist/extension-registry.d.ts.map +1 -0
  18. package/dist/extension-registry.js +23 -0
  19. package/dist/field-registry.d.ts +12 -0
  20. package/dist/field-registry.d.ts.map +1 -0
  21. package/dist/field-registry.js +30 -0
  22. package/dist/generated/action-contract/action-contract-keys.d.ts +3 -0
  23. package/dist/generated/action-contract/action-contract-keys.d.ts.map +1 -0
  24. package/dist/generated/action-contract/action-contract-keys.js +50 -0
  25. package/dist/generated/action-contract/index.d.ts +534 -0
  26. package/dist/generated/action-contract/index.d.ts.map +1 -0
  27. package/dist/generated/action-contract/index.js +579 -0
  28. package/dist/generated-validation-contract.d.ts +36 -0
  29. package/dist/generated-validation-contract.d.ts.map +1 -0
  30. package/dist/generated-validation-contract.js +124 -0
  31. package/dist/index.d.ts +15 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +14 -0
  34. package/dist/page-contract-linking.d.ts +36 -0
  35. package/dist/page-contract-linking.d.ts.map +1 -0
  36. package/dist/page-contract-linking.js +78 -0
  37. package/dist/project-check.d.ts +34 -0
  38. package/dist/project-check.d.ts.map +1 -0
  39. package/dist/project-check.js +151 -0
  40. package/dist/registry-diagnostics.d.ts +15 -0
  41. package/dist/registry-diagnostics.d.ts.map +1 -0
  42. package/dist/registry-diagnostics.js +41 -0
  43. package/dist/renderer-registry.d.ts +22 -0
  44. package/dist/renderer-registry.d.ts.map +1 -0
  45. package/dist/renderer-registry.js +33 -0
  46. package/dist/types.d.ts +290 -0
  47. package/dist/types.d.ts.map +1 -0
  48. package/dist/types.js +49 -0
  49. package/package.json +30 -0
@@ -0,0 +1,1300 @@
1
+ import { z } from 'zod';
2
+ import { YOYO_PAGE_KINDS, YOYO_COLUMN_PRIORITIES, YOYO_COLUMN_ROLES, YOYO_GRID_PRESENTATIONS, YOYO_FILTER_OPERATORS, YOYO_MOBILE_PLACEMENTS, } from './types.js';
3
+ import { isYoyoMutationActionKind, linkYoyoActionContract, operationForYoyoActionKind, validateYoyoActionContractCatalog, } from './action-contract.js';
4
+ import { linkYoyoGeneratedValidationContract, validateYoyoGeneratedValidationContractCatalog, } from './generated-validation-contract.js';
5
+ import { yoyoV1ActionRegistry } from './action-registry.js';
6
+ import { YOYO_EXTENSION_SLOTS } from './extension-registry.js';
7
+ import { yoyoV1FieldRegistry } from './field-registry.js';
8
+ import { yoyoV1RendererRegistry } from './renderer-registry.js';
9
+ import { YoyoSchemaValidationError } from './diagnostics.js';
10
+ const fieldLifecycleShape = z
11
+ .object({
12
+ editableOn: z.enum(['create', 'edit', 'both', 'never']).optional(),
13
+ createDefault: z.unknown().optional(),
14
+ editDefault: z.unknown().optional(),
15
+ submitOnEdit: z.boolean().optional(),
16
+ })
17
+ .strict();
18
+ const fieldOptionShape = z.lazy(() => z
19
+ .object({
20
+ value: z.string().min(1),
21
+ label: z.string().min(1),
22
+ disabled: z.boolean().optional(),
23
+ children: z.array(fieldOptionShape).optional(),
24
+ })
25
+ .strict());
26
+ const queryPresentationShape = z
27
+ .object({
28
+ defaultSort: z
29
+ .object({ key: z.string().min(1), direction: z.enum(['asc', 'desc']) })
30
+ .optional(),
31
+ pageSize: z.number().int().positive().optional(),
32
+ allowedUrlParams: z.array(z.string().min(1)).optional(),
33
+ })
34
+ .strict();
35
+ const actionConfirmationShape = z
36
+ .object({
37
+ intent: z.enum(['destructive', 'warning']),
38
+ subjectFields: z.array(z.string().min(1)).optional(),
39
+ })
40
+ .strict();
41
+ const fieldShape = z
42
+ .object({
43
+ key: z.string().min(1),
44
+ kind: z.string().min(1),
45
+ label: z.string().min(1),
46
+ description: z.string().optional(),
47
+ required: z.boolean().optional(),
48
+ lifecycle: fieldLifecycleShape.optional(),
49
+ options: z.array(fieldOptionShape).min(1).optional(),
50
+ optionSource: z.string().min(1).optional(),
51
+ filter: z
52
+ .object({
53
+ quick: z.boolean().optional(),
54
+ operators: z.array(z.enum(YOYO_FILTER_OPERATORS)).min(1).optional(),
55
+ })
56
+ .strict()
57
+ .optional(),
58
+ })
59
+ .strict();
60
+ const columnShape = z
61
+ .object({
62
+ key: z.string().min(1),
63
+ title: z.string().min(1),
64
+ renderer: z.string().optional(),
65
+ sortable: z.boolean().optional(),
66
+ role: z.enum(YOYO_COLUMN_ROLES).optional(),
67
+ priority: z.enum(YOYO_COLUMN_PRIORITIES).optional(),
68
+ mobilePlacement: z.enum(YOYO_MOBILE_PLACEMENTS).optional(),
69
+ recordRendererKey: z.string().min(1).optional(),
70
+ export: z
71
+ .object({
72
+ mode: z.enum(['allowed', 'sensitive', 'disabled']),
73
+ })
74
+ .strict()
75
+ .optional(),
76
+ })
77
+ .strict();
78
+ const actionShape = z
79
+ .object({
80
+ key: z.string().min(1),
81
+ kind: z.string().min(1),
82
+ label: z.string().optional(),
83
+ placement: z.enum(['page', 'row', 'bulk', 'form']).optional(),
84
+ permission: z.string().optional(),
85
+ feature: z.string().optional(),
86
+ contractKey: z.string().min(1).optional(),
87
+ confirmation: actionConfirmationShape.optional(),
88
+ rowOverlay: z.string().min(1).optional(),
89
+ })
90
+ .strict();
91
+ const extensionShape = z
92
+ .object({
93
+ key: z.string().min(1),
94
+ slot: z.string().min(1),
95
+ overlay: z.enum(['dialog', 'drawer']).optional(),
96
+ })
97
+ .strict();
98
+ const tableVirtualizationShape = z
99
+ .object({
100
+ mode: z.literal('fixed'),
101
+ })
102
+ .strict();
103
+ const tableGroupingShape = z
104
+ .object({
105
+ mode: z.literal('client'),
106
+ columnKey: z.string().min(1),
107
+ defaultExpanded: z.boolean().optional(),
108
+ })
109
+ .strict();
110
+ const queryPageShape = z
111
+ .object({
112
+ schemaVersion: z.literal(1),
113
+ pageKind: z.literal('query'),
114
+ pageKey: z.string().min(1),
115
+ title: z.string().min(1),
116
+ query: z
117
+ .object({
118
+ datasource: z.string().min(1),
119
+ fields: z.array(fieldShape).optional(),
120
+ defaultValues: z.record(z.string(), z.unknown()).optional(),
121
+ presentation: queryPresentationShape.optional(),
122
+ })
123
+ .strict(),
124
+ table: z
125
+ .object({
126
+ rowKey: z.string().min(1),
127
+ columns: z.array(columnShape).min(1),
128
+ grouping: tableGroupingShape.optional(),
129
+ virtualization: tableVirtualizationShape.optional(),
130
+ })
131
+ .strict(),
132
+ form: z.never().optional(),
133
+ actions: z.array(actionShape).optional(),
134
+ extensions: z.never().optional(),
135
+ })
136
+ .strict();
137
+ function isJsonFixedValue(value, ancestors = new Set()) {
138
+ if (value === null || typeof value === 'string' || typeof value === 'boolean')
139
+ return true;
140
+ if (typeof value === 'number')
141
+ return Number.isFinite(value);
142
+ if (typeof value !== 'object' || ancestors.has(value))
143
+ return false;
144
+ if (!Array.isArray(value) &&
145
+ Object.getPrototypeOf(value) !== Object.prototype &&
146
+ Object.getPrototypeOf(value) !== null)
147
+ return false;
148
+ ancestors.add(value);
149
+ const descriptors = Object.getOwnPropertyDescriptors(value);
150
+ const valid = Reflect.ownKeys(value).every((key) => typeof key === 'string') &&
151
+ Object.entries(descriptors).every(([key, descriptor]) => Array.isArray(value) && key === 'length'
152
+ ? true
153
+ : descriptor.enumerable &&
154
+ 'value' in descriptor &&
155
+ isJsonFixedValue(descriptor.value, ancestors)) &&
156
+ (!Array.isArray(value) || Object.keys(value).length === value.length);
157
+ ancestors.delete(value);
158
+ return valid;
159
+ }
160
+ const fixedRequestShape = z
161
+ .object({
162
+ fixedValues: z.custom((value) => value !== null &&
163
+ typeof value === 'object' &&
164
+ !Array.isArray(value) &&
165
+ isJsonFixedValue(value), 'fixedValues must contain only deterministic JSON-safe values'),
166
+ })
167
+ .strict();
168
+ const formShape = z
169
+ .object({
170
+ fields: z.array(fieldShape).min(1),
171
+ request: z
172
+ .object({
173
+ create: fixedRequestShape.optional(),
174
+ update: fixedRequestShape.optional(),
175
+ })
176
+ .strict()
177
+ .optional(),
178
+ })
179
+ .strict();
180
+ const crudPageShape = z
181
+ .object({
182
+ schemaVersion: z.literal(1),
183
+ pageKind: z.literal('crud'),
184
+ pageKey: z.string().min(1),
185
+ title: z.string().min(1),
186
+ query: z
187
+ .object({
188
+ datasource: z.string().min(1),
189
+ fields: z.array(fieldShape).optional(),
190
+ defaultValues: z.record(z.string(), z.unknown()).optional(),
191
+ presentation: queryPresentationShape.optional(),
192
+ })
193
+ .strict(),
194
+ table: z
195
+ .object({
196
+ rowKey: z.string().min(1),
197
+ columns: z.array(columnShape).min(1),
198
+ grouping: tableGroupingShape.optional(),
199
+ virtualization: tableVirtualizationShape.optional(),
200
+ })
201
+ .strict(),
202
+ form: formShape,
203
+ actions: z.array(actionShape).optional(),
204
+ extensions: z.never().optional(),
205
+ })
206
+ .strict();
207
+ const crudExPageShape = z
208
+ .object({
209
+ schemaVersion: z.literal(1),
210
+ pageKind: z.literal('crud-ex'),
211
+ pageKey: z.string().min(1),
212
+ title: z.string().min(1),
213
+ query: z
214
+ .object({
215
+ datasource: z.string().min(1),
216
+ fields: z.array(fieldShape).optional(),
217
+ defaultValues: z.record(z.string(), z.unknown()).optional(),
218
+ presentation: queryPresentationShape.optional(),
219
+ })
220
+ .strict(),
221
+ table: z
222
+ .object({
223
+ rowKey: z.string().min(1),
224
+ columns: z.array(columnShape).min(1),
225
+ grouping: tableGroupingShape.optional(),
226
+ virtualization: tableVirtualizationShape.optional(),
227
+ })
228
+ .strict(),
229
+ form: formShape,
230
+ actions: z.array(actionShape).optional(),
231
+ extensions: z.array(extensionShape).min(1),
232
+ })
233
+ .strict();
234
+ const pageShape = z.discriminatedUnion('pageKind', [queryPageShape, crudPageShape, crudExPageShape]);
235
+ /**
236
+ * Declares a canonical V1 page schema while retaining its literal page kind.
237
+ * The historical name remains the single public authoring entry point.
238
+ */
239
+ export function defineYoyoQueryPage(page) {
240
+ return page;
241
+ }
242
+ export function compileYoyoPageSchema(input, options = {}) {
243
+ const structuralResult = pageShape.safeParse(input);
244
+ if (!structuralResult.success) {
245
+ throw new YoyoSchemaValidationError(structuralResult.error.issues.map((issue) => ({
246
+ code: issue.code === 'unrecognized_keys' ? 'YSCH003' : 'YSCH002',
247
+ path: formatPath(issue.path),
248
+ severity: 'blocking',
249
+ guidance: issue.path[0] === 'pageKind'
250
+ ? 'Use one of query/crud/crud-ex and satisfy its required structure.'
251
+ : 'Use the declared v1 schema shape and remove unknown fields.',
252
+ message: issue.message,
253
+ })));
254
+ }
255
+ const page = structuralResult.data;
256
+ const diagnostics = [];
257
+ const mode = options.mode ?? 'fixture';
258
+ const context = mode === 'project' ? requireCompileContext(page.pageKey, options) : options.context;
259
+ const source = toSourceLocation(page.pageKey, context);
260
+ const registry = options.registry ?? context?.registry;
261
+ if (page.pageKind === 'query' && !page.table) {
262
+ diagnostics.push({
263
+ code: 'YSCH006',
264
+ path: 'table',
265
+ severity: 'blocking',
266
+ ...diagnosticSource(source),
267
+ guidance: 'Query pages must provide table for rendering and conformance.',
268
+ message: 'Query pages require a table contract.',
269
+ });
270
+ }
271
+ if (isBareUrl(page.query.datasource)) {
272
+ diagnostics.push({
273
+ code: 'YSCH005',
274
+ path: 'query.datasource',
275
+ severity: 'blocking',
276
+ ...diagnosticSource(source),
277
+ guidance: 'Replace URL paths with a registered datasource key.',
278
+ message: 'Schema datasource must be a registered key, not a URL.',
279
+ });
280
+ }
281
+ if (!isPageKindAllowed(page.pageKind)) {
282
+ diagnostics.push({
283
+ code: 'YSCH010',
284
+ path: 'pageKind',
285
+ severity: 'blocking',
286
+ ...diagnosticSource(source),
287
+ guidance: 'Use query / crud / crud-ex only.',
288
+ message: `Page kind '${page.pageKind}' is not supported by Schema v1.`,
289
+ });
290
+ }
291
+ if (page.pageKind === 'crud' && !page.form) {
292
+ diagnostics.push({
293
+ code: 'YSCH006',
294
+ path: 'form',
295
+ severity: 'blocking',
296
+ ...diagnosticSource(source),
297
+ guidance: 'CRUD pages must declare form fields.',
298
+ message: 'CRUD pages require form schema.',
299
+ });
300
+ }
301
+ if (page.pageKind === 'crud-ex' && (!page.extensions || page.extensions.length === 0)) {
302
+ diagnostics.push({
303
+ code: 'YSCH006',
304
+ path: 'extensions',
305
+ severity: 'blocking',
306
+ ...diagnosticSource(source),
307
+ guidance: 'crud-ex pages must declare at least one extension entry.',
308
+ message: 'crud-ex pages require extension entries.',
309
+ });
310
+ }
311
+ if (registry?.datasources && !registry.datasources.includes(page.query.datasource)) {
312
+ diagnostics.push({
313
+ code: 'YDS001',
314
+ path: 'query.datasource',
315
+ severity: 'blocking',
316
+ source,
317
+ guidance: 'Register the datasource key in compile project context.',
318
+ message: `Datasource '${page.query.datasource}' is not registered.`,
319
+ });
320
+ }
321
+ addDuplicateDiagnostics(diagnostics, page.query.fields ?? [], 'query.fields', source, 'YSCH004');
322
+ if (page.table) {
323
+ addDuplicateDiagnostics(diagnostics, page.table.columns, 'table.columns', source, 'YSCH004');
324
+ if (page.table.grouping &&
325
+ !page.table.columns.some((column) => column.key === page.table.grouping.columnKey)) {
326
+ diagnostics.push({
327
+ code: 'YGRP001',
328
+ path: 'table.grouping.columnKey',
329
+ severity: 'blocking',
330
+ source,
331
+ guidance: 'Reference a column declared by table.columns.',
332
+ message: `Grouping column '${page.table.grouping.columnKey}' is not declared by the table.`,
333
+ });
334
+ }
335
+ if (page.table.grouping && page.table.virtualization) {
336
+ diagnostics.push({
337
+ code: 'YGRP002',
338
+ path: 'table.grouping',
339
+ severity: 'blocking',
340
+ source,
341
+ guidance: 'Use client grouping or fixed-row virtualization independently until a mixed-height grouped virtualizer is approved.',
342
+ message: 'Client row grouping cannot be combined with fixed-row virtualization in Schema v1.',
343
+ });
344
+ }
345
+ }
346
+ const actions = page.actions ?? [];
347
+ const linkedActionContracts = new Map();
348
+ const linkedValidationContracts = new Map();
349
+ const extensions = page.pageKind === 'crud-ex' ? page.extensions : [];
350
+ addDuplicateDiagnostics(diagnostics, actions, 'actions', source, 'YSCH004');
351
+ addDuplicateDiagnostics(diagnostics, extensions, 'extensions', source, 'YSCH004');
352
+ if (registry?.actionContracts) {
353
+ diagnostics.push(...validateYoyoActionContractCatalog(registry.actionContracts, registry));
354
+ }
355
+ if (registry?.validationContracts) {
356
+ diagnostics.push(...validateYoyoGeneratedValidationContractCatalog(registry.validationContracts));
357
+ }
358
+ for (const [index, field] of page.query.fields?.entries() ?? []) {
359
+ validateField(diagnostics, field, `query.fields[${index}]`, registry, source);
360
+ validateFieldLifecycle(diagnostics, field, `query.fields[${index}]`, source);
361
+ validateQueryFilterContract(diagnostics, field, `query.fields[${index}]`, registry, source);
362
+ }
363
+ for (const [index, field] of page.form?.fields.entries() ?? []) {
364
+ validateField(diagnostics, field, `form.fields[${index}]`, registry, source);
365
+ validateFieldLifecycle(diagnostics, field, `form.fields[${index}]`, source);
366
+ }
367
+ const normalizedColumns = page.table?.columns.map((column, index) => normalizeColumn(column, page.table.rowKey, index)) ?? [];
368
+ for (const [index, column] of normalizedColumns.entries()) {
369
+ const renderer = resolveRendererDefinition(column.renderer, registry);
370
+ if (!renderer) {
371
+ diagnostics.push({
372
+ code: 'YREG001',
373
+ path: `table.columns[${index}].renderer`,
374
+ severity: 'blocking',
375
+ ...diagnosticSource(source),
376
+ guidance: 'Use renderer keys from the registered renderer catalog.',
377
+ message: `Renderer '${column.renderer}' is not registered in Schema v1.`,
378
+ });
379
+ }
380
+ else if (renderer.inputKind !== 'cell-value' ||
381
+ !renderer.slots.includes('cell') ||
382
+ YOYO_GRID_PRESENTATIONS.some((presentation) => !renderer.presentations.includes(presentation))) {
383
+ diagnostics.push({
384
+ code: 'YREG002',
385
+ path: `table.columns[${index}].renderer`,
386
+ severity: 'blocking',
387
+ ...diagnosticSource(source),
388
+ guidance: 'Use a registered cell-value renderer that supports Grid, Priority Table, and Record List.',
389
+ message: `Renderer '${column.renderer}' does not support the required Data Workbench cell presentations.`,
390
+ });
391
+ }
392
+ if (column.recordRendererKey) {
393
+ const recordRenderer = resolveRendererDefinition(column.recordRendererKey, registry);
394
+ if (!recordRenderer ||
395
+ recordRenderer.inputKind !== 'record' ||
396
+ !recordRenderer.slots.includes('record') ||
397
+ !recordRenderer.presentations.includes('record-list')) {
398
+ diagnostics.push({
399
+ code: 'YREG002',
400
+ path: `table.columns[${index}].recordRendererKey`,
401
+ severity: 'blocking',
402
+ source,
403
+ guidance: 'Register a record renderer with inputKind record, slot record, and record-list presentation support.',
404
+ message: `Record renderer '${column.recordRendererKey}' does not support the required record-list capability.`,
405
+ });
406
+ }
407
+ }
408
+ }
409
+ validateResponsiveColumns(diagnostics, normalizedColumns, source);
410
+ for (const [index, action] of actions.entries()) {
411
+ if (!yoyoV1ActionRegistry.has(action.kind) && !registry?.actions?.includes(action.kind)) {
412
+ diagnostics.push({
413
+ code: 'YREG001',
414
+ path: `actions[${index}].kind`,
415
+ severity: 'blocking',
416
+ source,
417
+ guidance: 'Use action keys from the registered action catalog.',
418
+ message: `Action '${action.kind}' is not registered in Schema v1.`,
419
+ });
420
+ }
421
+ if (!allowedActionPlacements(action.kind).includes(action.placement ?? defaultActionPlacement(action.kind))) {
422
+ diagnostics.push({
423
+ code: 'YACT013',
424
+ path: `actions[${index}].placement`,
425
+ severity: 'blocking',
426
+ source,
427
+ guidance: 'Use the canonical action outlet or register a separately typed action capability.',
428
+ message: `Action '${action.kind}' cannot be placed in '${action.placement}'.`,
429
+ });
430
+ }
431
+ const expectedOperation = operationForYoyoActionKind(action.kind);
432
+ // A generic 'command' carries no canonical operation, but it links the same generated action
433
+ // contract so the runtime can resolve its context before invoking the executor.
434
+ const linksActionContract = Boolean(expectedOperation) || action.kind === 'command';
435
+ const linkedContract = action.contractKey && linksActionContract && registry?.actionContracts
436
+ ? linkYoyoActionContract(registry.actionContracts, action.contractKey)
437
+ : undefined;
438
+ if (action.confirmation &&
439
+ !isYoyoMutationActionKind(action.kind) &&
440
+ !(action.kind === 'command' && linkedContract)) {
441
+ diagnostics.push({
442
+ code: 'YCON001',
443
+ path: `actions[${index}].confirmation`,
444
+ severity: 'blocking',
445
+ source,
446
+ guidance: 'Confirmation intent is only valid for datasource mutation actions.',
447
+ message: `Non-mutation action '${action.kind}' cannot declare confirmation intent.`,
448
+ });
449
+ }
450
+ if (action.confirmation?.intent === 'destructive' && expectedOperation === 'create') {
451
+ diagnostics.push({
452
+ code: 'YCON002',
453
+ path: `actions[${index}].confirmation`,
454
+ severity: 'blocking',
455
+ source,
456
+ guidance: 'A destructive confirmation intent is incompatible with a create operation/contract.',
457
+ message: `Destructive confirmation on action '${action.key}' conflicts with operation '${expectedOperation}'.`,
458
+ });
459
+ }
460
+ if (action.confirmation?.intent === 'destructive' &&
461
+ linkedContract?.operation === 'create' &&
462
+ expectedOperation !== 'create') {
463
+ diagnostics.push({
464
+ code: 'YCON004',
465
+ path: `actions[${index}].confirmation`,
466
+ severity: 'blocking',
467
+ source,
468
+ guidance: 'Use a non-create linked action contract for destructive confirmation.',
469
+ message: `Destructive confirmation on action '${action.key}' conflicts with linked contract operation 'create'.`,
470
+ });
471
+ }
472
+ // YACT001 only fires once an action contract catalog is configured (downstream phase).
473
+ // In the foundation phase no action contract catalog exists, so mutations may declare
474
+ // permission/feature literals validated by the capability catalog (YCAP001) instead.
475
+ if (isYoyoMutationActionKind(action.kind) && !action.contractKey && registry?.actionContracts) {
476
+ diagnostics.push({
477
+ code: 'YACT001',
478
+ path: `actions[${index}].contractKey`,
479
+ severity: 'blocking',
480
+ source,
481
+ guidance: 'Link every datasource mutation to a backend action contract with contractKey.',
482
+ message: `Mutation action '${action.key}' must declare contractKey.`,
483
+ });
484
+ }
485
+ // 'command' is a generic semantic kind: it carries no canonical operation, but it
486
+ // must be able to link the action contract the runtime resolves before executing.
487
+ if (!isYoyoMutationActionKind(action.kind) && action.kind !== 'command' && action.contractKey) {
488
+ diagnostics.push({
489
+ code: 'YACT007',
490
+ path: `actions[${index}].contractKey`,
491
+ severity: 'blocking',
492
+ source,
493
+ guidance: 'Only datasource mutation actions may declare contractKey.',
494
+ message: `Non-mutation action '${action.kind}' cannot declare contractKey.`,
495
+ });
496
+ }
497
+ if (action.contractKey && linksActionContract) {
498
+ const contract = linkedContract;
499
+ if (!contract) {
500
+ diagnostics.push({
501
+ code: 'YACT002',
502
+ path: `actions[${index}].contractKey`,
503
+ severity: 'blocking',
504
+ source,
505
+ guidance: 'Register the feature-scoped contractKey in the generated backend action catalog.',
506
+ message: `Action contract '${action.contractKey}' is unknown or the action catalog is unavailable.`,
507
+ });
508
+ }
509
+ else {
510
+ if (expectedOperation && contract.operation !== expectedOperation) {
511
+ diagnostics.push({
512
+ code: 'YACT003',
513
+ path: `actions[${index}].contractKey`,
514
+ severity: 'blocking',
515
+ source,
516
+ guidance: 'Use the fixed Schema kind to catalog operation compatibility mapping.',
517
+ message: `Schema action kind '${action.kind}' is incompatible with contract operation '${contract.operation}'.`,
518
+ });
519
+ }
520
+ if (action.permission || action.feature) {
521
+ diagnostics.push({
522
+ code: 'YACT004',
523
+ path: `actions[${index}]`,
524
+ severity: 'blocking',
525
+ source,
526
+ guidance: 'Read permission and feature references from the linked action contract only.',
527
+ message: 'A mutation with contractKey must not duplicate permission or feature literals in Page Schema.',
528
+ });
529
+ }
530
+ linkedActionContracts.set(action.key, contract);
531
+ const formMode = action.kind === 'create' ? 'create' : action.kind === 'edit' ? 'update' : undefined;
532
+ if (formMode) {
533
+ const validationCatalog = registry?.validationContracts;
534
+ const validation = validationCatalog && contract.validationOperationId
535
+ ? linkYoyoGeneratedValidationContract(validationCatalog, contract.validationOperationId, formMode)
536
+ : undefined;
537
+ if (validation) {
538
+ linkedValidationContracts.set(action.key, validation);
539
+ validateValidationFieldMapping(diagnostics, page, validation, formMode, source);
540
+ if (!registry?.validationContractExecutables?.includes(validation.contractKey)) {
541
+ diagnostics.push({
542
+ code: 'YVAL006',
543
+ path: `actions[${index}].contractKey`,
544
+ severity: 'blocking',
545
+ source,
546
+ guidance: 'Regenerate and register the executable validator that matches this validation contract key.',
547
+ message: `Generated validation contract '${validation.contractKey}' has no executable validator.`,
548
+ });
549
+ }
550
+ }
551
+ else {
552
+ diagnostics.push({
553
+ code: 'YVAL003',
554
+ path: `actions[${index}].contractKey`,
555
+ severity: 'blocking',
556
+ source,
557
+ guidance: 'Regenerate matching backend action and OpenAPI validation contracts for this create/update action.',
558
+ message: `Action contract '${contract.actionKey}' has no generated validation branch for mode '${formMode}'.`,
559
+ });
560
+ }
561
+ }
562
+ }
563
+ }
564
+ validateActionConfirmation(diagnostics, page, action, index, source);
565
+ if (!action.contractKey &&
566
+ action.permission &&
567
+ registry?.permissions &&
568
+ !registry.permissions.includes(action.permission)) {
569
+ diagnostics.push({
570
+ code: 'YCAP001',
571
+ path: `actions[${index}].permission`,
572
+ severity: 'blocking',
573
+ source,
574
+ guidance: 'Use permission keys produced by capability catalog.',
575
+ message: `Permission '${action.permission}' is not present in the capability catalog.`,
576
+ });
577
+ }
578
+ if (!action.contractKey &&
579
+ action.feature &&
580
+ registry?.features &&
581
+ !registry.features.includes(action.feature)) {
582
+ diagnostics.push({
583
+ code: 'YCAP001',
584
+ path: `actions[${index}].feature`,
585
+ severity: 'blocking',
586
+ source,
587
+ guidance: 'Use feature keys produced by capability catalog.',
588
+ message: `Feature '${action.feature}' is not present in the capability catalog.`,
589
+ });
590
+ }
591
+ }
592
+ validateQueryPresentation(diagnostics, page, source);
593
+ for (const [index, extension] of extensions.entries()) {
594
+ if (!isYoyoExtensionSlot(extension.slot)) {
595
+ diagnostics.push({
596
+ code: 'YREG001',
597
+ path: `extensions[${index}].slot`,
598
+ severity: 'blocking',
599
+ source,
600
+ guidance: 'Use allowed extension slot values only.',
601
+ message: `Extension slot '${extension.slot}' is not registered in Schema v1.`,
602
+ });
603
+ }
604
+ if (extension.overlay !== undefined &&
605
+ extension.slot !== 'editor' &&
606
+ extension.slot !== 'row-overlay') {
607
+ diagnostics.push({
608
+ code: 'YREG001',
609
+ path: `extensions[${index}].overlay`,
610
+ severity: 'blocking',
611
+ source,
612
+ guidance: 'Only editor and row-overlay extensions may select an overlay.',
613
+ message: `Extension '${extension.key}' cannot declare an overlay for slot '${extension.slot}'.`,
614
+ });
615
+ }
616
+ if (extension.key && extension.key.length > 0) {
617
+ const definition = registry?.extensions?.find((candidate) => candidate.key === extension.key);
618
+ if (!definition) {
619
+ diagnostics.push({
620
+ code: 'YREG001',
621
+ path: `extensions[${index}].key`,
622
+ severity: 'blocking',
623
+ source,
624
+ guidance: 'Use extension keys from the extension registry.',
625
+ message: `Extension '${extension.key}' is not registered.`,
626
+ });
627
+ }
628
+ else if (definition.slot !== extension.slot) {
629
+ diagnostics.push({
630
+ code: 'YREG001',
631
+ path: `extensions[${index}].slot`,
632
+ severity: 'blocking',
633
+ source,
634
+ guidance: 'Use the slot declared by the extension registry.',
635
+ message: `Extension '${extension.key}' is registered for slot '${definition.slot}', not '${extension.slot}'.`,
636
+ });
637
+ }
638
+ }
639
+ }
640
+ for (const [index, action] of actions.entries()) {
641
+ if (!action.rowOverlay)
642
+ continue;
643
+ if (action.placement !== 'row') {
644
+ diagnostics.push({
645
+ code: 'YACT017',
646
+ path: `actions[${index}].rowOverlay`,
647
+ severity: 'blocking',
648
+ source,
649
+ guidance: "Declare placement 'row' for an action that opens a row-overlay extension.",
650
+ message: `Action '${action.key}' references row-overlay extension '${action.rowOverlay}' but its placement is '${action.placement ?? defaultActionPlacement(action.kind)}'.`,
651
+ });
652
+ }
653
+ if (!extensions.some((extension) => extension.key === action.rowOverlay && extension.slot === 'row-overlay')) {
654
+ diagnostics.push({
655
+ code: 'YACT017',
656
+ path: `actions[${index}].rowOverlay`,
657
+ severity: 'blocking',
658
+ source,
659
+ guidance: "Declare the extension key in page.extensions with slot 'row-overlay'.",
660
+ message: `Action '${action.key}' references row-overlay extension '${action.rowOverlay}' which is not declared with slot 'row-overlay'.`,
661
+ });
662
+ }
663
+ }
664
+ const defaultValues = page.query.defaultValues;
665
+ if (defaultValues) {
666
+ const availableDefaults = new Set([
667
+ ...(page.query.fields?.map((field) => field.key) ?? []),
668
+ ...(page.form?.fields?.map((field) => field.key) ?? []),
669
+ ]);
670
+ const ghostDefaultValues = Object.keys(defaultValues).filter((name) => !availableDefaults.has(name));
671
+ if (ghostDefaultValues.length > 0) {
672
+ diagnostics.push({
673
+ code: 'YSCH007',
674
+ path: 'query.defaultValues',
675
+ severity: 'blocking',
676
+ source,
677
+ guidance: 'Only declare defaults for declared field keys.',
678
+ message: `Unknown default keys: ${ghostDefaultValues.join(', ')}.`,
679
+ });
680
+ }
681
+ }
682
+ if (diagnostics.length > 0) {
683
+ throw new YoyoSchemaValidationError(diagnostics);
684
+ }
685
+ return {
686
+ ir: normalizePage(page, source, linkedActionContracts, linkedValidationContracts),
687
+ diagnostics: [],
688
+ usageReport: createUsageReport(page, linkedActionContracts),
689
+ };
690
+ }
691
+ function requireCompileContext(pageKey, options) {
692
+ const context = options.context;
693
+ if (!context) {
694
+ throw new YoyoSchemaValidationError([
695
+ {
696
+ code: 'YSCH009',
697
+ path: 'mode',
698
+ severity: 'blocking',
699
+ message: `Project compile for '${pageKey}' requires compile context.`,
700
+ guidance: 'Provide mode: "project" together with context.source/context.manifest/context.registry.',
701
+ },
702
+ ]);
703
+ }
704
+ const missing = [];
705
+ if (!context.manifest ||
706
+ !context.manifest.schemaVersion ||
707
+ !context.manifest.generatorVersion ||
708
+ !context.manifest.sourceHash ||
709
+ !context.manifest.generatedBy ||
710
+ !context.manifest.generatedCommand) {
711
+ missing.push('context.manifest');
712
+ }
713
+ if (!context.registry) {
714
+ missing.push('context.registry');
715
+ }
716
+ if (!context.source?.path ||
717
+ !Number.isInteger(context.source.line) ||
718
+ !Number.isInteger(context.source.column)) {
719
+ missing.push('context.source');
720
+ }
721
+ if (missing.length > 0) {
722
+ throw new YoyoSchemaValidationError([
723
+ {
724
+ code: 'YSCH009',
725
+ path: missing[0],
726
+ severity: 'blocking',
727
+ message: 'Project compile context is incomplete.',
728
+ guidance: `Provide complete compile context before project checks: ${missing.join(', ')}.`,
729
+ },
730
+ ]);
731
+ }
732
+ return context;
733
+ }
734
+ export function isPageKindAllowed(value) {
735
+ return YOYO_PAGE_KINDS.includes(value);
736
+ }
737
+ function validateField(diagnostics, field, path, registry, source) {
738
+ if (!yoyoV1FieldRegistry.has(field.kind) && !registry?.fields?.includes(field.kind)) {
739
+ diagnostics.push({
740
+ code: 'YREG001',
741
+ path: `${path}.kind`,
742
+ severity: 'blocking',
743
+ ...diagnosticSource(source),
744
+ guidance: 'Use field keys from field registry.',
745
+ message: `Field '${field.kind}' is not registered in Schema v1.`,
746
+ });
747
+ }
748
+ }
749
+ function validateQueryFilterContract(diagnostics, field, path, registry, source) {
750
+ const unknownOperator = field.filter?.operators?.find((operator) => !YOYO_FILTER_OPERATORS.includes(operator));
751
+ if (unknownOperator) {
752
+ diagnostics.push({
753
+ code: 'YFIL006',
754
+ path: `${path}.filter.operators`,
755
+ severity: 'blocking',
756
+ ...diagnosticSource(source),
757
+ guidance: 'Use a controlled operator from YOYO_FILTER_OPERATORS.',
758
+ message: `Query filter '${field.key}' declares unsupported operator '${unknownOperator}'.`,
759
+ });
760
+ }
761
+ const requiresStaticOptions = field.kind === 'select' || field.kind === 'multi-select';
762
+ const requiresOptionContract = requiresStaticOptions || field.kind === 'remote-select' || field.kind === 'tree-select';
763
+ if (requiresStaticOptions && !field.options?.length) {
764
+ diagnostics.push({
765
+ code: 'YFIL001',
766
+ path,
767
+ severity: 'blocking',
768
+ ...diagnosticSource(source),
769
+ guidance: `Declare non-empty options for query filter kind '${field.kind}'.`,
770
+ message: `Query filter '${field.key}' is missing its option contract.`,
771
+ });
772
+ }
773
+ if ((field.kind === 'remote-select' || field.kind === 'tree-select') &&
774
+ !field.options?.length &&
775
+ !field.optionSource) {
776
+ diagnostics.push({
777
+ code: 'YFIL001',
778
+ path,
779
+ severity: 'blocking',
780
+ ...diagnosticSource(source),
781
+ guidance: `Declare options or a typed optionSource for query filter kind '${field.kind}'.`,
782
+ message: `Query filter '${field.key}' is missing its option contract.`,
783
+ });
784
+ }
785
+ if (field.optionSource &&
786
+ registry &&
787
+ !registry.filterOptionSources?.includes(field.optionSource)) {
788
+ diagnostics.push({
789
+ code: 'YREG001',
790
+ path: `${path}.optionSource`,
791
+ severity: 'blocking',
792
+ ...diagnosticSource(source),
793
+ guidance: 'Use an optionSource from the filter option source registry.',
794
+ message: `Filter option source '${field.optionSource}' is not registered.`,
795
+ });
796
+ }
797
+ if (!requiresOptionContract && (field.options?.length || field.optionSource)) {
798
+ diagnostics.push({
799
+ code: 'YFIL003',
800
+ path,
801
+ severity: 'blocking',
802
+ ...diagnosticSource(source),
803
+ guidance: 'Remove options/optionSource or use an option-based query filter kind.',
804
+ message: `Query filter '${field.key}' cannot declare an option contract for kind '${field.kind}'.`,
805
+ });
806
+ }
807
+ const optionValues = flattenOptionValues(field.options ?? []);
808
+ const duplicate = optionValues.find((value, index) => optionValues.indexOf(value) !== index);
809
+ if (duplicate) {
810
+ diagnostics.push({
811
+ code: 'YFIL002',
812
+ path,
813
+ severity: 'blocking',
814
+ ...diagnosticSource(source),
815
+ guidance: 'Use one unique option value across the entire query filter option tree.',
816
+ message: `Query filter '${field.key}' declares duplicate option value '${duplicate}'.`,
817
+ });
818
+ }
819
+ }
820
+ function flattenOptionValues(options) {
821
+ return options.flatMap((option) => [option.value, ...flattenOptionValues(option.children ?? [])]);
822
+ }
823
+ function diagnosticSource(source) {
824
+ return source === undefined ? {} : { source };
825
+ }
826
+ function validateFieldLifecycle(diagnostics, field, path, source) {
827
+ const lifecycle = field.lifecycle;
828
+ if (!lifecycle)
829
+ return;
830
+ const editableOn = lifecycle.editableOn ?? 'both';
831
+ const editableAtCreate = editableOn === 'both' || editableOn === 'create';
832
+ const editableAtEdit = editableOn === 'both' || editableOn === 'edit';
833
+ // A create default is useless when the field is never editable at create (YDL001).
834
+ if (lifecycle.createDefault !== undefined && !editableAtCreate) {
835
+ diagnostics.push({
836
+ code: 'YDL001',
837
+ path: `${path}.lifecycle`,
838
+ severity: 'blocking',
839
+ ...diagnosticSource(source),
840
+ guidance: 'Remove createDefault or set lifecycle.editableOn to include create.',
841
+ message: `Field '${field.key}' declares createDefault but is not editable at create.`,
842
+ });
843
+ }
844
+ // An edit default is useless when the field is never editable at edit (YDL001).
845
+ if (lifecycle.editDefault !== undefined && !editableAtEdit) {
846
+ diagnostics.push({
847
+ code: 'YDL001',
848
+ path: `${path}.lifecycle`,
849
+ severity: 'blocking',
850
+ ...diagnosticSource(source),
851
+ guidance: 'Remove editDefault or set lifecycle.editableOn to include edit.',
852
+ message: `Field '${field.key}' declares editDefault but is not editable at edit.`,
853
+ });
854
+ }
855
+ // A required field that is never editable can never receive a value (YDL001).
856
+ if (field.required === true && editableOn === 'never') {
857
+ diagnostics.push({
858
+ code: 'YDL001',
859
+ path: `${path}.lifecycle`,
860
+ severity: 'blocking',
861
+ ...diagnosticSource(source),
862
+ guidance: 'A required field must be editable in at least one phase.',
863
+ message: `Field '${field.key}' is required but lifecycle.editableOn is 'never'.`,
864
+ });
865
+ }
866
+ }
867
+ const RESERVED_URL_PARAMS = ['page', 'pageSize', 'sort'];
868
+ function validateQueryPresentation(diagnostics, page, source) {
869
+ const presentation = page.query.presentation;
870
+ if (!presentation)
871
+ return;
872
+ const sortableColumns = page.table?.columns.filter((column) => column.sortable).map((column) => column.key) ?? [];
873
+ if (presentation.defaultSort && !sortableColumns.includes(presentation.defaultSort.key)) {
874
+ diagnostics.push({
875
+ code: 'YURL001',
876
+ path: 'query.presentation.defaultSort.key',
877
+ severity: 'blocking',
878
+ ...diagnosticSource(source),
879
+ guidance: 'defaultSort.key must reference a column declared with sortable: true.',
880
+ message: `Query presentation defaultSort references undeclared sort key '${presentation.defaultSort.key}'.`,
881
+ });
882
+ }
883
+ if (presentation.allowedUrlParams) {
884
+ const duplicateParams = presentation.allowedUrlParams.filter((param, index) => presentation.allowedUrlParams.indexOf(param) !== index);
885
+ if (duplicateParams.length > 0) {
886
+ diagnostics.push({
887
+ code: 'YURL002',
888
+ path: 'query.presentation.allowedUrlParams',
889
+ severity: 'blocking',
890
+ ...diagnosticSource(source),
891
+ guidance: 'Declare each URL parameter at most once.',
892
+ message: `Query presentation contains duplicate URL parameters: ${[...new Set(duplicateParams)].join(', ')}.`,
893
+ });
894
+ }
895
+ const declaredFilterKeys = new Set([
896
+ ...(page.query.fields ?? []).map((field) => field.key),
897
+ ...RESERVED_URL_PARAMS,
898
+ ]);
899
+ const undeclared = presentation.allowedUrlParams.filter((param) => !declaredFilterKeys.has(param));
900
+ if (undeclared.length > 0) {
901
+ diagnostics.push({
902
+ code: 'YURL001',
903
+ path: 'query.presentation.allowedUrlParams',
904
+ severity: 'blocking',
905
+ ...diagnosticSource(source),
906
+ guidance: 'allowedUrlParams must be a subset of declared filter field keys plus page/pageSize/sort.',
907
+ message: `Query presentation declares URL parameters that are not in the query contract: ${undeclared.join(', ')}.`,
908
+ });
909
+ }
910
+ }
911
+ }
912
+ function validateActionConfirmation(diagnostics, page, action, index, source) {
913
+ const subjectFields = action.confirmation?.subjectFields;
914
+ if (!subjectFields)
915
+ return;
916
+ const declaredFields = new Set([
917
+ ...(page.query.fields ?? []).map((field) => field.key),
918
+ ...(page.form?.fields ?? []).map((field) => field.key),
919
+ ...(page.table?.columns ?? []).map((column) => column.key),
920
+ ]);
921
+ const unknownFields = subjectFields.filter((field) => !declaredFields.has(field));
922
+ const duplicateFields = subjectFields.filter((field, fieldIndex) => subjectFields.indexOf(field) !== fieldIndex);
923
+ if (unknownFields.length > 0 || duplicateFields.length > 0) {
924
+ diagnostics.push({
925
+ code: 'YCON003',
926
+ path: `actions[${index}].confirmation.subjectFields`,
927
+ severity: 'blocking',
928
+ ...diagnosticSource(source),
929
+ guidance: 'Use unique field keys declared by the query, table, or form contract.',
930
+ message: [
931
+ unknownFields.length > 0
932
+ ? `Unknown subject fields: ${[...new Set(unknownFields)].join(', ')}.`
933
+ : '',
934
+ duplicateFields.length > 0
935
+ ? `Duplicate subject fields: ${[...new Set(duplicateFields)].join(', ')}.`
936
+ : '',
937
+ ]
938
+ .filter(Boolean)
939
+ .join(' '),
940
+ });
941
+ }
942
+ }
943
+ function createUsageReport(page, linkedActionContracts) {
944
+ const fields = [...(page.query.fields ?? []), ...(page.form?.fields ?? [])];
945
+ const permissions = page.actions?.flatMap((action) => {
946
+ const linked = linkedActionContracts.get(action.key);
947
+ return linked?.permission ? [linked.permission] : action.permission ? [action.permission] : [];
948
+ }) ?? [];
949
+ const features = page.actions?.flatMap((action) => {
950
+ const linked = linkedActionContracts.get(action.key);
951
+ return linked?.feature ? [linked.feature] : action.feature ? [action.feature] : [];
952
+ }) ?? [];
953
+ return {
954
+ pageKey: page.pageKey,
955
+ datasources: [page.query.datasource],
956
+ permissions: unique(permissions),
957
+ features: unique(features),
958
+ fields: unique(fields.map((field) => field.kind)),
959
+ renderers: unique(page.table?.columns.map((column) => column.renderer ?? 'text') ?? []),
960
+ actions: unique(page.actions?.map((action) => action.kind) ?? []),
961
+ filterOptionSources: unique(fields.flatMap((field) => (field.optionSource ? [field.optionSource] : []))),
962
+ };
963
+ }
964
+ export function diffYoyoPageIR(before, after) {
965
+ const beforeValues = flattenValues(before);
966
+ const afterValues = flattenValues(after);
967
+ const paths = [...new Set([...beforeValues.keys(), ...afterValues.keys()])].sort();
968
+ const changes = [];
969
+ for (const path of paths) {
970
+ const hasBefore = beforeValues.has(path);
971
+ const hasAfter = afterValues.has(path);
972
+ if (!hasBefore) {
973
+ changes.push({ kind: 'added', path, after: afterValues.get(path) });
974
+ continue;
975
+ }
976
+ if (!hasAfter) {
977
+ changes.push({ kind: 'removed', path, before: beforeValues.get(path) });
978
+ continue;
979
+ }
980
+ if (!Object.is(beforeValues.get(path), afterValues.get(path))) {
981
+ changes.push({
982
+ kind: 'changed',
983
+ path,
984
+ before: beforeValues.get(path),
985
+ after: afterValues.get(path),
986
+ });
987
+ }
988
+ }
989
+ return changes;
990
+ }
991
+ export function formatYoyoSchemaDiff(entries) {
992
+ return entries
993
+ .map((entry) => {
994
+ if (entry.kind === 'added') {
995
+ return `+ ${entry.path}: ${String(entry.after)}`;
996
+ }
997
+ if (entry.kind === 'removed') {
998
+ return `- ${entry.path}: ${String(entry.before)}`;
999
+ }
1000
+ return `~ ${entry.path}: ${String(entry.before)} -> ${String(entry.after)}`;
1001
+ })
1002
+ .join('\n');
1003
+ }
1004
+ function flattenValues(value, prefix = '', result = new Map()) {
1005
+ if (value === null || typeof value !== 'object') {
1006
+ result.set(prefix || '$', value);
1007
+ return result;
1008
+ }
1009
+ for (const [key, child] of Object.entries(value)) {
1010
+ flattenValues(child, prefix ? `${prefix}.${key}` : key, result);
1011
+ }
1012
+ return result;
1013
+ }
1014
+ function unique(values) {
1015
+ return [...new Set(values)];
1016
+ }
1017
+ function addDuplicateDiagnostics(diagnostics, items, path, source, code = 'YSCH004') {
1018
+ const seen = new Set();
1019
+ for (const [index, item] of items.entries()) {
1020
+ if (seen.has(item.key)) {
1021
+ diagnostics.push({
1022
+ code,
1023
+ path: `${path}[${index}].key`,
1024
+ severity: 'blocking',
1025
+ ...diagnosticSource(source),
1026
+ guidance: 'Ensure key uniqueness within the same declaration block.',
1027
+ message: `Duplicate key '${item.key}'.`,
1028
+ });
1029
+ }
1030
+ seen.add(item.key);
1031
+ }
1032
+ }
1033
+ function normalizePage(page, source, linkedActionContracts = new Map(), linkedValidationContracts = new Map()) {
1034
+ return {
1035
+ schemaVersion: 1,
1036
+ pageKind: page.pageKind,
1037
+ pageKey: page.pageKey,
1038
+ title: page.title,
1039
+ query: {
1040
+ ...page.query,
1041
+ fields: page.query.fields ?? [],
1042
+ },
1043
+ ...(page.table
1044
+ ? {
1045
+ table: {
1046
+ rowKey: page.table.rowKey,
1047
+ columns: page.table.columns.map((column, index) => normalizeColumn(column, page.table.rowKey, index)),
1048
+ ...(page.table.grouping
1049
+ ? {
1050
+ grouping: {
1051
+ mode: page.table.grouping.mode,
1052
+ columnKey: page.table.grouping.columnKey,
1053
+ ...(page.table.grouping.defaultExpanded === undefined
1054
+ ? {}
1055
+ : { defaultExpanded: page.table.grouping.defaultExpanded }),
1056
+ },
1057
+ }
1058
+ : {}),
1059
+ ...(page.table.virtualization
1060
+ ? {
1061
+ virtualization: {
1062
+ mode: page.table.virtualization.mode,
1063
+ },
1064
+ }
1065
+ : {}),
1066
+ },
1067
+ }
1068
+ : {}),
1069
+ ...(page.form ? { form: { ...page.form } } : {}),
1070
+ actions: (page.actions ?? []).map((action) => {
1071
+ const contract = linkedActionContracts.get(action.key);
1072
+ const validation = linkedValidationContracts.get(action.key);
1073
+ return {
1074
+ ...action,
1075
+ placement: action.placement ?? defaultActionPlacement(action.kind),
1076
+ ...(contract ? { contract } : {}),
1077
+ ...(validation ? { validation } : {}),
1078
+ };
1079
+ }),
1080
+ extensions: page.extensions ?? [],
1081
+ source,
1082
+ };
1083
+ }
1084
+ function defaultActionPlacement(kind) {
1085
+ switch (kind) {
1086
+ case 'edit':
1087
+ case 'disable':
1088
+ case 'delete':
1089
+ return 'row';
1090
+ case 'replaceRoles':
1091
+ case 'addChild':
1092
+ case 'updateChild':
1093
+ case 'removeChild':
1094
+ case 'command':
1095
+ return 'form';
1096
+ default:
1097
+ return 'page';
1098
+ }
1099
+ }
1100
+ function allowedActionPlacements(kind) {
1101
+ switch (kind) {
1102
+ case 'delete':
1103
+ case 'disable':
1104
+ return ['row', 'bulk'];
1105
+ case 'edit':
1106
+ return ['row'];
1107
+ case 'replaceRoles':
1108
+ case 'addChild':
1109
+ case 'updateChild':
1110
+ case 'removeChild':
1111
+ return ['form'];
1112
+ case 'command':
1113
+ return ['page', 'row', 'bulk', 'form'];
1114
+ default:
1115
+ return ['page'];
1116
+ }
1117
+ }
1118
+ function normalizeColumn(column, rowKey, index) {
1119
+ const role = column.role ??
1120
+ (column.key === rowKey || index === 0
1121
+ ? 'identity'
1122
+ : column.renderer === 'booleanBadge'
1123
+ ? 'status'
1124
+ : 'meta');
1125
+ const priority = column.priority ??
1126
+ (role === 'identity' || role === 'key' || role === 'status' || role === 'action'
1127
+ ? 'high'
1128
+ : 'medium');
1129
+ const mobilePlacement = column.mobilePlacement ??
1130
+ (role === 'identity' || role === 'key'
1131
+ ? 'headline'
1132
+ : role === 'status' || role === 'media' || role === 'action'
1133
+ ? 'supporting'
1134
+ : 'details');
1135
+ const responsiveProvenance = {
1136
+ role: column.role === undefined ? 'defaulted' : 'declared',
1137
+ priority: column.priority === undefined ? 'defaulted' : 'declared',
1138
+ mobilePlacement: column.mobilePlacement === undefined ? 'defaulted' : 'declared',
1139
+ ...(column.recordRendererKey === undefined ? {} : { recordRendererKey: 'declared' }),
1140
+ };
1141
+ return {
1142
+ ...column,
1143
+ renderer: column.renderer ?? 'text',
1144
+ sortable: column.sortable ?? false,
1145
+ role,
1146
+ priority,
1147
+ mobilePlacement,
1148
+ responsiveProvenance,
1149
+ };
1150
+ }
1151
+ function resolveRendererDefinition(key, registry) {
1152
+ if (yoyoV1RendererRegistry.has(key))
1153
+ return yoyoV1RendererRegistry.resolve(key);
1154
+ const external = registry?.rendererDefinitions?.find((definition) => definition.key === key);
1155
+ if (external)
1156
+ return external;
1157
+ if (!registry?.renderers?.includes(key))
1158
+ return undefined;
1159
+ return {
1160
+ key,
1161
+ inputKind: 'cell-value',
1162
+ presentations: ['grid', 'priority-table', 'record-list'],
1163
+ slots: ['cell'],
1164
+ };
1165
+ }
1166
+ function validateResponsiveColumns(diagnostics, columns, source) {
1167
+ if (columns.length === 0)
1168
+ return;
1169
+ const reachableIdentity = columns.filter((column) => (column.role === 'identity' || column.role === 'key') && column.mobilePlacement !== 'hidden');
1170
+ if (reachableIdentity.length === 0) {
1171
+ diagnostics.push({
1172
+ code: 'YGRID001',
1173
+ path: 'table.columns',
1174
+ severity: 'blocking',
1175
+ source,
1176
+ guidance: 'Declare at least one identity or key column with a reachable mobile placement.',
1177
+ message: 'A record-list requires a reachable identity or key column.',
1178
+ });
1179
+ }
1180
+ const identityIndexes = columns.flatMap((column, index) => column.role === 'identity' ? [index] : []);
1181
+ for (const index of identityIndexes.slice(1)) {
1182
+ diagnostics.push({
1183
+ code: 'YGRID002',
1184
+ path: `table.columns[${index}].role`,
1185
+ severity: 'blocking',
1186
+ source,
1187
+ guidance: 'Use one identity column and mark additional identifying data as key.',
1188
+ message: 'Only one identity column may define the record headline.',
1189
+ });
1190
+ }
1191
+ const actionIndexes = columns.flatMap((column, index) => column.role === 'action' ? [index] : []);
1192
+ for (const index of actionIndexes.slice(1)) {
1193
+ diagnostics.push({
1194
+ code: 'YGRID003',
1195
+ path: `table.columns[${index}].role`,
1196
+ severity: 'blocking',
1197
+ source,
1198
+ guidance: 'Expose one action surface and group additional row operations behind it.',
1199
+ message: 'Only one action column may define the record-list action surface.',
1200
+ });
1201
+ }
1202
+ const allowedPlacements = {
1203
+ identity: ['headline'],
1204
+ key: ['headline', 'supporting'],
1205
+ status: ['supporting', 'details'],
1206
+ meta: ['supporting', 'details', 'hidden'],
1207
+ media: ['supporting', 'details', 'hidden'],
1208
+ action: ['supporting'],
1209
+ };
1210
+ for (const [index, column] of columns.entries()) {
1211
+ if (!allowedPlacements[column.role].includes(column.mobilePlacement)) {
1212
+ diagnostics.push({
1213
+ code: 'YGRID004',
1214
+ path: `table.columns[${index}].mobilePlacement`,
1215
+ severity: 'blocking',
1216
+ source,
1217
+ guidance: `Use a supported mobile placement for the '${column.role}' role.`,
1218
+ message: `Role '${column.role}' cannot use mobile placement '${column.mobilePlacement}'.`,
1219
+ });
1220
+ }
1221
+ if ((column.role === 'identity' || column.role === 'key' || column.role === 'action') &&
1222
+ column.priority === 'low') {
1223
+ diagnostics.push({
1224
+ code: 'YGRID005',
1225
+ path: `table.columns[${index}].priority`,
1226
+ severity: 'blocking',
1227
+ source,
1228
+ guidance: `Use high or medium priority for the '${column.role}' role.`,
1229
+ message: `Role '${column.role}' cannot be assigned low priority.`,
1230
+ });
1231
+ }
1232
+ }
1233
+ }
1234
+ function validateValidationFieldMapping(diagnostics, page, validation, formMode, source) {
1235
+ const schemaFields = new Set((page.form?.fields ?? [])
1236
+ .filter((field) => isSubmittedInFormMode(field, formMode))
1237
+ .map((field) => field.key));
1238
+ const runtimeSuppliedFields = new Set(['id', 'concurrencyToken', 'concurrencyStamp']);
1239
+ const generatedFields = new Set(validation.fieldPaths.filter((fieldPath) => !(formMode === 'update' && runtimeSuppliedFields.has(fieldPath))));
1240
+ const fixedFields = Object.keys(page.form?.request?.[formMode]?.fixedValues ?? {});
1241
+ const overlap = fixedFields.some((field) => schemaFields.has(field));
1242
+ const suppliedFields = new Set([...schemaFields, ...fixedFields]);
1243
+ if (!overlap && sameStringSet(suppliedFields, generatedFields))
1244
+ return;
1245
+ diagnostics.push({
1246
+ code: 'YVAL005',
1247
+ path: 'form.fields',
1248
+ severity: 'blocking',
1249
+ source,
1250
+ guidance: 'Editable and fixed request fields must be disjoint and exactly cover generated validation fields; runtime entity identity is supplied separately for update.',
1251
+ message: `Form fields for '${formMode}' do not match generated validation fields for '${validation.contractKey}'.`,
1252
+ });
1253
+ }
1254
+ function isEditableInFormMode(field, formMode) {
1255
+ const editableOn = field.lifecycle?.editableOn ?? 'both';
1256
+ const lifecycleMode = formMode === 'update' ? 'edit' : formMode;
1257
+ return editableOn === 'both' || editableOn === lifecycleMode;
1258
+ }
1259
+ function isSubmittedInFormMode(field, formMode) {
1260
+ return (isEditableInFormMode(field, formMode) ||
1261
+ (formMode === 'update' && field.lifecycle?.submitOnEdit === true));
1262
+ }
1263
+ function sameStringSet(left, right) {
1264
+ return left.size === right.size && [...left].every((value) => right.has(value));
1265
+ }
1266
+ function toSourceLocation(pageKey, context) {
1267
+ if (!context) {
1268
+ return { kind: 'typescript', pageKey };
1269
+ }
1270
+ return {
1271
+ kind: 'typescript',
1272
+ pageKey,
1273
+ path: context.source.path,
1274
+ line: context.source.line,
1275
+ column: context.source.column,
1276
+ };
1277
+ }
1278
+ function isBareUrl(value) {
1279
+ const trimmed = value.trim();
1280
+ return (/^(https?:\/\/)/i.test(trimmed) ||
1281
+ trimmed.startsWith('/') ||
1282
+ trimmed.startsWith('./') ||
1283
+ trimmed.startsWith('../') ||
1284
+ trimmed.includes('/'));
1285
+ }
1286
+ function isYoyoExtensionSlot(value) {
1287
+ return YOYO_EXTENSION_SLOTS.includes(value);
1288
+ }
1289
+ function formatPath(path) {
1290
+ let result = '';
1291
+ for (const segment of path) {
1292
+ if (typeof segment === 'number') {
1293
+ result += `[${segment}]`;
1294
+ continue;
1295
+ }
1296
+ const name = String(segment);
1297
+ result += result ? `.${name}` : name;
1298
+ }
1299
+ return result || '$';
1300
+ }