septum 0.1.3 → 0.1.5

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 (74) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +105 -102
  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 +188 -188
  7. package/src/cli/commands/ingest.ts +29 -29
  8. package/src/cli/commands/init.ts +211 -211
  9. package/src/cli/commands/locate.ts +61 -61
  10. package/src/cli/commands/query.ts +64 -64
  11. package/src/cli/commands/serve.ts +5 -5
  12. package/src/cli/commands/slice.ts +48 -90
  13. package/src/cli/commands/sync.ts +31 -31
  14. package/src/cli/commands/topology.ts +119 -0
  15. package/src/cli/index.ts +210 -199
  16. package/src/cli/templates/hooks.ts +75 -75
  17. package/src/cli/templates/rules.ts +38 -38
  18. package/src/cli/templates/skill.ts +69 -69
  19. package/src/core/boundary/evaluator.ts +232 -210
  20. package/src/core/config/loader.ts +97 -97
  21. package/src/core/config/schema.ts +39 -39
  22. package/src/core/database/client.ts +44 -44
  23. package/src/core/database/repositories/dependency.repository.ts +366 -349
  24. package/src/core/database/repositories/domain.repository.ts +218 -198
  25. package/src/core/database/repositories/environment.repository.ts +207 -0
  26. package/src/core/database/repositories/file.repository.ts +116 -106
  27. package/src/core/database/repositories/meta.repository.ts +33 -33
  28. package/src/core/database/repositories/symbol.repository.ts +443 -424
  29. package/src/core/database/repository.ts +264 -255
  30. package/src/core/database/schema.ts +34 -1
  31. package/src/core/discovery/topology-detector.ts +486 -486
  32. package/src/core/ingestion/hasher.ts +5 -5
  33. package/src/core/ingestion/pipeline.ts +584 -535
  34. package/src/core/parser/behavioral-analyzer.ts +119 -0
  35. package/src/core/parser/boundary-tracker.ts +288 -128
  36. package/src/core/parser/extractors/base.ts +62 -61
  37. package/src/core/parser/extractors/frontend.ts +194 -194
  38. package/src/core/parser/extractors/go.ts +229 -176
  39. package/src/core/parser/extractors/laravel-semantic.ts +1039 -585
  40. package/src/core/parser/extractors/mcp-cli-semantic.ts +199 -199
  41. package/src/core/parser/extractors/nestjs-semantic.ts +119 -119
  42. package/src/core/parser/extractors/php.ts +153 -149
  43. package/src/core/parser/extractors/python.ts +228 -226
  44. package/src/core/parser/extractors/registry.ts +70 -70
  45. package/src/core/parser/extractors/semantic-extractor.interface.ts +21 -21
  46. package/src/core/parser/extractors/typescript.ts +181 -177
  47. package/src/core/parser/schema/providers/laravel-migration-provider.ts +215 -0
  48. package/src/core/parser/schema/providers/prisma-schema-provider.ts +89 -0
  49. package/src/core/parser/schema/schema-provider-registry.ts +43 -0
  50. package/src/core/parser/schema/schema-provider.interface.ts +33 -0
  51. package/src/core/parser/tree-sitter.ts +41 -41
  52. package/src/core/resolver/call-graph-tracer.ts +666 -668
  53. package/src/core/resolver/module-resolver.ts +408 -342
  54. package/src/core/resolver/symbol-locator.ts +543 -543
  55. package/src/core/resolver/vertical-slice-tracer.ts +301 -252
  56. package/src/core/session/session-manager.ts +56 -56
  57. package/src/core/telemetry/telemetry.ts +86 -86
  58. package/src/core/topology/environment-detector.ts +860 -0
  59. package/src/core/topology/types.ts +83 -0
  60. package/src/index.ts +21 -18
  61. package/src/mcp/schemas.ts +578 -238
  62. package/src/mcp/server.ts +875 -656
  63. package/src/mcp/tools/check-boundary.ts +171 -152
  64. package/src/mcp/tools/get-domain-catalog.ts +145 -121
  65. package/src/mcp/tools/get-environment-topology.ts +141 -0
  66. package/src/mcp/tools/get-feature-context.ts +212 -116
  67. package/src/mcp/tools/get-symbol-hotspots.ts +60 -49
  68. package/src/mcp/tools/get-symbol-impact.ts +28 -26
  69. package/src/mcp/tools/get-symbol.ts +65 -63
  70. package/src/mcp/tools/locate-symbol.ts +171 -190
  71. package/src/mcp/tools/register-domain.ts +68 -64
  72. package/src/mcp/tools/register-feature.ts +100 -0
  73. package/src/mcp/tools/trace-vertical-slice.ts +74 -103
  74. package/src/types/index.ts +335 -299
