@principal-ai/principal-view-core 0.28.1 → 0.28.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/events/ScopeEventsValidator.d.ts +3 -0
  2. package/dist/events/ScopeEventsValidator.d.ts.map +1 -1
  3. package/dist/events/ScopeEventsValidator.js +5 -4
  4. package/dist/events/ScopeEventsValidator.js.map +1 -1
  5. package/dist/index.d.ts +2 -2
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +4 -2
  8. package/dist/index.js.map +1 -1
  9. package/dist/node.d.ts +2 -2
  10. package/dist/node.d.ts.map +1 -1
  11. package/dist/node.js +4 -2
  12. package/dist/node.js.map +1 -1
  13. package/dist/scopes/ScopePathIndex.d.ts +56 -0
  14. package/dist/scopes/ScopePathIndex.d.ts.map +1 -0
  15. package/dist/scopes/ScopePathIndex.js +67 -0
  16. package/dist/scopes/ScopePathIndex.js.map +1 -0
  17. package/dist/scopes/ScopesCanvasValidator.d.ts.map +1 -1
  18. package/dist/scopes/ScopesCanvasValidator.js +64 -0
  19. package/dist/scopes/ScopesCanvasValidator.js.map +1 -1
  20. package/dist/scopes/index.d.ts +2 -0
  21. package/dist/scopes/index.d.ts.map +1 -1
  22. package/dist/scopes/index.js +5 -1
  23. package/dist/scopes/index.js.map +1 -1
  24. package/dist/scopes/validateScopeNamespaceNesting.d.ts +38 -0
  25. package/dist/scopes/validateScopeNamespaceNesting.d.ts.map +1 -0
  26. package/dist/scopes/validateScopeNamespaceNesting.js +69 -0
  27. package/dist/scopes/validateScopeNamespaceNesting.js.map +1 -0
  28. package/dist/types/canvas.d.ts +15 -0
  29. package/dist/types/canvas.d.ts.map +1 -1
  30. package/dist/types/canvas.js.map +1 -1
  31. package/dist/workflow/validator.d.ts +4 -1
  32. package/dist/workflow/validator.d.ts.map +1 -1
  33. package/dist/workflow/validator.js +14 -12
  34. package/dist/workflow/validator.js.map +1 -1
  35. package/package.json +1 -1
  36. package/src/events/ScopeEventsValidator.test.ts +173 -0
  37. package/src/events/ScopeEventsValidator.ts +5 -4
  38. package/src/index.ts +6 -0
  39. package/src/node.ts +6 -0
  40. package/src/scopes/ScopePathIndex.test.ts +94 -0
  41. package/src/scopes/ScopePathIndex.ts +89 -0
  42. package/src/scopes/ScopesCanvasValidator.test.ts +126 -0
  43. package/src/scopes/ScopesCanvasValidator.ts +68 -0
  44. package/src/scopes/index.ts +12 -0
  45. package/src/scopes/validateScopeNamespaceNesting.test.ts +127 -0
  46. package/src/scopes/validateScopeNamespaceNesting.ts +88 -0
  47. package/src/types/canvas.ts +15 -0
  48. package/src/workflow/__tests__/validator.test.ts +120 -1
  49. package/src/workflow/validator.ts +14 -12
