@loj-lang/rdsl-compiler 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +23 -0
  2. package/dist/cache-signature.d.ts +2 -0
  3. package/dist/cache-signature.d.ts.map +1 -0
  4. package/dist/cache-signature.js +17 -0
  5. package/dist/cache-signature.js.map +1 -0
  6. package/dist/codegen.d.ts +37 -0
  7. package/dist/codegen.d.ts.map +1 -0
  8. package/dist/codegen.js +6394 -0
  9. package/dist/codegen.js.map +1 -0
  10. package/dist/dependency-graph.d.ts +23 -0
  11. package/dist/dependency-graph.d.ts.map +1 -0
  12. package/dist/dependency-graph.js +516 -0
  13. package/dist/dependency-graph.js.map +1 -0
  14. package/dist/expr.d.ts +24 -0
  15. package/dist/expr.d.ts.map +1 -0
  16. package/dist/expr.js +359 -0
  17. package/dist/expr.js.map +1 -0
  18. package/dist/flow-proof.d.ts +68 -0
  19. package/dist/flow-proof.d.ts.map +1 -0
  20. package/dist/flow-proof.js +487 -0
  21. package/dist/flow-proof.js.map +1 -0
  22. package/dist/host-files.d.ts +27 -0
  23. package/dist/host-files.d.ts.map +1 -0
  24. package/dist/host-files.js +441 -0
  25. package/dist/host-files.js.map +1 -0
  26. package/dist/index.d.ts +120 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +948 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/ir.d.ts +451 -0
  31. package/dist/ir.d.ts.map +1 -0
  32. package/dist/ir.js +13 -0
  33. package/dist/ir.js.map +1 -0
  34. package/dist/manifest.d.ts +104 -0
  35. package/dist/manifest.d.ts.map +1 -0
  36. package/dist/manifest.js +635 -0
  37. package/dist/manifest.js.map +1 -0
  38. package/dist/node-inspect.d.ts +23 -0
  39. package/dist/node-inspect.d.ts.map +1 -0
  40. package/dist/node-inspect.js +475 -0
  41. package/dist/node-inspect.js.map +1 -0
  42. package/dist/normalize.d.ts +101 -0
  43. package/dist/normalize.d.ts.map +1 -0
  44. package/dist/normalize.js +1771 -0
  45. package/dist/normalize.js.map +1 -0
  46. package/dist/page-table-block.d.ts +69 -0
  47. package/dist/page-table-block.d.ts.map +1 -0
  48. package/dist/page-table-block.js +241 -0
  49. package/dist/page-table-block.js.map +1 -0
  50. package/dist/parser.d.ts +262 -0
  51. package/dist/parser.d.ts.map +1 -0
  52. package/dist/parser.js +1335 -0
  53. package/dist/parser.js.map +1 -0
  54. package/dist/project-paths.d.ts +6 -0
  55. package/dist/project-paths.d.ts.map +1 -0
  56. package/dist/project-paths.js +84 -0
  57. package/dist/project-paths.js.map +1 -0
  58. package/dist/relation-projection.d.ts +60 -0
  59. package/dist/relation-projection.d.ts.map +1 -0
  60. package/dist/relation-projection.js +121 -0
  61. package/dist/relation-projection.js.map +1 -0
  62. package/dist/rules-proof.d.ts +95 -0
  63. package/dist/rules-proof.d.ts.map +1 -0
  64. package/dist/rules-proof.js +537 -0
  65. package/dist/rules-proof.js.map +1 -0
  66. package/dist/source-files.d.ts +9 -0
  67. package/dist/source-files.d.ts.map +1 -0
  68. package/dist/source-files.js +27 -0
  69. package/dist/source-files.js.map +1 -0
  70. package/dist/style-proof.d.ts +70 -0
  71. package/dist/style-proof.d.ts.map +1 -0
  72. package/dist/style-proof.js +640 -0
  73. package/dist/style-proof.js.map +1 -0
  74. package/dist/validator.d.ts +51 -0
  75. package/dist/validator.d.ts.map +1 -0
  76. package/dist/validator.js +2487 -0
  77. package/dist/validator.js.map +1 -0
  78. package/package.json +40 -0
