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.
Files changed (70) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +105 -105
  3. package/bin/septum.ts +4 -4
  4. package/package.json +63 -63
  5. package/src/cli/commands/check.ts +161 -161
  6. package/src/cli/commands/hook.ts +216 -188
  7. package/src/cli/commands/ingest.ts +29 -29
  8. package/src/cli/commands/init.ts +213 -211
  9. package/src/cli/commands/locate.ts +61 -61
  10. package/src/cli/commands/query.ts +104 -64
  11. package/src/cli/commands/serve.ts +5 -5
  12. package/src/cli/commands/slice.ts +50 -48
  13. package/src/cli/commands/sync.ts +31 -31
  14. package/src/cli/index.ts +220 -210
  15. package/src/cli/templates/hooks.ts +75 -75
  16. package/src/cli/templates/rules.ts +50 -38
  17. package/src/cli/templates/skill.ts +65 -69
  18. package/src/core/boundary/evaluator.ts +429 -210
  19. package/src/core/config/loader.ts +117 -97
  20. package/src/core/config/schema.ts +39 -39
  21. package/src/core/database/client.ts +44 -44
  22. package/src/core/database/repositories/dependency.repository.ts +552 -349
  23. package/src/core/database/repositories/domain.repository.ts +275 -198
  24. package/src/core/database/repositories/file.repository.ts +117 -106
  25. package/src/core/database/repositories/meta.repository.ts +33 -33
  26. package/src/core/database/repositories/symbol.repository.ts +482 -443
  27. package/src/core/database/repository.ts +281 -260
  28. package/src/core/database/schema.ts +154 -153
  29. package/src/core/discovery/topology-detector.ts +486 -486
  30. package/src/core/ingestion/hasher.ts +5 -5
  31. package/src/core/ingestion/pipeline.ts +606 -535
  32. package/src/core/parser/boundary-tracker.ts +433 -288
  33. package/src/core/parser/extractors/base.ts +62 -62
  34. package/src/core/parser/extractors/blade.ts +158 -158
  35. package/src/core/parser/extractors/frontend.ts +147 -194
  36. package/src/core/parser/extractors/go.ts +408 -180
  37. package/src/core/parser/extractors/laravel-semantic.ts +1045 -901
  38. package/src/core/parser/extractors/mcp-cli-semantic.ts +328 -199
  39. package/src/core/parser/extractors/nestjs-semantic.ts +134 -119
  40. package/src/core/parser/extractors/php.ts +415 -153
  41. package/src/core/parser/extractors/python.ts +460 -228
  42. package/src/core/parser/extractors/registry.ts +70 -70
  43. package/src/core/parser/extractors/semantic-extractor.interface.ts +21 -21
  44. package/src/core/parser/extractors/typescript.ts +668 -181
  45. package/src/core/parser/tree-sitter.ts +41 -41
  46. package/src/core/parser/wasm-loader.ts +271 -0
  47. package/src/core/resolver/call-graph-tracer.ts +698 -668
  48. package/src/core/resolver/module-resolver.ts +479 -342
  49. package/src/core/resolver/path-resolver.ts +147 -147
  50. package/src/core/resolver/symbol-locator.ts +543 -543
  51. package/src/core/resolver/vertical-slice-tracer.ts +323 -285
  52. package/src/core/session/session-manager.ts +56 -56
  53. package/src/core/telemetry/telemetry.ts +86 -86
  54. package/src/core/topology/environment-detector.ts +109 -6
  55. package/src/index.ts +21 -21
  56. package/src/mcp/schemas.ts +695 -286
  57. package/src/mcp/server.ts +927 -716
  58. package/src/mcp/tools/check-boundary.ts +307 -152
  59. package/src/mcp/tools/get-domain-catalog.ts +155 -121
  60. package/src/mcp/tools/get-environment-topology.ts +3 -6
  61. package/src/mcp/tools/get-feature-context.ts +227 -116
  62. package/src/mcp/tools/get-symbol-hotspots.ts +54 -59
  63. package/src/mcp/tools/get-symbol-impact.ts +28 -26
  64. package/src/mcp/tools/get-symbol.ts +65 -63
  65. package/src/mcp/tools/locate-symbol.ts +169 -190
  66. package/src/mcp/tools/register-domain.ts +59 -64
  67. package/src/mcp/tools/register-feature.ts +91 -0
  68. package/src/mcp/tools/trace-vertical-slice.ts +74 -48
  69. package/src/types/index.ts +366 -315
  70. package/src/version.ts +3 -3
