septum 0.1.6 → 0.1.8

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 (72) hide show
  1. package/README.md +38 -37
  2. package/package.json +6 -5
  3. package/src/cli/commands/check.ts +34 -122
  4. package/src/cli/commands/hook.ts +68 -16
  5. package/src/cli/commands/init.ts +440 -121
  6. package/src/cli/commands/sync.ts +27 -17
  7. package/src/cli/commands/topology.ts +89 -47
  8. package/src/cli/index.ts +7 -68
  9. package/src/cli/scaffold/harness-config.ts +697 -0
  10. package/src/cli/templates/hooks.ts +6 -6
  11. package/src/cli/templates/rules.ts +8 -8
  12. package/src/cli/templates/skill.ts +11 -18
  13. package/src/cli/ui/prompts.ts +122 -0
  14. package/src/core/boundary/evaluator.ts +21 -25
  15. package/src/core/boundary/staged-auditor.ts +50 -0
  16. package/src/core/config/loader.ts +21 -24
  17. package/src/core/database/client.ts +8 -0
  18. package/src/core/database/repositories/dependency.repository.ts +80 -16
  19. package/src/core/database/repositories/domain.repository.ts +86 -33
  20. package/src/core/database/repositories/symbol.repository.ts +147 -65
  21. package/src/core/database/repository.ts +17 -5
  22. package/src/core/database/schema.ts +1 -1
  23. package/src/core/discovery/topology-detector.ts +71 -19
  24. package/src/core/ingestion/pipeline.ts +274 -126
  25. package/src/core/parser/extractors/frontend.ts +1 -0
  26. package/src/core/parser/extractors/go.ts +2 -2
  27. package/src/core/parser/extractors/laravel-semantic.ts +9 -8
  28. package/src/core/parser/extractors/mcp-cli-semantic.ts +56 -16
  29. package/src/core/parser/extractors/nestjs-semantic.ts +3 -2
  30. package/src/core/parser/extractors/php.ts +2 -2
  31. package/src/core/parser/extractors/python.ts +2 -2
  32. package/src/core/parser/extractors/typescript.ts +2 -2
  33. package/src/core/parser/schema/providers/laravel-migration-provider.ts +4 -3
  34. package/src/core/parser/schema/providers/prisma-schema-provider.ts +2 -1
  35. package/src/core/parser/tree-sitter.ts +6 -1
  36. package/src/core/parser/wasm-assets.ts +17 -0
  37. package/src/core/parser/wasm-loader.ts +3 -0
  38. package/src/core/resolver/call-graph-tracer.ts +4 -3
  39. package/src/core/resolver/module-resolver.ts +1 -10
  40. package/src/core/resolver/path-resolver.ts +57 -4
  41. package/src/core/resolver/vertical-slice-tracer.ts +1 -0
  42. package/src/core/resources/ignore.ts +35 -0
  43. package/src/core/resources/limits.ts +14 -0
  44. package/src/core/resources/source-files.ts +62 -0
  45. package/src/core/resources/work-queue.ts +74 -0
  46. package/src/core/session/session-manager.ts +67 -38
  47. package/src/core/telemetry/telemetry.ts +12 -1
  48. package/src/core/topology/environment-detector.ts +21 -11
  49. package/src/mcp/schemas.ts +381 -8
  50. package/src/mcp/server.ts +326 -529
  51. package/src/mcp/tools/boundary.ts +17 -0
  52. package/src/mcp/tools/check-boundary.ts +97 -43
  53. package/src/mcp/tools/domain.ts +40 -0
  54. package/src/mcp/tools/feature.ts +67 -0
  55. package/src/mcp/tools/get-domain-catalog.ts +15 -7
  56. package/src/mcp/tools/get-environment-topology.ts +2 -2
  57. package/src/mcp/tools/get-feature-context.ts +9 -12
  58. package/src/mcp/tools/get-symbol-impact.ts +5 -2
  59. package/src/mcp/tools/get-symbol.ts +2 -1
  60. package/src/mcp/tools/locate-symbol.ts +28 -5
  61. package/src/mcp/tools/register-domain.ts +25 -9
  62. package/src/mcp/tools/register-feature.ts +40 -21
  63. package/src/mcp/tools/slice.ts +15 -0
  64. package/src/mcp/tools/symbol.ts +82 -0
  65. package/src/mcp/workspace-manager.ts +159 -0
  66. package/src/mcp/workspace-watcher.ts +202 -0
  67. package/src/types/index.ts +21 -1
  68. package/src/types/wasm.d.ts +4 -0
  69. package/src/cli/commands/ingest.ts +0 -29
  70. package/src/cli/commands/locate.ts +0 -61
  71. package/src/cli/commands/query.ts +0 -104
  72. package/src/cli/commands/slice.ts +0 -50
