@principal-ai/principal-view-core 0.26.28 → 0.26.30

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.
@@ -0,0 +1,341 @@
1
+ /**
2
+ * Events Canvas Validator
3
+ *
4
+ * Validates that a .events.canvas file properly documents event namespaces
5
+ * and their events, with correct namespace extraction and node structure.
6
+ */
7
+
8
+ import type { ExtendedCanvas, ExtendedCanvasNode } from '../types/canvas';
9
+
10
+ /**
11
+ * Event namespace node structure
12
+ */
13
+ export interface EventNamespaceNode {
14
+ id: string;
15
+ type: 'event-namespace';
16
+ namespace: {
17
+ name: string;
18
+ description: string;
19
+ events: Array<{
20
+ name: string;
21
+ severity?: 'INFO' | 'WARN' | 'ERROR';
22
+ description?: string;
23
+ attributes?: Record<string, {
24
+ type: string;
25
+ required?: boolean;
26
+ description?: string;
27
+ }>;
28
+ }>;
29
+ };
30
+ // Standard canvas node fields
31
+ x: number;
32
+ y: number;
33
+ width: number;
34
+ height: number;
35
+ color?: string;
36
+ }
37
+
38
+ /**
39
+ * Events canvas validation context
40
+ */
41
+ export interface EventsCanvasValidationContext {
42
+ /** The events canvas (if found) */
43
+ eventsCanvas?: ExtendedCanvas;
44
+
45
+ /** Path to the events canvas file */
46
+ eventsCanvasPath?: string;
47
+
48
+ /** Base path for resolving relative paths */
49
+ basePath: string;
50
+
51
+ /** Optional: Workflow files to validate against */
52
+ workflowFiles?: Array<{
53
+ path: string;
54
+ events: string[];
55
+ }>;
56
+ }
57
+
58
+ /**
59
+ * Events canvas validation violation
60
+ */
61
+ export interface EventsCanvasViolation {
62
+ /** Rule ID that detected this violation */
63
+ ruleId: string;
64
+
65
+ /** Severity level */
66
+ severity: 'error' | 'warn';
67
+
68
+ /** File path where violation occurred */
69
+ file: string;
70
+
71
+ /** JSON path within file (optional) */
72
+ path?: string;
73
+
74
+ /** Human-readable description of what's wrong */
75
+ message: string;
76
+
77
+ /** Why this matters */
78
+ impact: string;
79
+
80
+ /** How to fix it */
81
+ suggestion: string;
82
+ }
83
+
84
+ /**
85
+ * Events canvas validation result
86
+ */
87
+ export interface EventsCanvasValidationResult {
88
+ /** Whether validation passed (no errors) */
89
+ valid: boolean;
90
+
91
+ /** List of violations found */
92
+ violations: EventsCanvasViolation[];
93
+
94
+ /** Coverage metrics */
95
+ metrics: {
96
+ /** Total unique namespaces found */
97
+ totalNamespaces: number;
98
+ /** Namespaces with nodes */
99
+ documentedNamespaces: string[];
100
+ /** Namespaces missing nodes */
101
+ missingNamespaces: string[];
102
+ /** Total events across all namespaces */
103
+ totalEvents: number;
104
+ /** Events properly registered in namespace nodes */
105
+ registeredEvents: string[];
106
+ /** Events not in any namespace node */
107
+ unregisteredEvents: string[];
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Validates events canvas files
113
+ */
114
+ export class EventsCanvasValidator {
115
+ /**
116
+ * Extract namespace from event name (all segments except last)
117
+ *
118
+ * @example
119
+ * extractNamespace('validation.started') // 'validation'
120
+ * extractNamespace('file.read.complete') // 'file.read'
121
+ * extractNamespace('error') // null (invalid, needs at least 2 segments)
122
+ */
123
+ private extractNamespace(eventName: string): string | null {
124
+ const segments = eventName.split('.');
125
+ if (segments.length < 2) {
126
+ return null; // Invalid event name
127
+ }
128
+ return segments.slice(0, -1).join('.');
129
+ }
130
+
131
+ /**
132
+ * Check if a node is an event-namespace node
133
+ */
134
+ private isEventNamespaceNode(node: any): node is EventNamespaceNode {
135
+ return node?.type === 'event-namespace' &&
136
+ node?.namespace?.name !== undefined;
137
+ }
138
+
139
+ /**
140
+ * Extract all events from namespace nodes
141
+ */
142
+ private extractEventsFromCanvas(
143
+ canvas: ExtendedCanvas
144
+ ): Map<string, Set<string>> {
145
+ const namespaceEvents = new Map<string, Set<string>>();
146
+
147
+ for (const node of canvas.nodes || []) {
148
+ if (this.isEventNamespaceNode(node)) {
149
+ const namespaceNode = node as EventNamespaceNode;
150
+ const namespace = namespaceNode.namespace.name;
151
+ const events = new Set<string>();
152
+
153
+ for (const event of namespaceNode.namespace.events || []) {
154
+ events.add(event.name);
155
+ }
156
+
157
+ namespaceEvents.set(namespace, events);
158
+ }
159
+ }
160
+
161
+ return namespaceEvents;
162
+ }
163
+
164
+ /**
165
+ * Build a map of event name → expected namespace
166
+ */
167
+ private buildEventNamespaceMap(
168
+ namespaceEvents: Map<string, Set<string>>
169
+ ): Map<string, string> {
170
+ const eventNamespaceMap = new Map<string, string>();
171
+
172
+ for (const [namespace, events] of namespaceEvents) {
173
+ for (const eventName of events) {
174
+ const extractedNamespace = this.extractNamespace(eventName);
175
+ eventNamespaceMap.set(eventName, extractedNamespace || '');
176
+ }
177
+ }
178
+
179
+ return eventNamespaceMap;
180
+ }
181
+
182
+ /**
183
+ * Validate an events canvas
184
+ */
185
+ async validate(
186
+ context: EventsCanvasValidationContext
187
+ ): Promise<EventsCanvasValidationResult> {
188
+ const violations: EventsCanvasViolation[] = [];
189
+ const { eventsCanvas, eventsCanvasPath, basePath } = context;
190
+
191
+ // Initialize metrics
192
+ const metrics = {
193
+ totalNamespaces: 0,
194
+ documentedNamespaces: [] as string[],
195
+ missingNamespaces: [] as string[],
196
+ totalEvents: 0,
197
+ registeredEvents: [] as string[],
198
+ unregisteredEvents: [] as string[],
199
+ };
200
+
201
+ // Check if canvas exists
202
+ if (!eventsCanvas) {
203
+ violations.push({
204
+ ruleId: 'events-canvas-required',
205
+ severity: 'error',
206
+ file: eventsCanvasPath || '.principal-views/cli.events.canvas',
207
+ message: 'Events canvas is required for documenting event namespaces',
208
+ impact: 'Cannot validate event structure or namespace organization',
209
+ suggestion: 'Create an events canvas with event-namespace nodes for each namespace',
210
+ });
211
+
212
+ return {
213
+ valid: false,
214
+ violations,
215
+ metrics,
216
+ };
217
+ }
218
+
219
+ // Extract namespace nodes and events
220
+ const namespaceEvents = this.extractEventsFromCanvas(eventsCanvas);
221
+ const namespaceNodes = new Set(namespaceEvents.keys());
222
+ metrics.documentedNamespaces = Array.from(namespaceNodes);
223
+ metrics.totalNamespaces = namespaceNodes.size;
224
+
225
+ // Collect all event names and their extracted namespaces
226
+ const allEvents = new Set<string>();
227
+ const eventToExtractedNamespace = new Map<string, string>();
228
+ const extractedNamespaces = new Set<string>();
229
+
230
+ for (const [namespace, events] of namespaceEvents) {
231
+ for (const eventName of events) {
232
+ allEvents.add(eventName);
233
+ metrics.totalEvents++;
234
+
235
+ // Extract namespace from event name
236
+ const extractedNamespace = this.extractNamespace(eventName);
237
+
238
+ if (!extractedNamespace) {
239
+ violations.push({
240
+ ruleId: 'events-invalid-event-name',
241
+ severity: 'error',
242
+ file: eventsCanvasPath || '.principal-views/cli.events.canvas',
243
+ path: `nodes[].namespace.events[name="${eventName}"]`,
244
+ message: `Event name "${eventName}" is invalid (must have at least 2 segments)`,
245
+ impact: 'Event name does not follow {namespace}.{action} convention',
246
+ suggestion: `Rename to follow pattern like "${namespace}.${eventName}"`,
247
+ });
248
+ continue;
249
+ }
250
+
251
+ eventToExtractedNamespace.set(eventName, extractedNamespace);
252
+ extractedNamespaces.add(extractedNamespace);
253
+
254
+ // Check namespace consistency: extracted namespace should match the node it's in
255
+ if (extractedNamespace !== namespace) {
256
+ violations.push({
257
+ ruleId: 'events-namespace-mismatch',
258
+ severity: 'error',
259
+ file: eventsCanvasPath || '.principal-views/cli.events.canvas',
260
+ path: `nodes[namespace.name="${namespace}"].namespace.events[name="${eventName}"]`,
261
+ message: `Event "${eventName}" is in wrong namespace node (expected: "${extractedNamespace}", actual: "${namespace}")`,
262
+ impact: 'Event is incorrectly organized, making namespace structure confusing',
263
+ suggestion: `Move event "${eventName}" to namespace node "${extractedNamespace}"`,
264
+ });
265
+ } else {
266
+ metrics.registeredEvents.push(eventName);
267
+ }
268
+ }
269
+ }
270
+
271
+ // Check for missing namespace nodes
272
+ for (const namespace of extractedNamespaces) {
273
+ if (!namespaceNodes.has(namespace)) {
274
+ metrics.missingNamespaces.push(namespace);
275
+ violations.push({
276
+ ruleId: 'events-namespace-node-missing',
277
+ severity: 'error',
278
+ file: eventsCanvasPath || '.principal-views/cli.events.canvas',
279
+ message: `Namespace "${namespace}" is missing a node in the canvas`,
280
+ impact: 'Cannot visualize or document this namespace group',
281
+ suggestion: `Add a node with type: "event-namespace" and namespace.name: "${namespace}"`,
282
+ });
283
+ }
284
+ }
285
+
286
+ // Validate namespace nodes have descriptions
287
+ for (const node of eventsCanvas.nodes || []) {
288
+ if (this.isEventNamespaceNode(node)) {
289
+ const namespaceNode = node as EventNamespaceNode;
290
+ if (!namespaceNode.namespace.description) {
291
+ violations.push({
292
+ ruleId: 'events-namespace-missing-description',
293
+ severity: 'warn',
294
+ file: eventsCanvasPath || '.principal-views/cli.events.canvas',
295
+ path: `nodes[id="${namespaceNode.id}"].namespace`,
296
+ message: `Namespace "${namespaceNode.namespace.name}" is missing a description`,
297
+ impact: 'Namespace purpose is undocumented',
298
+ suggestion: 'Add a description field explaining what events this namespace contains',
299
+ });
300
+ }
301
+
302
+ // Validate each event has description and severity
303
+ for (const event of namespaceNode.namespace.events || []) {
304
+ if (!event.description) {
305
+ violations.push({
306
+ ruleId: 'events-event-missing-description',
307
+ severity: 'warn',
308
+ file: eventsCanvasPath || '.principal-views/cli.events.canvas',
309
+ path: `nodes[id="${namespaceNode.id}"].namespace.events[name="${event.name}"]`,
310
+ message: `Event "${event.name}" is missing a description`,
311
+ impact: 'Event purpose is undocumented',
312
+ suggestion: 'Add a description field explaining when/why this event is emitted',
313
+ });
314
+ }
315
+
316
+ if (!event.severity) {
317
+ violations.push({
318
+ ruleId: 'events-event-missing-severity',
319
+ severity: 'warn',
320
+ file: eventsCanvasPath || '.principal-views/cli.events.canvas',
321
+ path: `nodes[id="${namespaceNode.id}"].namespace.events[name="${event.name}"]`,
322
+ message: `Event "${event.name}" is missing a severity level`,
323
+ impact: 'Cannot determine event criticality',
324
+ suggestion: 'Add severity field with value: "INFO", "WARN", or "ERROR"',
325
+ });
326
+ }
327
+ }
328
+ }
329
+ }
330
+
331
+ metrics.totalNamespaces = extractedNamespaces.size;
332
+
333
+ const errors = violations.filter(v => v.severity === 'error');
334
+
335
+ return {
336
+ valid: errors.length === 0,
337
+ violations,
338
+ metrics,
339
+ };
340
+ }
341
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Scope Events Validator
3
+ *
4
+ * Validates that each instrumentation scope documented in architecture.scopes.canvas
5
+ * has a corresponding {scope-name}.events.canvas file documenting its event vocabulary.
6
+ */
7
+
8
+ import type { ExtendedCanvas, OtelScopeNode } from '../types/canvas';
9
+ import { existsSync } from 'fs';
10
+ import { resolve, join } from 'path';
11
+
12
+ /**
13
+ * Scope events validation context
14
+ */
15
+ export interface ScopeEventsValidationContext {
16
+ /** The scopes canvas (if found) */
17
+ scopesCanvas?: ExtendedCanvas;
18
+
19
+ /** Path to the scopes canvas file */
20
+ scopesCanvasPath?: string;
21
+
22
+ /** Base path for resolving relative paths */
23
+ basePath: string;
24
+ }
25
+
26
+ /**
27
+ * Scope events validation violation
28
+ */
29
+ export interface ScopeEventsViolation {
30
+ /** Rule ID that detected this violation */
31
+ ruleId: string;
32
+
33
+ /** Severity level */
34
+ severity: 'error' | 'warn';
35
+
36
+ /** Scope name */
37
+ scope: string;
38
+
39
+ /** Expected file path */
40
+ expectedPath: string;
41
+
42
+ /** Human-readable description of what's wrong */
43
+ message: string;
44
+
45
+ /** Why this matters */
46
+ impact: string;
47
+
48
+ /** How to fix it */
49
+ suggestion: string;
50
+ }
51
+
52
+ /**
53
+ * Scope events validation result
54
+ */
55
+ export interface ScopeEventsValidationResult {
56
+ /** Whether validation passed (no errors) */
57
+ valid: boolean;
58
+
59
+ /** List of violations found */
60
+ violations: ScopeEventsViolation[];
61
+
62
+ /** Summary of events canvas coverage */
63
+ coverage: {
64
+ /** Total owned scopes from library.yaml */
65
+ totalScopes: number;
66
+ /** Scopes with events canvas files */
67
+ scopesWithEvents: string[];
68
+ /** Scopes missing events canvas */
69
+ scopesMissingEvents: string[];
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Convert scope name to events canvas filename
75
+ * e.g., "backlog.md.cli" -> "backlog-md-cli.events.canvas"
76
+ */
77
+ function scopeToEventsCanvasFilename(scope: string): string {
78
+ return `${scope.replace(/\./g, '-')}.events.canvas`;
79
+ }
80
+
81
+ /**
82
+ * Validates that scopes have corresponding events canvas files
83
+ */
84
+ export class ScopeEventsValidator {
85
+ /**
86
+ * Validate that each scope documented in scopes canvas has an events canvas
87
+ */
88
+ async validate(context: ScopeEventsValidationContext): Promise<ScopeEventsValidationResult> {
89
+ const violations: ScopeEventsViolation[] = [];
90
+ const { scopesCanvas, scopesCanvasPath, basePath } = context;
91
+
92
+ const scopesWithEvents: string[] = [];
93
+ const scopesMissingEvents: string[] = [];
94
+
95
+ // If no scopes canvas, nothing to validate
96
+ if (!scopesCanvas) {
97
+ return {
98
+ valid: true,
99
+ violations: [],
100
+ coverage: {
101
+ totalScopes: 0,
102
+ scopesWithEvents: [],
103
+ scopesMissingEvents: [],
104
+ },
105
+ };
106
+ }
107
+
108
+ // Extract scope nodes from the canvas
109
+ const scopeNodes = (scopesCanvas.nodes || []).filter(
110
+ (node): node is OtelScopeNode => node.type === 'otel-scope'
111
+ );
112
+
113
+ // Check each scope for corresponding events canvas
114
+ for (const scopeNode of scopeNodes) {
115
+ const scope = scopeNode.otel?.scope;
116
+ if (!scope) continue;
117
+
118
+ const eventsCanvasFilename = scopeToEventsCanvasFilename(scope);
119
+ const eventsCanvasPath = join(basePath, '.principal-views', eventsCanvasFilename);
120
+ const relativePath = `.principal-views/${eventsCanvasFilename}`;
121
+
122
+ if (existsSync(eventsCanvasPath)) {
123
+ scopesWithEvents.push(scope);
124
+ } else {
125
+ scopesMissingEvents.push(scope);
126
+ violations.push({
127
+ ruleId: 'scope-events-canvas-required',
128
+ severity: 'warn',
129
+ scope,
130
+ expectedPath: relativePath,
131
+ message: `Scope "${scope}" is missing an events canvas`,
132
+ impact: `Event vocabulary for scope "${scope}" is not documented. This makes it impossible to:
133
+ - Understand what events the scope emits
134
+ - Validate event flows match actual code
135
+ - Track event schema changes over time
136
+ - Generate documentation for implementers`,
137
+ suggestion: `Create ${relativePath} that documents:
138
+ - Event namespaces (groups of related events like "validation", "file", etc.)
139
+ - Events within each namespace with their attributes
140
+ - Adjacency relationships (which event namespaces connect in workflows)
141
+
142
+ See architecture.events.md for conventions.`,
143
+ });
144
+ }
145
+ }
146
+
147
+ return {
148
+ valid: violations.filter(v => v.severity === 'error').length === 0,
149
+ violations,
150
+ coverage: {
151
+ totalScopes: scopeNodes.length,
152
+ scopesWithEvents,
153
+ scopesMissingEvents,
154
+ },
155
+ };
156
+ }
157
+ }
@@ -0,0 +1,14 @@
1
+ export { EventsCanvasValidator } from './EventsCanvasValidator';
2
+ export type {
3
+ EventNamespaceNode,
4
+ EventsCanvasValidationContext,
5
+ EventsCanvasViolation,
6
+ EventsCanvasValidationResult,
7
+ } from './EventsCanvasValidator';
8
+
9
+ export { ScopeEventsValidator } from './ScopeEventsValidator';
10
+ export type {
11
+ ScopeEventsValidationContext,
12
+ ScopeEventsViolation,
13
+ ScopeEventsValidationResult,
14
+ } from './ScopeEventsValidator';
package/src/index.ts CHANGED
@@ -357,6 +357,15 @@ export type {
357
357
  NormalizedScope,
358
358
  } from './scopes';
359
359
 
360
+ // Export events module (event namespace canvas validation)
361
+ export { EventsCanvasValidator } from './events';
362
+ export type {
363
+ EventNamespaceNode,
364
+ EventsCanvasValidationContext,
365
+ EventsCanvasViolation,
366
+ EventsCanvasValidationResult,
367
+ } from './events';
368
+
360
369
  // Export spans module (span conventions + span color utilities)
361
370
  export {
362
371
  DEFAULT_SPAN_COLOR,
package/src/node.ts CHANGED
@@ -219,3 +219,14 @@ export type {
219
219
  ScopesCanvasViolation,
220
220
  NormalizedScope,
221
221
  } from './scopes';
222
+
223
+ // Export events module (canvas validation)
224
+ export { EventsCanvasValidator, ScopeEventsValidator } from './events';
225
+ export type {
226
+ EventsCanvasValidationContext,
227
+ EventsCanvasValidationResult,
228
+ EventsCanvasViolation,
229
+ ScopeEventsValidationContext,
230
+ ScopeEventsValidationResult,
231
+ ScopeEventsViolation,
232
+ } from './events';