@@ -1,349 +1,552 @@
1
- import * as fs from "node:fs";
2
- import type { Database } from "bun:sqlite";
3
- import type {
4
- ExtractedDependency,
5
- GetSymbolImpactResponse,
6
- InboundCaller,
7
- VerticalSliceRecord,
8
- } from "../../../types/index.ts";
9
-
10
- export class DependencyRepository {
11
- constructor(private db: Database) {}
12
-
13
- public replaceFileDependencies(fileId: number, deps: ExtractedDependency[]): void {
14
- this.db.query("DELETE FROM dependencies WHERE source_file_id = ?").run(fileId);
15
- if (deps.length === 0) return;
16
-
17
- const stmt = this.db.query(
18
- `INSERT INTO dependencies (source_file_id, target_symbol_or_path, import_statement, line_number, is_external)
19
- VALUES (?, ?, ?, ?, ?)`
20
- );
21
-
22
- for (const dep of deps) {
23
- stmt.run(fileId, dep.target, dep.statement, dep.line_number, dep.is_external ? 1 : 0);
24
- }
25
- }
26
-
27
- public upsertVerticalSlice(
28
- slice: Omit<VerticalSliceRecord, "id" | "created_at">
29
- ): void {
30
- const existing = this.db
31
- .query<VerticalSliceRecord, [string, string]>(
32
- "SELECT id FROM vertical_slices WHERE http_method = ? AND route_uri = ?"
33
- )
34
- .get(slice.http_method, slice.route_uri);
35
-
36
- if (existing) {
37
- this.db
38
- .query(
39
- `UPDATE vertical_slices
40
- SET domain_id = ?, feature_key = ?, route_name = ?, controller_class = ?,
41
- action_name = ?, controller_file = ?, controller_line = ?,
42
- architecture_style = ?, entry_kind = ?, execution_chain_json = ?
43
- WHERE id = ?`
44
- )
45
- .run(
46
- slice.domain_id,
47
- slice.feature_key,
48
- slice.route_name,
49
- slice.controller_class,
50
- slice.action_name,
51
- slice.controller_file,
52
- slice.controller_line,
53
- slice.architecture_style ?? "clean",
54
- slice.entry_kind ?? "http_route",
55
- slice.execution_chain_json,
56
- existing.id
57
- );
58
- } else {
59
- this.db
60
- .query(
61
- `INSERT INTO vertical_slices (
62
- domain_id, feature_key, http_method, route_uri, route_name,
63
- controller_class, action_name, controller_file, controller_line,
64
- architecture_style, entry_kind, execution_chain_json
65
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
66
- )
67
- .run(
68
- slice.domain_id,
69
- slice.feature_key,
70
- slice.http_method,
71
- slice.route_uri,
72
- slice.route_name,
73
- slice.controller_class,
74
- slice.action_name,
75
- slice.controller_file,
76
- slice.controller_line,
77
- slice.architecture_style ?? "clean",
78
- slice.entry_kind ?? "http_route",
79
- slice.execution_chain_json
80
- );
81
- }
82
- }
83
-
84
- public findVerticalSlices(searchTerm: string): VerticalSliceRecord[] {
85
- const term = searchTerm.trim();
86
- const wild = `%${term}%`;
87
- const query = `
88
- SELECT * FROM vertical_slices
89
- WHERE route_uri LIKE ?
90
- OR route_name LIKE ?
91
- OR controller_class LIKE ?
92
- OR action_name LIKE ?
93
- OR feature_key LIKE ?
94
- OR execution_chain_json LIKE ?
95
- ORDER BY id ASC
96
- `;
97
- return this.db.query<VerticalSliceRecord, [string, string, string, string, string, string]>(query).all(
98
- wild, wild, wild, wild, wild, wild
99
- );
100
- }
101
-
102
- public getAllVerticalSlices(): VerticalSliceRecord[] {
103
- return this.db.query<VerticalSliceRecord, []>("SELECT * FROM vertical_slices ORDER BY id ASC").all();
104
- }
105
-
106
- public clearVerticalSlices(domainId?: number): void {
107
- if (domainId) {
108
- this.db.query("DELETE FROM vertical_slices WHERE domain_id = ?").run(domainId);
109
- } else {
110
- this.db.query("DELETE FROM vertical_slices").run();
111
- }
112
- }
113
-
114
- public getAllDependenciesWithDomains(): Array<{
115
- source_file: string;
116
- source_domain: string;
117
- line_number: number;
118
- target: string;
119
- statement: string;
120
- is_external: boolean;
121
- }> {
122
- const query = `
123
- SELECT
124
- f.path AS source_file,
125
- d.name AS source_domain,
126
- dep.line_number,
127
- dep.target_symbol_or_path AS target,
128
- dep.import_statement AS statement,
129
- dep.is_external
130
- FROM dependencies dep
131
- JOIN files f ON dep.source_file_id = f.id
132
- JOIN domains d ON f.domain_id = d.id
133
- ORDER BY d.name, f.path, dep.line_number
134
- `;
135
- return this.db.query<any, []>(query).all();
136
- }
137
-
138
- public getSymbolImpact(symbolQuery: string): GetSymbolImpactResponse {
139
- const trimmed = symbolQuery.trim();
140
- if (!trimmed) {
141
- return {
142
- target_symbol: symbolQuery,
143
- impact_summary: {
144
- total_dependents: 0,
145
- risk_level: "low",
146
- affected_files_count: 0,
147
- },
148
- inbound_callers: [],
149
- };
150
- }
151
-
152
- let targetContainer = trimmed;
153
- let targetMethod = "";
154
- if (trimmed.includes("::")) {
155
- const parts = trimmed.split("::");
156
- targetContainer = parts[0];
157
- targetMethod = parts[1];
158
- } else if (trimmed.includes("@")) {
159
- const parts = trimmed.split("@");
160
- targetContainer = parts[0];
161
- targetMethod = parts[1];
162
- }
163
-
164
- // 1. Inbound dependencies from dependencies table
165
- const depSql = `
166
- SELECT
167
- f.path AS file,
168
- d.line_number,
169
- d.target_symbol_or_path,
170
- dom.name AS domain_name
171
- FROM dependencies d
172
- JOIN files f ON d.source_file_id = f.id
173
- JOIN domains dom ON f.domain_id = dom.id
174
- WHERE d.target_symbol_or_path LIKE ?
175
- OR d.target_symbol_or_path LIKE ?
176
- ORDER BY f.path ASC, d.line_number ASC
177
- `;
178
- const depRows = this.db
179
- .query<any, [string, string]>(depSql)
180
- .all(`%${targetContainer}%`, `%${trimmed}%`);
181
-
182
- const callersMap = new Map<string, InboundCaller>();
183
-
184
- for (const row of depRows) {
185
- // Find enclosing symbol in caller file if any
186
- const enclosing = this.db
187
- .query<{ name: string; kind: string }, [string, number]>(
188
- `SELECT s.name, s.kind
189
- FROM symbols s
190
- JOIN files f ON s.file_id = f.id
191
- WHERE f.path = ? AND ? BETWEEN s.line_start AND s.line_end
192
- ORDER BY (s.line_end - s.line_start) ASC LIMIT 1`
193
- )
194
- .get(row.file, row.line_number);
195
-
196
- const callerName = enclosing ? enclosing.name : `${row.file}:${row.line_number}`;
197
- const key = `${row.file}:${row.line_number}:${callerName}`;
198
-
199
- if (!callersMap.has(key)) {
200
- callersMap.set(key, {
201
- file: row.file,
202
- caller: callerName,
203
- line: row.line_number,
204
- kind: enclosing?.kind || "import",
205
- domain: row.domain_name,
206
- });
207
- }
208
- }
209
-
210
- // 2. Scan file content of callers or related files for exact method calls if targetMethod specified
211
- if (targetMethod) {
212
- // Look for files importing targetContainer and inspect if they call targetMethod
213
- const uniqueFiles = Array.from(new Set(depRows.map((r) => r.file)));
214
- for (const filePath of uniqueFiles) {
215
- try {
216
- if (fs.existsSync(filePath)) {
217
- const content = fs.readFileSync(filePath, "utf-8");
218
- const lines = content.split(/\r?\n/);
219
- for (let i = 0; i < lines.length; i++) {
220
- const line = lines[i];
221
- if (
222
- line.includes(`->${targetMethod}(`) ||
223
- line.includes(`::${targetMethod}(`)
224
- ) {
225
- const lineNum = i + 1;
226
- const enclosing = this.db
227
- .query<{ name: string; kind: string }, [string, number]>(
228
- `SELECT s.name, s.kind
229
- FROM symbols s
230
- JOIN files f ON s.file_id = f.id
231
- WHERE f.path = ? AND ? BETWEEN s.line_start AND s.line_end
232
- ORDER BY (s.line_end - s.line_start) ASC LIMIT 1`
233
- )
234
- .get(filePath, lineNum);
235
-
236
- const callerName = enclosing ? enclosing.name : `${filePath}:${lineNum}`;
237
- const key = `${filePath}:${lineNum}:${callerName}`;
238
- if (!callersMap.has(key)) {
239
- callersMap.set(key, {
240
- file: filePath,
241
- caller: callerName,
242
- line: lineNum,
243
- kind: enclosing?.kind || "call",
244
- });
245
- }
246
- }
247
- }
248
- }
249
- } catch {
250
- // Ignore read errors
251
- }
252
- }
253
- }
254
-
255
- const inboundCallers = Array.from(callersMap.values());
256
- const affectedFiles = new Set(inboundCallers.map((c) => c.file));
257
- const distinctDomains = new Set(
258
- inboundCallers.map((c) => c.domain).filter(Boolean)
259
- );
260
-
261
- let riskLevel: "low" | "medium" | "high" = "low";
262
- if (affectedFiles.size > 5 || distinctDomains.size > 2) {
263
- riskLevel = "high";
264
- } else if (affectedFiles.size >= 2 || distinctDomains.size >= 2) {
265
- riskLevel = "medium";
266
- }
267
-
268
- return {
269
- target_symbol: trimmed,
270
- impact_summary: {
271
- total_dependents: inboundCallers.length,
272
- risk_level: riskLevel,
273
- affected_files_count: affectedFiles.size,
274
- },
275
- inbound_callers: inboundCallers,
276
- };
277
- }
278
-
279
- public getFileInboundImpact(filePath: string): {
280
- direct_dependents_count: number;
281
- top_consumers: string[];
282
- symbols_count: number;
283
- risk_level: "low" | "medium" | "high";
284
- } {
285
- const cleanPath = filePath.replace(/\\/g, "/");
286
- const baseName = cleanPath.split("/").pop()?.replace(/\.[^.]+$/, "") || "";
287
-
288
- const symbols = this.db
289
- .query<{ name: string }, [string, string]>(
290
- `SELECT s.name
291
- FROM symbols s
292
- JOIN files f ON s.file_id = f.id
293
- WHERE f.path = ? OR f.path LIKE ?`
294
- )
295
- .all(cleanPath, `%${cleanPath}`);
296
-
297
- const matchedFiles = new Set<string>();
298
-
299
- if (baseName.length >= 3) {
300
- const depRows = this.db
301
- .query<{ file: string }, [string, string, string]>(
302
- `SELECT DISTINCT f.path AS file
303
- FROM dependencies d
304
- JOIN files f ON d.source_file_id = f.id
305
- WHERE (d.target_symbol_or_path LIKE ? OR d.target_symbol_or_path LIKE ?)
306
- AND f.path NOT LIKE ?`
307
- )
308
- .all(`%${cleanPath}%`, `%${baseName}%`, `%${cleanPath}`);
309
-
310
- for (const row of depRows) {
311
- matchedFiles.add(row.file);
312
- }
313
- }
314
-
315
- for (const sym of symbols.slice(0, 8)) {
316
- const symName = sym.name.split("::").pop() || sym.name;
317
- if (symName.length >= 4) {
318
- const symDeps = this.db
319
- .query<{ file: string }, [string, string]>(
320
- `SELECT DISTINCT f.path AS file
321
- FROM dependencies d
322
- JOIN files f ON d.source_file_id = f.id
323
- WHERE d.target_symbol_or_path LIKE ?
324
- AND f.path NOT LIKE ?
325
- LIMIT 10`
326
- )
327
- .all(`%${symName}%`, `%${cleanPath}`);
328
- for (const r of symDeps) {
329
- matchedFiles.add(r.file);
330
- }
331
- }
332
- }
333
-
334
- const consumerList = Array.from(matchedFiles);
335
- let riskLevel: "low" | "medium" | "high" = "low";
336
- if (consumerList.length >= 5) {
337
- riskLevel = "high";
338
- } else if (consumerList.length >= 2) {
339
- riskLevel = "medium";
340
- }
341
-
342
- return {
343
- direct_dependents_count: consumerList.length,
344
- top_consumers: consumerList.slice(0, 5),
345
- symbols_count: symbols.length,
346
- risk_level: riskLevel,
347
- };
348
- }
349
- }
1
+ import * as fs from "node:fs";
2
+ import { dirname, join, posix } from "node:path";
3
+ import type { Database } from "bun:sqlite";
4
+ import type {
5
+ ExtractedDependency,
6
+ FileConnectionsSummary,
7
+ GetSymbolImpactResponse,
8
+ InboundCaller,
9
+ VerticalSliceRecord,
10
+ } from "../../../types/index.ts";
11
+
12
+ function escapeRegExp(str: string): string {
13
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
14
+ }
15
+
16
+ export class DependencyRepository {
17
+ constructor(private db: Database) {}
18
+
19
+ public replaceFileDependencies(fileId: number, deps: ExtractedDependency[]): void {
20
+ this.db.query("DELETE FROM dependencies WHERE source_file_id = ?").run(fileId);
21
+ if (deps.length === 0) return;
22
+
23
+ const stmt = this.db.query(
24
+ `INSERT INTO dependencies (source_file_id, target_symbol_or_path, import_statement, line_number, is_external)
25
+ VALUES (?, ?, ?, ?, ?)`
26
+ );
27
+
28
+ for (const dep of deps) {
29
+ stmt.run(fileId, dep.target, dep.statement, dep.line_number, dep.is_external ? 1 : 0);
30
+ }
31
+ }
32
+
33
+ public upsertVerticalSlice(
34
+ slice: Omit<VerticalSliceRecord, "id" | "created_at">
35
+ ): void {
36
+ const existing = this.db
37
+ .query<VerticalSliceRecord, [string, string]>(
38
+ "SELECT id FROM vertical_slices WHERE http_method = ? AND route_uri = ?"
39
+ )
40
+ .get(slice.http_method, slice.route_uri);
41
+
42
+ if (existing) {
43
+ this.db
44
+ .query(
45
+ `UPDATE vertical_slices
46
+ SET domain_id = ?, feature_key = ?, route_name = ?, controller_class = ?,
47
+ action_name = ?, controller_file = ?, controller_line = ?,
48
+ architecture_style = ?, entry_kind = ?, execution_chain_json = ?
49
+ WHERE id = ?`
50
+ )
51
+ .run(
52
+ slice.domain_id,
53
+ slice.feature_key,
54
+ slice.route_name,
55
+ slice.controller_class,
56
+ slice.action_name,
57
+ slice.controller_file,
58
+ slice.controller_line,
59
+ slice.architecture_style ?? "clean",
60
+ slice.entry_kind ?? "http_route",
61
+ slice.execution_chain_json,
62
+ existing.id
63
+ );
64
+ } else {
65
+ this.db
66
+ .query(
67
+ `INSERT INTO vertical_slices (
68
+ domain_id, feature_key, http_method, route_uri, route_name,
69
+ controller_class, action_name, controller_file, controller_line,
70
+ architecture_style, entry_kind, execution_chain_json
71
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
72
+ )
73
+ .run(
74
+ slice.domain_id,
75
+ slice.feature_key,
76
+ slice.http_method,
77
+ slice.route_uri,
78
+ slice.route_name,
79
+ slice.controller_class,
80
+ slice.action_name,
81
+ slice.controller_file,
82
+ slice.controller_line,
83
+ slice.architecture_style ?? "clean",
84
+ slice.entry_kind ?? "http_route",
85
+ slice.execution_chain_json
86
+ );
87
+ }
88
+ }
89
+
90
+ public findVerticalSlices(searchTerm: string): VerticalSliceRecord[] {
91
+ const term = searchTerm.trim();
92
+ const wild = `%${term}%`;
93
+ const query = `
94
+ SELECT * FROM vertical_slices
95
+ WHERE route_uri LIKE ?
96
+ OR route_name LIKE ?
97
+ OR controller_class LIKE ?
98
+ OR action_name LIKE ?
99
+ OR feature_key LIKE ?
100
+ OR execution_chain_json LIKE ?
101
+ ORDER BY id ASC
102
+ `;
103
+ return this.db.query<VerticalSliceRecord, [string, string, string, string, string, string]>(query).all(
104
+ wild, wild, wild, wild, wild, wild
105
+ );
106
+ }
107
+
108
+ public getAllVerticalSlices(): VerticalSliceRecord[] {
109
+ return this.db.query<VerticalSliceRecord, []>("SELECT * FROM vertical_slices ORDER BY id ASC").all();
110
+ }
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
+
119
+ public clearVerticalSlices(domainId?: number): void {
120
+ if (domainId) {
121
+ this.db.query("DELETE FROM vertical_slices WHERE domain_id = ?").run(domainId);
122
+ } else {
123
+ this.db.query("DELETE FROM vertical_slices").run();
124
+ }
125
+ }
126
+
127
+ public getAllDependenciesWithDomains(): Array<{
128
+ source_file: string;
129
+ source_domain: string;
130
+ line_number: number;
131
+ target: string;
132
+ statement: string;
133
+ is_external: boolean;
134
+ }> {
135
+ const query = `
136
+ SELECT
137
+ f.path AS source_file,
138
+ d.name AS source_domain,
139
+ dep.line_number,
140
+ dep.target_symbol_or_path AS target,
141
+ dep.import_statement AS statement,
142
+ dep.is_external
143
+ FROM dependencies dep
144
+ JOIN files f ON dep.source_file_id = f.id
145
+ JOIN domains d ON f.domain_id = d.id
146
+ ORDER BY d.name, f.path, dep.line_number
147
+ `;
148
+ return this.db.query<any, []>(query).all();
149
+ }
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
+
252
+ public getSymbolImpact(symbolQuery: string): GetSymbolImpactResponse {
253
+ const trimmed = symbolQuery.trim();
254
+ if (!trimmed) {
255
+ return {
256
+ target_symbol: symbolQuery,
257
+ found_in_index: false,
258
+ warning: "Empty symbol query provided.",
259
+ impact_summary: {
260
+ total_dependents: 0,
261
+ risk_level: "low",
262
+ affected_files_count: 0,
263
+ },
264
+ inbound_callers: [],
265
+ };
266
+ }
267
+
268
+ let targetContainer = trimmed;
269
+ let targetMethod = "";
270
+ if (trimmed.includes("::")) {
271
+ const parts = trimmed.split("::");
272
+ targetContainer = parts[0];
273
+ targetMethod = parts[1];
274
+ } else if (trimmed.includes("@")) {
275
+ const parts = trimmed.split("@");
276
+ targetContainer = parts[0];
277
+ targetMethod = parts[1];
278
+ }
279
+
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
+
318
+ const depSql = `
319
+ SELECT
320
+ f.path AS file,
321
+ d.line_number,
322
+ d.target_symbol_or_path,
323
+ d.import_statement,
324
+ dom.name AS domain_name
325
+ FROM dependencies d
326
+ JOIN files f ON d.source_file_id = f.id
327
+ LEFT JOIN domains dom ON f.domain_id = dom.id
328
+ WHERE ${whereClauses.join(" OR ")}
329
+ ORDER BY f.path ASC, d.line_number ASC
330
+ `;
331
+ const rawDepRows = this.db.query<any, string[]>(depSql).all(...queryParams);
332
+
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;
337
+
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
373
+ FROM symbols s
374
+ JOIN files f ON s.file_id = f.id
375
+ WHERE f.path IN (${placeholders})
376
+ ORDER BY (s.line_end - s.line_start) ASC`
377
+ )
378
+ .all(...uniqueCallerFiles);
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);
400
+ const callerName = enclosing ? enclosing.name : `${row.file}:${row.line_number}`;
401
+ const key = `${row.file}:${row.line_number}:${callerName}`;
402
+
403
+ if (!callersMap.has(key)) {
404
+ callersMap.set(key, {
405
+ file: row.file,
406
+ caller: callerName,
407
+ line: row.line_number,
408
+ kind: enclosing?.kind || "import",
409
+ domain: row.domain_name ?? undefined,
410
+ });
411
+ }
412
+ }
413
+
414
+ // 4. Scan file content of callers for exact method calls if targetMethod specified
415
+ if (targetMethod) {
416
+ for (const filePath of uniqueCallerFiles) {
417
+ try {
418
+ if (fs.existsSync(filePath)) {
419
+ const content = fs.readFileSync(filePath, "utf-8");
420
+ const lines = content.split(/\r?\n/);
421
+ for (let i = 0; i < lines.length; i++) {
422
+ const line = lines[i];
423
+ if (
424
+ line.includes(`->${targetMethod}(`) ||
425
+ line.includes(`::${targetMethod}(`) ||
426
+ line.includes(`.${targetMethod}(`)
427
+ ) {
428
+ const lineNum = i + 1;
429
+ const enclosing = findEnclosingSymbol(filePath, lineNum);
430
+ const callerName = enclosing ? enclosing.name : `${filePath}:${lineNum}`;
431
+ const key = `${filePath}:${lineNum}:${callerName}`;
432
+ if (!callersMap.has(key)) {
433
+ callersMap.set(key, {
434
+ file: filePath,
435
+ caller: callerName,
436
+ line: lineNum,
437
+ kind: enclosing?.kind || "call",
438
+ });
439
+ }
440
+ }
441
+ }
442
+ }
443
+ } catch {
444
+ // Ignore read errors
445
+ }
446
+ }
447
+ }
448
+
449
+ const inboundCallers = Array.from(callersMap.values());
450
+ const affectedFiles = new Set(inboundCallers.map((c) => c.file));
451
+ const distinctDomains = new Set(
452
+ inboundCallers.map((c) => c.domain).filter(Boolean)
453
+ );
454
+
455
+ let riskLevel: "low" | "medium" | "high" = "low";
456
+ if (affectedFiles.size > 5 || distinctDomains.size > 2) {
457
+ riskLevel = "high";
458
+ } else if (affectedFiles.size >= 2 || distinctDomains.size >= 2) {
459
+ riskLevel = "medium";
460
+ }
461
+
462
+ // 5. Determine if symbol exists in index
463
+ const foundInIndex = targetSymRows.length > 0 || depRows.length > 0;
464
+ const warning = foundInIndex
465
+ ? undefined
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.`;
467
+
468
+ return {
469
+ target_symbol: trimmed,
470
+ found_in_index: foundInIndex,
471
+ warning,
472
+ impact_summary: {
473
+ total_dependents: inboundCallers.length,
474
+ risk_level: riskLevel,
475
+ affected_files_count: affectedFiles.size,
476
+ },
477
+ inbound_callers: inboundCallers,
478
+ };
479
+ }
480
+
481
+ public getFileInboundImpact(filePath: string): {
482
+ direct_dependents_count: number;
483
+ top_consumers: string[];
484
+ symbols_count: number;
485
+ risk_level: "low" | "medium" | "high";
486
+ } {
487
+ const cleanPath = filePath.replace(/\\/g, "/");
488
+ const baseName = cleanPath.split("/").pop()?.replace(/\.[^.]+$/, "") || "";
489
+
490
+ const symbols = this.db
491
+ .query<{ name: string }, [string, string]>(
492
+ `SELECT s.name
493
+ FROM symbols s
494
+ JOIN files f ON s.file_id = f.id
495
+ WHERE f.path = ? OR f.path LIKE ?`
496
+ )
497
+ .all(cleanPath, `%${cleanPath}`);
498
+
499
+ const matchedFiles = new Set<string>();
500
+ const orConditions: string[] = [];
501
+ const params: string[] = [];
502
+
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}`);
522
+ const depRows = this.db
523
+ .query<{ file: string }, string[]>(
524
+ `SELECT DISTINCT f.path AS file
525
+ FROM dependencies d
526
+ JOIN files f ON d.source_file_id = f.id
527
+ WHERE (${orConditions.join(" OR ")})
528
+ AND f.path NOT LIKE ?`
529
+ )
530
+ .all(...params);
531
+
532
+ for (const row of depRows) {
533
+ matchedFiles.add(row.file);
534
+ }
535
+ }
536
+
537
+ const consumerList = Array.from(matchedFiles);
538
+ let riskLevel: "low" | "medium" | "high" = "low";
539
+ if (consumerList.length >= 5) {
540
+ riskLevel = "high";
541
+ } else if (consumerList.length >= 2) {
542
+ riskLevel = "medium";
543
+ }
544
+
545
+ return {
546
+ direct_dependents_count: consumerList.length,
547
+ top_consumers: consumerList.slice(0, 5),
548
+ symbols_count: symbols.length,
549
+ risk_level: riskLevel,
550
+ };
551
+ }
552
+ }