@principal-ai/principal-view-core 0.28.2 → 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.
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/node.d.ts +2 -2
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js +4 -2
- package/dist/node.js.map +1 -1
- package/dist/scopes/ScopePathIndex.d.ts +56 -0
- package/dist/scopes/ScopePathIndex.d.ts.map +1 -0
- package/dist/scopes/ScopePathIndex.js +67 -0
- package/dist/scopes/ScopePathIndex.js.map +1 -0
- package/dist/scopes/ScopesCanvasValidator.d.ts.map +1 -1
- package/dist/scopes/ScopesCanvasValidator.js +64 -0
- package/dist/scopes/ScopesCanvasValidator.js.map +1 -1
- package/dist/scopes/index.d.ts +2 -0
- package/dist/scopes/index.d.ts.map +1 -1
- package/dist/scopes/index.js +5 -1
- package/dist/scopes/index.js.map +1 -1
- package/dist/scopes/validateScopeNamespaceNesting.d.ts +38 -0
- package/dist/scopes/validateScopeNamespaceNesting.d.ts.map +1 -0
- package/dist/scopes/validateScopeNamespaceNesting.js +69 -0
- package/dist/scopes/validateScopeNamespaceNesting.js.map +1 -0
- package/dist/types/canvas.d.ts +15 -0
- package/dist/types/canvas.d.ts.map +1 -1
- package/dist/types/canvas.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +6 -0
- package/src/node.ts +6 -0
- package/src/scopes/ScopePathIndex.test.ts +94 -0
- package/src/scopes/ScopePathIndex.ts +89 -0
- package/src/scopes/ScopesCanvasValidator.test.ts +126 -0
- package/src/scopes/ScopesCanvasValidator.ts +68 -0
- package/src/scopes/index.ts +12 -0
- package/src/scopes/validateScopeNamespaceNesting.test.ts +127 -0
- package/src/scopes/validateScopeNamespaceNesting.ts +88 -0
- package/src/types/canvas.ts +15 -0
|
@@ -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
|
+
}
|
package/src/types/canvas.ts
CHANGED
|
@@ -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')) */
|