@@ -21,50 +21,103 @@ export class DomainRepository {
21
21
  constructor(private db: Database) {}
22
22
 
23
23
  public upsertDomain(name: string, config: DomainConfig): number {
24
- const normalizedName = name.trim();
25
- const existing = this.getDomainByName(normalizedName);
24
+ return this.db.transaction(() => {
25
+ const normalizedName = name.trim();
26
+ let existing = this.getDomainByName(normalizedName);
26
27
 
27
- // Merge dependencies and archetypes if existing and incoming is empty
28
- let allowed = config.allowed_dependencies ?? [];
29
- let forbidden = config.forbidden_dependencies ?? [];
30
- let archetypes = config.archetypes ?? {};
28
+ // MCP-13: If duplicates exist with different casing, consolidate into canonical primary row
29
+ if (existing) {
30
+ const allMatching = this.db
31
+ .query<DomainRecord, [string]>("SELECT * FROM domains WHERE LOWER(name) = LOWER(?) ORDER BY id ASC")
32
+ .all(normalizedName);
33
+ if (allMatching.length > 1) {
34
+ const primary = allMatching[0];
35
+ const fields = [
36
+ ["allowed_dependencies", "allowed_deps_json"],
37
+ ["forbidden_dependencies", "forbidden_deps_json"],
38
+ ["archetypes", "archetypes_json"],
39
+ ] as const;
40
+ for (const [field, column] of fields) {
41
+ if (config[field] !== undefined) continue;
42
+ const values = allMatching.map(row => {
43
+ const parsed = JSON.parse(row[column]);
44
+ return JSON.stringify(Array.isArray(parsed) ? [...parsed].sort() : Object.entries(parsed).sort());
45
+ });
46
+ if (new Set(values).size > 1) {
47
+ throw new Error(`Conflicting legacy domain '${normalizedName}': provide '${field}' explicitly before consolidating case variants.`);
48
+ }
49
+ }
50
+ for (let i = 1; i < allMatching.length; i++) {
51
+ const duplicate = allMatching[i];
52
+ this.db.query("UPDATE files SET domain_id = ? WHERE domain_id = ?").run(primary.id, duplicate.id);
53
+ this.db.query("UPDATE vertical_slices SET domain_id = ? WHERE domain_id = ?").run(primary.id, duplicate.id);
54
+ this.db.query("DELETE FROM domains WHERE id = ?").run(duplicate.id);
55
+ }
56
+ existing = primary;
57
+ }
58
+ }
31
59
 
32
- if (existing) {
33
- if (allowed.length === 0 && existing.allowed_deps_json && existing.allowed_deps_json !== "[]") {
60
+ const targetName = existing ? existing.name : normalizedName;
61
+
62
+ // MCP-12: Distinguish absent (undefined -> preserve) from explicit empty ([] -> clear)
63
+ let allowed: string[];
64
+ if (config.allowed_dependencies !== undefined) {
65
+ allowed = config.allowed_dependencies;
66
+ } else if (existing?.allowed_deps_json) {
34
67
  try {
35
68
  allowed = JSON.parse(existing.allowed_deps_json);
36
- } catch {}
69
+ } catch {
70
+ allowed = [];
71
+ }
72
+ } else {
73
+ allowed = [];
37
74
  }
38
- if (forbidden.length === 0 && existing.forbidden_deps_json && existing.forbidden_deps_json !== "[]") {
75
+
76
+ let forbidden: string[];
77
+ if (config.forbidden_dependencies !== undefined) {
78
+ forbidden = config.forbidden_dependencies;
79
+ } else if (existing?.forbidden_deps_json) {
39
80
  try {
40
81
  forbidden = JSON.parse(existing.forbidden_deps_json);
41
- } catch {}
82
+ } catch {
83
+ forbidden = [];
84
+ }
85
+ } else {
86
+ forbidden = [];
42
87
  }
43
- if (Object.keys(archetypes).length === 0 && existing.archetypes_json && existing.archetypes_json !== "{}") {
88
+
89
+ let archetypes: Record<string, string>;
90
+ if (config.archetypes !== undefined) {
91
+ archetypes = config.archetypes;
92
+ } else if (existing?.archetypes_json) {
44
93
  try {
45
94
  archetypes = JSON.parse(existing.archetypes_json);
46
- } catch {}
95
+ } catch {
96
+ archetypes = {};
97
+ }
98
+ } else {
99
+ archetypes = {};
47
100
  }
48
- }
49
101
 
50
- const allowedJson = JSON.stringify(allowed);
51
- const forbiddenJson = JSON.stringify(forbidden);
52
- const archetypesJson = JSON.stringify(archetypes);
53
-
54
- const query = `
55
- INSERT INTO domains (name, root_path, allowed_deps_json, forbidden_deps_json, archetypes_json)
56
- VALUES (?, ?, ?, ?, ?)
57
- ON CONFLICT(name) DO UPDATE SET
58
- root_path = excluded.root_path,
59
- allowed_deps_json = excluded.allowed_deps_json,
60
- forbidden_deps_json = excluded.forbidden_deps_json,
61
- archetypes_json = excluded.archetypes_json,
62
- updated_at = CURRENT_TIMESTAMP
63
- `;
64
- this.db.query(query).run(normalizedName, config.root, allowedJson, forbiddenJson, archetypesJson);
65
-
66
- const record = this.getDomainByName(normalizedName);
67
- return record?.id ?? 0;
102
+ const allowedJson = JSON.stringify(allowed);
103
+ const forbiddenJson = JSON.stringify(forbidden);
104
+ const archetypesJson = JSON.stringify(archetypes);
105
+
106
+ const query = `
107
+ INSERT INTO domains (name, root_path, allowed_deps_json, forbidden_deps_json, archetypes_json)
108
+ VALUES (?, ?, ?, ?, ?)
109
+ ON CONFLICT(name) DO UPDATE SET
110
+ root_path = excluded.root_path,
111
+ allowed_deps_json = excluded.allowed_deps_json,
112
+ forbidden_deps_json = excluded.forbidden_deps_json,
113
+ archetypes_json = excluded.archetypes_json,
114
+ updated_at = CURRENT_TIMESTAMP
115
+ `;
116
+ this.db.query(query).run(targetName, config.root, allowedJson, forbiddenJson, archetypesJson);
117
+
118
+ const record = this.getDomainByName(targetName);
119
+ return record?.id ?? 0;
120
+ }).immediate();
68
121
  }
69
122
 
70
123
  public getDomainByName(name: string): DomainRecord | null {
@@ -268,7 +321,7 @@ export class DomainRepository {
268
321
  files: catalogFiles,
269
322
  agent_hint:
270
323
  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."
324
+ ? "Compact view (<400 tokens). Use septum_symbol({ action: 'inspect', query: symbol }) for exact method signatures, or septum_domain({ action: 'catalog', domain, detail_level: 'full', file_filter: '<path>' }) for full member lists."
272
325
  : undefined,
273
326
  };
274
327
  }
@@ -1,10 +1,14 @@
1
1
  import * as fs from "node:fs";
2
+ import * as path from "node:path";
2
3
  import type { Database } from "bun:sqlite";
3
4
  import type {
4
5
  DomainRecord,
5
6
  ExtractedSymbol,
6
7
  FileRecord,
7
8
  GetSymbolDetailsResponse,
9
+ FileConnectionsSummary,
10
+ ResolvedOutboundCall,
11
+ SymbolComplexitySmell,
8
12
  SymbolKind,
9
13
  SymbolRecord,
10
14
  } from "../../../types/index.ts";
@@ -310,7 +314,7 @@ export class SymbolRepository {
310
314
 
311
315
  public getSymbolDetails(
312
316
  symbolQuery: string,
313
- options: { domain?: string; includeDependencies?: boolean } = {}
317
+ options: { domain?: string; includeDependencies?: boolean; workspaceRoot?: string } = {}
314
318
  ): GetSymbolDetailsResponse | null {
315
319
  const trimmed = symbolQuery.trim();
316
320
  if (!trimmed) return null;
@@ -380,85 +384,161 @@ export class SymbolRepository {
380
384
 
381
385
  // 2. Dependencies Injected (from signature type-hints and constructor)
382
386
  const dependenciesInjected = new Set<string>();
383
- const extractTypeHints = (sig: string) => {
384
- const paramMatch = sig.match(/\(([^)]*)\)/);
385
- if (paramMatch && paramMatch[1]) {
386
- const params = paramMatch[1].split(",");
387
- const primitiveTypes = new Set([
388
- "int", "string", "bool", "boolean", "float", "array", "iterable",
389
- "callable", "void", "mixed", "object", "null", "never", "any", "number", "unknown"
390
- ]);
391
- for (const p of params) {
392
- const parts = p.trim().replace(/^\?/, "").split(/\s+/);
393
- if (parts.length >= 2) {
394
- const rawType = parts[0].replace(/\[\]$/, "").replace(/^[\\$]/, "");
395
- if (rawType && !primitiveTypes.has(rawType.toLowerCase()) && !rawType.startsWith("$")) {
396
- dependenciesInjected.add(rawType);
387
+ const outboundCalls = new Set<string>();
388
+ const resolvedOutboundCalls: ResolvedOutboundCall[] = [];
389
+ let connectedFiles: FileConnectionsSummary = { imports_from: [], imported_by_count: 0 };
390
+
391
+ const shouldIncludeDeps = options.includeDependencies !== false;
392
+
393
+ if (shouldIncludeDeps) {
394
+ const extractTypeHints = (sig: string) => {
395
+ const paramMatch = sig.match(/\(([^)]*)\)/);
396
+ if (paramMatch && paramMatch[1]) {
397
+ const params = paramMatch[1].split(",");
398
+ const primitiveTypes = new Set([
399
+ "int", "string", "bool", "boolean", "float", "array", "iterable",
400
+ "callable", "void", "mixed", "object", "null", "never", "any", "number", "unknown"
401
+ ]);
402
+ for (const p of params) {
403
+ const parts = p.trim().replace(/^\?/, "").split(/\s+/);
404
+ if (parts.length >= 2) {
405
+ const rawType = parts[0].replace(/\[\]$/, "").replace(/^[\\$]/, "");
406
+ if (rawType && !primitiveTypes.has(rawType.toLowerCase()) && !rawType.startsWith("$")) {
407
+ dependenciesInjected.add(rawType);
408
+ }
397
409
  }
398
410
  }
399
411
  }
412
+ };
413
+
414
+ extractTypeHints(row.signature);
415
+
416
+ // Also check constructor injection if this is a method
417
+ if (containerName) {
418
+ const ctor = this.db
419
+ .query<{ signature: string }, [number, string, string]>(
420
+ "SELECT signature FROM symbols WHERE file_id = ? AND (name = ? OR name LIKE ?) LIMIT 1"
421
+ )
422
+ .get(row.file_id, `${containerName}::__construct`, `%::__construct`);
423
+ if (ctor) {
424
+ extractTypeHints(ctor.signature);
425
+ }
400
426
  }
401
- };
402
427
 
403
- extractTypeHints(row.signature);
404
-
405
- // Also check constructor injection if this is a method
406
- if (containerName) {
407
- const ctor = this.db
408
- .query<{ signature: string }, [number, string, string]>(
409
- "SELECT signature FROM symbols WHERE file_id = ? AND (name = ? OR name LIKE ?) LIMIT 1"
410
- )
411
- .get(row.file_id, `${containerName}::__construct`, `%::__construct`);
412
- if (ctor) {
413
- extractTypeHints(ctor.signature);
414
- }
415
- }
428
+ // 3. Outbound Calls (inspected from physical source file lines if accessible)
429
+ try {
430
+ const fullPath = options.workspaceRoot
431
+ ? path.resolve(options.workspaceRoot, row.file_path)
432
+ : row.file_path;
433
+ if (fs.existsSync(fullPath)) {
434
+ const fileContent = fs.readFileSync(fullPath, "utf-8");
435
+ const lines = fileContent.split(/\r?\n/);
436
+ const start = Math.max(0, row.line_start - 1);
437
+ const end = Math.min(lines.length, row.line_end);
438
+ const rawSlice = lines.slice(start, end).join("\n");
439
+ const sanitizedSlice = stripCommentsAndStrings(rawSlice);
440
+
441
+ // Match Static invocations: ClassName::methodName(
442
+ const staticCalls = sanitizedSlice.matchAll(/([A-Z][a-zA-Z0-9_]+)::([a-zA-Z0-9_]+)\s*\(/g);
443
+ for (const m of staticCalls) {
444
+ if (m[1] !== "self" && m[1] !== "parent" && m[1] !== "static") {
445
+ outboundCalls.add(`${m[1]}::${m[2]}`);
446
+ }
447
+ }
416
448
 
417
- // 3. Outbound Calls (inspected from physical source file lines if accessible)
418
- const outboundCalls = new Set<string>();
419
- try {
420
- if (fs.existsSync(row.file_path)) {
421
- const fileContent = fs.readFileSync(row.file_path, "utf-8");
422
- const lines = fileContent.split(/\r?\n/);
423
- const start = Math.max(0, row.line_start - 1);
424
- const end = Math.min(lines.length, row.line_end);
425
- const rawSlice = lines.slice(start, end).join("\n");
426
- const sanitizedSlice = stripCommentsAndStrings(rawSlice);
427
-
428
- // Match Static invocations: ClassName::methodName(
429
- const staticCalls = sanitizedSlice.matchAll(/([A-Z][a-zA-Z0-9_]+)::([a-zA-Z0-9_]+)\s*\(/g);
430
- for (const m of staticCalls) {
431
- if (m[1] !== "self" && m[1] !== "parent" && m[1] !== "static") {
432
- outboundCalls.add(`${m[1]}::${m[2]}`);
449
+ // Match PHP property calls: $this->property->method(
450
+ const propCalls = sanitizedSlice.matchAll(/\$this->([a-zA-Z0-9_]+)->([a-zA-Z0-9_]+)\s*\(/g);
451
+ for (const m of propCalls) {
452
+ const propName = m[1];
453
+ const methodName = m[2];
454
+ const inferredClass = propName.charAt(0).toUpperCase() + propName.slice(1);
455
+ outboundCalls.add(`${inferredClass}::${methodName}`);
433
456
  }
434
- }
435
457
 
436
- // Match PHP property calls: $this->property->method(
437
- const propCalls = sanitizedSlice.matchAll(/\$this->([a-zA-Z0-9_]+)->([a-zA-Z0-9_]+)\s*\(/g);
438
- for (const m of propCalls) {
439
- const propName = m[1];
440
- const methodName = m[2];
441
- const inferredClass = propName.charAt(0).toUpperCase() + propName.slice(1);
442
- outboundCalls.add(`${inferredClass}::${methodName}`);
458
+ // Match TS/JS property calls: this.property.method(
459
+ const tsPropCalls = sanitizedSlice.matchAll(/\bthis\.([a-zA-Z0-9_]+)\.([a-zA-Z0-9_]+)\s*\(/g);
460
+ for (const m of tsPropCalls) {
461
+ const propName = m[1];
462
+ const methodName = m[2];
463
+ if (propName !== "db") {
464
+ const inferredClass = propName.charAt(0).toUpperCase() + propName.slice(1);
465
+ outboundCalls.add(`${inferredClass}::${methodName}`);
466
+ }
467
+ }
443
468
  }
469
+ } catch {
470
+ // Graceful fallback if file not physically accessible
471
+ }
444
472
 
445
- // Match TS/JS property calls: this.property.method(
446
- const tsPropCalls = sanitizedSlice.matchAll(/\bthis\.([a-zA-Z0-9_]+)\.([a-zA-Z0-9_]+)\s*\(/g);
447
- for (const m of tsPropCalls) {
448
- const propName = m[1];
449
- const methodName = m[2];
450
- if (propName !== "db") {
451
- const inferredClass = propName.charAt(0).toUpperCase() + propName.slice(1);
452
- outboundCalls.add(`${inferredClass}::${methodName}`);
473
+ const depRepo = new DependencyRepository(this.db);
474
+ connectedFiles = depRepo.getFileConnections(row.file_path);
475
+
476
+ // 4. Resolve Outbound Calls to target symbol line ranges
477
+ const lookupStmt = this.db.query<
478
+ {
479
+ name: string;
480
+ line_start: number;
481
+ line_end: number;
482
+ line_count: number;
483
+ file_path: string;
484
+ domain_name: string;
485
+ },
486
+ [string, string, string, string]
487
+ >(`
488
+ SELECT
489
+ s.name,
490
+ s.line_start,
491
+ s.line_end,
492
+ s.line_count,
493
+ f.path AS file_path,
494
+ d.name AS domain_name
495
+ FROM symbols s
496
+ JOIN files f ON s.file_id = f.id
497
+ JOIN domains d ON f.domain_id = d.id
498
+ WHERE (s.name = ? OR s.name LIKE ? OR s.name LIKE ?)
499
+ ORDER BY CASE WHEN s.name = ? THEN 1 ELSE 2 END, s.line_start ASC
500
+ LIMIT 1
501
+ `);
502
+
503
+ for (const call of outboundCalls) {
504
+ try {
505
+ const target = lookupStmt.get(call, `%::${call}`, `${call}::%`, call);
506
+ if (target) {
507
+ resolvedOutboundCalls.push({
508
+ symbol: call,
509
+ target_file: target.file_path,
510
+ target_range: {
511
+ start_line: target.line_start,
512
+ end_line: target.line_end,
513
+ total_lines: target.line_count,
514
+ },
515
+ domain: target.domain_name,
516
+ is_resolved: true,
517
+ });
518
+ } else {
519
+ resolvedOutboundCalls.push({
520
+ symbol: call,
521
+ is_resolved: false,
522
+ });
453
523
  }
524
+ } catch {
525
+ resolvedOutboundCalls.push({
526
+ symbol: call,
527
+ is_resolved: false,
528
+ });
454
529
  }
455
530
  }
456
- } catch {
457
- // Graceful fallback if file not physically accessible
458
531
  }
459
532
 
460
- const depRepo = new DependencyRepository(this.db);
461
- const connectedFiles = depRepo.getFileConnections(row.file_path);
533
+ const complexitySmell: SymbolComplexitySmell | undefined =
534
+ row.line_count > 300
535
+ ? {
536
+ is_high_complexity: true,
537
+ threshold: 300,
538
+ total_lines: row.line_count,
539
+ warning: `Symbol '${row.name}' spans ${row.line_count} lines (exceeds 300-line threshold). Candidate for decomposition/refactoring.`,
540
+ }
541
+ : undefined;
462
542
 
463
543
  return {
464
544
  symbol: row.name,
@@ -474,6 +554,8 @@ export class SymbolRepository {
474
554
  visibility: row.visibility,
475
555
  dependencies_injected: Array.from(dependenciesInjected),
476
556
  outbound_calls: Array.from(outboundCalls),
557
+ resolved_outbound_calls: resolvedOutboundCalls.length > 0 ? resolvedOutboundCalls : undefined,
558
+ complexity_smell: complexitySmell,
477
559
  sibling_methods: siblingMethods.length > 0 ? siblingMethods : undefined,
478
560
  connected_files: connectedFiles,
479
561
  };
@@ -56,8 +56,17 @@ export class SeptumRepository {
56
56
  this.environment = new EnvironmentRepository(db);
57
57
  }
58
58
 
59
- public runInTransaction<T>(fn: () => T): T {
60
- return this.db.transaction(fn)();
59
+ public runInTransaction<T>(fn: () => T, immediate = false): T {
60
+ const transaction = this.db.transaction(fn);
61
+ return immediate ? transaction.immediate() : transaction();
62
+ }
63
+
64
+ public checkpoint(): void {
65
+ try {
66
+ this.db.run("PRAGMA wal_checkpoint(PASSIVE);");
67
+ } catch {
68
+ // Ignore if locked or busy
69
+ }
61
70
  }
62
71
 
63
72
  // --- Domain Methods ---
@@ -247,13 +256,16 @@ export class SeptumRepository {
247
256
 
248
257
  public getSymbolDetails(
249
258
  symbolQuery: string,
250
- options: { domain?: string; includeDependencies?: boolean } = {}
259
+ options: { domain?: string; includeDependencies?: boolean; workspaceRoot?: string } = {}
251
260
  ): GetSymbolDetailsResponse | null {
252
261
  return this.symbols.getSymbolDetails(symbolQuery, options);
253
262
  }
254
263
 
255
- public getSymbolImpact(symbolName: string): GetSymbolImpactResponse {
256
- return this.dependencies.getSymbolImpact(symbolName);
264
+ public getSymbolImpact(
265
+ symbolName: string,
266
+ options: { domain?: string; workspaceRoot?: string } = {}
267
+ ): GetSymbolImpactResponse {
268
+ return this.dependencies.getSymbolImpact(symbolName, options);
257
269
  }
258
270
 
259
271
  public getFileInboundImpact(filePath: string): {
@@ -5,7 +5,7 @@ export const CURRENT_SCHEMA_VERSION = 4;
5
5
  export const TABLE_STATEMENTS = [
6
6
  `CREATE TABLE IF NOT EXISTS domains (
7
7
  id INTEGER PRIMARY KEY AUTOINCREMENT,
8
- name TEXT NOT NULL UNIQUE,
8
+ name TEXT NOT NULL UNIQUE COLLATE NOCASE,
9
9
  root_path TEXT NOT NULL,
10
10
  allowed_deps_json TEXT NOT NULL DEFAULT '[]',
11
11
  forbidden_deps_json TEXT NOT NULL DEFAULT '[]',
@@ -126,29 +126,63 @@ export class TopologyDetector {
126
126
  if (isMonorepo) {
127
127
  architectureStyle = "monorepo-workspaces";
128
128
  const workspaceDirs = ["apps", "packages", "modules"];
129
- const appDomains: string[] = [];
130
- const packageDomains: string[] = [];
129
+ interface WorkspaceCandidate {
130
+ parent: string;
131
+ sub: string;
132
+ relRoot: string;
133
+ }
134
+ const candidates: WorkspaceCandidate[] = [];
131
135
 
132
136
  for (const parent of workspaceDirs) {
133
137
  const fullParent = path.join(projectRoot, parent);
134
138
  if (fs.existsSync(fullParent) && fs.statSync(fullParent).isDirectory()) {
135
139
  const subs = this.getSubdirectories(fullParent);
136
140
  for (const sub of subs) {
137
- const relRoot = path.join(parent, sub);
138
- const domainName = sub.toLowerCase();
139
- if (parent === "apps") appDomains.push(domainName);
140
- else packageDomains.push(domainName);
141
-
142
- domains[domainName] = {
143
- root: relRoot,
144
- description: `Monorepo ${parent} package: ${sub}`,
145
- allowed_dependencies: parent === "apps" ? packageDomains : [],
146
- forbidden_dependencies: parent !== "apps" ? appDomains : [],
147
- archetypes: this.inferDefaultArchetypes(path.join(projectRoot, relRoot)),
148
- };
141
+ candidates.push({ parent, sub, relRoot: path.join(parent, sub) });
149
142
  }
150
143
  }
151
144
  }
145
+
146
+ // Detect collisions across workspace subdirectories
147
+ const nameCounts = new Map<string, number>();
148
+ for (const c of candidates) {
149
+ const lower = c.sub.toLowerCase();
150
+ nameCounts.set(lower, (nameCounts.get(lower) || 0) + 1);
151
+ }
152
+
153
+ const appDomains: string[] = [];
154
+ const packageDomains: string[] = [];
155
+ const items: Array<{ domainName: string; parent: string; sub: string; relRoot: string }> = [];
156
+
157
+ for (const c of candidates) {
158
+ const lower = c.sub.toLowerCase();
159
+ let domainName = (nameCounts.get(lower) || 0) > 1
160
+ ? `${c.parent}-${lower}`
161
+ : lower;
162
+
163
+ let uniqueName = domainName;
164
+ let counter = 2;
165
+ while (items.some((it) => it.domainName === uniqueName)) {
166
+ uniqueName = `${domainName}-${counter++}`;
167
+ }
168
+
169
+ if (c.parent === "apps") {
170
+ appDomains.push(uniqueName);
171
+ } else {
172
+ packageDomains.push(uniqueName);
173
+ }
174
+ items.push({ domainName: uniqueName, parent: c.parent, sub: c.sub, relRoot: c.relRoot });
175
+ }
176
+
177
+ for (const item of items) {
178
+ domains[item.domainName] = {
179
+ root: item.relRoot,
180
+ description: `Monorepo ${item.parent} package: ${item.sub}`,
181
+ allowed_dependencies: item.parent === "apps" ? packageDomains : [],
182
+ forbidden_dependencies: item.parent !== "apps" ? appDomains : [],
183
+ archetypes: this.inferDefaultArchetypes(path.join(projectRoot, item.relRoot)),
184
+ };
185
+ }
152
186
  }
153
187
 
154
188
  // 2. Modular DDD (app/Domain, src/modules, src/domains, etc.)
@@ -308,15 +342,21 @@ export class TopologyDetector {
308
342
 
309
343
  /**
310
344
  * Discovers project topology and persists domains and macro metadata directly into SQLite SSOT.
345
+ * If a domain is already registered, its existing root and policy are preserved on repeat init,
346
+ * while new domains discovered from the codebase are added.
311
347
  */
312
348
  public static discoverAndPersist(
313
349
  repo: SeptumRepository,
314
- projectRoot: string = process.cwd()
350
+ projectRoot: string = process.cwd(),
351
+ forceResetPolicies: boolean = false
315
352
  ): Record<string, DomainConfig> {
316
353
  const topology = this.detect(projectRoot);
317
354
 
318
355
  for (const [name, config] of Object.entries(topology.domains)) {
319
- repo.upsertDomain(name, config);
356
+ const existing = repo.domains.getDomainByName(name);
357
+ if (!existing || forceResetPolicies) {
358
+ repo.upsertDomain(name, config);
359
+ }
320
360
  }
321
361
 
322
362
  // Persist Macro Metadata SSOT
@@ -324,18 +364,30 @@ export class TopologyDetector {
324
364
  repo.setMeta("architecture_style", topology.architectureStyle);
325
365
  repo.setMeta("framework", topology.framework);
326
366
  repo.setMeta("language", topology.language);
367
+
368
+ const allRecords = repo.domains.getAllDomains();
327
369
  repo.setMeta(
328
370
  "macro_summary",
329
371
  JSON.stringify({
330
372
  projectType: topology.projectType,
331
373
  framework: topology.framework,
332
374
  architectureStyle: topology.architectureStyle,
333
- domainsCount: Object.keys(topology.domains).length,
334
- domains: Object.keys(topology.domains),
375
+ domainsCount: allRecords.length,
376
+ domains: allRecords.map((r) => r.name),
335
377
  })
336
378
  );
337
379
 
338
- return topology.domains;
380
+ const result: Record<string, DomainConfig> = {};
381
+ for (const record of allRecords) {
382
+ result[record.name] = {
383
+ root: record.root_path,
384
+ allowed_dependencies: JSON.parse(record.allowed_deps_json || "[]"),
385
+ forbidden_dependencies: JSON.parse(record.forbidden_deps_json || "[]"),
386
+ archetypes: JSON.parse(record.archetypes_json || "{}"),
387
+ };
388
+ }
389
+
390
+ return result;
339
391
  }
340
392
 
341
393
  private static detectMonorepo(projectRoot: string): boolean {