Reference implementation for the Phoenix Architecture. Work in progress.
aicoding.leaflet.pub/
ai
coding
crazy
1/**
2 * Boundary Validator (Architectural Linter)
3 *
4 * Validates extracted dependencies against an IU's boundary policy.
5 * Produces diagnostics for violations.
6 */
7
8import type { ImplementationUnit, BoundaryPolicy, EnforcementConfig } from './models/iu.js';
9import type { DependencyGraph } from './dep-extractor.js';
10import type { Diagnostic } from './models/diagnostic.js';
11
12/**
13 * Validate a dependency graph against an IU's boundary policy.
14 */
15export function validateBoundary(
16 depGraph: DependencyGraph,
17 iu: ImplementationUnit,
18 iuIdToName?: Map<string, string>,
19): Diagnostic[] {
20 const diagnostics: Diagnostic[] = [];
21 const policy = iu.boundary_policy;
22 const enforcement = iu.enforcement;
23
24 // Validate imports
25 for (const dep of depGraph.imports) {
26 if (dep.is_relative) {
27 // Check against forbidden_paths
28 for (const forbidden of policy.code.forbidden_paths) {
29 if (matchGlob(dep.source, forbidden)) {
30 diagnostics.push({
31 severity: enforcement.dependency_violation.severity,
32 category: 'dependency_violation',
33 subject: dep.source,
34 message: `Import "${dep.source}" matches forbidden path pattern "${forbidden}"`,
35 iu_id: iu.iu_id,
36 source_file: depGraph.file_path,
37 source_line: dep.source_line,
38 recommended_actions: ['Remove the import or update boundary policy'],
39 });
40 }
41 }
42 } else {
43 // Package import — check forbidden + allowed
44 const pkgName = extractPackageName(dep.source);
45
46 // Check forbidden packages (takes priority)
47 if (policy.code.forbidden_packages.includes(pkgName)) {
48 diagnostics.push({
49 severity: enforcement.dependency_violation.severity,
50 category: 'dependency_violation',
51 subject: pkgName,
52 message: `Package "${pkgName}" is forbidden by boundary policy`,
53 iu_id: iu.iu_id,
54 source_file: depGraph.file_path,
55 source_line: dep.source_line,
56 recommended_actions: ['Remove the dependency or update boundary policy'],
57 });
58 } else if (policy.code.allowed_packages.length > 0 && !policy.code.allowed_packages.includes(pkgName)) {
59 // If allowed_packages is non-empty, check allowlist (only if not already caught by forbidden)
60 diagnostics.push({
61 severity: enforcement.dependency_violation.severity,
62 category: 'dependency_violation',
63 subject: pkgName,
64 message: `Package "${pkgName}" is not in the allowed packages list`,
65 iu_id: iu.iu_id,
66 source_file: depGraph.file_path,
67 source_line: dep.source_line,
68 recommended_actions: [`Add "${pkgName}" to allowed_packages or remove the import`],
69 });
70 }
71 }
72 }
73
74 // Validate side channels
75 for (const sc of depGraph.side_channels) {
76 const policyKey = sideChannelPolicyKey(sc.kind);
77 const allowed = (policy.side_channels as Record<string, string[]>)[policyKey] ?? [];
78
79 if (allowed.length === 0 || !allowed.includes(sc.identifier)) {
80 diagnostics.push({
81 severity: enforcement.side_channel_violation.severity,
82 category: 'side_channel_violation',
83 subject: sc.identifier,
84 message: `Undeclared ${sc.kind} side channel: "${sc.identifier}"`,
85 iu_id: iu.iu_id,
86 source_file: depGraph.file_path,
87 source_line: sc.source_line,
88 recommended_actions: [
89 `Declare "${sc.identifier}" in boundary_policy.side_channels.${policyKey}`,
90 'Or remove the side-channel usage',
91 ],
92 });
93 }
94 }
95
96 return diagnostics;
97}
98
99/**
100 * Validate multiple files for one IU.
101 */
102export function validateIU(
103 depGraphs: DependencyGraph[],
104 iu: ImplementationUnit,
105): Diagnostic[] {
106 return depGraphs.flatMap(g => validateBoundary(g, iu));
107}
108
109/**
110 * Detect boundary policy changes between two versions of an IU.
111 */
112export interface UnitBoundaryChange {
113 iu_id: string;
114 iu_name: string;
115 changes: string[];
116}
117
118export function detectBoundaryChanges(
119 before: ImplementationUnit,
120 after: ImplementationUnit,
121): UnitBoundaryChange | null {
122 const changes: string[] = [];
123 const bp = before.boundary_policy;
124 const ap = after.boundary_policy;
125
126 // Compare code policies
127 for (const key of ['allowed_ius', 'allowed_packages', 'forbidden_ius', 'forbidden_packages', 'forbidden_paths'] as const) {
128 const bv = JSON.stringify(bp.code[key].sort());
129 const av = JSON.stringify(ap.code[key].sort());
130 if (bv !== av) {
131 changes.push(`code.${key} changed`);
132 }
133 }
134
135 // Compare side channel policies
136 for (const key of ['databases', 'queues', 'caches', 'config', 'external_apis', 'files'] as const) {
137 const bv = JSON.stringify(bp.side_channels[key].sort());
138 const av = JSON.stringify(ap.side_channels[key].sort());
139 if (bv !== av) {
140 changes.push(`side_channels.${key} changed`);
141 }
142 }
143
144 if (changes.length === 0) return null;
145 return { iu_id: after.iu_id, iu_name: after.name, changes };
146}
147
148/**
149 * Simple glob matching (supports * and ** wildcards).
150 */
151function matchGlob(path: string, pattern: string): boolean {
152 const regex = pattern
153 .replace(/\*\*/g, '<<<GLOBSTAR>>>')
154 .replace(/\*/g, '[^/]*')
155 .replace(/<<<GLOBSTAR>>>/g, '.*');
156 return new RegExp(`^${regex}$`).test(path);
157}
158
159/**
160 * Extract npm package name from import specifier.
161 * Handles scoped packages: @scope/pkg/sub → @scope/pkg
162 */
163function extractPackageName(specifier: string): string {
164 if (specifier.startsWith('@')) {
165 const parts = specifier.split('/');
166 return parts.slice(0, 2).join('/');
167 }
168 return specifier.split('/')[0];
169}
170
171function sideChannelPolicyKey(kind: string): string {
172 switch (kind) {
173 case 'external_api': return 'external_apis';
174 case 'database': return 'databases';
175 case 'queue': return 'queues';
176 case 'cache': return 'caches';
177 case 'file': return 'files';
178 case 'config': return 'config';
179 default: return kind;
180 }
181}