@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.
Files changed (63) hide show
  1. package/dist/index.d.mts +1436 -0
  2. package/dist/index.d.ts +1436 -0
  3. package/dist/index.js +4828 -0
  4. package/dist/index.mjs +4762 -0
  5. package/package.json +35 -0
  6. package/package.json.backup +35 -0
  7. package/src/__tests__/actions.test.ts +187 -0
  8. package/src/__tests__/blueprint-e2e.test.ts +706 -0
  9. package/src/__tests__/blueprint-test-runner.test.ts +680 -0
  10. package/src/__tests__/core-functions.test.ts +78 -0
  11. package/src/__tests__/dsl-compiler.test.ts +1382 -0
  12. package/src/__tests__/dsl-grammar.test.ts +1682 -0
  13. package/src/__tests__/events.test.ts +200 -0
  14. package/src/__tests__/expression.test.ts +296 -0
  15. package/src/__tests__/failure-policies.test.ts +110 -0
  16. package/src/__tests__/frontend-context.test.ts +182 -0
  17. package/src/__tests__/integration.test.ts +256 -0
  18. package/src/__tests__/security.test.ts +190 -0
  19. package/src/__tests__/state-machine.test.ts +450 -0
  20. package/src/__tests__/testing-engine.test.ts +671 -0
  21. package/src/actions/dispatcher.ts +80 -0
  22. package/src/actions/index.ts +7 -0
  23. package/src/actions/types.ts +25 -0
  24. package/src/dsl/compiler/component-mapper.ts +289 -0
  25. package/src/dsl/compiler/field-mapper.ts +187 -0
  26. package/src/dsl/compiler/index.ts +82 -0
  27. package/src/dsl/compiler/manifest-compiler.ts +76 -0
  28. package/src/dsl/compiler/symbol-table.ts +214 -0
  29. package/src/dsl/compiler/utils.ts +48 -0
  30. package/src/dsl/compiler/view-compiler.ts +286 -0
  31. package/src/dsl/compiler/workflow-compiler.ts +600 -0
  32. package/src/dsl/index.ts +66 -0
  33. package/src/dsl/ir-migration.ts +221 -0
  34. package/src/dsl/ir-types.ts +416 -0
  35. package/src/dsl/lexer.ts +579 -0
  36. package/src/dsl/parser.ts +115 -0
  37. package/src/dsl/types.ts +256 -0
  38. package/src/events/event-bus.ts +68 -0
  39. package/src/events/index.ts +9 -0
  40. package/src/events/pattern-matcher.ts +61 -0
  41. package/src/events/types.ts +27 -0
  42. package/src/expression/evaluator.ts +676 -0
  43. package/src/expression/functions.ts +214 -0
  44. package/src/expression/index.ts +13 -0
  45. package/src/expression/types.ts +64 -0
  46. package/src/index.ts +61 -0
  47. package/src/state-machine/index.ts +16 -0
  48. package/src/state-machine/interpreter.ts +319 -0
  49. package/src/state-machine/types.ts +89 -0
  50. package/src/testing/action-trace.ts +209 -0
  51. package/src/testing/blueprint-test-runner.ts +214 -0
  52. package/src/testing/graph-walker.ts +249 -0
  53. package/src/testing/index.ts +69 -0
  54. package/src/testing/nrt-comparator.ts +199 -0
  55. package/src/testing/nrt-types.ts +230 -0
  56. package/src/testing/test-actions.ts +645 -0
  57. package/src/testing/test-compiler.ts +278 -0
  58. package/src/testing/test-runner.ts +444 -0
  59. package/src/testing/types.ts +231 -0
  60. package/src/validation/definition-validator.ts +812 -0
  61. package/src/validation/index.ts +13 -0
  62. package/tsconfig.json +26 -0
  63. 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
+ }