@loj-lang/rdsl-compiler 0.5.0 → 0.6.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 (72) hide show
  1. package/dist/codegen.d.ts.map +1 -1
  2. package/dist/codegen.js +3952 -1171
  3. package/dist/codegen.js.map +1 -1
  4. package/dist/dependency-graph.js +2 -2
  5. package/dist/dependency-graph.js.map +1 -1
  6. package/dist/expr.d.ts +24 -3
  7. package/dist/expr.d.ts.map +1 -1
  8. package/dist/expr.js +211 -8
  9. package/dist/expr.js.map +1 -1
  10. package/dist/flow-proof.d.ts +23 -3
  11. package/dist/flow-proof.d.ts.map +1 -1
  12. package/dist/flow-proof.js +179 -26
  13. package/dist/flow-proof.js.map +1 -1
  14. package/dist/formula-proof.d.ts +30 -0
  15. package/dist/formula-proof.d.ts.map +1 -0
  16. package/dist/formula-proof.js +596 -0
  17. package/dist/formula-proof.js.map +1 -0
  18. package/dist/host-files.d.ts +34 -0
  19. package/dist/host-files.d.ts.map +1 -1
  20. package/dist/host-files.js +418 -20
  21. package/dist/host-files.js.map +1 -1
  22. package/dist/index.d.ts +15 -6
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +147 -9
  25. package/dist/index.js.map +1 -1
  26. package/dist/ir.d.ts +142 -8
  27. package/dist/ir.d.ts.map +1 -1
  28. package/dist/manifest.d.ts +51 -0
  29. package/dist/manifest.d.ts.map +1 -1
  30. package/dist/manifest.js +107 -5
  31. package/dist/manifest.js.map +1 -1
  32. package/dist/model-display-field.d.ts +3 -0
  33. package/dist/model-display-field.d.ts.map +1 -0
  34. package/dist/model-display-field.js +5 -0
  35. package/dist/model-display-field.js.map +1 -0
  36. package/dist/model-field-type.d.ts +10 -0
  37. package/dist/model-field-type.d.ts.map +1 -0
  38. package/dist/model-field-type.js +25 -0
  39. package/dist/model-field-type.js.map +1 -0
  40. package/dist/node-inspect.js +12 -5
  41. package/dist/node-inspect.js.map +1 -1
  42. package/dist/normalize.d.ts +4 -0
  43. package/dist/normalize.d.ts.map +1 -1
  44. package/dist/normalize.js +492 -59
  45. package/dist/normalize.js.map +1 -1
  46. package/dist/page-table-block.d.ts +11 -2
  47. package/dist/page-table-block.d.ts.map +1 -1
  48. package/dist/page-table-block.js +33 -1
  49. package/dist/page-table-block.js.map +1 -1
  50. package/dist/parser.d.ts +151 -4
  51. package/dist/parser.d.ts.map +1 -1
  52. package/dist/parser.js +798 -40
  53. package/dist/parser.js.map +1 -1
  54. package/dist/relation-projection.d.ts +1 -1
  55. package/dist/relation-projection.d.ts.map +1 -1
  56. package/dist/rules-proof.d.ts +16 -1
  57. package/dist/rules-proof.d.ts.map +1 -1
  58. package/dist/rules-proof.js +522 -28
  59. package/dist/rules-proof.js.map +1 -1
  60. package/dist/source-files.d.ts +7 -0
  61. package/dist/source-files.d.ts.map +1 -1
  62. package/dist/source-files.js +15 -2
  63. package/dist/source-files.js.map +1 -1
  64. package/dist/style-proof.d.ts +16 -2
  65. package/dist/style-proof.d.ts.map +1 -1
  66. package/dist/style-proof.js +156 -11
  67. package/dist/style-proof.js.map +1 -1
  68. package/dist/validator.d.ts +10 -0
  69. package/dist/validator.d.ts.map +1 -1
  70. package/dist/validator.js +939 -86
  71. package/dist/validator.js.map +1 -1
  72. package/package.json +31 -10
package/dist/validator.js CHANGED
@@ -10,10 +10,11 @@
10
10
  * - Effect targets reference known resources
11
11
  * - Required fields are present
12
12
  */
13
+ import { lintSharedSnapshotCandidates, validateDuplicateSharedModelFieldNames, validateGeneratedPersistenceFieldNames, validateSharedCascadeFieldDecorator, validateSharedDisplayFieldTypes, validateSharedEnumFieldDecorator, validateSharedModelDisplayFieldUsage, validateSharedModelRelations, validateSharedSnapshotFieldDecorator, validateSharedSnapshotReferences, validateSharedSoftDeleteFieldNames } from '@loj-lang/shared-contracts';
13
14
  import { analyzePageBlockData } from './page-table-block.js';
14
15
  import { analyzeRelationProjection } from './relation-projection.js';
15
16
  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
+ const VALIDATION_CACHE_VERSION = '0.1.16';
17
18
  export function validate(ir, options) {
18
19
  const errors = [];
19
20
  const previousCache = options?.cache?.version === VALIDATION_CACHE_VERSION
@@ -52,8 +53,10 @@ export function validate(ir, options) {
52
53
  resourceNames,
53
54
  readModelNames,
54
55
  pageNames: pageNameList,
56
+ shell: ir.shell ?? null,
57
+ routes: ir.routes ?? null,
55
58
  escapeStats: ir.escapeStats,
56
- }), previousCache?.global?.core, () => validateGlobal(ir));
59
+ }), previousCache?.global?.core, () => validateGlobal(ir, resourceMap, pageMap));
57
60
  errors.push(...globalEntry.errors);
58
61
  const navigationEntries = ir.navigation.map((group) => resolveValidationSegment(createValidationSignature({
59
62
  targets: group.items.map((item) => item.target),
@@ -189,12 +192,13 @@ function createValidationSignature(value) {
189
192
  return normalized;
190
193
  });
191
194
  }
192
- function validateGlobal(ir) {
195
+ function validateGlobal(ir, resourceMap, pageMap) {
193
196
  const errors = [];
194
197
  errors.push(...collectDuplicateErrors(ir.models, 'model'));
195
198
  errors.push(...collectDuplicateErrors(ir.resources, 'resource'));
196
199
  errors.push(...collectDuplicateErrors(ir.readModels, 'readModel'));
197
200
  errors.push(...collectDuplicateErrors(ir.pages, 'page'));
201
+ errors.push(...collectModelDecoratorErrors(ir.models));
198
202
  errors.push(...collectRelationErrors(ir.models));
199
203
  if (ir.escapeStats) {
200
204
  if (ir.escapeStats.overBudget) {
@@ -207,8 +211,38 @@ function validateGlobal(ir) {
207
211
  });
208
212
  }
209
213
  }
214
+ validateAppRoutes(ir, resourceMap, pageMap, errors);
215
+ validateAppShell(ir, errors);
210
216
  return errors;
211
217
  }
218
+ function validateAppShell(ir, errors) {
219
+ const primaryNav = ir.shell?.navigation?.primary;
220
+ const secondaryNav = ir.shell?.navigation?.secondary;
221
+ const primaryGroups = ir.navigation.filter((group) => group.placement === 'primary');
222
+ const secondaryGroups = ir.navigation.filter((group) => group.placement === 'secondary');
223
+ if (primaryGroups.length > 0 && !primaryNav?.desktop && !primaryNav?.mobile) {
224
+ errors.push({
225
+ message: 'primary app.navigation groups are defined but app.shell.navigation.primary has no desktop/mobile placement; primary navigation will not render',
226
+ nodeId: ir.id,
227
+ severity: 'warning',
228
+ });
229
+ }
230
+ if (secondaryGroups.length > 0 && !secondaryNav?.desktop && !secondaryNav?.mobile) {
231
+ errors.push({
232
+ message: 'secondary app.navigation groups are defined but app.shell.navigation.secondary has no desktop/mobile placement; secondary navigation will not render',
233
+ nodeId: ir.id,
234
+ severity: 'warning',
235
+ });
236
+ }
237
+ }
238
+ function validateAppRoutes(ir, resourceMap, pageMap, errors) {
239
+ if (ir.routes?.default) {
240
+ validateStaticAppRouteTarget(ir.routes.default, 'app.routes.default', resourceMap, pageMap, errors, 'default');
241
+ }
242
+ if (ir.routes?.notFound) {
243
+ validateStaticAppRouteTarget(ir.routes.notFound, 'app.routes.notFound', resourceMap, pageMap, errors, 'notFound');
244
+ }
245
+ }
212
246
  function validateReadModel(readModel) {
213
247
  const errors = [];
214
248
  if (!readModel.api) {
@@ -227,13 +261,6 @@ function validateReadModel(readModel) {
227
261
  validateReadModelField(readModel, field, resultNames, errors);
228
262
  }
229
263
  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
264
  for (const column of readModel.list.columns) {
238
265
  const resultField = readModel.result.find((field) => field.name === column.field);
239
266
  if (!resultField) {
@@ -482,9 +509,9 @@ function validateLinkedFormRules(resource, model, view, errors, mode) {
482
509
  });
483
510
  }
484
511
  seenEligibility.add(entry.name);
485
- validateWorkflowExpr(entry.when, rules.id, errors, (path) => validateLinkedFormRulesIdentifier(path, resource.name, mode, modelFieldNames));
512
+ validateWorkflowExpr(entry.when, rules.id, errors, (path) => validateLinkedFormRulesIdentifier(path, resource.name, mode, modelFieldMap));
486
513
  for (const expr of entry.or) {
487
- validateWorkflowExpr(expr, rules.id, errors, (path) => validateLinkedFormRulesIdentifier(path, resource.name, mode, modelFieldNames));
514
+ validateWorkflowExpr(expr, rules.id, errors, (path) => validateLinkedFormRulesIdentifier(path, resource.name, mode, modelFieldMap));
488
515
  }
489
516
  }
490
517
  const seenValidation = new Set();
@@ -497,9 +524,9 @@ function validateLinkedFormRules(resource, model, view, errors, mode) {
497
524
  });
498
525
  }
499
526
  seenValidation.add(entry.name);
500
- validateWorkflowExpr(entry.when, rules.id, errors, (path) => validateLinkedFormRulesIdentifier(path, resource.name, mode, modelFieldNames));
527
+ validateWorkflowExpr(entry.when, rules.id, errors, (path) => validateLinkedFormRulesIdentifier(path, resource.name, mode, modelFieldMap));
501
528
  for (const expr of entry.or) {
502
- validateWorkflowExpr(expr, rules.id, errors, (path) => validateLinkedFormRulesIdentifier(path, resource.name, mode, modelFieldNames));
529
+ validateWorkflowExpr(expr, rules.id, errors, (path) => validateLinkedFormRulesIdentifier(path, resource.name, mode, modelFieldMap));
503
530
  }
504
531
  }
