@mmapp/player-core 0.1.0-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/dist/index.d.mts +1436 -0
- package/dist/index.d.ts +1436 -0
- package/dist/index.js +4828 -0
- package/dist/index.mjs +4762 -0
- package/package.json +35 -0
- package/package.json.backup +35 -0
- package/src/__tests__/actions.test.ts +187 -0
- package/src/__tests__/blueprint-e2e.test.ts +706 -0
- package/src/__tests__/blueprint-test-runner.test.ts +680 -0
- package/src/__tests__/core-functions.test.ts +78 -0
- package/src/__tests__/dsl-compiler.test.ts +1382 -0
- package/src/__tests__/dsl-grammar.test.ts +1682 -0
- package/src/__tests__/events.test.ts +200 -0
- package/src/__tests__/expression.test.ts +296 -0
- package/src/__tests__/failure-policies.test.ts +110 -0
- package/src/__tests__/frontend-context.test.ts +182 -0
- package/src/__tests__/integration.test.ts +256 -0
- package/src/__tests__/security.test.ts +190 -0
- package/src/__tests__/state-machine.test.ts +450 -0
- package/src/__tests__/testing-engine.test.ts +671 -0
- package/src/actions/dispatcher.ts +80 -0
- package/src/actions/index.ts +7 -0
- package/src/actions/types.ts +25 -0
- package/src/dsl/compiler/component-mapper.ts +289 -0
- package/src/dsl/compiler/field-mapper.ts +187 -0
- package/src/dsl/compiler/index.ts +82 -0
- package/src/dsl/compiler/manifest-compiler.ts +76 -0
- package/src/dsl/compiler/symbol-table.ts +214 -0
- package/src/dsl/compiler/utils.ts +48 -0
- package/src/dsl/compiler/view-compiler.ts +286 -0
- package/src/dsl/compiler/workflow-compiler.ts +600 -0
- package/src/dsl/index.ts +66 -0
- package/src/dsl/ir-migration.ts +221 -0
- package/src/dsl/ir-types.ts +416 -0
- package/src/dsl/lexer.ts +579 -0
- package/src/dsl/parser.ts +115 -0
- package/src/dsl/types.ts +256 -0
- package/src/events/event-bus.ts +68 -0
- package/src/events/index.ts +9 -0
- package/src/events/pattern-matcher.ts +61 -0
- package/src/events/types.ts +27 -0
- package/src/expression/evaluator.ts +676 -0
- package/src/expression/functions.ts +214 -0
- package/src/expression/index.ts +13 -0
- package/src/expression/types.ts +64 -0
- package/src/index.ts +61 -0
- package/src/state-machine/index.ts +16 -0
- package/src/state-machine/interpreter.ts +319 -0
- package/src/state-machine/types.ts +89 -0
- package/src/testing/action-trace.ts +209 -0
- package/src/testing/blueprint-test-runner.ts +214 -0
- package/src/testing/graph-walker.ts +249 -0
- package/src/testing/index.ts +69 -0
- package/src/testing/nrt-comparator.ts +199 -0
- package/src/testing/nrt-types.ts +230 -0
- package/src/testing/test-actions.ts +645 -0
- package/src/testing/test-compiler.ts +278 -0
- package/src/testing/test-runner.ts +444 -0
- package/src/testing/types.ts +231 -0
- package/src/validation/definition-validator.ts +812 -0
- package/src/validation/index.ts +13 -0
- package/tsconfig.json +26 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,812 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Definition Validator — static validation of compiled IR definitions.
|
|
3
|
+
*
|
|
4
|
+
* Validates structural integrity, referential consistency, and semantic
|
|
5
|
+
* correctness of IRWorkflowDefinition before it reaches the runtime.
|
|
6
|
+
*
|
|
7
|
+
* Categories:
|
|
8
|
+
* - STRUCTURAL: Missing required fields, type mismatches
|
|
9
|
+
* - REFERENTIAL: Dangling state refs, missing transition targets
|
|
10
|
+
* - SEMANTIC: Unreachable states, dead transitions, missing START
|
|
11
|
+
* - EXPERIENCE: Unknown components, invalid bindings, missing data sources
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
IRWorkflowDefinition,
|
|
16
|
+
IRStateDefinition,
|
|
17
|
+
IRTransitionDefinition,
|
|
18
|
+
IRFieldDefinition,
|
|
19
|
+
IRExperienceNode,
|
|
20
|
+
IRActionDefinition,
|
|
21
|
+
IRDuringAction,
|
|
22
|
+
} from '../dsl/ir-types';
|
|
23
|
+
|
|
24
|
+
// =============================================================================
|
|
25
|
+
// Types
|
|
26
|
+
// =============================================================================
|
|
27
|
+
|
|
28
|
+
export type ValidationSeverity = 'error' | 'warning' | 'info';
|
|
29
|
+
|
|
30
|
+
export type ValidationCategory =
|
|
31
|
+
| 'structural'
|
|
32
|
+
| 'referential'
|
|
33
|
+
| 'semantic'
|
|
34
|
+
| 'experience';
|
|
35
|
+
|
|
36
|
+
export interface ValidationIssue {
|
|
37
|
+
/** Unique code for programmatic matching. */
|
|
38
|
+
code: string;
|
|
39
|
+
/** Human-readable message. */
|
|
40
|
+
message: string;
|
|
41
|
+
/** Severity level. */
|
|
42
|
+
severity: ValidationSeverity;
|
|
43
|
+
/** Validation category. */
|
|
44
|
+
category: ValidationCategory;
|
|
45
|
+
/** Location path (e.g., 'states[0].on_enter[1]'). */
|
|
46
|
+
path?: string;
|
|
47
|
+
/** Suggested fix. */
|
|
48
|
+
suggestion?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ValidationResult {
|
|
52
|
+
/** Whether the definition passed all error-level checks. */
|
|
53
|
+
valid: boolean;
|
|
54
|
+
/** All issues found. */
|
|
55
|
+
issues: ValidationIssue[];
|
|
56
|
+
/** Quick access to error count. */
|
|
57
|
+
errorCount: number;
|
|
58
|
+
/** Quick access to warning count. */
|
|
59
|
+
warningCount: number;
|
|
60
|
+
/** Summary statistics. */
|
|
61
|
+
summary: {
|
|
62
|
+
stateCount: number;
|
|
63
|
+
transitionCount: number;
|
|
64
|
+
fieldCount: number;
|
|
65
|
+
hasExperience: boolean;
|
|
66
|
+
hasExtensions: boolean;
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ValidatorOptions {
|
|
71
|
+
/** Known component IDs (for experience validation). */
|
|
72
|
+
knownComponents?: string[];
|
|
73
|
+
/** Known binding roots (default: $instance, $definition, $instances, $local, $entity, $user, $fn, $action, $item, $index). */
|
|
74
|
+
knownBindingRoots?: string[];
|
|
75
|
+
/** Skip experience tree validation. */
|
|
76
|
+
skipExperience?: boolean;
|
|
77
|
+
/** Skip semantic analysis (reachability, etc.). */
|
|
78
|
+
skipSemantic?: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// =============================================================================
|
|
82
|
+
// Default known binding roots
|
|
83
|
+
// =============================================================================
|
|
84
|
+
|
|
85
|
+
const DEFAULT_BINDING_ROOTS = [
|
|
86
|
+
'$instance', '$definition', '$instances', '$local',
|
|
87
|
+
'$entity', '$user', '$fn', '$action', '$item', '$index',
|
|
88
|
+
'$pagination',
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
// =============================================================================
|
|
92
|
+
// Validator
|
|
93
|
+
// =============================================================================
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Validates an IRWorkflowDefinition for structural, referential,
|
|
97
|
+
* semantic, and experience correctness.
|
|
98
|
+
*/
|
|
99
|
+
export function validateDefinition(
|
|
100
|
+
def: IRWorkflowDefinition,
|
|
101
|
+
options: ValidatorOptions = {},
|
|
102
|
+
): ValidationResult {
|
|
103
|
+
const issues: ValidationIssue[] = [];
|
|
104
|
+
const knownRoots = options.knownBindingRoots ?? DEFAULT_BINDING_ROOTS;
|
|
105
|
+
|
|
106
|
+
// Structural validation
|
|
107
|
+
validateStructure(def, issues);
|
|
108
|
+
|
|
109
|
+
// Referential validation
|
|
110
|
+
validateReferences(def, issues);
|
|
111
|
+
|
|
112
|
+
// Semantic validation
|
|
113
|
+
if (!options.skipSemantic) {
|
|
114
|
+
validateSemantics(def, issues);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Experience tree validation
|
|
118
|
+
if (!options.skipExperience && def.metadata?.experience) {
|
|
119
|
+
validateExperienceTree(
|
|
120
|
+
def.metadata.experience as IRExperienceNode,
|
|
121
|
+
options.knownComponents,
|
|
122
|
+
knownRoots,
|
|
123
|
+
issues,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const errorCount = issues.filter(i => i.severity === 'error').length;
|
|
128
|
+
const warningCount = issues.filter(i => i.severity === 'warning').length;
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
valid: errorCount === 0,
|
|
132
|
+
issues,
|
|
133
|
+
errorCount,
|
|
134
|
+
warningCount,
|
|
135
|
+
summary: {
|
|
136
|
+
stateCount: def.states?.length ?? 0,
|
|
137
|
+
transitionCount: def.transitions?.length ?? 0,
|
|
138
|
+
fieldCount: def.fields?.length ?? 0,
|
|
139
|
+
hasExperience: !!def.metadata?.experience,
|
|
140
|
+
hasExtensions: !!def.extensions && Object.keys(def.extensions).length > 0,
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// =============================================================================
|
|
146
|
+
// Structural Validation
|
|
147
|
+
// =============================================================================
|
|
148
|
+
|
|
149
|
+
function validateStructure(def: IRWorkflowDefinition, issues: ValidationIssue[]): void {
|
|
150
|
+
// Required top-level fields
|
|
151
|
+
if (!def.slug) {
|
|
152
|
+
issues.push({
|
|
153
|
+
code: 'MISSING_SLUG',
|
|
154
|
+
message: 'Workflow definition is missing a slug',
|
|
155
|
+
severity: 'error',
|
|
156
|
+
category: 'structural',
|
|
157
|
+
suggestion: 'Add a slug field to the workflow definition',
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!def.name) {
|
|
162
|
+
issues.push({
|
|
163
|
+
code: 'MISSING_NAME',
|
|
164
|
+
message: 'Workflow definition is missing a name',
|
|
165
|
+
severity: 'warning',
|
|
166
|
+
category: 'structural',
|
|
167
|
+
suggestion: 'Add a name field to the workflow definition',
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (!def.version) {
|
|
172
|
+
issues.push({
|
|
173
|
+
code: 'MISSING_VERSION',
|
|
174
|
+
message: 'Workflow definition is missing a version',
|
|
175
|
+
severity: 'warning',
|
|
176
|
+
category: 'structural',
|
|
177
|
+
suggestion: 'Add a version field (e.g., "1.0.0")',
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (!def.states || def.states.length === 0) {
|
|
182
|
+
issues.push({
|
|
183
|
+
code: 'NO_STATES',
|
|
184
|
+
message: 'Workflow definition has no states',
|
|
185
|
+
severity: 'error',
|
|
186
|
+
category: 'structural',
|
|
187
|
+
suggestion: 'Add at least one state with type START',
|
|
188
|
+
});
|
|
189
|
+
return; // Can't validate further without states
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Validate each state
|
|
193
|
+
for (let i = 0; i < def.states.length; i++) {
|
|
194
|
+
validateState(def.states[i], i, issues);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Validate transitions
|
|
198
|
+
if (def.transitions) {
|
|
199
|
+
for (let i = 0; i < def.transitions.length; i++) {
|
|
200
|
+
validateTransitionStructure(def.transitions[i], i, issues);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Validate fields
|
|
205
|
+
if (def.fields) {
|
|
206
|
+
const fieldNames = new Set<string>();
|
|
207
|
+
for (let i = 0; i < def.fields.length; i++) {
|
|
208
|
+
validateFieldStructure(def.fields[i], i, fieldNames, issues);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function validateState(state: IRStateDefinition, index: number, issues: ValidationIssue[]): void {
|
|
214
|
+
const path = `states[${index}]`;
|
|
215
|
+
|
|
216
|
+
if (!state.name) {
|
|
217
|
+
issues.push({
|
|
218
|
+
code: 'STATE_MISSING_NAME',
|
|
219
|
+
message: `State at index ${index} is missing a name`,
|
|
220
|
+
severity: 'error',
|
|
221
|
+
category: 'structural',
|
|
222
|
+
path,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Accept both 'type' (v1.1+) and 'state_type' (v1.0) naming
|
|
227
|
+
const stateType = state.type || (state as any).state_type;
|
|
228
|
+
if (!stateType) {
|
|
229
|
+
issues.push({
|
|
230
|
+
code: 'STATE_MISSING_TYPE',
|
|
231
|
+
message: `State '${state.name || index}' is missing a type`,
|
|
232
|
+
severity: 'error',
|
|
233
|
+
category: 'structural',
|
|
234
|
+
path,
|
|
235
|
+
suggestion: 'Set type to START, REGULAR, END, or CANCELLED',
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Validate on_enter actions
|
|
240
|
+
if (state.on_enter) {
|
|
241
|
+
for (let j = 0; j < state.on_enter.length; j++) {
|
|
242
|
+
validateAction(state.on_enter[j], `${path}.on_enter[${j}]`, issues);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Validate on_exit actions
|
|
247
|
+
if (state.on_exit) {
|
|
248
|
+
for (let j = 0; j < state.on_exit.length; j++) {
|
|
249
|
+
validateAction(state.on_exit[j], `${path}.on_exit[${j}]`, issues);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Validate during actions
|
|
254
|
+
if (state.during) {
|
|
255
|
+
for (let j = 0; j < state.during.length; j++) {
|
|
256
|
+
validateDuringAction(state.during[j], `${path}.during[${j}]`, issues);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function validateAction(action: IRActionDefinition, path: string, issues: ValidationIssue[]): void {
|
|
262
|
+
// Accept both 'type' and 'action_type' (compiled output uses action_type)
|
|
263
|
+
const actionType = action.type || (action as any).action_type;
|
|
264
|
+
if (!action.id) {
|
|
265
|
+
issues.push({
|
|
266
|
+
code: 'ACTION_MISSING_ID',
|
|
267
|
+
message: `Action at ${path} is missing an id`,
|
|
268
|
+
severity: 'warning',
|
|
269
|
+
category: 'structural',
|
|
270
|
+
path,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (!actionType) {
|
|
275
|
+
issues.push({
|
|
276
|
+
code: 'ACTION_MISSING_TYPE',
|
|
277
|
+
message: `Action '${action.id || 'unknown'}' at ${path} is missing a type`,
|
|
278
|
+
severity: 'error',
|
|
279
|
+
category: 'structural',
|
|
280
|
+
path,
|
|
281
|
+
suggestion: 'Add a type field (e.g., "set_field", "set_memory")',
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function validateDuringAction(during: IRDuringAction, path: string, issues: ValidationIssue[]): void {
|
|
287
|
+
if (!during.type) {
|
|
288
|
+
issues.push({
|
|
289
|
+
code: 'DURING_MISSING_TYPE',
|
|
290
|
+
message: `During action at ${path} is missing a type`,
|
|
291
|
+
severity: 'error',
|
|
292
|
+
category: 'structural',
|
|
293
|
+
path,
|
|
294
|
+
suggestion: 'Set type to interval, timeout, poll, once, or cron',
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (during.type === 'interval' && !during.interval_ms) {
|
|
299
|
+
issues.push({
|
|
300
|
+
code: 'DURING_MISSING_INTERVAL',
|
|
301
|
+
message: `Interval during action at ${path} is missing interval_ms`,
|
|
302
|
+
severity: 'error',
|
|
303
|
+
category: 'structural',
|
|
304
|
+
path,
|
|
305
|
+
suggestion: 'Set interval_ms to a positive number (milliseconds)',
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (during.type === 'cron' && !during.cron) {
|
|
310
|
+
issues.push({
|
|
311
|
+
code: 'DURING_MISSING_CRON',
|
|
312
|
+
message: `Cron during action at ${path} is missing cron expression`,
|
|
313
|
+
severity: 'error',
|
|
314
|
+
category: 'structural',
|
|
315
|
+
path,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function validateTransitionStructure(
|
|
321
|
+
transition: IRTransitionDefinition,
|
|
322
|
+
index: number,
|
|
323
|
+
issues: ValidationIssue[]
|
|
324
|
+
): void {
|
|
325
|
+
const path = `transitions[${index}]`;
|
|
326
|
+
|
|
327
|
+
if (!transition.name) {
|
|
328
|
+
issues.push({
|
|
329
|
+
code: 'TRANSITION_MISSING_NAME',
|
|
330
|
+
message: `Transition at index ${index} is missing a name`,
|
|
331
|
+
severity: 'error',
|
|
332
|
+
category: 'structural',
|
|
333
|
+
path,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (!transition.from || transition.from.length === 0) {
|
|
338
|
+
issues.push({
|
|
339
|
+
code: 'TRANSITION_MISSING_FROM',
|
|
340
|
+
message: `Transition '${transition.name || index}' has no source states`,
|
|
341
|
+
severity: 'error',
|
|
342
|
+
category: 'structural',
|
|
343
|
+
path,
|
|
344
|
+
suggestion: 'Add at least one source state in the from array',
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (!transition.to) {
|
|
349
|
+
issues.push({
|
|
350
|
+
code: 'TRANSITION_MISSING_TO',
|
|
351
|
+
message: `Transition '${transition.name || index}' has no target state`,
|
|
352
|
+
severity: 'error',
|
|
353
|
+
category: 'structural',
|
|
354
|
+
path,
|
|
355
|
+
suggestion: 'Set the to field to a valid state name',
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function validateFieldStructure(
|
|
361
|
+
field: IRFieldDefinition,
|
|
362
|
+
index: number,
|
|
363
|
+
seen: Set<string>,
|
|
364
|
+
issues: ValidationIssue[]
|
|
365
|
+
): void {
|
|
366
|
+
const path = `fields[${index}]`;
|
|
367
|
+
|
|
368
|
+
if (!field.name) {
|
|
369
|
+
issues.push({
|
|
370
|
+
code: 'FIELD_MISSING_NAME',
|
|
371
|
+
message: `Field at index ${index} is missing a name`,
|
|
372
|
+
severity: 'error',
|
|
373
|
+
category: 'structural',
|
|
374
|
+
path,
|
|
375
|
+
});
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (seen.has(field.name)) {
|
|
380
|
+
issues.push({
|
|
381
|
+
code: 'DUPLICATE_FIELD',
|
|
382
|
+
message: `Duplicate field name '${field.name}'`,
|
|
383
|
+
severity: 'error',
|
|
384
|
+
category: 'structural',
|
|
385
|
+
path,
|
|
386
|
+
suggestion: `Rename one of the '${field.name}' fields`,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
seen.add(field.name);
|
|
390
|
+
|
|
391
|
+
// Accept both 'type' and 'field_type' (compiled output uses field_type)
|
|
392
|
+
const fieldType = field.type || (field as any).field_type;
|
|
393
|
+
if (!fieldType) {
|
|
394
|
+
issues.push({
|
|
395
|
+
code: 'FIELD_MISSING_TYPE',
|
|
396
|
+
message: `Field '${field.name}' is missing a type`,
|
|
397
|
+
severity: 'error',
|
|
398
|
+
category: 'structural',
|
|
399
|
+
path,
|
|
400
|
+
suggestion: 'Add a type field (e.g., "text", "number", "boolean")',
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// =============================================================================
|
|
406
|
+
// Referential Validation
|
|
407
|
+
// =============================================================================
|
|
408
|
+
|
|
409
|
+
function validateReferences(def: IRWorkflowDefinition, issues: ValidationIssue[]): void {
|
|
410
|
+
const stateNames = new Set((def.states || []).map(s => s.name));
|
|
411
|
+
const fieldNames = new Set((def.fields || []).map(f => f.name));
|
|
412
|
+
|
|
413
|
+
// Validate transition references
|
|
414
|
+
if (def.transitions) {
|
|
415
|
+
for (let i = 0; i < def.transitions.length; i++) {
|
|
416
|
+
const t = def.transitions[i];
|
|
417
|
+
const path = `transitions[${i}]`;
|
|
418
|
+
|
|
419
|
+
// Check from states exist
|
|
420
|
+
if (t.from) {
|
|
421
|
+
for (const fromState of t.from) {
|
|
422
|
+
if (!stateNames.has(fromState)) {
|
|
423
|
+
issues.push({
|
|
424
|
+
code: 'TRANSITION_UNKNOWN_FROM',
|
|
425
|
+
message: `Transition '${t.name}' references unknown source state '${fromState}'`,
|
|
426
|
+
severity: 'error',
|
|
427
|
+
category: 'referential',
|
|
428
|
+
path,
|
|
429
|
+
suggestion: `Valid states: ${Array.from(stateNames).join(', ')}`,
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Check to state exists
|
|
436
|
+
if (t.to && !stateNames.has(t.to)) {
|
|
437
|
+
issues.push({
|
|
438
|
+
code: 'TRANSITION_UNKNOWN_TO',
|
|
439
|
+
message: `Transition '${t.name}' references unknown target state '${t.to}'`,
|
|
440
|
+
severity: 'error',
|
|
441
|
+
category: 'referential',
|
|
442
|
+
path,
|
|
443
|
+
suggestion: `Valid states: ${Array.from(stateNames).join(', ')}`,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Check required_fields references
|
|
448
|
+
if (t.required_fields) {
|
|
449
|
+
for (const rf of t.required_fields) {
|
|
450
|
+
if (!fieldNames.has(rf)) {
|
|
451
|
+
issues.push({
|
|
452
|
+
code: 'TRANSITION_UNKNOWN_FIELD',
|
|
453
|
+
message: `Transition '${t.name}' requires unknown field '${rf}'`,
|
|
454
|
+
severity: 'warning',
|
|
455
|
+
category: 'referential',
|
|
456
|
+
path,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Validate field references in actions
|
|
465
|
+
if (def.states) {
|
|
466
|
+
for (let i = 0; i < def.states.length; i++) {
|
|
467
|
+
const state = def.states[i];
|
|
468
|
+
validateActionFieldRefs(state.on_enter, fieldNames, `states[${i}].on_enter`, issues);
|
|
469
|
+
validateActionFieldRefs(state.on_exit, fieldNames, `states[${i}].on_exit`, issues);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Validate field visible_in_states and editable_in_states
|
|
474
|
+
if (def.fields) {
|
|
475
|
+
for (let i = 0; i < def.fields.length; i++) {
|
|
476
|
+
const field = def.fields[i];
|
|
477
|
+
const path = `fields[${i}]`;
|
|
478
|
+
|
|
479
|
+
if (field.visible_in_states) {
|
|
480
|
+
for (const s of field.visible_in_states) {
|
|
481
|
+
if (!stateNames.has(s)) {
|
|
482
|
+
issues.push({
|
|
483
|
+
code: 'FIELD_UNKNOWN_STATE',
|
|
484
|
+
message: `Field '${field.name}' references unknown state '${s}' in visible_in_states`,
|
|
485
|
+
severity: 'warning',
|
|
486
|
+
category: 'referential',
|
|
487
|
+
path,
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (field.editable_in_states) {
|
|
494
|
+
for (const s of field.editable_in_states) {
|
|
495
|
+
if (!stateNames.has(s)) {
|
|
496
|
+
issues.push({
|
|
497
|
+
code: 'FIELD_UNKNOWN_STATE',
|
|
498
|
+
message: `Field '${field.name}' references unknown state '${s}' in editable_in_states`,
|
|
499
|
+
severity: 'warning',
|
|
500
|
+
category: 'referential',
|
|
501
|
+
path,
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function validateActionFieldRefs(
|
|
511
|
+
actions: IRActionDefinition[] | undefined,
|
|
512
|
+
fieldNames: Set<string>,
|
|
513
|
+
basePath: string,
|
|
514
|
+
issues: ValidationIssue[]
|
|
515
|
+
): void {
|
|
516
|
+
if (!actions) return;
|
|
517
|
+
for (let j = 0; j < actions.length; j++) {
|
|
518
|
+
const action = actions[j];
|
|
519
|
+
const actionType = action.type || (action as any).action_type;
|
|
520
|
+
if (actionType === 'set_field' && action.config?.field) {
|
|
521
|
+
const fieldName = String(action.config.field);
|
|
522
|
+
if (!fieldNames.has(fieldName)) {
|
|
523
|
+
issues.push({
|
|
524
|
+
code: 'ACTION_UNKNOWN_FIELD',
|
|
525
|
+
message: `Action '${action.id}' references unknown field '${fieldName}'`,
|
|
526
|
+
severity: 'warning',
|
|
527
|
+
category: 'referential',
|
|
528
|
+
path: `${basePath}[${j}]`,
|
|
529
|
+
suggestion: `Declare field '${fieldName}' in the fields array`,
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// =============================================================================
|
|
537
|
+
// Semantic Validation
|
|
538
|
+
// =============================================================================
|
|
539
|
+
|
|
540
|
+
/** Get state type, accepting both v1.0 (state_type) and v1.1+ (type) naming. */
|
|
541
|
+
function getStateType(state: IRStateDefinition): string | undefined {
|
|
542
|
+
return state.type || (state as any).state_type;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function validateSemantics(def: IRWorkflowDefinition, issues: ValidationIssue[]): void {
|
|
546
|
+
if (!def.states || def.states.length === 0) return;
|
|
547
|
+
|
|
548
|
+
// Check for exactly one START state
|
|
549
|
+
const startStates = def.states.filter(s => getStateType(s) === 'START');
|
|
550
|
+
if (startStates.length === 0) {
|
|
551
|
+
issues.push({
|
|
552
|
+
code: 'NO_START_STATE',
|
|
553
|
+
message: 'Workflow has no START state',
|
|
554
|
+
severity: 'error',
|
|
555
|
+
category: 'semantic',
|
|
556
|
+
suggestion: 'Mark one state with type: "START"',
|
|
557
|
+
});
|
|
558
|
+
} else if (startStates.length > 1) {
|
|
559
|
+
issues.push({
|
|
560
|
+
code: 'MULTIPLE_START_STATES',
|
|
561
|
+
message: `Workflow has ${startStates.length} START states: ${startStates.map(s => s.name).join(', ')}`,
|
|
562
|
+
severity: 'error',
|
|
563
|
+
category: 'semantic',
|
|
564
|
+
suggestion: 'Only one state should have type: "START"',
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Check for duplicate state names
|
|
569
|
+
const stateNameCounts = new Map<string, number>();
|
|
570
|
+
for (const state of def.states) {
|
|
571
|
+
const count = stateNameCounts.get(state.name) || 0;
|
|
572
|
+
stateNameCounts.set(state.name, count + 1);
|
|
573
|
+
}
|
|
574
|
+
for (const [name, count] of stateNameCounts) {
|
|
575
|
+
if (count > 1) {
|
|
576
|
+
issues.push({
|
|
577
|
+
code: 'DUPLICATE_STATE',
|
|
578
|
+
message: `Duplicate state name '${name}' (appears ${count} times)`,
|
|
579
|
+
severity: 'error',
|
|
580
|
+
category: 'semantic',
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Check for duplicate transition names
|
|
586
|
+
if (def.transitions) {
|
|
587
|
+
const transitionNames = new Map<string, number>();
|
|
588
|
+
for (const t of def.transitions) {
|
|
589
|
+
const count = transitionNames.get(t.name) || 0;
|
|
590
|
+
transitionNames.set(t.name, count + 1);
|
|
591
|
+
}
|
|
592
|
+
for (const [name, count] of transitionNames) {
|
|
593
|
+
if (count > 1) {
|
|
594
|
+
issues.push({
|
|
595
|
+
code: 'DUPLICATE_TRANSITION',
|
|
596
|
+
message: `Duplicate transition name '${name}' (appears ${count} times)`,
|
|
597
|
+
severity: 'warning',
|
|
598
|
+
category: 'semantic',
|
|
599
|
+
suggestion: 'Consider using unique transition names for clarity',
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Check for unreachable states (no transition leads to them, except START)
|
|
606
|
+
if (def.transitions && def.transitions.length > 0) {
|
|
607
|
+
const reachableStates = new Set<string>();
|
|
608
|
+
// START states are always reachable
|
|
609
|
+
for (const state of startStates) {
|
|
610
|
+
reachableStates.add(state.name);
|
|
611
|
+
}
|
|
612
|
+
// States that are transition targets are reachable
|
|
613
|
+
for (const t of def.transitions) {
|
|
614
|
+
if (t.to) reachableStates.add(t.to);
|
|
615
|
+
}
|
|
616
|
+
// Check each state
|
|
617
|
+
for (const state of def.states) {
|
|
618
|
+
if (!reachableStates.has(state.name)) {
|
|
619
|
+
issues.push({
|
|
620
|
+
code: 'UNREACHABLE_STATE',
|
|
621
|
+
message: `State '${state.name}' is unreachable (no transition targets it)`,
|
|
622
|
+
severity: 'warning',
|
|
623
|
+
category: 'semantic',
|
|
624
|
+
suggestion: `Add a transition with to: "${state.name}" or remove this state`,
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Check for dead-end states (non-END states with no outgoing transitions)
|
|
631
|
+
if (def.transitions) {
|
|
632
|
+
const statesWithOutgoing = new Set<string>();
|
|
633
|
+
for (const t of def.transitions) {
|
|
634
|
+
if (t.from) {
|
|
635
|
+
for (const fromState of t.from) {
|
|
636
|
+
statesWithOutgoing.add(fromState);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
for (const state of def.states) {
|
|
641
|
+
const sType = getStateType(state);
|
|
642
|
+
if (sType !== 'END' && sType !== 'CANCELLED' && !statesWithOutgoing.has(state.name)) {
|
|
643
|
+
issues.push({
|
|
644
|
+
code: 'DEAD_END_STATE',
|
|
645
|
+
message: `State '${state.name}' has no outgoing transitions (dead end)`,
|
|
646
|
+
severity: 'info',
|
|
647
|
+
category: 'semantic',
|
|
648
|
+
suggestion: `Add a transition from "${state.name}" or mark it as an END state`,
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Check for transitions from END/CANCELLED states
|
|
655
|
+
if (def.transitions) {
|
|
656
|
+
const endStates = new Set(
|
|
657
|
+
def.states
|
|
658
|
+
.filter(s => {
|
|
659
|
+
const sType = getStateType(s);
|
|
660
|
+
return sType === 'END' || sType === 'CANCELLED';
|
|
661
|
+
})
|
|
662
|
+
.map(s => s.name)
|
|
663
|
+
);
|
|
664
|
+
for (const t of def.transitions) {
|
|
665
|
+
if (t.from) {
|
|
666
|
+
for (const fromState of t.from) {
|
|
667
|
+
if (endStates.has(fromState)) {
|
|
668
|
+
issues.push({
|
|
669
|
+
code: 'TRANSITION_FROM_END',
|
|
670
|
+
message: `Transition '${t.name}' has source state '${fromState}' which is an END/CANCELLED state`,
|
|
671
|
+
severity: 'warning',
|
|
672
|
+
category: 'semantic',
|
|
673
|
+
suggestion: 'Remove transitions from END/CANCELLED states',
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// =============================================================================
|
|
683
|
+
// Experience Tree Validation
|
|
684
|
+
// =============================================================================
|
|
685
|
+
|
|
686
|
+
function validateExperienceTree(
|
|
687
|
+
node: IRExperienceNode,
|
|
688
|
+
knownComponents: string[] | undefined,
|
|
689
|
+
knownRoots: string[],
|
|
690
|
+
issues: ValidationIssue[],
|
|
691
|
+
path: string = 'metadata.experience',
|
|
692
|
+
): void {
|
|
693
|
+
// Validate component reference
|
|
694
|
+
if (node.component && knownComponents) {
|
|
695
|
+
if (!knownComponents.includes(node.component)) {
|
|
696
|
+
issues.push({
|
|
697
|
+
code: 'UNKNOWN_COMPONENT',
|
|
698
|
+
message: `Experience node '${node.id}' references unknown component '${node.component}'`,
|
|
699
|
+
severity: 'warning',
|
|
700
|
+
category: 'experience',
|
|
701
|
+
path,
|
|
702
|
+
suggestion: `Registered components: ${knownComponents.slice(0, 10).join(', ')}${knownComponents.length > 10 ? '...' : ''}`,
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Validate bindings
|
|
708
|
+
if (node.bindings) {
|
|
709
|
+
for (const [prop, expr] of Object.entries(node.bindings)) {
|
|
710
|
+
validateBindingExpression(expr, `${path}.bindings.${prop}`, knownRoots, issues);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Validate visible_when
|
|
715
|
+
if (node.visible_when) {
|
|
716
|
+
validateBindingExpression(node.visible_when, `${path}.visible_when`, knownRoots, issues);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// Validate children recursively
|
|
720
|
+
if (node.children) {
|
|
721
|
+
const childIds = new Set<string>();
|
|
722
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
723
|
+
const child = node.children[i];
|
|
724
|
+
if (child.id && childIds.has(child.id)) {
|
|
725
|
+
issues.push({
|
|
726
|
+
code: 'DUPLICATE_NODE_ID',
|
|
727
|
+
message: `Duplicate experience node ID '${child.id}' under '${node.id}'`,
|
|
728
|
+
severity: 'warning',
|
|
729
|
+
category: 'experience',
|
|
730
|
+
path: `${path}.children[${i}]`,
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
if (child.id) childIds.add(child.id);
|
|
734
|
+
validateExperienceTree(child, knownComponents, knownRoots, issues, `${path}.children[${i}]`);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function validateBindingExpression(
|
|
740
|
+
expr: string,
|
|
741
|
+
path: string,
|
|
742
|
+
knownRoots: string[],
|
|
743
|
+
issues: ValidationIssue[],
|
|
744
|
+
): void {
|
|
745
|
+
if (!expr) return;
|
|
746
|
+
|
|
747
|
+
const trimmed = expr.trim();
|
|
748
|
+
|
|
749
|
+
// Skip literals
|
|
750
|
+
if (trimmed.startsWith('"') || trimmed.startsWith("'") || /^-?\d/.test(trimmed) ||
|
|
751
|
+
trimmed === 'true' || trimmed === 'false' || trimmed === 'null') {
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Skip special action syntax
|
|
756
|
+
if (trimmed.startsWith('action:') || trimmed.startsWith('setLocal:')) {
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// Skip transition.fire bindings (compiled format)
|
|
761
|
+
if (/^\w+\.fire$/.test(trimmed)) {
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Check that $ bindings use known roots
|
|
766
|
+
if (trimmed.startsWith('$')) {
|
|
767
|
+
const dotIdx = trimmed.indexOf('.');
|
|
768
|
+
const parenIdx = trimmed.indexOf('(');
|
|
769
|
+
let root: string;
|
|
770
|
+
if (dotIdx > 0 && (parenIdx < 0 || dotIdx < parenIdx)) {
|
|
771
|
+
root = trimmed.slice(0, dotIdx);
|
|
772
|
+
} else if (parenIdx > 0) {
|
|
773
|
+
root = trimmed.slice(0, parenIdx);
|
|
774
|
+
} else {
|
|
775
|
+
root = trimmed;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// Strip operators from root if present in conditions
|
|
779
|
+
const rootClean = root.replace(/[!=<>&|+\-*/\s].*/g, '').trim();
|
|
780
|
+
|
|
781
|
+
if (rootClean && !knownRoots.includes(rootClean)) {
|
|
782
|
+
issues.push({
|
|
783
|
+
code: 'UNKNOWN_BINDING_ROOT',
|
|
784
|
+
message: `Binding expression at ${path} uses unknown root '${rootClean}'`,
|
|
785
|
+
severity: 'warning',
|
|
786
|
+
category: 'experience',
|
|
787
|
+
path,
|
|
788
|
+
suggestion: `Known roots: ${knownRoots.join(', ')}`,
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// =============================================================================
|
|
795
|
+
// Quick Check (lightweight validation for hot paths)
|
|
796
|
+
// =============================================================================
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Quick structural check — returns true if the definition has minimum viable structure.
|
|
800
|
+
* Much faster than full validateDefinition() for use in render-path guards.
|
|
801
|
+
*/
|
|
802
|
+
export function isViableDefinition(def: unknown): def is IRWorkflowDefinition {
|
|
803
|
+
if (!def || typeof def !== 'object') return false;
|
|
804
|
+
const d = def as Record<string, unknown>;
|
|
805
|
+
return (
|
|
806
|
+
typeof d.slug === 'string' &&
|
|
807
|
+
Array.isArray(d.states) &&
|
|
808
|
+
d.states.length > 0 &&
|
|
809
|
+
Array.isArray(d.transitions) &&
|
|
810
|
+
Array.isArray(d.fields)
|
|
811
|
+
);
|
|
812
|
+
}
|