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.
Files changed (44) hide show
  1. package/package.json +1 -1
  2. package/src/cli/commands/hook.ts +36 -8
  3. package/src/cli/commands/init.ts +6 -4
  4. package/src/cli/commands/query.ts +45 -5
  5. package/src/cli/commands/slice.ts +4 -2
  6. package/src/cli/index.ts +15 -5
  7. package/src/cli/templates/hooks.ts +6 -6
  8. package/src/cli/templates/rules.ts +35 -23
  9. package/src/cli/templates/skill.ts +47 -51
  10. package/src/core/boundary/evaluator.ts +198 -1
  11. package/src/core/config/loader.ts +20 -0
  12. package/src/core/database/repositories/dependency.repository.ts +249 -63
  13. package/src/core/database/repositories/domain.repository.ts +96 -39
  14. package/src/core/database/repositories/file.repository.ts +20 -19
  15. package/src/core/database/repositories/symbol.repository.ts +44 -5
  16. package/src/core/database/repository.ts +19 -2
  17. package/src/core/database/schema.ts +3 -2
  18. package/src/core/ingestion/pipeline.ts +47 -25
  19. package/src/core/parser/boundary-tracker.ts +145 -0
  20. package/src/core/parser/extractors/frontend.ts +43 -90
  21. package/src/core/parser/extractors/go.ts +200 -21
  22. package/src/core/parser/extractors/laravel-semantic.ts +18 -12
  23. package/src/core/parser/extractors/mcp-cli-semantic.ts +173 -44
  24. package/src/core/parser/extractors/nestjs-semantic.ts +20 -5
  25. package/src/core/parser/extractors/php.ts +293 -31
  26. package/src/core/parser/extractors/python.ts +242 -10
  27. package/src/core/parser/extractors/typescript.ts +604 -117
  28. package/src/core/parser/wasm-loader.ts +271 -0
  29. package/src/core/resolver/call-graph-tracer.ts +49 -17
  30. package/src/core/resolver/module-resolver.ts +87 -16
  31. package/src/core/resolver/vertical-slice-tracer.ts +33 -11
  32. package/src/core/topology/environment-detector.ts +109 -6
  33. package/src/mcp/schemas.ts +146 -29
  34. package/src/mcp/server.ts +93 -41
  35. package/src/mcp/tools/check-boundary.ts +176 -40
  36. package/src/mcp/tools/get-domain-catalog.ts +24 -14
  37. package/src/mcp/tools/get-environment-topology.ts +3 -6
  38. package/src/mcp/tools/get-feature-context.ts +35 -20
  39. package/src/mcp/tools/get-symbol-hotspots.ts +4 -10
  40. package/src/mcp/tools/locate-symbol.ts +12 -14
  41. package/src/mcp/tools/register-domain.ts +3 -12
  42. package/src/mcp/tools/register-feature.ts +22 -31
  43. package/src/mcp/tools/trace-vertical-slice.ts +8 -8
  44. 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
- const activeConfig = config ?? ConfigLoader.loadFromDatabaseOrDefaults();
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. Inbound dependencies from dependencies table
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 d.target_symbol_or_path LIKE ?
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 depRows = this.db
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 callersMap = new Map<string, InboundCaller>();
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
- 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
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 = ? AND ? BETWEEN s.line_start AND s.line_end
194
- ORDER BY (s.line_end - s.line_start) ASC LIMIT 1`
375
+ WHERE f.path IN (${placeholders})
376
+ ORDER BY (s.line_end - s.line_start) ASC`
195
377
  )
196
- .get(row.file, row.line_number);
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
- // 2. Scan file content of callers or related files for exact method calls if targetMethod specified
414
+ // 4. Scan file content of callers for exact method calls if targetMethod specified
213
415
  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) {
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 = 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
-
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
- // 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;
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 }, [string, string, 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 (d.target_symbol_or_path LIKE ? OR d.target_symbol_or_path LIKE ?)
527
+ WHERE (${orConditions.join(" OR ")})
323
528
  AND f.path NOT LIKE ?`
324
529
  )
325
- .all(`%${cleanPath}%`, `%${baseName}%`, `%${cleanPath}`);
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) {