505
532
  const seenDerivations = new Set();
@@ -547,9 +574,9 @@ function validateLinkedFormRules(resource, model, view, errors, mode) {
547
574
  continue;
548
575
  }
549
576
  if (entry.when) {
550
- validateWorkflowExpr(entry.when, rules.id, errors, (path) => validateLinkedFormRulesIdentifier(path, resource.name, mode, modelFieldNames));
577
+ validateWorkflowExpr(entry.when, rules.id, errors, (path) => validateLinkedFormRulesIdentifier(path, resource.name, mode, modelFieldMap));
551
578
  }
552
- validateWorkflowExpr(entry.value, rules.id, errors, (path) => validateLinkedFormRulesIdentifier(path, resource.name, mode, modelFieldNames));
579
+ validateWorkflowExpr(entry.value, rules.id, errors, (path) => validateLinkedFormRulesIdentifier(path, resource.name, mode, modelFieldMap));
553
580
  }
554
581
  }
555
582
  function validateLinkedFormIncludeRules(resource, parentModel, include, targetModel, errors, mode) {
@@ -666,48 +693,104 @@ function validateReadModelField(readModel, field, seen, errors) {
666
693
  }
667
694
  }
668
695
  function collectRelationErrors(models) {
696
+ return validateSharedModelRelations(models).map((issue) => ({
697
+ message: issue.message.replace(/^model ([^. ]+) field ([^. ]+) /, 'Field "$2" in model "$1" '),
698
+ nodeId: issue.nodeId,
699
+ severity: 'error',
700
+ }));
701
+ }
702
+ function collectModelDecoratorErrors(models) {
669
703
  const errors = [];
670
- const modelMap = new Map(models.map((model) => [model.name, model]));
704
+ for (const issue of validateSharedModelDisplayFieldUsage(models)) {
705
+ errors.push({
706
+ message: issue.message.replace(/^model ([^. ]+) /, 'Model "$1" '),
707
+ nodeId: issue.nodeId,
708
+ severity: 'error',
709
+ });
710
+ }
711
+ for (const issue of validateSharedSoftDeleteFieldNames(models)) {
712
+ errors.push({
713
+ message: issue.message,
714
+ nodeId: issue.nodeId,
715
+ severity: 'error',
716
+ });
717
+ }
718
+ for (const issue of validateSharedDisplayFieldTypes(models)) {
719
+ errors.push({
720
+ message: issue.message,
721
+ nodeId: issue.nodeId,
722
+ severity: 'error',
723
+ });
724
+ }
725
+ for (const issue of validateDuplicateSharedModelFieldNames(models)) {
726
+ errors.push({
727
+ message: issue.message,
728
+ nodeId: issue.nodeId,
729
+ severity: 'error',
730
+ });
731
+ }
732
+ for (const issue of validateGeneratedPersistenceFieldNames(models)) {
733
+ errors.push({
734
+ message: issue.message,
735
+ nodeId: issue.nodeId,
736
+ severity: 'error',
737
+ });
738
+ }
739
+ for (const issue of validateSharedSnapshotReferences(models)) {
740
+ errors.push({
741
+ message: issue.message,
742
+ nodeId: issue.nodeId,
743
+ severity: issue.severity,
744
+ });
745
+ }
746
+ for (const issue of lintSharedSnapshotCandidates(models)) {
747
+ errors.push({
748
+ message: issue.message,
749
+ nodeId: issue.nodeId,
750
+ severity: issue.severity,
751
+ });
752
+ }
671
753
  for (const model of models) {
672
754
  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;
755
+ const enumKeyPrefixDecorator = field.decorators.find((decorator) => decorator.name === 'enumKeyPrefix');
756
+ if (enumKeyPrefixDecorator) {
757
+ for (const issue of validateSharedEnumFieldDecorator(field, enumKeyPrefixDecorator)) {
758
+ errors.push({
759
+ message: issue.message,
760
+ nodeId: issue.nodeId,
761
+ severity: 'error',
762
+ });
763
+ }
684
764
  }
685
- const relationField = field.fieldType;
686
- if (relationField.kind !== 'hasMany') {
765
+ const enumLabelsDecorator = field.decorators.find((decorator) => decorator.name === 'enumLabels');
766
+ if (!enumLabelsDecorator) {
687
767
  continue;
688
768
  }
689
- if (field.decorators.length > 0) {
769
+ for (const issue of validateSharedEnumFieldDecorator(field, enumLabelsDecorator)) {
690
770
  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,
771
+ message: issue.message,
772
+ nodeId: issue.nodeId,
693
773
  severity: 'error',
694
774
  });
695
775
  }
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
- });
776
+ const snapshotDecorator = field.decorators.find((decorator) => decorator.name === 'snapshot');
777
+ if (snapshotDecorator) {
778
+ for (const issue of validateSharedSnapshotFieldDecorator(field, snapshotDecorator)) {
779
+ errors.push({
780
+ message: issue.message,
781
+ nodeId: issue.nodeId,
782
+ severity: 'error',
783
+ });
784
+ }
785
+ }
786
+ const cascadeDecorator = field.decorators.find((decorator) => decorator.name === 'cascade');
787
+ if (!cascadeDecorator) {
703
788
  continue;
704
789
  }
705
- if (inverseField.fieldType.type !== 'relation'
706
- || inverseField.fieldType.kind !== 'belongsTo'
707
- || inverseField.fieldType.target !== model.name) {
790
+ for (const issue of validateSharedCascadeFieldDecorator(field, cascadeDecorator)) {
708
791
  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,
792
+ message: issue.message,
793
+ nodeId: issue.nodeId,
711
794
  severity: 'error',
712
795
  });
713
796
  }
@@ -715,6 +798,9 @@ function collectRelationErrors(models) {
715
798
  }
716
799
  return errors;
717
800
  }
801
+ function hasDecorator(field, decoratorName) {
802
+ return field.decorators.some((decorator) => decorator.name === decoratorName);
803
+ }
718
804
  function resolveResourceValidation(resource, modelMap, modelNames, resourceMap, resourceNames, pageNames, pageMap, pageNameList, modelFieldContext, resourceContext, previous) {
719
805
  const resourceEntry = resolveValidationSegment(createValidationSignature({
720
806
  name: resource.name,
@@ -743,6 +829,9 @@ function resolveResourceValidation(resource, modelMap, modelNames, resourceMap,
743
829
  field: column.field,
744
830
  customRenderer: Boolean(column.customRenderer),
745
831
  })),
832
+ actions: resource.views.list.actions.map((action) => action.name),
833
+ batchActions: resource.views.list.batchActions.map((action) => action.name),
834
+ messages: resource.views.list.messages ?? null,
746
835
  rules: resource.views.list.rules ?? null,
747
836
  }), previous?.list, () => validateListView(resource, modelMap.get(resource.model), resource.views.list, Array.from(modelMap.values()), Array.from(resourceMap.values())))
748
837
  : undefined;