@@ -0,0 +1,94 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { ScopePathIndex } from './ScopePathIndex';
3
+
4
+ function makeIndex(entries: Array<{ scope: string; paths: string[] }>) {
5
+ const idx = new ScopePathIndex();
6
+ idx.addAll(entries);
7
+ return idx;
8
+ }
9
+
10
+ describe('ScopePathIndex.resolve', () => {
11
+ test('empty index resolves to null', () => {
12
+ const idx = new ScopePathIndex();
13
+ expect(idx.resolve('packages/core/src/foo.ts')).toBeNull();
14
+ });
15
+
16
+ test('matches file nested under a declared folder path', () => {
17
+ const idx = makeIndex([{ scope: 'principal-ai.core', paths: ['packages/core/src'] }]);
18
+ const match = idx.resolve('packages/core/src/events/foo.ts');
19
+ expect(match?.entry.scope).toBe('principal-ai.core');
20
+ expect(match?.matchedPath).toBe('packages/core/src');
21
+ });
22
+
23
+ test('returns null when no path covers the file', () => {
24
+ const idx = makeIndex([{ scope: 'principal-ai.core', paths: ['packages/core/src'] }]);
25
+ expect(idx.resolve('packages/cli/src/foo.ts')).toBeNull();
26
+ });
27
+
28
+ test('longest-prefix wins: nested child scope beats parent', () => {
29
+ const idx = makeIndex([
30
+ { scope: 'principal-ai.core', paths: ['packages/core/src'] },
31
+ { scope: 'principal-ai.core.validation', paths: ['packages/core/src/validation'] },
32
+ ]);
33
+
34
+ // Parent-only subtree → parent wins
35
+ expect(idx.resolve('packages/core/src/events/foo.ts')?.entry.scope).toBe('principal-ai.core');
36
+ // Child subtree → child wins (longer prefix)
37
+ expect(idx.resolve('packages/core/src/validation/engine.ts')?.entry.scope).toBe(
38
+ 'principal-ai.core.validation',
39
+ );
40
+ // Deeper under child → still child (inherited)
41
+ expect(idx.resolve('packages/core/src/validation/rules/foo.ts')?.entry.scope).toBe(
42
+ 'principal-ai.core.validation',
43
+ );
44
+ });
45
+
46
+ test('sibling scopes resolve disjointly', () => {
47
+ const idx = makeIndex([
48
+ { scope: 'principal-ai.core', paths: ['packages/core/src'] },
49
+ { scope: 'principal-ai.cli', paths: ['packages/cli/src'] },
50
+ ]);
51
+ expect(idx.resolve('packages/core/src/foo.ts')?.entry.scope).toBe('principal-ai.core');
52
+ expect(idx.resolve('packages/cli/src/foo.ts')?.entry.scope).toBe('principal-ai.cli');
53
+ });
54
+
55
+ test('exact-file path matches when declared as a file', () => {
56
+ const idx = makeIndex([{ scope: 'tiny', paths: ['packages/tiny/src/index.ts'] }]);
57
+ expect(idx.resolve('packages/tiny/src/index.ts')?.entry.scope).toBe('tiny');
58
+ expect(idx.resolve('packages/tiny/src/other.ts')).toBeNull();
59
+ });
60
+
61
+ test('normalization: trailing slash and ./ prefix are equivalent', () => {
62
+ const idx = makeIndex([{ scope: 's', paths: ['packages/core/src/'] }]);
63
+ expect(idx.resolve('./packages/core/src/foo.ts')?.entry.scope).toBe('s');
64
+ });
65
+
66
+ test('similar-prefix paths are not mistaken for nesting', () => {
67
+ const idx = makeIndex([
68
+ { scope: 'foo', paths: ['packages/foo/src'] },
69
+ { scope: 'foobar', paths: ['packages/foobar/src'] },
70
+ ]);
71
+ expect(idx.resolve('packages/foobar/src/x.ts')?.entry.scope).toBe('foobar');
72
+ expect(idx.resolve('packages/foo/src/x.ts')?.entry.scope).toBe('foo');
73
+ });
74
+ });
75
+
76
+ describe('ScopePathIndex.getEntry / getScopes', () => {
77
+ test('getEntry returns null when scope not registered', () => {
78
+ const idx = makeIndex([{ scope: 's', paths: ['packages/core/src'] }]);
79
+ expect(idx.getEntry('other')).toBeNull();
80
+ });
81
+
82
+ test('getEntry returns the entry when present', () => {
83
+ const idx = makeIndex([{ scope: 's', paths: ['packages/core/src'] }]);
84
+ expect(idx.getEntry('s')?.paths).toEqual(['packages/core/src']);
85
+ });
86
+
87
+ test('getScopes lists each scope once', () => {
88
+ const idx = makeIndex([
89
+ { scope: 'a', paths: ['packages/a/src'] },
90
+ { scope: 'b', paths: ['packages/b/src'] },
91
+ ]);
92
+ expect(idx.getScopes().slice().sort()).toEqual(['a', 'b']);
93
+ });
94
+ });
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Scope Path Index
3
+ *
4
+ * Maps a filepath to the `otel-scope` that owns it, using the `paths` field
5
+ * declared on `otel-scope` nodes.
6
+ *
7
+ * Resolution uses the longest-prefix partitioning rule: when multiple scopes
8
+ * have paths covering the same file, the one whose matched path is the most
9
+ * specific (longest after normalization) wins. This naturally supports nested
10
+ * scopes by dotted-name (e.g. `principal-ai.core` and
11
+ * `principal-ai.core.validation`), where the child claims its subtree without
12
+ * stealing the parent's.
13
+ *
14
+ * Pure/browser-safe — no filesystem access, just string comparison.
15
+ */
16
+
17
+ import { normalizePath, pathCovers } from '../events/path-helpers';
18
+
19
+ /**
20
+ * One declared scope/paths tuple. Multiple entries may share the same scope
21
+ * if a scope canvas needs to declare more than one region (generated code,
22
+ * platform splits, migration periods).
23
+ */
24
+ export interface ScopePathEntry {
25
+ scope: string;
26
+ paths: string[];
27
+ /** Optional — useful for surfacing where a match came from in diagnostics. */
28
+ sourceCanvasPath?: string;
29
+ }
30
+
31
+ /**
32
+ * Result of resolving a file against the index.
33
+ */
34
+ export interface ScopePathMatch {
35
+ /** The winning entry (by longest-prefix). */
36
+ entry: ScopePathEntry;
37
+ /** Which of the entry's paths covered the file — the one that won. */
38
+ matchedPath: string;
39
+ }
40
+
41
+ export class ScopePathIndex {
42
+ private entries: ScopePathEntry[] = [];
43
+
44
+ /** Register a single scope/paths entry. */
45
+ add(entry: ScopePathEntry): void {
46
+ this.entries.push(entry);
47
+ }
48
+
49
+ /** Register many entries at once. */
50
+ addAll(entries: readonly ScopePathEntry[]): void {
51
+ for (const e of entries) this.add(e);
52
+ }
53
+
54
+ /**
55
+ * Resolve a filepath to its owning scope.
56
+ * Returns null if no entry has a path covering the file.
57
+ */
58
+ resolve(filepath: string): ScopePathMatch | null {
59
+ const normalizedFile = normalizePath(filepath);
60
+ let best: { entry: ScopePathEntry; matchedPath: string; length: number } | null = null;
61
+
62
+ for (const entry of this.entries) {
63
+ for (const declared of entry.paths) {
64
+ if (!pathCovers(declared, normalizedFile)) continue;
65
+ const normalizedDeclared = normalizePath(declared);
66
+ const length = normalizedDeclared.length;
67
+ if (best === null || length > best.length) {
68
+ best = { entry, matchedPath: declared, length };
69
+ }
70
+ }
71
+ }
72
+
73
+ return best ? { entry: best.entry, matchedPath: best.matchedPath } : null;
74
+ }
75
+
76
+ /**
77
+ * Look up the entry for a specific scope. Returns null when the scope
78
+ * isn't registered — either because its scopes-canvas node omits `paths`
79
+ * (opt-out of enforcement) or because no such scope is declared.
80
+ */
81
+ getEntry(scope: string): ScopePathEntry | null {
82
+ return this.entries.find((e) => e.scope === scope) ?? null;
83
+ }
84
+
85
+ /** All scopes present in the index. */
86
+ getScopes(): readonly string[] {
87
+ return Array.from(new Set(this.entries.map((e) => e.scope)));
88
+ }
89
+ }
@@ -0,0 +1,126 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { tmpdir } from 'os';
5
+ import { ScopesCanvasValidator } from './ScopesCanvasValidator';
6
+ import type { ExtendedCanvas } from '../types/canvas';
7
+
8
+ function makeScopeNode(opts: {
9
+ id: string;
10
+ scope: string;
11
+ description?: string;
12
+ paths?: string[];
13
+ }): any {
14
+ return {
15
+ id: opts.id,
16
+ type: 'otel-scope',
17
+ description: opts.description ?? `${opts.scope} scope`,
18
+ ...(opts.paths !== undefined ? { paths: opts.paths } : {}),
19
+ otel: { scope: opts.scope },
20
+ x: 0,
21
+ y: 0,
22
+ width: 100,
23
+ height: 50,
24
+ };
25
+ }
26
+
27
+ function makeCanvas(nodes: any[]): ExtendedCanvas {
28
+ return { nodes, edges: [] } as ExtendedCanvas;
29
+ }
30
+
31
+ function withTempRepo(layout: string[], fn: (root: string) => Promise<void>): Promise<void> {
32
+ const root = mkdtempSync(join(tmpdir(), 'scope-paths-'));
33
+ try {
34
+ for (const rel of layout) {
35
+ const full = join(root, rel);
36
+ mkdirSync(full, { recursive: true });
37
+ writeFileSync(join(full, '.keep'), '');
38
+ }
39
+ return fn(root).finally(() => rmSync(root, { recursive: true, force: true }));
40
+ } catch (e) {
41
+ rmSync(root, { recursive: true, force: true });
42
+ throw e;
43
+ }
44
+ }
45
+
46
+ describe('ScopesCanvasValidator scope paths', () => {
47
+ test('no paths declared → no path violations', async () => {
48
+ const canvas = makeCanvas([makeScopeNode({ id: 's1', scope: 'a.b' })]);
49
+ const v = new ScopesCanvasValidator();
50
+ const r = await v.validate({
51
+ scopesCanvas: canvas,
52
+ ownedScopes: ['a.b'],
53
+ basePath: '/tmp',
54
+ });
55
+ expect(r.violations.find(x => x.ruleId.startsWith('scopes-paths'))).toBeUndefined();
56
+ });
57
+
58
+ test('scopes-paths-multiple warns when more than one path', async () => {
59
+ await withTempRepo(['packages/a/src', 'packages/a/generated'], async (root) => {
60
+ const canvas = makeCanvas([
61
+ makeScopeNode({
62
+ id: 's1',
63
+ scope: 'a',
64
+ paths: ['packages/a/src', 'packages/a/generated'],
65
+ }),
66
+ ]);
67
+ const v = new ScopesCanvasValidator();
68
+ const r = await v.validate({ scopesCanvas: canvas, ownedScopes: ['a'], basePath: root });
69
+ const w = r.violations.find(x => x.ruleId === 'scopes-paths-multiple');
70
+ expect(w).toBeDefined();
71
+ expect(w?.severity).toBe('warn');
72
+ });
73
+ });
74
+
75
+ test('scopes-paths-missing warns when path does not exist', async () => {
76
+ await withTempRepo(['packages/a/src'], async (root) => {
77
+ const canvas = makeCanvas([
78
+ makeScopeNode({ id: 's1', scope: 'a', paths: ['packages/a/missing'] }),
79
+ ]);
80
+ const v = new ScopesCanvasValidator();
81
+ const r = await v.validate({ scopesCanvas: canvas, ownedScopes: ['a'], basePath: root });
82
+ const w = r.violations.find(x => x.ruleId === 'scopes-paths-missing');
83
+ expect(w).toBeDefined();
84
+ expect(w?.severity).toBe('warn');
85
+ });
86
+ });
87
+
88
+ test('scopes-paths-overlap errors on identical paths across scopes', async () => {
89
+ await withTempRepo(['packages/shared/src'], async (root) => {
90
+ const canvas = makeCanvas([
91
+ makeScopeNode({ id: 's1', scope: 'a', paths: ['packages/shared/src'] }),
92
+ makeScopeNode({ id: 's2', scope: 'b', paths: ['packages/shared/src'] }),
93
+ ]);
94
+ const v = new ScopesCanvasValidator();
95
+ const r = await v.validate({
96
+ scopesCanvas: canvas,
97
+ ownedScopes: ['a', 'b'],
98
+ basePath: root,
99
+ });
100
+ const e = r.violations.find(x => x.ruleId === 'scopes-paths-overlap');
101
+ expect(e).toBeDefined();
102
+ expect(e?.severity).toBe('error');
103
+ expect(r.valid).toBe(false);
104
+ });
105
+ });
106
+
107
+ test('parent-child path nesting is allowed (no overlap error)', async () => {
108
+ await withTempRepo(['packages/core/src/validation'], async (root) => {
109
+ const canvas = makeCanvas([
110
+ makeScopeNode({ id: 's1', scope: 'principal-ai.core', paths: ['packages/core/src'] }),
111
+ makeScopeNode({
112
+ id: 's2',
113
+ scope: 'principal-ai.core.validation',
114
+ paths: ['packages/core/src/validation'],
115
+ }),
116
+ ]);
117
+ const v = new ScopesCanvasValidator();
118
+ const r = await v.validate({
119
+ scopesCanvas: canvas,
120
+ ownedScopes: ['principal-ai.core', 'principal-ai.core.validation'],
121
+ basePath: root,
122
+ });
123
+ expect(r.violations.find(x => x.ruleId === 'scopes-paths-overlap')).toBeUndefined();
124
+ });
125
+ });
126
+ });
@@ -5,8 +5,11 @@
5
5
  * all instrumentation scopes declared in library.yaml.
