@cynodia/axiom-core 0.3.1-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +28 -0
- package/dist/context.d.ts +5 -0
- package/dist/context.js +30 -0
- package/dist/derive-edges.d.ts +18 -0
- package/dist/derive-edges.js +282 -0
- package/dist/diagnostics.d.ts +39 -0
- package/dist/diagnostics.js +25 -0
- package/dist/expressions.d.ts +83 -0
- package/dist/expressions.js +65 -0
- package/dist/graph.d.ts +55 -0
- package/dist/graph.js +176 -0
- package/dist/ids.d.ts +22 -0
- package/dist/ids.js +22 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +15 -0
- package/dist/infer.d.ts +41 -0
- package/dist/infer.js +186 -0
- package/dist/ir.d.ts +50 -0
- package/dist/ir.js +1 -0
- package/dist/location.d.ts +57 -0
- package/dist/location.js +77 -0
- package/dist/nodes.d.ts +164 -0
- package/dist/nodes.js +16 -0
- package/dist/type-ref.d.ts +35 -0
- package/dist/type-ref.js +19 -0
- package/dist/types.d.ts +22 -0
- package/dist/types.js +1 -0
- package/dist/ui.d.ts +101 -0
- package/dist/ui.js +29 -0
- package/dist/validate-location.d.ts +16 -0
- package/dist/validate-location.js +78 -0
- package/dist/validate.d.ts +9 -0
- package/dist/validate.js +574 -0
- package/package.json +37 -0
package/dist/validate.js
ADDED
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
import { VALIDATION_CODES } from './diagnostics.js';
|
|
2
|
+
import { EDGE_KINDS } from './nodes.js';
|
|
3
|
+
import { isUINode, uiChildIds } from './ui.js';
|
|
4
|
+
import { semanticContextFromGraph } from './context.js';
|
|
5
|
+
import { inferExpressionType, inferLocationType, isObviouslyIncompatible } from './infer.js';
|
|
6
|
+
import { locationExpressions } from './location.js';
|
|
7
|
+
import { validateLocation } from './validate-location.js';
|
|
8
|
+
/**
|
|
9
|
+
* Verifies referential integrity of an application graph. An invalid graph must never
|
|
10
|
+
* be executed, so every reference — nodes, fields, edges, expressions, UI children —
|
|
11
|
+
* is resolved here.
|
|
12
|
+
*/
|
|
13
|
+
export function validateGraph(graph) {
|
|
14
|
+
const nodes = new Map();
|
|
15
|
+
const fields = new Map();
|
|
16
|
+
const errors = [];
|
|
17
|
+
const warnings = [];
|
|
18
|
+
const context = {
|
|
19
|
+
graph,
|
|
20
|
+
semantics: semanticContextFromGraph(graph),
|
|
21
|
+
nodes,
|
|
22
|
+
fields,
|
|
23
|
+
scopes: new Set(),
|
|
24
|
+
errors,
|
|
25
|
+
warnings,
|
|
26
|
+
};
|
|
27
|
+
for (const node of graph.listNodes()) {
|
|
28
|
+
if (nodes.has(node.id)) {
|
|
29
|
+
errors.push({ code: VALIDATION_CODES.duplicateNodeId, message: `Duplicate node id ${node.id}`, nodeId: node.id });
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
nodes.set(node.id, node);
|
|
33
|
+
}
|
|
34
|
+
// Only these ids can be resolved by a `ref` expression at runtime.
|
|
35
|
+
for (const node of nodes.values()) {
|
|
36
|
+
if (node.kind === 'state' || node.kind === 'entity' || node.kind === 'repeat') {
|
|
37
|
+
context.scopes.add(node.id);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (node.kind === 'route') {
|
|
41
|
+
for (const parameter of node.parameters ?? []) {
|
|
42
|
+
context.scopes.add(parameter.id);
|
|
43
|
+
}
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (node.kind === 'action') {
|
|
47
|
+
for (const parameter of node.parameters ?? []) {
|
|
48
|
+
context.scopes.add(parameter.id);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
for (const node of nodes.values()) {
|
|
53
|
+
if (node.kind !== 'entity') {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
for (const field of node.fields) {
|
|
57
|
+
const owner = fields.get(field.id);
|
|
58
|
+
if (owner) {
|
|
59
|
+
errors.push({
|
|
60
|
+
code: VALIDATION_CODES.duplicateFieldId,
|
|
61
|
+
message: `Field id ${field.id} is declared by both ${owner} and ${node.id}`,
|
|
62
|
+
nodeId: node.id,
|
|
63
|
+
fieldId: field.id,
|
|
64
|
+
});
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
fields.set(field.id, node.id);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
for (const node of nodes.values()) {
|
|
71
|
+
validateNode(node, context);
|
|
72
|
+
}
|
|
73
|
+
validateEdges(context);
|
|
74
|
+
validateRoutes(context);
|
|
75
|
+
reportUnreachableUiNodes(context);
|
|
76
|
+
return { valid: errors.length === 0, errors, warnings };
|
|
77
|
+
}
|
|
78
|
+
function validateNode(node, context) {
|
|
79
|
+
if (isUINode(node)) {
|
|
80
|
+
validateUiNode(node, context);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
switch (node.kind) {
|
|
84
|
+
case 'entity':
|
|
85
|
+
validateEntity(node, context);
|
|
86
|
+
return;
|
|
87
|
+
case 'state':
|
|
88
|
+
validateState(node, context);
|
|
89
|
+
return;
|
|
90
|
+
case 'action':
|
|
91
|
+
validateAction(node, context);
|
|
92
|
+
return;
|
|
93
|
+
case 'constraint':
|
|
94
|
+
validateConstraint(node, context);
|
|
95
|
+
return;
|
|
96
|
+
case 'route':
|
|
97
|
+
validateRoute(node, context);
|
|
98
|
+
return;
|
|
99
|
+
default:
|
|
100
|
+
context.errors.push({
|
|
101
|
+
code: VALIDATION_CODES.danglingNodeRef,
|
|
102
|
+
message: `Unknown node kind ${node.kind}`,
|
|
103
|
+
nodeId: node.id,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function validateEntity(entity, context) {
|
|
108
|
+
for (const field of entity.fields) {
|
|
109
|
+
validateTypeRef(field.valueType, entity.id, context, field.id);
|
|
110
|
+
}
|
|
111
|
+
if (entity.identityFieldId && !entity.fields.some((field) => field.id === entity.identityFieldId)) {
|
|
112
|
+
context.errors.push({
|
|
113
|
+
code: VALIDATION_CODES.danglingFieldRef,
|
|
114
|
+
message: `Identity field ${entity.identityFieldId} is not declared by entity ${entity.id}`,
|
|
115
|
+
nodeId: entity.id,
|
|
116
|
+
fieldId: entity.identityFieldId,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function validateState(state, context) {
|
|
121
|
+
validateTypeRef(state.valueType, state.id, context);
|
|
122
|
+
if (state.derivation) {
|
|
123
|
+
validateExpression(state.derivation, state.id, context, new Set());
|
|
124
|
+
}
|
|
125
|
+
if (state.persistence?.kind === 'remote' && !context.nodes.has(state.persistence.sourceId)) {
|
|
126
|
+
context.errors.push({
|
|
127
|
+
code: VALIDATION_CODES.danglingNodeRef,
|
|
128
|
+
message: `State ${state.id} persists to unknown source ${state.persistence.sourceId}`,
|
|
129
|
+
nodeId: state.id,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function validateAction(action, context) {
|
|
134
|
+
const local = new Set((action.parameters ?? []).map((parameter) => parameter.id));
|
|
135
|
+
for (const parameter of action.parameters ?? []) {
|
|
136
|
+
if (parameter.valueType) {
|
|
137
|
+
validateTypeRef(parameter.valueType, action.id, context);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
for (const precondition of action.preconditions ?? []) {
|
|
141
|
+
validateExpression(precondition, action.id, context, local);
|
|
142
|
+
}
|
|
143
|
+
for (const postcondition of action.postconditions ?? []) {
|
|
144
|
+
validateExpression(postcondition, action.id, context, local);
|
|
145
|
+
}
|
|
146
|
+
for (const operation of action.operations ?? []) {
|
|
147
|
+
validateOperation(operation, action, context, local);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
function validateOperation(operation, action, context, local) {
|
|
151
|
+
switch (operation.kind) {
|
|
152
|
+
case 'set': {
|
|
153
|
+
checkLocation(operation.target, action.id, context, local, true);
|
|
154
|
+
validateExpression(operation.value, action.id, context, local);
|
|
155
|
+
checkAssignment(operation.target, operation.value, action.id, context);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
case 'insert': {
|
|
159
|
+
checkLocation(operation.target, action.id, context, local, true);
|
|
160
|
+
validateExpression(operation.value, action.id, context, local);
|
|
161
|
+
const target = resolveKnownType(inferLocationType(operation.target, context.semantics));
|
|
162
|
+
if (target && target.kind !== 'collection') {
|
|
163
|
+
context.errors.push({
|
|
164
|
+
code: VALIDATION_CODES.selectorOnNonCollection,
|
|
165
|
+
message: `Action ${action.id} inserts into a ${target.kind} location, which is not a collection`,
|
|
166
|
+
nodeId: action.id,
|
|
167
|
+
});
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (target?.kind === 'collection') {
|
|
171
|
+
reportIncompatible(target.itemType, inferExpressionType(operation.value, context.semantics), action.id, context);
|
|
172
|
+
}
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
case 'remove':
|
|
176
|
+
checkLocation(operation.target, action.id, context, local, true);
|
|
177
|
+
return;
|
|
178
|
+
case 'invoke': {
|
|
179
|
+
requireKind(operation.actionId, 'action', action.id, context, VALIDATION_CODES.invalidActionRef);
|
|
180
|
+
const target = context.nodes.get(operation.actionId);
|
|
181
|
+
for (const [parameterId, argument] of Object.entries(operation.arguments ?? {})) {
|
|
182
|
+
validateExpression(argument, action.id, context, local);
|
|
183
|
+
if (target && target.kind === 'action' && !(target.parameters ?? []).some((p) => p.id === parameterId)) {
|
|
184
|
+
context.errors.push({
|
|
185
|
+
code: VALIDATION_CODES.danglingNodeRef,
|
|
186
|
+
message: `Action ${action.id} passes unknown parameter ${parameterId} to ${operation.actionId}`,
|
|
187
|
+
nodeId: action.id,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
case 'navigate':
|
|
194
|
+
if (operation.routeId) {
|
|
195
|
+
requireKind(operation.routeId, 'route', action.id, context, VALIDATION_CODES.danglingNodeRef);
|
|
196
|
+
}
|
|
197
|
+
if (!operation.routeId && !operation.path) {
|
|
198
|
+
context.errors.push({
|
|
199
|
+
code: VALIDATION_CODES.danglingNodeRef,
|
|
200
|
+
message: `Navigate operation in ${action.id} declares neither a route nor a path`,
|
|
201
|
+
nodeId: action.id,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
for (const argument of Object.values(operation.parameters ?? {})) {
|
|
205
|
+
validateExpression(argument, action.id, context, local);
|
|
206
|
+
}
|
|
207
|
+
return;
|
|
208
|
+
case 'native':
|
|
209
|
+
for (const input of Object.values(operation.inputs ?? {})) {
|
|
210
|
+
validateExpression(input, action.id, context, local);
|
|
211
|
+
}
|
|
212
|
+
if (operation.resultTarget) {
|
|
213
|
+
checkLocation(operation.resultTarget, action.id, context, local, true);
|
|
214
|
+
}
|
|
215
|
+
for (const effect of operation.declaredEffects ?? []) {
|
|
216
|
+
if (effect.kind !== 'external') {
|
|
217
|
+
requireKind(effect.stateId, 'state', action.id, context, VALIDATION_CODES.invalidStateRef);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return;
|
|
221
|
+
default:
|
|
222
|
+
context.errors.push({
|
|
223
|
+
code: VALIDATION_CODES.danglingNodeRef,
|
|
224
|
+
message: `Unknown operation kind in action ${action.id}`,
|
|
225
|
+
nodeId: action.id,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
function resolveKnownType(type) {
|
|
230
|
+
return type?.kind === 'optional' ? resolveKnownType(type.valueType) : type;
|
|
231
|
+
}
|
|
232
|
+
/** Validates a location structurally and validates the expressions inside it. */
|
|
233
|
+
function checkLocation(location, ownerId, context, local, requireWritable) {
|
|
234
|
+
context.errors.push(...validateLocation(location, context.semantics, { ownerId, requireWritable }));
|
|
235
|
+
for (const expression of locationExpressions(location)) {
|
|
236
|
+
validateExpression(expression, ownerId, context, local);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
function checkAssignment(target, value, ownerId, context) {
|
|
240
|
+
reportIncompatible(inferLocationType(target, context.semantics), inferExpressionType(value, context.semantics), ownerId, context);
|
|
241
|
+
}
|
|
242
|
+
function reportIncompatible(target, value, ownerId, context) {
|
|
243
|
+
if (isObviouslyIncompatible(target, value)) {
|
|
244
|
+
context.errors.push({
|
|
245
|
+
code: VALIDATION_CODES.assignmentTypeMismatch,
|
|
246
|
+
message: `${ownerId} assigns a ${describeType(value)} to a ${describeType(target)} location`,
|
|
247
|
+
nodeId: ownerId,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
function describeType(type) {
|
|
252
|
+
if (!type) {
|
|
253
|
+
return 'value of unknown type';
|
|
254
|
+
}
|
|
255
|
+
switch (type.kind) {
|
|
256
|
+
case 'primitive':
|
|
257
|
+
return type.primitive;
|
|
258
|
+
case 'entity':
|
|
259
|
+
return `entity ${type.entityId}`;
|
|
260
|
+
case 'collection':
|
|
261
|
+
return `collection of ${describeType(type.itemType)}`;
|
|
262
|
+
case 'optional':
|
|
263
|
+
return `optional ${describeType(type.valueType)}`;
|
|
264
|
+
case 'enum':
|
|
265
|
+
return `enum(${type.values.join('|')})`;
|
|
266
|
+
default:
|
|
267
|
+
return 'value of unknown type';
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
function validateConstraint(constraint, context) {
|
|
271
|
+
const local = new Set();
|
|
272
|
+
if (constraint.entityId) {
|
|
273
|
+
requireKind(constraint.entityId, 'entity', constraint.id, context, VALIDATION_CODES.danglingNodeRef);
|
|
274
|
+
local.add(constraint.entityId);
|
|
275
|
+
}
|
|
276
|
+
validateExpression(constraint.expression, constraint.id, context, local);
|
|
277
|
+
}
|
|
278
|
+
function validateRoute(route, context) {
|
|
279
|
+
const view = context.nodes.get(route.viewId);
|
|
280
|
+
if (!view || view.kind !== 'view') {
|
|
281
|
+
context.errors.push({
|
|
282
|
+
code: VALIDATION_CODES.invalidRouteView,
|
|
283
|
+
message: `Route ${route.id} does not resolve to a view node`,
|
|
284
|
+
nodeId: route.id,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
const placeholders = route.path
|
|
288
|
+
.split('/')
|
|
289
|
+
.filter((segment) => segment.startsWith(':'))
|
|
290
|
+
.map((segment) => segment.slice(1));
|
|
291
|
+
for (const placeholder of placeholders) {
|
|
292
|
+
if (!(route.parameters ?? []).some((parameter) => parameter.name === placeholder)) {
|
|
293
|
+
context.errors.push({
|
|
294
|
+
code: VALIDATION_CODES.invalidRouteParameter,
|
|
295
|
+
message: `Route ${route.id} has no parameter declared for ":${placeholder}"`,
|
|
296
|
+
nodeId: route.id,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
for (const parameter of route.parameters ?? []) {
|
|
301
|
+
if (!placeholders.includes(parameter.name)) {
|
|
302
|
+
context.warnings.push({
|
|
303
|
+
code: VALIDATION_CODES.invalidRouteParameter,
|
|
304
|
+
message: `Route ${route.id} declares parameter "${parameter.name}" that its path never uses`,
|
|
305
|
+
nodeId: route.id,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
function validateUiNode(node, context) {
|
|
311
|
+
if (node.visibleWhen) {
|
|
312
|
+
validateExpression(node.visibleWhen, node.id, context, new Set());
|
|
313
|
+
}
|
|
314
|
+
for (const childId of uiChildIds(node)) {
|
|
315
|
+
const child = context.nodes.get(childId);
|
|
316
|
+
if (!child || !isUINode(child)) {
|
|
317
|
+
context.errors.push({
|
|
318
|
+
code: VALIDATION_CODES.invalidUiChild,
|
|
319
|
+
message: `UI node ${node.id} references ${childId}, which is not a UI node`,
|
|
320
|
+
nodeId: node.id,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
switch (node.kind) {
|
|
325
|
+
case 'text':
|
|
326
|
+
if (typeof node.value !== 'string') {
|
|
327
|
+
validateExpression(node.value, node.id, context, new Set());
|
|
328
|
+
}
|
|
329
|
+
return;
|
|
330
|
+
case 'repeat':
|
|
331
|
+
validateExpression(node.source, node.id, context, new Set());
|
|
332
|
+
return;
|
|
333
|
+
case 'field-display':
|
|
334
|
+
validateExpression(node.source, node.id, context, new Set());
|
|
335
|
+
requireField(node.fieldId, node.id, context);
|
|
336
|
+
return;
|
|
337
|
+
case 'form':
|
|
338
|
+
validateExpression(node.target, node.id, context, new Set());
|
|
339
|
+
if (node.submitActionId) {
|
|
340
|
+
requireKind(node.submitActionId, 'action', node.id, context, VALIDATION_CODES.invalidActionRef);
|
|
341
|
+
}
|
|
342
|
+
return;
|
|
343
|
+
case 'input':
|
|
344
|
+
if (!node.binding?.location) {
|
|
345
|
+
context.errors.push({
|
|
346
|
+
code: VALIDATION_CODES.unknownStateRef,
|
|
347
|
+
message: `Input ${node.id} has no bound location`,
|
|
348
|
+
nodeId: node.id,
|
|
349
|
+
});
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
checkLocation(node.binding.location, node.id, context, new Set(), true);
|
|
353
|
+
if (node.options) {
|
|
354
|
+
validateExpression(node.options.source, node.id, context, new Set());
|
|
355
|
+
requireField(node.options.valueFieldId, node.id, context);
|
|
356
|
+
if (node.options.labelFieldId) {
|
|
357
|
+
requireField(node.options.labelFieldId, node.id, context);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return;
|
|
361
|
+
case 'button': {
|
|
362
|
+
requireKind(node.actionId, 'action', node.id, context, VALIDATION_CODES.invalidActionRef);
|
|
363
|
+
if (typeof node.label !== 'string') {
|
|
364
|
+
validateExpression(node.label, node.id, context, new Set());
|
|
365
|
+
}
|
|
366
|
+
const action = context.nodes.get(node.actionId);
|
|
367
|
+
for (const [parameterId, argument] of Object.entries(node.arguments ?? {})) {
|
|
368
|
+
validateExpression(argument, node.id, context, new Set());
|
|
369
|
+
if (action && action.kind === 'action' && !(action.parameters ?? []).some((p) => p.id === parameterId)) {
|
|
370
|
+
context.errors.push({
|
|
371
|
+
code: VALIDATION_CODES.danglingNodeRef,
|
|
372
|
+
message: `Button ${node.id} passes unknown parameter ${parameterId} to action ${node.actionId}`,
|
|
373
|
+
nodeId: node.id,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
case 'conditional':
|
|
380
|
+
validateExpression(node.condition, node.id, context, new Set());
|
|
381
|
+
return;
|
|
382
|
+
default:
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
function validateEdges(context) {
|
|
386
|
+
for (const edge of context.graph.listEdges()) {
|
|
387
|
+
if (!EDGE_KINDS.includes(edge.kind)) {
|
|
388
|
+
context.errors.push({
|
|
389
|
+
code: VALIDATION_CODES.invalidEdgeKind,
|
|
390
|
+
message: `Edge ${edge.id} uses unknown kind "${edge.kind}"`,
|
|
391
|
+
edgeId: edge.id,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
for (const endpoint of [edge.from, edge.to]) {
|
|
395
|
+
if (!context.nodes.has(endpoint)) {
|
|
396
|
+
context.errors.push({
|
|
397
|
+
code: VALIDATION_CODES.danglingNodeRef,
|
|
398
|
+
message: `Edge ${edge.id} references unknown node ${endpoint}`,
|
|
399
|
+
edgeId: edge.id,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
function validateRoutes(context) {
|
|
406
|
+
const seen = new Map();
|
|
407
|
+
for (const node of context.nodes.values()) {
|
|
408
|
+
if (node.kind !== 'route') {
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
const owner = seen.get(node.path);
|
|
412
|
+
if (owner) {
|
|
413
|
+
context.errors.push({
|
|
414
|
+
code: VALIDATION_CODES.duplicateRoutePath,
|
|
415
|
+
message: `Route path ${node.path} is declared by both ${owner} and ${node.id}`,
|
|
416
|
+
nodeId: node.id,
|
|
417
|
+
});
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
seen.set(node.path, node.id);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
function reportUnreachableUiNodes(context) {
|
|
424
|
+
const reachable = new Set();
|
|
425
|
+
const visit = (id) => {
|
|
426
|
+
if (reachable.has(id)) {
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
const node = context.nodes.get(id);
|
|
430
|
+
if (!node || !isUINode(node)) {
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
reachable.add(id);
|
|
434
|
+
for (const childId of uiChildIds(node)) {
|
|
435
|
+
visit(childId);
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
for (const node of context.nodes.values()) {
|
|
439
|
+
if (node.kind === 'route') {
|
|
440
|
+
visit(node.viewId);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
for (const node of context.nodes.values()) {
|
|
444
|
+
if (isUINode(node) && !reachable.has(node.id)) {
|
|
445
|
+
context.warnings.push({
|
|
446
|
+
code: VALIDATION_CODES.unreachableUiNode,
|
|
447
|
+
message: `UI node ${node.id} is not reachable from any route`,
|
|
448
|
+
nodeId: node.id,
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
function validateTypeRef(type, ownerId, context, field, inCollection = false) {
|
|
454
|
+
switch (type.kind) {
|
|
455
|
+
case 'primitive':
|
|
456
|
+
return;
|
|
457
|
+
case 'entity': {
|
|
458
|
+
const target = context.nodes.get(type.entityId);
|
|
459
|
+
if (!target || target.kind !== 'entity') {
|
|
460
|
+
context.errors.push({
|
|
461
|
+
code: VALIDATION_CODES.invalidTypeRef,
|
|
462
|
+
message: `Type reference in ${ownerId} points to ${type.entityId}, which is not an entity`,
|
|
463
|
+
nodeId: ownerId,
|
|
464
|
+
...(field ? { fieldId: field } : {}),
|
|
465
|
+
});
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
if (!target.identityFieldId && inCollection) {
|
|
469
|
+
context.warnings.push({
|
|
470
|
+
code: VALIDATION_CODES.missingIdentityField,
|
|
471
|
+
message: `Entity ${target.id} has no identity field; item-level operations cannot match instances`,
|
|
472
|
+
nodeId: target.id,
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
case 'collection':
|
|
478
|
+
validateTypeRef(type.itemType, ownerId, context, field, true);
|
|
479
|
+
return;
|
|
480
|
+
case 'optional':
|
|
481
|
+
validateTypeRef(type.valueType, ownerId, context, field, inCollection);
|
|
482
|
+
return;
|
|
483
|
+
case 'enum':
|
|
484
|
+
if (type.values.length === 0) {
|
|
485
|
+
context.errors.push({
|
|
486
|
+
code: VALIDATION_CODES.invalidTypeRef,
|
|
487
|
+
message: `Enum type in ${ownerId} declares no values`,
|
|
488
|
+
nodeId: ownerId,
|
|
489
|
+
...(field ? { fieldId: field } : {}),
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
return;
|
|
493
|
+
default:
|
|
494
|
+
context.errors.push({
|
|
495
|
+
code: VALIDATION_CODES.invalidTypeRef,
|
|
496
|
+
message: `Unknown type kind in ${ownerId}`,
|
|
497
|
+
nodeId: ownerId,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
function validateExpression(expression, ownerId, context, local) {
|
|
502
|
+
switch (expression.kind) {
|
|
503
|
+
case 'literal':
|
|
504
|
+
return;
|
|
505
|
+
case 'ref':
|
|
506
|
+
if (!local.has(expression.targetId) && !context.scopes.has(expression.targetId)) {
|
|
507
|
+
context.errors.push({
|
|
508
|
+
code: VALIDATION_CODES.invalidExpressionRef,
|
|
509
|
+
message: `Expression in ${ownerId} references unknown id ${expression.targetId}`,
|
|
510
|
+
nodeId: ownerId,
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
return;
|
|
514
|
+
case 'field':
|
|
515
|
+
requireField(expression.fieldId, ownerId, context);
|
|
516
|
+
validateExpression(expression.source, ownerId, context, local);
|
|
517
|
+
return;
|
|
518
|
+
case 'object':
|
|
519
|
+
if (expression.entityId) {
|
|
520
|
+
requireKind(expression.entityId, 'entity', ownerId, context, VALIDATION_CODES.danglingNodeRef);
|
|
521
|
+
}
|
|
522
|
+
for (const entry of expression.entries) {
|
|
523
|
+
requireField(entry.fieldId, ownerId, context);
|
|
524
|
+
validateExpression(entry.value, ownerId, context, local);
|
|
525
|
+
}
|
|
526
|
+
return;
|
|
527
|
+
case 'binary':
|
|
528
|
+
validateExpression(expression.left, ownerId, context, local);
|
|
529
|
+
validateExpression(expression.right, ownerId, context, local);
|
|
530
|
+
return;
|
|
531
|
+
case 'unary':
|
|
532
|
+
validateExpression(expression.operand, ownerId, context, local);
|
|
533
|
+
return;
|
|
534
|
+
case 'call':
|
|
535
|
+
for (const argument of expression.arguments) {
|
|
536
|
+
validateExpression(argument, ownerId, context, local);
|
|
537
|
+
}
|
|
538
|
+
return;
|
|
539
|
+
case 'filter':
|
|
540
|
+
case 'find': {
|
|
541
|
+
validateExpression(expression.source, ownerId, context, local);
|
|
542
|
+
const scoped = new Set(local);
|
|
543
|
+
scoped.add(expression.scopeId);
|
|
544
|
+
validateExpression(expression.predicate, ownerId, context, scoped);
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
default:
|
|
548
|
+
context.errors.push({
|
|
549
|
+
code: VALIDATION_CODES.invalidExpressionRef,
|
|
550
|
+
message: `Unknown expression kind in ${ownerId}`,
|
|
551
|
+
nodeId: ownerId,
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
function requireField(id, ownerId, context) {
|
|
556
|
+
if (!context.fields.has(id)) {
|
|
557
|
+
context.errors.push({
|
|
558
|
+
code: VALIDATION_CODES.danglingFieldRef,
|
|
559
|
+
message: `${ownerId} references unknown field ${id}`,
|
|
560
|
+
nodeId: ownerId,
|
|
561
|
+
fieldId: id,
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
function requireKind(id, kind, ownerId, context, code) {
|
|
566
|
+
const node = context.nodes.get(id);
|
|
567
|
+
if (!node || node.kind !== kind) {
|
|
568
|
+
context.errors.push({
|
|
569
|
+
code,
|
|
570
|
+
message: `${ownerId} references ${id}, which is not a ${kind} node`,
|
|
571
|
+
nodeId: ownerId,
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cynodia/axiom-core",
|
|
3
|
+
"version": "0.3.1-alpha.1",
|
|
4
|
+
"description": "Application Graph, semantic types, locations and validation for Axiom.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "AskTech AS",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/cynodia/axiom.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/cynodia/axiom",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/cynodia/axiom/issues"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist/**/*.js",
|
|
21
|
+
"dist/**/*.d.ts",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"main": "./dist/index.js",
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"import": "./dist/index.js"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsc -b tsconfig.json tsconfig.test.json",
|
|
35
|
+
"test": "node --test dist-test/**/*.test.js"
|
|
36
|
+
}
|
|
37
|
+
}
|