@@ -752,8 +841,8 @@ function resolveResourceValidation(resource, modelMap, modelNames, resourceMap,
752
841
  fields: resource.views.edit.fields.map((field) => ({
753
842
  field: field.field,
754
843
  customField: Boolean(field.customField),
755
- visibleWhen: field.visibleWhen ?? null,
756
- enabledWhen: field.enabledWhen ?? null,
844
+ visibleIf: field.visibleIf ?? null,
845
+ enabledIf: field.enabledIf ?? null,
757
846
  })),
758
847
  includes: resource.views.edit.includes.map((include) => ({
759
848
  field: include.field,
@@ -767,11 +856,12 @@ function resolveResourceValidation(resource, modelMap, modelNames, resourceMap,
767
856
  fields: include.fields.map((field) => ({
768
857
  field: field.field,
769
858
  customField: Boolean(field.customField),
770
- visibleWhen: field.visibleWhen ?? null,
771
- enabledWhen: field.enabledWhen ?? null,
859
+ visibleIf: field.visibleIf ?? null,
860
+ enabledIf: field.enabledIf ?? null,
772
861
  })),
773
862
  })),
774
863
  rules: resource.views.edit.rules ?? null,
864
+ messages: resource.views.edit.messages ?? null,
775
865
  rulesLink: resource.views.edit.rulesLink
776
866
  ? {
777
867
  resolvedPath: resource.views.edit.rulesLink.resolvedPath,
@@ -789,8 +879,8 @@ function resolveResourceValidation(resource, modelMap, modelNames, resourceMap,
789
879
  fields: resource.views.create.fields.map((field) => ({
790
880
  field: field.field,
791
881
  customField: Boolean(field.customField),
792
- visibleWhen: field.visibleWhen ?? null,
793
- enabledWhen: field.enabledWhen ?? null,
882
+ visibleIf: field.visibleIf ?? null,
883
+ enabledIf: field.enabledIf ?? null,
794
884
  })),
795
885
  includes: resource.views.create.includes.map((include) => ({
796
886
  field: include.field,
@@ -804,11 +894,12 @@ function resolveResourceValidation(resource, modelMap, modelNames, resourceMap,
804
894
  fields: include.fields.map((field) => ({
805
895
  field: field.field,
806
896
  customField: Boolean(field.customField),
807
- visibleWhen: field.visibleWhen ?? null,
808
- enabledWhen: field.enabledWhen ?? null,
897
+ visibleIf: field.visibleIf ?? null,
898
+ enabledIf: field.enabledIf ?? null,
809
899
  })),
810
900
  })),
811
901
  rules: resource.views.create.rules ?? null,
902
+ messages: resource.views.create.messages ?? null,
812
903
  rulesLink: resource.views.create.rulesLink
813
904
  ? {
814
905
  resolvedPath: resource.views.create.rulesLink.resolvedPath,
@@ -833,6 +924,8 @@ function resolveResourceValidation(resource, modelMap, modelNames, resourceMap,
833
924
  customRenderer: Boolean(field.customRenderer),
834
925
  })),
835
926
  related: resource.views.read.related.map((panel) => panel.field),
927
+ messages: resource.views.read.messages ?? null,
928
+ workflowMessages: resource.workflow ? resource.workflowMessages ?? null : null,
836
929
  }), previous?.read, () => validateReadView(resource, modelMap.get(resource.model), Array.from(modelMap.values()), Array.from(resourceMap.values())))
837
930
  : undefined;
838
931
  return {
@@ -934,6 +1027,7 @@ function validateResourceBase(resource, modelMap) {
934
1027
  }
935
1028
  validateWorkflowExpr(transition.allow, resource.workflow.id, errors, (path) => validateWorkflowTransitionIdentifier(path, resource.name, modelFieldNames));
936
1029
  }
1030
+ pushWorkflowMessageWarnings(resource, Boolean(resource.views.read?.related.length), errors);
937
1031
  }
938
1032
  return errors;
939
1033
  }
@@ -1009,7 +1103,34 @@ function validateListView(resource, model, view, models, resources) {
1009
1103
  });
1010
1104
  }
1011
1105
  }
1106
+ for (const action of view.batchActions) {
1107
+ if (action.name !== 'delete') {
1108
+ errors.push({
1109
+ message: `List batch action "${action.name}" in resource "${resource.name}" is not supported in the current slice; only "delete" is supported`,
1110
+ nodeId: action.id,
1111
+ severity: 'error',
1112
+ });
1113
+ continue;
1114
+ }
1115
+ if (!view.actions.some((entry) => entry.name === 'delete')) {
1116
+ errors.push({
1117
+ message: `List batch action "delete" in resource "${resource.name}" requires list.actions to include delete`,
1118
+ nodeId: action.id,
1119
+ severity: 'error',
1120
+ });
1121
+ }
1122
+ }
1123
+ for (const format of view.exportFormats) {
1124
+ if (format !== 'csv' && format !== 'xlsx') {
1125
+ errors.push({
1126
+ message: `List export "${format}" in resource "${resource.name}" is not supported in the current slice; only "csv" and "xlsx" are supported`,
1127
+ nodeId: view.id,
1128
+ severity: 'error',
1129
+ });
1130
+ }
1131
+ }
1012
1132
  validateViewRulePaths(view.id, view.rules, errors);
1133
+ pushListMessageWarnings(resource, view, errors);
1013
1134
  return errors;
1014
1135
  }
1015
1136
  function validateEditView(resource, model, modelMap, resourceMap, pageMap) {
@@ -1148,6 +1269,7 @@ function validateEditView(resource, model, modelMap, resourceMap, pageMap) {
1148
1269
  }
1149
1270
  validateViewRulePaths(view.id, view.rules, errors);
1150
1271
  validateLinkedFormRules(resource, model, view, errors, 'edit');
1272
+ pushEditMessageWarnings(resource, model, view, errors);
1151
1273
  return errors;
1152
1274
  }
1153
1275
  function validateCreateView(resource, model, modelMap, resourceMap, pageMap) {
@@ -1271,6 +1393,7 @@ function validateCreateView(resource, model, modelMap, resourceMap, pageMap) {
1271
1393
  }
1272
1394
  validateViewRulePaths(view.id, view.rules, errors);
1273
1395
  validateLinkedFormRules(resource, model, view, errors, 'create');
1396
+ pushCreateMessageWarnings(resource, model, view, errors);
1274
1397
  return errors;
1275
1398
  }
1276
1399
  function validateReadView(resource, model, models, resources) {
@@ -1343,6 +1466,7 @@ function validateReadView(resource, model, models, resources) {
1343
1466
  });
1344
1467
  }
1345
1468
  }
1469
+ pushReadMessageWarnings(resource, view, errors);
1346
1470
  return errors;
1347
1471
  }
