@principal-ai/principal-studio-cli 0.35.3

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 (184) hide show
  1. package/README.md +172 -0
  2. package/dist/commands/agent-session.d.ts +3 -0
  3. package/dist/commands/agent-session.d.ts.map +1 -0
  4. package/dist/commands/agent-session.js +67 -0
  5. package/dist/commands/agent-sessions.d.ts +11 -0
  6. package/dist/commands/agent-sessions.d.ts.map +1 -0
  7. package/dist/commands/agent-sessions.js +39 -0
  8. package/dist/commands/auxiliary/index.d.ts +3 -0
  9. package/dist/commands/auxiliary/index.d.ts.map +1 -0
  10. package/dist/commands/auxiliary/index.js +9 -0
  11. package/dist/commands/auxiliary/validate.d.ts +3 -0
  12. package/dist/commands/auxiliary/validate.d.ts.map +1 -0
  13. package/dist/commands/auxiliary/validate.js +123 -0
  14. package/dist/commands/collector/check.d.ts +6 -0
  15. package/dist/commands/collector/check.d.ts.map +1 -0
  16. package/dist/commands/collector/check.js +153 -0
  17. package/dist/commands/collector/diagnose.d.ts +6 -0
  18. package/dist/commands/collector/diagnose.d.ts.map +1 -0
  19. package/dist/commands/collector/diagnose.js +229 -0
  20. package/dist/commands/collector/index.d.ts +6 -0
  21. package/dist/commands/collector/index.d.ts.map +1 -0
  22. package/dist/commands/collector/index.js +16 -0
  23. package/dist/commands/collector/status.d.ts +6 -0
  24. package/dist/commands/collector/status.d.ts.map +1 -0
  25. package/dist/commands/collector/status.js +77 -0
  26. package/dist/commands/collector/utils.d.ts +92 -0
  27. package/dist/commands/collector/utils.d.ts.map +1 -0
  28. package/dist/commands/collector/utils.js +154 -0
  29. package/dist/commands/coverage.d.ts +9 -0
  30. package/dist/commands/coverage.d.ts.map +1 -0
  31. package/dist/commands/coverage.js +252 -0
  32. package/dist/commands/create.d.ts +6 -0
  33. package/dist/commands/create.d.ts.map +1 -0
  34. package/dist/commands/create.js +50 -0
  35. package/dist/commands/events/index.d.ts +3 -0
  36. package/dist/commands/events/index.d.ts.map +1 -0
  37. package/dist/commands/events/index.js +9 -0
  38. package/dist/commands/events/validate.d.ts +3 -0
  39. package/dist/commands/events/validate.d.ts.map +1 -0
  40. package/dist/commands/events/validate.js +124 -0
  41. package/dist/commands/formats.d.ts +6 -0
  42. package/dist/commands/formats.d.ts.map +1 -0
  43. package/dist/commands/formats.js +1118 -0
  44. package/dist/commands/hooks.d.ts +9 -0
  45. package/dist/commands/hooks.d.ts.map +1 -0
  46. package/dist/commands/hooks.js +295 -0
  47. package/dist/commands/inbox.d.ts +13 -0
  48. package/dist/commands/inbox.d.ts.map +1 -0
  49. package/dist/commands/inbox.js +179 -0
  50. package/dist/commands/init.d.ts +6 -0
  51. package/dist/commands/init.d.ts.map +1 -0
  52. package/dist/commands/init.js +220 -0
  53. package/dist/commands/lint.d.ts +11 -0
  54. package/dist/commands/lint.d.ts.map +1 -0
  55. package/dist/commands/lint.js +71 -0
  56. package/dist/commands/list.d.ts +6 -0
  57. package/dist/commands/list.d.ts.map +1 -0
  58. package/dist/commands/list.js +78 -0
  59. package/dist/commands/migrate-nodes.d.ts +9 -0
  60. package/dist/commands/migrate-nodes.d.ts.map +1 -0
  61. package/dist/commands/migrate-nodes.js +363 -0
  62. package/dist/commands/migrate-scopes-to-canvas.d.ts +3 -0
  63. package/dist/commands/migrate-scopes-to-canvas.d.ts.map +1 -0
  64. package/dist/commands/migrate-scopes-to-canvas.js +143 -0
  65. package/dist/commands/migration.d.ts +6 -0
  66. package/dist/commands/migration.d.ts.map +1 -0
  67. package/dist/commands/migration.js +247 -0
  68. package/dist/commands/open-studio.d.ts +10 -0
  69. package/dist/commands/open-studio.d.ts.map +1 -0
  70. package/dist/commands/open-studio.js +32 -0
  71. package/dist/commands/opencode/fetch.d.ts +3 -0
  72. package/dist/commands/opencode/fetch.d.ts.map +1 -0
  73. package/dist/commands/opencode/fetch.js +25 -0
  74. package/dist/commands/opencode/index.d.ts +3 -0
  75. package/dist/commands/opencode/index.d.ts.map +1 -0
  76. package/dist/commands/opencode/index.js +11 -0
  77. package/dist/commands/opencode/list-sessions.d.ts +3 -0
  78. package/dist/commands/opencode/list-sessions.d.ts.map +1 -0
  79. package/dist/commands/opencode/list-sessions.js +20 -0
  80. package/dist/commands/repo.d.ts +18 -0
  81. package/dist/commands/repo.d.ts.map +1 -0
  82. package/dist/commands/repo.js +233 -0
  83. package/dist/commands/schema.d.ts +6 -0
  84. package/dist/commands/schema.d.ts.map +1 -0
  85. package/dist/commands/schema.js +367 -0
  86. package/dist/commands/scopes/index.d.ts +3 -0
  87. package/dist/commands/scopes/index.d.ts.map +1 -0
  88. package/dist/commands/scopes/index.js +9 -0
  89. package/dist/commands/scopes/validate.d.ts +3 -0
  90. package/dist/commands/scopes/validate.d.ts.map +1 -0
  91. package/dist/commands/scopes/validate.js +187 -0
  92. package/dist/commands/setup.d.ts +6 -0
  93. package/dist/commands/setup.d.ts.map +1 -0
  94. package/dist/commands/setup.js +406 -0
  95. package/dist/commands/starred-collections.d.ts +12 -0
  96. package/dist/commands/starred-collections.d.ts.map +1 -0
  97. package/dist/commands/starred-collections.js +310 -0
  98. package/dist/commands/subsystem-model.d.ts +11 -0
  99. package/dist/commands/subsystem-model.d.ts.map +1 -0
  100. package/dist/commands/subsystem-model.js +249 -0
  101. package/dist/commands/topic.d.ts +15 -0
  102. package/dist/commands/topic.d.ts.map +1 -0
  103. package/dist/commands/topic.js +683 -0
  104. package/dist/commands/tour.d.ts +23 -0
  105. package/dist/commands/tour.d.ts.map +1 -0
  106. package/dist/commands/tour.js +783 -0
  107. package/dist/commands/trace/flow.d.ts +6 -0
  108. package/dist/commands/trace/flow.d.ts.map +1 -0
  109. package/dist/commands/trace/flow.js +158 -0
  110. package/dist/commands/trace/index.d.ts +6 -0
  111. package/dist/commands/trace/index.d.ts.map +1 -0
  112. package/dist/commands/trace/index.js +20 -0
  113. package/dist/commands/trace/inspect.d.ts +6 -0
  114. package/dist/commands/trace/inspect.d.ts.map +1 -0
  115. package/dist/commands/trace/inspect.js +187 -0
  116. package/dist/commands/trace/list.d.ts +6 -0
  117. package/dist/commands/trace/list.d.ts.map +1 -0
  118. package/dist/commands/trace/list.js +145 -0
  119. package/dist/commands/trace/registrations.d.ts +6 -0
  120. package/dist/commands/trace/registrations.d.ts.map +1 -0
  121. package/dist/commands/trace/registrations.js +81 -0
  122. package/dist/commands/trace/validate.d.ts +6 -0
  123. package/dist/commands/trace/validate.d.ts.map +1 -0
  124. package/dist/commands/trace/validate.js +276 -0
  125. package/dist/commands/trail.d.ts +36 -0
  126. package/dist/commands/trail.d.ts.map +1 -0
  127. package/dist/commands/trail.js +622 -0
  128. package/dist/commands/validate.d.ts +12 -0
  129. package/dist/commands/validate.d.ts.map +1 -0
  130. package/dist/commands/validate.js +3334 -0
  131. package/dist/commands/workflow/implementation.d.ts +3 -0
  132. package/dist/commands/workflow/implementation.d.ts.map +1 -0
  133. package/dist/commands/workflow/implementation.js +51 -0
  134. package/dist/commands/workflow/index.d.ts +3 -0
  135. package/dist/commands/workflow/index.d.ts.map +1 -0
  136. package/dist/commands/workflow/index.js +19 -0
  137. package/dist/commands/workflow/inspect.d.ts +3 -0
  138. package/dist/commands/workflow/inspect.d.ts.map +1 -0
  139. package/dist/commands/workflow/inspect.js +123 -0
  140. package/dist/commands/workflow/list.d.ts +3 -0
  141. package/dist/commands/workflow/list.d.ts.map +1 -0
  142. package/dist/commands/workflow/list.js +95 -0
  143. package/dist/commands/workflow/render.d.ts +3 -0
  144. package/dist/commands/workflow/render.d.ts.map +1 -0
  145. package/dist/commands/workflow/render.js +88 -0
  146. package/dist/commands/workflow/test.d.ts +3 -0
  147. package/dist/commands/workflow/test.d.ts.map +1 -0
  148. package/dist/commands/workflow/test.js +141 -0
  149. package/dist/commands/workflow/utils.d.ts +49 -0
  150. package/dist/commands/workflow/utils.d.ts.map +1 -0
  151. package/dist/commands/workflow/utils.js +164 -0
  152. package/dist/commands/workflow/validate.d.ts +3 -0
  153. package/dist/commands/workflow/validate.d.ts.map +1 -0
  154. package/dist/commands/workflow/validate.js +344 -0
  155. package/dist/file-utils.d.ts +15 -0
  156. package/dist/file-utils.d.ts.map +1 -0
  157. package/dist/file-utils.js +29 -0
  158. package/dist/index.cjs +285718 -0
  159. package/dist/index.cjs.map +7 -0
  160. package/dist/index.d.ts +8 -0
  161. package/dist/index.d.ts.map +1 -0
  162. package/dist/index.js +81 -0
  163. package/dist/lib/bridge-ipc.d.ts +29 -0
  164. package/dist/lib/bridge-ipc.d.ts.map +1 -0
  165. package/dist/lib/bridge-ipc.js +128 -0
  166. package/dist/lib/github-user.d.ts +17 -0
  167. package/dist/lib/github-user.d.ts.map +1 -0
  168. package/dist/lib/github-user.js +53 -0
  169. package/dist/lib/open-url.d.ts +11 -0
  170. package/dist/lib/open-url.d.ts.map +1 -0
  171. package/dist/lib/open-url.js +45 -0
  172. package/dist/lib/subsystem-model-store.d.ts +74 -0
  173. package/dist/lib/subsystem-model-store.d.ts.map +1 -0
  174. package/dist/lib/subsystem-model-store.js +292 -0
  175. package/dist/lib/tour-cache.d.ts +26 -0
  176. package/dist/lib/tour-cache.d.ts.map +1 -0
  177. package/dist/lib/tour-cache.js +46 -0
  178. package/dist/lib/trail-cache.d.ts +40 -0
  179. package/dist/lib/trail-cache.d.ts.map +1 -0
  180. package/dist/lib/trail-cache.js +97 -0
  181. package/dist/lib/viewer-ipc.d.ts +44 -0
  182. package/dist/lib/viewer-ipc.d.ts.map +1 -0
  183. package/dist/lib/viewer-ipc.js +68 -0
  184. package/package.json +68 -0