@@ -1,349 +1,366 @@
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 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
+ found_in_index: false,
144
+ warning: "Empty symbol query provided.",
145
+ impact_summary: {
146
+ total_dependents: 0,
147
+ risk_level: "low",
148
+ affected_files_count: 0,
149
+ },
150
+ inbound_callers: [],
151
+ };
152
+ }
153
+
154
+ let targetContainer = trimmed;
155
+ let targetMethod = "";
156
+ if (trimmed.includes("::")) {
157
+ const parts = trimmed.split("::");
158
+ targetContainer = parts[0];
159
+ targetMethod = parts[1];
160
+ } else if (trimmed.includes("@")) {
161
+ const parts = trimmed.split("@");
162
+ targetContainer = parts[0];
163
+ targetMethod = parts[1];
164
+ }
165
+
166
+ // 1. Inbound dependencies from dependencies table
167
+ const depSql = `
168
+ SELECT
169
+ f.path AS file,
170
+ d.line_number,
171
+ d.target_symbol_or_path,
172
+ dom.name AS domain_name
173
+ FROM dependencies d
174
+ JOIN files f ON d.source_file_id = f.id
175
+ JOIN domains dom ON f.domain_id = dom.id
176
+ WHERE d.target_symbol_or_path LIKE ?
177
+ OR d.target_symbol_or_path LIKE ?
178
+ ORDER BY f.path ASC, d.line_number ASC
179
+ `;
180
+ const depRows = this.db
181
+ .query<any, [string, string]>(depSql)
182
+ .all(`%${targetContainer}%`, `%${trimmed}%`);
183
+
184
+ const callersMap = new Map<string, InboundCaller>();
185
+
186
+ for (const row of depRows) {
187
+ // Find enclosing symbol in caller file if any
188
+ const enclosing = this.db
189
+ .query<{ name: string; kind: string }, [string, number]>(
190
+ `SELECT s.name, s.kind
191
+ FROM symbols s
192
+ JOIN files f ON s.file_id = f.id
193
+ WHERE f.path = ? AND ? BETWEEN s.line_start AND s.line_end
194
+ ORDER BY (s.line_end - s.line_start) ASC LIMIT 1`
195
+ )
196
+ .get(row.file, row.line_number);
197
+
198
+ const callerName = enclosing ? enclosing.name : `${row.file}:${row.line_number}`;
199
+ const key = `${row.file}:${row.line_number}:${callerName}`;
200
+
201
+ if (!callersMap.has(key)) {
202
+ callersMap.set(key, {
203
+ file: row.file,
204
+ caller: callerName,
205
+ line: row.line_number,
206
+ kind: enclosing?.kind || "import",
207
+ domain: row.domain_name,
208
+ });
209
+ }
210
+ }
211
+
212
+ // 2. Scan file content of callers or related files for exact method calls if targetMethod specified
213
+ if (targetMethod) {
214
+ // Look for files importing targetContainer and inspect if they call targetMethod
215
+ const uniqueFiles = Array.from(new Set(depRows.map((r) => r.file)));
216
+ for (const filePath of uniqueFiles) {
217
+ try {
218
+ if (fs.existsSync(filePath)) {
219
+ const content = fs.readFileSync(filePath, "utf-8");
220
+ const lines = content.split(/\r?\n/);
221
+ for (let i = 0; i < lines.length; i++) {
222
+ const line = lines[i];
223
+ if (
224
+ line.includes(`->${targetMethod}(`) ||
225
+ line.includes(`::${targetMethod}(`)
226
+ ) {
227
+ const lineNum = i + 1;
228
+ const enclosing = this.db
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
+
238
+ const callerName = enclosing ? enclosing.name : `${filePath}:${lineNum}`;
239
+ const key = `${filePath}:${lineNum}:${callerName}`;
240
+ if (!callersMap.has(key)) {
241
+ callersMap.set(key, {
242
+ file: filePath,
243
+ caller: callerName,
244
+ line: lineNum,
245
+ kind: enclosing?.kind || "call",
246
+ });
247
+ }
248
+ }
249
+ }
250
+ }
251
+ } catch {
252
+ // Ignore read errors
253
+ }
254
+ }
255
+ }
256
+
257
+ const inboundCallers = Array.from(callersMap.values());
258
+ const affectedFiles = new Set(inboundCallers.map((c) => c.file));
259
+ const distinctDomains = new Set(
260
+ inboundCallers.map((c) => c.domain).filter(Boolean)
261
+ );
262
+
263
+ let riskLevel: "low" | "medium" | "high" = "low";
264
+ if (affectedFiles.size > 5 || distinctDomains.size > 2) {
265
+ riskLevel = "high";
266
+ } else if (affectedFiles.size >= 2 || distinctDomains.size >= 2) {
267
+ riskLevel = "medium";
268
+ }
269
+
270
+ // 3. Determine if symbol exists in index
271
+ const symbolInDb = this.db
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;
279
+ const warning = foundInIndex
280
+ ? undefined
281
+ : `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.`;
282
+
283
+ return {
284
+ target_symbol: trimmed,
285
+ found_in_index: foundInIndex,
286
+ warning,
287
+ impact_summary: {
288
+ total_dependents: inboundCallers.length,
289
+ risk_level: riskLevel,
290
+ affected_files_count: affectedFiles.size,
291
+ },
292
+ inbound_callers: inboundCallers,
293
+ };
294
+ }
295
+
296
+ public getFileInboundImpact(filePath: string): {
297
+ direct_dependents_count: number;
298
+ top_consumers: string[];
299
+ symbols_count: number;
300
+ risk_level: "low" | "medium" | "high";
301
+ } {
302
+ const cleanPath = filePath.replace(/\\/g, "/");
303
+ const baseName = cleanPath.split("/").pop()?.replace(/\.[^.]+$/, "") || "";
304
+
305
+ const symbols = this.db
306
+ .query<{ name: string }, [string, string]>(
307
+ `SELECT s.name
308
+ FROM symbols s
309
+ JOIN files f ON s.file_id = f.id
310
+ WHERE f.path = ? OR f.path LIKE ?`
311
+ )
312
+ .all(cleanPath, `%${cleanPath}`);
313
+
314
+ const matchedFiles = new Set<string>();
315
+
316
+ if (baseName.length >= 3) {
317
+ const depRows = this.db
318
+ .query<{ file: string }, [string, string, string]>(
319
+ `SELECT DISTINCT f.path AS file
320
+ FROM dependencies d
321
+ JOIN files f ON d.source_file_id = f.id
322
+ WHERE (d.target_symbol_or_path LIKE ? OR d.target_symbol_or_path LIKE ?)
323
+ AND f.path NOT LIKE ?`
324
+ )
325
+ .all(`%${cleanPath}%`, `%${baseName}%`, `%${cleanPath}`);
326
+
327
+ for (const row of depRows) {
328
+ matchedFiles.add(row.file);
329
+ }
330
+ }
331
+
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
+ const consumerList = Array.from(matchedFiles);
352
+ let riskLevel: "low" | "medium" | "high" = "low";
353
+ if (consumerList.length >= 5) {
354
+ riskLevel = "high";
355
+ } else if (consumerList.length >= 2) {
356
+ riskLevel = "medium";
357
+ }
358
+
359
+ return {
360
+ direct_dependents_count: consumerList.length,
361
+ top_consumers: consumerList.slice(0, 5),
362
+ symbols_count: symbols.length,
363
+ risk_level: riskLevel,
364
+ };
365
+ }
366
+ }