1348
1472
  function validateToastEffect(effect, context, errors) {
@@ -1354,6 +1478,337 @@ function validateToastEffect(effect, context, errors) {
1354
1478
  function isInverseRelationField(field) {
1355
1479
  return field.fieldType.type === 'relation' && field.fieldType.kind === 'hasMany';
1356
1480
  }
1481
+ function hasVersionFieldInFormView(model, view) {
1482
+ if (!model) {
1483
+ return false;
1484
+ }
1485
+ const fieldMap = new Map(model.fields.map((field) => [field.name, field]));
1486
+ return view.fields
1487
+ .map((field) => fieldMap.get(field.field))
1488
+ .some((field) => Boolean(field) && field.decorators.some((decorator) => decorator.name === 'version'));
1489
+ }
1490
+ function pushMessageFallbackWarning(errors, nodeId, messageKey, fallback) {
1491
+ errors.push({
1492
+ message: `message "${messageKey}" not declared — generated code will use English fallback "${fallback}"`,
1493
+ nodeId,
1494
+ severity: 'warning',
1495
+ });
1496
+ }
1497
+ function reportMissingMessage(errors, reporter, nodeId, ownerKind, ownerName, surface, key, fallback) {
1498
+ const messageKey = surface === 'workflowMessages'
1499
+ ? `workflowMessages.${key}`
1500
+ : surface === 'page'
1501
+ ? `page.messages.${key}`
1502
+ : `messages.${key}`;
1503
+ if (errors) {
1504
+ pushMessageFallbackWarning(errors, nodeId, messageKey, fallback);
1505
+ }
1506
+ reporter?.({
1507
+ nodeId,
1508
+ ownerKind,
1509
+ ownerName,
1510
+ surface,
1511
+ key,
1512
+ fallback,
1513
+ });
1514
+ }
1515
+ function pushListMessageWarnings(resource, view, errors, reporter) {
1516
+ const messages = view.messages ?? {};
1517
+ if (!messages.loadFailed) {
1518
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'list', 'loadFailed', 'Failed to load data');
1519
+ }
1520
+ if (resource.views.create && !messages.createLabel) {
1521
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'list', 'createLabel', 'Create');
1522
+ }
1523
+ if (view.actions.some((action) => action.name === 'view') && !messages.viewLabel) {
1524
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'list', 'viewLabel', 'View');
1525
+ }
1526
+ if (view.actions.some((action) => action.name === 'edit') && !messages.editLabel) {
1527
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'list', 'editLabel', 'Edit');
1528
+ }
1529
+ if (view.actions.some((action) => action.name === 'workflow') && !messages.workflowLabel) {
1530
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'list', 'workflowLabel', 'Workflow');
1531
+ }
1532
+ if (view.actions.some((action) => action.name === 'delete') && !messages.deleteLabel) {
1533
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'list', 'deleteLabel', 'Delete');
1534
+ }
1535
+ if (view.batchActions.some((action) => action.name === 'delete') && !messages.deleteSelectedLabel) {
1536
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'list', 'deleteSelectedLabel', 'Delete selected');
1537
+ }
1538
+ if (view.exportFormats.length > 1 && !messages.exportLabel) {
1539
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'list', 'exportLabel', 'Export');
1540
+ }
1541
+ if (view.exportFormats.includes('csv') && !messages.exportCsvLabel) {
1542
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'list', 'exportCsvLabel', 'Export CSV');
1543
+ }
1544
+ if (view.exportFormats.includes('xlsx') && !messages.exportXlsxLabel) {
1545
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'list', 'exportXlsxLabel', 'Export XLSX');
1546
+ }
1547
+ if ((view.actions.some((action) => action.name === 'delete') || view.batchActions.some((action) => action.name === 'delete')) && !messages.deleted) {
1548
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'list', 'deleted', 'Archived/Deleted successfully');
1549
+ }
1550
+ if (view.exportFormats.length > 0 && !messages.exportReady) {
1551
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'list', 'exportReady', 'Export ready');
1552
+ }
1553
+ }
1554
+ function pushEditMessageWarnings(resource, model, view, errors, reporter) {
1555
+ const messages = view.messages ?? {};
1556
+ if (!messages.saveFailed) {
1557
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'edit', 'saveFailed', 'Failed to save');
1558
+ }
1559
+ if (!messages.permissionDenied) {
1560
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'edit', 'permissionDenied', 'You do not have permission to perform this action');
1561
+ }
1562
+ if (!messages.cancelLabel) {
1563
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'edit', 'cancelLabel', 'Cancel');
1564
+ }
1565
+ if (!messages.loadingLabel) {
1566
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'edit', 'loadingLabel', 'Loading...');
1567
+ }
1568
+ if (!messages.notFound) {
1569
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'edit', 'notFound', 'Record not found');
1570
+ }
1571
+ if (!resource.workflow && !messages.saveLabel) {
1572
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'edit', 'saveLabel', 'Save');
1573
+ }
1574
+ if (resource.workflow && !messages.workflowLabel) {
1575
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'edit', 'workflowLabel', 'Workflow');
1576
+ }
1577
+ if (resource.workflow && !messages.saveAndContinueLabel) {
1578
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'edit', 'saveAndContinueLabel', 'Save and continue to {step}');
1579
+ }
1580
+ if (hasVersionFieldInFormView(model, view) && !messages.versionConflict) {
1581
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'edit', 'versionConflict', 'This record changed since you loaded it. Reload and try again.');
1582
+ }
1583
+ if (hasVersionFieldInFormView(model, view) && !messages.reloadLatest) {
1584
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'edit', 'reloadLatest', 'Reload latest');
1585
+ }
1586
+ if (view.includes.some((include) => include.minItems > 0) && !messages.minItemsRequired) {
1587
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'edit', 'minItemsRequired', 'At least {count} {field} item(s) required in the current slice.');
1588
+ }
1589
+ if (view.includes.length > 0 && !messages.existingItemLabel) {
1590
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'edit', 'existingItemLabel', 'Existing');
1591
+ }
1592
+ if (view.includes.length > 0 && !messages.newItemLabel) {
1593
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'edit', 'newItemLabel', 'New');
1594
+ }
1595
+ }
1596
+ function pushCreateMessageWarnings(resource, model, view, errors, reporter) {
1597
+ const messages = view.messages ?? {};
1598
+ if (!messages.createFailed) {
1599
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'create', 'createFailed', 'Failed to create');
1600
+ }
1601
+ if (!messages.permissionDenied) {
1602
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'create', 'permissionDenied', 'You do not have permission to perform this action');
1603
+ }
1604
+ if (!messages.cancelLabel) {
1605
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'create', 'cancelLabel', 'Cancel');
1606
+ }
1607
+ if (!messages.loadingLabel) {
1608
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'create', 'loadingLabel', 'Loading...');
1609
+ }
1610
+ if (!messages.notFound) {
1611
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'create', 'notFound', 'Record not found');
1612
+ }
1613
+ if (!resource.workflow && !messages.createLabel) {
1614
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'create', 'createLabel', 'Create');
1615
+ }
1616
+ if (resource.workflow && !messages.createAndContinueLabel) {
1617
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'create', 'createAndContinueLabel', 'Create and continue to {step}');
1618
+ }
1619
+ if (hasVersionFieldInFormView(model, view) && !messages.versionConflict) {
1620
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'create', 'versionConflict', 'This record changed since you loaded it. Reload and try again.');
1621
+ }
1622
+ if (view.includes.some((include) => include.minItems > 0) && !messages.minItemsRequired) {
1623
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'create', 'minItemsRequired', 'At least {count} {field} item(s) required in the current slice.');
1624
+ }
1625
+ if (view.includes.length > 0 && !messages.existingItemLabel) {
1626
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'create', 'existingItemLabel', 'Existing');
1627
+ }
1628
+ if (view.includes.length > 0 && !messages.newItemLabel) {
1629
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'create', 'newItemLabel', 'New');
1630
+ }
1631
+ }
1632
+ function pushReadMessageWarnings(resource, view, errors, reporter) {
1633
+ const messages = view.messages ?? {};
1634
+ if (!messages.loadFailed) {
1635
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'read', 'loadFailed', 'Failed to load record');
1636
+ }
1637
+ if (!messages.loadingLabel) {
1638
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'read', 'loadingLabel', 'Loading...');
1639
+ }
1640
+ if (!messages.notFound) {
1641
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'read', 'notFound', 'Record not found');
1642
+ }
1643
+ if (!messages.backLabel) {
1644
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'read', 'backLabel', 'Back');
1645
+ }
1646
+ if (resource.views.edit && !messages.editLabel) {
1647
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'read', 'editLabel', 'Edit');
1648
+ }
1649
+ if (resource.workflow && !messages.workflowLabel) {
1650
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'read', 'workflowLabel', 'Workflow');
1651
+ }
1652
+ if (view.related.length > 0 && !messages.relatedTitle) {
1653
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'read', 'relatedTitle', 'Related');
1654
+ }
1655
+ if (view.related.length > 0 && !messages.emptyRelated) {
1656
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'read', 'emptyRelated', 'No related records');
1657
+ }
1658
+ if (view.related.length > 0 && !messages.createRelatedLabel) {
1659
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'read', 'createRelatedLabel', 'Create');
1660
+ }
1661
+ if (view.related.length > 0 && !messages.viewAllRelatedLabel) {
1662
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'read', 'viewAllRelatedLabel', 'View all');
1663
+ }
1664
+ if (resource.workflow) {
1665
+ if (!messages.currentState) {
1666
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'read', 'currentState', 'Current state');
1667
+ }
1668
+ if (!messages.currentStep) {
1669
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'read', 'currentStep', 'Current step');
1670
+ }
1671
+ if (!messages.nextStep) {
1672
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'read', 'nextStep', 'Next step');
1673
+ }
1674
+ if (!messages.stepDone) {
1675
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'read', 'stepDone', 'done');
1676
+ }
1677
+ if (!messages.stepCurrent) {
1678
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'read', 'stepCurrent', 'current');
1679
+ }
1680
+ if (!messages.stepUpcoming) {
1681
+ reportMissingMessage(errors, reporter, view.id, 'resource', resource.name, 'read', 'stepUpcoming', 'upcoming');
1682
+ }
1683
+ }
1684
+ }
1685
+ function pushWorkflowMessageWarnings(resource, hasRelatedPanels, errors, reporter) {
1686
+ const messages = resource.workflowMessages ?? {};
1687
+ const nodeId = resource.workflow?.id ?? resource.id;
1688
+ if (!messages.preconditionBlocked) {
1689
+ reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'preconditionBlocked', 'Action blocked by workflow preconditions');
1690
+ }
1691
+ if (!messages.invalidTransition) {
1692
+ reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'invalidTransition', 'Invalid workflow transition request');
1693
+ }
1694
+ if (!messages.updated) {
1695
+ reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'updated', 'Workflow updated');
1696
+ }
1697
+ if (!messages.updateFailed) {
1698
+ reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'updateFailed', 'Failed to update workflow');
1699
+ }
1700
+ if (!messages.title) {
1701
+ reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'title', 'Workflow');
1702
+ }
1703
+ if (!messages.loadFailed) {
1704
+ reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'loadFailed', 'Failed to load record');
1705
+ }
1706
+ if (!messages.loadingLabel) {
1707
+ reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'loadingLabel', 'Loading...');
1708
+ }
1709
+ if (!messages.notFound) {
1710
+ reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'notFound', 'Record not found');
1711
+ }
1712
+ if (!messages.backLabel) {
1713
+ reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'backLabel', 'Back');
1714
+ }
1715
+ if (resource.views.edit && !messages.editLabel) {
1716
+ reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'editLabel', 'Edit');
1717
+ }
1718
+ if (resource.views.read && !messages.viewLabel) {
1719
+ reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'viewLabel', 'View');
1720
+ }
1721
+ if (hasRelatedPanels && !messages.relatedTitle) {
1722
+ reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'relatedTitle', 'Related');
1723
+ }
1724
+ if (hasRelatedPanels && !messages.emptyRelated) {
1725
+ reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'emptyRelated', 'No related records');
1726
+ }
1727
+ if (hasRelatedPanels && !messages.createRelatedLabel) {
1728
+ reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'createRelatedLabel', 'Create');
1729
+ }
1730
+ if (hasRelatedPanels && !messages.viewAllRelatedLabel) {
1731
+ reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'viewAllRelatedLabel', 'View all');
1732
+ }
1733
+ if (!messages.currentState) {
1734
+ reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'currentState', 'Current state');
1735
+ }
1736
+ if (!messages.currentStep) {
1737
+ reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'currentStep', 'Current step');
1738
+ }
1739
+ if (!messages.nextStep) {
1740
+ reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'nextStep', 'Next step');
1741
+ }
1742
+ if (!messages.stepDone) {
1743
+ reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'stepDone', 'done');
1744
+ }
1745
+ if (!messages.stepCurrent) {
1746
+ reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'stepCurrent', 'current');
1747
+ }
1748
+ if (!messages.stepUpcoming) {
1749
+ reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'stepUpcoming', 'upcoming');
1750
+ }
1751
+ if (!messages.redoStep) {
1752
+ reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'redoStep', 'Redo {step}');
1753
+ }
1754
+ if (!messages.completeStep) {
1755
+ reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'completeStep', 'Complete {step}');
1756
+ }
1757
+ if (!messages.advanceToStep) {
1758
+ reportMissingMessage(errors, reporter, nodeId, 'resource', resource.name, 'workflowMessages', 'advanceToStep', 'Advance to {step}');
1759
+ }
1760
+ }
1761
+ function pushPageMessageWarnings(page, errors, reporter) {
1762
+ const messages = page.messages ?? {};
1763
+ const hasDataBlocks = page.blocks.some((block) => block.blockType === 'table'
1764
+ || block.blockType === 'timeline'
1765
+ || block.blockType === 'notes'
1766
+ || block.blockType === 'attachments'
1767
+ || block.blockType === 'metric');
1768
+ if (!hasDataBlocks) {
1769
+ return;
1770
+ }
1771
+ if (!messages.loadFailed) {
1772
+ reportMissingMessage(errors, reporter, page.id, 'page', page.name, 'page', 'loadFailed', 'Failed to load data');
1773
+ }
1774
+ if (!messages.loadingLabel) {
1775
+ reportMissingMessage(errors, reporter, page.id, 'page', page.name, 'page', 'loadingLabel', 'Loading...');
1776
+ }
1777
+ }
1778
+ export function collectMissingMessageTemplateEntries(ir) {
1779
+ const entries = [];
1780
+ const seen = new Set();
1781
+ const reporter = (entry) => {
1782
+ const signature = `${entry.ownerKind}:${entry.ownerName}:${entry.surface}:${entry.key}`;
1783
+ if (seen.has(signature)) {
1784
+ return;
1785
+ }
1786
+ seen.add(signature);
1787
+ entries.push(entry);
1788
+ };
1789
+ const modelMap = new Map(ir.models.map((model) => [model.name, model]));
1790
+ for (const resource of ir.resources) {
1791
+ if (resource.workflow) {
1792
+ pushWorkflowMessageWarnings(resource, Boolean(resource.views.read?.related.length), [], reporter);
1793
+ }
1794
+ if (resource.views.list) {
1795
+ pushListMessageWarnings(resource, resource.views.list, [], reporter);
1796
+ }
1797
+ if (resource.views.edit) {
1798
+ pushEditMessageWarnings(resource, modelMap.get(resource.model), resource.views.edit, [], reporter);
1799
+ }
1800
+ if (resource.views.create) {
1801
+ pushCreateMessageWarnings(resource, modelMap.get(resource.model), resource.views.create, [], reporter);
1802
+ }
1803
+ if (resource.views.read) {
1804
+ pushReadMessageWarnings(resource, resource.views.read, [], reporter);
1805
+ }
1806
+ }
1807
+ for (const page of ir.pages) {
1808
+ pushPageMessageWarnings(page, [], reporter);
1809
+ }
1810
+ return entries;
1811
+ }
1357
1812
  function formatRelationProjectionError(subject, resource, analysis) {
1358
1813
  switch (analysis.reason) {
1359
1814
  case 'unsupportedPathShape':
@@ -1574,18 +2029,25 @@ function validatePage(page, resources, models, readModels) {
1574
2029
  || analysis.kind === 'resourceList'
1575
2030
  || analysis.kind === 'readModelList'
1576
2031
  || analysis.kind === 'readModelCount'
2032
+ || analysis.kind === 'readModelValue'
1577
2033
  || analysis.kind === 'recordRelationList'
1578
2034
  || analysis.kind === 'recordRelationCount') {
1579
2035
  continue;
1580
2036
  }
1581
2037
  const blockLabel = block.title || block.id;
1582
- const blockKindLabel = block.blockType === 'metric' ? 'Metric block' : 'Table block';
2038
+ const blockKindLabel = pageBlockKindLabel(block);
1583
2039
  switch (analysis.reason) {
1584
2040
  case 'missingData':
1585
2041
  errors.push({
1586
2042
  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>`,
2043
+ ? `Metric block "${blockLabel}" in page "${page.name}" must set data: readModel.<name>.count, data: readModel.<name>.<field>, or data: <resource>.<hasManyField>.count`
2044
+ : block.blockType === 'timeline'
2045
+ ? `Timeline block "${blockLabel}" in page "${page.name}" must set data: readModel.<name>.list or data: <resource>.<hasManyField>`
2046
+ : block.blockType === 'notes'
2047
+ ? `Notes block "${blockLabel}" in page "${page.name}" must set data: readModel.<name>.list or data: <resource>.<hasManyField>`
2048
+ : block.blockType === 'attachments'
2049
+ ? `Attachments block "${blockLabel}" in page "${page.name}" must set data: readModel.<name>.list or data: <resource>.<hasManyField>`
2050
+ : `Table block "${blockLabel}" in page "${page.name}" must set data: <resource>.list, data: readModel.<name>.list, or data: <resource>.<hasManyField>`,
1589
2051
  nodeId: block.id,
1590
2052
  severity: 'error',
1591
2053
  });
@@ -1593,8 +2055,14 @@ function validatePage(page, resources, models, readModels) {
1593
2055
  case 'unsupportedDataRef':
1594
2056
  errors.push({
1595
2057
  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}"`,
2058
+ ? `Metric block "${blockLabel}" in page "${page.name}" must use data: readModel.<name>.count, data: readModel.<name>.<field>, or data: <resource>.<hasManyField>.count; got "${analysis.data}"`
2059
+ : block.blockType === 'timeline'
2060
+ ? `Timeline block "${blockLabel}" in page "${page.name}" must use data: readModel.<name>.list or data: <resource>.<hasManyField>; got "${analysis.data}"`
2061
+ : block.blockType === 'notes'
2062
+ ? `Notes block "${blockLabel}" in page "${page.name}" must use data: readModel.<name>.list or data: <resource>.<hasManyField>; got "${analysis.data}"`
2063
+ : block.blockType === 'attachments'
2064
+ ? `Attachments block "${blockLabel}" in page "${page.name}" must use data: readModel.<name>.list or data: <resource>.<hasManyField>; got "${analysis.data}"`
2065
+ : `Table block "${blockLabel}" in page "${page.name}" must use data: <resource>.list, data: readModel.<name>.list, or data: <resource>.<hasManyField>; got "${analysis.data}"`,
1598
2066
  nodeId: block.id,
1599
2067
  severity: 'error',
1600
2068
  });
@@ -1613,6 +2081,13 @@ function validatePage(page, resources, models, readModels) {
1613
2081
  severity: 'error',
1614
2082
  });
1615
2083
  break;
2084
+ case 'readModelResultFieldMissing':
2085
+ errors.push({
2086
+ message: `${blockKindLabel} "${blockLabel}" in page "${page.name}" references missing read-model result field "${analysis.readModelFieldName}" on "${analysis.readModelName}"`,
2087
+ nodeId: block.id,
2088
+ severity: 'error',
2089
+ });
2090
+ break;
1616
2091
  case 'resourceMissing':
1617
2092
  errors.push({
1618
2093
  message: `${blockKindLabel} "${blockLabel}" in page "${page.name}" references unknown resource "${analysis.resourceName}"`,
@@ -1622,7 +2097,7 @@ function validatePage(page, resources, models, readModels) {
1622
2097
  break;
1623
2098
  case 'resourceListMissing':
1624
2099
  errors.push({
1625
- message: `Table block "${blockLabel}" in page "${page.name}" requires resource "${analysis.resourceName}" to define list:`,
2100
+ message: `${blockKindLabel} "${blockLabel}" in page "${page.name}" requires resource "${analysis.resourceName}" to define list:`,
1626
2101
  nodeId: block.id,
1627
2102
  severity: 'error',
1628
2103
  });
@@ -1664,7 +2139,7 @@ function validatePage(page, resources, models, readModels) {
1664
2139
  break;
1665
2140
  case 'targetResourceListMissing':
1666
2141
  errors.push({
1667
- message: `Table block "${blockLabel}" in page "${page.name}" requires related resource "${analysis.targetResource?.name ?? analysis.targetModel?.name}" to define list:`,
2142
+ message: `${blockKindLabel} "${blockLabel}" in page "${page.name}" requires related resource "${analysis.targetResource?.name ?? analysis.targetModel?.name}" to define list:`,
1668
2143
  nodeId: block.id,
1669
2144
  severity: 'error',
1670
2145
  });
@@ -1676,6 +2151,58 @@ function validatePage(page, resources, models, readModels) {
1676
2151
  for (const block of page.blocks) {
1677
2152
  const analysis = analyzePageBlockData(block, resources, models, readModels);
1678
2153
  const blockLabel = block.title || block.id;
2154
+ if (block.blockType === 'timeline') {
2155
+ if (analysis.kind !== 'readModelList' && analysis.kind !== 'recordRelationList') {
2156
+ errors.push({
2157
+ message: `Timeline block "${blockLabel}" in page "${page.name}" must use data: readModel.<name>.list or data: <resource>.<hasManyField> in the current slice`,
2158
+ nodeId: block.id,
2159
+ severity: 'error',
2160
+ });
2161
+ }
2162
+ else if (analysis.kind === 'recordRelationList') {
2163
+ errors.push(...validateTimelineBlock(page, block, analysis.targetModel, `related model "${analysis.targetModel.name}"`));
2164
+ }
2165
+ else {
2166
+ errors.push(...validateTimelineBlock(page, block, analysis.readModel, `read-model "${analysis.readModel.name}" result`));
2167
+ }
2168
+ }
2169
+ if (block.blockType === 'notes') {
2170
+ if (analysis.kind !== 'readModelList' && analysis.kind !== 'recordRelationList') {
2171
+ errors.push({
2172
+ message: `Notes block "${blockLabel}" in page "${page.name}" must use data: readModel.<name>.list or data: <resource>.<hasManyField> in the current slice`,
2173
+ nodeId: block.id,
2174
+ severity: 'error',
2175
+ });
2176
+ }
2177
+ else if (analysis.kind === 'recordRelationList') {
2178
+ errors.push(...validateNotesBlock(page, block, analysis.targetModel, `related model "${analysis.targetModel.name}"`));
2179
+ }
2180
+ else {
2181
+ errors.push(...validateNotesBlock(page, block, analysis.readModel, `read-model "${analysis.readModel.name}" result`));
2182
+ }
2183
+ }
2184
+ if (block.blockType === 'attachments') {
2185
+ if (analysis.kind !== 'readModelList' && analysis.kind !== 'recordRelationList') {
2186
+ errors.push({
2187
+ message: `Attachments block "${blockLabel}" in page "${page.name}" must use data: readModel.<name>.list or data: <resource>.<hasManyField> in the current slice`,
2188
+ nodeId: block.id,
2189
+ severity: 'error',
2190
+ });
2191
+ }
2192
+ else if (analysis.kind === 'recordRelationList') {
2193
+ errors.push(...validateAttachmentsBlock(page, block, analysis.targetModel, `related model "${analysis.targetModel.name}"`));
2194
+ }
2195
+ else {
2196
+ errors.push(...validateAttachmentsBlock(page, block, analysis.readModel, `read-model "${analysis.readModel.name}" result`));
2197
+ }
2198
+ }
2199
+ if (block.blockType === 'table' && analysis.kind === 'readModelList' && analysis.listView.columns.length === 0) {
2200
+ errors.push({
2201
+ message: `Table block "${blockLabel}" in page "${page.name}" requires read-model "${analysis.readModel.name}" list to define at least one column`,
2202
+ nodeId: block.id,
2203
+ severity: 'error',
2204
+ });
2205
+ }
1679
2206
  if (block.queryState) {
1680
2207
  if (block.queryState.trim() === '') {
1681
2208
  errors.push({
@@ -1684,9 +2211,9 @@ function validatePage(page, resources, models, readModels) {
1684
2211
  severity: 'error',
1685
2212
  });
1686
2213
  }
1687
- else if (analysis.kind !== 'readModelList' && analysis.kind !== 'readModelCount') {
2214
+ else if (analysis.kind !== 'readModelList' && analysis.kind !== 'readModelCount' && analysis.kind !== 'readModelValue') {
1688
2215
  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`,
2216
+ message: `Page block "${blockLabel}" in page "${page.name}" may only use queryState with data: readModel.<name>.list, data: readModel.<name>.count, or data: readModel.<name>.<field>`,
1690
2217
  nodeId: block.id,
1691
2218
  severity: 'error',
1692
2219
  });
@@ -1705,6 +2232,27 @@ function validatePage(page, resources, models, readModels) {
1705
2232
  severity: 'error',
1706
2233
  });
1707
2234
  }
2235
+ else if (block.blockType === 'timeline') {
2236
+ errors.push({
2237
+ message: `Timeline block "${blockLabel}" in page "${page.name}" may not use selectionState`,
2238
+ nodeId: block.id,
2239
+ severity: 'error',
2240
+ });
2241
+ }
2242
+ else if (block.blockType === 'notes') {
2243
+ errors.push({
2244
+ message: `Notes block "${blockLabel}" in page "${page.name}" may not use selectionState`,
2245
+ nodeId: block.id,
2246
+ severity: 'error',
2247
+ });
2248
+ }
2249
+ else if (block.blockType === 'attachments') {
2250
+ errors.push({
2251
+ message: `Attachments block "${blockLabel}" in page "${page.name}" may not use selectionState`,
2252
+ nodeId: block.id,
2253
+ severity: 'error',
2254
+ });
2255
+ }
1708
2256
  else if (analysis.kind !== 'readModelList' || block.blockType !== 'table') {
1709
2257
  errors.push({
1710
2258
  message: `Page block "${blockLabel}" in page "${page.name}" may only use selectionState with data: readModel.<name>.list table consumers in the current slice`,
@@ -1744,7 +2292,7 @@ function validatePage(page, resources, models, readModels) {
1744
2292
  severity: 'error',
1745
2293
  });
1746
2294
  }
1747
- else if (inputField.fieldType.type !== 'scalar' || (inputField.fieldType.name !== 'string' && inputField.fieldType.name !== 'datetime')) {
2295
+ else if (inputField.fieldType.type !== 'scalar' || !['string', 'date', 'datetime'].includes(inputField.fieldType.name)) {
1748
2296
  errors.push({
1749
2297
  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
2298
  nodeId: block.id,
@@ -1773,6 +2321,184 @@ function validatePage(page, resources, models, readModels) {
1773
2321
  errors.push(...validatePageBlockRowActions(page, block, resources, models, readModels));
1774
2322
  }
1775
2323
  errors.push(...validatePageActions(page, resources, models, queryStateGroups, selectionStateGroups));
2324
+ pushPageMessageWarnings(page, errors);
2325
+ return errors;
2326
+ }
2327
+ function pageBlockKindLabel(block) {
2328
+ if (block.blockType === 'metric') {
2329
+ return 'Metric block';
2330
+ }
2331
+ if (block.blockType === 'timeline') {
2332
+ return 'Timeline block';
2333
+ }
2334
+ if (block.blockType === 'notes') {
2335
+ return 'Notes block';
2336
+ }
2337
+ if (block.blockType === 'attachments') {
2338
+ return 'Attachments block';
2339
+ }
2340
+ return 'Table block';
2341
+ }
2342
+ function validateFieldContract(fields, fieldName, typeName) {
2343
+ const field = fields.find((candidate) => candidate.name === fieldName);
2344
+ return Boolean(field && field.fieldType.type === 'scalar' && field.fieldType.name === typeName);
2345
+ }
2346
+ function validateTimelineBlock(page, block, source, sourceLabel) {
2347
+ const errors = [];
2348
+ const blockLabel = block.title || block.id;
2349
+ const fields = 'result' in source ? source.result : source.fields;
2350
+ if (!validateFieldContract(fields, 'action', 'string')) {
2351
+ errors.push({
2352
+ message: `Timeline block "${blockLabel}" in page "${page.name}" requires ${sourceLabel}.action: string`,
2353
+ nodeId: block.id,
2354
+ severity: 'error',
2355
+ });
2356
+ }
2357
+ if (!validateFieldContract(fields, 'performedAt', 'datetime')) {
2358
+ errors.push({
2359
+ message: `Timeline block "${blockLabel}" in page "${page.name}" requires ${sourceLabel}.performedAt: datetime`,
2360
+ nodeId: block.id,
2361
+ severity: 'error',
2362
+ });
2363
+ }
2364
+ for (const fieldName of ['performedBy', 'message', 'beforeState', 'afterState']) {
2365
+ const field = fields.find((candidate) => candidate.name === fieldName);
2366
+ if (!field) {
2367
+ continue;
2368
+ }
2369
+ if (field.fieldType.type !== 'scalar' || field.fieldType.name !== 'string') {
2370
+ errors.push({
2371
+ message: `Timeline block "${blockLabel}" in page "${page.name}" requires ${sourceLabel}.${fieldName}: string when present`,
2372
+ nodeId: block.id,
2373
+ severity: 'error',
2374
+ });
2375
+ }
2376
+ }
2377
+ if (block.rowActions.length > 0) {
2378
+ errors.push({
2379
+ message: `Timeline block "${blockLabel}" in page "${page.name}" may not use rowActions`,
2380
+ nodeId: block.id,
2381
+ severity: 'error',
2382
+ });
2383
+ }
2384
+ if (block.selectionState) {
2385
+ errors.push({
2386
+ message: `Timeline block "${blockLabel}" in page "${page.name}" may not use selectionState`,
2387
+ nodeId: block.id,
2388
+ severity: 'error',
2389
+ });
2390
+ }
2391
+ return errors;
2392
+ }
2393
+ function validateNotesBlock(page, block, source, sourceLabel) {
2394
+ const errors = [];
2395
+ const blockLabel = block.title || block.id;
2396
+ const fields = 'result' in source ? source.result : source.fields;
2397
+ if (!validateFieldContract(fields, 'body', 'string')) {
2398
+ errors.push({
2399
+ message: `Notes block "${blockLabel}" in page "${page.name}" requires ${sourceLabel}.body: string`,
2400
+ nodeId: block.id,
2401
+ severity: 'error',
2402
+ });
2403
+ }
2404
+ if (!validateFieldContract(fields, 'notedAt', 'datetime')) {
2405
+ errors.push({
2406
+ message: `Notes block "${blockLabel}" in page "${page.name}" requires ${sourceLabel}.notedAt: datetime`,
2407
+ nodeId: block.id,
2408
+ severity: 'error',
2409
+ });
2410
+ }
2411
+ for (const fieldName of ['notedBy', 'title']) {
2412
+ const field = fields.find((candidate) => candidate.name === fieldName);
2413
+ if (!field) {
2414
+ continue;
2415
+ }
2416
+ if (field.fieldType.type !== 'scalar' || field.fieldType.name !== 'string') {
2417
+ errors.push({
2418
+ message: `Notes block "${blockLabel}" in page "${page.name}" requires ${sourceLabel}.${fieldName}: string when present`,
2419
+ nodeId: block.id,
2420
+ severity: 'error',
2421
+ });
2422
+ }
2423
+ }
2424
+ const visibilityField = fields.find((candidate) => candidate.name === 'visibility');
2425
+ if (visibilityField
2426
+ && ((visibilityField.fieldType.type !== 'scalar' || visibilityField.fieldType.name !== 'string')
2427
+ && visibilityField.fieldType.type !== 'enum')) {
2428
+ errors.push({
2429
+ message: `Notes block "${blockLabel}" in page "${page.name}" requires ${sourceLabel}.visibility: string or enum(...) when present`,
2430
+ nodeId: block.id,
2431
+ severity: 'error',
2432
+ });
2433
+ }
2434
+ if (block.rowActions.length > 0) {
2435
+ errors.push({
2436
+ message: `Notes block "${blockLabel}" in page "${page.name}" may not use rowActions`,
2437
+ nodeId: block.id,
2438
+ severity: 'error',
2439
+ });
2440
+ }
2441
+ if (block.selectionState) {
2442
+ errors.push({
2443
+ message: `Notes block "${blockLabel}" in page "${page.name}" may not use selectionState`,
2444
+ nodeId: block.id,
2445
+ severity: 'error',
2446
+ });
2447
+ }
2448
+ return errors;
2449
+ }
2450
+ function validateAttachmentsBlock(page, block, source, sourceLabel) {
2451
+ const errors = [];
2452
+ const blockLabel = block.title || block.id;
2453
+ const fields = 'result' in source ? source.result : source.fields;
2454
+ if (!validateFieldContract(fields, 'fileName', 'string')) {
2455
+ errors.push({
2456
+ message: `Attachments block "${blockLabel}" in page "${page.name}" requires ${sourceLabel}.fileName: string`,
2457
+ nodeId: block.id,
2458
+ severity: 'error',
2459
+ });
2460
+ }
2461
+ if (!validateFieldContract(fields, 'fileUrl', 'string')) {
2462
+ errors.push({
2463
+ message: `Attachments block "${blockLabel}" in page "${page.name}" requires ${sourceLabel}.fileUrl: string`,
2464
+ nodeId: block.id,
2465
+ severity: 'error',
2466
+ });
2467
+ }
2468
+ if (!validateFieldContract(fields, 'attachedAt', 'datetime')) {
2469
+ errors.push({
2470
+ message: `Attachments block "${blockLabel}" in page "${page.name}" requires ${sourceLabel}.attachedAt: datetime`,
2471
+ nodeId: block.id,
2472
+ severity: 'error',
2473
+ });
2474
+ }
2475
+ for (const fieldName of ['attachedBy', 'mediaType', 'sizeLabel', 'title']) {
2476
+ const field = fields.find((candidate) => candidate.name === fieldName);
2477
+ if (!field) {
2478
+ continue;
2479
+ }
2480
+ if (field.fieldType.type !== 'scalar' || field.fieldType.name !== 'string') {
2481
+ errors.push({
2482
+ message: `Attachments block "${blockLabel}" in page "${page.name}" requires ${sourceLabel}.${fieldName}: string when present`,
2483
+ nodeId: block.id,
2484
+ severity: 'error',
2485
+ });
2486
+ }
2487
+ }
2488
+ if (block.rowActions.length > 0) {
2489
+ errors.push({
2490
+ message: `Attachments block "${blockLabel}" in page "${page.name}" may not use rowActions`,
2491
+ nodeId: block.id,
2492
+ severity: 'error',
2493
+ });
2494
+ }
2495
+ if (block.selectionState) {
2496
+ errors.push({
2497
+ message: `Attachments block "${blockLabel}" in page "${page.name}" may not use selectionState`,
2498
+ nodeId: block.id,
2499
+ severity: 'error',
2500
+ });
2501
+ }
1776
2502
  return errors;
1777
2503
  }
1778
2504
  function serializeReadModelInputSignature(readModel) {
@@ -1789,7 +2515,13 @@ function validatePageBlockRowActions(page, block, resources, models, readModels)
1789
2515
  }
1790
2516
  if (block.blockType !== 'table') {
1791
2517
  errors.push({
1792
- message: `Page block "${block.title || block.id}" in page "${page.name}" may only use rowActions on type: table`,
2518
+ message: block.blockType === 'timeline'
2519
+ ? `Timeline block "${block.title || block.id}" in page "${page.name}" may not use rowActions`
2520
+ : block.blockType === 'notes'
2521
+ ? `Notes block "${block.title || block.id}" in page "${page.name}" may not use rowActions`
2522
+ : block.blockType === 'attachments'
2523
+ ? `Attachments block "${block.title || block.id}" in page "${page.name}" may not use rowActions`
2524
+ : `Page block "${block.title || block.id}" in page "${page.name}" may only use rowActions on type: table`,
1793
2525
  nodeId: block.id,
1794
2526
  severity: 'error',
1795
2527
  });
@@ -2043,30 +2775,75 @@ function validateRuleValuePath(rule, key, nodeId, errors) {
2043
2775
  }
2044
2776
  }
2045
2777
  function validateFormFieldRulePaths(field, errors) {
2046
- validateRuleValuePath(field.visibleWhen, 'field.visibleIf', field.id, errors);
2047
- validateRuleValuePath(field.enabledWhen, 'field.enabledIf', field.id, errors);
2778
+ validateRuleValuePath(field.visibleIf, 'field.visibleIf', field.id, errors);
2779
+ validateRuleValuePath(field.enabledIf, 'field.enabledIf', field.id, errors);
2048
2780
  }
2049
2781
  function validateFormFieldRules(field, errors, validateIdentifier) {
2050
- if (field.visibleWhen?.source === 'builtin') {
2051
- validateWorkflowExpr(field.visibleWhen.expr, field.id, errors, validateIdentifier);
2782
+ if (field.visibleIf?.source === 'builtin') {
2783
+ validateWorkflowExpr(field.visibleIf.expr, field.id, errors, validateIdentifier);
2052
2784
  }
2053
- if (field.enabledWhen?.source === 'builtin') {
2054
- validateWorkflowExpr(field.enabledWhen.expr, field.id, errors, validateIdentifier);
2785
+ if (field.enabledIf?.source === 'builtin') {
2786
+ validateWorkflowExpr(field.enabledIf.expr, field.id, errors, validateIdentifier);
2055
2787
  }
2056
2788
  }
2057
2789
  function validateWorkflowExpr(expr, nodeId, errors, validateIdentifier) {
2058
- visitExpr(expr, (node) => {
2790
+ const visitScoped = (node, bound = new Set()) => {
2059
2791
  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
- });
2792
+ if (!(node.path.length > 0 && bound.has(node.path[0]))) {
2793
+ const error = validateIdentifier(node.path);
2794
+ if (error) {
2795
+ errors.push({
2796
+ message: error,
2797
+ nodeId,
2798
+ severity: 'error',
2799
+ });
2800
+ }
2067
2801
  }
2802
+ return;
2068
2803
  }
2069
- });
2804
+ if (node.type === 'binary') {
2805
+ visitScoped(node.left, bound);
2806
+ visitScoped(node.right, bound);
2807
+ return;
2808
+ }
2809
+ if (node.type === 'unary') {
2810
+ visitScoped(node.operand, bound);
2811
+ return;
2812
+ }
2813
+ if (node.type === 'call') {
2814
+ for (const arg of node.args) {
2815
+ visitScoped(arg, bound);
2816
+ }
2817
+ return;
2818
+ }
2819
+ if (node.type === 'quantifier') {
2820
+ visitScoped(node.collection, bound);
2821
+ const inner = new Set(bound);
2822
+ inner.add(node.binding);
2823
+ visitScoped(node.predicate, inner);
2824
+ return;
2825
+ }
2826
+ if (node.type === 'member') {
2827
+ visitScoped(node.object, bound);
2828
+ return;
2829
+ }
2830
+ if (node.type === 'in') {
2831
+ visitScoped(node.value, bound);
2832
+ for (const item of node.list) {
2833
+ visitScoped(item, bound);
2834
+ }
2835
+ return;
2836
+ }
2837
+ if (node.type === 'nullCheck') {
2838
+ visitScoped(node.value, bound);
2839
+ return;
2840
+ }
2841
+ if (node.type === 'like') {
2842
+ visitScoped(node.value, bound);
2843
+ visitScoped(node.pattern, bound);
2844
+ }
2845
+ };
2846
+ visitScoped(expr);
2070
2847
  }
2071
2848
  function validateWorkflowTransitionIdentifier(path, resourceName, modelFieldNames) {
2072
2849
  if (path.length === 0) {
@@ -2130,7 +2907,7 @@ function validateReadModelRulesEligibilityIdentifier(path, readModelName, inputF
2130
2907
  }
2131
2908
  return `Read-model "${readModelName}" rules eligibility uses unsupported identifier root "${root}"; use currentUser, input, or bare enum-like literals`;
2132
2909
  }
2133
- function validateLinkedFormRulesIdentifier(path, resourceName, mode, modelFieldNames) {
2910
+ function validateLinkedFormRulesIdentifier(path, resourceName, mode, modelFieldMap) {
2134
2911
  if (path.length === 0) {
2135
2912
  return undefined;
2136
2913
  }
@@ -2138,9 +2915,6 @@ function validateLinkedFormRulesIdentifier(path, resourceName, mode, modelFieldN
2138
2915
  if (path.length === 1 && /^[A-Z][A-Z0-9_]*$/.test(root)) {
2139
2916
  return undefined;
2140
2917
  }
2141
- if (rest.length > 0) {
2142
- return `Resource "${resourceName}" ${mode}.rules support only one-level property access; got "${path.join('.')}"`;
2143
- }
2144
2918
  if (root === 'currentUser') {
2145
2919
  if (!property) {
2146
2920
  return undefined;
@@ -2154,16 +2928,26 @@ function validateLinkedFormRulesIdentifier(path, resourceName, mode, modelFieldN
2154
2928
  if (!property) {
2155
2929
  return undefined;
2156
2930
  }
2157
- if (!modelFieldNames.has(property)) {
2931
+ const field = modelFieldMap.get(property);
2932
+ if (!field) {
2158
2933
  return `Resource "${resourceName}" ${mode}.rules reference unknown formData field "${property}"`;
2159
2934
  }
2160
- return undefined;
2935
+ if (rest.length === 0) {
2936
+ return undefined;
2937
+ }
2938
+ if (rest.length === 1 && field.fieldType.type === 'relation' && field.fieldType.kind === 'hasMany') {
2939
+ return undefined;
2940
+ }
2941
+ return `Resource "${resourceName}" ${mode}.rules support only one-level property access except direct hasMany(...) projection; got "${path.join('.')}"`;
2942
+ }
2943
+ if (rest.length > 0) {
2944
+ return `Resource "${resourceName}" ${mode}.rules support only one-level property access; got "${path.join('.')}"`;
2161
2945
  }
2162
2946
  if (root === 'record' && mode === 'edit') {
2163
2947
  if (!property) {
2164
2948
  return undefined;
2165
2949
  }
2166
- if (property !== 'id' && !modelFieldNames.has(property)) {
2950
+ if (property !== 'id' && !modelFieldMap.has(property)) {
2167
2951
  return `Resource "${resourceName}" edit.rules reference unknown record field "${property}"`;
2168
2952
  }
2169
2953
  return undefined;
@@ -2369,6 +3153,11 @@ function visitExpr(expr, visit) {
2369
3153
  }
2370
3154
  return;
2371
3155
  }
3156
+ if (expr.type === 'quantifier') {
3157
+ visitExpr(expr.collection, visit);
3158
+ visitExpr(expr.predicate, visit);
3159
+ return;
3160
+ }
2372
3161
  if (expr.type === 'member') {
2373
3162
  visitExpr(expr.object, visit);
2374
3163
  return;
@@ -2454,6 +3243,70 @@ function validateRouteTarget(target, nodeId, resourceMap, pageMap, errors) {
2454
3243
  }
2455
3244
  }
2456
3245
  }
3246
+ function validateStaticAppRouteTarget(target, nodeId, resourceMap, pageMap, errors, routeName) {
3247
+ const parts = target.split('.');
3248
+ if (parts[0] === 'page' && parts.length === 2) {
3249
+ const page = pageMap.get(parts[1]);
3250
+ if (!page) {
3251
+ errors.push({
3252
+ message: `app.routes.${routeName} references unknown page "${parts[1]}"`,
3253
+ nodeId,
3254
+ severity: 'error',
3255
+ });
3256
+ return;
3257
+ }
3258
+ if (page.path && page.path.split('/').some((segment) => segment.startsWith(':'))) {
3259
+ errors.push({
3260
+ message: `app.routes.${routeName} references page "${parts[1]}" with record-scoped path "${page.path}"; only static page routes are supported`,
3261
+ nodeId,
3262
+ severity: 'error',
3263
+ });
3264
+ }
3265
+ return;
3266
+ }
3267
+ if (parts[0] === 'resource' && parts.length === 3) {
3268
+ const resource = resourceMap.get(parts[1]);
3269
+ if (!resource) {
3270
+ errors.push({
3271
+ message: `app.routes.${routeName} references unknown resource "${parts[1]}"`,
3272
+ nodeId,
3273
+ severity: 'error',
3274
+ });
3275
+ return;
3276
+ }
3277
+ if (parts[2] === 'list') {
3278
+ if (!resource.views.list) {
3279
+ errors.push({
3280
+ message: `app.routes.${routeName} requires resource "${resource.name}" to define list:`,
3281
+ nodeId,
3282
+ severity: 'error',
3283
+ });
3284
+ }
3285
+ return;
3286
+ }
3287
+ if (parts[2] === 'create') {
3288
+ if (!resource.views.create) {
3289
+ errors.push({
3290
+ message: `app.routes.${routeName} requires resource "${resource.name}" to define create:`,
3291
+ nodeId,
3292
+ severity: 'error',
3293
+ });
3294
+ }
3295
+ return;
3296
+ }
3297
+ errors.push({
3298
+ message: `app.routes.${routeName} only supports static resource views; use resource.<name>.list or resource.<name>.create`,
3299
+ nodeId,
3300
+ severity: 'error',
3301
+ });
3302
+ return;
3303
+ }
3304
+ errors.push({
3305
+ message: `app.routes.${routeName} must target page.<name> or resource.<name>.list/create`,
3306
+ nodeId,
3307
+ severity: 'error',
3308
+ });
3309
+ }
2457
3310
  function validateNavTarget(target, nodeId, resourceMap, pageMap, errors) {
2458
3311
  // Navigation target formats: "resource.users.list", "page.dashboard"
2459
3312
  const parts = target.split('.');