@@ -0,0 +1,3334 @@
1
+ /**
2
+ * Validate command - Comprehensive validation of all Principal View artifacts
3
+ *
4
+ * This command validates:
5
+ * - Canvas files (.canvas, .otel.canvas)
6
+ * - Workflow templates (.workflow.json)
7
+ * - Execution artifacts (.otel.json)
8
+ * - Component library (library.yaml)
9
+ */
10
+ import { Command } from 'commander';
11
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
12
+ import { resolve, relative, dirname, basename } from 'node:path';
13
+ import { determineFileType } from '../file-utils.js';
14
+ import { readFile } from 'node:fs/promises';
15
+ import chalk from 'chalk';
16
+ import { globby } from 'globby';
17
+ import yaml from 'js-yaml';
18
+ import { createExecutionValidator, validateLibraryStructure, createDashboardValidator, } from '@principal-ai/subsystems-core';
19
+ import { CanvasDiscovery, LibraryDiscovery, createWorkflowValidator, EventRegistry, WorkflowValidator, ScopeEventsValidator, OtelEventPathsValidator, } from '@principal-ai/subsystems-core/node';
20
+ import { FilesystemService, NodeFileSystemAdapter as CompositionFsAdapter, } from '@principal-ai/codebase-composition/node';
21
+ import { NodeFileSystemAdapter } from '@principal-ai/repository-abstraction/node';
22
+ /**
23
+ * Cross-canvas path enforcement. For every `otel-event` node in an OTEL
24
+ * canvas, cross-references each entry in `otel.files` against the `paths`
25
+ * declared on the event's namespace in the matching events canvas.
26
+ * Enforcement is opt-in per namespace — namespaces without `paths` produce
27
+ * no violations. Mutates `results` in place to attach any violations.
28
+ */
29
+ function runOtelEventPathsValidation(results, repositoryPath) {
30
+ try {
31
+ const eventsCanvasInputs = [];
32
+ const otelCanvasInputs = [];
33
+ for (const r of results) {
34
+ if (r.fileType !== 'canvas')
35
+ continue;
36
+ const absPath = resolve(repositoryPath, r.file);
37
+ if (!existsSync(absPath))
38
+ continue;
39
+ let canvas;
40
+ try {
41
+ canvas = JSON.parse(readFileSync(absPath, 'utf-8'));
42
+ }
43
+ catch {
44
+ continue; // malformed — caught by per-file validator
45
+ }
46
+ if (r.file.endsWith('.events.canvas')) {
47
+ const rawScope = canvas.scope;
48
+ if (typeof rawScope !== 'string' || rawScope.length === 0)
49
+ continue;
50
+ eventsCanvasInputs.push({ canvas, canvasPath: r.file, scope: rawScope });
51
+ }
52
+ else if (r.file.endsWith('.otel.canvas')) {
53
+ otelCanvasInputs.push({ canvas, canvasPath: r.file });
54
+ }
55
+ }
56
+ if (eventsCanvasInputs.length === 0 || otelCanvasInputs.length === 0)
57
+ return;
58
+ const pathsResult = new OtelEventPathsValidator().validate({
59
+ eventsCanvases: eventsCanvasInputs,
60
+ otelCanvases: otelCanvasInputs,
61
+ });
62
+ for (const violation of pathsResult.violations) {
63
+ const target = results.find((r) => r.file === violation.file);
64
+ if (!target)
65
+ continue;
66
+ if (violation.severity === 'error')
67
+ target.isValid = false;
68
+ target.issues.push({
69
+ type: violation.severity === 'error' ? 'error' : 'warning',
70
+ message: violation.message,
71
+ path: violation.path,
72
+ suggestion: violation.suggestion,
73
+ });
74
+ }
75
+ }
76
+ catch {
77
+ // Non-fatal — any loading/parsing failure leaves the cross-canvas check
78
+ // quiet, letting the per-file validators surface their own errors.
79
+ }
80
+ }
81
+ /**
82
+ * Load the library.yaml file from the .principal-views directory
83
+ */
84
+ function loadLibrary(principalViewsDir) {
85
+ const libraryFiles = ['library.yaml', 'library.yml', 'library.json'];
86
+ for (const fileName of libraryFiles) {
87
+ const libraryPath = resolve(principalViewsDir, fileName);
88
+ if (existsSync(libraryPath)) {
89
+ try {
90
+ const content = readFileSync(libraryPath, 'utf8');
91
+ const library = fileName.endsWith('.json') ? JSON.parse(content) : yaml.load(content);
92
+ if (library && typeof library === 'object') {
93
+ return {
94
+ nodeComponents: library.nodeComponents ||
95
+ {},
96
+ edgeComponents: library.edgeComponents ||
97
+ {},
98
+ scopes: library.scopes ||
99
+ undefined,
100
+ raw: library,
101
+ path: libraryPath,
102
+ };
103
+ }
104
+ }
105
+ catch {
106
+ // Library exists but failed to parse - return empty to avoid false positives
107
+ return { nodeComponents: {}, edgeComponents: {}, scopes: undefined, raw: {}, path: libraryPath };
108
+ }
109
+ }
110
+ }
111
+ return null;
112
+ }
113
+ function rectanglesIntersect(a, b) {
114
+ return !(a.x + a.width <= b.x ||
115
+ b.x + b.width <= a.x ||
116
+ a.y + a.height <= b.y ||
117
+ b.y + b.height <= a.y);
118
+ }
119
+ /**
120
+ * Validate library.yaml file
121
+ *
122
+ * Uses core's validateLibraryStructure for schema validation,
123
+ * then adds CLI-specific checks (unknown fields, icons, recommended fields).
124
+ */
125
+ function validateLibrary(library) {
126
+ const issues = [];
127
+ const lib = library.raw;
128
+ // 1. Run core schema validation
129
+ const schemaResult = validateLibraryStructure(lib);
130
+ for (const error of schemaResult.errors) {
131
+ issues.push({
132
+ type: 'error',
133
+ message: error.message,
134
+ path: error.path,
135
+ suggestion: error.suggestion,
136
+ });
137
+ }
138
+ // 2. CLI-specific: Check for unknown fields (helpful for typos)
139
+ checkUnknownFields(lib, ALLOWED_LIBRARY_FIELDS.root, '', issues);
140
+ // CLI-specific: Validate nodeComponents for unknown fields and icon names
141
+ if (lib.nodeComponents && typeof lib.nodeComponents === 'object') {
142
+ for (const [compId, compDef] of Object.entries(lib.nodeComponents)) {
143
+ if (compDef && typeof compDef === 'object') {
144
+ const comp = compDef;
145
+ checkUnknownFields(comp, ALLOWED_LIBRARY_FIELDS.nodeComponent, `nodeComponents.${compId}`, issues);
146
+ // Validate icon name format (must be PascalCase for Lucide icons)
147
+ validateIconName(comp.icon, `nodeComponents.${compId}.icon`, issues);
148
+ // Check nested fields
149
+ if (comp.size && typeof comp.size === 'object') {
150
+ checkUnknownFields(comp.size, ALLOWED_LIBRARY_FIELDS.nodeComponentSize, `nodeComponents.${compId}.size`, issues);
151
+ }
152
+ if (comp.states && typeof comp.states === 'object') {
153
+ for (const [stateId, stateDef] of Object.entries(comp.states)) {
154
+ if (stateDef && typeof stateDef === 'object') {
155
+ checkUnknownFields(stateDef, ALLOWED_LIBRARY_FIELDS.nodeComponentState, `nodeComponents.${compId}.states.${stateId}`, issues);
156
+ // Validate state icon name format
157
+ const state = stateDef;
158
+ validateIconName(state.icon, `nodeComponents.${compId}.states.${stateId}.icon`, issues);
159
+ }
160
+ }
161
+ }
162
+ if (comp.dataSchema && typeof comp.dataSchema === 'object') {
163
+ for (const [fieldName, fieldDef] of Object.entries(comp.dataSchema)) {
164
+ if (fieldDef && typeof fieldDef === 'object') {
165
+ const field = fieldDef;
166
+ checkUnknownFields(field, ALLOWED_LIBRARY_FIELDS.nodeComponentDataSchemaField, `nodeComponents.${compId}.dataSchema.${fieldName}`, issues);
167
+ // Check required fields
168
+ if (field.description === undefined) {
169
+ issues.push({
170
+ type: 'error',
171
+ message: `Missing required field "description" in nodeComponents.${compId}.dataSchema.${fieldName}`,
172
+ });
173
+ }
174
+ if (field.placeholder === undefined) {
175
+ issues.push({
176
+ type: 'error',
177
+ message: `Missing required field "placeholder" in nodeComponents.${compId}.dataSchema.${fieldName}`,
178
+ });
179
+ }
180
+ }
181
+ }
182
+ }
183
+ if (comp.layout && typeof comp.layout === 'object') {
184
+ checkUnknownFields(comp.layout, ALLOWED_LIBRARY_FIELDS.nodeComponentLayout, `nodeComponents.${compId}.layout`, issues);
185
+ }
186
+ if (Array.isArray(comp.actions)) {
187
+ comp.actions.forEach((action, actionIndex) => {
188
+ if (action && typeof action === 'object') {
189
+ checkUnknownFields(action, ALLOWED_LIBRARY_FIELDS.nodeComponentAction, `nodeComponents.${compId}.actions[${actionIndex}]`, issues);
190
+ }
191
+ });
192
+ }
193
+ }
194
+ }
195
+ }
196
+ // CLI-specific: Validate edgeComponents for unknown fields
197
+ if (lib.edgeComponents && typeof lib.edgeComponents === 'object') {
198
+ for (const [compId, compDef] of Object.entries(lib.edgeComponents)) {
199
+ if (compDef && typeof compDef === 'object') {
200
+ const comp = compDef;
201
+ checkUnknownFields(comp, ALLOWED_LIBRARY_FIELDS.edgeComponent, `edgeComponents.${compId}`, issues);
202
+ // Check nested fields
203
+ if (comp.animation && typeof comp.animation === 'object') {
204
+ checkUnknownFields(comp.animation, ALLOWED_LIBRARY_FIELDS.edgeComponentAnimation, `edgeComponents.${compId}.animation`, issues);
205
+ }
206
+ if (comp.label && typeof comp.label === 'object') {
207
+ checkUnknownFields(comp.label, ALLOWED_LIBRARY_FIELDS.edgeComponentLabel, `edgeComponents.${compId}.label`, issues);
208
+ }
209
+ }
210
+ }
211
+ }
212
+ // CLI-specific: Validate connectionRules for unknown fields
213
+ if (Array.isArray(lib.connectionRules)) {
214
+ lib.connectionRules.forEach((rule, ruleIndex) => {
215
+ if (rule && typeof rule === 'object') {
216
+ const r = rule;
217
+ checkUnknownFields(r, ALLOWED_LIBRARY_FIELDS.connectionRule, `connectionRules[${ruleIndex}]`, issues);
218
+ if (r.constraints && typeof r.constraints === 'object') {
219
+ checkUnknownFields(r.constraints, ALLOWED_LIBRARY_FIELDS.connectionRuleConstraints, `connectionRules[${ruleIndex}].constraints`, issues);
220
+ }
221
+ }
222
+ });
223
+ }
224
+ // 3. CLI-specific: Recommended field warnings for resources
225
+ // (Schema validation for resources is handled by core)
226
+ if (lib.resources && typeof lib.resources === 'object' && !Array.isArray(lib.resources)) {
227
+ const resources = lib.resources;
228
+ const resourceKeys = Object.keys(resources);
229
+ // Warn if resources is defined but empty
230
+ if (resourceKeys.length === 0) {
231
+ issues.push({
232
+ type: 'warning',
233
+ message: 'resources section is empty. Consider documenting services that emit OTEL traces.',
234
+ path: 'resources',
235
+ suggestion: 'See: npx @principal-ai/principal-studio-cli formats library',
236
+ });
237
+ }
238
+ // Check for recommended (but not required) fields
239
+ for (const [serviceId, serviceDef] of Object.entries(resources)) {
240
+ if (serviceDef && typeof serviceDef === 'object') {
241
+ const service = serviceDef;
242
+ // Warn about missing recommended fields
243
+ if (!service['service.version']) {
244
+ issues.push({
245
+ type: 'warning',
246
+ message: `Missing recommended field "service.version" in resource "${serviceId}"`,
247
+ path: `resources.${serviceId}`,
248
+ suggestion: 'Consider adding service.version to track which version emitted traces',
249
+ });
250
+ }
251
+ if (!service['deployment.environment']) {
252
+ issues.push({
253
+ type: 'warning',
254
+ message: `Missing recommended field "deployment.environment" in resource "${serviceId}"`,
255
+ path: `resources.${serviceId}`,
256
+ suggestion: 'Consider adding deployment.environment (e.g., "development", "production")',
257
+ });
258
+ }
259
+ }
260
+ }
261
+ }
262
+ // 4. CLI-specific: Validate scopes section for unknown fields and icon names
263
+ // Check for deprecated scopes section (breaking change)
264
+ if (lib.scopes && typeof lib.scopes === 'object' && !Array.isArray(lib.scopes)) {
265
+ const scopeCount = Object.keys(lib.scopes).length;
266
+ issues.push({
267
+ type: 'error',
268
+ message: `The 'scopes' section in library.yaml is no longer supported (${scopeCount} scope(s) found)`,
269
+ path: `library.yaml:scopes`,
270
+ suggestion: `Run "pv migrate scopes-to-canvas" to migrate to .scopes.canvas format, then remove the scopes section from library.yaml. Scope visual metadata (colors, icons, descriptions) is now defined in .scopes.canvas files. The "owned-scopes" field in resources is still used for telemetry routing.`,
271
+ });
272
+ }
273
+ return issues;
274
+ }
275
+ /**
276
+ * Standard JSON Canvas node types that don't require pv metadata
277
+ */
278
+ const STANDARD_CANVAS_TYPES = ['text', 'group', 'file', 'link'];
279
+ /**
280
+ * OTEL semantic node types (new format)
281
+ * These replace the legacy "type: text" + "pv.nodeType" pattern
282
+ */
283
+ const OTEL_NODE_TYPES = [
284
+ 'otel-event',
285
+ 'otel-span-convention',
286
+ 'otel-scope',
287
+ 'otel-resource',
288
+ 'otel-boundary',
289
+ 'event-namespace',
290
+ ];
291
+ // ============================================================================
292
+ // Icon Validation
293
+ // ============================================================================
294
+ /**
295
+ * Common Lucide icons that are known to work
296
+ * This is not exhaustive - see https://lucide.dev/icons/ for the full list
297
+ */
298
+ const KNOWN_LUCIDE_ICONS = new Set([
299
+ // Common UI
300
+ 'Server',
301
+ 'Database',
302
+ 'Cloud',
303
+ 'Shield',
304
+ 'Lock',
305
+ 'Key',
306
+ 'Zap',
307
+ 'Cpu',
308
+ 'HardDrive',
309
+ 'Network',
310
+ 'Wifi',
311
+ 'WifiOff',
312
+ 'User',
313
+ 'Users',
314
+ 'UserCheck',
315
+ 'UserPlus',
316
+ 'UserMinus',
317
+ 'File',
318
+ 'Folder',
319
+ 'Package',
320
+ 'Box',
321
+ 'Archive',
322
+ 'GitBranch',
323
+ 'GitCommit',
324
+ 'GitMerge',
325
+ 'GitPullRequest',
326
+ 'Github',
327
+ 'Circle',
328
+ 'Square',
329
+ 'Triangle',
330
+ 'Pentagon',
331
+ 'Hexagon',
332
+ 'Octagon',
333
+ 'Settings',
334
+ 'Wrench',
335
+ 'Tool',
336
+ 'Hammer',
337
+ 'Cog',
338
+ 'Monitor',
339
+ 'Smartphone',
340
+ 'Tablet',
341
+ 'Laptop',
342
+ 'Mail',
343
+ 'Phone',
344
+ 'MessageSquare',
345
+ 'MessageCircle',
346
+ 'Calendar',
347
+ 'Clock',
348
+ 'Timer',
349
+ 'Watch',
350
+ 'Check',
351
+ 'X',
352
+ 'AlertCircle',
353
+ 'AlertTriangle',
354
+ 'Info',
355
+ 'Plus',
356
+ 'Minus',
357
+ 'Edit',
358
+ 'Trash',
359
+ 'Copy',
360
+ 'Search',
361
+ 'Filter',
362
+ 'Download',
363
+ 'Upload',
364
+ 'Home',
365
+ 'Star',
366
+ 'Heart',
367
+ 'Bookmark',
368
+ 'ChevronRight',
369
+ 'ChevronLeft',
370
+ 'ChevronUp',
371
+ 'ChevronDown',
372
+ 'ArrowRight',
373
+ 'ArrowLeft',
374
+ 'ArrowUp',
375
+ 'ArrowDown',
376
+ 'Activity',
377
+ 'BarChart',
378
+ 'PieChart',
379
+ 'TrendingUp',
380
+ 'TrendingDown',
381
+ 'FileText',
382
+ 'FileCode',
383
+ 'FileJson',
384
+ 'Image',
385
+ 'Video',
386
+ 'Link',
387
+ 'ExternalLink',
388
+ 'Unlink',
389
+ 'Eye',
390
+ 'EyeOff',
391
+ 'Play',
392
+ 'Pause',
393
+ 'Stop',
394
+ 'RefreshCw',
395
+ ]);
396
+ /**
397
+ * Convert kebab-case to PascalCase
398
+ * e.g., "file-text" -> "FileText", "alert-circle" -> "AlertCircle"
399
+ */
400
+ function kebabToPascalCase(str) {
401
+ return str
402
+ .split('-')
403
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
404
+ .join('');
405
+ }
406
+ /**
407
+ * Convert a dot/hyphen-separated ID to a human-readable label.
408
+ * E.g., "multi-canvas-panel.render" → "Multi Canvas Panel Render"
409
+ */
410
+ function idToHumanReadable(id) {
411
+ return id
412
+ .split(/[-.]/)
413
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
414
+ .join(' ');
415
+ }
416
+ /**
417
+ * Check if a string looks like kebab-case (has hyphens and lowercase)
418
+ */
419
+ function isKebabCase(str) {
420
+ return str.includes('-') && str === str.toLowerCase();
421
+ }
422
+ /**
423
+ * Validate an icon name and return issues if invalid
424
+ * Icons should be in PascalCase (e.g., "FileText", "Database", "AlertCircle")
425
+ */
426
+ function validateIconName(iconValue, path, issues) {
427
+ if (typeof iconValue !== 'string' || !iconValue) {
428
+ return; // No icon specified, that's fine
429
+ }
430
+ // Check if it looks like kebab-case
431
+ if (isKebabCase(iconValue)) {
432
+ const suggested = kebabToPascalCase(iconValue);
433
+ issues.push({
434
+ type: 'error',
435
+ message: `Invalid icon name "${iconValue}" - icons must be in PascalCase`,
436
+ path,
437
+ suggestion: `Use "${suggested}" instead of "${iconValue}". See https://lucide.dev/icons/ for valid icon names.`,
438
+ });
439
+ return;
440
+ }
441
+ // Check if first character is lowercase (common mistake)
442
+ if (iconValue[0] === iconValue[0].toLowerCase() && iconValue[0] !== iconValue[0].toUpperCase()) {
443
+ const suggested = iconValue.charAt(0).toUpperCase() + iconValue.slice(1);
444
+ issues.push({
445
+ type: 'error',
446
+ message: `Invalid icon name "${iconValue}" - icons must start with uppercase`,
447
+ path,
448
+ suggestion: `Use "${suggested}" instead of "${iconValue}". See https://lucide.dev/icons/ for valid icon names.`,
449
+ });
450
+ return;
451
+ }
452
+ // Warn if icon is not in our known list (but might still be valid)
453
+ if (!KNOWN_LUCIDE_ICONS.has(iconValue)) {
454
+ issues.push({
455
+ type: 'warning',
456
+ message: `Icon "${iconValue}" is not in the list of commonly used Lucide icons`,
457
+ path,
458
+ suggestion: `Verify that "${iconValue}" exists at https://lucide.dev/icons/. If it does, you can ignore this warning. Common icons: Server, Database, User, File, Settings, etc.`,
459
+ });
460
+ }
461
+ }
462
+ // ============================================================================
463
+ // Allowed Fields Definitions
464
+ // ============================================================================
465
+ /**
466
+ * Allowed fields for canvas validation
467
+ */
468
+ const ALLOWED_CANVAS_FIELDS = {
469
+ // Top-level canvas fields (all pv fields moved to root)
470
+ root: [
471
+ 'nodes',
472
+ 'edges',
473
+ 'name',
474
+ 'markdown',
475
+ 'description',
476
+ 'type', // For event-namespace canvases
477
+ 'nodeTypes',
478
+ 'edgeTypes',
479
+ 'pathConfig',
480
+ 'display',
481
+ 'scope',
482
+ 'audit',
483
+ 'pv', // deprecated - kept for error reporting
484
+ ],
485
+ // pv is fully deprecated - all fields moved to root
486
+ pv: [
487
+ 'name',
488
+ 'description',
489
+ 'markdown',
490
+ 'nodeTypes',
491
+ 'edgeTypes',
492
+ 'pathConfig',
493
+ 'display',
494
+ 'scope',
495
+ 'audit',
496
+ ],
497
+ pvPathConfig: [
498
+ 'projectRoot',
499
+ 'captureSource',
500
+ 'enableActionPatterns',
501
+ 'logLevel',
502
+ 'ignoreUnsourced',
503
+ ],
504
+ pvDisplay: ['layout', 'theme', 'animations'],
505
+ pvDisplayTheme: ['primary', 'success', 'warning', 'danger', 'info'],
506
+ pvDisplayAnimations: ['enabled', 'speed'],
507
+ pvNodeType: ['label', 'description', 'color', 'icon', 'shape'],
508
+ pvEdgeType: [
509
+ 'label',
510
+ 'description',
511
+ 'style',
512
+ 'color',
513
+ 'width',
514
+ 'directed',
515
+ 'animation',
516
+ 'labelConfig',
517
+ 'activatedBy',
518
+ ],
519
+ pvEdgeTypeAnimation: ['type', 'duration', 'color'],
520
+ pvEdgeTypeLabelConfig: ['field', 'position'],
521
+ // Base node fields from JSON Canvas spec
522
+ nodeBase: ['id', 'type', 'x', 'y', 'width', 'height', 'color', 'pv'],
523
+ // Type-specific node fields
524
+ nodeText: ['text'],
525
+ nodeFile: ['file', 'subpath'],
526
+ nodeLink: ['url'],
527
+ nodeGroup: ['label', 'background', 'backgroundStyle'],
528
+ // OTEL node type fields (new semantic format)
529
+ nodeOtel: [
530
+ 'label',
531
+ 'description',
532
+ 'icon',
533
+ 'shape',
534
+ 'fill',
535
+ 'otel',
536
+ 'event',
537
+ 'eventRef',
538
+ 'dataSchema',
539
+ 'boundary',
540
+ 'namespace',
541
+ 'paths',
542
+ ],
543
+ // Node pv extension
544
+ nodePv: [
545
+ 'nodeType',
546
+ 'name',
547
+ 'description',
548
+ 'otel',
549
+ 'event',
550
+ 'eventRef',
551
+ 'status',
552
+ 'shape',
553
+ 'icon',
554
+ 'fill',
555
+ 'stroke',
556
+ 'states',
557
+ 'origin',
558
+ 'references',
559
+ 'sources', // deprecated, use references
560
+ 'resourceMatch',
561
+ 'actions',
562
+ 'dataSchema',
563
+ 'layout',
564
+ 'boundary', // For boundary nodes representing external system interfaces
565
+ ],
566
+ nodePvOtel: ['kind', 'category', 'files', 'scope', 'spanPattern', 'spanKind'],
567
+ nodePvBoundary: ['direction', 'node'],
568
+ nodePvState: ['color', 'icon', 'label'],
569
+ nodePvAction: ['pattern', 'event', 'state', 'metadata', 'triggerEdges'],
570
+ nodePvDataSchemaField: ['type', 'required', 'displayInLabel', 'description', 'placeholder'],
571
+ nodePvLayout: ['layer', 'cluster'],
572
+ // Edge fields (edgeType moved from pv to top-level)
573
+ edge: [
574
+ 'id',
575
+ 'fromNode',
576
+ 'toNode',
577
+ 'fromSide',
578
+ 'toSide',
579
+ 'fromEnd',
580
+ 'toEnd',
581
+ 'color',
582
+ 'label',
583
+ 'edgeType', // moved from pv.edgeType
584
+ 'description', // for event-namespace canvases
585
+ 'pv', // deprecated
586
+ ],
587
+ // edgePv is deprecated - edgeType moved to top-level
588
+ edgePv: ['edgeType', 'style', 'width', 'animation', 'activatedBy'],
589
+ edgePvAnimation: ['type', 'duration', 'color'],
590
+ edgePvActivatedBy: ['action', 'animation', 'direction', 'duration'],
591
+ };
592
+ /**
593
+ * Allowed fields for library validation
594
+ */
595
+ const ALLOWED_LIBRARY_FIELDS = {
596
+ root: [
597
+ 'version',
598
+ 'name',
599
+ 'description',
600
+ 'nodeComponents',
601
+ 'edgeComponents',
602
+ 'connectionRules',
603
+ 'resources',
604
+ 'scopes',
605
+ 'eventSchemas',
606
+ ],
607
+ nodeComponent: [
608
+ 'description',
609
+ 'tags',
610
+ 'defaultLabel',
611
+ 'shape',
612
+ 'icon',
613
+ 'color',
614
+ 'size',
615
+ 'states',
616
+ 'status',
617
+ 'sources',
618
+ 'resourceMatch',
619
+ 'actions',
620
+ 'dataSchema',
621
+ 'layout',
622
+ ],
623
+ nodeComponentSize: ['width', 'height'],
624
+ nodeComponentState: ['color', 'icon', 'label'],
625
+ nodeComponentAction: ['pattern', 'event', 'state', 'metadata', 'triggerEdges'],
626
+ nodeComponentDataSchemaField: [
627
+ 'type',
628
+ 'required',
629
+ 'displayInLabel',
630
+ 'label',
631
+ 'displayInInfo',
632
+ 'description',
633
+ 'placeholder',
634
+ ],
635
+ nodeComponentLayout: ['layer', 'cluster'],
636
+ edgeComponent: [
637
+ 'description',
638
+ 'tags',
639
+ 'style',
640
+ 'color',
641
+ 'width',
642
+ 'directed',
643
+ 'animation',
644
+ 'label',
645
+ ],
646
+ edgeComponentAnimation: ['type', 'duration', 'color'],
647
+ edgeComponentLabel: ['field', 'position'],
648
+ connectionRule: ['from', 'to', 'via', 'constraints'],
649
+ connectionRuleConstraints: ['maxInstances', 'bidirectional', 'exclusive'],
650
+ scope: ['color', 'icon', 'description', 'external'],
651
+ };
652
+ /**
653
+ * Check for unknown fields and return validation issues
654
+ */
655
+ function checkUnknownFields(obj, allowedFields, path, issues) {
656
+ for (const field of Object.keys(obj)) {
657
+ if (!allowedFields.includes(field)) {
658
+ const suggestion = findSimilarField(field, allowedFields);
659
+ issues.push({
660
+ type: 'error',
661
+ message: `Unknown field "${field}"${path ? ` in ${path}` : ' at root level'}`,
662
+ path: path ? `${path}.${field}` : field,
663
+ suggestion: suggestion
664
+ ? `Did you mean "${suggestion}"? Allowed fields: ${allowedFields.join(', ')}`
665
+ : `Allowed fields: ${allowedFields.join(', ')}`,
666
+ });
667
+ }
668
+ }
669
+ }
670
+ /**
671
+ * Find a similar field name for suggestions
672
+ */
673
+ function findSimilarField(field, allowedFields) {
674
+ const fieldLower = field.toLowerCase();
675
+ for (const allowed of allowedFields) {
676
+ const allowedLower = allowed.toLowerCase();
677
+ if (fieldLower.includes(allowedLower) || allowedLower.includes(fieldLower)) {
678
+ return allowed;
679
+ }
680
+ // Check for small edit distance
681
+ if (Math.abs(field.length - allowed.length) <= 2) {
682
+ let differences = 0;
683
+ const minLen = Math.min(fieldLower.length, allowedLower.length);
684
+ for (let i = 0; i < minLen; i++) {
685
+ if (fieldLower[i] !== allowedLower[i])
686
+ differences++;
687
+ }
688
+ differences += Math.abs(field.length - allowed.length);
689
+ if (differences <= 2)
690
+ return allowed;
691
+ }
692
+ }
693
+ return null;
694
+ }
695
+ /**
696
+ * Check if a span pattern matches a workflow span pattern
697
+ * Supports wildcards: "task.*" matches "task.create", "task.edit", etc.
698
+ */
699
+ function spanPatternMatches(conventionPattern, workflowPattern) {
700
+ // Exact match
701
+ if (conventionPattern === workflowPattern) {
702
+ return true;
703
+ }
704
+ // Wildcard match: "task.*" matches "task.create"
705
+ if (conventionPattern.endsWith('.*')) {
706
+ const prefix = conventionPattern.slice(0, -1); // Remove the '*', keep the '.'
707
+ return workflowPattern.startsWith(prefix);
708
+ }
709
+ return false;
710
+ }
711
+ /**
712
+ * Find workflows that match a span convention pattern (with wildcard support)
713
+ */
714
+ function findMatchingWorkflows(conventionPattern, workflowSpanPatterns) {
715
+ const matches = [];
716
+ for (const [workflowPattern, workflowPath] of workflowSpanPatterns) {
717
+ if (spanPatternMatches(conventionPattern, workflowPattern)) {
718
+ matches.push({ pattern: workflowPattern, path: workflowPath });
719
+ }
720
+ }
721
+ return matches;
722
+ }
723
+ /**
724
+ * Load a workflow template file
725
+ */
726
+ function loadWorkflowTemplate(filePath) {
727
+ if (!existsSync(filePath)) {
728
+ return null;
729
+ }
730
+ try {
731
+ const content = readFileSync(filePath, 'utf8');
732
+ return JSON.parse(content);
733
+ }
734
+ catch {
735
+ return null;
736
+ }
737
+ }
738
+ /**
739
+ * Load an execution artifact file
740
+ */
741
+ function loadExecutionFile(filePath) {
742
+ if (!existsSync(filePath)) {
743
+ return null;
744
+ }
745
+ try {
746
+ const content = readFileSync(filePath, 'utf8');
747
+ return JSON.parse(content);
748
+ }
749
+ catch {
750
+ return null;
751
+ }
752
+ }
753
+ /**
754
+ * Find matching canvas file for an execution artifact
755
+ *
756
+ * Strategy:
757
+ * 1. Look for a co-located workflow file and use its canvas reference
758
+ * 2. Fall back to name-based matching for legacy patterns
759
+ */
760
+ function findMatchingCanvas(executionPath, repositoryPath) {
761
+ const fileName = basename(executionPath);
762
+ const dir = dirname(executionPath);
763
+ // Strategy 1: Look for co-located workflow file
764
+ // In the hierarchical structure, test traces are co-located with their workflow:
765
+ // .principal-views/storyboard/workflow-name/
766
+ // ├── workflow-name.workflow.json
767
+ // └── test-trace.otel.json
768
+ const workflowFiles = readdirSync(dir).filter((f) => f.endsWith('.workflow.json'));
769
+ if (workflowFiles.length > 0) {
770
+ // Use the first workflow found (typically there's only one per directory)
771
+ const workflowPath = resolve(dir, workflowFiles[0]);
772
+ try {
773
+ const workflowContent = readFileSync(workflowPath, 'utf8');
774
+ const workflow = JSON.parse(workflowContent);
775
+ if (workflow.canvas) {
776
+ // Canvas paths in workflows are relative to repository root
777
+ const canvasPath = resolve(repositoryPath, workflow.canvas);
778
+ if (existsSync(canvasPath)) {
779
+ return { canvasPath, workflowPath };
780
+ }
781
+ }
782
+ }
783
+ catch {
784
+ // Failed to parse workflow, fall through to name-based matching
785
+ }
786
+ }
787
+ // Strategy 2: Name-based matching for legacy patterns
788
+ // Extract basename by removing .otel.json extension
789
+ const canvasBasename = fileName.replace(/\.otel\.json$/, '');
790
+ // Determine canvas directory (go up from __executions__ to .principal-views)
791
+ let canvasDir;
792
+ if (dir.includes('.principal-views/__executions__')) {
793
+ canvasDir = dir.replace('/__executions__', '');
794
+ }
795
+ else if (dir.endsWith('__executions__')) {
796
+ canvasDir = resolve(dirname(dir), '.principal-views');
797
+ }
798
+ else {
799
+ // Fallback: look in .principal-views relative to repository root
800
+ canvasDir = resolve(repositoryPath, '.principal-views');
801
+ }
802
+ // Check for .otel.canvas first (preferred)
803
+ const otelCanvasPath = resolve(canvasDir, `${canvasBasename}.otel.canvas`);
804
+ if (existsSync(otelCanvasPath)) {
805
+ return { canvasPath: otelCanvasPath, workflowPath: null };
806
+ }
807
+ // Check for regular .canvas as fallback
808
+ const regularCanvasPath = resolve(canvasDir, `${canvasBasename}.canvas`);
809
+ if (existsSync(regularCanvasPath)) {
810
+ return { canvasPath: regularCanvasPath, workflowPath: null };
811
+ }
812
+ return {
813
+ canvasPath: null,
814
+ workflowPath: workflowFiles.length > 0 ? resolve(dir, workflowFiles[0]) : null,
815
+ };
816
+ }
817
+ /**
818
+ * Find workflow files that reference a given canvas
819
+ *
820
+ * Searches the canvas directory and parent storyboard directory for .workflow.json files
821
+ * that have a 'canvas' field referencing the given canvas path.
822
+ */
823
+ function findWorkflowsForCanvas(canvasPath, repositoryPath) {
824
+ const canvasDir = dirname(canvasPath);
825
+ const canvasRelPath = relative(repositoryPath, canvasPath);
826
+ const workflows = [];
827
+ // Helper to check if a workflow references this canvas
828
+ const checkWorkflowFile = (workflowPath) => {
829
+ try {
830
+ const content = readFileSync(workflowPath, 'utf8');
831
+ const workflow = JSON.parse(content);
832
+ if (workflow.canvas) {
833
+ // Normalize both paths for comparison
834
+ const workflowCanvasPath = resolve(repositoryPath, workflow.canvas);
835
+ const normalizedCanvasPath = resolve(repositoryPath, canvasRelPath);
836
+ return workflowCanvasPath === normalizedCanvasPath;
837
+ }
838
+ }
839
+ catch {
840
+ // Failed to parse, skip
841
+ }
842
+ return false;
843
+ };
844
+ // Check direct directory for workflow files
845
+ try {
846
+ const filesInDir = readdirSync(canvasDir);
847
+ for (const file of filesInDir) {
848
+ if (file.endsWith('.workflow.json')) {
849
+ const workflowPath = resolve(canvasDir, file);
850
+ if (checkWorkflowFile(workflowPath)) {
851
+ workflows.push(workflowPath);
852
+ }
853
+ }
854
+ }
855
+ }
856
+ catch {
857
+ // Directory not readable
858
+ }
859
+ // Also check subdirectories (storyboard pattern: canvas at storyboard root, workflows in subdirs)
860
+ try {
861
+ const entries = readdirSync(canvasDir, { withFileTypes: true });
862
+ for (const entry of entries) {
863
+ if (entry.isDirectory()) {
864
+ const subDir = resolve(canvasDir, entry.name);
865
+ try {
866
+ const subFiles = readdirSync(subDir);
867
+ for (const file of subFiles) {
868
+ if (file.endsWith('.workflow.json')) {
869
+ const workflowPath = resolve(subDir, file);
870
+ if (checkWorkflowFile(workflowPath)) {
871
+ workflows.push(workflowPath);
872
+ }
873
+ }
874
+ }
875
+ }
876
+ catch {
877
+ // Subdirectory not readable
878
+ }
879
+ }
880
+ }
881
+ }
882
+ catch {
883
+ // Directory not readable
884
+ }
885
+ return workflows;
886
+ }
887
+ /**
888
+ * Check if a canvas has OTEL-related features
889
+ * Returns true if the canvas contains any of:
890
+ * 1. Nodes with pv.otel extension (kind, category)
891
+ * 2. Event schema (pv.event or pv.eventRef with validation)
892
+ * 3. Canvas scope/audit config (OTEL log routing)
893
+ * 4. Resource matching for OTEL logs
894
+ */
895
+ function hasOtelFeatures(canvas) {
896
+ if (!canvas || typeof canvas !== 'object') {
897
+ return false;
898
+ }
899
+ const c = canvas;
900
+ // Check for canvas-level scope or audit config (top-level)
901
+ if (c.scope !== undefined || c.audit !== undefined) {
902
+ return true;
903
+ }
904
+ // Check nodes for OTEL features
905
+ if (Array.isArray(c.nodes)) {
906
+ for (const node of c.nodes) {
907
+ if (node && typeof node === 'object') {
908
+ const n = node;
909
+ // Check for OTEL semantic node types (e.g., otel-event, otel-span-convention)
910
+ if (typeof n.type === 'string' &&
911
+ OTEL_NODE_TYPES.includes(n.type)) {
912
+ return true;
913
+ }
914
+ if (n.pv && typeof n.pv === 'object') {
915
+ const nodePv = n.pv;
916
+ // Check for pv.otel extension
917
+ if (nodePv.otel !== undefined) {
918
+ return true;
919
+ }
920
+ // Check for event schema (pv.event or pv.eventRef)
921
+ if (nodePv.event !== undefined || nodePv.eventRef !== undefined) {
922
+ return true;
923
+ }
924
+ // Check for resourceMatch (OTEL log routing)
925
+ if (nodePv.resourceMatch !== undefined) {
926
+ return true;
927
+ }
928
+ }
929
+ }
930
+ }
931
+ }
932
+ return false;
933
+ }
934
+ /**
935
+ * Validate an ExtendedCanvas object with strict validation
936
+ *
937
+ * Strict validation ensures:
938
+ * - All required fields are present
939
+ * - Custom node types have proper pv metadata
940
+ * - Edge types reference defined types in pv.edgeTypes or library.edgeComponents
941
+ * - Node types reference defined types in pv.nodeTypes or library.nodeComponents
942
+ * - Canvas has pv extension with name and version
943
+ * - OTEL nodes have source file references and the files exist
944
+ * - Scopes used in OTEL canvases are documented in scopes canvas
945
+ */
946
+ function validateCanvas(canvas, filePath, library, repositoryPath, scopesCanvas) {
947
+ const issues = [];
948
+ if (!canvas || typeof canvas !== 'object') {
949
+ issues.push({ type: 'error', message: 'Canvas must be an object' });
950
+ return issues;
951
+ }
952
+ const c = canvas;
953
+ // Check unknown fields at canvas root level
954
+ checkUnknownFields(c, ALLOWED_CANVAS_FIELDS.root, '', issues);
955
+ // Collect library-defined types
956
+ const libraryNodeTypes = library ? Object.keys(library.nodeComponents) : [];
957
+ const libraryEdgeTypes = library ? Object.keys(library.edgeComponents) : [];
958
+ // Validate top-level canvas fields (name is required)
959
+ let canvasEdgeTypes = [];
960
+ let canvasNodeTypes = [];
961
+ // Check for deprecated pv field at canvas level
962
+ if (c.pv !== undefined) {
963
+ issues.push({
964
+ type: 'error',
965
+ message: 'The "pv" field is fully deprecated. All fields have been moved to top-level.',
966
+ path: 'pv',
967
+ suggestion: 'Move all pv fields to top-level: name, markdown, description, nodeTypes, edgeTypes, display, pathConfig, scope, audit. Then remove the pv field entirely.',
968
+ });
969
+ }
970
+ // Require top-level name field
971
+ if (typeof c.name !== 'string' || !c.name) {
972
+ issues.push({
973
+ type: 'error',
974
+ message: 'Canvas must have a top-level "name" field',
975
+ path: 'name',
976
+ suggestion: 'Add: "name": "My Graph"',
977
+ });
978
+ }
979
+ // Validate top-level pathConfig if present
980
+ if (c.pathConfig && typeof c.pathConfig === 'object') {
981
+ checkUnknownFields(c.pathConfig, ALLOWED_CANVAS_FIELDS.pvPathConfig, 'pathConfig', issues);
982
+ }
983
+ // Validate top-level display if present
984
+ if (c.display && typeof c.display === 'object') {
985
+ const display = c.display;
986
+ checkUnknownFields(display, ALLOWED_CANVAS_FIELDS.pvDisplay, 'display', issues);
987
+ if (display.theme && typeof display.theme === 'object') {
988
+ checkUnknownFields(display.theme, ALLOWED_CANVAS_FIELDS.pvDisplayTheme, 'display.theme', issues);
989
+ }
990
+ if (display.animations && typeof display.animations === 'object') {
991
+ checkUnknownFields(display.animations, ALLOWED_CANVAS_FIELDS.pvDisplayAnimations, 'display.animations', issues);
992
+ }
993
+ }
994
+ // Collect and validate defined node types (top-level)
995
+ if (c.nodeTypes && typeof c.nodeTypes === 'object') {
996
+ canvasNodeTypes = Object.keys(c.nodeTypes);
997
+ for (const [typeId, typeDef] of Object.entries(c.nodeTypes)) {
998
+ if (typeDef && typeof typeDef === 'object') {
999
+ checkUnknownFields(typeDef, ALLOWED_CANVAS_FIELDS.pvNodeType, `nodeTypes.${typeId}`, issues);
1000
+ // Validate icon name format
1001
+ const nodeType = typeDef;
1002
+ validateIconName(nodeType.icon, `nodeTypes.${typeId}.icon`, issues);
1003
+ }
1004
+ }
1005
+ }
1006
+ // Collect and validate defined edge types (top-level)
1007
+ if (c.edgeTypes && typeof c.edgeTypes === 'object') {
1008
+ canvasEdgeTypes = Object.keys(c.edgeTypes);
1009
+ for (const [typeId, typeDef] of Object.entries(c.edgeTypes)) {
1010
+ if (typeDef && typeof typeDef === 'object') {
1011
+ const edgeTypeDef = typeDef;
1012
+ checkUnknownFields(edgeTypeDef, ALLOWED_CANVAS_FIELDS.pvEdgeType, `edgeTypes.${typeId}`, issues);
1013
+ if (edgeTypeDef.animation && typeof edgeTypeDef.animation === 'object') {
1014
+ checkUnknownFields(edgeTypeDef.animation, ALLOWED_CANVAS_FIELDS.pvEdgeTypeAnimation, `edgeTypes.${typeId}.animation`, issues);
1015
+ }
1016
+ if (edgeTypeDef.labelConfig && typeof edgeTypeDef.labelConfig === 'object') {
1017
+ checkUnknownFields(edgeTypeDef.labelConfig, ALLOWED_CANVAS_FIELDS.pvEdgeTypeLabelConfig, `edgeTypes.${typeId}.labelConfig`, issues);
1018
+ }
1019
+ }
1020
+ }
1021
+ }
1022
+ // Built-in node types that are always valid
1023
+ const builtInNodeTypes = ['scope', 'boundary'];
1024
+ // Combined types from canvas + library + built-ins
1025
+ const allDefinedNodeTypes = [
1026
+ ...new Set([...builtInNodeTypes, ...canvasNodeTypes, ...libraryNodeTypes]),
1027
+ ];
1028
+ const allDefinedEdgeTypes = [...new Set([...canvasEdgeTypes, ...libraryEdgeTypes])];
1029
+ // Check nodes
1030
+ // Track if any nodes have status: 'implemented' for library resources validation
1031
+ let hasImplementedNodes = false;
1032
+ if (!Array.isArray(c.nodes)) {
1033
+ issues.push({ type: 'error', message: 'Canvas must have a "nodes" array' });
1034
+ }
1035
+ else if (c.nodes.length === 0) {
1036
+ issues.push({ type: 'error', message: 'Canvas must have at least one node' });
1037
+ }
1038
+ else {
1039
+ c.nodes.forEach((node, index) => {
1040
+ if (!node || typeof node !== 'object') {
1041
+ issues.push({
1042
+ type: 'error',
1043
+ message: `Node at index ${index} must be an object`,
1044
+ path: `nodes[${index}]`,
1045
+ });
1046
+ return;
1047
+ }
1048
+ const n = node;
1049
+ const nodePath = `nodes[${index}]`;
1050
+ const nodeLabel = n.id || index;
1051
+ // Check unknown fields on node based on type
1052
+ const nodeType = n.type;
1053
+ let allowedNodeFields = [...ALLOWED_CANVAS_FIELDS.nodeBase];
1054
+ if (nodeType === 'text') {
1055
+ allowedNodeFields = [...allowedNodeFields, ...ALLOWED_CANVAS_FIELDS.nodeText];
1056
+ }
1057
+ else if (nodeType === 'file') {
1058
+ allowedNodeFields = [...allowedNodeFields, ...ALLOWED_CANVAS_FIELDS.nodeFile];
1059
+ }
1060
+ else if (nodeType === 'link') {
1061
+ allowedNodeFields = [...allowedNodeFields, ...ALLOWED_CANVAS_FIELDS.nodeLink];
1062
+ }
1063
+ else if (nodeType === 'group') {
1064
+ allowedNodeFields = [...allowedNodeFields, ...ALLOWED_CANVAS_FIELDS.nodeGroup];
1065
+ }
1066
+ else if (OTEL_NODE_TYPES.includes(nodeType)) {
1067
+ // OTEL node types have their own set of fields
1068
+ allowedNodeFields = [...allowedNodeFields, ...ALLOWED_CANVAS_FIELDS.nodeOtel];
1069
+ }
1070
+ // Custom types can have any base fields
1071
+ checkUnknownFields(n, allowedNodeFields, nodePath, issues);
1072
+ if (typeof n.id !== 'string' || !n.id) {
1073
+ issues.push({
1074
+ type: 'error',
1075
+ message: `Node at index ${index} must have a string "id"`,
1076
+ path: `${nodePath}.id`,
1077
+ });
1078
+ }
1079
+ if (typeof n.type !== 'string') {
1080
+ issues.push({
1081
+ type: 'error',
1082
+ message: `Node "${nodeLabel}" must have a string "type"`,
1083
+ path: `${nodePath}.type`,
1084
+ });
1085
+ }
1086
+ if (typeof n.x !== 'number') {
1087
+ issues.push({
1088
+ type: 'error',
1089
+ message: `Node "${nodeLabel}" must have a numeric "x" position`,
1090
+ path: `${nodePath}.x`,
1091
+ });
1092
+ }
1093
+ if (typeof n.y !== 'number') {
1094
+ issues.push({
1095
+ type: 'error',
1096
+ message: `Node "${nodeLabel}" must have a numeric "y" position`,
1097
+ path: `${nodePath}.y`,
1098
+ });
1099
+ }
1100
+ // Width and height are now REQUIRED (was warning)
1101
+ if (typeof n.width !== 'number') {
1102
+ issues.push({
1103
+ type: 'error',
1104
+ message: `Node "${nodeLabel}" must have a numeric "width"`,
1105
+ path: `${nodePath}.width`,
1106
+ });
1107
+ }
1108
+ if (typeof n.height !== 'number') {
1109
+ issues.push({
1110
+ type: 'error',
1111
+ message: `Node "${nodeLabel}" must have a numeric "height"`,
1112
+ path: `${nodePath}.height`,
1113
+ });
1114
+ }
1115
+ // Validate color requirements based on canvas type
1116
+ // - .otel.canvas (events): colors NOT required - derived from scope (border) + span (fill) at render time
1117
+ // - .spans.canvas: colors required on span convention nodes (used as fill color for events in that span)
1118
+ // - Other canvases: colors required either directly or via nodeType
1119
+ const isOtelCanvasForColor = filePath.endsWith('.otel.canvas');
1120
+ const isSpansCanvasForColor = filePath.endsWith('.spans.canvas');
1121
+ if (!isOtelCanvasForColor) {
1122
+ const hasDirectColor = typeof n.color === 'string' && n.color;
1123
+ let hasNodeTypeColor = false;
1124
+ if (!hasDirectColor && n.pv && typeof n.pv === 'object') {
1125
+ const nodePv = n.pv;
1126
+ const nodeTypeName = nodePv.nodeType;
1127
+ if (typeof nodeTypeName === 'string' && nodeTypeName) {
1128
+ // Check if nodeType has a color defined in canvas nodeTypes
1129
+ if (c.nodeTypes && typeof c.nodeTypes === 'object') {
1130
+ const nodeTypes = c.nodeTypes;
1131
+ const nodeTypeDef = nodeTypes[nodeTypeName];
1132
+ if (nodeTypeDef && typeof nodeTypeDef === 'object') {
1133
+ const typeDef = nodeTypeDef;
1134
+ if (typeof typeDef.color === 'string' && typeDef.color) {
1135
+ hasNodeTypeColor = true;
1136
+ }
1137
+ }
1138
+ }
1139
+ // Check if nodeType has a color defined in library.nodeComponents
1140
+ if (!hasNodeTypeColor && library) {
1141
+ const nodeComponent = library.nodeComponents[nodeTypeName];
1142
+ if (nodeComponent && typeof nodeComponent === 'object') {
1143
+ const component = nodeComponent;
1144
+ if (typeof component.color === 'string' && component.color) {
1145
+ hasNodeTypeColor = true;
1146
+ }
1147
+ }
1148
+ }
1149
+ }
1150
+ }
1151
+ // For spans.canvas, require direct color on the node (not via nodeType)
1152
+ // This color will be used as the fill color for events emitted within this span
1153
+ if (isSpansCanvasForColor) {
1154
+ if (!hasDirectColor) {
1155
+ issues.push({
1156
+ type: 'error',
1157
+ message: `Span convention "${nodeLabel}" must have a color`,
1158
+ path: `${nodePath}.color`,
1159
+ suggestion: 'Add a "color" field (e.g., "color": "#3B82F6"). This color is used as the fill color for events emitted within this span.',
1160
+ });
1161
+ }
1162
+ }
1163
+ else if (!hasDirectColor && !hasNodeTypeColor) {
1164
+ issues.push({
1165
+ type: 'error',
1166
+ message: `Node "${nodeLabel}" must have a color`,
1167
+ path: `${nodePath}`,
1168
+ suggestion: 'Add a "color" field (e.g., "color": "#64748B") or use a pv.nodeType that defines a color',
1169
+ });
1170
+ }
1171
+ }
1172
+ // Validate required fields for standard canvas types
1173
+ if (nodeType === 'text' && (typeof n.text !== 'string' || !n.text)) {
1174
+ issues.push({
1175
+ type: 'error',
1176
+ message: `Node "${nodeLabel}" has type "text" but is missing required "text" field`,
1177
+ path: `${nodePath}.text`,
1178
+ suggestion: 'Add a "text" field with markdown content, or change the node type',
1179
+ });
1180
+ }
1181
+ if (nodeType === 'file' && (typeof n.file !== 'string' || !n.file)) {
1182
+ issues.push({
1183
+ type: 'error',
1184
+ message: `Node "${nodeLabel}" has type "file" but is missing required "file" field`,
1185
+ path: `${nodePath}.file`,
1186
+ suggestion: 'Add a "file" field with a file path, or change the node type',
1187
+ });
1188
+ }
1189
+ if (nodeType === 'link' && (typeof n.url !== 'string' || !n.url)) {
1190
+ issues.push({
1191
+ type: 'error',
1192
+ message: `Node "${nodeLabel}" has type "link" but is missing required "url" field`,
1193
+ path: `${nodePath}.url`,
1194
+ suggestion: 'Add a "url" field with a URL, or change the node type',
1195
+ });
1196
+ }
1197
+ // Validate node type - must be a standard JSON Canvas type or OTEL type
1198
+ const isStandardType = STANDARD_CANVAS_TYPES.includes(nodeType);
1199
+ const isOtelType = OTEL_NODE_TYPES.includes(nodeType);
1200
+ if (!isStandardType && !isOtelType) {
1201
+ issues.push({
1202
+ type: 'error',
1203
+ message: `Node "${n.id || index}" uses invalid type "${nodeType}"`,
1204
+ path: `nodes[${index}].type`,
1205
+ suggestion: `Use a standard JSON Canvas type (${STANDARD_CANVAS_TYPES.join(', ')}) or OTEL type (${OTEL_NODE_TYPES.join(', ')}). For custom shapes, use type: "text" with pv.shape: "${nodeType}"`,
1206
+ });
1207
+ }
1208
+ // Validate OTEL node types have required fields
1209
+ if (isOtelType) {
1210
+ // OTEL nodes must have a label (except event-namespace which uses namespace.name)
1211
+ if (nodeType !== 'event-namespace') {
1212
+ if (typeof n.label !== 'string' || !n.label) {
1213
+ issues.push({
1214
+ type: 'error',
1215
+ message: `OTEL node "${n.id || index}" must have a "label" field`,
1216
+ path: `${nodePath}.label`,
1217
+ suggestion: 'Add a human-readable label for display (e.g., "User Login", "Process Payment")',
1218
+ });
1219
+ }
1220
+ else if (n.label === n.id) {
1221
+ // Label should not be the same as ID
1222
+ const suggestedLabel = idToHumanReadable(n.id);
1223
+ issues.push({
1224
+ type: 'error',
1225
+ message: `OTEL node "${n.id}" has label identical to its ID`,
1226
+ path: `${nodePath}.label`,
1227
+ suggestion: `Labels must be human-readable, not technical identifiers. Try: "${suggestedLabel}"`,
1228
+ });
1229
+ }
1230
+ else if (typeof n.label === 'string' && /[*_#`\[\]]/.test(n.label)) {
1231
+ // Label should not contain markdown formatting
1232
+ const cleanLabel = n.label
1233
+ .replace(/\*\*/g, '')
1234
+ .replace(/\*/g, '')
1235
+ .replace(/_/g, ' ')
1236
+ .replace(/^#+\s*/, '')
1237
+ .replace(/`/g, '')
1238
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
1239
+ .trim();
1240
+ issues.push({
1241
+ type: 'error',
1242
+ message: `OTEL node "${n.id}" has markdown formatting in label`,
1243
+ path: `${nodePath}.label`,
1244
+ suggestion: `Labels should be plain text without markdown. Try: "${cleanLabel}"`,
1245
+ });
1246
+ }
1247
+ }
1248
+ // otel-event nodes must have event or eventRef
1249
+ if (nodeType === 'otel-event') {
1250
+ const hasEvent = n.event && typeof n.event === 'object';
1251
+ const hasEventRef = typeof n.eventRef === 'string' && n.eventRef;
1252
+ if (!hasEvent && !hasEventRef) {
1253
+ issues.push({
1254
+ type: 'error',
1255
+ message: `OTEL event node "${n.id}" is missing "event" or "eventRef" field`,
1256
+ path: `${nodePath}`,
1257
+ suggestion: 'Add an event schema: event: { name: "your.event.name", attributes: {...} } or reference a library event: eventRef: "library.event.name". If migrating from legacy format, run: npx @principal-ai/principal-studio-cli migrate-nodes',
1258
+ });
1259
+ }
1260
+ // otel-event nodes MUST have otel.scope
1261
+ const otel = n.otel;
1262
+ const scope = otel?.scope;
1263
+ if (!scope) {
1264
+ issues.push({
1265
+ type: 'error',
1266
+ message: `OTEL event node "${n.id}" is missing required "otel.scope" field`,
1267
+ path: `${nodePath}.otel.scope`,
1268
+ suggestion: 'Add otel.scope to specify which instrumentation library emits this event. Example: otel: { scope: "my-service" }. Document scopes in your .scopes.canvas file with otel-scope nodes.',
1269
+ });
1270
+ }
1271
+ }
1272
+ // otel-span-convention nodes must have spanPattern in otel
1273
+ if (nodeType === 'otel-span-convention') {
1274
+ const otel = n.otel;
1275
+ if (!otel?.spanPattern) {
1276
+ issues.push({
1277
+ type: 'error',
1278
+ message: `Span convention "${n.id}" is missing "otel.spanPattern"`,
1279
+ path: `${nodePath}.otel.spanPattern`,
1280
+ suggestion: 'Add the span pattern: otel: { spanPattern: "your.span.pattern" }. This pattern is used to match workflows and color events within this span.',
1281
+ });
1282
+ }
1283
+ }
1284
+ // Semantic OTEL nodes must have valid otel.status (except event-namespace)
1285
+ if (OTEL_NODE_TYPES.includes(nodeType) && nodeType !== 'event-namespace') {
1286
+ const otel = n.otel;
1287
+ const validStatuses = ['draft', 'approved', 'implemented'];
1288
+ if (otel?.status === undefined) {
1289
+ issues.push({
1290
+ type: 'error',
1291
+ message: `OTEL node "${n.id}" is missing required "otel.status" field`,
1292
+ path: `${nodePath}.otel.status`,
1293
+ suggestion: 'Add implementation status: "status": "draft" | "approved" | "implemented". Use "draft" for design, "approved" for finalized design, "implemented" for code with instrumentation.',
1294
+ });
1295
+ }
1296
+ else if (!validStatuses.includes(otel.status)) {
1297
+ issues.push({
1298
+ type: 'error',
1299
+ message: `OTEL node "${n.id}" has invalid status value "${otel.status}"`,
1300
+ path: `${nodePath}.otel.status`,
1301
+ suggestion: `Valid values: ${validStatuses.join(', ')}`,
1302
+ });
1303
+ }
1304
+ }
1305
+ }
1306
+ // Validate node pv extension fields
1307
+ if (n.pv && typeof n.pv === 'object') {
1308
+ const nodePv = n.pv;
1309
+ // Check unknown fields in node pv extension
1310
+ checkUnknownFields(nodePv, ALLOWED_CANVAS_FIELDS.nodePv, `${nodePath}.pv`, issues);
1311
+ // Validate icon name format (must be PascalCase for Lucide icons)
1312
+ validateIconName(nodePv.icon, `${nodePath}.pv.icon`, issues);
1313
+ // Check nested pv fields
1314
+ if (nodePv.states && typeof nodePv.states === 'object') {
1315
+ for (const [stateId, stateDef] of Object.entries(nodePv.states)) {
1316
+ if (stateDef && typeof stateDef === 'object') {
1317
+ checkUnknownFields(stateDef, ALLOWED_CANVAS_FIELDS.nodePvState, `${nodePath}.pv.states.${stateId}`, issues);
1318
+ // Validate state icon name format
1319
+ const state = stateDef;
1320
+ validateIconName(state.icon, `${nodePath}.pv.states.${stateId}.icon`, issues);
1321
+ }
1322
+ }
1323
+ }
1324
+ if (nodePv.dataSchema && typeof nodePv.dataSchema === 'object') {
1325
+ for (const [fieldName, fieldDef] of Object.entries(nodePv.dataSchema)) {
1326
+ if (fieldDef && typeof fieldDef === 'object') {
1327
+ const field = fieldDef;
1328
+ checkUnknownFields(field, ALLOWED_CANVAS_FIELDS.nodePvDataSchemaField, `${nodePath}.pv.dataSchema.${fieldName}`, issues);
1329
+ // Check required fields
1330
+ if (field.description === undefined) {
1331
+ issues.push({
1332
+ type: 'error',
1333
+ message: `Missing required field "description" in ${nodePath}.pv.dataSchema.${fieldName}`,
1334
+ });
1335
+ }
1336
+ if (field.placeholder === undefined) {
1337
+ issues.push({
1338
+ type: 'error',
1339
+ message: `Missing required field "placeholder" in ${nodePath}.pv.dataSchema.${fieldName}`,
1340
+ });
1341
+ }
1342
+ }
1343
+ }
1344
+ }
1345
+ if (nodePv.layout && typeof nodePv.layout === 'object') {
1346
+ checkUnknownFields(nodePv.layout, ALLOWED_CANVAS_FIELDS.nodePvLayout, `${nodePath}.pv.layout`, issues);
1347
+ }
1348
+ if (nodePv.otel && typeof nodePv.otel === 'object') {
1349
+ checkUnknownFields(nodePv.otel, ALLOWED_CANVAS_FIELDS.nodePvOtel, `${nodePath}.pv.otel`, issues);
1350
+ }
1351
+ // Validate boundary extension if present
1352
+ if (nodePv.boundary && typeof nodePv.boundary === 'object') {
1353
+ checkUnknownFields(nodePv.boundary, ALLOWED_CANVAS_FIELDS.nodePvBoundary, `${nodePath}.pv.boundary`, issues);
1354
+ // Validate boundary direction (required)
1355
+ const boundary = nodePv.boundary;
1356
+ const validDirections = ['outbound', 'inbound'];
1357
+ if (!boundary.direction || !validDirections.includes(boundary.direction)) {
1358
+ issues.push({
1359
+ type: 'error',
1360
+ message: `Node "${nodeLabel}" has invalid or missing boundary.direction`,
1361
+ path: `${nodePath}.pv.boundary.direction`,
1362
+ suggestion: 'Use "outbound" for calls to external systems, "inbound" for callbacks from external systems',
1363
+ });
1364
+ }
1365
+ // Validate boundary node query (required)
1366
+ if (!boundary.node || typeof boundary.node !== 'object') {
1367
+ issues.push({
1368
+ type: 'error',
1369
+ message: `Node "${nodeLabel}" has boundary but is missing required "node" field`,
1370
+ path: `${nodePath}.pv.boundary.node`,
1371
+ suggestion: 'Add node query for resolution, e.g.: "node": { "pv.event.name": "host.event-name" }',
1372
+ });
1373
+ }
1374
+ }
1375
+ // Check for conflict: node cannot have both event and eventRef
1376
+ if (nodePv.event !== undefined && nodePv.eventRef !== undefined) {
1377
+ issues.push({
1378
+ type: 'error',
1379
+ message: `Node "${nodeLabel}" has both "pv.event" and "pv.eventRef" - only one is allowed`,
1380
+ path: `${nodePath}.pv`,
1381
+ suggestion: 'Use "event" for inline event definition, or "eventRef" to reference a library event schema. Remove one of them.',
1382
+ });
1383
+ }
1384
+ // Check for legacy string format: event should be object or use eventRef instead
1385
+ if (nodePv.event !== undefined && typeof nodePv.event === 'string') {
1386
+ issues.push({
1387
+ type: 'error',
1388
+ message: `Node "${nodeLabel}" uses deprecated string format for "pv.event": "${nodePv.event}"`,
1389
+ path: `${nodePath}.pv.event`,
1390
+ suggestion: `Migration options:\n 1. Use "eventRef": "${nodePv.event}" to reference a library event (define in library.yaml under eventSchemas)\n 2. Use "event": { "name": "${nodePv.event}", "attributes": {} } for inline event definition`,
1391
+ });
1392
+ }
1393
+ // Validate that display name doesn't match event name (poor UX)
1394
+ // Check both inline event definitions (pv.event.name) and library references (pv.eventRef)
1395
+ if (nodeType === 'text' && typeof n.text === 'string') {
1396
+ let eventName = null;
1397
+ // Check inline event definition
1398
+ if (nodePv.event && typeof nodePv.event === 'object') {
1399
+ const eventObj = nodePv.event;
1400
+ if (eventObj.name) {
1401
+ eventName = eventObj.name;
1402
+ }
1403
+ }
1404
+ // Check library event reference
1405
+ else if (nodePv.eventRef && typeof nodePv.eventRef === 'string') {
1406
+ eventName = nodePv.eventRef;
1407
+ }
1408
+ if (eventName) {
1409
+ // Extract display name the same way CanvasConverter does
1410
+ const displayName = n.text.split('\n')[0].replace(/^#+ /, '').substring(0, 50);
1411
+ if (displayName === eventName) {
1412
+ issues.push({
1413
+ type: 'error',
1414
+ message: `Node "${nodeLabel}" has display name identical to event name "${eventName}"`,
1415
+ path: `${nodePath}.text`,
1416
+ suggestion: `The first line of the text field becomes the node's display name and should be human-readable, not a technical event name.
1417
+
1418
+ Current:
1419
+ text: "# ${eventName}\\n..."
1420
+ ${nodePv.eventRef ? `eventRef: "${eventName}"` : `event.name: "${eventName}"`}
1421
+
1422
+ Suggested:
1423
+ text: "# [Human-Readable Title]\\n..."
1424
+ ${nodePv.eventRef ? `eventRef: "${eventName}"` : `event.name: "${eventName}"`}
1425
+
1426
+ Example:
1427
+ text: "# Registration Started\\nVersion registration request received"
1428
+ ${nodePv.eventRef
1429
+ ? `eventRef: "version.registration.started"`
1430
+ : `event.name: "version.registration.started"`}
1431
+
1432
+ The display name will be shown large on the node, and the event name will appear below it in smaller monospace font.`,
1433
+ });
1434
+ }
1435
+ }
1436
+ }
1437
+ // Validate origin and references
1438
+ const origin = nodePv.origin || 'internal';
1439
+ const isExternal = origin === 'external';
1440
+ // Validate origin value
1441
+ if (nodePv.origin !== undefined &&
1442
+ nodePv.origin !== 'internal' &&
1443
+ nodePv.origin !== 'external') {
1444
+ issues.push({
1445
+ type: 'error',
1446
+ message: `Node "${nodeLabel}" has invalid origin value "${nodePv.origin}"`,
1447
+ path: `${nodePath}.pv.origin`,
1448
+ suggestion: 'Valid values: "internal" (default), "external"',
1449
+ });
1450
+ }
1451
+ // Deprecation warning for sources field
1452
+ if (nodePv.sources !== undefined) {
1453
+ issues.push({
1454
+ type: 'warning',
1455
+ message: `Node "${nodeLabel}" uses deprecated "pv.sources" field`,
1456
+ path: `${nodePath}.pv.sources`,
1457
+ suggestion: 'Use "pv.references" instead. The "sources" field will be removed in a future version.',
1458
+ });
1459
+ }
1460
+ // Deprecation warnings for pv.otel.kind and pv.otel.category
1461
+ const nodeOtel = nodePv.otel;
1462
+ if (nodeOtel?.kind !== undefined) {
1463
+ issues.push({
1464
+ type: 'error',
1465
+ message: `Node "${nodeLabel}" uses deprecated "pv.otel.kind" field`,
1466
+ path: `${nodePath}.pv.otel.kind`,
1467
+ suggestion: 'Use semantic node types instead. For example, use type: "otel-event" instead of type: "text" with otel.kind: "event". Run: npx @principal-ai/principal-studio-cli migrate-nodes',
1468
+ });
1469
+ }
1470
+ if (nodeOtel?.category !== undefined) {
1471
+ issues.push({
1472
+ type: 'error',
1473
+ message: `Node "${nodeLabel}" uses deprecated "pv.otel.category" field`,
1474
+ path: `${nodePath}.pv.otel.category`,
1475
+ suggestion: 'Use semantic node types instead. Run: npx @principal-ai/principal-studio-cli migrate-nodes',
1476
+ });
1477
+ }
1478
+ // When origin is external, references is required
1479
+ if (isExternal) {
1480
+ if (!Array.isArray(nodePv.references) || nodePv.references.length === 0) {
1481
+ issues.push({
1482
+ type: 'error',
1483
+ message: `Node "${nodeLabel}" has origin "external" but is missing required "pv.references" field`,
1484
+ path: `${nodePath}.pv.references`,
1485
+ suggestion: 'Add references to document the external package/service, e.g.: "references": ["@logfire/pydantic-ai"]',
1486
+ });
1487
+ }
1488
+ }
1489
+ // Validate source file references for OTEL event nodes (skip boundary nodes)
1490
+ const isBoundaryNode = nodePv.nodeType === 'boundary';
1491
+ const hasOtelFeatures = nodePv.otel !== undefined || nodePv.event !== undefined || nodePv.eventRef !== undefined;
1492
+ if (hasOtelFeatures && !isBoundaryNode) {
1493
+ // For .otel.canvas files: nodes using legacy pv.event/pv.eventRef should migrate to otel-event type
1494
+ if (filePath.endsWith('.otel.canvas') &&
1495
+ nodeType === 'text' &&
1496
+ (nodePv.event !== undefined || nodePv.eventRef !== undefined)) {
1497
+ issues.push({
1498
+ type: 'error',
1499
+ message: `Node "${nodeLabel}" uses legacy format: type "text" with pv.event/pv.eventRef`,
1500
+ path: `${nodePath}.type`,
1501
+ suggestion: 'Migrate to semantic type: change type from "text" to "otel-event" and move event/eventRef to top level. Run: npx @principal-ai/principal-studio-cli migrate-nodes',
1502
+ });
1503
+ }
1504
+ // For .otel.canvas files: nodes with OTEL features must have pv.status
1505
+ if (filePath.endsWith('.otel.canvas') && nodeType !== 'group') {
1506
+ if (nodePv.status === undefined) {
1507
+ issues.push({
1508
+ type: 'error',
1509
+ message: `Node "${nodeLabel}" in .otel.canvas file is missing required "pv.status" field`,
1510
+ path: `${nodePath}.pv.status`,
1511
+ suggestion: 'Add implementation status: "status": "draft" | "approved" | "implemented". Use "draft" for design, "approved" for finalized design, "implemented" for code with instrumentation.',
1512
+ });
1513
+ }
1514
+ else {
1515
+ // Validate status value
1516
+ const validStatuses = ['draft', 'approved', 'implemented'];
1517
+ if (!validStatuses.includes(nodePv.status)) {
1518
+ issues.push({
1519
+ type: 'error',
1520
+ message: `Node "${nodeLabel}" has invalid status value "${nodePv.status}"`,
1521
+ path: `${nodePath}.pv.status`,
1522
+ suggestion: `Valid values: ${validStatuses.join(', ')}`,
1523
+ });
1524
+ }
1525
+ // Validate approved and implemented nodes have pv.otel.files (unless external origin)
1526
+ const status = nodePv.status;
1527
+ const otelFiles = nodePv.otel?.files;
1528
+ const hasFiles = Array.isArray(otelFiles) && otelFiles.length > 0;
1529
+ // Track if any nodes are implemented for resources/owned-scopes validation
1530
+ if (status === 'implemented') {
1531
+ hasImplementedNodes = true;
1532
+ }
1533
+ // External origin nodes don't need pv.otel.files since implementation is in external package
1534
+ if ((status === 'approved' || status === 'implemented') && !hasFiles && !isExternal) {
1535
+ issues.push({
1536
+ type: 'error',
1537
+ message: `Node "${nodeLabel}" with status="${status}" must have pv.otel.files specified`,
1538
+ path: `${nodePath}.pv.otel.files`,
1539
+ suggestion: 'Add file paths where this event is instrumented, e.g.: "otel": { "files": ["src/app/api/route.ts"] }. For external/auto-instrumented events, set "origin": "external" instead.',
1540
+ });
1541
+ }
1542
+ // Validate scope is specified for ALL event nodes (deprecated pv.otel.scope format)
1543
+ const legacyScope = nodePv.otel?.scope;
1544
+ if (legacyScope) {
1545
+ issues.push({
1546
+ type: 'error',
1547
+ message: `Node "${nodeLabel}" uses deprecated "pv.otel.scope" field`,
1548
+ path: `${nodePath}.pv.otel.scope`,
1549
+ suggestion: 'Use new format: convert to type: "otel-event" with top-level "otel.scope" field instead. Run: npx @principal-ai/principal-studio-cli migrate-nodes',
1550
+ });
1551
+ }
1552
+ // For implemented nodes: validate that events exist in the specified files (skip for external origin)
1553
+ if (status === 'implemented' && hasFiles && repositoryPath && !isExternal) {
1554
+ // Extract event name
1555
+ let eventName = null;
1556
+ if (nodePv.event && typeof nodePv.event === 'object') {
1557
+ const eventObj = nodePv.event;
1558
+ eventName = eventObj.name || null;
1559
+ }
1560
+ else if (typeof nodePv.eventRef === 'string') {
1561
+ eventName = nodePv.eventRef;
1562
+ }
1563
+ if (eventName) {
1564
+ otelFiles.forEach((file, fileIndex) => {
1565
+ // Check if file exists
1566
+ const fullPath = resolve(repositoryPath, file);
1567
+ if (!existsSync(fullPath)) {
1568
+ issues.push({
1569
+ type: 'error',
1570
+ message: `Node "${nodeLabel}" references non-existent file in pv.otel.files: ${file}`,
1571
+ path: `${nodePath}.pv.otel.files[${fileIndex}]`,
1572
+ suggestion: `Verify the file path is correct relative to repository root: ${repositoryPath}. If this is an auto-instrumented event from an external library, set "origin": "external" and add "references" to document the external package.`,
1573
+ });
1574
+ }
1575
+ else {
1576
+ // Check if event is in the file
1577
+ try {
1578
+ const content = readFileSync(fullPath, 'utf-8');
1579
+ if (!content.includes(eventName)) {
1580
+ issues.push({
1581
+ type: 'error',
1582
+ message: `Node "${nodeLabel}" is marked as "implemented" but event "${eventName}" not found in file: ${file}`,
1583
+ path: `${nodePath}.pv.otel.files[${fileIndex}]`,
1584
+ suggestion: `Add the event to the file using span.addEvent('${eventName}', { ... }) or change status to "approved" if not yet implemented`,
1585
+ });
1586
+ }
1587
+ }
1588
+ catch (error) {
1589
+ // File read error - already reported above if file doesn't exist
1590
+ }
1591
+ }
1592
+ });
1593
+ }
1594
+ }
1595
+ }
1596
+ }
1597
+ }
1598
+ // Validate source file paths
1599
+ if (Array.isArray(nodePv.sources)) {
1600
+ nodePv.sources.forEach((source, sourceIndex) => {
1601
+ if (typeof source === 'string') {
1602
+ // Check for glob patterns
1603
+ if (/[*?[\]{}]/.test(source)) {
1604
+ issues.push({
1605
+ type: 'error',
1606
+ message: `Node "${nodeLabel}" has glob pattern in sources: ${source}`,
1607
+ path: `${nodePath}.pv.sources[${sourceIndex}]`,
1608
+ suggestion: 'Use exact file paths only. Glob patterns (*, ?, [], {}) are not supported in sources.',
1609
+ });
1610
+ }
1611
+ // Check for line number suffix (e.g., "file.ts:123")
1612
+ if (/:\d+$/.test(source)) {
1613
+ issues.push({
1614
+ type: 'error',
1615
+ message: `Node "${nodeLabel}" has line number suffix in sources: ${source}`,
1616
+ path: `${nodePath}.pv.sources[${sourceIndex}]`,
1617
+ suggestion: 'Remove line number suffix. Use exact file paths only (e.g., "src/file.ts" not "src/file.ts:123").',
1618
+ });
1619
+ }
1620
+ // Validate that source file exists (if repository path is provided and not external)
1621
+ if (repositoryPath && !isExternal) {
1622
+ const fullPath = resolve(repositoryPath, source);
1623
+ if (!existsSync(fullPath)) {
1624
+ issues.push({
1625
+ type: 'error',
1626
+ message: `Node "${nodeLabel}" references non-existent source file: ${source}`,
1627
+ path: `${nodePath}.pv.sources[${sourceIndex}]`,
1628
+ suggestion: `Verify the file path is correct relative to repository root: ${repositoryPath}. If this references an external package or auto-instrumented code, set "origin": "external" and use "references" to document the external dependency.`,
1629
+ });
1630
+ }
1631
+ }
1632
+ }
1633
+ });
1634
+ }
1635
+ if (Array.isArray(nodePv.actions)) {
1636
+ nodePv.actions.forEach((action, actionIndex) => {
1637
+ if (action && typeof action === 'object') {
1638
+ checkUnknownFields(action, ALLOWED_CANVAS_FIELDS.nodePvAction, `${nodePath}.pv.actions[${actionIndex}]`, issues);
1639
+ }
1640
+ });
1641
+ }
1642
+ // Validate pv.nodeType references a defined nodeType
1643
+ if (typeof nodePv.nodeType === 'string' && nodePv.nodeType) {
1644
+ // Check for legacy OTEL format: type "text" with pv.nodeType set to OTEL type
1645
+ const LEGACY_OTEL_NODE_TYPES = [
1646
+ 'event',
1647
+ 'span',
1648
+ 'span-convention',
1649
+ 'scope',
1650
+ 'resource',
1651
+ 'boundary',
1652
+ ];
1653
+ if (nodeType === 'text' && LEGACY_OTEL_NODE_TYPES.includes(nodePv.nodeType)) {
1654
+ const newType = nodePv.nodeType === 'span' ? 'otel-span-convention' : `otel-${nodePv.nodeType}`;
1655
+ issues.push({
1656
+ type: 'error',
1657
+ message: `Node "${nodeLabel}" uses legacy format: type "text" with pv.nodeType: "${nodePv.nodeType}"`,
1658
+ path: `${nodePath}.type`,
1659
+ suggestion: `Migrate to semantic type: change type from "text" to "${newType}". Run: npx @principal-ai/principal-studio-cli migrate-nodes`,
1660
+ });
1661
+ }
1662
+ if (allDefinedNodeTypes.length === 0) {
1663
+ issues.push({
1664
+ type: 'error',
1665
+ message: `Node "${nodeLabel}" uses nodeType "${nodePv.nodeType}" but no node types are defined`,
1666
+ path: `${nodePath}.pv.nodeType`,
1667
+ suggestion: 'Define node types in canvas pv.nodeTypes or library.yaml nodeComponents',
1668
+ });
1669
+ }
1670
+ else if (!allDefinedNodeTypes.includes(nodePv.nodeType)) {
1671
+ // Build a helpful suggestion showing where types can be defined
1672
+ const sources = [];
1673
+ if (canvasNodeTypes.length > 0) {
1674
+ sources.push(`canvas pv.nodeTypes: ${canvasNodeTypes.join(', ')}`);
1675
+ }
1676
+ if (libraryNodeTypes.length > 0) {
1677
+ sources.push(`library.yaml nodeComponents: ${libraryNodeTypes.join(', ')}`);
1678
+ }
1679
+ const suggestion = sources.length > 0
1680
+ ? `Available types from ${sources.join(' | ')}`
1681
+ : 'Define node types in canvas pv.nodeTypes or library.yaml nodeComponents';
1682
+ issues.push({
1683
+ type: 'error',
1684
+ message: `Node "${nodeLabel}" uses undefined nodeType "${nodePv.nodeType}"`,
1685
+ path: `${nodePath}.pv.nodeType`,
1686
+ suggestion,
1687
+ });
1688
+ }
1689
+ // Validate nodeType is appropriate for the canvas type
1690
+ const nodeTypeValue = nodePv.nodeType;
1691
+ const isResourcesCanvas = filePath.endsWith('resources.canvas');
1692
+ const isSpansCanvas = filePath.endsWith('.spans.canvas');
1693
+ const isOtelCanvas = filePath.endsWith('.otel.canvas');
1694
+ if (isResourcesCanvas) {
1695
+ const validResourceTypes = ['resource', 'scope'];
1696
+ if (!validResourceTypes.includes(nodeTypeValue)) {
1697
+ issues.push({
1698
+ type: 'error',
1699
+ message: `Node "${nodeLabel}" in resources.canvas has invalid pv.nodeType "${nodeTypeValue}"`,
1700
+ path: `${nodePath}.pv.nodeType`,
1701
+ suggestion: `Migrate to semantic types: use type: "otel-resource" or type: "otel-scope" instead of pv.nodeType. Run: npx @principal-ai/principal-studio-cli migrate-nodes`,
1702
+ });
1703
+ }
1704
+ }
1705
+ else if (isSpansCanvas) {
1706
+ const validSpanTypes = ['span-convention'];
1707
+ if (!validSpanTypes.includes(nodeTypeValue)) {
1708
+ issues.push({
1709
+ type: 'error',
1710
+ message: `Node "${nodeLabel}" in .spans.canvas has invalid pv.nodeType "${nodeTypeValue}"`,
1711
+ path: `${nodePath}.pv.nodeType`,
1712
+ suggestion: `Migrate to semantic types: use type: "otel-span-convention" instead of pv.nodeType. Run: npx @principal-ai/principal-studio-cli migrate-nodes`,
1713
+ });
1714
+ }
1715
+ }
1716
+ else if (isOtelCanvas) {
1717
+ const validOtelTypes = ['event', 'boundary'];
1718
+ if (!validOtelTypes.includes(nodeTypeValue)) {
1719
+ issues.push({
1720
+ type: 'error',
1721
+ message: `Node "${nodeLabel}" in .otel.canvas has invalid pv.nodeType "${nodeTypeValue}"`,
1722
+ path: `${nodePath}.pv.nodeType`,
1723
+ suggestion: `Migrate to semantic types: use type: "otel-event" or type: "otel-boundary" instead of pv.nodeType. Run: npx @principal-ai/principal-studio-cli migrate-nodes`,
1724
+ });
1725
+ }
1726
+ }
1727
+ }
1728
+ }
1729
+ });
1730
+ // Check for text nodes that contain other nodes (suggest using group nodes instead)
1731
+ const nodesWithBounds = c.nodes
1732
+ .filter((n) => n &&
1733
+ typeof n.x === 'number' &&
1734
+ typeof n.y === 'number' &&
1735
+ typeof n.width === 'number' &&
1736
+ typeof n.height === 'number' &&
1737
+ typeof n.id === 'string')
1738
+ .map((n) => ({
1739
+ id: n.id,
1740
+ x: n.x,
1741
+ y: n.y,
1742
+ width: n.width,
1743
+ height: n.height,
1744
+ }));
1745
+ for (const textNode of nodesWithBounds) {
1746
+ const allNodes = c.nodes;
1747
+ const nodeData = allNodes?.find((n) => n.id === textNode.id);
1748
+ if (!nodeData || nodeData.type !== 'text')
1749
+ continue;
1750
+ const containedNonTextNodes = nodesWithBounds.filter((other) => {
1751
+ if (other.id === textNode.id)
1752
+ return false;
1753
+ const otherNode = allNodes?.find((n) => n.id === other.id);
1754
+ if (!otherNode || otherNode.type === 'text')
1755
+ return false;
1756
+ return (other.x >= textNode.x &&
1757
+ other.y >= textNode.y &&
1758
+ other.x + other.width <= textNode.x + textNode.width &&
1759
+ other.y + other.height <= textNode.y + textNode.height);
1760
+ });
1761
+ if (containedNonTextNodes.length > 0) {
1762
+ const textNodeLabel = (nodeData.pv &&
1763
+ typeof nodeData.pv === 'object' &&
1764
+ nodeData.pv.label) ||
1765
+ textNode.id;
1766
+ issues.push({
1767
+ type: 'warning',
1768
+ message: `Text node "${textNodeLabel}" contains ${containedNonTextNodes.length} other node(s): ${containedNonTextNodes.map((n) => `"${n.id}"`).join(', ')}`,
1769
+ path: `nodes[${allNodes?.findIndex((n) => n.id === textNode.id)}].type`,
1770
+ suggestion: 'Consider using type "group" instead of type "text" for container nodes that visually wrap other nodes',
1771
+ });
1772
+ }
1773
+ }
1774
+ }
1775
+ // For .otel.canvas files: warn if library.yaml is missing
1776
+ if (filePath.endsWith('.otel.canvas') && !library) {
1777
+ issues.push({
1778
+ type: 'warning',
1779
+ message: 'Found otel.canvas file but no library.yaml',
1780
+ path: 'library.yaml',
1781
+ suggestion: 'Create .principal-views/library.yaml to register your instrumentation library.\nThis ensures traces are properly attributed to your library.',
1782
+ });
1783
+ }
1784
+ // For .otel.canvas files with implemented nodes: validate library has resources with owned-scopes
1785
+ if (filePath.endsWith('.otel.canvas') && hasImplementedNodes) {
1786
+ const resources = library?.raw?.resources;
1787
+ if (!resources || Object.keys(resources).length === 0) {
1788
+ issues.push({
1789
+ type: 'error',
1790
+ message: 'Canvas has implemented nodes but library.yaml is missing "resources" section',
1791
+ path: 'library.yaml:resources',
1792
+ suggestion: 'Add a resources section to library.yaml defining your services and their owned-scopes:\n resources:\n my-service:\n service.name: "my-service"\n owned-scopes:\n - "my-instrumentation-scope"',
1793
+ });
1794
+ }
1795
+ else {
1796
+ // Check that at least one resource has owned-scopes
1797
+ const hasOwnedScopes = Object.values(resources).some((resource) => Array.isArray(resource['owned-scopes']) && resource['owned-scopes'].length > 0);
1798
+ if (!hasOwnedScopes) {
1799
+ issues.push({
1800
+ type: 'error',
1801
+ message: 'Canvas has implemented nodes but no resources in library.yaml have "owned-scopes" defined',
1802
+ path: 'library.yaml:resources',
1803
+ suggestion: 'Add owned-scopes to at least one resource to specify which instrumentation scopes belong to your services:\n resources:\n my-service:\n service.name: "my-service"\n owned-scopes:\n - "my-instrumentation-scope"',
1804
+ });
1805
+ }
1806
+ }
1807
+ }
1808
+ // Check edges (required)
1809
+ if (c.edges === undefined) {
1810
+ issues.push({ type: 'error', message: 'Canvas must have an "edges" array' });
1811
+ }
1812
+ else if (!Array.isArray(c.edges)) {
1813
+ issues.push({ type: 'error', message: '"edges" must be an array' });
1814
+ }
1815
+ else if (c.edges.length === 0) {
1816
+ issues.push({ type: 'error', message: 'Canvas must have at least one edge' });
1817
+ }
1818
+ else if (Array.isArray(c.edges)) {
1819
+ const nodeIds = new Set(c.nodes?.map((n) => n.id) || []);
1820
+ c.edges.forEach((edge, index) => {
1821
+ if (!edge || typeof edge !== 'object') {
1822
+ issues.push({
1823
+ type: 'error',
1824
+ message: `Edge at index ${index} must be an object`,
1825
+ path: `edges[${index}]`,
1826
+ });
1827
+ return;
1828
+ }
1829
+ const e = edge;
1830
+ const edgePath = `edges[${index}]`;
1831
+ const edgeLabel = e.id || index;
1832
+ // Check unknown fields on edge
1833
+ checkUnknownFields(e, ALLOWED_CANVAS_FIELDS.edge, edgePath, issues);
1834
+ if (typeof e.id !== 'string' || !e.id) {
1835
+ issues.push({
1836
+ type: 'error',
1837
+ message: `Edge at index ${index} must have a string "id"`,
1838
+ path: `${edgePath}.id`,
1839
+ });
1840
+ }
1841
+ if (typeof e.fromNode !== 'string') {
1842
+ issues.push({
1843
+ type: 'error',
1844
+ message: `Edge "${edgeLabel}" must have a string "fromNode"`,
1845
+ path: `${edgePath}.fromNode`,
1846
+ });
1847
+ }
1848
+ else if (!nodeIds.has(e.fromNode)) {
1849
+ issues.push({
1850
+ type: 'error',
1851
+ message: `Edge "${edgeLabel}" references unknown node "${e.fromNode}"`,
1852
+ path: `${edgePath}.fromNode`,
1853
+ });
1854
+ }
1855
+ if (typeof e.toNode !== 'string') {
1856
+ issues.push({
1857
+ type: 'error',
1858
+ message: `Edge "${edgeLabel}" must have a string "toNode"`,
1859
+ path: `${edgePath}.toNode`,
1860
+ });
1861
+ }
1862
+ else if (!nodeIds.has(e.toNode)) {
1863
+ issues.push({
1864
+ type: 'error',
1865
+ message: `Edge "${edgeLabel}" references unknown node "${e.toNode}"`,
1866
+ path: `${edgePath}.toNode`,
1867
+ });
1868
+ }
1869
+ // Validate fromSide and toSide are present and valid (optional for event-namespace canvases)
1870
+ const isEventNamespaceCanvas = c.type === 'event-namespace';
1871
+ const VALID_SIDES = ['top', 'right', 'bottom', 'left'];
1872
+ if (!isEventNamespaceCanvas) {
1873
+ if (typeof e.fromSide !== 'string') {
1874
+ issues.push({
1875
+ type: 'error',
1876
+ message: `Edge "${edgeLabel}" must have a "fromSide" field`,
1877
+ path: `${edgePath}.fromSide`,
1878
+ suggestion: `Specify which side of the source node the edge starts from: ${VALID_SIDES.join(', ')}`,
1879
+ });
1880
+ }
1881
+ else if (!VALID_SIDES.includes(e.fromSide)) {
1882
+ issues.push({
1883
+ type: 'error',
1884
+ message: `Edge "${edgeLabel}" has invalid fromSide "${e.fromSide}"`,
1885
+ path: `${edgePath}.fromSide`,
1886
+ suggestion: `Valid values: ${VALID_SIDES.join(', ')}`,
1887
+ });
1888
+ }
1889
+ if (typeof e.toSide !== 'string') {
1890
+ issues.push({
1891
+ type: 'error',
1892
+ message: `Edge "${edgeLabel}" must have a "toSide" field`,
1893
+ path: `${edgePath}.toSide`,
1894
+ suggestion: `Specify which side of the target node the edge connects to: ${VALID_SIDES.join(', ')}`,
1895
+ });
1896
+ }
1897
+ else if (!VALID_SIDES.includes(e.toSide)) {
1898
+ issues.push({
1899
+ type: 'error',
1900
+ message: `Edge "${edgeLabel}" has invalid toSide "${e.toSide}"`,
1901
+ path: `${edgePath}.toSide`,
1902
+ suggestion: `Valid values: ${VALID_SIDES.join(', ')}`,
1903
+ });
1904
+ }
1905
+ }
1906
+ else if (e.fromSide || e.toSide) {
1907
+ // If sides are provided in event-namespace canvas, they must be valid
1908
+ if (e.fromSide && !VALID_SIDES.includes(e.fromSide)) {
1909
+ issues.push({
1910
+ type: 'error',
1911
+ message: `Edge "${edgeLabel}" has invalid fromSide "${e.fromSide}"`,
1912
+ path: `${edgePath}.fromSide`,
1913
+ suggestion: `Valid values: ${VALID_SIDES.join(', ')}`,
1914
+ });
1915
+ }
1916
+ if (e.toSide && !VALID_SIDES.includes(e.toSide)) {
1917
+ issues.push({
1918
+ type: 'error',
1919
+ message: `Edge "${edgeLabel}" has invalid toSide "${e.toSide}"`,
1920
+ path: `${edgePath}.toSide`,
1921
+ suggestion: `Valid values: ${VALID_SIDES.join(', ')}`,
1922
+ });
1923
+ }
1924
+ }
1925
+ // Check for deprecated pv field on edge
1926
+ if (e.pv !== undefined) {
1927
+ issues.push({
1928
+ type: 'error',
1929
+ message: `Edge "${edgeLabel}" has deprecated "pv" field. Use top-level "edgeType" instead.`,
1930
+ path: `${edgePath}.pv`,
1931
+ suggestion: 'Move pv.edgeType to top-level "edgeType" and remove the "pv" field. ' +
1932
+ 'Example: { "id": "e1", "fromNode": "a", "toNode": "b", "edgeType": "data-flow" }',
1933
+ });
1934
+ }
1935
+ // Validate top-level edgeType is present
1936
+ if (typeof e.edgeType !== 'string' || !e.edgeType) {
1937
+ issues.push({
1938
+ type: 'error',
1939
+ message: `Edge "${edgeLabel}" must have an "edgeType" field`,
1940
+ path: `${edgePath}.edgeType`,
1941
+ suggestion: allDefinedEdgeTypes.length > 0
1942
+ ? `Available types: ${allDefinedEdgeTypes.join(', ')}`
1943
+ : 'Define edge types in canvas pv.edgeTypes or library.yaml edgeComponents',
1944
+ });
1945
+ }
1946
+ // Validate edge pv extension fields
1947
+ if (e.pv && typeof e.pv === 'object') {
1948
+ const edgePv = e.pv;
1949
+ // Check unknown fields in edge pv extension
1950
+ checkUnknownFields(edgePv, ALLOWED_CANVAS_FIELDS.edgePv, `${edgePath}.pv`, issues);
1951
+ // Check nested edge pv fields
1952
+ if (edgePv.animation && typeof edgePv.animation === 'object') {
1953
+ checkUnknownFields(edgePv.animation, ALLOWED_CANVAS_FIELDS.edgePvAnimation, `${edgePath}.pv.animation`, issues);
1954
+ }
1955
+ if (Array.isArray(edgePv.activatedBy)) {
1956
+ edgePv.activatedBy.forEach((trigger, triggerIndex) => {
1957
+ if (trigger && typeof trigger === 'object') {
1958
+ checkUnknownFields(trigger, ALLOWED_CANVAS_FIELDS.edgePvActivatedBy, `${edgePath}.pv.activatedBy[${triggerIndex}]`, issues);
1959
+ }
1960
+ });
1961
+ }
1962
+ }
1963
+ // Validate edge type references (using top-level edgeType)
1964
+ if (e.edgeType && typeof e.edgeType === 'string') {
1965
+ if (allDefinedEdgeTypes.length === 0) {
1966
+ issues.push({
1967
+ type: 'error',
1968
+ message: `Edge "${edgeLabel}" uses edgeType "${e.edgeType}" but no edge types are defined`,
1969
+ path: `${edgePath}.edgeType`,
1970
+ suggestion: 'Define edge types in canvas pv.edgeTypes or library.yaml edgeComponents',
1971
+ });
1972
+ }
1973
+ else if (!allDefinedEdgeTypes.includes(e.edgeType)) {
1974
+ // Build a helpful suggestion showing where types can be defined
1975
+ const sources = [];
1976
+ if (canvasEdgeTypes.length > 0) {
1977
+ sources.push(`canvas pv.edgeTypes: ${canvasEdgeTypes.join(', ')}`);
1978
+ }
1979
+ if (libraryEdgeTypes.length > 0) {
1980
+ sources.push(`library.yaml edgeComponents: ${libraryEdgeTypes.join(', ')}`);
1981
+ }
1982
+ const suggestion = sources.length > 0
1983
+ ? `Available types from ${sources.join(' | ')}`
1984
+ : 'Define edge types in canvas pv.edgeTypes or library.yaml edgeComponents';
1985
+ issues.push({
1986
+ type: 'error',
1987
+ message: `Edge "${edgeLabel}" uses undefined edgeType "${e.edgeType}"`,
1988
+ path: `${edgePath}.edgeType`,
1989
+ suggestion,
1990
+ });
1991
+ }
1992
+ }
1993
+ });
1994
+ }
1995
+ // Validate OTEL canvas naming convention
1996
+ const hasOtel = hasOtelFeatures(canvas);
1997
+ const isOtelCanvas = filePath.endsWith('.otel.canvas');
1998
+ const isScopesCanvas = filePath.endsWith('.scopes.canvas');
1999
+ const isSpansCanvas = filePath.endsWith('.spans.canvas');
2000
+ const isEventsCanvas = filePath.endsWith('.events.canvas');
2001
+ const isEventNamespaceCanvas = canvas.type === 'event-namespace';
2002
+ if (hasOtel && !isOtelCanvas && !isScopesCanvas && !isSpansCanvas && !isEventsCanvas) {
2003
+ issues.push({
2004
+ type: 'error',
2005
+ message: 'Canvas contains OTEL features but does not use .otel.canvas naming convention',
2006
+ suggestion: 'Rename file to use .otel.canvas extension (e.g., "graph-name.otel.canvas")',
2007
+ });
2008
+ }
2009
+ else if (!hasOtel && isOtelCanvas && !isEventNamespaceCanvas) {
2010
+ issues.push({
2011
+ type: 'warning',
2012
+ message: 'Canvas uses .otel.canvas naming but does not contain any OTEL features',
2013
+ suggestion: 'Either add OTEL features (otel-event, otel-span-convention, otel-scope, otel-resource nodes, or pv.otel, pv.scope, pv.audit) or rename to .canvas',
2014
+ });
2015
+ }
2016
+ // Validate markdown field for all canvas files (now at top level)
2017
+ if (typeof c.markdown !== 'string' || !c.markdown) {
2018
+ issues.push({
2019
+ type: 'error',
2020
+ message: 'Canvas files must have a top-level "markdown" field pointing to documentation',
2021
+ path: 'markdown',
2022
+ suggestion: `Add: "markdown": ".principal-views/graph-name.md"
2023
+
2024
+ The markdown file should explain the FEATURE, not the canvas itself.
2025
+
2026
+ Good: "Task management lets users create, edit, and archive tasks.
2027
+ Tasks move through a lifecycle from draft → active → archive..."
2028
+
2029
+ Bad: "This canvas shows telemetry events. The task.create.started
2030
+ event is emitted when..."
2031
+
2032
+ The canvas shows HOW ${isOtelCanvas ? 'we instrument it' : 'it works'}. The markdown explains WHAT the feature does and WHY.
2033
+
2034
+ Include:
2035
+ - What problem does this feature solve?
2036
+ - What operations are available?
2037
+ - What design choices were made and why?
2038
+ - Common workflow patterns
2039
+ - Error scenarios and recovery
2040
+
2041
+ The canvas is visual documentation. The markdown supplements it with context.`,
2042
+ });
2043
+ }
2044
+ else {
2045
+ // Validate that the markdown file exists
2046
+ // Try to resolve relative to repository root, or relative to canvas directory if no repository path
2047
+ let markdownPath;
2048
+ if (repositoryPath) {
2049
+ markdownPath = resolve(repositoryPath, c.markdown);
2050
+ }
2051
+ else {
2052
+ // If no repository path, try to find the repository root by looking for .principal-views parent
2053
+ const canvasDir = dirname(filePath);
2054
+ const principalViewsIndex = canvasDir.lastIndexOf('.principal-views');
2055
+ if (principalViewsIndex !== -1) {
2056
+ const repoRoot = canvasDir.substring(0, principalViewsIndex);
2057
+ markdownPath = resolve(repoRoot, c.markdown);
2058
+ }
2059
+ else {
2060
+ // Fallback: resolve relative to canvas directory
2061
+ markdownPath = resolve(canvasDir, c.markdown);
2062
+ }
2063
+ }
2064
+ if (!existsSync(markdownPath)) {
2065
+ issues.push({
2066
+ type: 'error',
2067
+ message: `Referenced markdown file does not exist: ${c.markdown}`,
2068
+ path: 'markdown',
2069
+ suggestion: `Create the markdown file at: ${markdownPath}
2070
+
2071
+ The markdown should explain the FEATURE (what it does, why it exists), not describe the canvas itself.
2072
+ The canvas shows HOW ${isOtelCanvas ? 'we instrument it' : 'it works'}. The markdown explains WHAT the feature does and WHY.
2073
+
2074
+ Example structure:
2075
+ - What problem does this feature solve?
2076
+ - What operations are available?
2077
+ - What design choices were made and why?
2078
+ - Common workflow patterns
2079
+ - Error scenarios and recovery`,
2080
+ });
2081
+ }
2082
+ }
2083
+ // Validate that scopes used in OTEL canvas are documented in scopes canvas
2084
+ if (filePath.endsWith('.otel.canvas') && scopesCanvas) {
2085
+ // Extract scopes documented in scopes canvas
2086
+ const documentedScopes = new Set();
2087
+ if (Array.isArray(scopesCanvas.nodes)) {
2088
+ for (const node of scopesCanvas.nodes) {
2089
+ if (node.type === 'otel-scope' && node.otel?.scope) {
2090
+ documentedScopes.add(node.otel.scope);
2091
+ }
2092
+ }
2093
+ }
2094
+ // Extract scopes used in this canvas
2095
+ const usedScopes = new Set();
2096
+ if (Array.isArray(c.nodes)) {
2097
+ for (const node of c.nodes) {
2098
+ // Check OTEL nodes with otel.scope
2099
+ if (node.type && typeof node.type === 'string' && node.type.startsWith('otel-')) {
2100
+ const otelScope = node.otel?.scope;
2101
+ if (typeof otelScope === 'string') {
2102
+ usedScopes.add(otelScope);
2103
+ }
2104
+ }
2105
+ }
2106
+ }
2107
+ // Check for undocumented scopes
2108
+ for (const scope of usedScopes) {
2109
+ if (!documentedScopes.has(scope)) {
2110
+ issues.push({
2111
+ type: 'error',
2112
+ message: `Scope "${scope}" is used in this canvas but not documented in architecture.scopes.canvas`,
2113
+ path: 'nodes[].otel.scope',
2114
+ suggestion: `Add a node to architecture.scopes.canvas with:
2115
+ {
2116
+ "type": "otel-scope",
2117
+ "label": "${scope.split('.').pop() || scope}",
2118
+ "otel": {
2119
+ "scope": "${scope}",
2120
+ "status": "implemented"
2121
+ }
2122
+ }
2123
+
2124
+ All scopes must be documented in architecture.scopes.canvas before being used in workflow canvases.`,
2125
+ });
2126
+ }
2127
+ }
2128
+ }
2129
+ return issues;
2130
+ }
2131
+ /**
2132
+ * Validate a workflow template
2133
+ */
2134
+ async function validateWorkflow(filePath, allWorkflowEvents, repositoryPath, executionFiles, eventRegistry) {
2135
+ const relativePath = relative(repositoryPath, filePath);
2136
+ if (!existsSync(filePath)) {
2137
+ return {
2138
+ file: relativePath,
2139
+ fileType: 'workflow',
2140
+ isValid: false,
2141
+ issues: [{ type: 'error', message: `File not found: ${filePath}` }],
2142
+ };
2143
+ }
2144
+ try {
2145
+ const workflow = loadWorkflowTemplate(filePath);
2146
+ if (!workflow) {
2147
+ return {
2148
+ file: relativePath,
2149
+ fileType: 'workflow',
2150
+ isValid: false,
2151
+ issues: [{ type: 'error', message: 'Could not parse workflow file' }],
2152
+ };
2153
+ }
2154
+ // Load referenced canvas if it exists
2155
+ // Canvas paths are always relative to repository root
2156
+ const canvasPath = workflow.canvas ? resolve(repositoryPath, workflow.canvas) : undefined;
2157
+ const canvas = canvasPath && existsSync(canvasPath)
2158
+ ? JSON.parse(readFileSync(canvasPath, 'utf8'))
2159
+ : undefined;
2160
+ // Validate using workflow validator
2161
+ const validator = createWorkflowValidator(new NodeFileSystemAdapter());
2162
+ const rawContent = readFileSync(filePath, 'utf8');
2163
+ const result = await validator.validate({
2164
+ workflow,
2165
+ workflowPath: relativePath,
2166
+ canvas,
2167
+ canvasPath,
2168
+ basePath: repositoryPath,
2169
+ rawContent,
2170
+ allWorkflowEvents,
2171
+ executionFiles,
2172
+ eventRegistry,
2173
+ });
2174
+ // Convert workflow violations to validation issues
2175
+ const issues = result.violations.map((v) => ({
2176
+ type: v.severity === 'error' ? 'error' : 'warning',
2177
+ message: v.message,
2178
+ path: v.path,
2179
+ suggestion: v.suggestion,
2180
+ }));
2181
+ return {
2182
+ file: relativePath,
2183
+ fileType: 'workflow',
2184
+ isValid: result.errorCount === 0,
2185
+ issues,
2186
+ };
2187
+ }
2188
+ catch (error) {
2189
+ return {
2190
+ file: relativePath,
2191
+ fileType: 'workflow',
2192
+ isValid: false,
2193
+ issues: [{ type: 'error', message: `Failed to validate: ${error.message}` }],
2194
+ };
2195
+ }
2196
+ }
2197
+ /**
2198
+ * Validate a test trace artifact (.otel.json file)
2199
+ */
2200
+ function validateExecution(filePath, repositoryPath) {
2201
+ const relativePath = relative(repositoryPath, filePath);
2202
+ if (!existsSync(filePath)) {
2203
+ return {
2204
+ file: relativePath,
2205
+ fileType: 'testTrace',
2206
+ isValid: false,
2207
+ issues: [{ type: 'error', message: `File not found: ${filePath}` }],
2208
+ };
2209
+ }
2210
+ try {
2211
+ const data = loadExecutionFile(filePath);
2212
+ if (!data) {
2213
+ return {
2214
+ file: relativePath,
2215
+ fileType: 'testTrace',
2216
+ isValid: false,
2217
+ issues: [{ type: 'error', message: 'Could not parse test trace file' }],
2218
+ };
2219
+ }
2220
+ // Validate using execution validator
2221
+ const validator = createExecutionValidator();
2222
+ const result = validator.validate(data, relativePath);
2223
+ // Check if matching canvas exists
2224
+ const { canvasPath, workflowPath } = findMatchingCanvas(filePath, repositoryPath);
2225
+ if (!canvasPath) {
2226
+ const fileName = basename(filePath);
2227
+ const traceDir = dirname(filePath);
2228
+ if (workflowPath) {
2229
+ // Workflow found but its canvas reference is invalid
2230
+ const workflowName = basename(workflowPath);
2231
+ try {
2232
+ const workflowContent = readFileSync(workflowPath, 'utf8');
2233
+ const workflow = JSON.parse(workflowContent);
2234
+ const canvasRef = workflow.canvas || '(no canvas field)';
2235
+ result.errors.push({
2236
+ path: relativePath,
2237
+ message: `Workflow '${workflowName}' references canvas that doesn't exist: ${canvasRef}`,
2238
+ severity: 'error',
2239
+ suggestion: `Check the 'canvas' field in ${relative(repositoryPath, workflowPath)} and ensure the referenced canvas file exists`,
2240
+ });
2241
+ }
2242
+ catch {
2243
+ result.errors.push({
2244
+ path: relativePath,
2245
+ message: `Found workflow '${workflowName}' but it could not be parsed`,
2246
+ severity: 'error',
2247
+ suggestion: `Check that ${relative(repositoryPath, workflowPath)} is valid JSON`,
2248
+ });
2249
+ }
2250
+ }
2251
+ else {
2252
+ // No workflow found - provide guidance on expected structure
2253
+ result.errors.push({
2254
+ path: relativePath,
2255
+ message: 'No co-located workflow file found for test trace',
2256
+ severity: 'error',
2257
+ suggestion: `Test traces should be co-located with a workflow file. Expected structure:
2258
+ ${relative(repositoryPath, traceDir)}/
2259
+ ├── <workflow-name>.workflow.json (with 'canvas' field referencing the canvas)
2260
+ └── ${fileName}
2261
+
2262
+ The workflow's 'canvas' field should point to the canvas this trace validates against.`,
2263
+ });
2264
+ }
2265
+ result.valid = false;
2266
+ }
2267
+ // Convert execution validation result to validation issues
2268
+ const issues = [
2269
+ ...result.errors.map((e) => ({
2270
+ type: 'error',
2271
+ message: e.message,
2272
+ path: e.path,
2273
+ suggestion: e.suggestion,
2274
+ })),
2275
+ ...result.warnings.map((w) => ({
2276
+ type: 'warning',
2277
+ message: w.message,
2278
+ path: w.path,
2279
+ suggestion: w.suggestion,
2280
+ })),
2281
+ ];
2282
+ return {
2283
+ file: relativePath,
2284
+ fileType: 'testTrace',
2285
+ isValid: result.valid,
2286
+ issues,
2287
+ };
2288
+ }
2289
+ catch (error) {
2290
+ return {
2291
+ file: relativePath,
2292
+ fileType: 'testTrace',
2293
+ isValid: false,
2294
+ issues: [{ type: 'error', message: `Failed to validate: ${error.message}` }],
2295
+ };
2296
+ }
2297
+ }
2298
+ /**
2299
+ * Validate a .dashboard.json file
2300
+ */
2301
+ function validateDashboard(filePath, repositoryPath, context) {
2302
+ const relativePath = relative(repositoryPath, filePath);
2303
+ if (!existsSync(filePath)) {
2304
+ return {
2305
+ file: relativePath,
2306
+ fileType: 'dashboard',
2307
+ isValid: false,
2308
+ issues: [{ type: 'error', message: `File not found: ${filePath}` }],
2309
+ };
2310
+ }
2311
+ try {
2312
+ const content = readFileSync(filePath, 'utf8');
2313
+ const data = JSON.parse(content);
2314
+ // Validate using dashboard validator with optional cross-reference context
2315
+ const validator = createDashboardValidator();
2316
+ const result = validator.validate(data, relativePath, context);
2317
+ // Convert dashboard validation result to validation issues
2318
+ const issues = [
2319
+ ...result.errors.map((e) => ({
2320
+ type: 'error',
2321
+ message: e.message,
2322
+ path: e.path,
2323
+ suggestion: e.suggestion,
2324
+ })),
2325
+ ...result.warnings.map((w) => ({
2326
+ type: 'warning',
2327
+ message: w.message,
2328
+ path: w.path,
2329
+ suggestion: w.suggestion,
2330
+ })),
2331
+ ];
2332
+ return {
2333
+ file: relativePath,
2334
+ fileType: 'dashboard',
2335
+ isValid: result.valid,
2336
+ issues,
2337
+ };
2338
+ }
2339
+ catch (error) {
2340
+ return {
2341
+ file: relativePath,
2342
+ fileType: 'dashboard',
2343
+ isValid: false,
2344
+ issues: [{ type: 'error', message: `Failed to validate: ${error.message}` }],
2345
+ };
2346
+ }
2347
+ }
2348
+ /**
2349
+ * Validate a single .canvas file
2350
+ */
2351
+ function validateFile(filePath, library, repositoryPath, scopesCanvas) {
2352
+ const absolutePath = repositoryPath ? resolve(repositoryPath, filePath) : resolve(filePath);
2353
+ const relativePath = relative(repositoryPath || process.cwd(), absolutePath);
2354
+ if (!existsSync(absolutePath)) {
2355
+ return {
2356
+ file: relativePath,
2357
+ fileType: 'canvas',
2358
+ isValid: false,
2359
+ issues: [{ type: 'error', message: `File not found: ${filePath}` }],
2360
+ };
2361
+ }
2362
+ try {
2363
+ const content = readFileSync(absolutePath, 'utf8');
2364
+ const canvas = JSON.parse(content);
2365
+ const issues = validateCanvas(canvas, relativePath, library, repositoryPath, scopesCanvas);
2366
+ const hasErrors = issues.some((i) => i.type === 'error');
2367
+ return {
2368
+ file: relativePath,
2369
+ fileType: 'canvas',
2370
+ isValid: !hasErrors,
2371
+ issues,
2372
+ canvas: hasErrors ? undefined : canvas,
2373
+ };
2374
+ }
2375
+ catch (error) {
2376
+ return {
2377
+ file: relativePath,
2378
+ fileType: 'canvas',
2379
+ isValid: false,
2380
+ issues: [{ type: 'error', message: `Failed to parse JSON: ${error.message}` }],
2381
+ };
2382
+ }
2383
+ }
2384
+ /**
2385
+ * Output validation results, organized by file type
2386
+ */
2387
+ function outputResults(results, libraryResult, options, targetedValidation = false) {
2388
+ const allResults = libraryResult ? [libraryResult, ...results] : results;
2389
+ const validCount = allResults.filter((r) => r.isValid).length;
2390
+ const invalidCount = allResults.length - validCount;
2391
+ // Group by file type
2392
+ const byType = {
2393
+ canvas: allResults.filter((r) => r.fileType === 'canvas'),
2394
+ workflow: allResults.filter((r) => r.fileType === 'workflow'),
2395
+ testTrace: allResults.filter((r) => r.fileType === 'testTrace'),
2396
+ library: allResults.filter((r) => r.fileType === 'library'),
2397
+ dashboard: allResults.filter((r) => r.fileType === 'dashboard'),
2398
+ };
2399
+ if (options.json) {
2400
+ console.log(JSON.stringify({
2401
+ files: allResults,
2402
+ summary: {
2403
+ total: allResults.length,
2404
+ valid: validCount,
2405
+ invalid: invalidCount,
2406
+ byType: {
2407
+ canvas: byType.canvas.length,
2408
+ workflow: byType.workflow.length,
2409
+ testTrace: byType.testTrace.length,
2410
+ library: byType.library.length,
2411
+ dashboard: byType.dashboard.length,
2412
+ },
2413
+ },
2414
+ }, null, 2));
2415
+ }
2416
+ else {
2417
+ if (!options.quiet) {
2418
+ const counts = [];
2419
+ if (byType.canvas.length > 0)
2420
+ counts.push(`${byType.canvas.length} canvas`);
2421
+ if (byType.workflow.length > 0)
2422
+ counts.push(`${byType.workflow.length} workflow`);
2423
+ if (byType.testTrace.length > 0)
2424
+ counts.push(`${byType.testTrace.length} test trace`);
2425
+ if (byType.library.length > 0)
2426
+ counts.push(`${byType.library.length} library`);
2427
+ if (byType.dashboard.length > 0)
2428
+ counts.push(`${byType.dashboard.length} dashboard`);
2429
+ console.log(chalk.bold(`\nValidating ${counts.join(', ')} file(s)...\n`));
2430
+ }
2431
+ // Output by type for better organization
2432
+ const outputByType = (type, results) => {
2433
+ if (results.length === 0)
2434
+ return;
2435
+ if (!options.quiet) {
2436
+ console.log(chalk.bold(`${type.charAt(0).toUpperCase() + type.slice(1)} Files:`));
2437
+ }
2438
+ for (const result of results) {
2439
+ if (result.isValid) {
2440
+ if (!options.quiet) {
2441
+ console.log(chalk.green(`✓ ${result.file}`));
2442
+ const warnings = result.issues.filter((i) => i.type === 'warning');
2443
+ if (warnings.length > 0) {
2444
+ warnings.forEach((w) => {
2445
+ console.log(chalk.yellow(` ⚠ ${w.message}`));
2446
+ });
2447
+ }
2448
+ }
2449
+ }
2450
+ else {
2451
+ console.log(chalk.red(`✗ ${result.file}`));
2452
+ result.issues.forEach((issue) => {
2453
+ const label = issue.type === 'error' ? 'error' : 'warning';
2454
+ const color = issue.type === 'error' ? chalk.red : chalk.yellow;
2455
+ console.log(color(` ${label}: ${issue.message}`));
2456
+ if (issue.suggestion) {
2457
+ console.log(chalk.dim(` → ${issue.suggestion}`));
2458
+ }
2459
+ });
2460
+ }
2461
+ }
2462
+ if (!options.quiet)
2463
+ console.log('');
2464
+ };
2465
+ // Output in logical order
2466
+ outputByType('Library', byType.library);
2467
+ outputByType('Canvas', byType.canvas);
2468
+ outputByType('Workflow', byType.workflow);
2469
+ outputByType('Test Trace', byType.testTrace);
2470
+ outputByType('Dashboard', byType.dashboard);
2471
+ // Summary
2472
+ if (invalidCount === 0) {
2473
+ console.log(chalk.green(`✓ All ${validCount} file(s) are valid`));
2474
+ if (targetedValidation) {
2475
+ console.log(chalk.dim(`\nTip: Run the validate command without arguments for comprehensive validation of all artifacts.`));
2476
+ }
2477
+ }
2478
+ else {
2479
+ console.log(chalk.red(`✗ ${invalidCount} of ${allResults.length} file(s) failed validation`));
2480
+ }
2481
+ }
2482
+ // Exit with error if validation failed
2483
+ if (invalidCount > 0) {
2484
+ process.exit(1);
2485
+ }
2486
+ }
2487
+ export function createValidateCommand() {
2488
+ const command = new Command('validate');
2489
+ command
2490
+ .description('Validate all Principal View artifacts (canvas, workflow, test trace files)')
2491
+ .argument('[files...]', 'Files or glob patterns to validate (defaults to all Principal View files)')
2492
+ .option('-q, --quiet', 'Only output errors')
2493
+ .option('--json', 'Output results as JSON')
2494
+ .option('-r, --repository <path>', 'Repository root path for validating source file references (defaults to current directory)')
2495
+ .option('--canvas-only', 'Only validate canvas files')
2496
+ .option('--workflow-only', 'Only validate workflow files')
2497
+ .option('--execution-only', 'Only validate test trace files')
2498
+ .action(async (files, options) => {
2499
+ try {
2500
+ // Determine repository path for source file validation
2501
+ const repositoryPath = options.repository ? resolve(options.repository) : process.cwd();
2502
+ // If specific files are provided, validate each based on its type
2503
+ if (files.length > 0) {
2504
+ const matchedFiles = await globby(files, {
2505
+ expandDirectories: false,
2506
+ });
2507
+ if (matchedFiles.length === 0) {
2508
+ if (options.json) {
2509
+ console.log(JSON.stringify({ files: [], summary: { total: 0, valid: 0, invalid: 0 } }));
2510
+ }
2511
+ else {
2512
+ console.log(chalk.yellow('No files found matching the specified patterns.'));
2513
+ console.log(chalk.dim(`Patterns searched: ${files.join(', ')}`));
2514
+ }
2515
+ return;
2516
+ }
2517
+ const library = loadLibrary(resolve(repositoryPath, '.principal-views'));
2518
+ // Helper to extract storyboard name from a path
2519
+ const extractStoryboardName = (filePath) => {
2520
+ const parts = filePath.split('/');
2521
+ const pvIndex = parts.indexOf('.principal-views');
2522
+ if (pvIndex === -1 || parts.length < pvIndex + 2) {
2523
+ return null;
2524
+ }
2525
+ return parts[pvIndex + 1];
2526
+ };
2527
+ // PHASE 1: Group workflows by canvas and collect all events used
2528
+ // Only include co-located workflows (same storyboard folder) for event coverage
2529
+ const workflowsByCanvas = new Map();
2530
+ const workflowFiles = [];
2531
+ const canvasFiles = [];
2532
+ const testTraceFiles = [];
2533
+ const dashboardFiles = [];
2534
+ let libraryFile = null;
2535
+ // First pass: categorize files and build workflow event map
2536
+ for (const file of matchedFiles) {
2537
+ const fileType = determineFileType(file);
2538
+ const absolutePath = resolve(file);
2539
+ // Skip unknown file types (e.g., .md files)
2540
+ if (fileType === 'unknown') {
2541
+ continue;
2542
+ }
2543
+ if (fileType === 'canvas') {
2544
+ canvasFiles.push(file);
2545
+ }
2546
+ else if (fileType === 'workflow') {
2547
+ workflowFiles.push(absolutePath);
2548
+ // Load workflow and collect events
2549
+ const workflow = loadWorkflowTemplate(absolutePath);
2550
+ if (workflow && workflow.canvas) {
2551
+ const canvasPath = resolve(repositoryPath, workflow.canvas);
2552
+ const canvasKey = relative(repositoryPath, canvasPath);
2553
+ // Only include events from workflows that are co-located with the canvas
2554
+ // (same storyboard folder). Cross-referenced workflows are validated separately.
2555
+ const workflowRelativePath = relative(repositoryPath, absolutePath);
2556
+ const workflowStoryboard = extractStoryboardName(workflowRelativePath);
2557
+ const canvasStoryboard = extractStoryboardName(workflow.canvas);
2558
+ if (workflowStoryboard !== canvasStoryboard) {
2559
+ // Skip cross-referenced workflows for event coverage calculation
2560
+ continue;
2561
+ }
2562
+ // Collect events from this workflow
2563
+ if (!workflowsByCanvas.has(canvasKey)) {
2564
+ workflowsByCanvas.set(canvasKey, new Set());
2565
+ }
2566
+ const workflowEvents = workflowsByCanvas.get(canvasKey);
2567
+ for (const scenario of workflow.scenarios) {
2568
+ if (scenario.template?.events) {
2569
+ for (const eventName of Object.keys(scenario.template.events)) {
2570
+ if (!eventName.includes('*')) {
2571
+ workflowEvents.add(eventName);
2572
+ }
2573
+ }
2574
+ }
2575
+ }
2576
+ }
2577
+ }
2578
+ else if (fileType === 'testTrace') {
2579
+ testTraceFiles.push(absolutePath);
2580
+ }
2581
+ else if (fileType === 'dashboard') {
2582
+ dashboardFiles.push(absolutePath);
2583
+ }
2584
+ else if (fileType === 'library') {
2585
+ libraryFile = file;
2586
+ }
2587
+ }
2588
+ // PHASE 2: Validate canvases and build EventRegistry
2589
+ const results = [];
2590
+ const parsedCanvases = new Map();
2591
+ for (const file of canvasFiles) {
2592
+ const validationResult = validateFile(file, library, repositoryPath);
2593
+ // Check if this canvas has any associated workflow files
2594
+ // Only check for .otel.canvas files (workflows are for telemetry scenarios)
2595
+ const isOtelCanvas = file.endsWith('.otel.canvas');
2596
+ const hasErrors = validationResult.issues.some((i) => i.type === 'error');
2597
+ if (isOtelCanvas && !hasErrors) {
2598
+ const absoluteCanvasPath = resolve(repositoryPath, file);
2599
+ const associatedWorkflows = findWorkflowsForCanvas(absoluteCanvasPath, repositoryPath);
2600
+ // Also check if any workflows were passed to validation that reference this canvas
2601
+ const canvasRelPath = relative(repositoryPath, absoluteCanvasPath);
2602
+ const passedWorkflowsForCanvas = workflowsByCanvas.has(canvasRelPath);
2603
+ if (associatedWorkflows.length === 0 && !passedWorkflowsForCanvas) {
2604
+ validationResult.isValid = false;
2605
+ validationResult.issues.push({
2606
+ type: 'error',
2607
+ message: 'No workflow files found for this .otel.canvas',
2608
+ suggestion: 'Create a .workflow.json file to define telemetry scenarios for this canvas. Workflows are required for .otel.canvas files.',
2609
+ });
2610
+ }
2611
+ }
2612
+ results.push(validationResult);
2613
+ // Collect parsed canvas for EventRegistry
2614
+ if (validationResult.canvas) {
2615
+ parsedCanvases.set(file, validationResult.canvas);
2616
+ }
2617
+ }
2618
+ // Build EventRegistry from library and all parsed canvases
2619
+ const componentLibrary = library?.raw;
2620
+ const eventRegistry = EventRegistry.build(componentLibrary, parsedCanvases, library?.path);
2621
+ // PHASE 3: Validate workflows with canvas-wide event knowledge
2622
+ for (const absolutePath of workflowFiles) {
2623
+ const workflow = loadWorkflowTemplate(absolutePath);
2624
+ if (!workflow) {
2625
+ results.push({
2626
+ file: relative(repositoryPath, absolutePath),
2627
+ fileType: 'workflow',
2628
+ isValid: false,
2629
+ issues: [{ type: 'error', message: 'Could not parse workflow file' }],
2630
+ });
2631
+ continue;
2632
+ }
2633
+ // Get combined event set for this workflow's canvas
2634
+ const canvasPath = workflow.canvas
2635
+ ? resolve(repositoryPath, workflow.canvas)
2636
+ : undefined;
2637
+ const canvasKey = canvasPath ? relative(repositoryPath, canvasPath) : undefined;
2638
+ const allWorkflowEvents = canvasKey ? workflowsByCanvas.get(canvasKey) : undefined;
2639
+ // Find co-located execution files
2640
+ const workflowDir = dirname(absolutePath);
2641
+ const executionFiles = [];
2642
+ try {
2643
+ const filesInDir = readdirSync(workflowDir);
2644
+ for (const file of filesInDir) {
2645
+ if (file.endsWith('.otel.json')) {
2646
+ executionFiles.push(resolve(workflowDir, file));
2647
+ }
2648
+ }
2649
+ }
2650
+ catch {
2651
+ // Directory not readable, skip
2652
+ }
2653
+ const validationResult = await validateWorkflow(absolutePath, allWorkflowEvents, repositoryPath, executionFiles, eventRegistry);
2654
+ results.push(validationResult);
2655
+ }
2656
+ // PHASE 4: Validate test traces
2657
+ for (const absolutePath of testTraceFiles) {
2658
+ results.push(validateExecution(absolutePath, repositoryPath));
2659
+ }
2660
+ // PHASE 5: Validate dashboards
2661
+ for (const absolutePath of dashboardFiles) {
2662
+ results.push(validateDashboard(absolutePath, repositoryPath));
2663
+ }
2664
+ // PHASE 6: Validate library
2665
+ if (libraryFile && library) {
2666
+ const libraryIssues = validateLibrary(library);
2667
+ // Validate scope events canvases (check against scopes canvas)
2668
+ // Look for architecture.scopes.canvas in .principal-views
2669
+ const scopesCanvasPath = resolve(repositoryPath, '.principal-views/architecture.scopes.canvas');
2670
+ if (existsSync(scopesCanvasPath)) {
2671
+ try {
2672
+ const scopesCanvasData = readFileSync(scopesCanvasPath, 'utf-8');
2673
+ const scopesCanvasContent = JSON.parse(scopesCanvasData);
2674
+ const scopeEventsValidator = new ScopeEventsValidator(new NodeFileSystemAdapter());
2675
+ const scopeEventsResult = await scopeEventsValidator.validate({
2676
+ scopesCanvas: scopesCanvasContent,
2677
+ scopesCanvasPath: '.principal-views/architecture.scopes.canvas',
2678
+ basePath: repositoryPath,
2679
+ });
2680
+ for (const violation of scopeEventsResult.violations) {
2681
+ libraryIssues.push({
2682
+ type: violation.severity === 'error' ? 'error' : 'warning',
2683
+ message: violation.message,
2684
+ path: violation.expectedPath,
2685
+ suggestion: violation.suggestion,
2686
+ });
2687
+ }
2688
+ }
2689
+ catch {
2690
+ // Skip if we can't load the scopes canvas
2691
+ }
2692
+ }
2693
+ const libraryHasErrors = libraryIssues.some((i) => i.type === 'error');
2694
+ results.push({
2695
+ file: relative(repositoryPath, library.path),
2696
+ fileType: 'library',
2697
+ isValid: !libraryHasErrors,
2698
+ issues: libraryIssues,
2699
+ });
2700
+ }
2701
+ // Cross-canvas path enforcement: cross-reference `otel.files` on
2702
+ // `otel-event` nodes against `paths` on `event-namespace` nodes.
2703
+ runOtelEventPathsValidation(results, repositoryPath);
2704
+ // Group canvases by directory for validation
2705
+ const canvasesByDir = new Map();
2706
+ for (const result of results) {
2707
+ if (result.fileType !== 'canvas')
2708
+ continue;
2709
+ const dir = dirname(result.file);
2710
+ if (!canvasesByDir.has(dir)) {
2711
+ canvasesByDir.set(dir, { otel: [], regular: [] });
2712
+ }
2713
+ const entry = canvasesByDir.get(dir);
2714
+ if (result.file.endsWith('.otel.canvas')) {
2715
+ entry.otel.push(result.file);
2716
+ }
2717
+ else if (result.file.endsWith('.canvas')) {
2718
+ entry.regular.push(result.file);
2719
+ }
2720
+ }
2721
+ for (const [dir, { otel, regular }] of canvasesByDir.entries()) {
2722
+ const folderName = basename(dir);
2723
+ // Check for multiple .otel.canvas files in the same directory
2724
+ if (otel.length > 1) {
2725
+ for (const filePath of otel) {
2726
+ const result = results.find((r) => r.file === filePath);
2727
+ if (result) {
2728
+ result.isValid = false;
2729
+ result.issues.push({
2730
+ type: 'error',
2731
+ message: `Multiple .otel.canvas files in the same directory: ${otel
2732
+ .map((f) => basename(f))
2733
+ .join(', ')}`,
2734
+ path: filePath,
2735
+ suggestion: `A storyboard folder should contain only one .otel.canvas file. Move additional canvases to separate storyboard folders.`,
2736
+ });
2737
+ }
2738
+ }
2739
+ }
2740
+ // If there's an .otel.canvas in the folder:
2741
+ if (otel.length > 0) {
2742
+ // No .canvas files allowed in the same folder
2743
+ if (regular.length > 0) {
2744
+ for (const filePath of regular) {
2745
+ const result = results.find((r) => r.file === filePath);
2746
+ if (result) {
2747
+ result.isValid = false;
2748
+ result.issues.push({
2749
+ type: 'error',
2750
+ message: `Cannot have .canvas files in a folder with .otel.canvas`,
2751
+ path: filePath,
2752
+ suggestion: `Remove this .canvas file or move it to a different folder. The .otel.canvas format supports both architectural and instrumented nodes.`,
2753
+ });
2754
+ }
2755
+ }
2756
+ }
2757
+ // .otel.canvas filename must match folder name
2758
+ for (const filePath of otel) {
2759
+ const fileName = basename(filePath, '.otel.canvas');
2760
+ if (fileName !== folderName) {
2761
+ const result = results.find((r) => r.file === filePath);
2762
+ if (result) {
2763
+ result.isValid = false;
2764
+ result.issues.push({
2765
+ type: 'error',
2766
+ message: `Canvas filename "${fileName}" does not match folder name "${folderName}"`,
2767
+ path: filePath,
2768
+ suggestion: `Rename to "${folderName}.otel.canvas" to match the storyboard folder name.`,
2769
+ });
2770
+ }
2771
+ }
2772
+ }
2773
+ }
2774
+ }
2775
+ return outputResults(results, null, options, true);
2776
+ }
2777
+ // Determine which file types to validate
2778
+ const validateCanvases = !options.workflowOnly && !options.executionOnly;
2779
+ const validateWorkflows = !options.canvasOnly && !options.executionOnly;
2780
+ const validateExecutions = !options.canvasOnly && !options.workflowOnly;
2781
+ // Use CanvasDiscovery to find all canvases (including storyboards)
2782
+ const compositionFsAdapter = new CompositionFsAdapter();
2783
+ const service = new FilesystemService(compositionFsAdapter);
2784
+ const fileTree = await service.buildFileSystemTreeFromPath(repositoryPath);
2785
+ const fileReader = async (path) => readFile(resolve(repositoryPath, path), 'utf-8');
2786
+ const discovery = new CanvasDiscovery();
2787
+ const discoveryResult = await discovery.discover(fileTree, {
2788
+ fileReader,
2789
+ includeContent: true,
2790
+ });
2791
+ // Use LibraryDiscovery to validate scopes canvas requirements
2792
+ const repoFsAdapter = new NodeFileSystemAdapter();
2793
+ const libraryDiscovery = new LibraryDiscovery(repoFsAdapter);
2794
+ const libraryDiscoveryResult = await libraryDiscovery.discover(fileTree, {
2795
+ fileReader,
2796
+ });
2797
+ // Workflows and test traces are discovered by CanvasDiscovery
2798
+ // Extract them from the discovery result
2799
+ const workflows = validateWorkflows
2800
+ ? discoveryResult.storyboards.flatMap((sb) => sb.workflows)
2801
+ : [];
2802
+ const testTraces = validateExecutions ? discoveryResult.testTraces : [];
2803
+ // Dashboards are always validated when canvases are validated
2804
+ const dashboards = validateCanvases ? discoveryResult.dashboards : [];
2805
+ // Check if any files were found
2806
+ const canvasCount = discoveryResult.canvases.length;
2807
+ const totalFiles = canvasCount + workflows.length + testTraces.length + dashboards.length;
2808
+ if (totalFiles === 0) {
2809
+ if (options.json) {
2810
+ console.log(JSON.stringify({
2811
+ files: [],
2812
+ discoveryErrors: discoveryResult.errors,
2813
+ summary: {
2814
+ total: 0,
2815
+ valid: 0,
2816
+ invalid: 0,
2817
+ byType: { canvas: 0, workflow: 0, testTrace: 0, library: 0, dashboard: 0 },
2818
+ },
2819
+ }));
2820
+ }
2821
+ else {
2822
+ console.log(chalk.yellow('No Principal View files found.'));
2823
+ if (discoveryResult.errors.length > 0) {
2824
+ console.log(chalk.red('\nDiscovery errors:'));
2825
+ discoveryResult.errors.forEach((err) => {
2826
+ console.log(chalk.red(` ✗ ${err.path}: ${err.error}`));
2827
+ });
2828
+ }
2829
+ console.log(chalk.dim('\nTo create a new .principal-views folder, run: npx @principal-ai/principal-studio-cli init'));
2830
+ }
2831
+ return;
2832
+ }
2833
+ // Load library from .principal-views directory (used for type validation)
2834
+ const principalViewsDir = resolve(repositoryPath, '.principal-views');
2835
+ const library = loadLibrary(principalViewsDir);
2836
+ // Validate library if present
2837
+ let libraryResult = null;
2838
+ if (library && Object.keys(library.raw).length > 0) {
2839
+ const libraryIssues = validateLibrary(library);
2840
+ // Add scopes canvas requirement errors from LibraryDiscovery
2841
+ for (const error of libraryDiscoveryResult.errors) {
2842
+ if (error.type === 'scopes-canvas-required') {
2843
+ libraryIssues.push({
2844
+ type: 'error',
2845
+ message: error.message,
2846
+ path: relative(repositoryPath, error.path),
2847
+ suggestion: 'Create a .scopes.canvas file (e.g., architecture.scopes.canvas) with nodes for each scope.',
2848
+ });
2849
+ }
2850
+ }
2851
+ // Validate scope events canvases (check against scopes canvas)
2852
+ const scopesCanvas = discoveryResult.canvases.find(c => c.type === 'scopes');
2853
+ if (scopesCanvas) {
2854
+ // Load the scopes canvas content
2855
+ let scopesCanvasContent;
2856
+ try {
2857
+ const scopesCanvasPath = resolve(repositoryPath, scopesCanvas.path);
2858
+ const scopesCanvasData = readFileSync(scopesCanvasPath, 'utf-8');
2859
+ scopesCanvasContent = JSON.parse(scopesCanvasData);
2860
+ }
2861
+ catch {
2862
+ // Skip if we can't load the scopes canvas
2863
+ }
2864
+ if (scopesCanvasContent) {
2865
+ const scopeEventsValidator = new ScopeEventsValidator(new NodeFileSystemAdapter());
2866
+ const scopeEventsResult = await scopeEventsValidator.validate({
2867
+ scopesCanvas: scopesCanvasContent,
2868
+ scopesCanvasPath: scopesCanvas.path,
2869
+ basePath: repositoryPath,
2870
+ });
2871
+ for (const violation of scopeEventsResult.violations) {
2872
+ libraryIssues.push({
2873
+ type: violation.severity === 'error' ? 'error' : 'warning',
2874
+ message: violation.message,
2875
+ path: violation.expectedPath,
2876
+ suggestion: violation.suggestion,
2877
+ });
2878
+ }
2879
+ }
2880
+ }
2881
+ const libraryHasErrors = libraryIssues.some((i) => i.type === 'error');
2882
+ libraryResult = {
2883
+ file: relative(repositoryPath, library.path),
2884
+ fileType: 'library',
2885
+ isValid: !libraryHasErrors,
2886
+ issues: libraryIssues,
2887
+ };
2888
+ }
2889
+ // Convert discovery results to validation results
2890
+ const results = [];
2891
+ // Add discovery errors as validation failures
2892
+ for (const error of discoveryResult.errors) {
2893
+ // Skip if path doesn't look like a canvas file at all
2894
+ if (!error.path.endsWith('.canvas') && !error.path.endsWith('.otel.canvas')) {
2895
+ continue;
2896
+ }
2897
+ results.push({
2898
+ file: error.path,
2899
+ fileType: 'canvas',
2900
+ isValid: false,
2901
+ issues: [
2902
+ {
2903
+ type: 'error',
2904
+ message: error.error,
2905
+ path: error.path,
2906
+ },
2907
+ ],
2908
+ });
2909
+ }
2910
+ // Add discovery warnings
2911
+ for (const warning of discoveryResult.warnings) {
2912
+ // Skip if path doesn't look like a canvas file at all
2913
+ if (!warning.path.endsWith('.canvas') && !warning.path.endsWith('.otel.canvas')) {
2914
+ continue;
2915
+ }
2916
+ // Find existing result for this path or create new one
2917
+ let result = results.find((r) => r.file === warning.path);
2918
+ if (!result) {
2919
+ result = {
2920
+ file: warning.path,
2921
+ fileType: 'canvas',
2922
+ isValid: true,
2923
+ issues: [],
2924
+ };
2925
+ results.push(result);
2926
+ }
2927
+ result.issues.push({
2928
+ type: 'warning',
2929
+ message: warning.message,
2930
+ path: warning.path,
2931
+ });
2932
+ }
2933
+ // Group canvases by directory for validation
2934
+ const canvasesByDir = new Map();
2935
+ for (const canvas of discoveryResult.canvases) {
2936
+ const dir = dirname(canvas.path);
2937
+ if (!canvasesByDir.has(dir)) {
2938
+ canvasesByDir.set(dir, { otel: [], regular: [] });
2939
+ }
2940
+ const entry = canvasesByDir.get(dir);
2941
+ if (canvas.type === 'otel') {
2942
+ entry.otel.push(canvas.path);
2943
+ }
2944
+ else {
2945
+ entry.regular.push(canvas.path);
2946
+ }
2947
+ }
2948
+ for (const [dir, { otel, regular }] of canvasesByDir.entries()) {
2949
+ const folderName = basename(dir);
2950
+ // Check for multiple .otel.canvas files in the same directory
2951
+ if (otel.length > 1) {
2952
+ for (const filePath of otel) {
2953
+ let result = results.find((r) => r.file === filePath);
2954
+ if (!result) {
2955
+ result = {
2956
+ file: filePath,
2957
+ fileType: 'canvas',
2958
+ isValid: false,
2959
+ issues: [],
2960
+ };
2961
+ results.push(result);
2962
+ }
2963
+ result.isValid = false;
2964
+ result.issues.push({
2965
+ type: 'error',
2966
+ message: `Multiple .otel.canvas files in the same directory: ${otel
2967
+ .map((f) => basename(f))
2968
+ .join(', ')}`,
2969
+ path: filePath,
2970
+ suggestion: `A storyboard folder should contain only one .otel.canvas file. Move additional canvases to separate storyboard folders.`,
2971
+ });
2972
+ }
2973
+ }
2974
+ // If there's an .otel.canvas in the folder:
2975
+ if (otel.length > 0) {
2976
+ // No .canvas files allowed in the same folder
2977
+ if (regular.length > 0) {
2978
+ for (const filePath of regular) {
2979
+ let result = results.find((r) => r.file === filePath);
2980
+ if (!result) {
2981
+ result = {
2982
+ file: filePath,
2983
+ fileType: 'canvas',
2984
+ isValid: false,
2985
+ issues: [],
2986
+ };
2987
+ results.push(result);
2988
+ }
2989
+ result.isValid = false;
2990
+ result.issues.push({
2991
+ type: 'error',
2992
+ message: `Cannot have .canvas files in a folder with .otel.canvas`,
2993
+ path: filePath,
2994
+ suggestion: `Remove this .canvas file or move it to a different folder. The .otel.canvas format supports both architectural and instrumented nodes.`,
2995
+ });
2996
+ }
2997
+ }
2998
+ // .otel.canvas filename must match folder name
2999
+ for (const filePath of otel) {
3000
+ const fileName = basename(filePath, '.otel.canvas');
3001
+ if (fileName !== folderName) {
3002
+ let result = results.find((r) => r.file === filePath);
3003
+ if (!result) {
3004
+ result = {
3005
+ file: filePath,
3006
+ fileType: 'canvas',
3007
+ isValid: false,
3008
+ issues: [],
3009
+ };
3010
+ results.push(result);
3011
+ }
3012
+ result.isValid = false;
3013
+ result.issues.push({
3014
+ type: 'error',
3015
+ message: `Canvas filename "${fileName}" does not match folder name "${folderName}"`,
3016
+ path: filePath,
3017
+ suggestion: `Rename to "${folderName}.otel.canvas" to match the storyboard folder name.`,
3018
+ });
3019
+ }
3020
+ }
3021
+ }
3022
+ }
3023
+ // Helper to extract storyboard name from a path
3024
+ const extractStoryboardName = (filePath) => {
3025
+ const parts = filePath.split('/');
3026
+ const pvIndex = parts.indexOf('.principal-views');
3027
+ if (pvIndex === -1 || parts.length < pvIndex + 2) {
3028
+ return null;
3029
+ }
3030
+ return parts[pvIndex + 1];
3031
+ };
3032
+ // PHASE 1: Group workflows by canvas and collect all events used
3033
+ // Only include co-located workflows (same storyboard folder) for event coverage
3034
+ const workflowsByCanvas = new Map();
3035
+ for (const discoveredWorkflow of workflows) {
3036
+ const absolutePath = resolve(repositoryPath, discoveredWorkflow.path);
3037
+ const workflow = loadWorkflowTemplate(absolutePath);
3038
+ if (!workflow || !workflow.canvas)
3039
+ continue;
3040
+ // Canvas paths are always relative to repository root
3041
+ const canvasPath = resolve(repositoryPath, workflow.canvas);
3042
+ const canvasKey = relative(repositoryPath, canvasPath);
3043
+ // Only include events from workflows that are co-located with the canvas
3044
+ // (same storyboard folder). Cross-referenced workflows are validated separately.
3045
+ const workflowStoryboard = extractStoryboardName(discoveredWorkflow.path);
3046
+ const canvasStoryboard = extractStoryboardName(workflow.canvas);
3047
+ if (workflowStoryboard !== canvasStoryboard) {
3048
+ // Skip cross-referenced workflows for event coverage calculation
3049
+ continue;
3050
+ }
3051
+ // Collect events from this workflow
3052
+ if (!workflowsByCanvas.has(canvasKey)) {
3053
+ workflowsByCanvas.set(canvasKey, new Set());
3054
+ }
3055
+ const workflowEvents = workflowsByCanvas.get(canvasKey);
3056
+ for (const scenario of workflow.scenarios) {
3057
+ if (scenario.template?.events) {
3058
+ for (const eventName of Object.keys(scenario.template.events)) {
3059
+ if (!eventName.includes('*')) {
3060
+ workflowEvents.add(eventName);
3061
+ }
3062
+ }
3063
+ }
3064
+ }
3065
+ }
3066
+ // PHASE 2: Validate all canvas files and collect parsed canvases
3067
+ const parsedCanvases = new Map();
3068
+ // Load scopes canvas for cross-validation
3069
+ let scopesCanvasContent;
3070
+ const scopesCanvasPath = resolve(repositoryPath, '.principal-views/architecture.scopes.canvas');
3071
+ if (existsSync(scopesCanvasPath)) {
3072
+ try {
3073
+ const scopesCanvasData = readFileSync(scopesCanvasPath, 'utf-8');
3074
+ scopesCanvasContent = JSON.parse(scopesCanvasData);
3075
+ }
3076
+ catch {
3077
+ // Skip if we can't load the scopes canvas
3078
+ }
3079
+ }
3080
+ if (validateCanvases) {
3081
+ for (const canvas of discoveryResult.canvases) {
3082
+ // Check if we already have a result for this canvas (from discovery errors)
3083
+ const existingResult = results.find((r) => r.file === canvas.path);
3084
+ if (existingResult) {
3085
+ // Already has errors/warnings, skip validation
3086
+ continue;
3087
+ }
3088
+ const validationResult = validateFile(canvas.path, library, repositoryPath, scopesCanvasContent);
3089
+ results.push(validationResult);
3090
+ // Collect parsed canvas for EventRegistry
3091
+ if (validationResult.canvas) {
3092
+ parsedCanvases.set(canvas.path, validationResult.canvas);
3093
+ }
3094
+ }
3095
+ // Build dashboard validation context from discovered storyboards
3096
+ // This enables cross-reference validation of source storyboard/workflow references
3097
+ const dashboardContext = {
3098
+ storyboards: discoveryResult.storyboards.map((sb) => sb.basename),
3099
+ workflows: Object.fromEntries(discoveryResult.storyboards.map((sb) => [
3100
+ sb.basename,
3101
+ sb.workflows.map((wf) => wf.name),
3102
+ ])),
3103
+ };
3104
+ // Validate dashboard files from .principal-views/dashboards/
3105
+ for (const dashboard of dashboards) {
3106
+ const absolutePath = resolve(repositoryPath, dashboard.path);
3107
+ const validationResult = validateDashboard(absolutePath, repositoryPath, dashboardContext);
3108
+ results.push(validationResult);
3109
+ }
3110
+ }
3111
+ // Build EventRegistry from library and all parsed canvases
3112
+ const componentLibrary = library?.raw;
3113
+ const eventRegistry = EventRegistry.build(componentLibrary, parsedCanvases, library?.path);
3114
+ // PHASE 2.5a: Check that .otel.canvas files have co-located workflows
3115
+ // Build a set of canvas paths that have co-located workflows
3116
+ const canvasesWithColocatedWorkflows = new Set();
3117
+ for (const discoveredWorkflow of workflows) {
3118
+ const absolutePath = resolve(repositoryPath, discoveredWorkflow.path);
3119
+ const workflow = loadWorkflowTemplate(absolutePath);
3120
+ if (!workflow || !workflow.canvas)
3121
+ continue;
3122
+ const workflowStoryboard = extractStoryboardName(discoveredWorkflow.path);
3123
+ const canvasStoryboard = extractStoryboardName(workflow.canvas);
3124
+ // Only count as co-located if in same storyboard
3125
+ if (workflowStoryboard === canvasStoryboard) {
3126
+ canvasesWithColocatedWorkflows.add(workflow.canvas);
3127
+ }
3128
+ }
3129
+ // Check each .otel.canvas storyboard for missing workflows
3130
+ for (const storyboard of discoveryResult.storyboards) {
3131
+ if (storyboard.canvas.type !== 'otel')
3132
+ continue;
3133
+ if (!canvasesWithColocatedWorkflows.has(storyboard.canvas.path)) {
3134
+ // Find or create result for this canvas
3135
+ let result = results.find((r) => r.file === storyboard.canvas.path);
3136
+ if (!result) {
3137
+ result = {
3138
+ file: storyboard.canvas.path,
3139
+ fileType: 'canvas',
3140
+ isValid: false,
3141
+ issues: [],
3142
+ };
3143
+ results.push(result);
3144
+ }
3145
+ result.isValid = false;
3146
+ result.issues.push({
3147
+ type: 'error',
3148
+ message: `No workflows found for this .otel.canvas in the "${storyboard.basename}" storyboard`,
3149
+ path: storyboard.canvas.path,
3150
+ suggestion: `Create at least one workflow file in ".principal-views/${storyboard.basename}/${storyboard.basename}-workflow/" that references this canvas. ` +
3151
+ `Workflows define how events in the canvas are visualized during trace playback.`,
3152
+ });
3153
+ }
3154
+ }
3155
+ // PHASE 2.5b: Cross-workflow validation (duplicate spanPatterns)
3156
+ if (validateWorkflows && workflows.length > 0) {
3157
+ // Collect all workflows with their templates
3158
+ const workflowsForSpanPatternValidation = [];
3159
+ for (const discoveredWorkflow of workflows) {
3160
+ const absolutePath = resolve(repositoryPath, discoveredWorkflow.path);
3161
+ const workflow = loadWorkflowTemplate(absolutePath);
3162
+ if (workflow) {
3163
+ workflowsForSpanPatternValidation.push({
3164
+ workflow,
3165
+ workflowPath: discoveredWorkflow.path,
3166
+ });
3167
+ }
3168
+ }
3169
+ // Validate for duplicate spanPatterns
3170
+ const spanPatternViolations = WorkflowValidator.validateSpanPatterns(workflowsForSpanPatternValidation);
3171
+ // Add violations to results
3172
+ for (const violation of spanPatternViolations) {
3173
+ // Find or create result for this workflow file
3174
+ let result = results.find((r) => r.file === violation.file);
3175
+ if (!result) {
3176
+ result = {
3177
+ file: violation.file,
3178
+ fileType: 'workflow',
3179
+ isValid: false,
3180
+ issues: [],
3181
+ };
3182
+ results.push(result);
3183
+ }
3184
+ // Add the violation as an issue
3185
+ result.issues.push({
3186
+ type: violation.severity === 'error' ? 'error' : 'warning',
3187
+ message: violation.message,
3188
+ path: violation.path,
3189
+ suggestion: violation.suggestion,
3190
+ });
3191
+ // Mark as invalid if it's an error
3192
+ if (violation.severity === 'error') {
3193
+ result.isValid = false;
3194
+ }
3195
+ }
3196
+ }
3197
+ // PHASE 2.5c: Cross-validate spans.canvas ↔ workflow.json
3198
+ // - implemented span conventions must have a workflow.json
3199
+ // - draft span conventions with a workflow.json should be changed to implemented
3200
+ if (validateCanvases && validateWorkflows) {
3201
+ // Find spans.canvas files and extract span conventions
3202
+ const spansCanvases = discoveryResult.canvases.filter((c) => c.path.endsWith('.spans.canvas'));
3203
+ // Collect all workflow spanPatterns
3204
+ const workflowSpanPatterns = new Map(); // spanPattern -> workflow path
3205
+ for (const discoveredWorkflow of workflows) {
3206
+ const absolutePath = resolve(repositoryPath, discoveredWorkflow.path);
3207
+ const workflow = loadWorkflowTemplate(absolutePath);
3208
+ if (workflow?.spanPattern) {
3209
+ workflowSpanPatterns.set(workflow.spanPattern, discoveredWorkflow.path);
3210
+ }
3211
+ }
3212
+ // If workflows with spanPatterns exist but no spans.canvas, error on each workflow
3213
+ if (workflowSpanPatterns.size > 0 && spansCanvases.length === 0) {
3214
+ for (const [spanPattern, workflowPath] of workflowSpanPatterns) {
3215
+ let result = results.find((r) => r.file === workflowPath);
3216
+ if (!result) {
3217
+ result = {
3218
+ file: workflowPath,
3219
+ fileType: 'workflow',
3220
+ isValid: false,
3221
+ issues: [],
3222
+ };
3223
+ results.push(result);
3224
+ }
3225
+ result.issues.push({
3226
+ type: 'error',
3227
+ message: `Workflow defines spanPattern "${spanPattern}" but no spans.canvas file exists`,
3228
+ path: 'spanPattern',
3229
+ suggestion: 'Create a .spans.canvas file (e.g., ".principal-views/architecture.spans.canvas") to define span conventions for your project',
3230
+ });
3231
+ result.isValid = false;
3232
+ }
3233
+ }
3234
+ for (const spansCanvas of spansCanvases) {
3235
+ const absolutePath = resolve(repositoryPath, spansCanvas.path);
3236
+ let canvas = null;
3237
+ try {
3238
+ const content = readFileSync(absolutePath, 'utf-8');
3239
+ canvas = JSON.parse(content);
3240
+ }
3241
+ catch {
3242
+ // Skip - parse errors handled elsewhere
3243
+ continue;
3244
+ }
3245
+ if (!canvas?.nodes)
3246
+ continue;
3247
+ // Extract span conventions from nodes
3248
+ for (const node of canvas.nodes) {
3249
+ // Only process OTEL span convention nodes (new format)
3250
+ if (node.type !== 'otel-span-convention')
3251
+ continue;
3252
+ const spanNode = node;
3253
+ const spanPattern = spanNode.otel?.spanPattern;
3254
+ const status = spanNode.otel?.status;
3255
+ const nodeLabel = spanNode.label || node.id || 'unknown';
3256
+ if (!spanPattern)
3257
+ continue; // Skip nodes without spanPattern
3258
+ // Find workflows that match this span convention (with wildcard support)
3259
+ const matchingWorkflows = findMatchingWorkflows(spanPattern, workflowSpanPatterns);
3260
+ const hasWorkflow = matchingWorkflows.length > 0;
3261
+ // Find or create result for spans.canvas
3262
+ let result = results.find((r) => r.file === spansCanvas.path);
3263
+ if (!result) {
3264
+ result = {
3265
+ file: spansCanvas.path,
3266
+ fileType: 'canvas',
3267
+ isValid: true,
3268
+ issues: [],
3269
+ };
3270
+ results.push(result);
3271
+ }
3272
+ if (status === 'implemented' && !hasWorkflow) {
3273
+ // implemented span conventions must have a workflow.json
3274
+ result.issues.push({
3275
+ type: 'error',
3276
+ message: `Span convention "${spanPattern}" is implemented but has no workflow.json`,
3277
+ path: `nodes.${nodeLabel}.pv.otel.spanPattern`,
3278
+ suggestion: `Create a workflow.json with spanPattern: "${spanPattern}" or change status to "draft"`,
3279
+ });
3280
+ result.isValid = false;
3281
+ }
3282
+ else if (status === 'draft' && hasWorkflow) {
3283
+ // draft span conventions with a workflow.json should be changed to implemented
3284
+ const workflowPaths = matchingWorkflows.map((w) => w.path).join(', ');
3285
+ result.issues.push({
3286
+ type: 'error',
3287
+ message: `Span convention "${spanPattern}" has matching workflow(s) (${workflowPaths}) but is marked as draft`,
3288
+ path: `nodes.${nodeLabel}.pv.status`,
3289
+ suggestion: `Change status to "implemented" since workflow(s) already exist`,
3290
+ });
3291
+ result.isValid = false;
3292
+ }
3293
+ }
3294
+ }
3295
+ }
3296
+ // PHASE 3: Validate workflows with canvas-wide event knowledge
3297
+ if (validateWorkflows) {
3298
+ for (const discoveredWorkflow of workflows) {
3299
+ const absolutePath = resolve(repositoryPath, discoveredWorkflow.path);
3300
+ const workflow = loadWorkflowTemplate(absolutePath);
3301
+ if (!workflow)
3302
+ continue;
3303
+ // Canvas paths are always relative to repository root
3304
+ const canvasPath = workflow.canvas
3305
+ ? resolve(repositoryPath, workflow.canvas)
3306
+ : undefined;
3307
+ const canvasKey = canvasPath ? relative(repositoryPath, canvasPath) : undefined;
3308
+ const allWorkflowEvents = canvasKey ? workflowsByCanvas.get(canvasKey) : undefined;
3309
+ // Get co-located test traces for this workflow
3310
+ const executionFiles = discoveredWorkflow.testTraces.map((tt) => resolve(repositoryPath, tt.path));
3311
+ const validationResult = await validateWorkflow(absolutePath, allWorkflowEvents, repositoryPath, executionFiles, eventRegistry);
3312
+ results.push(validationResult);
3313
+ }
3314
+ }
3315
+ // PHASE 4: Validate test trace artifacts
3316
+ if (validateExecutions) {
3317
+ for (const testTrace of testTraces) {
3318
+ const absolutePath = resolve(repositoryPath, testTrace.path);
3319
+ const validationResult = validateExecution(absolutePath, repositoryPath);
3320
+ results.push(validationResult);
3321
+ }
3322
+ }
3323
+ // Cross-canvas path enforcement (discovery branch).
3324
+ runOtelEventPathsValidation(results, repositoryPath);
3325
+ // Output results using helper function
3326
+ outputResults(results, libraryResult, options, false);
3327
+ }
3328
+ catch (error) {
3329
+ console.error(chalk.red('Error:'), error.message);
3330
+ process.exit(1);
3331
+ }
3332
+ });
3333
+ return command;
3334
+ }