septum 0.1.5 → 0.1.7

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 (51) hide show
  1. package/README.md +23 -3
  2. package/package.json +5 -5
  3. package/src/cli/commands/check.ts +32 -122
  4. package/src/cli/commands/hook.ts +45 -12
  5. package/src/cli/commands/init.ts +6 -4
  6. package/src/cli/commands/query.ts +45 -5
  7. package/src/cli/commands/slice.ts +4 -2
  8. package/src/cli/index.ts +17 -6
  9. package/src/cli/templates/hooks.ts +6 -6
  10. package/src/cli/templates/rules.ts +35 -23
  11. package/src/cli/templates/skill.ts +47 -51
  12. package/src/core/boundary/evaluator.ts +217 -24
  13. package/src/core/boundary/staged-auditor.ts +47 -0
  14. package/src/core/config/loader.ts +29 -12
  15. package/src/core/database/repositories/dependency.repository.ts +252 -66
  16. package/src/core/database/repositories/domain.repository.ts +96 -39
  17. package/src/core/database/repositories/file.repository.ts +20 -19
  18. package/src/core/database/repositories/symbol.repository.ts +115 -5
  19. package/src/core/database/repository.ts +19 -2
  20. package/src/core/database/schema.ts +3 -2
  21. package/src/core/ingestion/pipeline.ts +96 -60
  22. package/src/core/parser/boundary-tracker.ts +145 -0
  23. package/src/core/parser/extractors/frontend.ts +44 -90
  24. package/src/core/parser/extractors/go.ts +200 -21
  25. package/src/core/parser/extractors/laravel-semantic.ts +18 -12
  26. package/src/core/parser/extractors/mcp-cli-semantic.ts +173 -44
  27. package/src/core/parser/extractors/nestjs-semantic.ts +20 -5
  28. package/src/core/parser/extractors/php.ts +293 -31
  29. package/src/core/parser/extractors/python.ts +242 -10
  30. package/src/core/parser/extractors/typescript.ts +604 -117
  31. package/src/core/parser/tree-sitter.ts +6 -1
  32. package/src/core/parser/wasm-assets.ts +17 -0
  33. package/src/core/parser/wasm-loader.ts +274 -0
  34. package/src/core/resolver/call-graph-tracer.ts +49 -17
  35. package/src/core/resolver/module-resolver.ts +88 -26
  36. package/src/core/resolver/vertical-slice-tracer.ts +34 -11
  37. package/src/core/session/session-manager.ts +55 -39
  38. package/src/core/topology/environment-detector.ts +109 -6
  39. package/src/mcp/schemas.ts +146 -29
  40. package/src/mcp/server.ts +177 -114
  41. package/src/mcp/tools/check-boundary.ts +159 -41
  42. package/src/mcp/tools/get-domain-catalog.ts +30 -15
  43. package/src/mcp/tools/get-environment-topology.ts +3 -6
  44. package/src/mcp/tools/get-feature-context.ts +35 -20
  45. package/src/mcp/tools/get-symbol-hotspots.ts +4 -10
  46. package/src/mcp/tools/locate-symbol.ts +37 -16
  47. package/src/mcp/tools/register-domain.ts +3 -12
  48. package/src/mcp/tools/register-feature.ts +25 -32
  49. package/src/mcp/tools/trace-vertical-slice.ts +8 -8
  50. package/src/types/index.ts +56 -6
  51. package/src/types/wasm.d.ts +4 -0