@@ -0,0 +1,2487 @@
1
+ /**
2
+ * ReactDSL Validator
3
+ *
4
+ * Performs semantic validation on the IR:
5
+ * - Model references exist
6
+ * - Column fields exist in referenced model
7
+ * - Filter fields exist in referenced model
8
+ * - Form fields exist in referenced model
9
+ * - Navigation targets point to valid resources/pages
10
+ * - Effect targets reference known resources
11
+ * - Required fields are present
12
+ */
13
+ import { analyzePageBlockData } from './page-table-block.js';
14
+ import { analyzeRelationProjection } from './relation-projection.js';
15
+ const ROUTE_PATH_SEGMENT_PATTERN = /^(?:[A-Za-z0-9_-]+|:[A-Za-z_][A-Za-z0-9_]*)$/;
16
+ const VALIDATION_CACHE_VERSION = '0.1.13';
17
+ export function validate(ir, options) {
18
+ const errors = [];
19
+ const previousCache = options?.cache?.version === VALIDATION_CACHE_VERSION
20
+ ? options.cache
21
+ : undefined;
22
+ // Build lookup maps
23
+ const modelMap = new Map();
24
+ for (const model of ir.models) {
25
+ modelMap.set(model.name, model);
26
+ }
27
+ const resourceMap = new Map();
28
+ for (const resource of ir.resources) {
29
+ resourceMap.set(resource.name, resource);
30
+ }
31
+ const readModelMap = new Map();
32
+ for (const readModel of ir.readModels) {
33
+ readModelMap.set(readModel.name, readModel);
34
+ }
35
+ const modelNames = ir.models.map((model) => model.name);
36
+ const resourceNames = ir.resources.map((resource) => resource.name);
37
+ const readModelNames = ir.readModels.map((readModel) => readModel.name);
38
+ const pageNameList = ir.pages.map((page) => page.name);
39
+ const pageNames = new Set(ir.pages.map(p => p.name));
40
+ const pageMap = new Map(ir.pages.map((page) => [page.name, page]));
41
+ const modelFieldContext = Object.fromEntries(ir.models.map((model) => [model.name, model.fields.map((field) => ({
42
+ name: field.name,
43
+ fieldType: field.fieldType,
44
+ }))]));
45
+ const resourceContext = ir.resources.map((resource) => ({
46
+ name: resource.name,
47
+ model: resource.model,
48
+ api: resource.api,
49
+ }));
50
+ const globalEntry = resolveValidationSegment(createValidationSignature({
51
+ modelNames,
52
+ resourceNames,
53
+ readModelNames,
54
+ pageNames: pageNameList,
55
+ escapeStats: ir.escapeStats,
56
+ }), previousCache?.global?.core, () => validateGlobal(ir));
57
+ errors.push(...globalEntry.errors);
58
+ const navigationEntries = ir.navigation.map((group) => resolveValidationSegment(createValidationSignature({
59
+ targets: group.items.map((item) => item.target),
60
+ resourceNames,
61
+ pages: ir.pages.map((page) => ({ name: page.name, path: page.path })),
62
+ }), previousCache?.global?.navigation[group.id], () => validateNavigationGroup(group, resourceMap, pageMap)));
63
+ errors.push(...navigationEntries.flatMap((entry) => entry.errors));
64
+ const resourceEntries = ir.resources.map((resource) => resolveResourceValidation(resource, modelMap, modelNames, resourceMap, resourceNames, pageNames, pageMap, pageNameList, modelFieldContext, resourceContext, previousCache?.resources[resource.name]));
65
+ errors.push(...resourceEntries.flatMap((entry) => entry.errors));
66
+ const readModelEntries = ir.readModels.map((readModel) => resolveValidationSegment(createValidationSignature({
67
+ name: readModel.name,
68
+ api: readModel.api,
69
+ rules: readModel.rules
70
+ ? {
71
+ resolvedPath: readModel.rules.resolvedPath,
72
+ program: readModel.rules.program,
73
+ }
74
+ : null,
75
+ inputs: readModel.inputs.map((field) => ({
76
+ name: field.name,
77
+ fieldType: field.fieldType,
78
+ decorators: field.decorators,
79
+ })),
80
+ result: readModel.result.map((field) => ({
81
+ name: field.name,
82
+ fieldType: field.fieldType,
83
+ decorators: field.decorators,
84
+ })),
85
+ list: readModel.list
86
+ ? {
87
+ columns: readModel.list.columns.map((column) => ({
88
+ field: column.field,
89
+ decorators: column.decorators,
90
+ customRenderer: Boolean(column.customRenderer),
91
+ displayFn: Boolean(column.displayFn),
92
+ })),
93
+ groupBy: readModel.list.groupBy,
94
+ pagination: readModel.list.pagination ?? null,
95
+ }
96
+ : null,
97
+ }), previousCache?.readModels[readModel.name], () => validateReadModel(readModel)));
98
+ errors.push(...readModelEntries.flatMap((entry) => entry.errors));
99
+ const pageEntries = ir.pages.map((page) => resolveValidationSegment(createValidationSignature({
100
+ name: page.name,
101
+ title: page.title,
102
+ path: page.path,
103
+ blocks: page.blocks.map((block) => ({
104
+ id: block.id,
105
+ blockType: block.blockType,
106
+ title: block.title,
107
+ data: block.data,
108
+ customBlock: block.customBlock,
109
+ })),
110
+ models: ir.models.map((model) => ({
111
+ name: model.name,
112
+ fields: model.fields.map((field) => ({
113
+ name: field.name,
114
+ fieldType: field.fieldType,
115
+ })),
116
+ })),
117
+ resources: ir.resources.map((resource) => ({
118
+ name: resource.name,
119
+ model: resource.model,
120
+ hasList: Boolean(resource.views.list),
121
+ hasRead: Boolean(resource.views.read),
122
+ })),
123
+ readModels: ir.readModels.map((readModel) => ({
124
+ name: readModel.name,
125
+ api: readModel.api,
126
+ hasList: Boolean(readModel.list),
127
+ rules: readModel.rules
128
+ ? {
129
+ resolvedPath: readModel.rules.resolvedPath,
130
+ program: readModel.rules.program,
131
+ }
132
+ : null,
133
+ inputs: readModel.inputs.map((field) => ({
134
+ name: field.name,
135
+ fieldType: field.fieldType,
136
+ })),
137
+ result: readModel.result.map((field) => ({
138
+ name: field.name,
139
+ fieldType: field.fieldType,
140
+ })),
141
+ })),
142
+ }), previousCache?.pages[page.name], () => validatePage(page, Array.from(resourceMap.values()), Array.from(modelMap.values()), Array.from(readModelMap.values()))));
143
+ errors.push(...pageEntries.flatMap((entry) => entry.errors));
144
+ if (!options) {
145
+ return errors;
146
+ }
147
+ return {
148
+ errors,
149
+ cacheSnapshot: {
150
+ version: VALIDATION_CACHE_VERSION,
151
+ global: {
152
+ core: globalEntry.cacheEntry,
153
+ navigation: Object.fromEntries(ir.navigation.map((group, index) => [group.id, navigationEntries[index].cacheEntry])),
154
+ },
155
+ resources: Object.fromEntries(ir.resources.map((resource, index) => [resource.name, resourceEntries[index].cacheSnapshot])),
156
+ readModels: Object.fromEntries(ir.readModels.map((readModel, index) => [readModel.name, readModelEntries[index].cacheEntry])),
157
+ pages: Object.fromEntries(ir.pages.map((page, index) => [page.name, pageEntries[index].cacheEntry])),
158
+ },
159
+ };
160
+ }
161
+ function resolveValidationSegment(signature, previous, emit) {
162
+ if (previous && previous.signature === signature) {
163
+ return {
164
+ errors: previous.errors,
165
+ cacheEntry: previous,
166
+ };
167
+ }
168
+ const errors = emit();
169
+ return {
170
+ errors,
171
+ cacheEntry: {
172
+ signature,
173
+ errors,
174
+ },
175
+ };
176
+ }
177
+ function createValidationSignature(value) {
178
+ return JSON.stringify(value, (_key, current) => {
179
+ if (!current || typeof current !== 'object' || Array.isArray(current)) {
180
+ return current;
181
+ }
182
+ const normalized = {};
183
+ for (const [key, entry] of Object.entries(current)) {
184
+ if (key === 'sourceSpan' || key === 'sourceFile') {
185
+ continue;
186
+ }
187
+ normalized[key] = entry;
188
+ }
189
+ return normalized;
190
+ });
191
+ }
192
+ function validateGlobal(ir) {
193
+ const errors = [];
194
+ errors.push(...collectDuplicateErrors(ir.models, 'model'));
195
+ errors.push(...collectDuplicateErrors(ir.resources, 'resource'));
196
+ errors.push(...collectDuplicateErrors(ir.readModels, 'readModel'));
197
+ errors.push(...collectDuplicateErrors(ir.pages, 'page'));
198
+ errors.push(...collectRelationErrors(ir.models));
199
+ if (ir.escapeStats) {
200
+ if (ir.escapeStats.overBudget) {
201
+ errors.push({
202
+ message: `Escape hatch budget exceeded: ${ir.escapeStats.escapePercent}% of nodes use escape hatches (threshold: 20%). ` +
203
+ `@expr: ${ir.escapeStats.exprCount}, @fn: ${ir.escapeStats.fnCount}, @custom: ${ir.escapeStats.customCount}. ` +
204
+ `Consider extending the DSL schema instead.`,
205
+ nodeId: ir.id,
206
+ severity: 'warning',
207
+ });
208
+ }
209
+ }
210
+ return errors;
211
+ }
212
+ function validateReadModel(readModel) {
213
+ const errors = [];
214
+ if (!readModel.api) {
215
+ errors.push({
216
+ message: `Read-model "${readModel.name}" has no API endpoint defined`,
217
+ nodeId: readModel.id,
218
+ severity: 'error',
219
+ });
220
+ }
221
+ const inputNames = new Set();
222
+ for (const field of readModel.inputs) {
223
+ validateReadModelField(readModel, field, inputNames, errors);
224
+ }
225
+ const resultNames = new Set();
226
+ for (const field of readModel.result) {
227
+ validateReadModelField(readModel, field, resultNames, errors);
228
+ }
229
+ if (readModel.list) {
230
+ if (readModel.list.columns.length === 0) {
231
+ errors.push({
232
+ message: `Read-model "${readModel.name}" list must define at least one column`,
233
+ nodeId: readModel.list.id,
234
+ severity: 'error',
235
+ });
236
+ }
237
+ for (const column of readModel.list.columns) {
238
+ const resultField = readModel.result.find((field) => field.name === column.field);
239
+ if (!resultField) {
240
+ errors.push({
241
+ message: `Read-model "${readModel.name}" list column "${column.field}" must reference a result field`,
242
+ nodeId: column.id,
243
+ severity: 'error',
244
+ });
245
+ }
246
+ if (column.field.includes('.')) {
247
+ errors.push({
248
+ message: `Read-model "${readModel.name}" list column "${column.field}" does not support relation-style projections in the current slice`,
249
+ nodeId: column.id,
250
+ severity: 'error',
251
+ });
252
+ }
253
+ if (column.customRenderer || column.displayFn || column.dynamicLabel) {
254
+ errors.push({
255
+ message: `Read-model "${readModel.name}" list column "${column.field}" does not support @custom, @fn, or @expr in the current slice`,
256
+ nodeId: column.id,
257
+ severity: 'error',
258
+ });
259
+ }
260
+ }
261
+ if (readModel.list.groupBy.length > 0) {
262
+ const resultFieldNames = new Set(readModel.result.map((field) => field.name));
263
+ const columnsByField = new Map(readModel.list.columns.map((column) => [column.field, column]));
264
+ for (const fieldName of readModel.list.groupBy) {
265
+ if (!resultFieldNames.has(fieldName)) {
266
+ errors.push({
267
+ message: `Read-model "${readModel.name}" list groupBy field "${fieldName}" must reference a result field`,
268
+ nodeId: readModel.list.id,
269
+ severity: 'error',
270
+ });
271
+ }
272
+ if (fieldName.includes('.')) {
273
+ errors.push({
274
+ message: `Read-model "${readModel.name}" list groupBy field "${fieldName}" does not support relation-style projections in the current slice`,
275
+ nodeId: readModel.list.id,
276
+ severity: 'error',
277
+ });
278
+ }
279
+ const column = columnsByField.get(fieldName);
280
+ if (!column) {
281
+ errors.push({
282
+ message: `Read-model "${readModel.name}" list groupBy field "${fieldName}" must also appear in list.columns`,
283
+ nodeId: readModel.list.id,
284
+ severity: 'error',
285
+ });
286
+ continue;
287
+ }
288
+ if (column.decorators.some((decorator) => decorator.name === 'sortable')) {
289
+ errors.push({
290
+ message: `Read-model "${readModel.name}" list groupBy field "${fieldName}" cannot also be @sortable in the current grouped-table slice`,
291
+ nodeId: column.id,
292
+ severity: 'error',
293
+ });
294
+ }
295
+ }
296
+ const nonGroupedColumns = readModel.list.columns.filter((column) => !readModel.list.groupBy.includes(column.field));
297
+ if (nonGroupedColumns.length === 0) {
298
+ errors.push({
299
+ message: `Read-model "${readModel.name}" grouped list must leave at least one non-grouped offer column`,
300
+ nodeId: readModel.list.id,
301
+ severity: 'error',
302
+ });
303
+ }
304
+ }
305
+ if (readModel.list.pivotBy) {
306
+ const resultFieldNames = new Set(readModel.result.map((field) => field.name));
307
+ const columnsByField = new Map(readModel.list.columns.map((column) => [column.field, column]));
308
+ if (readModel.list.groupBy.length === 0) {
309
+ errors.push({
310
+ message: `Read-model "${readModel.name}" list pivotBy requires groupBy in the current grouped-matrix slice`,
311
+ nodeId: readModel.list.id,
312
+ severity: 'error',
313
+ });
314
+ }
315
+ if (!resultFieldNames.has(readModel.list.pivotBy)) {
316
+ errors.push({
317
+ message: `Read-model "${readModel.name}" list pivotBy field "${readModel.list.pivotBy}" must reference a result field`,
318
+ nodeId: readModel.list.id,
319
+ severity: 'error',
320
+ });
321
+ }
322
+ if (readModel.list.pivotBy.includes('.')) {
323
+ errors.push({
324
+ message: `Read-model "${readModel.name}" list pivotBy field "${readModel.list.pivotBy}" does not support relation-style projections in the current slice`,
325
+ nodeId: readModel.list.id,
326
+ severity: 'error',
327
+ });
328
+ }
329
+ const pivotColumn = columnsByField.get(readModel.list.pivotBy);
330
+ if (!pivotColumn) {
331
+ errors.push({
332
+ message: `Read-model "${readModel.name}" list pivotBy field "${readModel.list.pivotBy}" must also appear in list.columns`,
333
+ nodeId: readModel.list.id,
334
+ severity: 'error',
335
+ });
336
+ }
337
+ else if (pivotColumn.decorators.some((decorator) => decorator.name === 'sortable')) {
338
+ errors.push({
339
+ message: `Read-model "${readModel.name}" list pivotBy field "${readModel.list.pivotBy}" cannot also be @sortable in the current grouped-matrix slice`,
340
+ nodeId: pivotColumn.id,
341
+ severity: 'error',
342
+ });
343
+ }
344
+ const sortableColumns = readModel.list.columns.filter((column) => column.decorators.some((decorator) => decorator.name === 'sortable'));
345
+ for (const column of sortableColumns) {
346
+ errors.push({
347
+ message: `Read-model "${readModel.name}" pivoted list column "${column.field}" cannot use @sortable in the current grouped-matrix slice`,
348
+ nodeId: column.id,
349
+ severity: 'error',
350
+ });
351
+ }
352
+ const nonPivotColumns = readModel.list.columns.filter((column) => !readModel.list.groupBy.includes(column.field) && column.field !== readModel.list.pivotBy);
353
+ if (nonPivotColumns.length === 0) {
354
+ errors.push({
355
+ message: `Read-model "${readModel.name}" pivoted list must leave at least one non-grouped, non-pivot offer column`,
356
+ nodeId: readModel.list.id,
357
+ severity: 'error',
358
+ });
359
+ }
360
+ }
361
+ }
362
+ validateReadModelRules(readModel, errors);
363
+ return errors;
364
+ }
365
+ function validateReadModelRules(readModel, errors) {
366
+ const rules = readModel.rules;
367
+ if (!rules) {
368
+ return;
369
+ }
370
+ const inputFieldNames = new Set(readModel.inputs.map((field) => field.name));
371
+ const resultFieldMap = new Map(readModel.result.map((field) => [field.name, field]));
372
+ if (rules.program.rules.length > 0) {
373
+ errors.push({
374
+ message: `Read-model "${readModel.name}" rules do not support allow/deny auth entries; keep readModel access control to local page gating in the current frontend slice`,
375
+ nodeId: rules.id,
376
+ severity: 'error',
377
+ });
378
+ }
379
+ if (rules.program.eligibility.length === 0 && rules.program.validation.length === 0 && rules.program.derivations.length === 0) {
380
+ errors.push({
381
+ message: `Read-model "${readModel.name}" rules must define at least one eligibility, validate, or derive entry`,
382
+ nodeId: rules.id,
383
+ severity: 'error',
384
+ });
385
+ }
386
+ const seenEligibility = new Set();
387
+ for (const entry of rules.program.eligibility) {
388
+ if (seenEligibility.has(entry.name)) {
389
+ errors.push({
390
+ message: `Read-model "${readModel.name}" rules have duplicate eligibility entry "${entry.name}"`,
391
+ nodeId: rules.id,
392
+ severity: 'error',
393
+ });
394
+ }
395
+ seenEligibility.add(entry.name);
396
+ validateWorkflowExpr(entry.when, rules.id, errors, (path) => validateReadModelRulesEligibilityIdentifier(path, readModel.name, inputFieldNames));
397
+ for (const expr of entry.or) {
398
+ validateWorkflowExpr(expr, rules.id, errors, (path) => validateReadModelRulesEligibilityIdentifier(path, readModel.name, inputFieldNames));
399
+ }
400
+ }
401
+ const seenValidation = new Set();
402
+ for (const entry of rules.program.validation) {
403
+ if (seenValidation.has(entry.name)) {
404
+ errors.push({
405
+ message: `Read-model "${readModel.name}" rules have duplicate validate entry "${entry.name}"`,
406
+ nodeId: rules.id,
407
+ severity: 'error',
408
+ });
409
+ }
410
+ seenValidation.add(entry.name);
411
+ validateWorkflowExpr(entry.when, rules.id, errors, (path) => validateReadModelRulesEligibilityIdentifier(path, readModel.name, inputFieldNames));
412
+ for (const expr of entry.or) {
413
+ validateWorkflowExpr(expr, rules.id, errors, (path) => validateReadModelRulesEligibilityIdentifier(path, readModel.name, inputFieldNames));
414
+ }
415
+ }
416
+ const seenDerivations = new Set();
417
+ for (const entry of rules.program.derivations) {
418
+ if (seenDerivations.has(entry.field)) {
419
+ errors.push({
420
+ message: `Read-model "${readModel.name}" rules have duplicate derive entry for "${entry.field}"`,
421
+ nodeId: rules.id,
422
+ severity: 'error',
423
+ });
424
+ continue;
425
+ }
426
+ seenDerivations.add(entry.field);
427
+ const resultField = resultFieldMap.get(entry.field);
428
+ if (!resultField) {
429
+ errors.push({
430
+ message: `Read-model "${readModel.name}" rules derive entry "${entry.field}" must target an existing result field`,
431
+ nodeId: rules.id,
432
+ severity: 'error',
433
+ });
434
+ continue;
435
+ }
436
+ if (resultField.fieldType.type !== 'scalar' || ['date', 'datetime'].includes(resultField.fieldType.name)) {
437
+ errors.push({
438
+ message: `Read-model "${readModel.name}" rules derive entry "${entry.field}" currently supports only string, text, integer, long, decimal, or boolean result fields`,
439
+ nodeId: rules.id,
440
+ severity: 'error',
441
+ });
442
+ }
443
+ if (entry.when) {
444
+ validateWorkflowExpr(entry.when, rules.id, errors, (path) => validateReadModelRulesDerivationIdentifier(path, readModel.name, inputFieldNames, resultFieldMap));
445
+ }
446
+ validateWorkflowExpr(entry.value, rules.id, errors, (path) => validateReadModelRulesDerivationIdentifier(path, readModel.name, inputFieldNames, resultFieldMap));
447
+ }
448
+ }
449
+ function validateLinkedFormRules(resource, model, view, errors, mode) {
450
+ const rules = view.rulesLink;
451
+ if (!rules) {
452
+ return;
453
+ }
454
+ if (!model) {
455
+ return;
456
+ }
457
+ const viewFieldNames = new Set(view.fields.filter((field) => !field.customField).map((field) => field.field));
458
+ const modelFieldMap = new Map(model.fields.map((field) => [field.name, field]));
459
+ const modelFieldNames = new Set(model.fields.map((field) => field.name));
460
+ const stateFieldName = resource.workflow?.program.field ?? null;
461
+ if (rules.program.rules.length > 0) {
462
+ errors.push({
463
+ message: `Resource "${resource.name}" ${mode}.rules do not support allow/deny auth entries; keep generated form rules to eligibility, validate, and derive in the current frontend slice`,
464
+ nodeId: rules.id,
465
+ severity: 'error',
466
+ });
467
+ }
468
+ if (rules.program.eligibility.length === 0 && rules.program.validation.length === 0 && rules.program.derivations.length === 0) {
469
+ errors.push({
470
+ message: `Resource "${resource.name}" ${mode}.rules must define at least one eligibility, validate, or derive entry`,
471
+ nodeId: rules.id,
472
+ severity: 'error',
473
+ });
474
+ }
475
+ const seenEligibility = new Set();
476
+ for (const entry of rules.program.eligibility) {
477
+ if (seenEligibility.has(entry.name)) {
478
+ errors.push({
479
+ message: `Resource "${resource.name}" ${mode}.rules have duplicate eligibility entry "${entry.name}"`,
480
+ nodeId: rules.id,
481
+ severity: 'error',
482
+ });
483
+ }
484
+ seenEligibility.add(entry.name);
485
+ validateWorkflowExpr(entry.when, rules.id, errors, (path) => validateLinkedFormRulesIdentifier(path, resource.name, mode, modelFieldNames));
486
+ for (const expr of entry.or) {
487
+ validateWorkflowExpr(expr, rules.id, errors, (path) => validateLinkedFormRulesIdentifier(path, resource.name, mode, modelFieldNames));
488
+ }
489
+ }
490
+ const seenValidation = new Set();
491
+ for (const entry of rules.program.validation) {
492
+ if (seenValidation.has(entry.name)) {
493
+ errors.push({
494
+ message: `Resource "${resource.name}" ${mode}.rules have duplicate validate entry "${entry.name}"`,
495
+ nodeId: rules.id,
496
+ severity: 'error',
497
+ });
498
+ }
499
+ seenValidation.add(entry.name);
500
+ validateWorkflowExpr(entry.when, rules.id, errors, (path) => validateLinkedFormRulesIdentifier(path, resource.name, mode, modelFieldNames));
501
+ for (const expr of entry.or) {
502
+ validateWorkflowExpr(expr, rules.id, errors, (path) => validateLinkedFormRulesIdentifier(path, resource.name, mode, modelFieldNames));
503
+ }
504
+ }
505
+ const seenDerivations = new Set();
506
+ for (const entry of rules.program.derivations) {
507
+ if (seenDerivations.has(entry.field)) {
508
+ errors.push({
509
+ message: `Resource "${resource.name}" ${mode}.rules have duplicate derive entry for "${entry.field}"`,
510
+ nodeId: rules.id,
511
+ severity: 'error',
512
+ });
513
+ continue;
514
+ }
515
+ seenDerivations.add(entry.field);
516
+ const targetField = modelFieldMap.get(entry.field);
517
+ if (!targetField) {
518
+ errors.push({
519
+ message: `Resource "${resource.name}" ${mode}.rules derive entry "${entry.field}" must target an existing model field`,
520
+ nodeId: rules.id,
521
+ severity: 'error',
522
+ });
523
+ continue;
524
+ }
525
+ if (!viewFieldNames.has(entry.field)) {
526
+ errors.push({
527
+ message: `Resource "${resource.name}" ${mode}.rules derive entry "${entry.field}" must target a top-level generated ${mode} field`,
528
+ nodeId: rules.id,
529
+ severity: 'error',
530
+ });
531
+ continue;
532
+ }
533
+ if (targetField.fieldType.type !== 'scalar') {
534
+ errors.push({
535
+ message: `Resource "${resource.name}" ${mode}.rules derive entry "${entry.field}" currently supports only scalar generated form fields`,
536
+ nodeId: rules.id,
537
+ severity: 'error',
538
+ });
539
+ continue;
540
+ }
541
+ if (stateFieldName && entry.field === stateFieldName) {
542
+ errors.push({
543
+ message: `Resource "${resource.name}" ${mode}.rules derive entry "${entry.field}" cannot target the workflow-controlled state field`,
544
+ nodeId: rules.id,
545
+ severity: 'error',
546
+ });
547
+ continue;
548
+ }
549
+ if (entry.when) {
550
+ validateWorkflowExpr(entry.when, rules.id, errors, (path) => validateLinkedFormRulesIdentifier(path, resource.name, mode, modelFieldNames));
551
+ }
552
+ validateWorkflowExpr(entry.value, rules.id, errors, (path) => validateLinkedFormRulesIdentifier(path, resource.name, mode, modelFieldNames));
553
+ }
554
+ }
555
+ function validateLinkedFormIncludeRules(resource, parentModel, include, targetModel, errors, mode) {
556
+ const rules = include.rulesLink;
557
+ if (!rules) {
558
+ return;
559
+ }
560
+ const parentModelFieldNames = new Set(parentModel.fields.map((field) => field.name));
561
+ const includeFieldNames = new Set(include.fields.filter((field) => !field.customField).map((field) => field.field));
562
+ const targetFieldMap = new Map(targetModel.fields.map((field) => [field.name, field]));
563
+ if (rules.program.rules.length > 0) {
564
+ errors.push({
565
+ message: `Resource "${resource.name}" ${mode}.includes.${include.field}.rules do not support allow/deny auth entries; keep repeated-child generated rules to eligibility, validate, and derive in the current frontend slice`,
566
+ nodeId: rules.id,
567
+ severity: 'error',
568
+ });
569
+ }
570
+ if (rules.program.eligibility.length === 0 && rules.program.validation.length === 0 && rules.program.derivations.length === 0) {
571
+ errors.push({
572
+ message: `Resource "${resource.name}" ${mode}.includes.${include.field}.rules must define at least one eligibility, validate, or derive entry`,
573
+ nodeId: rules.id,
574
+ severity: 'error',
575
+ });
576
+ }
577
+ const validateIdentifier = (path) => validateLinkedFormIncludeRulesIdentifier(path, resource.name, include.field, mode, parentModelFieldNames, includeFieldNames);
578
+ const seenEligibility = new Set();
579
+ for (const entry of rules.program.eligibility) {
580
+ if (seenEligibility.has(entry.name)) {
581
+ errors.push({
582
+ message: `Resource "${resource.name}" ${mode}.includes.${include.field}.rules have duplicate eligibility entry "${entry.name}"`,
583
+ nodeId: rules.id,
584
+ severity: 'error',
585
+ });
586
+ }
587
+ seenEligibility.add(entry.name);
588
+ validateWorkflowExpr(entry.when, rules.id, errors, validateIdentifier);
589
+ for (const expr of entry.or) {
590
+ validateWorkflowExpr(expr, rules.id, errors, validateIdentifier);
591
+ }
592
+ }
593
+ const seenValidation = new Set();
594
+ for (const entry of rules.program.validation) {
595
+ if (seenValidation.has(entry.name)) {
596
+ errors.push({
597
+ message: `Resource "${resource.name}" ${mode}.includes.${include.field}.rules have duplicate validate entry "${entry.name}"`,
598
+ nodeId: rules.id,
599
+ severity: 'error',
600
+ });
601
+ }
602
+ seenValidation.add(entry.name);
603
+ validateWorkflowExpr(entry.when, rules.id, errors, validateIdentifier);
604
+ for (const expr of entry.or) {
605
+ validateWorkflowExpr(expr, rules.id, errors, validateIdentifier);
606
+ }
607
+ }
608
+ const seenDerivations = new Set();
609
+ for (const entry of rules.program.derivations) {
610
+ if (seenDerivations.has(entry.field)) {
611
+ errors.push({
612
+ message: `Resource "${resource.name}" ${mode}.includes.${include.field}.rules have duplicate derive entry for "${entry.field}"`,
613
+ nodeId: rules.id,
614
+ severity: 'error',
615
+ });
616
+ continue;
617
+ }
618
+ seenDerivations.add(entry.field);
619
+ const targetField = targetFieldMap.get(entry.field);
620
+ if (!targetField) {
621
+ errors.push({
622
+ message: `Resource "${resource.name}" ${mode}.includes.${include.field}.rules derive entry "${entry.field}" must target an existing related model field`,
623
+ nodeId: rules.id,
624
+ severity: 'error',
625
+ });
626
+ continue;
627
+ }
628
+ if (!includeFieldNames.has(entry.field)) {
629
+ errors.push({
630
+ message: `Resource "${resource.name}" ${mode}.includes.${include.field}.rules derive entry "${entry.field}" must target a generated repeated-child field`,
631
+ nodeId: rules.id,
632
+ severity: 'error',
633
+ });
634
+ continue;
635
+ }
636
+ if (targetField.fieldType.type !== 'scalar') {
637
+ errors.push({
638
+ message: `Resource "${resource.name}" ${mode}.includes.${include.field}.rules derive entry "${entry.field}" currently supports only scalar repeated-child fields`,
639
+ nodeId: rules.id,
640
+ severity: 'error',
641
+ });
642
+ continue;
643
+ }
644
+ if (entry.when) {
645
+ validateWorkflowExpr(entry.when, rules.id, errors, validateIdentifier);
646
+ }
647
+ validateWorkflowExpr(entry.value, rules.id, errors, validateIdentifier);
648
+ }
649
+ }
650
+ function validateReadModelField(readModel, field, seen, errors) {
651
+ if (seen.has(field.name)) {
652
+ errors.push({
653
+ message: `Duplicate ${field.section} field "${field.name}" in read-model "${readModel.name}"`,
654
+ nodeId: field.id,
655
+ severity: 'error',
656
+ });
657
+ return;
658
+ }
659
+ seen.add(field.name);
660
+ if (field.fieldType.type === 'relation') {
661
+ errors.push({
662
+ message: `Read-model "${readModel.name}" ${field.section} field "${field.name}" currently supports only scalar or enum types`,
663
+ nodeId: field.id,
664
+ severity: 'error',
665
+ });
666
+ }
667
+ }
668
+ function collectRelationErrors(models) {
669
+ const errors = [];
670
+ const modelMap = new Map(models.map((model) => [model.name, model]));
671
+ for (const model of models) {
672
+ for (const field of model.fields) {
673
+ if (field.fieldType.type !== 'relation') {
674
+ continue;
675
+ }
676
+ const targetModel = modelMap.get(field.fieldType.target);
677
+ if (!targetModel) {
678
+ errors.push({
679
+ message: `Field "${field.name}" in model "${model.name}" references unknown relation target "${field.fieldType.target}"`,
680
+ nodeId: field.id,
681
+ severity: 'error',
682
+ });
683
+ continue;
684
+ }
685
+ const relationField = field.fieldType;
686
+ if (relationField.kind !== 'hasMany') {
687
+ continue;
688
+ }
689
+ if (field.decorators.length > 0) {
690
+ errors.push({
691
+ message: `Field "${field.name}" in model "${model.name}" is a hasMany() inverse relation and does not support field decorators`,
692
+ nodeId: field.id,
693
+ severity: 'error',
694
+ });
695
+ }
696
+ const inverseField = targetModel.fields.find((candidate) => candidate.name === relationField.by);
697
+ if (!inverseField) {
698
+ errors.push({
699
+ message: `Field "${field.name}" in model "${model.name}" references missing inverse field "${relationField.by}" on model "${targetModel.name}"`,
700
+ nodeId: field.id,
701
+ severity: 'error',
702
+ });
703
+ continue;
704
+ }
705
+ if (inverseField.fieldType.type !== 'relation'
706
+ || inverseField.fieldType.kind !== 'belongsTo'
707
+ || inverseField.fieldType.target !== model.name) {
708
+ errors.push({
709
+ message: `Field "${field.name}" in model "${model.name}" must reference a belongsTo(${model.name}) field via by: "${relationField.by}" on model "${targetModel.name}"`,
710
+ nodeId: field.id,
711
+ severity: 'error',
712
+ });
713
+ }
714
+ }
715
+ }
716
+ return errors;
717
+ }
718
+ function resolveResourceValidation(resource, modelMap, modelNames, resourceMap, resourceNames, pageNames, pageMap, pageNameList, modelFieldContext, resourceContext, previous) {
719
+ const resourceEntry = resolveValidationSegment(createValidationSignature({
720
+ name: resource.name,
721
+ model: resource.model,
722
+ api: resource.api,
723
+ workflow: resource.workflow ? {
724
+ model: resource.workflow.program.model,
725
+ field: resource.workflow.program.field,
726
+ states: resource.workflow.program.states,
727
+ wizard: resource.workflow.program.wizard,
728
+ transitions: resource.workflow.program.transitions,
729
+ } : null,
730
+ modelNames,
731
+ }), previous?.resource, () => validateResourceBase(resource, modelMap));
732
+ const listEntry = resource.views.list
733
+ ? resolveValidationSegment(createValidationSignature({
734
+ modelFields: modelFieldContext[resource.model] ?? null,
735
+ relationModelFields: resource.views.list.columns.some((column) => column.field.includes('.'))
736
+ ? modelFieldContext
737
+ : null,
738
+ resources: resource.views.list.columns.some((column) => column.field.includes('.'))
739
+ ? resourceContext
740
+ : null,
741
+ filters: resource.views.list.filters.map((filter) => filter.field),
742
+ columns: resource.views.list.columns.map((column) => ({
743
+ field: column.field,
744
+ customRenderer: Boolean(column.customRenderer),
745
+ })),
746
+ rules: resource.views.list.rules ?? null,
747
+ }), previous?.list, () => validateListView(resource, modelMap.get(resource.model), resource.views.list, Array.from(modelMap.values()), Array.from(resourceMap.values())))
748
+ : undefined;
749
+ const editEntry = resource.views.edit
750
+ ? resolveValidationSegment(createValidationSignature({
751
+ modelFields: modelFieldContext[resource.model] ?? null,
752
+ fields: resource.views.edit.fields.map((field) => ({
753
+ field: field.field,
754
+ customField: Boolean(field.customField),
755
+ visibleWhen: field.visibleWhen ?? null,
756
+ enabledWhen: field.enabledWhen ?? null,
757
+ })),
758
+ includes: resource.views.edit.includes.map((include) => ({
759
+ field: include.field,
760
+ minItems: include.minItems,
761
+ rulesLink: include.rulesLink
762
+ ? {
763
+ resolvedPath: include.rulesLink.resolvedPath,
764
+ program: include.rulesLink.program,
765
+ }
766
+ : null,
767
+ fields: include.fields.map((field) => ({
768
+ field: field.field,
769
+ customField: Boolean(field.customField),
770
+ visibleWhen: field.visibleWhen ?? null,
771
+ enabledWhen: field.enabledWhen ?? null,
772
+ })),
773
+ })),
774
+ rules: resource.views.edit.rules ?? null,
775
+ rulesLink: resource.views.edit.rulesLink
776
+ ? {
777
+ resolvedPath: resource.views.edit.rulesLink.resolvedPath,
778
+ program: resource.views.edit.rulesLink.program,
779
+ }
780
+ : null,
781
+ onSuccess: resource.views.edit.onSuccess,
782
+ resourceNames,
783
+ pageNames: pageNameList,
784
+ }), previous?.edit, () => validateEditView(resource, modelMap.get(resource.model), modelMap, resourceMap, pageMap))
785
+ : undefined;
786
+ const createEntry = resource.views.create
787
+ ? resolveValidationSegment(createValidationSignature({
788
+ modelFields: modelFieldContext[resource.model] ?? null,
789
+ fields: resource.views.create.fields.map((field) => ({
790
+ field: field.field,
791
+ customField: Boolean(field.customField),
792
+ visibleWhen: field.visibleWhen ?? null,
793
+ enabledWhen: field.enabledWhen ?? null,
794
+ })),
795
+ includes: resource.views.create.includes.map((include) => ({
796
+ field: include.field,
797
+ minItems: include.minItems,
798
+ rulesLink: include.rulesLink
799
+ ? {
800
+ resolvedPath: include.rulesLink.resolvedPath,
801
+ program: include.rulesLink.program,
802
+ }
803
+ : null,
804
+ fields: include.fields.map((field) => ({
805
+ field: field.field,
806
+ customField: Boolean(field.customField),
807
+ visibleWhen: field.visibleWhen ?? null,
808
+ enabledWhen: field.enabledWhen ?? null,
809
+ })),
810
+ })),
811
+ rules: resource.views.create.rules ?? null,
812
+ rulesLink: resource.views.create.rulesLink
813
+ ? {
814
+ resolvedPath: resource.views.create.rulesLink.resolvedPath,
815
+ program: resource.views.create.rulesLink.program,
816
+ }
817
+ : null,
818
+ onSuccess: resource.views.create.onSuccess,
819
+ resourceNames,
820
+ pageNames: pageNameList,
821
+ }), previous?.create, () => validateCreateView(resource, modelMap.get(resource.model), modelMap, resourceMap, pageMap))
822
+ : undefined;
823
+ const readEntry = resource.views.read
824
+ ? resolveValidationSegment(createValidationSignature({
825
+ modelFields: modelFieldContext[resource.model] ?? null,
826
+ relationModelFields: resource.views.read.fields.some((field) => field.field.includes('.'))
827
+ ? modelFieldContext
828
+ : null,
829
+ resources: resourceContext,
830
+ fields: resource.views.read.fields.map((field) => ({
831
+ field: field.field,
832
+ decorators: field.decorators.map((decorator) => decorator.name),
833
+ customRenderer: Boolean(field.customRenderer),
834
+ })),
835
+ related: resource.views.read.related.map((panel) => panel.field),
836
+ }), previous?.read, () => validateReadView(resource, modelMap.get(resource.model), Array.from(modelMap.values()), Array.from(resourceMap.values())))
837
+ : undefined;
838
+ return {
839
+ errors: [
840
+ ...resourceEntry.errors,
841
+ ...(listEntry?.errors ?? []),
842
+ ...(editEntry?.errors ?? []),
843
+ ...(createEntry?.errors ?? []),
844
+ ...(readEntry?.errors ?? []),
845
+ ],
846
+ cacheSnapshot: {
847
+ resource: resourceEntry.cacheEntry,
848
+ list: listEntry?.cacheEntry,
849
+ edit: editEntry?.cacheEntry,
850
+ create: createEntry?.cacheEntry,
851
+ read: readEntry?.cacheEntry,
852
+ },
853
+ };
854
+ }
855
+ function validateNavigationGroup(group, resourceMap, pageMap) {
856
+ const errors = [];
857
+ for (const item of group.items) {
858
+ validateNavTarget(item.target, item.id, resourceMap, pageMap, errors);
859
+ }
860
+ return errors;
861
+ }
862
+ function validateResourceBase(resource, modelMap) {
863
+ const errors = [];
864
+ const model = modelMap.get(resource.model);
865
+ if (!model) {
866
+ errors.push({
867
+ message: `Resource "${resource.name}" references unknown model "${resource.model}"`,
868
+ nodeId: resource.id,
869
+ severity: 'error',
870
+ });
871
+ }
872
+ if (!resource.api) {
873
+ errors.push({
874
+ message: `Resource "${resource.name}" has no API endpoint defined`,
875
+ nodeId: resource.id,
876
+ severity: 'error',
877
+ });
878
+ }
879
+ if (model && resource.workflow) {
880
+ if (resource.workflow.program.model !== model.name) {
881
+ errors.push({
882
+ message: `Resource "${resource.name}" workflow model "${resource.workflow.program.model}" must match resource model "${model.name}"`,
883
+ nodeId: resource.workflow.id,
884
+ severity: 'error',
885
+ });
886
+ }
887
+ const stateField = model.fields.find((field) => field.name === resource.workflow.program.field);
888
+ if (!stateField) {
889
+ errors.push({
890
+ message: `Resource "${resource.name}" workflow field "${resource.workflow.program.field}" not found in model "${model.name}"`,
891
+ nodeId: resource.workflow.id,
892
+ severity: 'error',
893
+ });
894
+ return errors;
895
+ }
896
+ if (stateField.fieldType.type !== 'enum') {
897
+ errors.push({
898
+ message: `Resource "${resource.name}" workflow field "${resource.workflow.program.field}" in model "${model.name}" must be an enum(...) field`,
899
+ nodeId: resource.workflow.id,
900
+ severity: 'error',
901
+ });
902
+ return errors;
903
+ }
904
+ const enumValues = new Set(stateField.fieldType.values);
905
+ const workflowStates = new Set(resource.workflow.program.states.map((state) => state.name));
906
+ for (const state of resource.workflow.program.states) {
907
+ if (!enumValues.has(state.name)) {
908
+ errors.push({
909
+ message: `Resource "${resource.name}" workflow state "${state.name}" is not declared in model ${model.name}.${stateField.name}`,
910
+ nodeId: resource.workflow.id,
911
+ severity: 'error',
912
+ });
913
+ }
914
+ }
915
+ for (const enumValue of stateField.fieldType.values) {
916
+ if (!workflowStates.has(enumValue)) {
917
+ errors.push({
918
+ message: `Resource "${resource.name}" workflow must declare enum state "${enumValue}" from model ${model.name}.${stateField.name}`,
919
+ nodeId: resource.workflow.id,
920
+ severity: 'error',
921
+ });
922
+ }
923
+ }
924
+ const modelFieldNames = new Set(model.fields.map((field) => field.name));
925
+ for (const step of resource.workflow.program.wizard?.steps ?? []) {
926
+ if (!step.allow) {
927
+ continue;
928
+ }
929
+ validateWorkflowExpr(step.allow, resource.workflow.id, errors, (path) => validateWorkflowStepIdentifier(path, resource.name, modelFieldNames));
930
+ }
931
+ for (const transition of resource.workflow.program.transitions) {
932
+ if (!transition.allow) {
933
+ continue;
934
+ }
935
+ validateWorkflowExpr(transition.allow, resource.workflow.id, errors, (path) => validateWorkflowTransitionIdentifier(path, resource.name, modelFieldNames));
936
+ }
937
+ }
938
+ return errors;
939
+ }
940
+ function validateListView(resource, model, view, models, resources) {
941
+ const errors = [];
942
+ if (!model) {
943
+ validateViewRulePaths(view.id, view.rules, errors);
944
+ return errors;
945
+ }
946
+ const fieldMap = new Map(model.fields.map((field) => [field.name, field]));
947
+ for (const col of view.columns) {
948
+ const relationProjection = analyzeRelationProjection(col.field, model, models, resources);
949
+ if (relationProjection.kind !== 'none') {
950
+ if (relationProjection.kind === 'invalid' && !col.customRenderer) {
951
+ errors.push({
952
+ message: formatRelationProjectionError('Column', resource, relationProjection),
953
+ nodeId: col.id,
954
+ severity: 'error',
955
+ });
956
+ }
957
+ continue;
958
+ }
959
+ const modelField = fieldMap.get(col.field);
960
+ if (!modelField && !col.customRenderer) {
961
+ errors.push({
962
+ message: `Column "${col.field}" not found in model "${resource.model}"`,
963
+ nodeId: col.id,
964
+ severity: 'error',
965
+ });
966
+ }
967
+ else if (modelField && isInverseRelationField(modelField) && !col.customRenderer) {
968
+ errors.push({
969
+ message: `Column "${col.field}" in model "${resource.model}" references inverse relation metadata directly; use "${col.field}.count" for a read-only projection`,
970
+ nodeId: col.id,
971
+ severity: 'error',
972
+ });
973
+ }
974
+ }
975
+ for (const filter of view.filters) {
976
+ const relationProjection = analyzeRelationProjection(filter.field, model, models, resources);
977
+ if (relationProjection.kind !== 'none') {
978
+ if (relationProjection.kind === 'invalid') {
979
+ errors.push({
980
+ message: formatRelationProjectionError('Filter field', resource, relationProjection),
981
+ nodeId: filter.id,
982
+ severity: 'error',
983
+ });
984
+ }
985
+ continue;
986
+ }
987
+ const modelField = fieldMap.get(filter.field);
988
+ if (!modelField) {
989
+ errors.push({
990
+ message: `Filter field "${filter.field}" not found in model "${resource.model}"`,
991
+ nodeId: filter.id,
992
+ severity: 'error',
993
+ });
994
+ }
995
+ else if (isInverseRelationField(modelField)) {
996
+ errors.push({
997
+ message: `Filter field "${filter.field}" in model "${resource.model}" references inverse relation metadata directly; use "${filter.field}.count" for a relation-derived filter`,
998
+ nodeId: filter.id,
999
+ severity: 'error',
1000
+ });
1001
+ }
1002
+ }
1003
+ for (const action of view.actions) {
1004
+ if (action.name === 'view' && !resource.views.read) {
1005
+ errors.push({
1006
+ message: `List action "view" in resource "${resource.name}" requires a read: view`,
1007
+ nodeId: action.id,
1008
+ severity: 'error',
1009
+ });
1010
+ }
1011
+ }
1012
+ validateViewRulePaths(view.id, view.rules, errors);
1013
+ return errors;
1014
+ }
1015
+ function validateEditView(resource, model, modelMap, resourceMap, pageMap) {
1016
+ const errors = [];
1017
+ const view = resource.views.edit;
1018
+ if (!view) {
1019
+ return errors;
1020
+ }
1021
+ if (model) {
1022
+ const fieldMap = new Map(model.fields.map((field) => [field.name, field]));
1023
+ const modelFieldNames = new Set(model.fields.map((field) => field.name));
1024
+ for (const field of view.fields) {
1025
+ const modelField = fieldMap.get(field.field);
1026
+ if (!modelField && !field.customField) {
1027
+ errors.push({
1028
+ message: `Edit field "${field.field}" not found in model "${resource.model}"`,
1029
+ nodeId: field.id,
1030
+ severity: 'error',
1031
+ });
1032
+ }
1033
+ else if (modelField && isInverseRelationField(modelField) && !field.customField) {
1034
+ errors.push({
1035
+ message: `Edit field "${field.field}" in model "${resource.model}" references inverse relation metadata and cannot be used in generated forms yet`,
1036
+ nodeId: field.id,
1037
+ severity: 'error',
1038
+ });
1039
+ }
1040
+ else if (resource.workflow && modelField?.name === resource.workflow.program.field && !field.customField) {
1041
+ errors.push({
1042
+ message: `Edit field "${field.field}" in model "${resource.model}" is controlled by resource workflow and cannot be edited directly in generated forms`,
1043
+ nodeId: field.id,
1044
+ severity: 'error',
1045
+ });
1046
+ }
1047
+ validateFormFieldRulePaths(field, errors);
1048
+ validateFormFieldRules(field, errors, (path) => validateEditFieldIdentifier(path, resource.name, modelFieldNames));
1049
+ }
1050
+ for (const include of view.includes) {
1051
+ if (!Number.isInteger(include.minItems) || include.minItems < 0) {
1052
+ errors.push({
1053
+ message: `Edit include "${include.field}" in model "${resource.model}" minItems must be a non-negative integer`,
1054
+ nodeId: include.id,
1055
+ severity: 'error',
1056
+ });
1057
+ }
1058
+ const relationField = fieldMap.get(include.field);
1059
+ if (!relationField) {
1060
+ errors.push({
1061
+ message: `Edit include "${include.field}" not found in model "${resource.model}"`,
1062
+ nodeId: include.id,
1063
+ severity: 'error',
1064
+ });
1065
+ continue;
1066
+ }
1067
+ if (!isInverseRelationField(relationField)) {
1068
+ errors.push({
1069
+ message: `Edit include "${include.field}" in model "${resource.model}" must reference a hasMany(..., by: ...) field`,
1070
+ nodeId: include.id,
1071
+ severity: 'error',
1072
+ });
1073
+ continue;
1074
+ }
1075
+ const targetModel = modelMap.get(relationField.fieldType.target);
1076
+ if (!targetModel) {
1077
+ errors.push({
1078
+ message: `Edit include "${include.field}" in model "${resource.model}" references unknown related model "${relationField.fieldType.target}"`,
1079
+ nodeId: include.id,
1080
+ severity: 'error',
1081
+ });
1082
+ continue;
1083
+ }
1084
+ const targetResource = Array.from(resourceMap.values()).find((candidate) => candidate.model === targetModel.name);
1085
+ if (!targetResource) {
1086
+ errors.push({
1087
+ message: `Edit include "${include.field}" in model "${resource.model}" requires a generated resource for related model "${targetModel.name}"`,
1088
+ nodeId: include.id,
1089
+ severity: 'error',
1090
+ });
1091
+ }
1092
+ const targetFieldMap = new Map(targetModel.fields.map((field) => [field.name, field]));
1093
+ const targetFieldNames = new Set(targetModel.fields.map((field) => field.name));
1094
+ for (const nestedField of include.fields) {
1095
+ const targetField = targetFieldMap.get(nestedField.field);
1096
+ if (!targetField && !nestedField.customField) {
1097
+ errors.push({
1098
+ message: `Edit include field "${nestedField.field}" not found in related model "${targetModel.name}"`,
1099
+ nodeId: nestedField.id,
1100
+ severity: 'error',
1101
+ });
1102
+ continue;
1103
+ }
1104
+ if (!targetField || nestedField.customField) {
1105
+ continue;
1106
+ }
1107
+ if (targetField.name === relationField.fieldType.by) {
1108
+ errors.push({
1109
+ message: `Edit include field "${nestedField.field}" in related model "${targetModel.name}" is the inverse belongsTo(${model.name}) field and is seeded automatically`,
1110
+ nodeId: nestedField.id,
1111
+ severity: 'error',
1112
+ });
1113
+ continue;
1114
+ }
1115
+ if (isInverseRelationField(targetField)) {
1116
+ errors.push({
1117
+ message: `Edit include field "${nestedField.field}" in related model "${targetModel.name}" references inverse relation metadata and cannot be nested again in the current slice`,
1118
+ nodeId: nestedField.id,
1119
+ severity: 'error',
1120
+ });
1121
+ }
1122
+ validateFormFieldRulePaths(nestedField, errors);
1123
+ validateFormFieldRules(nestedField, errors, (path) => validateEditIncludeFieldIdentifier(path, resource.name, targetFieldNames));
1124
+ }
1125
+ validateLinkedFormIncludeRules(resource, model, include, targetModel, errors, 'edit');
1126
+ }
1127
+ }
1128
+ for (const effect of view.onSuccess) {
1129
+ if (effect.type === 'refresh' && !resourceMap.has(effect.target)) {
1130
+ errors.push({
1131
+ message: `Refresh target "${effect.target}" is not a known resource`,
1132
+ nodeId: view.id,
1133
+ severity: 'warning',
1134
+ });
1135
+ }
1136
+ if (effect.type === 'redirect') {
1137
+ validateRouteTarget(effect.target, view.id, resourceMap, pageMap, errors);
1138
+ }
1139
+ validateToastEffect(effect, {
1140
+ nodeId: view.id,
1141
+ viewName: 'edit',
1142
+ model,
1143
+ allowRecord: true,
1144
+ allowForm: true,
1145
+ allowUser: true,
1146
+ allowedParams: new Set(['id']),
1147
+ }, errors);
1148
+ }
1149
+ validateViewRulePaths(view.id, view.rules, errors);
1150
+ validateLinkedFormRules(resource, model, view, errors, 'edit');
1151
+ return errors;
1152
+ }
1153
+ function validateCreateView(resource, model, modelMap, resourceMap, pageMap) {
1154
+ const errors = [];
1155
+ const view = resource.views.create;
1156
+ if (!view) {
1157
+ return errors;
1158
+ }
1159
+ if (model) {
1160
+ const fieldMap = new Map(model.fields.map((field) => [field.name, field]));
1161
+ const modelFieldNames = new Set(model.fields.map((field) => field.name));
1162
+ for (const field of view.fields) {
1163
+ const modelField = fieldMap.get(field.field);
1164
+ if (!modelField && !field.customField) {
1165
+ errors.push({
1166
+ message: `Create field "${field.field}" not found in model "${resource.model}"`,
1167
+ nodeId: field.id,
1168
+ severity: 'error',
1169
+ });
1170
+ }
1171
+ else if (modelField && isInverseRelationField(modelField) && !field.customField) {
1172
+ errors.push({
1173
+ message: `Create field "${field.field}" in model "${resource.model}" references inverse relation metadata and cannot be used in generated forms yet`,
1174
+ nodeId: field.id,
1175
+ severity: 'error',
1176
+ });
1177
+ }
1178
+ else if (resource.workflow && modelField?.name === resource.workflow.program.field && !field.customField) {
1179
+ errors.push({
1180
+ message: `Create field "${field.field}" in model "${resource.model}" is controlled by resource workflow and cannot be edited directly in generated forms`,
1181
+ nodeId: field.id,
1182
+ severity: 'error',
1183
+ });
1184
+ }
1185
+ validateFormFieldRulePaths(field, errors);
1186
+ validateFormFieldRules(field, errors, (path) => validateCreateFieldIdentifier(path, resource.name, modelFieldNames));
1187
+ }
1188
+ for (const include of view.includes) {
1189
+ if (!Number.isInteger(include.minItems) || include.minItems < 0) {
1190
+ errors.push({
1191
+ message: `Create include "${include.field}" in model "${resource.model}" minItems must be a non-negative integer`,
1192
+ nodeId: include.id,
1193
+ severity: 'error',
1194
+ });
1195
+ }
1196
+ const relationField = fieldMap.get(include.field);
1197
+ if (!relationField) {
1198
+ errors.push({
1199
+ message: `Create include "${include.field}" not found in model "${resource.model}"`,
1200
+ nodeId: include.id,
1201
+ severity: 'error',
1202
+ });
1203
+ continue;
1204
+ }
1205
+ if (!isInverseRelationField(relationField)) {
1206
+ errors.push({
1207
+ message: `Create include "${include.field}" in model "${resource.model}" must reference a hasMany(..., by: ...) field`,
1208
+ nodeId: include.id,
1209
+ severity: 'error',
1210
+ });
1211
+ continue;
1212
+ }
1213
+ const targetModel = modelMap.get(relationField.fieldType.target);
1214
+ if (!targetModel) {
1215
+ errors.push({
1216
+ message: `Create include "${include.field}" in model "${resource.model}" references unknown related model "${relationField.fieldType.target}"`,
1217
+ nodeId: include.id,
1218
+ severity: 'error',
1219
+ });
1220
+ continue;
1221
+ }
1222
+ const targetFieldMap = new Map(targetModel.fields.map((field) => [field.name, field]));
1223
+ const targetFieldNames = new Set(targetModel.fields.map((field) => field.name));
1224
+ for (const nestedField of include.fields) {
1225
+ const targetField = targetFieldMap.get(nestedField.field);
1226
+ if (!targetField && !nestedField.customField) {
1227
+ errors.push({
1228
+ message: `Create include field "${nestedField.field}" not found in related model "${targetModel.name}"`,
1229
+ nodeId: nestedField.id,
1230
+ severity: 'error',
1231
+ });
1232
+ continue;
1233
+ }
1234
+ if (!targetField || nestedField.customField) {
1235
+ continue;
1236
+ }
1237
+ if (targetField.name === relationField.fieldType.by) {
1238
+ errors.push({
1239
+ message: `Create include field "${nestedField.field}" in related model "${targetModel.name}" is the inverse belongsTo(${model.name}) field and is seeded automatically`,
1240
+ nodeId: nestedField.id,
1241
+ severity: 'error',
1242
+ });
1243
+ continue;
1244
+ }
1245
+ if (isInverseRelationField(targetField)) {
1246
+ errors.push({
1247
+ message: `Create include field "${nestedField.field}" in related model "${targetModel.name}" references inverse relation metadata and cannot be nested again in the current slice`,
1248
+ nodeId: nestedField.id,
1249
+ severity: 'error',
1250
+ });
1251
+ }
1252
+ validateFormFieldRulePaths(nestedField, errors);
1253
+ validateFormFieldRules(nestedField, errors, (path) => validateCreateIncludeFieldIdentifier(path, resource.name, targetFieldNames));
1254
+ }
1255
+ validateLinkedFormIncludeRules(resource, model, include, targetModel, errors, 'create');
1256
+ }
1257
+ }
1258
+ for (const effect of view.onSuccess) {
1259
+ if (effect.type === 'redirect') {
1260
+ validateRouteTarget(effect.target, view.id, resourceMap, pageMap, errors);
1261
+ }
1262
+ validateToastEffect(effect, {
1263
+ nodeId: view.id,
1264
+ viewName: 'create',
1265
+ model,
1266
+ allowRecord: false,
1267
+ allowForm: true,
1268
+ allowUser: true,
1269
+ allowedParams: new Set(),
1270
+ }, errors);
1271
+ }
1272
+ validateViewRulePaths(view.id, view.rules, errors);
1273
+ validateLinkedFormRules(resource, model, view, errors, 'create');
1274
+ return errors;
1275
+ }
1276
+ function validateReadView(resource, model, models, resources) {
1277
+ const errors = [];
1278
+ const view = resource.views.read;
1279
+ if (!view || !model) {
1280
+ return errors;
1281
+ }
1282
+ const fieldMap = new Map(model.fields.map((field) => [field.name, field]));
1283
+ for (const field of view.fields) {
1284
+ if (field.decorators.some((decorator) => decorator.name === 'sortable')) {
1285
+ errors.push({
1286
+ message: `Read field "${field.field}" in model "${resource.model}" cannot use @sortable`,
1287
+ nodeId: field.id,
1288
+ severity: 'error',
1289
+ });
1290
+ }
1291
+ const relationProjection = analyzeRelationProjection(field.field, model, models, resources);
1292
+ if (relationProjection.kind !== 'none') {
1293
+ if (relationProjection.kind === 'invalid' && !field.customRenderer) {
1294
+ errors.push({
1295
+ message: formatRelationProjectionError('Read field', resource, relationProjection),
1296
+ nodeId: field.id,
1297
+ severity: 'error',
1298
+ });
1299
+ }
1300
+ continue;
1301
+ }
1302
+ const modelField = fieldMap.get(field.field);
1303
+ if (!modelField && !field.customRenderer) {
1304
+ errors.push({
1305
+ message: `Read field "${field.field}" not found in model "${resource.model}"`,
1306
+ nodeId: field.id,
1307
+ severity: 'error',
1308
+ });
1309
+ }
1310
+ else if (modelField && isInverseRelationField(modelField) && !field.customRenderer) {
1311
+ errors.push({
1312
+ message: `Read field "${field.field}" in model "${resource.model}" references inverse relation metadata directly; use "${field.field}.count" or move it under related:`,
1313
+ nodeId: field.id,
1314
+ severity: 'error',
1315
+ });
1316
+ }
1317
+ }
1318
+ for (const panel of view.related) {
1319
+ const modelField = fieldMap.get(panel.field);
1320
+ if (!modelField) {
1321
+ errors.push({
1322
+ message: `Related panel "${panel.field}" not found in model "${resource.model}"`,
1323
+ nodeId: panel.id,
1324
+ severity: 'error',
1325
+ });
1326
+ continue;
1327
+ }
1328
+ if (!isInverseRelationField(modelField)) {
1329
+ errors.push({
1330
+ message: `Related panel "${panel.field}" in model "${resource.model}" must reference a hasMany(..., by: ...) field`,
1331
+ nodeId: panel.id,
1332
+ severity: 'error',
1333
+ });
1334
+ continue;
1335
+ }
1336
+ const relationField = modelField.fieldType;
1337
+ const targetResource = resources.find((candidate) => candidate.model === relationField.target);
1338
+ if (!targetResource) {
1339
+ errors.push({
1340
+ message: `Related panel "${panel.field}" in model "${resource.model}" requires a resource for related model "${relationField.target}"`,
1341
+ nodeId: panel.id,
1342
+ severity: 'error',
1343
+ });
1344
+ }
1345
+ }
1346
+ return errors;
1347
+ }
1348
+ function validateToastEffect(effect, context, errors) {
1349
+ if (effect.type !== 'toast' || typeof effect.message === 'string') {
1350
+ return;
1351
+ }
1352
+ validateToastDescriptor(effect.message, context, errors);
1353
+ }
1354
+ function isInverseRelationField(field) {
1355
+ return field.fieldType.type === 'relation' && field.fieldType.kind === 'hasMany';
1356
+ }
1357
+ function formatRelationProjectionError(subject, resource, analysis) {
1358
+ switch (analysis.reason) {
1359
+ case 'unsupportedPathShape':
1360
+ return `${subject} "${analysis.field}" in model "${resource.model}" must use a single relation hop like "team.name" or "members.count"`;
1361
+ case 'unknownRootField':
1362
+ return `${subject} "${analysis.field}" not found in model "${resource.model}"; relation projections must start from a declared relation field`;
1363
+ case 'rootFieldNotRelation':
1364
+ return `${subject} "${analysis.field}" in model "${resource.model}" must start from a relation field before projecting`;
1365
+ case 'targetModelMissing':
1366
+ return `${subject} "${analysis.field}" in model "${resource.model}" references unknown relation target "${analysis.rootField?.fieldType.type === 'relation' ? analysis.rootField.fieldType.target : analysis.rootFieldName}"`;
1367
+ case 'targetResourceMissing':
1368
+ return `${subject} "${analysis.field}" in model "${resource.model}" requires a resource for related model "${analysis.targetModel?.name}" to drive relation projection`;
1369
+ case 'unsupportedHasManyLeaf':
1370
+ return `${subject} "${analysis.field}" in model "${resource.model}" only supports hasMany(...).count projections in generated ${subject === 'Read field' ? 'read surfaces' : 'lists'}`;
1371
+ case 'unknownTargetField':
1372
+ return `${subject} "${analysis.field}" in model "${resource.model}" references unknown field "${analysis.leafFieldName}" on related model "${analysis.targetModel?.name}"`;
1373
+ case 'unsupportedTargetField':
1374
+ return `${subject} "${analysis.field}" in model "${resource.model}" can only project scalar or enum fields from belongsTo(${analysis.targetModel?.name}); nested relation chains are not supported`;
1375
+ }
1376
+ }
1377
+ function validateToastDescriptor(descriptor, context, errors) {
1378
+ if (!descriptor.key || descriptor.key.trim().length === 0) {
1379
+ errors.push({
1380
+ message: 'toast descriptor key must be a non-empty string',
1381
+ nodeId: context.nodeId,
1382
+ severity: 'error',
1383
+ });
1384
+ }
1385
+ if (!descriptor.values) {
1386
+ return;
1387
+ }
1388
+ for (const [name, value] of Object.entries(descriptor.values)) {
1389
+ validateToastMessageValue(name, value, context, errors);
1390
+ }
1391
+ }
1392
+ function validateToastMessageValue(name, value, context, errors) {
1393
+ if (typeof value === 'string'
1394
+ || typeof value === 'number'
1395
+ || typeof value === 'boolean'
1396
+ || value === null) {
1397
+ return;
1398
+ }
1399
+ const ref = value.ref.trim();
1400
+ if (!/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)+$/.test(ref)) {
1401
+ errors.push({
1402
+ message: `toast.values.${name} ref "${value.ref}" must be a dotted path like form.name`,
1403
+ nodeId: context.nodeId,
1404
+ severity: 'error',
1405
+ });
1406
+ return;
1407
+ }
1408
+ const [root, ...segments] = ref.split('.');
1409
+ switch (root) {
1410
+ case 'form':
1411
+ if (!context.allowForm) {
1412
+ errors.push({
1413
+ message: `toast.values.${name} ref "${value.ref}" is not available in ${context.viewName} views; allowed refs here are ${formatAllowedToastRoots(context)}`,
1414
+ nodeId: context.nodeId,
1415
+ severity: 'error',
1416
+ });
1417
+ return;
1418
+ }
1419
+ validateModelBackedToastRef(name, value.ref, segments, context.model, context.nodeId, errors, 'form');
1420
+ return;
1421
+ case 'record':
1422
+ if (!context.allowRecord) {
1423
+ const suggestedRef = segments.length > 0 ? `form.${segments.join('.')}` : 'form.<field>';
1424
+ errors.push({
1425
+ message: `toast.values.${name} ref "${value.ref}" is not available in ${context.viewName} views; create views do not expose record.* values, use ${suggestedRef} instead`,
1426
+ nodeId: context.nodeId,
1427
+ severity: 'error',
1428
+ });
1429
+ return;
1430
+ }
1431
+ validateModelBackedToastRef(name, value.ref, segments, context.model, context.nodeId, errors, 'record');
1432
+ return;
1433
+ case 'user':
1434
+ if (!context.allowUser) {
1435
+ errors.push({
1436
+ message: `toast.values.${name} ref "${value.ref}" is not available in ${context.viewName} views; allowed refs here are ${formatAllowedToastRoots(context)}`,
1437
+ nodeId: context.nodeId,
1438
+ severity: 'error',
1439
+ });
1440
+ }
1441
+ return;
1442
+ case 'params':
1443
+ if (context.allowedParams.size === 0) {
1444
+ errors.push({
1445
+ message: `toast.values.${name} ref "${value.ref}" is not available in ${context.viewName} views; ${context.viewName} views do not expose route params`,
1446
+ nodeId: context.nodeId,
1447
+ severity: 'error',
1448
+ });
1449
+ return;
1450
+ }
1451
+ if (segments.length !== 1 || !context.allowedParams.has(segments[0])) {
1452
+ errors.push({
1453
+ message: `toast.values.${name} ref "${value.ref}" is not a supported route param in ${context.viewName} views; allowed params: ${Array.from(context.allowedParams).sort().join(', ')}`,
1454
+ nodeId: context.nodeId,
1455
+ severity: 'error',
1456
+ });
1457
+ }
1458
+ return;
1459
+ default:
1460
+ errors.push({
1461
+ message: `toast.values.${name} ref "${value.ref}" must start with one of: ${formatAllowedToastRoots(context)}`,
1462
+ nodeId: context.nodeId,
1463
+ severity: 'error',
1464
+ });
1465
+ }
1466
+ }
1467
+ function formatAllowedToastRoots(context) {
1468
+ const roots = [];
1469
+ if (context.allowForm)
1470
+ roots.push('form.<field>');
1471
+ if (context.allowRecord)
1472
+ roots.push('record.<field>');
1473
+ if (context.allowUser)
1474
+ roots.push('user.<field>');
1475
+ if (context.allowedParams.size > 0) {
1476
+ for (const param of Array.from(context.allowedParams).sort()) {
1477
+ roots.push(`params.${param}`);
1478
+ }
1479
+ }
1480
+ return roots.join(', ');
1481
+ }
1482
+ function validateModelBackedToastRef(name, ref, segments, model, nodeId, errors, root) {
1483
+ if (segments.length === 0) {
1484
+ errors.push({
1485
+ message: `toast.values.${name} ref "${ref}" must include a field after ${root}.`,
1486
+ nodeId,
1487
+ severity: 'error',
1488
+ });
1489
+ return;
1490
+ }
1491
+ if (!model) {
1492
+ return;
1493
+ }
1494
+ const fieldNames = new Set(model.fields.map((field) => field.name));
1495
+ if (!fieldNames.has(segments[0])) {
1496
+ errors.push({
1497
+ message: `toast.values.${name} ref "${ref}" references unknown field "${segments[0]}" on model "${model.name}"`,
1498
+ nodeId,
1499
+ severity: 'error',
1500
+ });
1501
+ }
1502
+ }
1503
+ function validatePage(page, resources, models, readModels) {
1504
+ const errors = [];
1505
+ const routePath = page.path;
1506
+ const routeParams = routePath ? parseRouteParamNames(routePath) : [];
1507
+ const relationBlocks = page.blocks
1508
+ .map((block) => ({
1509
+ block,
1510
+ analysis: analyzePageBlockData(block, resources, models, readModels),
1511
+ }))
1512
+ .filter((entry) => entry.analysis.kind === 'recordRelationList' || entry.analysis.kind === 'recordRelationCount');
1513
+ if (!page.title) {
1514
+ errors.push({
1515
+ message: `Page "${page.name}" has no title`,
1516
+ nodeId: page.id,
1517
+ severity: 'warning',
1518
+ });
1519
+ }
1520
+ if (routePath) {
1521
+ if (!isSupportedRoutePath(routePath)) {
1522
+ errors.push({
1523
+ message: `Page "${page.name}" path "${routePath}" must start with "/" and use only static segments or :params`,
1524
+ nodeId: page.id,
1525
+ severity: 'error',
1526
+ });
1527
+ }
1528
+ if (relationBlocks.length === 0) {
1529
+ errors.push({
1530
+ message: `Page "${page.name}" uses path: but page-scoped params are currently only supported for relation blocks using data: <resource>.<hasManyField> or data: <resource>.<hasManyField>.count`,
1531
+ nodeId: page.id,
1532
+ severity: 'error',
1533
+ });
1534
+ }
1535
+ }
1536
+ if (relationBlocks.length > 0) {
1537
+ const parentResources = Array.from(new Set(relationBlocks.map((entry) => entry.analysis.resourceName)));
1538
+ if (parentResources.length > 1) {
1539
+ errors.push({
1540
+ message: `Page "${page.name}" mixes record-scoped relation blocks from multiple resources (${parentResources.join(', ')}); current page-scoped relation routes must belong to one parent resource`,
1541
+ nodeId: page.id,
1542
+ severity: 'error',
1543
+ });
1544
+ }
1545
+ const parentResourceName = parentResources[0];
1546
+ if (!routePath) {
1547
+ errors.push({
1548
+ message: `Page "${page.name}" contains record-scoped relation blocks and must set path: /${parentResourceName}/:id/...`,
1549
+ nodeId: page.id,
1550
+ severity: 'error',
1551
+ });
1552
+ }
1553
+ else if (isSupportedRoutePath(routePath)) {
1554
+ if (!routeParams.includes('id')) {
1555
+ errors.push({
1556
+ message: `Page "${page.name}" path "${routePath}" must include :id for record-scoped relation blocks`,
1557
+ nodeId: page.id,
1558
+ severity: 'error',
1559
+ });
1560
+ }
1561
+ const expectedPrefix = `/${parentResourceName}/:id`;
1562
+ if (!(routePath === expectedPrefix || routePath.startsWith(`${expectedPrefix}/`))) {
1563
+ errors.push({
1564
+ message: `Page "${page.name}" path "${routePath}" must start with "${expectedPrefix}" to scope data: ${parentResourceName}.<relation>`,
1565
+ nodeId: page.id,
1566
+ severity: 'error',
1567
+ });
1568
+ }
1569
+ }
1570
+ }
1571
+ for (const block of page.blocks) {
1572
+ const analysis = analyzePageBlockData(block, resources, models, readModels);
1573
+ if (analysis.kind === 'none'
1574
+ || analysis.kind === 'resourceList'
1575
+ || analysis.kind === 'readModelList'
1576
+ || analysis.kind === 'readModelCount'
1577
+ || analysis.kind === 'recordRelationList'
1578
+ || analysis.kind === 'recordRelationCount') {
1579
+ continue;
1580
+ }
1581
+ const blockLabel = block.title || block.id;
1582
+ const blockKindLabel = block.blockType === 'metric' ? 'Metric block' : 'Table block';
1583
+ switch (analysis.reason) {
1584
+ case 'missingData':
1585
+ errors.push({
1586
+ message: block.blockType === 'metric'
1587
+ ? `Metric block "${blockLabel}" in page "${page.name}" must set data: readModel.<name>.count or data: <resource>.<hasManyField>.count`
1588
+ : `Table block "${blockLabel}" in page "${page.name}" must set data: <resource>.list, data: readModel.<name>.list, or data: <resource>.<hasManyField>`,
1589
+ nodeId: block.id,
1590
+ severity: 'error',
1591
+ });
1592
+ break;
1593
+ case 'unsupportedDataRef':
1594
+ errors.push({
1595
+ message: block.blockType === 'metric'
1596
+ ? `Metric block "${blockLabel}" in page "${page.name}" must use data: readModel.<name>.count or data: <resource>.<hasManyField>.count; got "${analysis.data}"`
1597
+ : `Table block "${blockLabel}" in page "${page.name}" must use data: <resource>.list, data: readModel.<name>.list, or data: <resource>.<hasManyField>; got "${analysis.data}"`,
1598
+ nodeId: block.id,
1599
+ severity: 'error',
1600
+ });
1601
+ break;
1602
+ case 'readModelMissing':
1603
+ errors.push({
1604
+ message: `${blockKindLabel} "${blockLabel}" in page "${page.name}" references unknown read-model "${analysis.readModelName}"`,
1605
+ nodeId: block.id,
1606
+ severity: 'error',
1607
+ });
1608
+ break;
1609
+ case 'readModelListMissing':
1610
+ errors.push({
1611
+ message: `${blockKindLabel} "${blockLabel}" in page "${page.name}" requires read-model "${analysis.readModelName}" to define list:`,
1612
+ nodeId: block.id,
1613
+ severity: 'error',
1614
+ });
1615
+ break;
1616
+ case 'resourceMissing':
1617
+ errors.push({
1618
+ message: `${blockKindLabel} "${blockLabel}" in page "${page.name}" references unknown resource "${analysis.resourceName}"`,
1619
+ nodeId: block.id,
1620
+ severity: 'error',
1621
+ });
1622
+ break;
1623
+ case 'resourceListMissing':
1624
+ errors.push({
1625
+ message: `Table block "${blockLabel}" in page "${page.name}" requires resource "${analysis.resourceName}" to define list:`,
1626
+ nodeId: block.id,
1627
+ severity: 'error',
1628
+ });
1629
+ break;
1630
+ case 'modelMissing':
1631
+ errors.push({
1632
+ message: `${blockKindLabel} "${blockLabel}" in page "${page.name}" references resource "${analysis.resourceName}" with missing model "${analysis.resource?.model}"`,
1633
+ nodeId: block.id,
1634
+ severity: 'error',
1635
+ });
1636
+ break;
1637
+ case 'relationFieldMissing':
1638
+ errors.push({
1639
+ message: `${blockKindLabel} "${blockLabel}" in page "${page.name}" references unknown relation field "${analysis.relationFieldName}" on resource "${analysis.resourceName}"`,
1640
+ nodeId: block.id,
1641
+ severity: 'error',
1642
+ });
1643
+ break;
1644
+ case 'relationFieldNotHasMany':
1645
+ errors.push({
1646
+ message: `${blockKindLabel} "${blockLabel}" in page "${page.name}" must reference a hasMany(..., by: ...) field; "${analysis.relationFieldName}" on resource "${analysis.resourceName}" is not one`,
1647
+ nodeId: block.id,
1648
+ severity: 'error',
1649
+ });
1650
+ break;
1651
+ case 'targetModelMissing':
1652
+ errors.push({
1653
+ message: `${blockKindLabel} "${blockLabel}" in page "${page.name}" references relation "${analysis.relationFieldName}" on resource "${analysis.resourceName}" with missing target model "${analysis.relationField?.fieldType.type === 'relation' ? analysis.relationField.fieldType.target : '<unknown>'}"`,
1654
+ nodeId: block.id,
1655
+ severity: 'error',
1656
+ });
1657
+ break;
1658
+ case 'targetResourceMissing':
1659
+ errors.push({
1660
+ message: `${blockKindLabel} "${blockLabel}" in page "${page.name}" references relation "${analysis.relationFieldName}" on resource "${analysis.resourceName}" but target resource "${analysis.targetModel?.name}" does not exist`,
1661
+ nodeId: block.id,
1662
+ severity: 'error',
1663
+ });
1664
+ break;
1665
+ case 'targetResourceListMissing':
1666
+ errors.push({
1667
+ message: `Table block "${blockLabel}" in page "${page.name}" requires related resource "${analysis.targetResource?.name ?? analysis.targetModel?.name}" to define list:`,
1668
+ nodeId: block.id,
1669
+ severity: 'error',
1670
+ });
1671
+ break;
1672
+ }
1673
+ }
1674
+ const queryStateGroups = new Map();
1675
+ const selectionStateGroups = new Map();
1676
+ for (const block of page.blocks) {
1677
+ const analysis = analyzePageBlockData(block, resources, models, readModels);
1678
+ const blockLabel = block.title || block.id;
1679
+ if (block.queryState) {
1680
+ if (block.queryState.trim() === '') {
1681
+ errors.push({
1682
+ message: `Page block "${blockLabel}" in page "${page.name}" must not use an empty queryState`,
1683
+ nodeId: block.id,
1684
+ severity: 'error',
1685
+ });
1686
+ }
1687
+ else if (analysis.kind !== 'readModelList' && analysis.kind !== 'readModelCount') {
1688
+ errors.push({
1689
+ message: `Page block "${blockLabel}" in page "${page.name}" may only use queryState with data: readModel.<name>.list or data: readModel.<name>.count`,
1690
+ nodeId: block.id,
1691
+ severity: 'error',
1692
+ });
1693
+ }
1694
+ else {
1695
+ const group = queryStateGroups.get(block.queryState.trim()) ?? [];
1696
+ group.push({ block, readModel: analysis.readModel });
1697
+ queryStateGroups.set(block.queryState.trim(), group);
1698
+ }
1699
+ }
1700
+ if (block.selectionState) {
1701
+ if (block.selectionState.trim() === '') {
1702
+ errors.push({
1703
+ message: `Page block "${blockLabel}" in page "${page.name}" must not use an empty selectionState`,
1704
+ nodeId: block.id,
1705
+ severity: 'error',
1706
+ });
1707
+ }
1708
+ else if (analysis.kind !== 'readModelList' || block.blockType !== 'table') {
1709
+ errors.push({
1710
+ message: `Page block "${blockLabel}" in page "${page.name}" may only use selectionState with data: readModel.<name>.list table consumers in the current slice`,
1711
+ nodeId: block.id,
1712
+ severity: 'error',
1713
+ });
1714
+ }
1715
+ else {
1716
+ const existing = selectionStateGroups.get(block.selectionState.trim());
1717
+ if (existing) {
1718
+ errors.push({
1719
+ message: `Page "${page.name}" may not reuse selectionState "${block.selectionState.trim()}" across multiple table blocks`,
1720
+ nodeId: block.id,
1721
+ severity: 'error',
1722
+ });
1723
+ }
1724
+ else {
1725
+ selectionStateGroups.set(block.selectionState.trim(), { block, readModel: analysis.readModel });
1726
+ }
1727
+ }
1728
+ }
1729
+ if (block.dateNavigation) {
1730
+ if (analysis.kind !== 'readModelList') {
1731
+ errors.push({
1732
+ message: `Page block "${blockLabel}" in page "${page.name}" may only use dateNavigation with data: readModel.<name>.list`,
1733
+ nodeId: block.id,
1734
+ severity: 'error',
1735
+ });
1736
+ continue;
1737
+ }
1738
+ const dateNavigation = block.dateNavigation;
1739
+ const inputField = analysis.readModel.inputs.find((field) => field.name === dateNavigation.field);
1740
+ if (!inputField) {
1741
+ errors.push({
1742
+ message: `Page block "${blockLabel}" in page "${page.name}" dateNavigation field "${dateNavigation.field}" must reference a read-model input`,
1743
+ nodeId: block.id,
1744
+ severity: 'error',
1745
+ });
1746
+ }
1747
+ else if (inputField.fieldType.type !== 'scalar' || (inputField.fieldType.name !== 'string' && inputField.fieldType.name !== 'datetime')) {
1748
+ errors.push({
1749
+ message: `Page block "${blockLabel}" in page "${page.name}" dateNavigation field "${dateNavigation.field}" must be a string/date-like read-model input in the current slice`,
1750
+ nodeId: block.id,
1751
+ severity: 'error',
1752
+ });
1753
+ }
1754
+ }
1755
+ }
1756
+ for (const [queryStateName, group] of queryStateGroups.entries()) {
1757
+ if (group.length < 2) {
1758
+ continue;
1759
+ }
1760
+ const expectedSignature = serializeReadModelInputSignature(group[0].readModel);
1761
+ for (const entry of group.slice(1)) {
1762
+ const actualSignature = serializeReadModelInputSignature(entry.readModel);
1763
+ if (actualSignature !== expectedSignature) {
1764
+ errors.push({
1765
+ message: `Page "${page.name}" queryState "${queryStateName}" may only be shared by read-model consumers with identical inputs; "${group[0].readModel.name}" and "${entry.readModel.name}" differ`,
1766
+ nodeId: entry.block.id,
1767
+ severity: 'error',
1768
+ });
1769
+ }
1770
+ }
1771
+ }
1772
+ for (const block of page.blocks) {
1773
+ errors.push(...validatePageBlockRowActions(page, block, resources, models, readModels));
1774
+ }
1775
+ errors.push(...validatePageActions(page, resources, models, queryStateGroups, selectionStateGroups));
1776
+ return errors;
1777
+ }
1778
+ function serializeReadModelInputSignature(readModel) {
1779
+ return JSON.stringify(readModel.inputs.map((field) => ({
1780
+ name: field.name,
1781
+ fieldType: field.fieldType,
1782
+ decorators: field.decorators.map((decorator) => ({ name: decorator.name, args: decorator.args ?? null })),
1783
+ })));
1784
+ }
1785
+ function validatePageBlockRowActions(page, block, resources, models, readModels) {
1786
+ const errors = [];
1787
+ if (block.rowActions.length === 0) {
1788
+ return errors;
1789
+ }
1790
+ if (block.blockType !== 'table') {
1791
+ errors.push({
1792
+ message: `Page block "${block.title || block.id}" in page "${page.name}" may only use rowActions on type: table`,
1793
+ nodeId: block.id,
1794
+ severity: 'error',
1795
+ });
1796
+ return errors;
1797
+ }
1798
+ const analysis = analyzePageBlockData(block, resources, models, readModels);
1799
+ if (analysis.kind !== 'readModelList') {
1800
+ errors.push({
1801
+ message: `Table block "${block.title || block.id}" in page "${page.name}" may only use rowActions with data: readModel.<name>.list in the current slice`,
1802
+ nodeId: block.id,
1803
+ severity: 'error',
1804
+ });
1805
+ return errors;
1806
+ }
1807
+ const readModelInputNames = new Set(analysis.readModel.inputs.map((field) => field.name));
1808
+ const readModelResultNames = new Set(analysis.readModel.result.map((field) => field.name));
1809
+ for (const action of block.rowActions) {
1810
+ const targetResource = resources.find((candidate) => candidate.name === action.resource);
1811
+ if (!targetResource) {
1812
+ errors.push({
1813
+ message: `Row action "${action.label}" in page "${page.name}" references unknown resource "${action.resource}"`,
1814
+ nodeId: action.id,
1815
+ severity: 'error',
1816
+ });
1817
+ continue;
1818
+ }
1819
+ if (!targetResource.views.create) {
1820
+ errors.push({
1821
+ message: `Row action "${action.label}" in page "${page.name}" requires resource "${targetResource.name}" to define create:`,
1822
+ nodeId: action.id,
1823
+ severity: 'error',
1824
+ });
1825
+ continue;
1826
+ }
1827
+ const targetModel = models.find((candidate) => candidate.name === targetResource.model);
1828
+ if (!targetModel) {
1829
+ errors.push({
1830
+ message: `Row action "${action.label}" in page "${page.name}" references resource "${targetResource.name}" with missing model "${targetResource.model}"`,
1831
+ nodeId: action.id,
1832
+ severity: 'error',
1833
+ });
1834
+ continue;
1835
+ }
1836
+ const createFieldNames = new Set(targetResource.views.create.fields.map((field) => field.field));
1837
+ const targetFieldMap = new Map(targetModel.fields.map((field) => [field.name, field]));
1838
+ for (const [fieldName, seedValue] of Object.entries(action.seed)) {
1839
+ const targetField = targetFieldMap.get(fieldName);
1840
+ if (!targetField) {
1841
+ errors.push({
1842
+ message: `Row action "${action.label}" in page "${page.name}" seeds unknown field "${fieldName}" on model "${targetModel.name}"`,
1843
+ nodeId: action.id,
1844
+ severity: 'error',
1845
+ });
1846
+ continue;
1847
+ }
1848
+ if (!createFieldNames.has(fieldName)) {
1849
+ errors.push({
1850
+ message: `Row action "${action.label}" in page "${page.name}" may only seed fields already present in resource "${targetResource.name}" create.fields; "${fieldName}" is not included`,
1851
+ nodeId: action.id,
1852
+ severity: 'error',
1853
+ });
1854
+ }
1855
+ if (targetField.fieldType.type === 'relation' && targetField.fieldType.kind !== 'belongsTo') {
1856
+ errors.push({
1857
+ message: `Row action "${action.label}" in page "${page.name}" may only seed top-level scalar, enum, or belongsTo fields; "${fieldName}" is not supported`,
1858
+ nodeId: action.id,
1859
+ severity: 'error',
1860
+ });
1861
+ }
1862
+ if (seedValue.kind === 'rowField' && !readModelResultNames.has(seedValue.field)) {
1863
+ errors.push({
1864
+ message: `Row action "${action.label}" in page "${page.name}" references unknown read-model result field "${seedValue.field}"`,
1865
+ nodeId: action.id,
1866
+ severity: 'error',
1867
+ });
1868
+ }
1869
+ if (seedValue.kind === 'inputField' && !readModelInputNames.has(seedValue.field)) {
1870
+ errors.push({
1871
+ message: `Row action "${action.label}" in page "${page.name}" references unknown read-model input field "${seedValue.field}"`,
1872
+ nodeId: action.id,
1873
+ severity: 'error',
1874
+ });
1875
+ }
1876
+ }
1877
+ }
1878
+ return errors;
1879
+ }
1880
+ function validatePageActions(page, resources, models, queryStateGroups, selectionStateGroups) {
1881
+ const errors = [];
1882
+ for (const action of page.actions) {
1883
+ const targetResource = resources.find((candidate) => candidate.name === action.resource);
1884
+ if (!targetResource) {
1885
+ errors.push({
1886
+ message: `Page action "${action.label}" in page "${page.name}" references unknown resource "${action.resource}"`,
1887
+ nodeId: action.id,
1888
+ severity: 'error',
1889
+ });
1890
+ continue;
1891
+ }
1892
+ if (!targetResource.views.create) {
1893
+ errors.push({
1894
+ message: `Page action "${action.label}" in page "${page.name}" requires resource "${targetResource.name}" to define create:`,
1895
+ nodeId: action.id,
1896
+ severity: 'error',
1897
+ });
1898
+ continue;
1899
+ }
1900
+ const targetModel = models.find((candidate) => candidate.name === targetResource.model);
1901
+ if (!targetModel) {
1902
+ errors.push({
1903
+ message: `Page action "${action.label}" in page "${page.name}" references resource "${targetResource.name}" with missing model "${targetResource.model}"`,
1904
+ nodeId: action.id,
1905
+ severity: 'error',
1906
+ });
1907
+ continue;
1908
+ }
1909
+ const createFieldNames = new Set(targetResource.views.create.fields.map((field) => field.field));
1910
+ const targetFieldMap = new Map(targetModel.fields.map((field) => [field.name, field]));
1911
+ for (const [fieldName, seedValue] of Object.entries(action.seed)) {
1912
+ const targetField = targetFieldMap.get(fieldName);
1913
+ if (!targetField) {
1914
+ errors.push({
1915
+ message: `Page action "${action.label}" in page "${page.name}" seeds unknown field "${fieldName}" on model "${targetModel.name}"`,
1916
+ nodeId: action.id,
1917
+ severity: 'error',
1918
+ });
1919
+ continue;
1920
+ }
1921
+ if (!createFieldNames.has(fieldName)) {
1922
+ errors.push({
1923
+ message: `Page action "${action.label}" in page "${page.name}" may only seed fields already present in resource "${targetResource.name}" create.fields; "${fieldName}" is not included`,
1924
+ nodeId: action.id,
1925
+ severity: 'error',
1926
+ });
1927
+ }
1928
+ if (targetField.fieldType.type === 'relation' && targetField.fieldType.kind !== 'belongsTo') {
1929
+ errors.push({
1930
+ message: `Page action "${action.label}" in page "${page.name}" may only seed top-level scalar, enum, or belongsTo fields; "${fieldName}" is not supported`,
1931
+ nodeId: action.id,
1932
+ severity: 'error',
1933
+ });
1934
+ }
1935
+ if (seedValue.kind === 'inputField') {
1936
+ if (!seedValue.queryState || !seedValue.field) {
1937
+ errors.push({
1938
+ message: `Page action "${action.label}" in page "${page.name}" must reference input seeds as <queryState>.<field>`,
1939
+ nodeId: action.id,
1940
+ severity: 'error',
1941
+ });
1942
+ continue;
1943
+ }
1944
+ const queryStateGroup = queryStateGroups.get(seedValue.queryState);
1945
+ if (!queryStateGroup || queryStateGroup.length === 0) {
1946
+ errors.push({
1947
+ message: `Page action "${action.label}" in page "${page.name}" references unknown queryState "${seedValue.queryState}"`,
1948
+ nodeId: action.id,
1949
+ severity: 'error',
1950
+ });
1951
+ continue;
1952
+ }
1953
+ const inputNames = new Set(queryStateGroup[0].readModel.inputs.map((field) => field.name));
1954
+ if (!inputNames.has(seedValue.field)) {
1955
+ errors.push({
1956
+ message: `Page action "${action.label}" in page "${page.name}" references unknown input "${seedValue.field}" on queryState "${seedValue.queryState}"`,
1957
+ nodeId: action.id,
1958
+ severity: 'error',
1959
+ });
1960
+ }
1961
+ }
1962
+ if (seedValue.kind === 'selectionField') {
1963
+ if (!seedValue.selectionState || !seedValue.field) {
1964
+ errors.push({
1965
+ message: `Page action "${action.label}" in page "${page.name}" must reference selection seeds as <selectionState>.<field>`,
1966
+ nodeId: action.id,
1967
+ severity: 'error',
1968
+ });
1969
+ continue;
1970
+ }
1971
+ const selectionState = selectionStateGroups.get(seedValue.selectionState);
1972
+ if (!selectionState) {
1973
+ errors.push({
1974
+ message: `Page action "${action.label}" in page "${page.name}" references unknown selectionState "${seedValue.selectionState}"`,
1975
+ nodeId: action.id,
1976
+ severity: 'error',
1977
+ });
1978
+ continue;
1979
+ }
1980
+ const resultNames = new Set(selectionState.readModel.result.map((field) => field.name));
1981
+ if (!resultNames.has(seedValue.field)) {
1982
+ errors.push({
1983
+ message: `Page action "${action.label}" in page "${page.name}" references unknown selected field "${seedValue.field}" on selectionState "${seedValue.selectionState}"`,
1984
+ nodeId: action.id,
1985
+ severity: 'error',
1986
+ });
1987
+ }
1988
+ }
1989
+ }
1990
+ }
1991
+ return errors;
1992
+ }
1993
+ function isSupportedRoutePath(path) {
1994
+ if (!path.startsWith('/')) {
1995
+ return false;
1996
+ }
1997
+ const parts = path.split('/').filter(Boolean);
1998
+ return parts.length > 0 && parts.every((part) => ROUTE_PATH_SEGMENT_PATTERN.test(part));
1999
+ }
2000
+ function parseRouteParamNames(path) {
2001
+ if (!isSupportedRoutePath(path)) {
2002
+ return [];
2003
+ }
2004
+ return path
2005
+ .split('/')
2006
+ .filter(Boolean)
2007
+ .filter((part) => part.startsWith(':'))
2008
+ .map((part) => part.slice(1));
2009
+ }
2010
+ function validateViewRulePaths(nodeId, rules, errors) {
2011
+ if (!rules) {
2012
+ return;
2013
+ }
2014
+ for (const [key, rule] of Object.entries(rules)) {
2015
+ if (rule?.source !== 'escape-fn') {
2016
+ continue;
2017
+ }
2018
+ const path = rule.escape.path;
2019
+ const isAbsolute = path.startsWith('/') || /^[A-Za-z]:/.test(path);
2020
+ const escapesProjectRoot = path.startsWith('../');
2021
+ if (isAbsolute || escapesProjectRoot || path.length === 0) {
2022
+ errors.push({
2023
+ message: `@fn() path "${path}" in ${key} must resolve to a project-relative file inside the app root`,
2024
+ nodeId,
2025
+ severity: 'error',
2026
+ });
2027
+ }
2028
+ }
2029
+ }
2030
+ function validateRuleValuePath(rule, key, nodeId, errors) {
2031
+ if (rule?.source !== 'escape-fn') {
2032
+ return;
2033
+ }
2034
+ const path = rule.escape.path;
2035
+ const isAbsolute = path.startsWith('/') || /^[A-Za-z]:/.test(path);
2036
+ const escapesProjectRoot = path.startsWith('../');
2037
+ if (isAbsolute || escapesProjectRoot || path.length === 0) {
2038
+ errors.push({
2039
+ message: `@fn() path "${path}" in ${key} must resolve to a project-relative file inside the app root`,
2040
+ nodeId,
2041
+ severity: 'error',
2042
+ });
2043
+ }
2044
+ }
2045
+ function validateFormFieldRulePaths(field, errors) {
2046
+ validateRuleValuePath(field.visibleWhen, 'field.visibleIf', field.id, errors);
2047
+ validateRuleValuePath(field.enabledWhen, 'field.enabledIf', field.id, errors);
2048
+ }
2049
+ function validateFormFieldRules(field, errors, validateIdentifier) {
2050
+ if (field.visibleWhen?.source === 'builtin') {
2051
+ validateWorkflowExpr(field.visibleWhen.expr, field.id, errors, validateIdentifier);
2052
+ }
2053
+ if (field.enabledWhen?.source === 'builtin') {
2054
+ validateWorkflowExpr(field.enabledWhen.expr, field.id, errors, validateIdentifier);
2055
+ }
2056
+ }
2057
+ function validateWorkflowExpr(expr, nodeId, errors, validateIdentifier) {
2058
+ visitExpr(expr, (node) => {
2059
+ if (node.type === 'identifier') {
2060
+ const error = validateIdentifier(node.path);
2061
+ if (error) {
2062
+ errors.push({
2063
+ message: error,
2064
+ nodeId,
2065
+ severity: 'error',
2066
+ });
2067
+ }
2068
+ }
2069
+ });
2070
+ }
2071
+ function validateWorkflowTransitionIdentifier(path, resourceName, modelFieldNames) {
2072
+ if (path.length === 0) {
2073
+ return undefined;
2074
+ }
2075
+ const [root, property, ...rest] = path;
2076
+ if (path.length === 1 && /^[A-Z][A-Z0-9_]*$/.test(root)) {
2077
+ return undefined;
2078
+ }
2079
+ if (rest.length > 0) {
2080
+ return `Resource "${resourceName}" workflow transition rules support only one-level property access; got "${path.join('.')}"`;
2081
+ }
2082
+ if (root === 'currentUser') {
2083
+ if (!property) {
2084
+ return undefined;
2085
+ }
2086
+ if (!['id', 'role', 'roles'].includes(property)) {
2087
+ return `Resource "${resourceName}" workflow transition rules do not support currentUser.${property}; use currentUser.id, currentUser.role, or currentUser.roles`;
2088
+ }
2089
+ return undefined;
2090
+ }
2091
+ if (root === 'record') {
2092
+ if (!property) {
2093
+ return undefined;
2094
+ }
2095
+ if (property !== 'id' && !modelFieldNames.has(property)) {
2096
+ return `Resource "${resourceName}" workflow transition rules reference unknown record field "${property}"`;
2097
+ }
2098
+ return undefined;
2099
+ }
2100
+ return `Resource "${resourceName}" workflow transition rules use unsupported identifier root "${root}"; use currentUser, record, or bare enum-like literals`;
2101
+ }
2102
+ function validateReadModelRulesEligibilityIdentifier(path, readModelName, inputFieldNames) {
2103
+ if (path.length === 0) {
2104
+ return undefined;
2105
+ }
2106
+ const [root, property, ...rest] = path;
2107
+ if (path.length === 1 && /^[A-Z][A-Z0-9_]*$/.test(root)) {
2108
+ return undefined;
2109
+ }
2110
+ if (rest.length > 0) {
2111
+ return `Read-model "${readModelName}" rules eligibility supports only one-level property access; got "${path.join('.')}"`;
2112
+ }
2113
+ if (root === 'currentUser') {
2114
+ if (!property) {
2115
+ return undefined;
2116
+ }
2117
+ if (!['id', 'username', 'role', 'roles'].includes(property)) {
2118
+ return `Read-model "${readModelName}" rules eligibility does not support currentUser.${property}; use currentUser.id, currentUser.username, currentUser.role, or currentUser.roles`;
2119
+ }
2120
+ return undefined;
2121
+ }
2122
+ if (root === 'input') {
2123
+ if (!property) {
2124
+ return undefined;
2125
+ }
2126
+ if (!inputFieldNames.has(property)) {
2127
+ return `Read-model "${readModelName}" rules eligibility references unknown input field "${property}"`;
2128
+ }
2129
+ return undefined;
2130
+ }
2131
+ return `Read-model "${readModelName}" rules eligibility uses unsupported identifier root "${root}"; use currentUser, input, or bare enum-like literals`;
2132
+ }
2133
+ function validateLinkedFormRulesIdentifier(path, resourceName, mode, modelFieldNames) {
2134
+ if (path.length === 0) {
2135
+ return undefined;
2136
+ }
2137
+ const [root, property, ...rest] = path;
2138
+ if (path.length === 1 && /^[A-Z][A-Z0-9_]*$/.test(root)) {
2139
+ return undefined;
2140
+ }
2141
+ if (rest.length > 0) {
2142
+ return `Resource "${resourceName}" ${mode}.rules support only one-level property access; got "${path.join('.')}"`;
2143
+ }
2144
+ if (root === 'currentUser') {
2145
+ if (!property) {
2146
+ return undefined;
2147
+ }
2148
+ if (!['id', 'username', 'role', 'roles'].includes(property)) {
2149
+ return `Resource "${resourceName}" ${mode}.rules do not support currentUser.${property}; use currentUser.id, currentUser.username, currentUser.role, or currentUser.roles`;
2150
+ }
2151
+ return undefined;
2152
+ }
2153
+ if (root === 'formData') {
2154
+ if (!property) {
2155
+ return undefined;
2156
+ }
2157
+ if (!modelFieldNames.has(property)) {
2158
+ return `Resource "${resourceName}" ${mode}.rules reference unknown formData field "${property}"`;
2159
+ }
2160
+ return undefined;
2161
+ }
2162
+ if (root === 'record' && mode === 'edit') {
2163
+ if (!property) {
2164
+ return undefined;
2165
+ }
2166
+ if (property !== 'id' && !modelFieldNames.has(property)) {
2167
+ return `Resource "${resourceName}" edit.rules reference unknown record field "${property}"`;
2168
+ }
2169
+ return undefined;
2170
+ }
2171
+ return `Resource "${resourceName}" ${mode}.rules use unsupported identifier root "${root}"; use currentUser, formData${mode === 'edit' ? ', record' : ''}, or bare enum-like literals`;
2172
+ }
2173
+ function validateLinkedFormIncludeRulesIdentifier(path, resourceName, includeField, mode, parentModelFieldNames, includeFieldNames) {
2174
+ if (path.length === 0) {
2175
+ return undefined;
2176
+ }
2177
+ const [root, property, ...rest] = path;
2178
+ if (path.length === 1 && /^[A-Z][A-Z0-9_]*$/.test(root)) {
2179
+ return undefined;
2180
+ }
2181
+ if (rest.length > 0) {
2182
+ return `Resource "${resourceName}" ${mode}.includes.${includeField}.rules support only one-level property access; got "${path.join('.')}"`;
2183
+ }
2184
+ if (root === 'currentUser') {
2185
+ if (!property) {
2186
+ return undefined;
2187
+ }
2188
+ if (!['id', 'username', 'role', 'roles'].includes(property)) {
2189
+ return `Resource "${resourceName}" ${mode}.includes.${includeField}.rules do not support currentUser.${property}; use currentUser.id, currentUser.username, currentUser.role, or currentUser.roles`;
2190
+ }
2191
+ return undefined;
2192
+ }
2193
+ if (root === 'formData') {
2194
+ if (!property) {
2195
+ return undefined;
2196
+ }
2197
+ if (!parentModelFieldNames.has(property)) {
2198
+ return `Resource "${resourceName}" ${mode}.includes.${includeField}.rules reference unknown formData field "${property}"`;
2199
+ }
2200
+ return undefined;
2201
+ }
2202
+ if (root === 'item') {
2203
+ if (!property) {
2204
+ return undefined;
2205
+ }
2206
+ if (property !== 'id' && !includeFieldNames.has(property)) {
2207
+ return `Resource "${resourceName}" ${mode}.includes.${includeField}.rules reference unknown item field "${property}"`;
2208
+ }
2209
+ return undefined;
2210
+ }
2211
+ if (root === 'record' && mode === 'edit') {
2212
+ if (!property) {
2213
+ return undefined;
2214
+ }
2215
+ if (property !== 'id' && !parentModelFieldNames.has(property)) {
2216
+ return `Resource "${resourceName}" edit.includes.${includeField}.rules reference unknown record field "${property}"`;
2217
+ }
2218
+ return undefined;
2219
+ }
2220
+ return `Resource "${resourceName}" ${mode}.includes.${includeField}.rules use unsupported identifier root "${root}"; use currentUser, formData, item${mode === 'edit' ? ', record' : ''}, or bare enum-like literals`;
2221
+ }
2222
+ function validateCreateFieldIdentifier(path, resourceName, modelFieldNames) {
2223
+ return validateGeneratedFormFieldIdentifier(path, `resource ${resourceName} create field rules`, modelFieldNames, {
2224
+ allowRecord: false,
2225
+ allowItem: false,
2226
+ });
2227
+ }
2228
+ function validateEditFieldIdentifier(path, resourceName, modelFieldNames) {
2229
+ return validateGeneratedFormFieldIdentifier(path, `resource ${resourceName} edit field rules`, modelFieldNames, {
2230
+ allowRecord: true,
2231
+ allowItem: false,
2232
+ });
2233
+ }
2234
+ function validateCreateIncludeFieldIdentifier(path, resourceName, modelFieldNames) {
2235
+ return validateGeneratedFormFieldIdentifier(path, `resource ${resourceName} create include field rules`, modelFieldNames, {
2236
+ allowRecord: false,
2237
+ allowItem: true,
2238
+ });
2239
+ }
2240
+ function validateEditIncludeFieldIdentifier(path, resourceName, modelFieldNames) {
2241
+ return validateGeneratedFormFieldIdentifier(path, `resource ${resourceName} edit include field rules`, modelFieldNames, {
2242
+ allowRecord: true,
2243
+ allowItem: true,
2244
+ });
2245
+ }
2246
+ function validateGeneratedFormFieldIdentifier(path, surfaceLabel, modelFieldNames, options) {
2247
+ if (path.length === 0) {
2248
+ return undefined;
2249
+ }
2250
+ const [root, property, ...rest] = path;
2251
+ if (path.length === 1 && /^[A-Z][A-Z0-9_]*$/.test(root)) {
2252
+ return undefined;
2253
+ }
2254
+ if (rest.length > 0) {
2255
+ return `${surfaceLabel} support only one-level property access; got "${path.join('.')}"`;
2256
+ }
2257
+ if (root === 'currentUser') {
2258
+ if (!property) {
2259
+ return undefined;
2260
+ }
2261
+ if (!['id', 'username', 'role', 'roles'].includes(property)) {
2262
+ return `${surfaceLabel} do not support currentUser.${property}; use currentUser.id, currentUser.username, currentUser.role, or currentUser.roles`;
2263
+ }
2264
+ return undefined;
2265
+ }
2266
+ if (root === 'formData' || (options.allowRecord && root === 'record') || (options.allowItem && root === 'item')) {
2267
+ if (!property) {
2268
+ return undefined;
2269
+ }
2270
+ if (property !== 'id' && !modelFieldNames.has(property)) {
2271
+ return `${surfaceLabel} reference unknown ${root} field "${property}"`;
2272
+ }
2273
+ return undefined;
2274
+ }
2275
+ const roots = ['currentUser', 'formData'];
2276
+ if (options.allowRecord) {
2277
+ roots.push('record');
2278
+ }
2279
+ if (options.allowItem) {
2280
+ roots.push('item');
2281
+ }
2282
+ return `${surfaceLabel} use unsupported identifier root "${root}"; use ${roots.join(', ')}, or bare enum-like literals`;
2283
+ }
2284
+ function validateWorkflowStepIdentifier(path, resourceName, modelFieldNames) {
2285
+ if (path.length === 0) {
2286
+ return undefined;
2287
+ }
2288
+ const [root, property, ...rest] = path;
2289
+ if (path.length === 1 && /^[A-Z][A-Z0-9_]*$/.test(root)) {
2290
+ return undefined;
2291
+ }
2292
+ if (rest.length > 0) {
2293
+ return `Resource "${resourceName}" workflow wizard step rules support only one-level property access; got "${path.join('.')}"`;
2294
+ }
2295
+ if (root === 'currentUser') {
2296
+ if (!property) {
2297
+ return undefined;
2298
+ }
2299
+ if (!['id', 'role', 'roles'].includes(property)) {
2300
+ return `Resource "${resourceName}" workflow wizard step rules do not support currentUser.${property}; use currentUser.id, currentUser.role, or currentUser.roles`;
2301
+ }
2302
+ return undefined;
2303
+ }
2304
+ if (root === 'record' || root === 'formData') {
2305
+ if (!property) {
2306
+ return undefined;
2307
+ }
2308
+ if (property !== 'id' && !modelFieldNames.has(property)) {
2309
+ return `Resource "${resourceName}" workflow wizard step rules reference unknown ${root} field "${property}"`;
2310
+ }
2311
+ return undefined;
2312
+ }
2313
+ return `Resource "${resourceName}" workflow wizard step rules use unsupported identifier root "${root}"; use currentUser, record, formData, or bare enum-like literals`;
2314
+ }
2315
+ function validateReadModelRulesDerivationIdentifier(path, readModelName, inputFieldNames, resultFieldMap) {
2316
+ if (path.length === 0) {
2317
+ return undefined;
2318
+ }
2319
+ const [root, property, ...rest] = path;
2320
+ if (path.length === 1 && /^[A-Z][A-Z0-9_]*$/.test(root)) {
2321
+ return undefined;
2322
+ }
2323
+ if (rest.length > 0) {
2324
+ return `Read-model "${readModelName}" rules derive supports only one-level property access; got "${path.join('.')}"`;
2325
+ }
2326
+ if (root === 'currentUser') {
2327
+ if (!property) {
2328
+ return undefined;
2329
+ }
2330
+ if (!['id', 'username', 'role', 'roles'].includes(property)) {
2331
+ return `Read-model "${readModelName}" rules derive does not support currentUser.${property}; use currentUser.id, currentUser.username, currentUser.role, or currentUser.roles`;
2332
+ }
2333
+ return undefined;
2334
+ }
2335
+ if (root === 'input') {
2336
+ if (!property) {
2337
+ return undefined;
2338
+ }
2339
+ if (!inputFieldNames.has(property)) {
2340
+ return `Read-model "${readModelName}" rules derive references unknown input field "${property}"`;
2341
+ }
2342
+ return undefined;
2343
+ }
2344
+ if (root === 'item') {
2345
+ if (!property) {
2346
+ return undefined;
2347
+ }
2348
+ if (!resultFieldMap.has(property)) {
2349
+ return `Read-model "${readModelName}" rules derive references unknown item field "${property}"`;
2350
+ }
2351
+ return undefined;
2352
+ }
2353
+ return `Read-model "${readModelName}" rules derive uses unsupported identifier root "${root}"; use currentUser, input, item, or bare enum-like literals`;
2354
+ }
2355
+ function visitExpr(expr, visit) {
2356
+ visit(expr);
2357
+ if (expr.type === 'binary') {
2358
+ visitExpr(expr.left, visit);
2359
+ visitExpr(expr.right, visit);
2360
+ return;
2361
+ }
2362
+ if (expr.type === 'unary') {
2363
+ visitExpr(expr.operand, visit);
2364
+ return;
2365
+ }
2366
+ if (expr.type === 'call') {
2367
+ for (const arg of expr.args) {
2368
+ visitExpr(arg, visit);
2369
+ }
2370
+ return;
2371
+ }
2372
+ if (expr.type === 'member') {
2373
+ visitExpr(expr.object, visit);
2374
+ return;
2375
+ }
2376
+ if (expr.type === 'in') {
2377
+ visitExpr(expr.value, visit);
2378
+ for (const item of expr.list) {
2379
+ visitExpr(item, visit);
2380
+ }
2381
+ }
2382
+ }
2383
+ function collectDuplicateErrors(nodes, kind) {
2384
+ const grouped = new Map();
2385
+ for (const node of nodes) {
2386
+ const group = grouped.get(node.name);
2387
+ if (group) {
2388
+ group.push(node);
2389
+ }
2390
+ else {
2391
+ grouped.set(node.name, [node]);
2392
+ }
2393
+ }
2394
+ const errors = [];
2395
+ for (const [name, duplicates] of grouped) {
2396
+ if (duplicates.length < 2)
2397
+ continue;
2398
+ const locations = duplicates
2399
+ .map((node) => formatSourceLocation(node.sourceSpan))
2400
+ .join(', ');
2401
+ for (const duplicate of duplicates) {
2402
+ errors.push({
2403
+ message: `Duplicate ${kind} "${name}" defined at ${locations}`,
2404
+ nodeId: duplicate.id,
2405
+ severity: 'error',
2406
+ });
2407
+ }
2408
+ }
2409
+ return errors;
2410
+ }
2411
+ function formatSourceLocation(sourceSpan) {
2412
+ if (!sourceSpan)
2413
+ return '<unknown>';
2414
+ return `${sourceSpan.file}:${sourceSpan.startLine}:${sourceSpan.startCol}`;
2415
+ }
2416
+ function validateRouteTarget(target, nodeId, resourceMap, pageMap, errors) {
2417
+ // Target formats: "users.list", "users.edit", "page.dashboard"
2418
+ const parts = target.split('.');
2419
+ if (parts.length === 2) {
2420
+ const [prefix, suffix] = parts;
2421
+ if (prefix === 'page') {
2422
+ const page = pageMap.get(suffix);
2423
+ if (!page) {
2424
+ errors.push({
2425
+ message: `Redirect target page "${suffix}" does not exist`,
2426
+ nodeId,
2427
+ severity: 'warning',
2428
+ });
2429
+ }
2430
+ else if (page.path) {
2431
+ errors.push({
2432
+ message: `Redirect target page "${suffix}" uses a record-scoped path "${page.path}" and cannot be targeted without explicit route params`,
2433
+ nodeId,
2434
+ severity: 'warning',
2435
+ });
2436
+ }
2437
+ }
2438
+ else if (resourceMap.has(prefix)) {
2439
+ const validViews = ['list', 'edit', 'create'];
2440
+ if (!validViews.includes(suffix)) {
2441
+ errors.push({
2442
+ message: `Redirect target view "${suffix}" is not a valid view type`,
2443
+ nodeId,
2444
+ severity: 'warning',
2445
+ });
2446
+ }
2447
+ }
2448
+ else {
2449
+ errors.push({
2450
+ message: `Redirect target "${target}" references unknown resource "${prefix}"`,
2451
+ nodeId,
2452
+ severity: 'warning',
2453
+ });
2454
+ }
2455
+ }
2456
+ }
2457
+ function validateNavTarget(target, nodeId, resourceMap, pageMap, errors) {
2458
+ // Navigation target formats: "resource.users.list", "page.dashboard"
2459
+ const parts = target.split('.');
2460
+ if (parts[0] === 'resource' && parts.length >= 3) {
2461
+ if (!resourceMap.has(parts[1])) {
2462
+ errors.push({
2463
+ message: `Navigation target references unknown resource "${parts[1]}"`,
2464
+ nodeId,
2465
+ severity: 'error',
2466
+ });
2467
+ }
2468
+ }
2469
+ else if (parts[0] === 'page' && parts.length >= 2) {
2470
+ const page = pageMap.get(parts[1]);
2471
+ if (!page) {
2472
+ errors.push({
2473
+ message: `Navigation target references unknown page "${parts[1]}"`,
2474
+ nodeId,
2475
+ severity: 'error',
2476
+ });
2477
+ }
2478
+ else if (page.path) {
2479
+ errors.push({
2480
+ message: `Navigation target references page "${parts[1]}" with record-scoped path "${page.path}"; current navigation targets only support static page routes`,
2481
+ nodeId,
2482
+ severity: 'error',
2483
+ });
2484
+ }
2485
+ }
2486
+ }
2487
+ //# sourceMappingURL=validator.js.map