@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,1771 @@
1
+ /**
2
+ * ReactDSL Normalizer
3
+ *
4
+ * Transforms a RawAST into the versioned IR (Intermediate Representation).
5
+ * - Assigns stable node IDs
6
+ * - Expands shorthands
7
+ * - Parses expressions into ExprNode ASTs
8
+ * - Parses effects into EffectNode ASTs
9
+ * - Resolves decorator arguments
10
+ */
11
+ import { parseColumnEntry } from './parser.js';
12
+ import { parseExpr } from './expr.js';
13
+ import { compileFlowSource, isFlowSourceFile } from './flow-proof.js';
14
+ import { compileRulesSource, isRulesSourceFile } from './rules-proof.js';
15
+ import { compileStyleSource, isStyleSourceFile } from './style-proof.js';
16
+ import { dirnameProjectPath, resolveProjectPath, toProjectRelativePath, } from './project-paths.js';
17
+ const NORMALIZE_CACHE_VERSION = '0.1.7';
18
+ const FLOW_LINK_REGEX = /^@flow\(["'](.+?)["']\)$/;
19
+ const RULES_LINK_REGEX = /^@rules\(["'](.+?)["']\)$/;
20
+ const STYLE_LINK_REGEX = /^@style\(["'](.+?)["']\)$/;
21
+ const ASSET_LINK_REGEX = /^@asset\(["'](.+?)["']\)$/;
22
+ // ─── Main Entry Point ────────────────────────────────────────────
23
+ export function normalize(ast, options = {}) {
24
+ const previousCache = options.cache?.version === NORMALIZE_CACHE_VERSION
25
+ ? options.cache
26
+ : undefined;
27
+ const appSegment = resolveNormalizedSegment(createNormalizeSignature({
28
+ app: ast.app
29
+ ? {
30
+ name: ast.app.name,
31
+ theme: ast.app.theme,
32
+ auth: ast.app.auth,
33
+ style: ast.app.style,
34
+ seo: ast.app.seo,
35
+ }
36
+ : undefined,
37
+ compiler: ast.compiler,
38
+ }), previousCache?.app, () => normalizeAppShell(ast, options.projectRoot, options.readFile));
39
+ const navigationGroups = (ast.app?.navigation ?? []).map((group, index) => resolveNormalizedNavGroup(group, `app.nav.${index}`, previousCache?.navigationGroups[`app.nav.${index}`], options.projectRoot, appSegment.value.compiler.language, options.readFile));
40
+ const models = ast.models.map((model) => resolveNormalizedModel(model, previousCache?.models[model.name], options.projectRoot));
41
+ const resources = ast.resources.map((resource) => resolveNormalizedResource(resource, previousCache?.resources[resource.name], options.projectRoot, appSegment.value.style, appSegment.value.compiler.language, options.readFile));
42
+ const readModels = ast.readModels.map((readModel) => resolveNormalizedReadModel(readModel, previousCache?.readModels[readModel.name], options.projectRoot, options.readFile));
43
+ const pages = ast.pages.map((page) => resolveNormalizedPage(page, previousCache?.pages[page.name], options.projectRoot, appSegment.value.style, options.readFile));
44
+ const ir = {
45
+ id: 'app.main',
46
+ kind: 'app',
47
+ schemaVersion: '0.1.0',
48
+ name: appSegment.value.name,
49
+ compiler: appSegment.value.compiler,
50
+ theme: appSegment.value.theme,
51
+ auth: appSegment.value.auth,
52
+ style: appSegment.value.style,
53
+ seo: appSegment.value.seo,
54
+ navigation: navigationGroups.map((entry) => entry.value),
55
+ models: models.map((entry) => entry.value),
56
+ resources: resources.map((entry) => entry.value),
57
+ readModels: readModels.map((entry) => entry.value),
58
+ pages: pages.map((entry) => entry.value),
59
+ sourceSpan: appSegment.value.sourceSpan,
60
+ };
61
+ ir.escapeStats = computeEscapeStats(ir);
62
+ return {
63
+ ir,
64
+ errors: [
65
+ ...appSegment.errors,
66
+ ...navigationGroups.flatMap((entry) => entry.errors),
67
+ ...models.flatMap((entry) => entry.errors),
68
+ ...resources.flatMap((entry) => entry.errors),
69
+ ...readModels.flatMap((entry) => entry.errors),
70
+ ...pages.flatMap((entry) => entry.errors),
71
+ ],
72
+ cacheSnapshot: {
73
+ version: NORMALIZE_CACHE_VERSION,
74
+ app: appSegment.cacheEntry,
75
+ navigationGroups: Object.fromEntries(navigationGroups.map((entry) => [entry.value.id, entry.cacheSnapshot])),
76
+ models: Object.fromEntries(ast.models.map((model, index) => [model.name, models[index].cacheSnapshot])),
77
+ resources: Object.fromEntries(ast.resources.map((resource, index) => [resource.name, resources[index].cacheSnapshot])),
78
+ readModels: Object.fromEntries(ast.readModels.map((readModel, index) => [readModel.name, readModels[index].cacheSnapshot])),
79
+ pages: Object.fromEntries(ast.pages.map((page, index) => [page.name, pages[index].cacheSnapshot])),
80
+ },
81
+ };
82
+ }
83
+ function resolveNormalizedSegment(signature, previous, emit) {
84
+ if (previous && previous.signature === signature) {
85
+ return {
86
+ value: previous.value,
87
+ errors: previous.errors,
88
+ cacheEntry: previous,
89
+ };
90
+ }
91
+ const emitted = emit();
92
+ const cacheEntry = {
93
+ signature,
94
+ value: emitted.value,
95
+ errors: emitted.errors,
96
+ };
97
+ return {
98
+ value: emitted.value,
99
+ errors: emitted.errors,
100
+ cacheEntry,
101
+ };
102
+ }
103
+ function createNormalizeSignature(value) {
104
+ return JSON.stringify(value, (_key, current) => {
105
+ if (!current || typeof current !== 'object' || Array.isArray(current)) {
106
+ return current;
107
+ }
108
+ const normalized = {};
109
+ for (const [key, entry] of Object.entries(current)) {
110
+ if (key === 'sourceSpan') {
111
+ continue;
112
+ }
113
+ normalized[key] = entry;
114
+ }
115
+ return normalized;
116
+ });
117
+ }
118
+ function resolveNormalizedResource(raw, previous, projectRoot, appStyle, compilerLanguage = 'typescript', readFile) {
119
+ const resourceSignature = createNormalizeSignature(raw);
120
+ if (previous?.resource?.signature === resourceSignature) {
121
+ return {
122
+ value: previous.resource.value,
123
+ errors: previous.resource.errors,
124
+ cacheSnapshot: previous,
125
+ };
126
+ }
127
+ const id = `resource.${raw.name}`;
128
+ const list = raw.list
129
+ ? resolveNormalizedListView(raw.list, `${id}.view.list`, previous?.list, projectRoot, appStyle, compilerLanguage, readFile)
130
+ : undefined;
131
+ const edit = raw.edit
132
+ ? resolveNormalizedFormView(raw.edit, `${id}.view.edit`, previous?.edit, projectRoot, appStyle, compilerLanguage, readFile, normalizeEditView)
133
+ : undefined;
134
+ const create = raw.create
135
+ ? resolveNormalizedFormView(raw.create, `${id}.view.create`, previous?.create, projectRoot, appStyle, compilerLanguage, readFile, normalizeCreateView)
136
+ : undefined;
137
+ const read = raw.read
138
+ ? resolveNormalizedReadView(raw.read, `${id}.view.read`, previous?.read, projectRoot, appStyle, compilerLanguage, readFile)
139
+ : undefined;
140
+ const errors = [
141
+ ...(list?.errors ?? []),
142
+ ...(edit?.errors ?? []),
143
+ ...(create?.errors ?? []),
144
+ ...(read?.errors ?? []),
145
+ ];
146
+ const value = {
147
+ id,
148
+ kind: 'resource',
149
+ name: raw.name,
150
+ model: raw.model,
151
+ api: raw.api,
152
+ workflow: raw.workflow
153
+ ? normalizeFlowLink(raw.workflow, raw.workflowSourceSpan?.file ?? raw.sourceSpan?.file, projectRoot, readFile, errors, id, 'resource workflow')
154
+ : undefined,
155
+ workflowStyle: raw.workflowStyle
156
+ ? normalizeStyleReference(raw.workflowStyle, appStyle, `${id}.workflow.style`, errors, 'resource workflow style')
157
+ : undefined,
158
+ views: {
159
+ list: list?.value,
160
+ edit: edit?.value,
161
+ create: create?.value,
162
+ read: read?.value,
163
+ },
164
+ sourceSpan: raw.sourceSpan,
165
+ };
166
+ return {
167
+ value,
168
+ errors,
169
+ cacheSnapshot: {
170
+ resource: {
171
+ signature: resourceSignature,
172
+ value,
173
+ errors,
174
+ },
175
+ list: list?.cacheSnapshot,
176
+ edit: edit?.cacheSnapshot,
177
+ create: create?.cacheSnapshot,
178
+ read: read?.cacheSnapshot,
179
+ },
180
+ };
181
+ }
182
+ function resolveNormalizedNavGroup(raw, id, previous, projectRoot, compilerLanguage = 'typescript', readFile) {
183
+ const groupSignature = createNormalizeSignature(raw);
184
+ if (previous?.group?.signature === groupSignature) {
185
+ return {
186
+ value: previous.group.value,
187
+ errors: previous.group.errors,
188
+ cacheSnapshot: previous,
189
+ };
190
+ }
191
+ const items = raw.items.map((item, index) => {
192
+ const itemId = `${id}.${index}`;
193
+ return resolveNormalizedSegment(createNormalizeSignature(item), previous?.items[itemId], () => ({
194
+ value: normalizeNavItem(item, itemId),
195
+ errors: [],
196
+ }));
197
+ });
198
+ const errors = [];
199
+ const value = {
200
+ id,
201
+ kind: 'navGroup',
202
+ group: normalizeMessageLike(raw.group, `${id}.group`, errors, 'navigation group label'),
203
+ visibleIf: raw.visibleIf
204
+ ? tryParseRuleValue(raw.visibleIf, id, errors, raw.sourceSpan?.file, projectRoot, compilerLanguage, readFile, 'navigation rule')
205
+ : undefined,
206
+ items: items.map((entry) => entry.value),
207
+ sourceSpan: raw.sourceSpan,
208
+ };
209
+ const combinedErrors = [
210
+ ...errors,
211
+ ...items.flatMap((entry) => entry.errors),
212
+ ];
213
+ return {
214
+ value,
215
+ errors: combinedErrors,
216
+ cacheSnapshot: {
217
+ group: {
218
+ signature: groupSignature,
219
+ value,
220
+ errors: combinedErrors,
221
+ },
222
+ items: Object.fromEntries(items.map((entry, index) => [`${id}.${index}`, entry.cacheEntry])),
223
+ },
224
+ };
225
+ }
226
+ function resolveNormalizedModel(raw, previous, _projectRoot) {
227
+ const modelSignature = createNormalizeSignature(raw);
228
+ if (previous?.model?.signature === modelSignature) {
229
+ return {
230
+ value: previous.model.value,
231
+ errors: previous.model.errors,
232
+ cacheSnapshot: previous,
233
+ };
234
+ }
235
+ const id = `model.${raw.name}`;
236
+ const fields = raw.fields.map((field, index) => {
237
+ const fieldKey = `${id}.field.${index}`;
238
+ return resolveNormalizedSegment(createNormalizeSignature(field), previous?.fields[fieldKey], () => normalizeModelField(field, `${id}.field.${field.name}`));
239
+ });
240
+ const value = {
241
+ id,
242
+ kind: 'model',
243
+ name: raw.name,
244
+ fields: fields.map((entry) => entry.value),
245
+ sourceSpan: raw.sourceSpan,
246
+ };
247
+ return {
248
+ value,
249
+ errors: [],
250
+ cacheSnapshot: {
251
+ model: {
252
+ signature: modelSignature,
253
+ value,
254
+ errors: [],
255
+ },
256
+ fields: Object.fromEntries(fields.map((entry, index) => [`${id}.field.${index}`, entry.cacheEntry])),
257
+ },
258
+ };
259
+ }
260
+ function resolveNormalizedReadModel(raw, previous, projectRoot, readFile) {
261
+ const readModelSignature = createNormalizeSignature(raw);
262
+ if (previous?.readModel?.signature === readModelSignature) {
263
+ return {
264
+ value: previous.readModel.value,
265
+ errors: previous.readModel.errors,
266
+ cacheSnapshot: previous,
267
+ };
268
+ }
269
+ const id = `readModel.${raw.name}`;
270
+ const inputs = raw.inputs.map((field, index) => {
271
+ const fieldKey = `${id}.inputs.${index}`;
272
+ return resolveNormalizedSegment(createNormalizeSignature(field), previous?.inputs[fieldKey], () => normalizeReadModelField(field, `${id}.inputs.${field.name}`, 'inputs'));
273
+ });
274
+ const result = raw.result.map((field, index) => {
275
+ const fieldKey = `${id}.result.${index}`;
276
+ return resolveNormalizedSegment(createNormalizeSignature(field), previous?.result[fieldKey], () => normalizeReadModelField(field, `${id}.result.${field.name}`, 'result'));
277
+ });
278
+ const list = raw.list
279
+ ? resolveNormalizedReadModelListView(raw.list, `${id}.list`, previous?.list)
280
+ : undefined;
281
+ const combinedErrors = [
282
+ ...inputs.flatMap((entry) => entry.errors),
283
+ ...result.flatMap((entry) => entry.errors),
284
+ ...(list?.errors ?? []),
285
+ ];
286
+ const value = {
287
+ id,
288
+ kind: 'readModel',
289
+ name: raw.name,
290
+ api: raw.api || '',
291
+ rules: raw.rules
292
+ ? normalizeRulesLink(raw.rules, raw.sourceSpan?.file, projectRoot, readFile, combinedErrors, id, 'readModel rules')
293
+ : undefined,
294
+ inputs: inputs.map((entry) => entry.value),
295
+ result: result.map((entry) => entry.value),
296
+ list: list?.value,
297
+ sourceSpan: raw.sourceSpan,
298
+ };
299
+ return {
300
+ value,
301
+ errors: combinedErrors,
302
+ cacheSnapshot: {
303
+ readModel: {
304
+ signature: readModelSignature,
305
+ value,
306
+ errors: combinedErrors,
307
+ },
308
+ inputs: Object.fromEntries(inputs.map((entry, index) => [`${id}.inputs.${index}`, entry.cacheEntry])),
309
+ result: Object.fromEntries(result.map((entry, index) => [`${id}.result.${index}`, entry.cacheEntry])),
310
+ list: list?.cacheSnapshot,
311
+ },
312
+ };
313
+ }
314
+ function resolveNormalizedReadModelListView(raw, id, previous) {
315
+ const viewSignature = createNormalizeSignature(raw);
316
+ if (previous?.view?.signature === viewSignature) {
317
+ return {
318
+ value: previous.view.value,
319
+ errors: previous.view.errors,
320
+ cacheSnapshot: previous,
321
+ };
322
+ }
323
+ const columns = (raw.columns || []).map((column, index) => {
324
+ const columnKey = `${id}.column.${index}`;
325
+ return resolveNormalizedSegment(createNormalizeSignature(column), previous?.columns[columnKey], () => {
326
+ const errors = [];
327
+ return {
328
+ value: normalizeColumn(column, `${id}.column.${column.field}`, errors),
329
+ errors,
330
+ };
331
+ });
332
+ });
333
+ const combinedErrors = columns.flatMap((entry) => entry.errors);
334
+ const value = {
335
+ id,
336
+ kind: 'readModel.list',
337
+ columns: columns.map((entry) => entry.value),
338
+ groupBy: raw.groupBy ?? [],
339
+ pivotBy: raw.pivotBy,
340
+ pagination: raw.pagination ? {
341
+ size: raw.pagination.size || 20,
342
+ style: raw.pagination.style || 'numbered',
343
+ } : undefined,
344
+ sourceSpan: raw.sourceSpan,
345
+ };
346
+ return {
347
+ value,
348
+ errors: combinedErrors,
349
+ cacheSnapshot: {
350
+ view: {
351
+ signature: viewSignature,
352
+ value,
353
+ errors: combinedErrors,
354
+ },
355
+ columns: Object.fromEntries(columns.map((entry, index) => [`${id}.column.${index}`, entry.cacheEntry])),
356
+ },
357
+ };
358
+ }
359
+ function resolveNormalizedListView(raw, id, previous, projectRoot, appStyle, compilerLanguage = 'typescript', readFile) {
360
+ const viewSignature = createNormalizeSignature(raw);
361
+ if (previous?.view?.signature === viewSignature) {
362
+ return {
363
+ value: previous.view.value,
364
+ errors: previous.view.errors,
365
+ cacheSnapshot: previous,
366
+ };
367
+ }
368
+ const filters = (raw.filters || []).map((filter, index) => {
369
+ const filterKey = `${id}.filter.${index}`;
370
+ return resolveNormalizedSegment(createNormalizeSignature(filter), previous?.filters[filterKey], () => ({
371
+ value: {
372
+ id: `${id}.filter.${filter}`,
373
+ kind: 'filter',
374
+ field: filter,
375
+ sourceSpan: undefined,
376
+ },
377
+ errors: [],
378
+ }));
379
+ });
380
+ const columns = (raw.columns || []).map((column, index) => {
381
+ const columnKey = `${id}.column.${index}`;
382
+ return resolveNormalizedSegment(createNormalizeSignature(column), previous?.columns[columnKey], () => {
383
+ const errors = [];
384
+ return {
385
+ value: normalizeColumn(column, `${id}.column.${column.field}`, errors, projectRoot, compilerLanguage, readFile),
386
+ errors,
387
+ };
388
+ });
389
+ });
390
+ const actions = (raw.actions || []).map((action, index) => {
391
+ const actionKey = `${id}.action.${index}`;
392
+ return resolveNormalizedSegment(createNormalizeSignature(action), previous?.actions[actionKey], () => ({
393
+ value: normalizeAction(action, `${id}.action.${action.name}`),
394
+ errors: [],
395
+ }));
396
+ });
397
+ const errors = [];
398
+ const value = {
399
+ id,
400
+ kind: 'view.list',
401
+ title: normalizeMessageLike(raw.title, `${id}.title`, errors, 'list title'),
402
+ style: raw.style ? normalizeStyleReference(raw.style, appStyle, `${id}.style`, errors, 'list style') : undefined,
403
+ filters: filters.map((entry) => entry.value),
404
+ columns: columns.map((entry) => entry.value),
405
+ actions: actions.map((entry) => entry.value),
406
+ pagination: raw.pagination ? {
407
+ size: raw.pagination.size || 20,
408
+ style: raw.pagination.style || 'numbered',
409
+ } : undefined,
410
+ rules: raw.rules
411
+ ? normalizeRules(raw.rules, id, errors, raw.sourceSpan?.file, projectRoot, compilerLanguage, readFile)
412
+ : undefined,
413
+ sourceSpan: raw.sourceSpan,
414
+ };
415
+ const combinedErrors = [
416
+ ...errors,
417
+ ...filters.flatMap((entry) => entry.errors),
418
+ ...columns.flatMap((entry) => entry.errors),
419
+ ...actions.flatMap((entry) => entry.errors),
420
+ ];
421
+ return {
422
+ value,
423
+ errors: combinedErrors,
424
+ cacheSnapshot: {
425
+ view: {
426
+ signature: viewSignature,
427
+ value,
428
+ errors: combinedErrors,
429
+ },
430
+ filters: Object.fromEntries(filters.map((entry, index) => [`${id}.filter.${index}`, entry.cacheEntry])),
431
+ columns: Object.fromEntries(columns.map((entry, index) => [`${id}.column.${index}`, entry.cacheEntry])),
432
+ actions: Object.fromEntries(actions.map((entry, index) => [`${id}.action.${index}`, entry.cacheEntry])),
433
+ },
434
+ };
435
+ }
436
+ function resolveNormalizedFormView(raw, id, previous, projectRoot, appStyle, compilerLanguage = 'typescript', readFile, emitView) {
437
+ const viewSignature = createNormalizeSignature(raw);
438
+ if (previous?.view?.signature === viewSignature) {
439
+ return {
440
+ value: previous.view.value,
441
+ errors: previous.view.errors,
442
+ cacheSnapshot: previous,
443
+ };
444
+ }
445
+ const rawFields = (raw.fields || []);
446
+ const fields = rawFields.map((field, index) => {
447
+ const fieldKey = `${id}.field.${index}`;
448
+ return resolveNormalizedSegment(createNormalizeSignature(field), previous?.fields[fieldKey], () => {
449
+ const errors = [];
450
+ return {
451
+ value: normalizeFormFieldLike(field, id, errors, projectRoot, compilerLanguage, readFile),
452
+ errors,
453
+ };
454
+ });
455
+ });
456
+ const rawIncludes = 'includes' in raw && Array.isArray(raw.includes) ? raw.includes : [];
457
+ const includes = rawIncludes.map((include, index) => {
458
+ const includeKey = `${id}.include.${index}`;
459
+ return resolveNormalizedSegment(createNormalizeSignature(include), previous?.includes[includeKey]?.include, () => {
460
+ const includeErrors = [];
461
+ const rawIncludeFields = include.fields || [];
462
+ const includeFields = rawIncludeFields.map((field, fieldIndex) => resolveNormalizedSegment(createNormalizeSignature(field), previous?.includes[includeKey]?.fields[`${includeKey}.field.${fieldIndex}`], () => {
463
+ const fieldErrors = [];
464
+ return {
465
+ value: normalizeFormFieldLike(field, includeKey, fieldErrors, projectRoot, compilerLanguage, readFile),
466
+ errors: fieldErrors,
467
+ };
468
+ }));
469
+ const value = {
470
+ id: `${id}.include.${include.field}`,
471
+ kind: 'createInclude',
472
+ field: include.field,
473
+ minItems: typeof include.minItems === 'number' && Number.isFinite(include.minItems)
474
+ ? include.minItems
475
+ : 0,
476
+ fields: includeFields.map((entry) => entry.value),
477
+ rulesLink: typeof include.rules === 'string'
478
+ ? normalizeRulesLink(include.rules, include.sourceSpan?.file, projectRoot, readFile, includeErrors, `${id}.include.${include.field}`, `${id}.includes.${include.field} rules`)
479
+ : undefined,
480
+ sourceSpan: include.sourceSpan,
481
+ };
482
+ return {
483
+ value,
484
+ errors: [
485
+ ...includeErrors,
486
+ ...includeFields.flatMap((entry) => entry.errors),
487
+ ],
488
+ };
489
+ });
490
+ });
491
+ const errors = [];
492
+ const value = emitView(raw, id, fields.map((entry) => entry.value), includes.map((entry) => entry.value), errors, appStyle, projectRoot, compilerLanguage, readFile);
493
+ const combinedErrors = [
494
+ ...errors,
495
+ ...fields.flatMap((entry) => entry.errors),
496
+ ...includes.flatMap((entry) => entry.errors),
497
+ ];
498
+ return {
499
+ value,
500
+ errors: combinedErrors,
501
+ cacheSnapshot: {
502
+ view: {
503
+ signature: viewSignature,
504
+ value,
505
+ errors: combinedErrors,
506
+ },
507
+ fields: Object.fromEntries(fields.map((entry, index) => [`${id}.field.${index}`, entry.cacheEntry])),
508
+ includes: Object.fromEntries(includes.map((entry, index) => {
509
+ const includeKey = `${id}.include.${index}`;
510
+ const include = rawIncludes[index];
511
+ return [
512
+ includeKey,
513
+ {
514
+ include: entry.cacheEntry,
515
+ fields: Object.fromEntries((include?.fields || []).map((field, fieldIndex) => [
516
+ `${includeKey}.field.${fieldIndex}`,
517
+ resolveNormalizedSegment(createNormalizeSignature(field), previous?.includes[includeKey]?.fields[`${includeKey}.field.${fieldIndex}`], () => {
518
+ const fieldErrors = [];
519
+ return {
520
+ value: normalizeFormFieldLike(field, includeKey, fieldErrors, projectRoot, compilerLanguage, readFile),
521
+ errors: fieldErrors,
522
+ };
523
+ }).cacheEntry,
524
+ ])),
525
+ },
526
+ ];
527
+ })),
528
+ },
529
+ };
530
+ }
531
+ function resolveNormalizedReadView(raw, id, previous, projectRoot, appStyle, compilerLanguage = 'typescript', readFile) {
532
+ const viewSignature = createNormalizeSignature(raw);
533
+ if (previous?.view?.signature === viewSignature) {
534
+ return {
535
+ value: previous.view.value,
536
+ errors: previous.view.errors,
537
+ cacheSnapshot: previous,
538
+ };
539
+ }
540
+ const fields = (raw.fields || []).map((field, index) => {
541
+ const fieldKey = `${id}.field.${index}`;
542
+ return resolveNormalizedSegment(createNormalizeSignature(field), previous?.fields[fieldKey], () => {
543
+ const errors = [];
544
+ return {
545
+ value: normalizeColumn(field, `${id}.field.${field.field}`, errors, projectRoot, compilerLanguage, readFile),
546
+ errors,
547
+ };
548
+ });
549
+ });
550
+ const related = (raw.related || []).map((field, index) => {
551
+ const relatedKey = `${id}.related.${index}`;
552
+ return resolveNormalizedSegment(createNormalizeSignature(field), previous?.related[relatedKey], () => ({
553
+ value: {
554
+ id: `${id}.related.${field}`,
555
+ kind: 'relatedPanel',
556
+ field,
557
+ sourceSpan: undefined,
558
+ },
559
+ errors: [],
560
+ }));
561
+ });
562
+ const errors = [
563
+ ...fields.flatMap((entry) => entry.errors),
564
+ ...related.flatMap((entry) => entry.errors),
565
+ ];
566
+ const value = normalizeReadView(raw, id, fields.map((entry) => entry.value), related.map((entry) => entry.value), errors, appStyle);
567
+ return {
568
+ value,
569
+ errors,
570
+ cacheSnapshot: {
571
+ view: {
572
+ signature: viewSignature,
573
+ value,
574
+ errors,
575
+ },
576
+ fields: Object.fromEntries(fields.map((entry, index) => [`${id}.field.${index}`, entry.cacheEntry])),
577
+ related: Object.fromEntries(related.map((entry, index) => [`${id}.related.${index}`, entry.cacheEntry])),
578
+ },
579
+ };
580
+ }
581
+ function resolveNormalizedPage(raw, previous, projectRoot, appStyle, readFile) {
582
+ const pageSignature = createNormalizeSignature(raw);
583
+ if (previous?.page?.signature === pageSignature) {
584
+ return {
585
+ value: previous.page.value,
586
+ errors: previous.page.errors,
587
+ cacheSnapshot: previous,
588
+ };
589
+ }
590
+ const id = `page.${raw.name}`;
591
+ const blocks = raw.blocks.map((block, index) => resolveNormalizedSegment(createNormalizeSignature(block), previous?.blocks[`${id}.block.${index}`], () => {
592
+ const errors = [];
593
+ return {
594
+ value: normalizeDashboardBlock(block, `${id}.block.${index}`, errors, projectRoot, appStyle),
595
+ errors,
596
+ };
597
+ }));
598
+ const errors = blocks.flatMap((entry) => entry.errors);
599
+ const style = raw.style ? normalizeStyleReference(raw.style, appStyle, `${id}.style`, errors, 'page style') : undefined;
600
+ const seo = raw.seo
601
+ ? {
602
+ description: raw.seo.description
603
+ ? normalizeMessageLike(raw.seo.description, `${id}.seo.description`, errors, 'page seo description')
604
+ : undefined,
605
+ canonicalPath: normalizeCanonicalPath(raw.seo.canonicalPath, `${id}.seo.canonicalPath`, errors),
606
+ image: raw.seo.image
607
+ ? normalizeAssetLink(raw.seo.image, raw.sourceSpan?.file, projectRoot, readFile, errors, `${id}.seo.image`, 'page seo image')
608
+ : undefined,
609
+ noIndex: raw.seo.noIndex === true,
610
+ }
611
+ : undefined;
612
+ const value = {
613
+ id,
614
+ kind: 'page',
615
+ name: raw.name,
616
+ title: normalizeMessageLike(raw.title, `${id}.title`, errors, 'page title'),
617
+ style,
618
+ seo,
619
+ pageType: raw.type === 'dashboard' ? 'dashboard' : 'custom',
620
+ path: raw.path,
621
+ layout: raw.layout,
622
+ actions: (raw.actions || []).map((action, index) => normalizePageAction(action, `${id}.action.${index}`)),
623
+ blocks: blocks.map((entry) => entry.value),
624
+ sourceSpan: raw.sourceSpan,
625
+ };
626
+ return {
627
+ value,
628
+ errors,
629
+ cacheSnapshot: {
630
+ page: {
631
+ signature: pageSignature,
632
+ value,
633
+ errors,
634
+ },
635
+ blocks: Object.fromEntries(blocks.map((entry, index) => [`${id}.block.${index}`, entry.cacheEntry])),
636
+ },
637
+ };
638
+ }
639
+ // ─── Helpers ─────────────────────────────────────────────────────
640
+ function normalizeAppShell(ast, projectRoot, readFile) {
641
+ const errors = [];
642
+ const style = ast.app?.style
643
+ ? normalizeStyleLink(ast.app.style, ast.app.sourceSpan?.file, projectRoot, readFile, errors, 'app.main', 'app style')
644
+ : undefined;
645
+ const seo = ast.app?.seo
646
+ ? {
647
+ siteName: ast.app.seo.siteName
648
+ ? normalizeMessageLike(ast.app.seo.siteName, 'app.main.seo.siteName', errors, 'app seo siteName')
649
+ : undefined,
650
+ defaultTitle: ast.app.seo.defaultTitle
651
+ ? normalizeMessageLike(ast.app.seo.defaultTitle, 'app.main.seo.defaultTitle', errors, 'app seo defaultTitle')
652
+ : undefined,
653
+ titleTemplate: ast.app.seo.titleTemplate
654
+ ? normalizeMessageLike(ast.app.seo.titleTemplate, 'app.main.seo.titleTemplate', errors, 'app seo titleTemplate')
655
+ : undefined,
656
+ defaultDescription: ast.app.seo.defaultDescription
657
+ ? normalizeMessageLike(ast.app.seo.defaultDescription, 'app.main.seo.defaultDescription', errors, 'app seo defaultDescription')
658
+ : undefined,
659
+ defaultImage: ast.app.seo.defaultImage
660
+ ? normalizeAssetLink(ast.app.seo.defaultImage, ast.app.sourceSpan?.file, projectRoot, readFile, errors, 'app.main.seo.defaultImage', 'app seo defaultImage')
661
+ : undefined,
662
+ favicon: ast.app.seo.favicon
663
+ ? normalizeAssetLink(ast.app.seo.favicon, ast.app.sourceSpan?.file, projectRoot, readFile, errors, 'app.main.seo.favicon', 'app seo favicon')
664
+ : undefined,
665
+ }
666
+ : undefined;
667
+ return {
668
+ value: {
669
+ name: ast.app?.name || 'Untitled',
670
+ compiler: normalizeCompiler(ast.compiler, errors),
671
+ theme: normalizeTheme(ast.app?.theme, errors),
672
+ auth: normalizeAuth(ast.app?.auth, errors),
673
+ style,
674
+ seo,
675
+ sourceSpan: ast.app?.sourceSpan,
676
+ },
677
+ errors,
678
+ };
679
+ }
680
+ function normalizeTheme(theme, errors) {
681
+ if (theme === undefined)
682
+ return 'light';
683
+ if (theme === 'dark')
684
+ return 'dark';
685
+ if (theme !== 'light') {
686
+ errors.push({
687
+ message: `Invalid app theme "${theme}". Expected "light" or "dark".`,
688
+ nodeId: 'app.main',
689
+ });
690
+ }
691
+ return 'light';
692
+ }
693
+ function normalizeAuth(auth, errors) {
694
+ if (auth === undefined)
695
+ return 'none';
696
+ if (auth === 'jwt')
697
+ return 'jwt';
698
+ if (auth === 'session')
699
+ return 'session';
700
+ if (auth !== 'none') {
701
+ errors.push({
702
+ message: `Invalid auth mode "${auth}". Expected "jwt", "session", or "none".`,
703
+ nodeId: 'app.main',
704
+ });
705
+ }
706
+ return 'none';
707
+ }
708
+ function normalizeCompiler(compiler, errors) {
709
+ const target = compiler?.target;
710
+ if (target === undefined || target === 'react') {
711
+ return {
712
+ target: 'react',
713
+ language: 'typescript',
714
+ sourceSpan: compiler?.sourceSpan,
715
+ };
716
+ }
717
+ errors.push({
718
+ message: `Invalid compiler target "${target}". Expected "react".`,
719
+ nodeId: 'app.main',
720
+ });
721
+ return {
722
+ target: 'react',
723
+ language: 'typescript',
724
+ sourceSpan: compiler?.sourceSpan,
725
+ };
726
+ }
727
+ // ─── Navigation ──────────────────────────────────────────────────
728
+ function normalizeNavItem(raw, id) {
729
+ return {
730
+ id,
731
+ kind: 'navItem',
732
+ label: typeof raw.label === 'string' ? raw.label : normalizeMessageDescriptor(raw.label, `${id}.label`, [], 'navigation label'),
733
+ icon: raw.icon,
734
+ target: raw.target,
735
+ sourceSpan: raw.sourceSpan,
736
+ };
737
+ }
738
+ function normalizeReadModelField(raw, id, section) {
739
+ const errors = [];
740
+ return {
741
+ value: {
742
+ id,
743
+ kind: 'readModel.field',
744
+ name: raw.name,
745
+ section,
746
+ fieldType: normalizeFieldType(raw.typeExpr, id, errors),
747
+ decorators: raw.decorators.map((decorator) => normalizeDecorator(decorator)),
748
+ sourceSpan: raw.sourceSpan,
749
+ },
750
+ errors,
751
+ };
752
+ }
753
+ function normalizeModelField(raw, id) {
754
+ const errors = [];
755
+ return {
756
+ value: {
757
+ id,
758
+ kind: 'field',
759
+ name: raw.name,
760
+ fieldType: normalizeFieldType(raw.typeExpr, id, errors),
761
+ decorators: raw.decorators.map(d => normalizeDecorator(d)),
762
+ sourceSpan: raw.sourceSpan,
763
+ },
764
+ errors,
765
+ };
766
+ }
767
+ function normalizeFieldType(typeExpr, nodeId, errors) {
768
+ const relationMatch = typeExpr.match(/^belongsTo\(\s*([^)]+?)\s*\)$/);
769
+ if (relationMatch) {
770
+ return {
771
+ type: 'relation',
772
+ kind: 'belongsTo',
773
+ target: relationMatch[1].trim(),
774
+ };
775
+ }
776
+ if (typeExpr.startsWith('belongsTo(')) {
777
+ errors.push({
778
+ message: 'belongsTo() must use the form belongsTo(Target)',
779
+ nodeId,
780
+ });
781
+ return { type: 'scalar', name: 'string' };
782
+ }
783
+ const hasManyMatch = typeExpr.match(/^hasMany\(\s*([^,]+?)\s*,\s*by:\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)$/);
784
+ if (hasManyMatch) {
785
+ return {
786
+ type: 'relation',
787
+ kind: 'hasMany',
788
+ target: hasManyMatch[1].trim(),
789
+ by: hasManyMatch[2].trim(),
790
+ };
791
+ }
792
+ if (typeExpr.startsWith('hasMany(')) {
793
+ errors.push({
794
+ message: 'hasMany() must use the form hasMany(Target, by: relationField)',
795
+ nodeId,
796
+ });
797
+ return { type: 'scalar', name: 'string' };
798
+ }
799
+ const enumMatch = typeExpr.match(/^enum\(([^)]+)\)$/);
800
+ if (enumMatch) {
801
+ const values = enumMatch[1].split(',').map(v => v.trim());
802
+ return { type: 'enum', values };
803
+ }
804
+ const scalarTypes = ['string', 'number', 'boolean', 'datetime'];
805
+ for (const t of scalarTypes) {
806
+ if (typeExpr === t) {
807
+ return { type: 'scalar', name: t };
808
+ }
809
+ }
810
+ // Default to string for unknown types (with a warning in a real implementation)
811
+ return { type: 'scalar', name: 'string' };
812
+ }
813
+ function normalizeDecorator(raw) {
814
+ const dec = { name: raw.name };
815
+ if (raw.args) {
816
+ dec.args = parseDecoratorArgs(raw.args);
817
+ }
818
+ return dec;
819
+ }
820
+ /**
821
+ * Parse decorator arguments like:
822
+ * admin:red, editor:blue → { admin: "red", editor: "blue" }
823
+ * 2 → { value: 2 }
824
+ * "Delete this user?" → { value: "Delete this user?" }
825
+ * mode == "edit" → { expr: 'mode == "edit"' }
826
+ * "./components/Cell.tsx" → { path: "./components/Cell.tsx" }
827
+ */
828
+ function parseDecoratorArgs(args) {
829
+ const trimmed = args.trim();
830
+ // Check if it's a file path (escape hatch)
831
+ if (trimmed.startsWith('"./') || trimmed.startsWith("'./")) {
832
+ return { path: trimmed.replace(/^["']|["']$/g, '') };
833
+ }
834
+ // Check if it's a quoted string
835
+ if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
836
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
837
+ return { value: trimmed.slice(1, -1) };
838
+ }
839
+ // Check if it's a number
840
+ if (/^\d+$/.test(trimmed)) {
841
+ return { value: Number(trimmed) };
842
+ }
843
+ // Check if it's a key:value map (admin:red, editor:blue)
844
+ if (trimmed.includes(':') && !trimmed.includes('==')) {
845
+ const entries = {};
846
+ const pairs = trimmed.split(',').map(s => s.trim());
847
+ for (const pair of pairs) {
848
+ const [k, v] = pair.split(':').map(s => s.trim());
849
+ if (k && v)
850
+ entries[k] = v;
851
+ }
852
+ return entries;
853
+ }
854
+ // Check if it's an expression
855
+ if (trimmed.includes('==') || trimmed.includes('!=') || trimmed.includes('.')) {
856
+ return { expr: trimmed };
857
+ }
858
+ return { value: trimmed };
859
+ }
860
+ // ─── Resource ────────────────────────────────────────────────────
861
+ function normalizeColumn(raw, id, errors, projectRoot, compilerLanguage = 'typescript', readFile) {
862
+ const column = {
863
+ id,
864
+ kind: 'column',
865
+ field: raw.field,
866
+ decorators: raw.decorators.map(d => ({
867
+ name: d.name,
868
+ args: d.args ? parseDecoratorArgs(d.args) : undefined,
869
+ })),
870
+ sourceSpan: raw.sourceSpan,
871
+ };
872
+ // Logic escape tier 1: @expr() for computed label
873
+ const exprDec = raw.decorators.find(d => d.name === 'expr');
874
+ if (exprDec?.args) {
875
+ column.dynamicLabel = {
876
+ source: 'escape-expr',
877
+ escape: { tier: 'expr', raw: exprDec.args },
878
+ };
879
+ }
880
+ // Logic escape tier 2: @fn() for display transform
881
+ const fnDec = raw.decorators.find(d => d.name === 'fn');
882
+ if (fnDec?.args) {
883
+ const parsed = parseDecoratorArgs(fnDec.args);
884
+ if (typeof parsed['path'] === 'string') {
885
+ column.displayFn = normalizeFnEscape(parsed['path'], raw.sourceSpan?.file, projectRoot, compilerLanguage, readFile, errors, id, 'column @fn');
886
+ }
887
+ }
888
+ // UI escape: @custom() for full React cell renderer
889
+ const customDec = raw.decorators.find(d => d.name === 'custom');
890
+ if (customDec?.args) {
891
+ const parsed = parseDecoratorArgs(customDec.args);
892
+ if (typeof parsed['path'] === 'string') {
893
+ column.customRenderer = normalizeEscapePath(parsed['path'], raw.sourceSpan?.file, projectRoot, errors, id, 'column @custom');
894
+ }
895
+ }
896
+ return column;
897
+ }
898
+ function normalizeAction(raw, id) {
899
+ const action = {
900
+ id,
901
+ kind: 'action',
902
+ name: raw.name,
903
+ sourceSpan: raw.sourceSpan,
904
+ };
905
+ const confirmDec = raw.decorators.find(d => d.name === 'confirm');
906
+ if (confirmDec?.args) {
907
+ const parsed = parseDecoratorArgs(confirmDec.args);
908
+ action.confirm = typeof parsed['value'] === 'string' ? parsed['value'] : String(confirmDec.args);
909
+ }
910
+ return action;
911
+ }
912
+ function normalizeEditView(raw, id, fields, includes, errors, appStyle, projectRoot, compilerLanguage = 'typescript', readFile) {
913
+ const edit = raw;
914
+ const rules = typeof edit.rules === 'string'
915
+ ? undefined
916
+ : edit.rules
917
+ ? normalizeRules(edit.rules, id, errors, edit.sourceSpan?.file, projectRoot, compilerLanguage, readFile)
918
+ : undefined;
919
+ const rulesLink = typeof edit.rules === 'string'
920
+ ? normalizeRulesLink(edit.rules, edit.sourceSpan?.file, projectRoot, readFile, errors, id, 'edit rules')
921
+ : undefined;
922
+ return {
923
+ id,
924
+ kind: 'view.edit',
925
+ style: raw.style ? normalizeStyleReference(raw.style, appStyle, `${id}.style`, errors, 'edit style') : undefined,
926
+ fields,
927
+ includes,
928
+ rules,
929
+ rulesLink,
930
+ onSuccess: (edit.onSuccess || []).map(e => normalizeEffect(e, id, errors)),
931
+ sourceSpan: edit.sourceSpan,
932
+ };
933
+ }
934
+ function normalizeCreateView(raw, id, fields, includes, errors, appStyle, projectRoot, compilerLanguage = 'typescript', readFile) {
935
+ const create = raw;
936
+ const rules = typeof create.rules === 'string'
937
+ ? undefined
938
+ : create.rules
939
+ ? normalizeRules(create.rules, id, errors, create.sourceSpan?.file, projectRoot, compilerLanguage, readFile)
940
+ : undefined;
941
+ const rulesLink = typeof create.rules === 'string'
942
+ ? normalizeRulesLink(create.rules, create.sourceSpan?.file, projectRoot, readFile, errors, id, 'create rules')
943
+ : undefined;
944
+ return {
945
+ id,
946
+ kind: 'view.create',
947
+ style: raw.style ? normalizeStyleReference(raw.style, appStyle, `${id}.style`, errors, 'create style') : undefined,
948
+ fields,
949
+ includes,
950
+ rules,
951
+ rulesLink,
952
+ onSuccess: (create.onSuccess || []).map(e => normalizeEffect(e, id, errors)),
953
+ sourceSpan: create.sourceSpan,
954
+ };
955
+ }
956
+ function normalizeReadView(raw, id, fields, related, errors, appStyle) {
957
+ return {
958
+ id,
959
+ kind: 'view.read',
960
+ title: normalizeMessageLike(raw.title, `${id}.title`, errors, 'read title'),
961
+ style: raw.style ? normalizeStyleReference(raw.style, appStyle, `${id}.style`, errors, 'read style') : undefined,
962
+ fields,
963
+ related,
964
+ sourceSpan: raw.sourceSpan,
965
+ };
966
+ }
967
+ function normalizeFormFieldLike(raw, viewId, errors, projectRoot, compilerLanguage = 'typescript', readFile) {
968
+ if (typeof raw === 'string') {
969
+ const parsed = parseColumnEntry(raw);
970
+ return {
971
+ id: `${viewId}.field.${parsed.field}`,
972
+ kind: 'formField',
973
+ field: parsed.field,
974
+ decorators: parsed.decorators.map((decorator) => normalizeDecorator(decorator)),
975
+ };
976
+ }
977
+ return normalizeFormField(raw, `${viewId}.field.${raw.field}`, errors, projectRoot, compilerLanguage, readFile);
978
+ }
979
+ function normalizeFormField(raw, id, errors, projectRoot, compilerLanguage = 'typescript', readFile) {
980
+ const field = {
981
+ id,
982
+ kind: 'formField',
983
+ field: raw.field,
984
+ decorators: raw.decorators.map(d => normalizeDecorator(d)),
985
+ sourceSpan: raw.sourceSpan,
986
+ };
987
+ // Logic escape tier 1: @expr() for visibility
988
+ const exprDec = raw.decorators.find(d => d.name === 'expr');
989
+ if (exprDec?.args) {
990
+ field.visibleWhen = {
991
+ source: 'escape-expr',
992
+ escape: { tier: 'expr', raw: exprDec.args },
993
+ };
994
+ }
995
+ // Logic escape tier 2: @fn() for validation
996
+ const fnDec = raw.decorators.find(d => d.name === 'fn');
997
+ if (fnDec?.args) {
998
+ const parsed = parseDecoratorArgs(fnDec.args);
999
+ if (typeof parsed['path'] === 'string') {
1000
+ field.validateFn = normalizeFnEscape(parsed['path'], raw.sourceSpan?.file, projectRoot, compilerLanguage, readFile, errors, id, 'field validate @fn');
1001
+ }
1002
+ }
1003
+ // UI escape: @custom() for full React field component
1004
+ const customDec = raw.decorators.find(d => d.name === 'custom');
1005
+ if (customDec?.args) {
1006
+ const parsed = parseDecoratorArgs(customDec.args);
1007
+ if (typeof parsed['path'] === 'string') {
1008
+ field.customField = normalizeEscapePath(parsed['path'], raw.sourceSpan?.file, projectRoot, errors, id, 'field @custom');
1009
+ }
1010
+ }
1011
+ if (raw.rules?.visibleIf) {
1012
+ field.visibleWhen = tryParseRuleValue(raw.rules.visibleIf, `${id}.rules.visibleIf`, errors, raw.sourceSpan?.file, projectRoot, compilerLanguage, readFile, 'field visibleIf rule');
1013
+ }
1014
+ if (raw.rules?.enabledIf) {
1015
+ field.enabledWhen = tryParseRuleValue(raw.rules.enabledIf, `${id}.rules.enabledIf`, errors, raw.sourceSpan?.file, projectRoot, compilerLanguage, readFile, 'field enabledIf rule');
1016
+ }
1017
+ return field;
1018
+ }
1019
+ // ─── Rule Value Parser ───────────────────────────────────────────
1020
+ // Detects the tier of a rule string:
1021
+ // @expr(...) → escape-expr (Tier 1)
1022
+ // @fn(...) → escape-fn (Tier 2)
1023
+ // plain expression → builtin (Tier 0)
1024
+ function tryParseRuleValue(input, nodeId, errors, sourceFile, projectRoot, compilerLanguage = 'typescript', readFile, label = 'rule') {
1025
+ const trimmed = input.trim();
1026
+ // Tier 1: @expr("TS expression")
1027
+ const exprMatch = trimmed.match(/^@expr\((.+)\)$/);
1028
+ if (exprMatch) {
1029
+ return {
1030
+ source: 'escape-expr',
1031
+ escape: { tier: 'expr', raw: exprMatch[1] },
1032
+ };
1033
+ }
1034
+ // Tier 2: @fn("./path/to/file.ts")
1035
+ const fnMatch = trimmed.match(/^@fn\(["'](.+?)["']\)$/);
1036
+ if (fnMatch) {
1037
+ return {
1038
+ source: 'escape-fn',
1039
+ escape: normalizeFnEscape(fnMatch[1], sourceFile, projectRoot, compilerLanguage, readFile, errors, nodeId, label),
1040
+ };
1041
+ }
1042
+ // Tier 0: built-in DSL expression
1043
+ const expr = tryParseExpr(trimmed, nodeId, errors);
1044
+ if (expr) {
1045
+ return { source: 'builtin', expr };
1046
+ }
1047
+ return undefined;
1048
+ }
1049
+ function normalizeRules(raw, nodeId, errors, sourceFile, projectRoot, compilerLanguage = 'typescript', readFile) {
1050
+ const rules = {};
1051
+ if (raw.visibleIf)
1052
+ rules.visibleIf = tryParseRuleValue(raw.visibleIf, nodeId, errors, sourceFile, projectRoot, compilerLanguage, readFile, 'visibleIf rule');
1053
+ if (raw.enabledIf)
1054
+ rules.enabledIf = tryParseRuleValue(raw.enabledIf, nodeId, errors, sourceFile, projectRoot, compilerLanguage, readFile, 'enabledIf rule');
1055
+ if (raw.allowIf)
1056
+ rules.allowIf = tryParseRuleValue(raw.allowIf, nodeId, errors, sourceFile, projectRoot, compilerLanguage, readFile, 'allowIf rule');
1057
+ if (raw.enforce)
1058
+ rules.enforce = tryParseRuleValue(raw.enforce, nodeId, errors, sourceFile, projectRoot, compilerLanguage, readFile, 'enforce rule');
1059
+ return rules;
1060
+ }
1061
+ function normalizeEffect(raw, nodeId, errors) {
1062
+ switch (raw.type) {
1063
+ case 'refresh':
1064
+ return { type: 'refresh', target: typeof raw.value === 'string' ? raw.value : '' };
1065
+ case 'invalidate':
1066
+ return { type: 'invalidate', target: typeof raw.value === 'string' ? raw.value : '' };
1067
+ case 'toast':
1068
+ return { type: 'toast', message: normalizeToastMessage(raw.value, nodeId, errors) };
1069
+ case 'redirect':
1070
+ return { type: 'redirect', target: typeof raw.value === 'string' ? raw.value : '' };
1071
+ case 'openDialog':
1072
+ return { type: 'openDialog', dialog: typeof raw.value === 'string' ? raw.value : '' };
1073
+ case 'emitEvent':
1074
+ return { type: 'emitEvent', event: typeof raw.value === 'string' ? raw.value : '' };
1075
+ default:
1076
+ return { type: 'toast', message: `Unknown effect: ${raw.type}` };
1077
+ }
1078
+ }
1079
+ function normalizeToastMessage(raw, nodeId, errors) {
1080
+ if (typeof raw === 'string') {
1081
+ return raw;
1082
+ }
1083
+ return normalizeToastDescriptor(raw, nodeId, errors);
1084
+ }
1085
+ function normalizeMessageLike(raw, nodeId, errors, label) {
1086
+ if (typeof raw === 'string' || raw === undefined) {
1087
+ return raw ?? '';
1088
+ }
1089
+ return normalizeMessageDescriptor(raw, nodeId, errors, label);
1090
+ }
1091
+ function normalizeMessageDescriptor(raw, nodeId, errors, label) {
1092
+ const key = typeof raw.key === 'string' ? raw.key.trim() : '';
1093
+ const defaultMessage = typeof raw.defaultMessage === 'string' ? raw.defaultMessage : undefined;
1094
+ if (!key && (!defaultMessage || defaultMessage.trim().length === 0)) {
1095
+ errors.push({
1096
+ message: `${label} descriptor must define a non-empty "key" and/or "defaultMessage"`,
1097
+ nodeId,
1098
+ });
1099
+ }
1100
+ const values = raw.values
1101
+ ? Object.fromEntries(Object.entries(raw.values).flatMap(([name, value]) => {
1102
+ if (typeof value === 'object' && value !== null && 'ref' in value) {
1103
+ errors.push({
1104
+ message: `${label} descriptor values may only use scalar literals in the current slice`,
1105
+ nodeId,
1106
+ });
1107
+ return [];
1108
+ }
1109
+ return [[name, value]];
1110
+ }))
1111
+ : undefined;
1112
+ const descriptor = {};
1113
+ if (key) {
1114
+ descriptor.key = key;
1115
+ }
1116
+ if (defaultMessage && defaultMessage.length > 0) {
1117
+ descriptor.defaultMessage = defaultMessage;
1118
+ }
1119
+ if (values && Object.keys(values).length > 0) {
1120
+ descriptor.values = values;
1121
+ }
1122
+ return descriptor;
1123
+ }
1124
+ function normalizeToastDescriptor(raw, nodeId, errors) {
1125
+ const key = typeof raw.key === 'string' ? raw.key.trim() : '';
1126
+ if (!key) {
1127
+ errors.push({
1128
+ message: 'toast descriptor must define a non-empty "key"',
1129
+ nodeId,
1130
+ });
1131
+ }
1132
+ const values = raw.values
1133
+ ? Object.fromEntries(Object.entries(raw.values).map(([name, value]) => [name, normalizeToastValue(name, value, nodeId, errors)]))
1134
+ : undefined;
1135
+ const descriptor = {
1136
+ key,
1137
+ };
1138
+ if (typeof raw.defaultMessage === 'string' && raw.defaultMessage.length > 0) {
1139
+ descriptor.defaultMessage = raw.defaultMessage;
1140
+ }
1141
+ if (values && Object.keys(values).length > 0) {
1142
+ descriptor.values = values;
1143
+ }
1144
+ return descriptor;
1145
+ }
1146
+ function normalizeToastValue(name, raw, nodeId, errors) {
1147
+ if (typeof raw === 'string'
1148
+ || typeof raw === 'number'
1149
+ || typeof raw === 'boolean'
1150
+ || raw === null) {
1151
+ return raw;
1152
+ }
1153
+ const ref = typeof raw.ref === 'string' ? raw.ref.trim() : '';
1154
+ if (!ref) {
1155
+ errors.push({
1156
+ message: `toast.values.${name} must be a scalar or { ref: <path> }`,
1157
+ nodeId,
1158
+ });
1159
+ }
1160
+ return { ref };
1161
+ }
1162
+ // ─── Expression Parse Helper ─────────────────────────────────────
1163
+ function tryParseExpr(input, nodeId, errors) {
1164
+ try {
1165
+ return parseExpr(input);
1166
+ }
1167
+ catch (err) {
1168
+ errors.push({
1169
+ message: `Invalid expression "${input}": ${err instanceof Error ? err.message : String(err)}`,
1170
+ nodeId,
1171
+ });
1172
+ return undefined;
1173
+ }
1174
+ }
1175
+ function normalizeEscapePath(rawPath, sourceFile, projectRoot, errors, nodeId, label) {
1176
+ if (!projectRoot) {
1177
+ return rawPath;
1178
+ }
1179
+ if (!sourceFile) {
1180
+ errors.push({
1181
+ message: `${label} path "${rawPath}" cannot be resolved because the source file is unknown`,
1182
+ nodeId,
1183
+ });
1184
+ return rawPath;
1185
+ }
1186
+ const resolved = resolveProjectPath(dirnameProjectPath(sourceFile), rawPath);
1187
+ const projectRelative = toProjectRelativePath(projectRoot, resolved);
1188
+ if (!projectRelative) {
1189
+ errors.push({
1190
+ message: `${label} path "${rawPath}" resolves outside the project root`,
1191
+ nodeId,
1192
+ });
1193
+ return rawPath;
1194
+ }
1195
+ return projectRelative;
1196
+ }
1197
+ function normalizeFlowLink(input, sourceFile, projectRoot, readFile, errors, nodeId, label) {
1198
+ const trimmed = input.trim();
1199
+ const match = trimmed.match(FLOW_LINK_REGEX);
1200
+ if (!match) {
1201
+ errors.push({
1202
+ message: `${label} must use @flow("./path") syntax`,
1203
+ nodeId,
1204
+ });
1205
+ return undefined;
1206
+ }
1207
+ const rawPath = match[1];
1208
+ const explicitFlowPath = isFlowSourceFile(rawPath);
1209
+ if (!explicitFlowPath && hasExplicitExtension(rawPath)) {
1210
+ errors.push({
1211
+ message: `${label} "@flow(\\"${rawPath}\\")" must use an extensionless path or explicit .flow.loj suffix`,
1212
+ nodeId,
1213
+ });
1214
+ return undefined;
1215
+ }
1216
+ const requestedPath = explicitFlowPath ? rawPath : `${rawPath}.flow.loj`;
1217
+ const resolvedPath = normalizeEscapePath(rawPath === requestedPath ? rawPath : requestedPath, sourceFile, projectRoot, errors, nodeId, label);
1218
+ if (!readFile) {
1219
+ errors.push({
1220
+ message: `${label} "@flow(\\"${rawPath}\\")" cannot be resolved because file loading is unavailable`,
1221
+ nodeId,
1222
+ });
1223
+ return undefined;
1224
+ }
1225
+ if (!hostFileExists(resolvedPath, readFile)) {
1226
+ errors.push({
1227
+ message: `${label} "@flow(\\"${rawPath}\\")" did not resolve; expected ${resolvedPath}`,
1228
+ nodeId,
1229
+ });
1230
+ return undefined;
1231
+ }
1232
+ const compileResult = compileFlowSource(readFile(resolvedPath) ?? '', resolvedPath);
1233
+ for (const error of compileResult.errors) {
1234
+ const location = error.line !== undefined && error.col !== undefined
1235
+ ? `:${error.line}:${error.col}`
1236
+ : '';
1237
+ errors.push({
1238
+ message: `${label} "@flow(\\"${rawPath}\\")" could not be compiled at ${resolvedPath}${location}: ${error.message}`,
1239
+ nodeId,
1240
+ });
1241
+ }
1242
+ if (!compileResult.success || !compileResult.program || !compileResult.manifest) {
1243
+ return undefined;
1244
+ }
1245
+ return {
1246
+ id: `${nodeId}.workflow`,
1247
+ kind: 'flow.link',
1248
+ logicalPath: explicitFlowPath ? undefined : rawPath,
1249
+ resolvedPath,
1250
+ lockIn: explicitFlowPath ? 'explicit' : 'neutral',
1251
+ program: compileResult.program,
1252
+ manifest: compileResult.manifest,
1253
+ sourceSpan: undefined,
1254
+ };
1255
+ }
1256
+ function normalizeRulesLink(input, sourceFile, projectRoot, readFile, errors, nodeId, label) {
1257
+ const trimmed = input.trim();
1258
+ const match = trimmed.match(RULES_LINK_REGEX);
1259
+ if (!match) {
1260
+ errors.push({
1261
+ message: `${label} must use @rules("./path") syntax`,
1262
+ nodeId,
1263
+ });
1264
+ return undefined;
1265
+ }
1266
+ const rawPath = match[1];
1267
+ const explicitRulesPath = isRulesSourceFile(rawPath);
1268
+ if (!explicitRulesPath && hasExplicitExtension(rawPath)) {
1269
+ errors.push({
1270
+ message: `${label} "@rules(\\"${rawPath}\\")" must use an extensionless path or explicit .rules.loj suffix`,
1271
+ nodeId,
1272
+ });
1273
+ return undefined;
1274
+ }
1275
+ const requestedPath = explicitRulesPath ? rawPath : `${rawPath}.rules.loj`;
1276
+ const resolvedPath = normalizeEscapePath(rawPath === requestedPath ? rawPath : requestedPath, sourceFile, projectRoot, errors, nodeId, label);
1277
+ if (!readFile) {
1278
+ errors.push({
1279
+ message: `${label} "@rules(\\"${rawPath}\\")" cannot be resolved because file loading is unavailable`,
1280
+ nodeId,
1281
+ });
1282
+ return undefined;
1283
+ }
1284
+ if (!hostFileExists(resolvedPath, readFile)) {
1285
+ errors.push({
1286
+ message: `${label} "@rules(\\"${rawPath}\\")" did not resolve; expected ${resolvedPath}`,
1287
+ nodeId,
1288
+ });
1289
+ return undefined;
1290
+ }
1291
+ const compileResult = compileRulesSource(readFile(resolvedPath) ?? '', resolvedPath);
1292
+ for (const error of compileResult.errors) {
1293
+ const location = error.line !== undefined && error.col !== undefined
1294
+ ? `:${error.line}:${error.col}`
1295
+ : '';
1296
+ errors.push({
1297
+ message: `${label} "@rules(\\"${rawPath}\\")" could not be compiled at ${resolvedPath}${location}: ${error.message}`,
1298
+ nodeId,
1299
+ });
1300
+ }
1301
+ if (!compileResult.success || !compileResult.program || !compileResult.manifest) {
1302
+ return undefined;
1303
+ }
1304
+ return {
1305
+ id: `${nodeId}.rules`,
1306
+ kind: 'rules.link',
1307
+ logicalPath: explicitRulesPath ? undefined : rawPath,
1308
+ resolvedPath,
1309
+ lockIn: explicitRulesPath ? 'explicit' : 'neutral',
1310
+ program: compileResult.program,
1311
+ manifest: compileResult.manifest,
1312
+ sourceSpan: undefined,
1313
+ };
1314
+ }
1315
+ function normalizeStyleLink(input, sourceFile, projectRoot, readFile, errors, nodeId, label) {
1316
+ const trimmed = input.trim();
1317
+ const match = trimmed.match(STYLE_LINK_REGEX);
1318
+ if (!match) {
1319
+ errors.push({
1320
+ message: `${label} must use @style("./path") syntax`,
1321
+ nodeId,
1322
+ });
1323
+ return undefined;
1324
+ }
1325
+ const rawPath = match[1];
1326
+ const explicitStylePath = isStyleSourceFile(rawPath);
1327
+ if (!explicitStylePath && hasExplicitExtension(rawPath)) {
1328
+ errors.push({
1329
+ message: `${label} "@style(\\"${rawPath}\\")" must use an extensionless path or explicit .style.loj suffix`,
1330
+ nodeId,
1331
+ });
1332
+ return undefined;
1333
+ }
1334
+ const requestedPath = explicitStylePath ? rawPath : `${rawPath}.style.loj`;
1335
+ const resolvedPath = normalizeEscapePath(rawPath === requestedPath ? rawPath : requestedPath, sourceFile, projectRoot, errors, nodeId, label);
1336
+ if (!readFile) {
1337
+ errors.push({
1338
+ message: `${label} "@style(\\"${rawPath}\\")" cannot be resolved because file loading is unavailable`,
1339
+ nodeId,
1340
+ });
1341
+ return undefined;
1342
+ }
1343
+ if (!hostFileExists(resolvedPath, readFile)) {
1344
+ errors.push({
1345
+ message: `${label} "@style(\\"${rawPath}\\")" did not resolve; expected ${resolvedPath}`,
1346
+ nodeId,
1347
+ });
1348
+ return undefined;
1349
+ }
1350
+ const compileResult = compileStyleSource(readFile(resolvedPath) ?? '', resolvedPath);
1351
+ for (const error of compileResult.errors) {
1352
+ const location = error.line !== undefined && error.col !== undefined
1353
+ ? `:${error.line}:${error.col}`
1354
+ : '';
1355
+ errors.push({
1356
+ message: `${label} "@style(\\"${rawPath}\\")" could not be compiled at ${resolvedPath}${location}: ${error.message}`,
1357
+ nodeId,
1358
+ });
1359
+ }
1360
+ if (!compileResult.success || !compileResult.program || !compileResult.manifest) {
1361
+ return undefined;
1362
+ }
1363
+ return {
1364
+ id: `${nodeId}.style`,
1365
+ kind: 'style.link',
1366
+ logicalPath: explicitStylePath ? undefined : rawPath,
1367
+ resolvedPath,
1368
+ lockIn: explicitStylePath ? 'explicit' : 'neutral',
1369
+ program: compileResult.program,
1370
+ manifest: compileResult.manifest,
1371
+ sourceSpan: undefined,
1372
+ };
1373
+ }
1374
+ function normalizeAssetLink(input, sourceFile, projectRoot, readFile, errors, nodeId, label) {
1375
+ const trimmed = input.trim();
1376
+ const match = trimmed.match(ASSET_LINK_REGEX);
1377
+ if (!match) {
1378
+ errors.push({
1379
+ message: `${label} must use @asset("./path") syntax`,
1380
+ nodeId,
1381
+ });
1382
+ return undefined;
1383
+ }
1384
+ const rawPath = match[1];
1385
+ const resolvedPath = normalizeEscapePath(rawPath, sourceFile, projectRoot, errors, nodeId, label);
1386
+ if (!readFile) {
1387
+ errors.push({
1388
+ message: `${label} "@asset(\\"${rawPath}\\")" cannot be resolved because file loading is unavailable`,
1389
+ nodeId,
1390
+ });
1391
+ return undefined;
1392
+ }
1393
+ if (!hostFileExists(resolvedPath, readFile)) {
1394
+ errors.push({
1395
+ message: `${label} "@asset(\\"${rawPath}\\")" did not resolve; expected ${resolvedPath}`,
1396
+ nodeId,
1397
+ });
1398
+ return undefined;
1399
+ }
1400
+ return {
1401
+ id: `${nodeId}.asset`,
1402
+ kind: 'asset.link',
1403
+ logicalPath: rawPath,
1404
+ resolvedPath,
1405
+ lockIn: 'neutral',
1406
+ sourceSpan: undefined,
1407
+ };
1408
+ }
1409
+ function normalizeStyleReference(input, appStyle, nodeId, errors, label) {
1410
+ const trimmed = input.trim();
1411
+ if (!trimmed) {
1412
+ errors.push({
1413
+ message: `${label} must not be empty`,
1414
+ nodeId,
1415
+ });
1416
+ return undefined;
1417
+ }
1418
+ if (!appStyle) {
1419
+ errors.push({
1420
+ message: `${label} requires app.style to link a .style.loj source`,
1421
+ nodeId,
1422
+ });
1423
+ return undefined;
1424
+ }
1425
+ if (!appStyle.program.styles.some((style) => style.name === trimmed)) {
1426
+ errors.push({
1427
+ message: `${label} "${trimmed}" was not found in ${appStyle.resolvedPath}`,
1428
+ nodeId,
1429
+ });
1430
+ return undefined;
1431
+ }
1432
+ return trimmed;
1433
+ }
1434
+ function normalizeCanonicalPath(input, nodeId, errors) {
1435
+ if (input === undefined) {
1436
+ return undefined;
1437
+ }
1438
+ const trimmed = input.trim();
1439
+ if (!trimmed) {
1440
+ errors.push({
1441
+ message: 'page seo canonicalPath must not be empty',
1442
+ nodeId,
1443
+ });
1444
+ return undefined;
1445
+ }
1446
+ if (!trimmed.startsWith('/')) {
1447
+ errors.push({
1448
+ message: 'page seo canonicalPath must start with "/"',
1449
+ nodeId,
1450
+ });
1451
+ return undefined;
1452
+ }
1453
+ return trimmed;
1454
+ }
1455
+ function normalizeFnEscape(rawPath, sourceFile, projectRoot, compilerLanguage, readFile, errors, nodeId, label) {
1456
+ if (hasExplicitExtension(rawPath)) {
1457
+ return {
1458
+ tier: 'fn',
1459
+ path: normalizeEscapePath(rawPath, sourceFile, projectRoot, errors, nodeId, label),
1460
+ lockIn: 'explicit',
1461
+ };
1462
+ }
1463
+ const logicalPath = rawPath;
1464
+ const basePath = normalizeEscapePath(rawPath, sourceFile, projectRoot, errors, nodeId, label);
1465
+ if (!projectRoot || !sourceFile) {
1466
+ return {
1467
+ tier: 'fn',
1468
+ logicalPath,
1469
+ path: `${basePath}${preferredFnExtensions(compilerLanguage)[0] ?? ''}`,
1470
+ lockIn: 'neutral',
1471
+ };
1472
+ }
1473
+ const candidates = preferredFnExtensions(compilerLanguage).map((extension) => `${basePath}${extension}`);
1474
+ const matches = readFile
1475
+ ? candidates.filter((candidate) => hostFileExists(candidate, readFile))
1476
+ : candidates;
1477
+ if (readFile && matches.length === 0) {
1478
+ errors.push({
1479
+ message: `@fn("${rawPath}") did not resolve for react/${compilerLanguage}; expected ${candidates.join(' or ')}`,
1480
+ nodeId,
1481
+ });
1482
+ }
1483
+ if (readFile && matches.length > 1) {
1484
+ errors.push({
1485
+ message: `@fn("${rawPath}") is ambiguous; found ${matches.join(' and ')}`,
1486
+ nodeId,
1487
+ });
1488
+ }
1489
+ return {
1490
+ tier: 'fn',
1491
+ logicalPath,
1492
+ path: matches[0] ?? candidates[0] ?? basePath,
1493
+ lockIn: 'neutral',
1494
+ };
1495
+ }
1496
+ function preferredFnExtensions(language) {
1497
+ return language === 'typescript' ? ['.ts', '.js'] : ['.js'];
1498
+ }
1499
+ function hasExplicitExtension(rawPath) {
1500
+ const fileName = rawPath.split('/').pop() ?? rawPath;
1501
+ return /\.[A-Za-z0-9]+$/.test(fileName);
1502
+ }
1503
+ function hostFileExists(fileName, readFile) {
1504
+ try {
1505
+ return readFile(fileName) !== undefined;
1506
+ }
1507
+ catch {
1508
+ return false;
1509
+ }
1510
+ }
1511
+ // ─── Page ────────────────────────────────────────────────────────
1512
+ function normalizePage(raw, projectRoot, appStyle, readFile) {
1513
+ const id = `page.${raw.name}`;
1514
+ const errors = [];
1515
+ return {
1516
+ value: {
1517
+ id,
1518
+ kind: 'page',
1519
+ name: raw.name,
1520
+ title: normalizeMessageLike(raw.title, `${id}.title`, errors, 'page title'),
1521
+ style: raw.style ? normalizeStyleReference(raw.style, appStyle, `${id}.style`, errors, 'page style') : undefined,
1522
+ seo: raw.seo
1523
+ ? {
1524
+ description: raw.seo.description
1525
+ ? normalizeMessageLike(raw.seo.description, `${id}.seo.description`, errors, 'page seo description')
1526
+ : undefined,
1527
+ canonicalPath: normalizeCanonicalPath(raw.seo.canonicalPath, `${id}.seo.canonicalPath`, errors),
1528
+ image: raw.seo.image
1529
+ ? normalizeAssetLink(raw.seo.image, raw.sourceSpan?.file, projectRoot, readFile, errors, `${id}.seo.image`, 'page seo image')
1530
+ : undefined,
1531
+ noIndex: raw.seo.noIndex === true,
1532
+ }
1533
+ : undefined,
1534
+ pageType: raw.type === 'dashboard' ? 'dashboard' : 'custom',
1535
+ path: raw.path,
1536
+ layout: raw.layout,
1537
+ actions: (raw.actions || []).map((action, index) => normalizePageAction(action, `${id}.action.${index}`)),
1538
+ blocks: raw.blocks.map((b, i) => normalizeDashboardBlock(b, `${id}.block.${i}`, errors, projectRoot, appStyle)),
1539
+ sourceSpan: raw.sourceSpan,
1540
+ },
1541
+ errors,
1542
+ };
1543
+ }
1544
+ function normalizeDashboardBlock(raw, id, errors, projectRoot, appStyle) {
1545
+ const block = {
1546
+ id,
1547
+ kind: 'dashboardBlock',
1548
+ blockType: raw.type || 'custom',
1549
+ title: normalizeMessageLike(raw.title, `${id}.title`, errors, 'page block title'),
1550
+ style: raw.style ? normalizeStyleReference(raw.style, appStyle, `${id}.style`, errors, 'page block style') : undefined,
1551
+ data: raw.data,
1552
+ queryState: raw.queryState,
1553
+ selectionState: raw.selectionState,
1554
+ dateNavigation: raw.dateNavigation
1555
+ ? {
1556
+ field: raw.dateNavigation.field,
1557
+ prevLabel: raw.dateNavigation.prevLabel
1558
+ ? normalizeMessageLike(raw.dateNavigation.prevLabel, `${id}.dateNavigation.prevLabel`, errors, 'dateNavigation prevLabel')
1559
+ : undefined,
1560
+ nextLabel: raw.dateNavigation.nextLabel
1561
+ ? normalizeMessageLike(raw.dateNavigation.nextLabel, `${id}.dateNavigation.nextLabel`, errors, 'dateNavigation nextLabel')
1562
+ : undefined,
1563
+ sourceSpan: raw.dateNavigation.sourceSpan,
1564
+ }
1565
+ : undefined,
1566
+ rowActions: (raw.rowActions || []).map((action, index) => normalizeDashboardRowAction(action, `${id}.rowAction.${index}`)),
1567
+ sourceSpan: raw.sourceSpan,
1568
+ };
1569
+ // Escape hatch tier 3: custom block
1570
+ if (raw.custom) {
1571
+ block.customBlock = normalizeEscapePath(raw.custom, raw.sourceSpan?.file, projectRoot, errors, id, 'dashboard block @custom');
1572
+ block.blockType = 'custom';
1573
+ }
1574
+ return block;
1575
+ }
1576
+ function normalizePageAction(raw, id) {
1577
+ const resourceLabel = raw.create.resource === ''
1578
+ ? 'Resource'
1579
+ : raw.create.resource.charAt(0).toUpperCase() + raw.create.resource.slice(1);
1580
+ return {
1581
+ id,
1582
+ kind: 'pageAction',
1583
+ action: 'create',
1584
+ resource: raw.create.resource,
1585
+ label: normalizeMessageLike(raw.create.label ?? `Create ${resourceLabel}`, `${id}.label`, [], 'page action label'),
1586
+ seed: Object.fromEntries(Object.entries(raw.create.seed || {}).flatMap(([fieldName, value]) => {
1587
+ const normalized = normalizePageActionSeedValue(value);
1588
+ return normalized ? [[fieldName, normalized]] : [];
1589
+ })),
1590
+ sourceSpan: raw.sourceSpan,
1591
+ };
1592
+ }
1593
+ function normalizePageActionSeedValue(raw) {
1594
+ if (typeof raw === 'string' || typeof raw === 'number' || typeof raw === 'boolean') {
1595
+ return {
1596
+ kind: 'literal',
1597
+ value: raw,
1598
+ };
1599
+ }
1600
+ if (raw.input) {
1601
+ const [queryState, ...fieldParts] = raw.input.split('.');
1602
+ return {
1603
+ kind: 'inputField',
1604
+ queryState,
1605
+ field: fieldParts.join('.'),
1606
+ sourceSpan: raw.sourceSpan,
1607
+ };
1608
+ }
1609
+ if (raw.selection) {
1610
+ const [selectionState, ...fieldParts] = raw.selection.split('.');
1611
+ return {
1612
+ kind: 'selectionField',
1613
+ selectionState,
1614
+ field: fieldParts.join('.'),
1615
+ sourceSpan: raw.sourceSpan,
1616
+ };
1617
+ }
1618
+ return null;
1619
+ }
1620
+ function normalizeDashboardRowAction(raw, id) {
1621
+ const resourceLabel = raw.create.resource === ''
1622
+ ? 'Resource'
1623
+ : raw.create.resource.charAt(0).toUpperCase() + raw.create.resource.slice(1);
1624
+ return {
1625
+ id,
1626
+ kind: 'dashboardRowAction',
1627
+ action: 'create',
1628
+ resource: raw.create.resource,
1629
+ label: normalizeMessageLike(raw.create.label ?? `Create ${resourceLabel}`, `${id}.label`, [], 'row action label'),
1630
+ seed: Object.fromEntries(Object.entries(raw.create.seed || {}).flatMap(([fieldName, value]) => {
1631
+ const normalized = normalizeDashboardRowSeedValue(value);
1632
+ return normalized ? [[fieldName, normalized]] : [];
1633
+ })),
1634
+ sourceSpan: raw.sourceSpan,
1635
+ };
1636
+ }
1637
+ function normalizeDashboardRowSeedValue(raw) {
1638
+ if (typeof raw === 'string' || typeof raw === 'number' || typeof raw === 'boolean') {
1639
+ return {
1640
+ kind: 'literal',
1641
+ value: raw,
1642
+ };
1643
+ }
1644
+ if (raw.row) {
1645
+ return {
1646
+ kind: 'rowField',
1647
+ field: raw.row,
1648
+ sourceSpan: raw.sourceSpan,
1649
+ };
1650
+ }
1651
+ if (raw.input) {
1652
+ return {
1653
+ kind: 'inputField',
1654
+ field: raw.input,
1655
+ sourceSpan: raw.sourceSpan,
1656
+ };
1657
+ }
1658
+ return null;
1659
+ }
1660
+ // ─── Escape Hatch Metering ───────────────────────────────────────
1661
+ // Counts all escape hatch usage across the IR and flags overBudget.
1662
+ const ESCAPE_BUDGET_PERCENT = 20; // healthy threshold
1663
+ function computeEscapeStats(ir) {
1664
+ let totalNodes = 0;
1665
+ let exprCount = 0;
1666
+ let fnCount = 0;
1667
+ let customCount = 0;
1668
+ // Count model fields
1669
+ for (const model of ir.models) {
1670
+ totalNodes += model.fields.length;
1671
+ }
1672
+ // Count resource nodes
1673
+ for (const resource of ir.resources) {
1674
+ for (const view of [resource.views.list, resource.views.edit, resource.views.create, resource.views.read]) {
1675
+ if (!view)
1676
+ continue;
1677
+ if ('columns' in view) {
1678
+ for (const col of view.columns) {
1679
+ totalNodes++;
1680
+ if (col.dynamicLabel?.source === 'escape-expr')
1681
+ exprCount++;
1682
+ if (col.displayFn)
1683
+ fnCount++;
1684
+ if (col.customRenderer)
1685
+ customCount++;
1686
+ }
1687
+ }
1688
+ if ('fields' in view && view.kind !== 'view.read') {
1689
+ for (const field of view.fields) {
1690
+ totalNodes++;
1691
+ if (field.visibleWhen?.source === 'escape-expr')
1692
+ exprCount++;
1693
+ if (field.visibleWhen?.source === 'escape-fn')
1694
+ fnCount++;
1695
+ if (field.enabledWhen?.source === 'escape-expr')
1696
+ exprCount++;
1697
+ if (field.enabledWhen?.source === 'escape-fn')
1698
+ fnCount++;
1699
+ if (field.validateFn)
1700
+ fnCount++;
1701
+ if (field.customField)
1702
+ customCount++;
1703
+ }
1704
+ if (view.kind === 'view.create') {
1705
+ for (const include of view.includes) {
1706
+ totalNodes++;
1707
+ for (const field of include.fields) {
1708
+ totalNodes++;
1709
+ if (field.visibleWhen?.source === 'escape-expr')
1710
+ exprCount++;
1711
+ if (field.visibleWhen?.source === 'escape-fn')
1712
+ fnCount++;
1713
+ if (field.enabledWhen?.source === 'escape-expr')
1714
+ exprCount++;
1715
+ if (field.enabledWhen?.source === 'escape-fn')
1716
+ fnCount++;
1717
+ if (field.validateFn)
1718
+ fnCount++;
1719
+ if (field.customField)
1720
+ customCount++;
1721
+ }
1722
+ }
1723
+ }
1724
+ }
1725
+ if (view.kind === 'view.read') {
1726
+ for (const field of view.fields) {
1727
+ totalNodes++;
1728
+ if (field.dynamicLabel?.source === 'escape-expr')
1729
+ exprCount++;
1730
+ if (field.displayFn)
1731
+ fnCount++;
1732
+ if (field.customRenderer)
1733
+ customCount++;
1734
+ }
1735
+ totalNodes += view.related.length;
1736
+ }
1737
+ // Count rule escape hatches
1738
+ if ('rules' in view && view.rules) {
1739
+ for (const rule of [view.rules.visibleIf, view.rules.enabledIf, view.rules.allowIf, view.rules.enforce]) {
1740
+ if (!rule)
1741
+ continue;
1742
+ totalNodes++;
1743
+ if (rule.source === 'escape-expr')
1744
+ exprCount++;
1745
+ if (rule.source === 'escape-fn')
1746
+ fnCount++;
1747
+ }
1748
+ }
1749
+ }
1750
+ }
1751
+ // Count page blocks
1752
+ for (const page of ir.pages) {
1753
+ for (const block of page.blocks) {
1754
+ totalNodes++;
1755
+ if (block.customBlock)
1756
+ customCount++;
1757
+ }
1758
+ }
1759
+ totalNodes = Math.max(totalNodes, 1); // avoid div by zero
1760
+ const escapeTotal = exprCount + fnCount + customCount;
1761
+ const escapePercent = Math.round((escapeTotal / totalNodes) * 100);
1762
+ return {
1763
+ totalNodes,
1764
+ exprCount,
1765
+ fnCount,
1766
+ customCount,
1767
+ escapePercent,
1768
+ overBudget: escapePercent > ESCAPE_BUDGET_PERCENT,
1769
+ };
1770
+ }
1771
+ //# sourceMappingURL=normalize.js.map