@principal-ai/principal-view-core 0.28.2 → 0.28.4

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 (65) hide show
  1. package/dist/auxiliary/AuxiliaryManifestValidator.d.ts +38 -0
  2. package/dist/auxiliary/AuxiliaryManifestValidator.d.ts.map +1 -0
  3. package/dist/auxiliary/AuxiliaryManifestValidator.js +97 -0
  4. package/dist/auxiliary/AuxiliaryManifestValidator.js.map +1 -0
  5. package/dist/auxiliary/index.d.ts +9 -0
  6. package/dist/auxiliary/index.d.ts.map +1 -0
  7. package/dist/auxiliary/index.js +14 -0
  8. package/dist/auxiliary/index.js.map +1 -0
  9. package/dist/auxiliary/validateAreaScopeDisjoint.d.ts +24 -0
  10. package/dist/auxiliary/validateAreaScopeDisjoint.d.ts.map +1 -0
  11. package/dist/auxiliary/validateAreaScopeDisjoint.js +61 -0
  12. package/dist/auxiliary/validateAreaScopeDisjoint.js.map +1 -0
  13. package/dist/index.d.ts +2 -2
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +4 -2
  16. package/dist/index.js.map +1 -1
  17. package/dist/node.d.ts +4 -2
  18. package/dist/node.d.ts.map +1 -1
  19. package/dist/node.js +8 -2
  20. package/dist/node.js.map +1 -1
  21. package/dist/scopes/ScopePathIndex.d.ts +56 -0
  22. package/dist/scopes/ScopePathIndex.d.ts.map +1 -0
  23. package/dist/scopes/ScopePathIndex.js +67 -0
  24. package/dist/scopes/ScopePathIndex.js.map +1 -0
  25. package/dist/scopes/ScopesCanvasValidator.d.ts.map +1 -1
  26. package/dist/scopes/ScopesCanvasValidator.js +64 -0
  27. package/dist/scopes/ScopesCanvasValidator.js.map +1 -1
  28. package/dist/scopes/index.d.ts +2 -0
  29. package/dist/scopes/index.d.ts.map +1 -1
  30. package/dist/scopes/index.js +5 -1
  31. package/dist/scopes/index.js.map +1 -1
  32. package/dist/scopes/validateScopeNamespaceNesting.d.ts +38 -0
  33. package/dist/scopes/validateScopeNamespaceNesting.d.ts.map +1 -0
  34. package/dist/scopes/validateScopeNamespaceNesting.js +69 -0
  35. package/dist/scopes/validateScopeNamespaceNesting.js.map +1 -0
  36. package/dist/types/auxiliary.d.ts +54 -0
  37. package/dist/types/auxiliary.d.ts.map +1 -0
  38. package/dist/types/auxiliary.js +27 -0
  39. package/dist/types/auxiliary.js.map +1 -0
  40. package/dist/types/canvas.d.ts +15 -0
  41. package/dist/types/canvas.d.ts.map +1 -1
  42. package/dist/types/canvas.js.map +1 -1
  43. package/dist/types/index.d.ts +1 -0
  44. package/dist/types/index.d.ts.map +1 -1
  45. package/dist/types/index.js +1 -0
  46. package/dist/types/index.js.map +1 -1
  47. package/package.json +2 -1
  48. package/schemas/auxiliary.manifest.schema.json +48 -0
  49. package/src/auxiliary/AuxiliaryManifestValidator.test.ts +92 -0
  50. package/src/auxiliary/AuxiliaryManifestValidator.ts +129 -0
  51. package/src/auxiliary/index.ts +18 -0
  52. package/src/auxiliary/validateAreaScopeDisjoint.test.ts +85 -0
  53. package/src/auxiliary/validateAreaScopeDisjoint.ts +72 -0
  54. package/src/index.ts +6 -0
  55. package/src/node.ts +18 -0
  56. package/src/scopes/ScopePathIndex.test.ts +94 -0
  57. package/src/scopes/ScopePathIndex.ts +89 -0
  58. package/src/scopes/ScopesCanvasValidator.test.ts +126 -0
  59. package/src/scopes/ScopesCanvasValidator.ts +68 -0
  60. package/src/scopes/index.ts +12 -0
  61. package/src/scopes/validateScopeNamespaceNesting.test.ts +127 -0
  62. package/src/scopes/validateScopeNamespaceNesting.ts +88 -0
  63. package/src/types/auxiliary.ts +63 -0
  64. package/src/types/canvas.ts +15 -0
  65. package/src/types/index.ts +1 -0
