@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.
- package/README.md +172 -0
- package/dist/commands/agent-session.d.ts +3 -0
- package/dist/commands/agent-session.d.ts.map +1 -0
- package/dist/commands/agent-session.js +67 -0
- package/dist/commands/agent-sessions.d.ts +11 -0
- package/dist/commands/agent-sessions.d.ts.map +1 -0
- package/dist/commands/agent-sessions.js +39 -0
- package/dist/commands/auxiliary/index.d.ts +3 -0
- package/dist/commands/auxiliary/index.d.ts.map +1 -0
- package/dist/commands/auxiliary/index.js +9 -0
- package/dist/commands/auxiliary/validate.d.ts +3 -0
- package/dist/commands/auxiliary/validate.d.ts.map +1 -0
- package/dist/commands/auxiliary/validate.js +123 -0
- package/dist/commands/collector/check.d.ts +6 -0
- package/dist/commands/collector/check.d.ts.map +1 -0
- package/dist/commands/collector/check.js +153 -0
- package/dist/commands/collector/diagnose.d.ts +6 -0
- package/dist/commands/collector/diagnose.d.ts.map +1 -0
- package/dist/commands/collector/diagnose.js +229 -0
- package/dist/commands/collector/index.d.ts +6 -0
- package/dist/commands/collector/index.d.ts.map +1 -0
- package/dist/commands/collector/index.js +16 -0
- package/dist/commands/collector/status.d.ts +6 -0
- package/dist/commands/collector/status.d.ts.map +1 -0
- package/dist/commands/collector/status.js +77 -0
- package/dist/commands/collector/utils.d.ts +92 -0
- package/dist/commands/collector/utils.d.ts.map +1 -0
- package/dist/commands/collector/utils.js +154 -0
- package/dist/commands/coverage.d.ts +9 -0
- package/dist/commands/coverage.d.ts.map +1 -0
- package/dist/commands/coverage.js +252 -0
- package/dist/commands/create.d.ts +6 -0
- package/dist/commands/create.d.ts.map +1 -0
- package/dist/commands/create.js +50 -0
- package/dist/commands/events/index.d.ts +3 -0
- package/dist/commands/events/index.d.ts.map +1 -0
- package/dist/commands/events/index.js +9 -0
- package/dist/commands/events/validate.d.ts +3 -0
- package/dist/commands/events/validate.d.ts.map +1 -0
- package/dist/commands/events/validate.js +124 -0
- package/dist/commands/formats.d.ts +6 -0
- package/dist/commands/formats.d.ts.map +1 -0
- package/dist/commands/formats.js +1118 -0
- package/dist/commands/hooks.d.ts +9 -0
- package/dist/commands/hooks.d.ts.map +1 -0
- package/dist/commands/hooks.js +295 -0
- package/dist/commands/inbox.d.ts +13 -0
- package/dist/commands/inbox.d.ts.map +1 -0
- package/dist/commands/inbox.js +179 -0
- package/dist/commands/init.d.ts +6 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +220 -0
- package/dist/commands/lint.d.ts +11 -0
- package/dist/commands/lint.d.ts.map +1 -0
- package/dist/commands/lint.js +71 -0
- package/dist/commands/list.d.ts +6 -0
- package/dist/commands/list.d.ts.map +1 -0
- package/dist/commands/list.js +78 -0
- package/dist/commands/migrate-nodes.d.ts +9 -0
- package/dist/commands/migrate-nodes.d.ts.map +1 -0
- package/dist/commands/migrate-nodes.js +363 -0
- package/dist/commands/migrate-scopes-to-canvas.d.ts +3 -0
- package/dist/commands/migrate-scopes-to-canvas.d.ts.map +1 -0
- package/dist/commands/migrate-scopes-to-canvas.js +143 -0
- package/dist/commands/migration.d.ts +6 -0
- package/dist/commands/migration.d.ts.map +1 -0
- package/dist/commands/migration.js +247 -0
- package/dist/commands/open-studio.d.ts +10 -0
- package/dist/commands/open-studio.d.ts.map +1 -0
- package/dist/commands/open-studio.js +32 -0
- package/dist/commands/opencode/fetch.d.ts +3 -0
- package/dist/commands/opencode/fetch.d.ts.map +1 -0
- package/dist/commands/opencode/fetch.js +25 -0
- package/dist/commands/opencode/index.d.ts +3 -0
- package/dist/commands/opencode/index.d.ts.map +1 -0
- package/dist/commands/opencode/index.js +11 -0
- package/dist/commands/opencode/list-sessions.d.ts +3 -0
- package/dist/commands/opencode/list-sessions.d.ts.map +1 -0
- package/dist/commands/opencode/list-sessions.js +20 -0
- package/dist/commands/repo.d.ts +18 -0
- package/dist/commands/repo.d.ts.map +1 -0
- package/dist/commands/repo.js +233 -0
- package/dist/commands/schema.d.ts +6 -0
- package/dist/commands/schema.d.ts.map +1 -0
- package/dist/commands/schema.js +367 -0
- package/dist/commands/scopes/index.d.ts +3 -0
- package/dist/commands/scopes/index.d.ts.map +1 -0
- package/dist/commands/scopes/index.js +9 -0
- package/dist/commands/scopes/validate.d.ts +3 -0
- package/dist/commands/scopes/validate.d.ts.map +1 -0
- package/dist/commands/scopes/validate.js +187 -0
- package/dist/commands/setup.d.ts +6 -0
- package/dist/commands/setup.d.ts.map +1 -0
- package/dist/commands/setup.js +406 -0
- package/dist/commands/starred-collections.d.ts +12 -0
- package/dist/commands/starred-collections.d.ts.map +1 -0
- package/dist/commands/starred-collections.js +310 -0
- package/dist/commands/subsystem-model.d.ts +11 -0
- package/dist/commands/subsystem-model.d.ts.map +1 -0
- package/dist/commands/subsystem-model.js +249 -0
- package/dist/commands/topic.d.ts +15 -0
- package/dist/commands/topic.d.ts.map +1 -0
- package/dist/commands/topic.js +683 -0
- package/dist/commands/tour.d.ts +23 -0
- package/dist/commands/tour.d.ts.map +1 -0
- package/dist/commands/tour.js +783 -0
- package/dist/commands/trace/flow.d.ts +6 -0
- package/dist/commands/trace/flow.d.ts.map +1 -0
- package/dist/commands/trace/flow.js +158 -0
- package/dist/commands/trace/index.d.ts +6 -0
- package/dist/commands/trace/index.d.ts.map +1 -0
- package/dist/commands/trace/index.js +20 -0
- package/dist/commands/trace/inspect.d.ts +6 -0
- package/dist/commands/trace/inspect.d.ts.map +1 -0
- package/dist/commands/trace/inspect.js +187 -0
- package/dist/commands/trace/list.d.ts +6 -0
- package/dist/commands/trace/list.d.ts.map +1 -0
- package/dist/commands/trace/list.js +145 -0
- package/dist/commands/trace/registrations.d.ts +6 -0
- package/dist/commands/trace/registrations.d.ts.map +1 -0
- package/dist/commands/trace/registrations.js +81 -0
- package/dist/commands/trace/validate.d.ts +6 -0
- package/dist/commands/trace/validate.d.ts.map +1 -0
- package/dist/commands/trace/validate.js +276 -0
- package/dist/commands/trail.d.ts +36 -0
- package/dist/commands/trail.d.ts.map +1 -0
- package/dist/commands/trail.js +622 -0
- package/dist/commands/validate.d.ts +12 -0
- package/dist/commands/validate.d.ts.map +1 -0
- package/dist/commands/validate.js +3334 -0
- package/dist/commands/workflow/implementation.d.ts +3 -0
- package/dist/commands/workflow/implementation.d.ts.map +1 -0
- package/dist/commands/workflow/implementation.js +51 -0
- package/dist/commands/workflow/index.d.ts +3 -0
- package/dist/commands/workflow/index.d.ts.map +1 -0
- package/dist/commands/workflow/index.js +19 -0
- package/dist/commands/workflow/inspect.d.ts +3 -0
- package/dist/commands/workflow/inspect.d.ts.map +1 -0
- package/dist/commands/workflow/inspect.js +123 -0
- package/dist/commands/workflow/list.d.ts +3 -0
- package/dist/commands/workflow/list.d.ts.map +1 -0
- package/dist/commands/workflow/list.js +95 -0
- package/dist/commands/workflow/render.d.ts +3 -0
- package/dist/commands/workflow/render.d.ts.map +1 -0
- package/dist/commands/workflow/render.js +88 -0
- package/dist/commands/workflow/test.d.ts +3 -0
- package/dist/commands/workflow/test.d.ts.map +1 -0
- package/dist/commands/workflow/test.js +141 -0
- package/dist/commands/workflow/utils.d.ts +49 -0
- package/dist/commands/workflow/utils.d.ts.map +1 -0
- package/dist/commands/workflow/utils.js +164 -0
- package/dist/commands/workflow/validate.d.ts +3 -0
- package/dist/commands/workflow/validate.d.ts.map +1 -0
- package/dist/commands/workflow/validate.js +344 -0
- package/dist/file-utils.d.ts +15 -0
- package/dist/file-utils.d.ts.map +1 -0
- package/dist/file-utils.js +29 -0
- package/dist/index.cjs +285718 -0
- package/dist/index.cjs.map +7 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +81 -0
- package/dist/lib/bridge-ipc.d.ts +29 -0
- package/dist/lib/bridge-ipc.d.ts.map +1 -0
- package/dist/lib/bridge-ipc.js +128 -0
- package/dist/lib/github-user.d.ts +17 -0
- package/dist/lib/github-user.d.ts.map +1 -0
- package/dist/lib/github-user.js +53 -0
- package/dist/lib/open-url.d.ts +11 -0
- package/dist/lib/open-url.d.ts.map +1 -0
- package/dist/lib/open-url.js +45 -0
- package/dist/lib/subsystem-model-store.d.ts +74 -0
- package/dist/lib/subsystem-model-store.d.ts.map +1 -0
- package/dist/lib/subsystem-model-store.js +292 -0
- package/dist/lib/tour-cache.d.ts +26 -0
- package/dist/lib/tour-cache.d.ts.map +1 -0
- package/dist/lib/tour-cache.js +46 -0
- package/dist/lib/trail-cache.d.ts +40 -0
- package/dist/lib/trail-cache.d.ts.map +1 -0
- package/dist/lib/trail-cache.js +97 -0
- package/dist/lib/viewer-ipc.d.ts +44 -0
- package/dist/lib/viewer-ipc.d.ts.map +1 -0
- package/dist/lib/viewer-ipc.js +68 -0
- 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
|
+
}
|