septum 0.1.4 → 0.1.6
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/LICENSE +21 -21
- package/README.md +105 -105
- package/bin/septum.ts +4 -4
- package/package.json +63 -63
- package/src/cli/commands/check.ts +161 -161
- package/src/cli/commands/hook.ts +216 -188
- package/src/cli/commands/ingest.ts +29 -29
- package/src/cli/commands/init.ts +213 -211
- package/src/cli/commands/locate.ts +61 -61
- package/src/cli/commands/query.ts +104 -64
- package/src/cli/commands/serve.ts +5 -5
- package/src/cli/commands/slice.ts +50 -48
- package/src/cli/commands/sync.ts +31 -31
- package/src/cli/index.ts +220 -210
- package/src/cli/templates/hooks.ts +75 -75
- package/src/cli/templates/rules.ts +50 -38
- package/src/cli/templates/skill.ts +65 -69
- package/src/core/boundary/evaluator.ts +429 -210
- package/src/core/config/loader.ts +117 -97
- package/src/core/config/schema.ts +39 -39
- package/src/core/database/client.ts +44 -44
- package/src/core/database/repositories/dependency.repository.ts +552 -349
- package/src/core/database/repositories/domain.repository.ts +275 -198
- package/src/core/database/repositories/file.repository.ts +117 -106
- package/src/core/database/repositories/meta.repository.ts +33 -33
- package/src/core/database/repositories/symbol.repository.ts +482 -443
- package/src/core/database/repository.ts +281 -260
- package/src/core/database/schema.ts +154 -153
- package/src/core/discovery/topology-detector.ts +486 -486
- package/src/core/ingestion/hasher.ts +5 -5
- package/src/core/ingestion/pipeline.ts +606 -535
- package/src/core/parser/boundary-tracker.ts +433 -288
- package/src/core/parser/extractors/base.ts +62 -62
- package/src/core/parser/extractors/blade.ts +158 -158
- package/src/core/parser/extractors/frontend.ts +147 -194
- package/src/core/parser/extractors/go.ts +408 -180
- package/src/core/parser/extractors/laravel-semantic.ts +1045 -901
- package/src/core/parser/extractors/mcp-cli-semantic.ts +328 -199
- package/src/core/parser/extractors/nestjs-semantic.ts +134 -119
- package/src/core/parser/extractors/php.ts +415 -153
- package/src/core/parser/extractors/python.ts +460 -228
- package/src/core/parser/extractors/registry.ts +70 -70
- package/src/core/parser/extractors/semantic-extractor.interface.ts +21 -21
- package/src/core/parser/extractors/typescript.ts +668 -181
- package/src/core/parser/tree-sitter.ts +41 -41
- package/src/core/parser/wasm-loader.ts +271 -0
- package/src/core/resolver/call-graph-tracer.ts +698 -668
- package/src/core/resolver/module-resolver.ts +479 -342
- package/src/core/resolver/path-resolver.ts +147 -147
- package/src/core/resolver/symbol-locator.ts +543 -543
- package/src/core/resolver/vertical-slice-tracer.ts +323 -285
- package/src/core/session/session-manager.ts +56 -56
- package/src/core/telemetry/telemetry.ts +86 -86
- package/src/core/topology/environment-detector.ts +109 -6
- package/src/index.ts +21 -21
- package/src/mcp/schemas.ts +695 -286
- package/src/mcp/server.ts +927 -716
- package/src/mcp/tools/check-boundary.ts +307 -152
- package/src/mcp/tools/get-domain-catalog.ts +155 -121
- package/src/mcp/tools/get-environment-topology.ts +3 -6
- package/src/mcp/tools/get-feature-context.ts +227 -116
- package/src/mcp/tools/get-symbol-hotspots.ts +54 -59
- package/src/mcp/tools/get-symbol-impact.ts +28 -26
- package/src/mcp/tools/get-symbol.ts +65 -63
- package/src/mcp/tools/locate-symbol.ts +169 -190
- package/src/mcp/tools/register-domain.ts +59 -64
- package/src/mcp/tools/register-feature.ts +91 -0
- package/src/mcp/tools/trace-vertical-slice.ts +74 -48
- package/src/types/index.ts +366 -315
- package/src/version.ts +3 -3
|
@@ -1,210 +1,429 @@
|
|
|
1
|
-
import
|
|
2
|
-
import type {
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
private
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
this.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if (
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
}
|
|
1
|
+
import { posix } from "node:path";
|
|
2
|
+
import type { BoundaryViolation } from "../../types/index.ts";
|
|
3
|
+
import type { ValidatedSeptumConfig } from "../config/schema.ts";
|
|
4
|
+
import { ConfigLoader } from "../config/loader.ts";
|
|
5
|
+
import type { SeptumRepository } from "../database/repository.ts";
|
|
6
|
+
import { ModuleResolver } from "../resolver/module-resolver.ts";
|
|
7
|
+
|
|
8
|
+
export class BoundaryEvaluator {
|
|
9
|
+
private repo: SeptumRepository;
|
|
10
|
+
private resolver: ModuleResolver;
|
|
11
|
+
|
|
12
|
+
constructor(repo: SeptumRepository, resolver?: ModuleResolver) {
|
|
13
|
+
this.repo = repo;
|
|
14
|
+
this.resolver = resolver ?? new ModuleResolver(process.cwd(), repo);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
public getResolver(): ModuleResolver {
|
|
18
|
+
return this.resolver;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
public evaluate(
|
|
22
|
+
filePath: string,
|
|
23
|
+
proposedImports: string[] = [],
|
|
24
|
+
featureKey?: string,
|
|
25
|
+
config?: ValidatedSeptumConfig
|
|
26
|
+
): BoundaryViolation[] {
|
|
27
|
+
let activeConfig = config;
|
|
28
|
+
if (!activeConfig) {
|
|
29
|
+
const fromRepo = ConfigLoader.loadFromRepository(this.repo);
|
|
30
|
+
activeConfig =
|
|
31
|
+
Object.keys(fromRepo.domains).length > 0
|
|
32
|
+
? fromRepo
|
|
33
|
+
: ConfigLoader.loadFromDatabaseOrDefaults();
|
|
34
|
+
}
|
|
35
|
+
const violations: BoundaryViolation[] = [];
|
|
36
|
+
|
|
37
|
+
if (featureKey) {
|
|
38
|
+
violations.push(...this.checkFeatureTouchpoints(featureKey, filePath, activeConfig));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (proposedImports.length > 0) {
|
|
42
|
+
violations.push(...this.checkProposedChanges(filePath, proposedImports, activeConfig));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return violations;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
public auditCodebase(config: ValidatedSeptumConfig): BoundaryViolation[] {
|
|
49
|
+
const violations: BoundaryViolation[] = [];
|
|
50
|
+
const allDependencies = this.repo.getAllDependenciesWithDomains();
|
|
51
|
+
|
|
52
|
+
for (const dep of allDependencies) {
|
|
53
|
+
const targetDomain = this.resolveTargetDomain(dep.target, config, dep.source_file);
|
|
54
|
+
if (!targetDomain || targetDomain === dep.source_domain) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const sourceDomainConfig = config.domains[dep.source_domain];
|
|
59
|
+
if (!sourceDomainConfig) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const forbidden = sourceDomainConfig.forbidden_dependencies ?? [];
|
|
64
|
+
const allowed = sourceDomainConfig.allowed_dependencies ?? [];
|
|
65
|
+
|
|
66
|
+
// 1. Explicit forbidden check
|
|
67
|
+
if (forbidden.includes(targetDomain)) {
|
|
68
|
+
violations.push({
|
|
69
|
+
file: dep.source_file,
|
|
70
|
+
line: dep.line_number,
|
|
71
|
+
source_domain: dep.source_domain,
|
|
72
|
+
target_domain: targetDomain,
|
|
73
|
+
imported_target: dep.target,
|
|
74
|
+
rule: "forbidden_dependency",
|
|
75
|
+
message: `Forbidden cross-domain dependency detected: '${dep.source_domain}' is strictly prohibited from importing domain '${targetDomain}'.`,
|
|
76
|
+
});
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 2. Strict enforcement allowed check
|
|
81
|
+
if (config.settings.enforcement === "strict" && !allowed.includes(targetDomain)) {
|
|
82
|
+
violations.push({
|
|
83
|
+
file: dep.source_file,
|
|
84
|
+
line: dep.line_number,
|
|
85
|
+
source_domain: dep.source_domain,
|
|
86
|
+
target_domain: targetDomain,
|
|
87
|
+
imported_target: dep.target,
|
|
88
|
+
rule: "disallowed_dependency",
|
|
89
|
+
message: `Disallowed dependency: '${targetDomain}' is not declared in allowed_dependencies for domain '${dep.source_domain}'.`,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
violations.push(...this.detectCircularDependencies(config, allDependencies));
|
|
95
|
+
|
|
96
|
+
return violations;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
public detectCircularDependencies(
|
|
100
|
+
config: ValidatedSeptumConfig,
|
|
101
|
+
preloadedDeps?: Array<{
|
|
102
|
+
source_file: string;
|
|
103
|
+
source_domain: string;
|
|
104
|
+
line_number: number;
|
|
105
|
+
target: string;
|
|
106
|
+
statement: string;
|
|
107
|
+
is_external: boolean;
|
|
108
|
+
}>
|
|
109
|
+
): BoundaryViolation[] {
|
|
110
|
+
const violations: BoundaryViolation[] = [];
|
|
111
|
+
const allDependencies = preloadedDeps ?? this.repo.getAllDependenciesWithDomains();
|
|
112
|
+
|
|
113
|
+
// Prepare file lookup maps for internal file-to-file resolution
|
|
114
|
+
const fileByNormPath = new Map<string, string>();
|
|
115
|
+
const fileByNoExtPath = new Map<string, string>();
|
|
116
|
+
|
|
117
|
+
const registerKnownFile = (rawPath: string) => {
|
|
118
|
+
const norm = rawPath.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
119
|
+
fileByNormPath.set(norm.toLowerCase(), rawPath);
|
|
120
|
+
const noExt = norm.replace(/\.[^/.]+$/, "").toLowerCase();
|
|
121
|
+
fileByNoExtPath.set(noExt, rawPath);
|
|
122
|
+
if (noExt.endsWith("/index")) {
|
|
123
|
+
fileByNoExtPath.set(noExt.slice(0, -"/index".length), rawPath);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
if (typeof this.repo.getAllFiles === "function") {
|
|
128
|
+
for (const f of this.repo.getAllFiles()) {
|
|
129
|
+
registerKnownFile(f.path);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
for (const d of allDependencies) {
|
|
133
|
+
registerKnownFile(d.source_file);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const resolveTargetFile = (sourceFile: string, rawTarget: string): string | null => {
|
|
137
|
+
const cleaned = rawTarget.trim().replace(/^['"]|['"]$/g, "").replace(/\\/g, "/");
|
|
138
|
+
if (!cleaned) return null;
|
|
139
|
+
|
|
140
|
+
if (cleaned.startsWith(".")) {
|
|
141
|
+
const srcDir = posix.dirname(sourceFile.replace(/\\/g, "/"));
|
|
142
|
+
const joined = posix.normalize(posix.join(srcDir, cleaned)).replace(/^\.\//, "");
|
|
143
|
+
const lowerJoined = joined.toLowerCase();
|
|
144
|
+
const exact =
|
|
145
|
+
fileByNormPath.get(lowerJoined) ??
|
|
146
|
+
fileByNoExtPath.get(lowerJoined.replace(/\.[^/.]+$/, ""));
|
|
147
|
+
if (exact) return exact;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const aliasClean = cleaned.replace(/^@\//, "src/").replace(/^\.\//, "");
|
|
151
|
+
const lowerAlias = aliasClean.toLowerCase();
|
|
152
|
+
const byAlias =
|
|
153
|
+
fileByNormPath.get(lowerAlias) ??
|
|
154
|
+
fileByNoExtPath.get(lowerAlias.replace(/\.[^/.]+$/, ""));
|
|
155
|
+
if (byAlias) return byAlias;
|
|
156
|
+
|
|
157
|
+
return null;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
type EdgeMeta = {
|
|
161
|
+
file: string;
|
|
162
|
+
line: number;
|
|
163
|
+
source_domain: string;
|
|
164
|
+
target_domain: string;
|
|
165
|
+
target: string;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const fileGraph = new Map<string, Map<string, EdgeMeta>>();
|
|
169
|
+
const domainGraph = new Map<string, Map<string, EdgeMeta>>();
|
|
170
|
+
|
|
171
|
+
for (const dep of allDependencies) {
|
|
172
|
+
if (dep.is_external) continue;
|
|
173
|
+
|
|
174
|
+
const targetDomain = this.resolveTargetDomain(dep.target, config, dep.source_file);
|
|
175
|
+
|
|
176
|
+
// 1. Domain-to-domain edge
|
|
177
|
+
if (targetDomain && targetDomain !== dep.source_domain) {
|
|
178
|
+
let outMap = domainGraph.get(dep.source_domain);
|
|
179
|
+
if (!outMap) {
|
|
180
|
+
outMap = new Map();
|
|
181
|
+
domainGraph.set(dep.source_domain, outMap);
|
|
182
|
+
}
|
|
183
|
+
if (!outMap.has(targetDomain)) {
|
|
184
|
+
outMap.set(targetDomain, {
|
|
185
|
+
file: dep.source_file,
|
|
186
|
+
line: dep.line_number,
|
|
187
|
+
source_domain: dep.source_domain,
|
|
188
|
+
target_domain: targetDomain,
|
|
189
|
+
target: dep.target,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// 2. File-to-file edge
|
|
195
|
+
const targetFile = resolveTargetFile(dep.source_file, dep.target);
|
|
196
|
+
if (targetFile && targetFile !== dep.source_file) {
|
|
197
|
+
let outMap = fileGraph.get(dep.source_file);
|
|
198
|
+
if (!outMap) {
|
|
199
|
+
outMap = new Map();
|
|
200
|
+
fileGraph.set(dep.source_file, outMap);
|
|
201
|
+
}
|
|
202
|
+
if (!outMap.has(targetFile)) {
|
|
203
|
+
outMap.set(targetFile, {
|
|
204
|
+
file: dep.source_file,
|
|
205
|
+
line: dep.line_number,
|
|
206
|
+
source_domain: dep.source_domain,
|
|
207
|
+
target_domain: targetDomain ?? dep.source_domain,
|
|
208
|
+
target: dep.target,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const findCycles = (
|
|
215
|
+
graph: Map<string, Map<string, EdgeMeta>>,
|
|
216
|
+
kindLabel: "file" | "domain"
|
|
217
|
+
) => {
|
|
218
|
+
const visited = new Set<string>();
|
|
219
|
+
const inStack = new Set<string>();
|
|
220
|
+
const stack: string[] = [];
|
|
221
|
+
const reportedCycles = new Set<string>();
|
|
222
|
+
|
|
223
|
+
const canonicalizeCycle = (nodes: string[]): string => {
|
|
224
|
+
if (nodes.length === 0) return "";
|
|
225
|
+
let minIdx = 0;
|
|
226
|
+
for (let i = 1; i < nodes.length; i++) {
|
|
227
|
+
if (nodes[i] < nodes[minIdx]) minIdx = i;
|
|
228
|
+
}
|
|
229
|
+
const rotated = [...nodes.slice(minIdx), ...nodes.slice(0, minIdx)];
|
|
230
|
+
return rotated.join("->");
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const dfs = (node: string) => {
|
|
234
|
+
visited.add(node);
|
|
235
|
+
inStack.add(node);
|
|
236
|
+
stack.push(node);
|
|
237
|
+
|
|
238
|
+
const neighbors = graph.get(node);
|
|
239
|
+
if (neighbors) {
|
|
240
|
+
for (const [nextNode, edgeMeta] of neighbors.entries()) {
|
|
241
|
+
if (!visited.has(nextNode)) {
|
|
242
|
+
dfs(nextNode);
|
|
243
|
+
} else if (inStack.has(nextNode)) {
|
|
244
|
+
const cycleStartIndex = stack.indexOf(nextNode);
|
|
245
|
+
if (cycleStartIndex !== -1) {
|
|
246
|
+
const cycleNodes = stack.slice(cycleStartIndex);
|
|
247
|
+
const sig = canonicalizeCycle(cycleNodes);
|
|
248
|
+
if (!reportedCycles.has(sig)) {
|
|
249
|
+
reportedCycles.add(sig);
|
|
250
|
+
const firstNode = cycleNodes[0];
|
|
251
|
+
const secondNode = cycleNodes[1] ?? nextNode;
|
|
252
|
+
const firstEdge = graph.get(firstNode)?.get(secondNode) ?? edgeMeta;
|
|
253
|
+
const cycleDisplay = [...cycleNodes, cycleNodes[0]].join(" -> ");
|
|
254
|
+
violations.push({
|
|
255
|
+
file: firstEdge.file,
|
|
256
|
+
line: firstEdge.line,
|
|
257
|
+
source_domain: firstEdge.source_domain,
|
|
258
|
+
target_domain: firstEdge.target_domain,
|
|
259
|
+
imported_target: firstEdge.target,
|
|
260
|
+
rule: "circular_dependency",
|
|
261
|
+
message: `Circular ${kindLabel} dependency detected: ${cycleDisplay}.`,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
stack.pop();
|
|
270
|
+
inStack.delete(node);
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
for (const startNode of graph.keys()) {
|
|
274
|
+
if (!visited.has(startNode)) {
|
|
275
|
+
dfs(startNode);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
findCycles(fileGraph, "file");
|
|
281
|
+
findCycles(domainGraph, "domain");
|
|
282
|
+
|
|
283
|
+
return violations;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
public checkProposedChanges(
|
|
287
|
+
sourceFilePath: string,
|
|
288
|
+
proposedImports: string[],
|
|
289
|
+
config: ValidatedSeptumConfig
|
|
290
|
+
): BoundaryViolation[] {
|
|
291
|
+
const violations: BoundaryViolation[] = [];
|
|
292
|
+
const sourceDomain = this.resolveSourceDomain(sourceFilePath, config);
|
|
293
|
+
|
|
294
|
+
if (!sourceDomain) {
|
|
295
|
+
violations.push({
|
|
296
|
+
file: sourceFilePath,
|
|
297
|
+
line: 1,
|
|
298
|
+
source_domain: "unmapped",
|
|
299
|
+
target_domain: "unknown",
|
|
300
|
+
imported_target: "",
|
|
301
|
+
rule: "unmapped_domain",
|
|
302
|
+
message: `File '${sourceFilePath}' does not belong to any declared bounded context. Boundary integrity cannot be enforced.`,
|
|
303
|
+
});
|
|
304
|
+
return violations;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const sourceDomainConfig = config.domains[sourceDomain];
|
|
308
|
+
if (!sourceDomainConfig) {
|
|
309
|
+
return violations;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const forbidden = sourceDomainConfig.forbidden_dependencies ?? [];
|
|
313
|
+
const allowed = sourceDomainConfig.allowed_dependencies ?? [];
|
|
314
|
+
|
|
315
|
+
for (const statement of proposedImports) {
|
|
316
|
+
const targetDomain = this.resolveTargetDomain(statement, config, sourceFilePath);
|
|
317
|
+
if (!targetDomain || targetDomain === sourceDomain) {
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (forbidden.includes(targetDomain)) {
|
|
322
|
+
violations.push({
|
|
323
|
+
file: sourceFilePath,
|
|
324
|
+
line: 1,
|
|
325
|
+
source_domain: sourceDomain,
|
|
326
|
+
target_domain: targetDomain,
|
|
327
|
+
imported_target: statement,
|
|
328
|
+
rule: "forbidden_dependency",
|
|
329
|
+
message: `Illegal import blocked: Domain '${sourceDomain}' is strictly forbidden from importing domain '${targetDomain}'.`,
|
|
330
|
+
});
|
|
331
|
+
} else if (config.settings.enforcement === "strict" && !allowed.includes(targetDomain)) {
|
|
332
|
+
violations.push({
|
|
333
|
+
file: sourceFilePath,
|
|
334
|
+
line: 1,
|
|
335
|
+
source_domain: sourceDomain,
|
|
336
|
+
target_domain: targetDomain,
|
|
337
|
+
imported_target: statement,
|
|
338
|
+
rule: "disallowed_dependency",
|
|
339
|
+
message: `Unlisted domain dependency: '${targetDomain}' must be explicitly added to allowed_dependencies before domain '${sourceDomain}' can import it.`,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return violations;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
public resolveSourceDomain(filePath: string, config: ValidatedSeptumConfig): string | null {
|
|
348
|
+
return this.resolver.resolveSourceDomain(filePath, config);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
public resolveTargetDomain(
|
|
352
|
+
targetStatement: string,
|
|
353
|
+
config: ValidatedSeptumConfig,
|
|
354
|
+
sourceFilePath?: string
|
|
355
|
+
): string | null {
|
|
356
|
+
return this.resolver.resolveTargetDomain(targetStatement, sourceFilePath, config);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
public checkFeatureTouchpoints(
|
|
360
|
+
featureKey: string,
|
|
361
|
+
targetFilePath: string,
|
|
362
|
+
config: ValidatedSeptumConfig
|
|
363
|
+
): BoundaryViolation[] {
|
|
364
|
+
const violations: BoundaryViolation[] = [];
|
|
365
|
+
const features = config.features ?? {};
|
|
366
|
+
const featureConfig = features[featureKey];
|
|
367
|
+
|
|
368
|
+
if (!featureConfig) {
|
|
369
|
+
violations.push({
|
|
370
|
+
file: targetFilePath,
|
|
371
|
+
line: 1,
|
|
372
|
+
source_domain: "unknown",
|
|
373
|
+
target_domain: "unknown",
|
|
374
|
+
imported_target: targetFilePath,
|
|
375
|
+
rule: "unknown_feature",
|
|
376
|
+
message: `Feature '${featureKey}' is not defined in septum configuration.`,
|
|
377
|
+
});
|
|
378
|
+
return violations;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const normalizedTarget = targetFilePath.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
382
|
+
const allowedTouchpoints = featureConfig.allowed_touchpoints.map((tp) =>
|
|
383
|
+
tp.replace(/\\/g, "/").replace(/^\.\//, "")
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
const domainCfg = config.domains[featureConfig.domain];
|
|
387
|
+
const domainRoot = domainCfg?.root ? domainCfg.root.replace(/\\/g, "/").replace(/^\.\//, "") : "";
|
|
388
|
+
|
|
389
|
+
const isAllowed = allowedTouchpoints.some((pattern) => {
|
|
390
|
+
if (pattern.endsWith("/**")) {
|
|
391
|
+
const prefix = pattern.slice(0, -3);
|
|
392
|
+
return normalizedTarget.startsWith(prefix);
|
|
393
|
+
}
|
|
394
|
+
if (pattern.endsWith("/*")) {
|
|
395
|
+
const prefix = pattern.slice(0, -2);
|
|
396
|
+
return normalizedTarget.startsWith(prefix);
|
|
397
|
+
}
|
|
398
|
+
if (normalizedTarget === pattern) return true;
|
|
399
|
+
|
|
400
|
+
if (
|
|
401
|
+
domainRoot &&
|
|
402
|
+
(normalizedTarget === `${domainRoot}/${pattern}` ||
|
|
403
|
+
normalizedTarget.startsWith(`${domainRoot}/${pattern}`))
|
|
404
|
+
) {
|
|
405
|
+
return true;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (pattern.includes("/") && normalizedTarget.endsWith("/" + pattern)) {
|
|
409
|
+
return true;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return false;
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
if (!isAllowed) {
|
|
416
|
+
violations.push({
|
|
417
|
+
file: targetFilePath,
|
|
418
|
+
line: 1,
|
|
419
|
+
source_domain: featureConfig.domain,
|
|
420
|
+
target_domain: "unknown",
|
|
421
|
+
imported_target: targetFilePath,
|
|
422
|
+
rule: "touchpoint_violation",
|
|
423
|
+
message: `Scope violation: File '${targetFilePath}' is outside the declared allowed_touchpoints for feature '${featureKey}'. Permitted: [${allowedTouchpoints.join(", ")}].`,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return violations;
|
|
428
|
+
}
|
|
429
|
+
}
|