septum 0.1.5 → 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/package.json +1 -1
- package/src/cli/commands/hook.ts +36 -8
- package/src/cli/commands/init.ts +6 -4
- package/src/cli/commands/query.ts +45 -5
- package/src/cli/commands/slice.ts +4 -2
- package/src/cli/index.ts +15 -5
- package/src/cli/templates/hooks.ts +6 -6
- package/src/cli/templates/rules.ts +35 -23
- package/src/cli/templates/skill.ts +47 -51
- package/src/core/boundary/evaluator.ts +198 -1
- package/src/core/config/loader.ts +20 -0
- package/src/core/database/repositories/dependency.repository.ts +249 -63
- package/src/core/database/repositories/domain.repository.ts +96 -39
- package/src/core/database/repositories/file.repository.ts +20 -19
- package/src/core/database/repositories/symbol.repository.ts +44 -5
- package/src/core/database/repository.ts +19 -2
- package/src/core/database/schema.ts +3 -2
- package/src/core/ingestion/pipeline.ts +47 -25
- package/src/core/parser/boundary-tracker.ts +145 -0
- package/src/core/parser/extractors/frontend.ts +43 -90
- package/src/core/parser/extractors/go.ts +200 -21
- package/src/core/parser/extractors/laravel-semantic.ts +18 -12
- package/src/core/parser/extractors/mcp-cli-semantic.ts +173 -44
- package/src/core/parser/extractors/nestjs-semantic.ts +20 -5
- package/src/core/parser/extractors/php.ts +293 -31
- package/src/core/parser/extractors/python.ts +242 -10
- package/src/core/parser/extractors/typescript.ts +604 -117
- package/src/core/parser/wasm-loader.ts +271 -0
- package/src/core/resolver/call-graph-tracer.ts +49 -17
- package/src/core/resolver/module-resolver.ts +87 -16
- package/src/core/resolver/vertical-slice-tracer.ts +33 -11
- package/src/core/topology/environment-detector.ts +109 -6
- package/src/mcp/schemas.ts +146 -29
- package/src/mcp/server.ts +93 -41
- package/src/mcp/tools/check-boundary.ts +176 -40
- package/src/mcp/tools/get-domain-catalog.ts +24 -14
- package/src/mcp/tools/get-environment-topology.ts +3 -6
- package/src/mcp/tools/get-feature-context.ts +35 -20
- package/src/mcp/tools/get-symbol-hotspots.ts +4 -10
- package/src/mcp/tools/locate-symbol.ts +12 -14
- package/src/mcp/tools/register-domain.ts +3 -12
- package/src/mcp/tools/register-feature.ts +22 -31
- package/src/mcp/tools/trace-vertical-slice.ts +8 -8
- package/src/types/index.ts +36 -5
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { posix } from "node:path";
|
|
1
2
|
import type { BoundaryViolation } from "../../types/index.ts";
|
|
2
3
|
import type { ValidatedSeptumConfig } from "../config/schema.ts";
|
|
3
4
|
import { ConfigLoader } from "../config/loader.ts";
|
|
@@ -23,7 +24,14 @@ export class BoundaryEvaluator {
|
|
|
23
24
|
featureKey?: string,
|
|
24
25
|
config?: ValidatedSeptumConfig
|
|
25
26
|
): BoundaryViolation[] {
|
|
26
|
-
|
|
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
|
+
}
|
|
27
35
|
const violations: BoundaryViolation[] = [];
|
|
28
36
|
|
|
29
37
|
if (featureKey) {
|
|
@@ -83,6 +91,195 @@ export class BoundaryEvaluator {
|
|
|
83
91
|
}
|
|
84
92
|
}
|
|
85
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
|
+
|
|
86
283
|
return violations;
|
|
87
284
|
}
|
|
88
285
|
|
|
@@ -62,6 +62,26 @@ export class ConfigLoader {
|
|
|
62
62
|
};
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
public static loadFromRepository(repo: SeptumRepository): ValidatedSeptumConfig {
|
|
66
|
+
const config = ConfigLoader.getDefaultConfig();
|
|
67
|
+
if (repo && typeof repo.getAllDomains === "function") {
|
|
68
|
+
try {
|
|
69
|
+
const rows = repo.getAllDomains();
|
|
70
|
+
for (const r of rows) {
|
|
71
|
+
config.domains[r.name] = {
|
|
72
|
+
root: r.root_path,
|
|
73
|
+
allowed_dependencies: JSON.parse(r.allowed_deps_json || "[]"),
|
|
74
|
+
forbidden_dependencies: JSON.parse(r.forbidden_deps_json || "[]"),
|
|
75
|
+
archetypes: JSON.parse(r.archetypes_json || "{}"),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
// Fallback gracefully to default config
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return config;
|
|
83
|
+
}
|
|
84
|
+
|
|
65
85
|
public static loadFromDatabaseOrDefaults(dbPath: string = ".septum/septum.db"): ValidatedSeptumConfig {
|
|
66
86
|
const config = ConfigLoader.getDefaultConfig();
|
|
67
87
|
config.settings.db_path = dbPath;
|
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
+
import { dirname, join, posix } from "node:path";
|
|
2
3
|
import type { Database } from "bun:sqlite";
|
|
3
4
|
import type {
|
|
4
5
|
ExtractedDependency,
|
|
6
|
+
FileConnectionsSummary,
|
|
5
7
|
GetSymbolImpactResponse,
|
|
6
8
|
InboundCaller,
|
|
7
9
|
VerticalSliceRecord,
|
|
8
10
|
} from "../../../types/index.ts";
|
|
9
11
|
|
|
12
|
+
function escapeRegExp(str: string): string {
|
|
13
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
14
|
+
}
|
|
15
|
+
|
|
10
16
|
export class DependencyRepository {
|
|
11
17
|
constructor(private db: Database) {}
|
|
12
18
|
|
|
@@ -103,6 +109,13 @@ export class DependencyRepository {
|
|
|
103
109
|
return this.db.query<VerticalSliceRecord, []>("SELECT * FROM vertical_slices ORDER BY id ASC").all();
|
|
104
110
|
}
|
|
105
111
|
|
|
112
|
+
public countVerticalSlices(): number {
|
|
113
|
+
const row = this.db
|
|
114
|
+
.query<{ cnt: number }, []>("SELECT COUNT(*) as cnt FROM vertical_slices")
|
|
115
|
+
.get();
|
|
116
|
+
return row?.cnt ?? 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
106
119
|
public clearVerticalSlices(domainId?: number): void {
|
|
107
120
|
if (domainId) {
|
|
108
121
|
this.db.query("DELETE FROM vertical_slices WHERE domain_id = ?").run(domainId);
|
|
@@ -135,6 +148,107 @@ export class DependencyRepository {
|
|
|
135
148
|
return this.db.query<any, []>(query).all();
|
|
136
149
|
}
|
|
137
150
|
|
|
151
|
+
public getDomainFileConnections(domainId?: number): Map<string, FileConnectionsSummary> {
|
|
152
|
+
const allFiles = this.db
|
|
153
|
+
.query<{ id: number; domain_id: number; path: string }, []>(
|
|
154
|
+
"SELECT id, domain_id, path FROM files ORDER BY path ASC"
|
|
155
|
+
)
|
|
156
|
+
.all();
|
|
157
|
+
|
|
158
|
+
const fileById = new Map<number, { id: number; domain_id: number; path: string }>();
|
|
159
|
+
const fileByNormPath = new Map<string, string>();
|
|
160
|
+
const fileByNoExtPath = new Map<string, string>();
|
|
161
|
+
|
|
162
|
+
for (const f of allFiles) {
|
|
163
|
+
const norm = f.path.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
164
|
+
fileById.set(f.id, { ...f, path: norm });
|
|
165
|
+
fileByNormPath.set(norm.toLowerCase(), f.path);
|
|
166
|
+
const noExt = norm.replace(/\.[^/.]+$/, "").toLowerCase();
|
|
167
|
+
fileByNoExtPath.set(noExt, f.path);
|
|
168
|
+
if (noExt.endsWith("/index")) {
|
|
169
|
+
fileByNoExtPath.set(noExt.slice(0, -"/index".length), f.path);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const resolveInternalTarget = (sourcePath: string, rawTarget: string): string | null => {
|
|
174
|
+
const cleaned = rawTarget.trim().replace(/^['"]|['"]$/g, "").replace(/\\/g, "/");
|
|
175
|
+
if (!cleaned) return null;
|
|
176
|
+
|
|
177
|
+
if (cleaned.startsWith(".")) {
|
|
178
|
+
const srcDir = posix.dirname(sourcePath.replace(/\\/g, "/"));
|
|
179
|
+
const joined = posix.normalize(posix.join(srcDir, cleaned)).replace(/^\.\//, "");
|
|
180
|
+
const lowerJoined = joined.toLowerCase();
|
|
181
|
+
const exact = fileByNormPath.get(lowerJoined) ?? fileByNoExtPath.get(lowerJoined.replace(/\.[^/.]+$/, ""));
|
|
182
|
+
if (exact) return exact;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const aliasClean = cleaned.replace(/^@\//, "src/").replace(/^\.\//, "");
|
|
186
|
+
const lowerAlias = aliasClean.toLowerCase();
|
|
187
|
+
const byAlias = fileByNormPath.get(lowerAlias) ?? fileByNoExtPath.get(lowerAlias.replace(/\.[^/.]+$/, ""));
|
|
188
|
+
if (byAlias) return byAlias;
|
|
189
|
+
|
|
190
|
+
for (const [noExt, origPath] of fileByNoExtPath.entries()) {
|
|
191
|
+
if (noExt.endsWith(`/${lowerAlias}`)) {
|
|
192
|
+
return origPath;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const importsFromMap = new Map<string, Set<string>>();
|
|
199
|
+
const importedByMap = new Map<string, Set<string>>();
|
|
200
|
+
for (const f of allFiles) {
|
|
201
|
+
importsFromMap.set(f.path, new Set());
|
|
202
|
+
importedByMap.set(f.path, new Set());
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const allInternalDeps = this.db
|
|
206
|
+
.query<{ source_file_id: number; target_symbol_or_path: string }, []>(
|
|
207
|
+
"SELECT source_file_id, target_symbol_or_path FROM dependencies WHERE is_external = 0"
|
|
208
|
+
)
|
|
209
|
+
.all();
|
|
210
|
+
|
|
211
|
+
for (const dep of allInternalDeps) {
|
|
212
|
+
const srcFile = fileById.get(dep.source_file_id);
|
|
213
|
+
if (!srcFile) continue;
|
|
214
|
+
const resolvedTarget = resolveInternalTarget(srcFile.path, dep.target_symbol_or_path);
|
|
215
|
+
if (resolvedTarget && resolvedTarget !== srcFile.path) {
|
|
216
|
+
importsFromMap.get(srcFile.path)?.add(resolvedTarget);
|
|
217
|
+
if (!importedByMap.has(resolvedTarget)) {
|
|
218
|
+
importedByMap.set(resolvedTarget, new Set());
|
|
219
|
+
}
|
|
220
|
+
importedByMap.get(resolvedTarget)?.add(srcFile.path);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const result = new Map<string, FileConnectionsSummary>();
|
|
225
|
+
for (const f of allFiles) {
|
|
226
|
+
if (domainId !== undefined && f.domain_id !== domainId) continue;
|
|
227
|
+
const importsFrom = Array.from(importsFromMap.get(f.path) ?? []).sort();
|
|
228
|
+
const importedBy = Array.from(importedByMap.get(f.path) ?? []).sort();
|
|
229
|
+
result.set(f.path, {
|
|
230
|
+
imports_from: importsFrom,
|
|
231
|
+
imported_by_count: importedBy.length,
|
|
232
|
+
imported_by: importedBy,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return result;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
public getFileConnections(filePath: string): FileConnectionsSummary {
|
|
240
|
+
const clean = filePath.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
241
|
+
const allConns = this.getDomainFileConnections();
|
|
242
|
+
return (
|
|
243
|
+
allConns.get(clean) ??
|
|
244
|
+
allConns.get(filePath) ?? {
|
|
245
|
+
imports_from: [],
|
|
246
|
+
imported_by_count: 0,
|
|
247
|
+
imported_by: [],
|
|
248
|
+
}
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
138
252
|
public getSymbolImpact(symbolQuery: string): GetSymbolImpactResponse {
|
|
139
253
|
const trimmed = symbolQuery.trim();
|
|
140
254
|
if (!trimmed) {
|
|
@@ -163,38 +277,126 @@ export class DependencyRepository {
|
|
|
163
277
|
targetMethod = parts[1];
|
|
164
278
|
}
|
|
165
279
|
|
|
166
|
-
// 1.
|
|
280
|
+
// 1. Look up target symbol in symbols joined with files to find its defining file_path & stem
|
|
281
|
+
const targetSymRows = this.db
|
|
282
|
+
.query<{ name: string; file_path: string }, [string, string, string, string]>(
|
|
283
|
+
`SELECT s.name, f.path AS file_path
|
|
284
|
+
FROM symbols s
|
|
285
|
+
JOIN files f ON s.file_id = f.id
|
|
286
|
+
WHERE s.name = ? OR s.name = ? OR s.name LIKE ? OR s.name LIKE ?`
|
|
287
|
+
)
|
|
288
|
+
.all(trimmed, targetContainer, `${targetContainer}::%`, `%::${targetContainer}`);
|
|
289
|
+
|
|
290
|
+
const definingFiles = new Set<string>(
|
|
291
|
+
targetSymRows.map((r) => r.file_path.replace(/\\/g, "/"))
|
|
292
|
+
);
|
|
293
|
+
const definingFileStems = Array.from(
|
|
294
|
+
new Set(
|
|
295
|
+
Array.from(definingFiles)
|
|
296
|
+
.map((p) => p.split("/").pop()?.replace(/\.[^.]+$/, "") || "")
|
|
297
|
+
.filter((s) => s.length >= 2)
|
|
298
|
+
)
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
// 2. Query dependencies matching targetContainer, trimmed, import_statement, or defining file stem
|
|
302
|
+
const whereClauses: string[] = [
|
|
303
|
+
"d.target_symbol_or_path LIKE ?",
|
|
304
|
+
"d.import_statement LIKE ?",
|
|
305
|
+
"d.target_symbol_or_path LIKE ?",
|
|
306
|
+
];
|
|
307
|
+
const queryParams: string[] = [
|
|
308
|
+
`%${targetContainer}%`,
|
|
309
|
+
`%${targetContainer}%`,
|
|
310
|
+
`%${trimmed}%`,
|
|
311
|
+
];
|
|
312
|
+
|
|
313
|
+
for (const stem of definingFileStems) {
|
|
314
|
+
whereClauses.push("d.target_symbol_or_path LIKE ?");
|
|
315
|
+
queryParams.push(`%${stem}%`);
|
|
316
|
+
}
|
|
317
|
+
|
|
167
318
|
const depSql = `
|
|
168
319
|
SELECT
|
|
169
320
|
f.path AS file,
|
|
170
321
|
d.line_number,
|
|
171
322
|
d.target_symbol_or_path,
|
|
323
|
+
d.import_statement,
|
|
172
324
|
dom.name AS domain_name
|
|
173
325
|
FROM dependencies d
|
|
174
326
|
JOIN files f ON d.source_file_id = f.id
|
|
175
|
-
JOIN domains dom ON f.domain_id = dom.id
|
|
176
|
-
WHERE
|
|
177
|
-
OR d.target_symbol_or_path LIKE ?
|
|
327
|
+
LEFT JOIN domains dom ON f.domain_id = dom.id
|
|
328
|
+
WHERE ${whereClauses.join(" OR ")}
|
|
178
329
|
ORDER BY f.path ASC, d.line_number ASC
|
|
179
330
|
`;
|
|
180
|
-
const
|
|
181
|
-
.query<any, [string, string]>(depSql)
|
|
182
|
-
.all(`%${targetContainer}%`, `%${trimmed}%`);
|
|
331
|
+
const rawDepRows = this.db.query<any, string[]>(depSql).all(...queryParams);
|
|
183
332
|
|
|
184
|
-
const
|
|
333
|
+
const containerWordRegex = new RegExp(`\\b${escapeRegExp(targetContainer)}\\b`);
|
|
334
|
+
const depRows = rawDepRows.filter((row) => {
|
|
335
|
+
const normCallerFile = String(row.file).replace(/\\/g, "/");
|
|
336
|
+
if (definingFiles.has(normCallerFile)) return false;
|
|
185
337
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
338
|
+
const stmt = String(row.import_statement || "");
|
|
339
|
+
const target = String(row.target_symbol_or_path || "");
|
|
340
|
+
|
|
341
|
+
if (containerWordRegex.test(stmt) || containerWordRegex.test(target)) {
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// If no named specifier braces are present, accept path match against defining file stem
|
|
346
|
+
if (!stmt.includes("{")) {
|
|
347
|
+
for (const stem of definingFileStems) {
|
|
348
|
+
const stemRegex = new RegExp(`(?:^|[\\\\/])${escapeRegExp(stem)}(?:\\.[^\\\\/]+)?$`);
|
|
349
|
+
if (stemRegex.test(target)) {
|
|
350
|
+
return true;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return false;
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// 3. Batch-load all symbols for candidate caller files in ONE query (zero N+1)
|
|
359
|
+
const uniqueCallerFiles = Array.from(new Set(depRows.map((r) => String(r.file))));
|
|
360
|
+
const callerSymbolsByFile = new Map<
|
|
361
|
+
string,
|
|
362
|
+
Array<{ name: string; kind: string; line_start: number; line_end: number }>
|
|
363
|
+
>();
|
|
364
|
+
|
|
365
|
+
if (uniqueCallerFiles.length > 0) {
|
|
366
|
+
const placeholders = uniqueCallerFiles.map(() => "?").join(",");
|
|
367
|
+
const allCallerSyms = this.db
|
|
368
|
+
.query<
|
|
369
|
+
{ name: string; kind: string; line_start: number; line_end: number; path: string },
|
|
370
|
+
string[]
|
|
371
|
+
>(
|
|
372
|
+
`SELECT s.name, s.kind, s.line_start, s.line_end, f.path
|
|
191
373
|
FROM symbols s
|
|
192
374
|
JOIN files f ON s.file_id = f.id
|
|
193
|
-
WHERE f.path
|
|
194
|
-
ORDER BY (s.line_end - s.line_start) ASC
|
|
375
|
+
WHERE f.path IN (${placeholders})
|
|
376
|
+
ORDER BY (s.line_end - s.line_start) ASC`
|
|
195
377
|
)
|
|
196
|
-
.
|
|
378
|
+
.all(...uniqueCallerFiles);
|
|
197
379
|
|
|
380
|
+
for (const sym of allCallerSyms) {
|
|
381
|
+
const list = callerSymbolsByFile.get(sym.path) || [];
|
|
382
|
+
list.push(sym);
|
|
383
|
+
callerSymbolsByFile.set(sym.path, list);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const findEnclosingSymbol = (
|
|
388
|
+
filePath: string,
|
|
389
|
+
lineNum: number
|
|
390
|
+
): { name: string; kind: string } | undefined => {
|
|
391
|
+
const syms = callerSymbolsByFile.get(filePath);
|
|
392
|
+
if (!syms) return undefined;
|
|
393
|
+
return syms.find((s) => lineNum >= s.line_start && lineNum <= s.line_end);
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
const callersMap = new Map<string, InboundCaller>();
|
|
397
|
+
|
|
398
|
+
for (const row of depRows) {
|
|
399
|
+
const enclosing = findEnclosingSymbol(row.file, row.line_number);
|
|
198
400
|
const callerName = enclosing ? enclosing.name : `${row.file}:${row.line_number}`;
|
|
199
401
|
const key = `${row.file}:${row.line_number}:${callerName}`;
|
|
200
402
|
|
|
@@ -204,16 +406,14 @@ export class DependencyRepository {
|
|
|
204
406
|
caller: callerName,
|
|
205
407
|
line: row.line_number,
|
|
206
408
|
kind: enclosing?.kind || "import",
|
|
207
|
-
domain: row.domain_name,
|
|
409
|
+
domain: row.domain_name ?? undefined,
|
|
208
410
|
});
|
|
209
411
|
}
|
|
210
412
|
}
|
|
211
413
|
|
|
212
|
-
//
|
|
414
|
+
// 4. Scan file content of callers for exact method calls if targetMethod specified
|
|
213
415
|
if (targetMethod) {
|
|
214
|
-
|
|
215
|
-
const uniqueFiles = Array.from(new Set(depRows.map((r) => r.file)));
|
|
216
|
-
for (const filePath of uniqueFiles) {
|
|
416
|
+
for (const filePath of uniqueCallerFiles) {
|
|
217
417
|
try {
|
|
218
418
|
if (fs.existsSync(filePath)) {
|
|
219
419
|
const content = fs.readFileSync(filePath, "utf-8");
|
|
@@ -222,19 +422,11 @@ export class DependencyRepository {
|
|
|
222
422
|
const line = lines[i];
|
|
223
423
|
if (
|
|
224
424
|
line.includes(`->${targetMethod}(`) ||
|
|
225
|
-
line.includes(`::${targetMethod}(`)
|
|
425
|
+
line.includes(`::${targetMethod}(`) ||
|
|
426
|
+
line.includes(`.${targetMethod}(`)
|
|
226
427
|
) {
|
|
227
428
|
const lineNum = i + 1;
|
|
228
|
-
const enclosing =
|
|
229
|
-
.query<{ name: string; kind: string }, [string, number]>(
|
|
230
|
-
`SELECT s.name, s.kind
|
|
231
|
-
FROM symbols s
|
|
232
|
-
JOIN files f ON s.file_id = f.id
|
|
233
|
-
WHERE f.path = ? AND ? BETWEEN s.line_start AND s.line_end
|
|
234
|
-
ORDER BY (s.line_end - s.line_start) ASC LIMIT 1`
|
|
235
|
-
)
|
|
236
|
-
.get(filePath, lineNum);
|
|
237
|
-
|
|
429
|
+
const enclosing = findEnclosingSymbol(filePath, lineNum);
|
|
238
430
|
const callerName = enclosing ? enclosing.name : `${filePath}:${lineNum}`;
|
|
239
431
|
const key = `${filePath}:${lineNum}:${callerName}`;
|
|
240
432
|
if (!callersMap.has(key)) {
|
|
@@ -267,15 +459,8 @@ export class DependencyRepository {
|
|
|
267
459
|
riskLevel = "medium";
|
|
268
460
|
}
|
|
269
461
|
|
|
270
|
-
//
|
|
271
|
-
const
|
|
272
|
-
.query<{ count: number }, [string, string, string]>(
|
|
273
|
-
`SELECT COUNT(*) as count FROM symbols
|
|
274
|
-
WHERE name = ? OR name = ? OR name = ? LIMIT 1`
|
|
275
|
-
)
|
|
276
|
-
.get(trimmed, targetContainer, targetMethod || trimmed)?.count ?? 0;
|
|
277
|
-
|
|
278
|
-
const foundInIndex = symbolInDb > 0 || depRows.length > 0;
|
|
462
|
+
// 5. Determine if symbol exists in index
|
|
463
|
+
const foundInIndex = targetSymRows.length > 0 || depRows.length > 0;
|
|
279
464
|
const warning = foundInIndex
|
|
280
465
|
? undefined
|
|
281
466
|
: `Symbol '${trimmed}' was not found in Septum index (neither in declared symbols nor tracked dependencies). Risk assessment defaults to low (0 callers) due to unindexed status.`;
|
|
@@ -312,42 +497,43 @@ export class DependencyRepository {
|
|
|
312
497
|
.all(cleanPath, `%${cleanPath}`);
|
|
313
498
|
|
|
314
499
|
const matchedFiles = new Set<string>();
|
|
500
|
+
const orConditions: string[] = [];
|
|
501
|
+
const params: string[] = [];
|
|
315
502
|
|
|
316
503
|
if (baseName.length >= 3) {
|
|
504
|
+
orConditions.push("d.target_symbol_or_path LIKE ?");
|
|
505
|
+
params.push(`%${cleanPath}%`);
|
|
506
|
+
orConditions.push("d.target_symbol_or_path LIKE ?");
|
|
507
|
+
params.push(`%${baseName}%`);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
for (const sym of symbols.slice(0, 8)) {
|
|
511
|
+
const symName = sym.name.split("::").pop() || sym.name;
|
|
512
|
+
if (symName.length >= 4) {
|
|
513
|
+
orConditions.push("d.target_symbol_or_path LIKE ?");
|
|
514
|
+
params.push(`%${symName}%`);
|
|
515
|
+
orConditions.push("d.import_statement LIKE ?");
|
|
516
|
+
params.push(`%${symName}%`);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (orConditions.length > 0) {
|
|
521
|
+
params.push(`%${cleanPath}`);
|
|
317
522
|
const depRows = this.db
|
|
318
|
-
.query<{ file: string }, [
|
|
523
|
+
.query<{ file: string }, string[]>(
|
|
319
524
|
`SELECT DISTINCT f.path AS file
|
|
320
525
|
FROM dependencies d
|
|
321
526
|
JOIN files f ON d.source_file_id = f.id
|
|
322
|
-
WHERE (
|
|
527
|
+
WHERE (${orConditions.join(" OR ")})
|
|
323
528
|
AND f.path NOT LIKE ?`
|
|
324
529
|
)
|
|
325
|
-
.all(
|
|
530
|
+
.all(...params);
|
|
326
531
|
|
|
327
532
|
for (const row of depRows) {
|
|
328
533
|
matchedFiles.add(row.file);
|
|
329
534
|
}
|
|
330
535
|
}
|
|
331
536
|
|
|
332
|
-
for (const sym of symbols.slice(0, 8)) {
|
|
333
|
-
const symName = sym.name.split("::").pop() || sym.name;
|
|
334
|
-
if (symName.length >= 4) {
|
|
335
|
-
const symDeps = this.db
|
|
336
|
-
.query<{ file: string }, [string, string]>(
|
|
337
|
-
`SELECT DISTINCT f.path AS file
|
|
338
|
-
FROM dependencies d
|
|
339
|
-
JOIN files f ON d.source_file_id = f.id
|
|
340
|
-
WHERE d.target_symbol_or_path LIKE ?
|
|
341
|
-
AND f.path NOT LIKE ?
|
|
342
|
-
LIMIT 10`
|
|
343
|
-
)
|
|
344
|
-
.all(`%${symName}%`, `%${cleanPath}`);
|
|
345
|
-
for (const r of symDeps) {
|
|
346
|
-
matchedFiles.add(r.file);
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
|
|
351
537
|
const consumerList = Array.from(matchedFiles);
|
|
352
538
|
let riskLevel: "low" | "medium" | "high" = "low";
|
|
353
539
|
if (consumerList.length >= 5) {
|