@@ -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) {
@@ -144,7 +258,7 @@ export class DependencyRepository {
144
258
  warning: "Empty symbol query provided.",
145
259
  impact_summary: {
146
260
  total_dependents: 0,
147
- risk_level: "low",
261
+ risk_level: "unknown",
148
262
  affected_files_count: 0,
149
263
  },
150
264
  inbound_callers: [],
@@ -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,18 +459,11 @@ 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
- : `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.`;
466
+ : `Symbol '${trimmed}' was not found in Septum index (neither in declared symbols nor tracked dependencies). Risk cannot be assessed without index evidence.`;
282
467
 
283
468
  return {
284
469
  target_symbol: trimmed,
@@ -286,7 +471,7 @@ export class DependencyRepository {
286
471
  warning,
287
472
  impact_summary: {
288
473
  total_dependents: inboundCallers.length,
289
- risk_level: riskLevel,
474
+ risk_level: foundInIndex ? riskLevel : "unknown",
290
475
  affected_files_count: affectedFiles.size,
291
476
  },
292
477
  inbound_callers: inboundCallers,
@@ -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) {
@@ -7,6 +7,15 @@ import type {
7
7
  SymbolKind,
8
8
  SymbolRecord,
9
9
  } from "../../../types/index.ts";
10
+ import { DependencyRepository } from "./dependency.repository.ts";
11
+
12
+ const CONTAINER_KINDS = new Set<SymbolKind>([
13
+ "class",
14
+ "interface",
15
+ "trait",
16
+ "struct",
17
+ "enum",
18
+ ]);
10
19
 
11
20
  export class DomainRepository {
12
21
  constructor(private db: Database) {}
@@ -70,11 +79,15 @@ export class DomainRepository {
70
79
 
71
80
  public getDomainCatalog(
72
81
  domainName: string,
73
- archetypeFilter?: string
82
+ archetypeFilter?: string,
83
+ options?: { detailLevel?: "compact" | "full"; fileFilter?: string }
74
84
  ): DomainCatalogResponse | null {
75
85
  const domain = this.getDomainByName(domainName);
76
86
  if (!domain) return null;
77
87
 
88
+ const detailLevel = options?.detailLevel ?? "compact";
89
+ const fileFilter = options?.fileFilter?.trim();
90
+
78
91
  const allowedDeps: string[] = JSON.parse(domain.allowed_deps_json || "[]");
79
92
  const forbiddenDeps: string[] = JSON.parse(domain.forbidden_deps_json || "[]");
80
93
 
@@ -87,7 +100,14 @@ export class DomainRepository {
87
100
  }
88
101
  filesQuery += " ORDER BY path ASC";
89
102
 
90
- const files = this.db.query<FileRecord, any>(filesQuery).all(...params);
103
+ const allDomainFiles = this.db.query<FileRecord, any>(filesQuery).all(...params);
104
+ const totalFiles = allDomainFiles.length;
105
+
106
+ const files = fileFilter
107
+ ? allDomainFiles.filter((f) =>
108
+ f.path.replace(/\\/g, "/").toLowerCase().includes(fileFilter.replace(/\\/g, "/").toLowerCase())
109
+ )
110
+ : allDomainFiles;
91
111
 
92
112
  const catalogFiles: DomainCatalogResponse["files"] = [];
93
113
 
@@ -115,6 +135,10 @@ export class DomainRepository {
115
135
  }
116
136
  }
117
137
 
138
+ // Batch compute file connections for the domain (zero N+1)
139
+ const depRepo = new DependencyRepository(this.db);
140
+ const connectionsByPath = depRepo.getDomainFileConnections(domain.id);
141
+
118
142
  for (const file of files) {
119
143
  const symbols = symbolsByFileId.get(file.id) ?? [];
120
144
 
@@ -123,6 +147,7 @@ export class DomainRepository {
123
147
  {
124
148
  name: string;
125
149
  kind: SymbolKind;
150
+ lineSpan: string;
126
151
  properties: string[];
127
152
  methods: string[];
128
153
  signatures: string[];
@@ -130,63 +155,66 @@ export class DomainRepository {
130
155
  >();
131
156
 
132
157
  for (const sym of symbols) {
133
- if (
134
- sym.kind === "class" ||
135
- sym.kind === "interface" ||
136
- sym.kind === "trait" ||
137
- sym.kind === "enum"
138
- ) {
158
+ const lineSpan = `${sym.line_start}-${sym.line_end}`;
159
+ if (CONTAINER_KINDS.has(sym.kind)) {
139
160
  if (!symbolMap.has(sym.name)) {
140
161
  symbolMap.set(sym.name, {
141
162
  name: sym.name,
142
163
  kind: sym.kind,
164
+ lineSpan,
143
165
  properties: [],
144
166
  methods: [],
145
167
  signatures: [sym.signature],
146
168
  });
147
169
  }
148
- } else if (sym.kind === "method") {
149
- let targetContainer = Array.from(symbolMap.values()).pop();
170
+ } else if (sym.kind === "method" || sym.kind === "property") {
171
+ let targetContainer:
172
+ | {
173
+ name: string;
174
+ kind: SymbolKind;
175
+ lineSpan: string;
176
+ properties: string[];
177
+ methods: string[];
178
+ signatures: string[];
179
+ }
180
+ | undefined;
181
+
150
182
  if (sym.name.includes("::")) {
151
183
  const containerName = sym.name.split("::")[0];
152
- if (symbolMap.has(containerName)) {
153
- targetContainer = symbolMap.get(containerName);
184
+ const candidate = symbolMap.get(containerName);
185
+ if (candidate && CONTAINER_KINDS.has(candidate.kind)) {
186
+ targetContainer = candidate;
154
187
  }
155
188
  }
189
+
156
190
  if (targetContainer) {
157
- targetContainer.methods.push(sym.signature);
158
- } else {
159
- symbolMap.set(sym.name, {
160
- name: sym.name,
161
- kind: sym.kind,
162
- properties: [],
163
- methods: [],
164
- signatures: [sym.signature],
165
- });
166
- }
167
- } else if (sym.kind === "property") {
168
- let targetContainer = Array.from(symbolMap.values()).pop();
169
- if (sym.name.includes("::")) {
170
- const containerName = sym.name.split("::")[0];
171
- if (symbolMap.has(containerName)) {
172
- targetContainer = symbolMap.get(containerName);
191
+ if (sym.kind === "method") {
192
+ targetContainer.methods.push(sym.signature);
193
+ } else {
194
+ targetContainer.properties.push(sym.signature);
173
195
  }
174
- }
175
- if (targetContainer) {
176
- targetContainer.properties.push(sym.signature);
177
196
  } else {
178
197
  symbolMap.set(sym.name, {
179
198
  name: sym.name,
180
199
  kind: sym.kind,
181
- properties: [sym.signature],
200
+ lineSpan,
201
+ properties: sym.kind === "property" ? [sym.signature] : [],
182
202
  methods: [],
183
203
  signatures: [sym.signature],
184
204
  });
185
205
  }
186
206
  } else {
207
+ if (
208
+ detailLevel === "compact" &&
209
+ (sym.kind === "variable" || sym.kind === "constant") &&
210
+ sym.visibility === "private"
211
+ ) {
212
+ continue;
213
+ }
187
214
  symbolMap.set(sym.name, {
188
215
  name: sym.name,
189
216
  kind: sym.kind,
217
+ lineSpan,
190
218
  properties: [],
191
219
  methods: [],
192
220
  signatures: [sym.signature],
@@ -194,25 +222,54 @@ export class DomainRepository {
194
222
  }
195
223
  }
196
224
 
225
+ const rawConn = connectionsByPath.get(file.path);
226
+ const fileConnections = {
227
+ imports_from: rawConn?.imports_from ?? [],
228
+ imported_by_count: rawConn?.imported_by_count ?? 0,
229
+ };
230
+
197
231
  catalogFiles.push({
198
232
  path: file.path,
199
233
  archetype: file.archetype,
200
- symbols: Array.from(symbolMap.values()).map((s) => ({
201
- name: s.name,
202
- kind: s.kind,
203
- properties: s.properties.length > 0 ? s.properties : undefined,
204
- methods: s.methods.length > 0 ? s.methods : undefined,
205
- signatures: s.signatures.length > 0 ? s.signatures : undefined,
206
- })),
234
+ line_count: file.line_count,
235
+ connections: fileConnections,
236
+ symbols: Array.from(symbolMap.values()).map((s) => {
237
+ if (detailLevel === "compact") {
238
+ return {
239
+ name: s.name,
240
+ kind: s.kind,
241
+ line_span: s.lineSpan,
242
+ methods_count: s.methods.length > 0 ? s.methods.length : undefined,
243
+ properties_count: s.properties.length > 0 ? s.properties.length : undefined,
244
+ };
245
+ }
246
+ return {
247
+ name: s.name,
248
+ kind: s.kind,
249
+ line_span: s.lineSpan,
250
+ methods_count: s.methods.length > 0 ? s.methods.length : undefined,
251
+ properties_count: s.properties.length > 0 ? s.properties.length : undefined,
252
+ properties: s.properties.length > 0 ? s.properties : undefined,
253
+ methods: s.methods.length > 0 ? s.methods : undefined,
254
+ signatures: s.signatures.length > 0 ? s.signatures : undefined,
255
+ };
256
+ }),
207
257
  });
208
258
  }
209
259
 
210
260
  return {
211
261
  domain: domain.name,
212
262
  root: domain.root_path,
263
+ detail_level: detailLevel,
264
+ total_files: totalFiles,
265
+ showing_files: catalogFiles.length,
213
266
  allowed_dependencies: allowedDeps,
214
267
  forbidden_dependencies: forbiddenDeps,
215
268
  files: catalogFiles,
269
+ agent_hint:
270
+ detailLevel === "compact"
271
+ ? "Compact view (<400 tokens). Use septum_get_symbol({ symbol }) for exact method signatures, or septum_get_domain_catalog({ domain, detail_level: 'full', file_filter: '<path>' }) for full member lists."
272
+ : undefined,
216
273
  };
217
274
  }
218
275
  }