@@ -0,0 +1,92 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { mkdtempSync, mkdirSync, writeFileSync } from 'fs';
3
+ import { tmpdir } from 'os';
4
+ import { join } from 'path';
5
+ import { AuxiliaryManifestValidator } from './AuxiliaryManifestValidator';
6
+ import type { AuxiliaryManifest } from '../types/auxiliary';
7
+
8
+ function makeRepo(layout: string[]): string {
9
+ const root = mkdtempSync(join(tmpdir(), 'aux-manifest-'));
10
+ for (const rel of layout) {
11
+ const full = join(root, rel);
12
+ mkdirSync(full.substring(0, full.lastIndexOf('/')) || full, { recursive: true });
13
+ if (!rel.endsWith('/')) writeFileSync(full, '');
14
+ }
15
+ return root;
16
+ }
17
+
18
+ describe('AuxiliaryManifestValidator', () => {
19
+ const validator = new AuxiliaryManifestValidator();
20
+
21
+ test('no manifest is a no-op', () => {
22
+ const result = validator.validate({ basePath: '/tmp' });
23
+ expect(result.valid).toBe(true);
24
+ expect(result.violations).toHaveLength(0);
25
+ });
26
+
27
+ test('passes for a clean manifest', () => {
28
+ const root = makeRepo(['docs/README.md', '.github/workflows/ci.yml']);
29
+ const manifest: AuxiliaryManifest = {
30
+ areas: [
31
+ { name: 'Documentation', paths: ['docs'], description: 'design docs' },
32
+ { name: 'GitHub', paths: ['.github'], description: 'CI config' },
33
+ ],
34
+ };
35
+ const result = validator.validate({ manifest, basePath: root });
36
+ expect(result.valid).toBe(true);
37
+ expect(result.violations).toHaveLength(0);
38
+ });
39
+
40
+ test('flags duplicate area names', () => {
41
+ const root = makeRepo(['docs/README.md', 'misc/note.txt']);
42
+ const manifest: AuxiliaryManifest = {
43
+ areas: [
44
+ { name: 'Docs', paths: ['docs'], description: 'a' },
45
+ { name: 'Docs', paths: ['misc'], description: 'b' },
46
+ ],
47
+ };
48
+ const result = validator.validate({ manifest, basePath: root });
49
+ const dup = result.violations.find((v) => v.ruleId === 'auxiliary-area-name-duplicate');
50
+ expect(dup).toBeDefined();
51
+ expect(dup!.severity).toBe('error');
52
+ expect(result.valid).toBe(false);
53
+ });
54
+
55
+ test('warns when a declared path does not exist', () => {
56
+ const root = makeRepo(['docs/README.md']);
57
+ const manifest: AuxiliaryManifest = {
58
+ areas: [{ name: 'Ghost', paths: ['nope'], description: 'missing' }],
59
+ };
60
+ const result = validator.validate({ manifest, basePath: root });
61
+ const missing = result.violations.find((v) => v.ruleId === 'auxiliary-area-path-missing');
62
+ expect(missing).toBeDefined();
63
+ expect(missing!.severity).toBe('warn');
64
+ // Warnings alone don't invalidate the manifest
65
+ expect(result.valid).toBe(true);
66
+ });
67
+
68
+ test('flags overlap between two areas', () => {
69
+ const root = makeRepo(['docs/sub/file.md']);
70
+ const manifest: AuxiliaryManifest = {
71
+ areas: [
72
+ { name: 'A', paths: ['docs'], description: 'a' },
73
+ { name: 'B', paths: ['docs/sub'], description: 'b' },
74
+ ],
75
+ };
76
+ const result = validator.validate({ manifest, basePath: root });
77
+ const overlap = result.violations.find((v) => v.ruleId === 'auxiliary-area-paths-overlap');
78
+ expect(overlap).toBeDefined();
79
+ expect(overlap!.severity).toBe('error');
80
+ expect(result.valid).toBe(false);
81
+ });
82
+
83
+ test('flags duplicate paths within a single area as self-overlap', () => {
84
+ const root = makeRepo(['docs/file.md']);
85
+ const manifest: AuxiliaryManifest = {
86
+ areas: [{ name: 'A', paths: ['docs', 'docs'], description: 'a' }],
87
+ };
88
+ const result = validator.validate({ manifest, basePath: root });
89
+ const self = result.violations.find((v) => v.ruleId === 'auxiliary-area-paths-self-overlap');
90
+ expect(self).toBeDefined();
91
+ });
92
+ });
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Auxiliary Manifest Validator
3
+ *
4
+ * Own-file checks for `.principal-views/auxiliary.manifest.json`:
5
+ * - area names are unique
6
+ * - declared paths exist on disk
7
+ * - paths don't overlap each other (within or across areas)
8
+ *
9
+ * Cross-file overlap with scope paths lives in `validateAreaScopeDisjoint`,
10
+ * mirroring the way `validateScopeNamespaceNesting` handles cross-canvas
11
+ * checks for scopes/namespaces.
12
+ */
13
+
14
+ import { existsSync } from 'fs';
15
+ import { resolve } from 'path';
16
+ import type { AuxiliaryManifest } from '../types/auxiliary';
17
+ import { pathsOverlap } from '../events/path-helpers';
18
+
19
+ export interface AuxiliaryManifestValidationContext {
20
+ /** Parsed manifest. When absent, validation is a no-op (manifest is optional). */
21
+ manifest?: AuxiliaryManifest;
22
+
23
+ /** Path to the manifest file — used in violation messages. */
24
+ manifestPath?: string;
25
+
26
+ /** Repo root for resolving area paths against the filesystem. */
27
+ basePath: string;
28
+ }
29
+
30
+ export interface AuxiliaryManifestViolation {
31
+ ruleId: string;
32
+ severity: 'error' | 'warn';
33
+ file: string;
34
+ path?: string;
35
+ message: string;
36
+ impact: string;
37
+ suggestion: string;
38
+ }
39
+
40
+ export interface AuxiliaryManifestValidationResult {
41
+ valid: boolean;
42
+ violations: AuxiliaryManifestViolation[];
43
+ }
44
+
45
+ const DEFAULT_MANIFEST_PATH = '.principal-views/auxiliary.manifest.json';
46
+
47
+ export class AuxiliaryManifestValidator {
48
+ validate(context: AuxiliaryManifestValidationContext): AuxiliaryManifestValidationResult {
49
+ const violations: AuxiliaryManifestViolation[] = [];
50
+ const { manifest, basePath } = context;
51
+ const file = context.manifestPath || DEFAULT_MANIFEST_PATH;
52
+
53
+ if (!manifest) {
54
+ return { valid: true, violations };
55
+ }
56
+
57
+ const areas = manifest.areas || [];
58
+
59
+ // Unique area names
60
+ const seenNames = new Map<string, number>();
61
+ areas.forEach((area, idx) => {
62
+ const count = seenNames.get(area.name) ?? 0;
63
+ seenNames.set(area.name, count + 1);
64
+ if (count === 1) {
65
+ violations.push({
66
+ ruleId: 'auxiliary-area-name-duplicate',
67
+ severity: 'error',
68
+ file,
69
+ path: `areas[${idx}].name`,
70
+ message: `Area name "${area.name}" is declared more than once`,
71
+ impact: 'Duplicate names make diagnostics ambiguous — a violation cannot be tied back to a single area',
72
+ suggestion: 'Rename one of the areas so names are unique across the manifest.',
73
+ });
74
+ }
75
+ });
76
+
77
+ // Path existence + collect for overlap pass
78
+ const declaredPaths: Array<{ areaName: string; areaIndex: number; pathIndex: number; path: string }> = [];
79
+ areas.forEach((area, areaIdx) => {
80
+ const paths = area.paths || [];
81
+ paths.forEach((p, pathIdx) => {
82
+ declaredPaths.push({ areaName: area.name, areaIndex: areaIdx, pathIndex: pathIdx, path: p });
83
+
84
+ const resolved = resolve(basePath, p);
85
+ if (!existsSync(resolved)) {
86
+ violations.push({
87
+ ruleId: 'auxiliary-area-path-missing',
88
+ severity: 'warn',
89
+ file,
90
+ path: `areas[${areaIdx}].paths[${pathIdx}]`,
91
+ message: `Path "${p}" declared by area "${area.name}" does not exist`,
92
+ impact: 'The manifest documents a folder that is not present in the repository',
93
+ suggestion: 'Verify the path exists relative to the repository root, or remove it from the area declaration.',
94
+ });
95
+ }
96
+ });
97
+ });
98
+
99
+ // Path overlap. Unlike scopes, areas have no dotted-name hierarchy, so
100
+ // parent-child nesting between two areas is just as ambiguous as a flat
101
+ // overlap — both are flagged.
102
+ for (let i = 0; i < declaredPaths.length; i++) {
103
+ for (let j = i + 1; j < declaredPaths.length; j++) {
104
+ const a = declaredPaths[i];
105
+ const b = declaredPaths[j];
106
+ const relationship = pathsOverlap(a.path, b.path);
107
+ if (relationship === 'none') continue;
108
+
109
+ const sameArea = a.areaName === b.areaName;
110
+ violations.push({
111
+ ruleId: sameArea ? 'auxiliary-area-paths-self-overlap' : 'auxiliary-area-paths-overlap',
112
+ severity: 'error',
113
+ file,
114
+ path: sameArea
115
+ ? `areas[${a.areaIndex}].paths`
116
+ : undefined,
117
+ message: sameArea
118
+ ? `Area "${a.areaName}" declares overlapping paths "${a.path}" and "${b.path}"`
119
+ : `Paths overlap between areas "${a.areaName}" ("${a.path}") and "${b.areaName}" ("${b.path}")`,
120
+ impact: 'Two area declarations covering the same file make ownership ambiguous',
121
+ suggestion: 'Make the paths disjoint. If one folder genuinely sits inside another area, drop the redundant entry.',
122
+ });
123
+ }
124
+ }
125
+
126
+ const errors = violations.filter((v) => v.severity === 'error');
127
+ return { valid: errors.length === 0, violations };
128
+ }
129
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Auxiliary module
3
+ *
4
+ * Validation for `auxiliary.manifest.json` — the catalog of project regions
5
+ * that sit outside the OTEL telemetry surface (docs, .github, etc.).
6
+ */
7
+
8
+ export {
9
+ AuxiliaryManifestValidator,
10
+ type AuxiliaryManifestValidationContext,
11
+ type AuxiliaryManifestValidationResult,
12
+ type AuxiliaryManifestViolation,
13
+ } from './AuxiliaryManifestValidator';
14
+
15
+ export {
16
+ validateAreaScopeDisjoint,
17
+ type ValidateAreaScopeDisjointInput,
18
+ } from './validateAreaScopeDisjoint';
@@ -0,0 +1,85 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { validateAreaScopeDisjoint } from './validateAreaScopeDisjoint';
3
+ import type { AuxiliaryManifest } from '../types/auxiliary';
4
+ import type { ExtendedCanvas } from '../types/canvas';
5
+
6
+ function scopesCanvas(nodes: Array<{ scope: string; paths?: string[] }>): ExtendedCanvas {
7
+ return {
8
+ nodes: nodes.map((n, i) => ({
9
+ id: `s${i}`,
10
+ type: 'otel-scope',
11
+ description: `${n.scope} scope`,
12
+ ...(n.paths !== undefined ? { paths: n.paths } : {}),
13
+ otel: { scope: n.scope },
14
+ x: 0, y: 0, width: 100, height: 50,
15
+ })),
16
+ edges: [],
17
+ } as ExtendedCanvas;
18
+ }
19
+
20
+ describe('validateAreaScopeDisjoint', () => {
21
+ test('returns no violations when areas and scopes are disjoint', () => {
22
+ const manifest: AuxiliaryManifest = {
23
+ areas: [{ name: 'Docs', paths: ['docs'], description: 'd' }],
24
+ };
25
+ const violations = validateAreaScopeDisjoint({
26
+ manifest,
27
+ scopesCanvas: scopesCanvas([{ scope: 'core', paths: ['packages/core/src'] }]),
28
+ });
29
+ expect(violations).toHaveLength(0);
30
+ });
31
+
32
+ test('flags exact overlap with a scope path', () => {
33
+ const manifest: AuxiliaryManifest = {
34
+ areas: [{ name: 'Core', paths: ['packages/core/src'], description: 'd' }],
35
+ };
36
+ const violations = validateAreaScopeDisjoint({
37
+ manifest,
38
+ scopesCanvas: scopesCanvas([{ scope: 'core', paths: ['packages/core/src'] }]),
39
+ });
40
+ expect(violations).toHaveLength(1);
41
+ expect(violations[0].ruleId).toBe('auxiliary-area-scope-overlap');
42
+ });
43
+
44
+ test('flags parent-child overlap (area covering a scope path)', () => {
45
+ const manifest: AuxiliaryManifest = {
46
+ areas: [{ name: 'All', paths: ['packages'], description: 'd' }],
47
+ };
48
+ const violations = validateAreaScopeDisjoint({
49
+ manifest,
50
+ scopesCanvas: scopesCanvas([{ scope: 'core', paths: ['packages/core/src'] }]),
51
+ });
52
+ expect(violations).toHaveLength(1);
53
+ expect(violations[0].ruleId).toBe('auxiliary-area-scope-overlap');
54
+ });
55
+
56
+ test('flags parent-child overlap (scope path covering an area)', () => {
57
+ const manifest: AuxiliaryManifest = {
58
+ areas: [{ name: 'Inner', paths: ['packages/core/src/docs'], description: 'd' }],
59
+ };
60
+ const violations = validateAreaScopeDisjoint({
61
+ manifest,
62
+ scopesCanvas: scopesCanvas([{ scope: 'core', paths: ['packages/core/src'] }]),
63
+ });
64
+ expect(violations).toHaveLength(1);
65
+ });
66
+
67
+ test('no scopes canvas means no cross-check', () => {
68
+ const manifest: AuxiliaryManifest = {
69
+ areas: [{ name: 'Docs', paths: ['docs'], description: 'd' }],
70
+ };
71
+ const violations = validateAreaScopeDisjoint({ manifest });
72
+ expect(violations).toHaveLength(0);
73
+ });
74
+
75
+ test('scopes without paths impose no constraint', () => {
76
+ const manifest: AuxiliaryManifest = {
77
+ areas: [{ name: 'Docs', paths: ['docs'], description: 'd' }],
78
+ };
79
+ const violations = validateAreaScopeDisjoint({
80
+ manifest,
81
+ scopesCanvas: scopesCanvas([{ scope: 'core' }]),
82
+ });
83
+ expect(violations).toHaveLength(0);
84
+ });
85
+ });
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Area / Scope Disjointness Validator
3
+ *
4
+ * Cross-file check: project-area paths declared in
5
+ * `auxiliary.manifest.json` must be disjoint from every scope path declared
6
+ * in `architecture.scopes.canvas`. Areas and scopes carve up the repo into
7
+ * non-overlapping regions — any overlap (parent-child or otherwise) means a
8
+ * file is claimed by both an OTEL scope and an auxiliary area, which makes
9
+ * ownership ambiguous.
10
+ *
11
+ * Mirrors `validateScopeNamespaceNesting`'s cross-canvas pattern: takes both
12
+ * inputs, returns violations.
13
+ */
14
+
15
+ import type { AuxiliaryManifest } from '../types/auxiliary';
16
+ import type { ExtendedCanvas } from '../types/canvas';
17
+ import { isOtelScopeNode } from '../types/canvas';
18
+ import { pathsOverlap } from '../events/path-helpers';
19
+ import type { AuxiliaryManifestViolation } from './AuxiliaryManifestValidator';
20
+
21
+ export interface ValidateAreaScopeDisjointInput {
22
+ manifest: AuxiliaryManifest;
23
+ manifestPath?: string;
24
+ scopesCanvas?: ExtendedCanvas;
25
+ scopesCanvasPath?: string;
26
+ }
27
+
28
+ const DEFAULT_MANIFEST_PATH = '.principal-views/auxiliary.manifest.json';
29
+
30
+ export function validateAreaScopeDisjoint(
31
+ input: ValidateAreaScopeDisjointInput,
32
+ ): AuxiliaryManifestViolation[] {
33
+ const { manifest, scopesCanvas } = input;
34
+ const manifestFile = input.manifestPath || DEFAULT_MANIFEST_PATH;
35
+
36
+ if (!scopesCanvas) return [];
37
+
38
+ const scopePaths: Array<{ scope: string; path: string }> = [];
39
+ for (const node of scopesCanvas.nodes || []) {
40
+ if (!isOtelScopeNode(node)) continue;
41
+ const scope = node.otel?.scope;
42
+ const paths = node.paths;
43
+ if (!scope || !paths || paths.length === 0) continue;
44
+ for (const p of paths) scopePaths.push({ scope, path: p });
45
+ }
46
+
47
+ if (scopePaths.length === 0) return [];
48
+
49
+ const violations: AuxiliaryManifestViolation[] = [];
50
+ const areas = manifest.areas || [];
51
+
52
+ areas.forEach((area, areaIdx) => {
53
+ (area.paths || []).forEach((areaPath, pathIdx) => {
54
+ for (const sp of scopePaths) {
55
+ const relationship = pathsOverlap(areaPath, sp.path);
56
+ if (relationship === 'none') continue;
57
+
58
+ violations.push({
59
+ ruleId: 'auxiliary-area-scope-overlap',
60
+ severity: 'error',
61
+ file: manifestFile,
62
+ path: `areas[${areaIdx}].paths[${pathIdx}]`,
63
+ message: `Area "${area.name}" path "${areaPath}" overlaps scope "${sp.scope}" path "${sp.path}"`,
64
+ impact: 'A file claimed by both an OTEL scope and an auxiliary area has ambiguous ownership — the manifest is meant to catalog regions outside the telemetry surface',
65
+ suggestion: 'Make the area path disjoint from every scope path. If the folder is genuinely runtime code, drop it from the manifest; if it is auxiliary, narrow the scope path or move the folder.',
66
+ });
67
+ }
68
+ });
69
+ });
70
+
71
+ return violations;
72
+ }
package/src/index.ts CHANGED
@@ -339,6 +339,8 @@ export {
339
339
  // Export scopes module (canvas validation + scope utilities)
340
340
  export {
341
341
  ScopesCanvasValidator,
342
+ ScopePathIndex,
343
+ validateScopeNamespaceNesting,
342
344
  DEFAULT_SCOPE_COLOR,
343
345
  DRAFT_NODE_COLOR,
344
346
  getScopeNames,
@@ -352,6 +354,10 @@ export type {
352
354
  ScopesCanvasValidationContext,
353
355
  ScopesCanvasValidationResult,
354
356
  ScopesCanvasViolation,
357
+ ScopePathEntry,
358
+ ScopePathMatch,
359
+ ScopeEventsCanvasPair,
360
+ ValidateScopeNamespaceNestingInput,
355
361
  NormalizedScope,
356
362
  } from './scopes';
357
363
 
package/src/node.ts CHANGED
@@ -202,6 +202,8 @@ export type {
202
202
  // Export scopes module (canvas validation + scope utilities)
203
203
  export {
204
204
  ScopesCanvasValidator,
205
+ ScopePathIndex,
206
+ validateScopeNamespaceNesting,
205
207
  DEFAULT_SCOPE_COLOR,
206
208
  DRAFT_NODE_COLOR,
207
209
  getScopeNames,
@@ -215,9 +217,25 @@ export type {
215
217
  ScopesCanvasValidationContext,
216
218
  ScopesCanvasValidationResult,
217
219
  ScopesCanvasViolation,
220
+ ScopePathEntry,
221
+ ScopePathMatch,
222
+ ScopeEventsCanvasPair,
223
+ ValidateScopeNamespaceNestingInput,
218
224
  NormalizedScope,
219
225
  } from './scopes';
220
226
 
227
+ // Export auxiliary module (manifest validation for non-OTEL project regions)
228
+ export {
229
+ AuxiliaryManifestValidator,
230
+ validateAreaScopeDisjoint,
231
+ } from './auxiliary';
232
+ export type {
233
+ AuxiliaryManifestValidationContext,
234
+ AuxiliaryManifestValidationResult,
235
+ AuxiliaryManifestViolation,
236
+ ValidateAreaScopeDisjointInput,
237
+ } from './auxiliary';
238
+
221
239
  // Export events module (canvas validation)
222
240
  export {
223
241
  EventsCanvasValidator,
@@ -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
+ }