6
6
  */
7
7
 
8
+ import { existsSync } from 'fs';
9
+ import { resolve } from 'path';
8
10
  import type { ExtendedCanvas, ExtendedCanvasNode, OtelScopeNode, isOtelScopeNode } from '../types/canvas';
9
11
  import { isOtelScopeNode as checkOtelScopeNode } from '../types/canvas';
12
+ import { pathsOverlap } from '../events/path-helpers';
10
13
 
11
14
  /**
12
15
  * Scopes canvas validation context
@@ -194,6 +197,71 @@ export class ScopesCanvasValidator {
194
197
  }
195
198
  }
196
199
 
200
+ // Validate scope paths (optional field — enforcement is opt-in per scope).
201
+ // Collects declared paths across scopes to check for cross-scope overlap.
202
+ const declaredPaths: Array<{ scope: string; nodeId: string; path: string }> = [];
203
+ for (const { scope, nodeId, node } of canvasScopes) {
204
+ const otelScopeNode = node as unknown as OtelScopeNode;
205
+ const paths = otelScopeNode.paths;
206
+ if (!paths || paths.length === 0) continue;
207
+
208
+ // Warn when a scope declares more than one path — prompt author to
209
+ // confirm the multi-location is intentional (monorepo bundling,
210
+ // generated code, platform splits, migration) rather than accidental.
211
+ if (paths.length > 1) {
212
+ violations.push({
213
+ ruleId: 'scopes-paths-multiple',
214
+ severity: 'warn',
215
+ file: scopesCanvasPath || '.principal-views/architecture.scopes.canvas',
216
+ path: `nodes[${nodeId}].paths`,
217
+ message: `Scope "${scope}" declares ${paths.length} paths`,
218
+ impact: 'Multiple paths can indicate legitimate cases (monorepo bundling, generated code, platform splits, migration) but often signal that the scope should be split',
219
+ suggestion: 'Confirm the multi-location is intentional. Otherwise consider splitting the scope or grouping the code under a single parent folder.',
220
+ });
221
+ }
222
+
223
+ for (const p of paths) {
224
+ declaredPaths.push({ scope, nodeId, path: p });
225
+
226
+ // Warn when a declared path does not exist relative to the repo root.
227
+ const resolved = resolve(basePath, p);
228
+ if (!existsSync(resolved)) {
229
+ violations.push({
230
+ ruleId: 'scopes-paths-missing',
231
+ severity: 'warn',
232
+ file: scopesCanvasPath || '.principal-views/architecture.scopes.canvas',
233
+ path: `nodes[${nodeId}].paths`,
234
+ message: `Path "${p}" declared by scope "${scope}" does not exist`,
235
+ impact: 'Events emitted under this scope cannot be validated against a real code location',
236
+ suggestion: 'Verify the path exists relative to the repository root, or remove it from the scope declaration.',
237
+ });
238
+ }
239
+ }
240
+ }
241
+
242
+ // Cross-scope overlap check. Parent-child path nesting is a valid partition
243
+ // (longest-prefix wins at runtime); any other overlap makes scope ownership
244
+ // of a file ambiguous.
245
+ for (let i = 0; i < declaredPaths.length; i++) {
246
+ for (let j = i + 1; j < declaredPaths.length; j++) {
247
+ const a = declaredPaths[i];
248
+ const b = declaredPaths[j];
249
+ if (a.scope === b.scope) continue;
250
+
251
+ const relationship = pathsOverlap(a.path, b.path);
252
+ if (relationship === 'conflict') {
253
+ violations.push({
254
+ ruleId: 'scopes-paths-overlap',
255
+ severity: 'error',
256
+ file: scopesCanvasPath || '.principal-views/architecture.scopes.canvas',
257
+ message: `Paths overlap between scopes "${a.scope}" ("${a.path}") and "${b.scope}" ("${b.path}")`,
258
+ impact: 'A file covered by two scopes makes scope ownership ambiguous',
259
+ suggestion: 'Separate the paths so they are disjoint, or restructure as parent/child scopes by dotted name (e.g., "principal-ai.core" covering "packages/core/src" and "principal-ai.core.validation" covering "packages/core/src/validation").',
260
+ });
261
+ }
262
+ }
263
+ }
264
+
197
265
  const errors = violations.filter(v => v.severity === 'error');
198
266
 
199
267
  return {
@@ -13,6 +13,18 @@ export {
13
13
  type ScopesCanvasViolation,
14
14
  } from './ScopesCanvasValidator';
15
15
 
16
+ export {
17
+ ScopePathIndex,
18
+ type ScopePathEntry,
19
+ type ScopePathMatch,
20
+ } from './ScopePathIndex';
21
+
22
+ export {
23
+ validateScopeNamespaceNesting,
24
+ type ScopeEventsCanvasPair,
25
+ type ValidateScopeNamespaceNestingInput,
26
+ } from './validateScopeNamespaceNesting';
27
+
16
28
  export {
17
29
  DEFAULT_SCOPE_COLOR,
18
30
  DRAFT_NODE_COLOR,
@@ -0,0 +1,127 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { validateScopeNamespaceNesting } from './validateScopeNamespaceNesting';
3
+ import type { ExtendedCanvas } from '../types/canvas';
4
+
5
+ function scopesCanvas(nodes: Array<{ scope: string; paths?: string[] }>): ExtendedCanvas {
6
+ return {
7
+ nodes: nodes.map((n, i) => ({
8
+ id: `s${i}`,
9
+ type: 'otel-scope',
10
+ description: `${n.scope} scope`,
11
+ ...(n.paths !== undefined ? { paths: n.paths } : {}),
12
+ otel: { scope: n.scope },
13
+ x: 0, y: 0, width: 100, height: 50,
14
+ })),
15
+ edges: [],
16
+ } as ExtendedCanvas;
17
+ }
18
+
19
+ function eventsCanvas(namespaces: Array<{ name: string; paths?: string[] }>): ExtendedCanvas {
20
+ return {
21
+ nodes: namespaces.map((ns, i) => ({
22
+ id: `n${i}`,
23
+ type: 'event-namespace',
24
+ namespace: {
25
+ name: ns.name,
26
+ description: `${ns.name} namespace`,
27
+ ...(ns.paths !== undefined ? { paths: ns.paths } : {}),
28
+ events: [],
29
+ },
30
+ x: 0, y: 0, width: 100, height: 50,
31
+ })),
32
+ edges: [],
33
+ } as ExtendedCanvas;
34
+ }
35
+
36
+ describe('validateScopeNamespaceNesting', () => {
37
+ test('passes when every namespace path is covered by its scope path', () => {
38
+ const violations = validateScopeNamespaceNesting({
39
+ scopesCanvas: scopesCanvas([{ scope: 'a', paths: ['packages/core/src'] }]),
40
+ eventsCanvases: [
41
+ {
42
+ scope: 'a',
43
+ eventsCanvas: eventsCanvas([
44
+ { name: 'events', paths: ['packages/core/src/events'] },
45
+ { name: 'workflow', paths: ['packages/core/src/workflow'] },
46
+ ]),
47
+ },
48
+ ],
49
+ });
50
+ expect(violations).toHaveLength(0);
51
+ });
52
+
53
+ test('flags namespace paths that escape their scope', () => {
54
+ const violations = validateScopeNamespaceNesting({
55
+ scopesCanvas: scopesCanvas([{ scope: 'a', paths: ['packages/core/src'] }]),
56
+ eventsCanvases: [
57
+ {
58
+ scope: 'a',
59
+ eventsCanvas: eventsCanvas([
60
+ { name: 'rogue', paths: ['packages/react/src/hooks'] },
61
+ ]),
62
+ },
63
+ ],
64
+ });
65
+ expect(violations).toHaveLength(1);
66
+ expect(violations[0].ruleId).toBe('scopes-namespace-paths-escape');
67
+ expect(violations[0].severity).toBe('error');
68
+ expect(violations[0].message).toContain('rogue');
69
+ expect(violations[0].message).toContain('packages/react/src/hooks');
70
+ });
71
+
72
+ test('namespace path equal to scope path is covered (not an escape)', () => {
73
+ const violations = validateScopeNamespaceNesting({
74
+ scopesCanvas: scopesCanvas([{ scope: 'a', paths: ['packages/core/src'] }]),
75
+ eventsCanvases: [
76
+ {
77
+ scope: 'a',
78
+ eventsCanvas: eventsCanvas([{ name: 'whole', paths: ['packages/core/src'] }]),
79
+ },
80
+ ],
81
+ });
82
+ expect(violations).toHaveLength(0);
83
+ });
84
+
85
+ test('scope without paths imposes no constraint (opt-out)', () => {
86
+ const violations = validateScopeNamespaceNesting({
87
+ scopesCanvas: scopesCanvas([{ scope: 'a' }]),
88
+ eventsCanvases: [
89
+ {
90
+ scope: 'a',
91
+ eventsCanvas: eventsCanvas([{ name: 'anywhere', paths: ['packages/anywhere/src'] }]),
92
+ },
93
+ ],
94
+ });
95
+ expect(violations).toHaveLength(0);
96
+ });
97
+
98
+ test('namespaces without paths are skipped (opt-in per namespace)', () => {
99
+ const violations = validateScopeNamespaceNesting({
100
+ scopesCanvas: scopesCanvas([{ scope: 'a', paths: ['packages/core/src'] }]),
101
+ eventsCanvases: [
102
+ {
103
+ scope: 'a',
104
+ eventsCanvas: eventsCanvas([{ name: 'unenforced' }]),
105
+ },
106
+ ],
107
+ });
108
+ expect(violations).toHaveLength(0);
109
+ });
110
+
111
+ test('multiple scope paths: namespace covered by any one is allowed', () => {
112
+ const violations = validateScopeNamespaceNesting({
113
+ scopesCanvas: scopesCanvas([
114
+ { scope: 'a', paths: ['packages/core/src', 'packages/core/generated'] },
115
+ ]),
116
+ eventsCanvases: [
117
+ {
118
+ scope: 'a',
119
+ eventsCanvas: eventsCanvas([
120
+ { name: 'gen', paths: ['packages/core/generated/events'] },
121
+ ]),
122
+ },
123
+ ],
124
+ });
125
+ expect(violations).toHaveLength(0);
126
+ });
127
+ });
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Scope-Namespace Nesting Validator
3
+ *
4
+ * Cross-canvas check: every `event-namespace.paths` entry declared in a
5
+ * scope's events canvas must be covered by that scope's own `paths`.
6
+ * Surfaces `scopes-namespace-paths-escape` when a namespace claims a region
7
+ * outside its owning scope.
8
+ *
9
+ * Runs alongside the existing scopes-canvas validation but requires both
10
+ * canvases as input, so it lives outside `ScopesCanvasValidator` (which is
11
+ * single-canvas).
12
+ */
13
+
14
+ import type { ExtendedCanvas } from '../types/canvas';
15
+ import { isOtelScopeNode } from '../types/canvas';
16
+ import { pathCovers } from '../events/path-helpers';
17
+ import type { ScopesCanvasViolation } from './ScopesCanvasValidator';
18
+
19
+ /**
20
+ * One events canvas paired with the scope it documents.
21
+ * The mapping comes from the existing scope → events-canvas filename
22
+ * convention (see `ScopeEventsValidator`); callers resolve it before
23
+ * passing it in.
24
+ */
25
+ export interface ScopeEventsCanvasPair {
26
+ scope: string;
27
+ eventsCanvas: ExtendedCanvas;
28
+ eventsCanvasPath?: string;
29
+ }
30
+
31
+ export interface ValidateScopeNamespaceNestingInput {
32
+ scopesCanvas: ExtendedCanvas;
33
+ scopesCanvasPath?: string;
34
+ eventsCanvases: readonly ScopeEventsCanvasPair[];
35
+ }
36
+
37
+ /**
38
+ * For every namespace.paths entry, verify it is covered by its scope's paths.
39
+ * Scopes without `paths` impose no constraint (opt-in per scope) and are
40
+ * skipped. Namespaces without `paths` are also skipped — namespace-level
41
+ * enforcement remains independently opt-in.
42
+ */
43
+ export function validateScopeNamespaceNesting(
44
+ input: ValidateScopeNamespaceNestingInput,
45
+ ): ScopesCanvasViolation[] {
46
+ const { scopesCanvas, scopesCanvasPath, eventsCanvases } = input;
47
+ const violations: ScopesCanvasViolation[] = [];
48
+
49
+ // Build scope → paths map from the scopes canvas.
50
+ const scopePaths = new Map<string, string[]>();
51
+ for (const node of scopesCanvas.nodes || []) {
52
+ if (!isOtelScopeNode(node)) continue;
53
+ const scope = node.otel?.scope;
54
+ const paths = node.paths;
55
+ if (!scope || !paths || paths.length === 0) continue;
56
+ scopePaths.set(scope, paths);
57
+ }
58
+
59
+ // For each events canvas, walk its event-namespace nodes and check that
60
+ // every namespace.paths entry sits inside the owning scope's paths.
61
+ for (const pair of eventsCanvases) {
62
+ const ownerPaths = scopePaths.get(pair.scope);
63
+ if (!ownerPaths) continue; // scope opted out — nothing to check
64
+
65
+ for (const node of pair.eventsCanvas.nodes || []) {
66
+ if ((node as any)?.type !== 'event-namespace') continue;
67
+ const ns = (node as any).namespace;
68
+ if (!ns?.name || !Array.isArray(ns.paths) || ns.paths.length === 0) continue;
69
+
70
+ for (const declared of ns.paths as string[]) {
71
+ const covered = ownerPaths.some((sp) => pathCovers(sp, declared));
72
+ if (covered) continue;
73
+
74
+ violations.push({
75
+ ruleId: 'scopes-namespace-paths-escape',
76
+ severity: 'error',
77
+ file: scopesCanvasPath || '.principal-views/architecture.scopes.canvas',
78
+ path: `nodes[scope="${pair.scope}"].paths`,
79
+ message: `Namespace "${ns.name}" path "${declared}" escapes the bounds of scope "${pair.scope}" (paths: ${ownerPaths.map((p) => `"${p}"`).join(', ')})`,
80
+ impact: 'A namespace claiming code outside its owning scope breaks the scope→namespace partition; events from that file would be attributed to the wrong scope',
81
+ suggestion: `Either move the namespace path under one of "${pair.scope}"'s paths, or extend the scope to cover the namespace's region. If the code genuinely belongs to a different scope, declare the namespace in that scope's events canvas instead.`,
82
+ });
83
+ }
84
+ }
85
+ }
86
+
87
+ return violations;
88
+ }
@@ -1014,6 +1014,21 @@ export interface OtelScopeNode extends OtelNodeBase {
1014
1014
  type: 'otel-scope';
1015
1015
  /** Short description of this instrumentation scope */
1016
1016
  description?: string;
1017
+ /**
1018
+ * Optional source paths that define this scope's code region.
1019
+ * Each entry may be a folder (covers all descendants) or a specific file.
1020
+ * When present, events under this scope may only originate from files
1021
+ * covered by one of these paths, and every `event-namespace` declared in
1022
+ * this scope's events canvas must have its own `paths` nested inside this
1023
+ * region. Scopes without `paths` remain unenforced — enforcement is opt-in
1024
+ * per scope.
1025
+ *
1026
+ * Semantics are "ownership / partition", distinct from `otel.files` on
1027
+ * `otel-event` nodes (which lists specific emission sites). Across scopes,
1028
+ * paths must be disjoint except for strict parent-child nesting by
1029
+ * dotted-scope name (longest-prefix wins).
1030
+ */
1031
+ paths?: string[];
1017
1032
  /** OTEL metadata - scope name is required */
1018
1033
  otel: OtelMetadata & {
1019
1034
  /** Scope name (required - maps to getTracer('scope-name')) */