septum 0.1.7 → 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 (59) hide show
  1. package/README.md +36 -55
  2. package/package.json +3 -2
  3. package/src/cli/commands/check.ts +2 -0
  4. package/src/cli/commands/hook.ts +59 -12
  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 +5 -67
  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/staged-auditor.ts +5 -2
  15. package/src/core/database/client.ts +8 -0
  16. package/src/core/database/repositories/dependency.repository.ts +77 -13
  17. package/src/core/database/repositories/domain.repository.ts +86 -33
  18. package/src/core/database/repositories/symbol.repository.ts +128 -117
  19. package/src/core/database/repository.ts +17 -5
  20. package/src/core/database/schema.ts +1 -1
  21. package/src/core/discovery/topology-detector.ts +71 -19
  22. package/src/core/ingestion/pipeline.ts +228 -94
  23. package/src/core/parser/extractors/laravel-semantic.ts +9 -8
  24. package/src/core/parser/extractors/mcp-cli-semantic.ts +56 -16
  25. package/src/core/parser/extractors/nestjs-semantic.ts +3 -2
  26. package/src/core/parser/schema/providers/laravel-migration-provider.ts +4 -3
  27. package/src/core/parser/schema/providers/prisma-schema-provider.ts +2 -1
  28. package/src/core/resolver/call-graph-tracer.ts +4 -3
  29. package/src/core/resolver/path-resolver.ts +57 -4
  30. package/src/core/resources/ignore.ts +35 -0
  31. package/src/core/resources/limits.ts +14 -0
  32. package/src/core/resources/source-files.ts +62 -0
  33. package/src/core/resources/work-queue.ts +74 -0
  34. package/src/core/session/session-manager.ts +19 -6
  35. package/src/core/telemetry/telemetry.ts +12 -1
  36. package/src/core/topology/environment-detector.ts +21 -11
  37. package/src/mcp/schemas.ts +381 -8
  38. package/src/mcp/server.ts +321 -535
  39. package/src/mcp/tools/boundary.ts +17 -0
  40. package/src/mcp/tools/check-boundary.ts +83 -11
  41. package/src/mcp/tools/domain.ts +40 -0
  42. package/src/mcp/tools/feature.ts +67 -0
  43. package/src/mcp/tools/get-domain-catalog.ts +9 -6
  44. package/src/mcp/tools/get-environment-topology.ts +2 -2
  45. package/src/mcp/tools/get-feature-context.ts +9 -12
  46. package/src/mcp/tools/get-symbol-impact.ts +5 -2
  47. package/src/mcp/tools/get-symbol.ts +2 -1
  48. package/src/mcp/tools/locate-symbol.ts +3 -3
  49. package/src/mcp/tools/register-domain.ts +25 -9
  50. package/src/mcp/tools/register-feature.ts +34 -17
  51. package/src/mcp/tools/slice.ts +15 -0
  52. package/src/mcp/tools/symbol.ts +82 -0
  53. package/src/mcp/workspace-manager.ts +159 -0
  54. package/src/mcp/workspace-watcher.ts +202 -0
  55. package/src/types/index.ts +1 -0
  56. package/src/cli/commands/ingest.ts +0 -29
  57. package/src/cli/commands/locate.ts +0 -61
  58. package/src/cli/commands/query.ts +0 -104
  59. package/src/cli/commands/slice.ts +0 -50
@@ -1,10 +1,12 @@
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,
8
10
  ResolvedOutboundCall,
9
11
  SymbolComplexitySmell,
10
12
  SymbolKind,
@@ -312,7 +314,7 @@ export class SymbolRepository {
312
314
 
313
315
  public getSymbolDetails(
314
316
  symbolQuery: string,
315
- options: { domain?: string; includeDependencies?: boolean } = {}
317
+ options: { domain?: string; includeDependencies?: boolean; workspaceRoot?: string } = {}
316
318
  ): GetSymbolDetailsResponse | null {
317
319
  const trimmed = symbolQuery.trim();
318
320
  if (!trimmed) return null;
@@ -382,140 +384,149 @@ export class SymbolRepository {
382
384
 
383
385
  // 2. Dependencies Injected (from signature type-hints and constructor)
384
386
  const dependenciesInjected = new Set<string>();
385
- const extractTypeHints = (sig: string) => {
386
- const paramMatch = sig.match(/\(([^)]*)\)/);
387
- if (paramMatch && paramMatch[1]) {
388
- const params = paramMatch[1].split(",");
389
- const primitiveTypes = new Set([
390
- "int", "string", "bool", "boolean", "float", "array", "iterable",
391
- "callable", "void", "mixed", "object", "null", "never", "any", "number", "unknown"
392
- ]);
393
- for (const p of params) {
394
- const parts = p.trim().replace(/^\?/, "").split(/\s+/);
395
- if (parts.length >= 2) {
396
- const rawType = parts[0].replace(/\[\]$/, "").replace(/^[\\$]/, "");
397
- if (rawType && !primitiveTypes.has(rawType.toLowerCase()) && !rawType.startsWith("$")) {
398
- 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
+ }
399
409
  }
400
410
  }
401
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
+ }
402
426
  }
403
- };
404
-
405
- extractTypeHints(row.signature);
406
-
407
- // Also check constructor injection if this is a method
408
- if (containerName) {
409
- const ctor = this.db
410
- .query<{ signature: string }, [number, string, string]>(
411
- "SELECT signature FROM symbols WHERE file_id = ? AND (name = ? OR name LIKE ?) LIMIT 1"
412
- )
413
- .get(row.file_id, `${containerName}::__construct`, `%::__construct`);
414
- if (ctor) {
415
- extractTypeHints(ctor.signature);
416
- }
417
- }
418
427
 
419
- // 3. Outbound Calls (inspected from physical source file lines if accessible)
420
- const outboundCalls = new Set<string>();
421
- try {
422
- if (fs.existsSync(row.file_path)) {
423
- const fileContent = fs.readFileSync(row.file_path, "utf-8");
424
- const lines = fileContent.split(/\r?\n/);
425
- const start = Math.max(0, row.line_start - 1);
426
- const end = Math.min(lines.length, row.line_end);
427
- const rawSlice = lines.slice(start, end).join("\n");
428
- const sanitizedSlice = stripCommentsAndStrings(rawSlice);
429
-
430
- // Match Static invocations: ClassName::methodName(
431
- const staticCalls = sanitizedSlice.matchAll(/([A-Z][a-zA-Z0-9_]+)::([a-zA-Z0-9_]+)\s*\(/g);
432
- for (const m of staticCalls) {
433
- if (m[1] !== "self" && m[1] !== "parent" && m[1] !== "static") {
434
- outboundCalls.add(`${m[1]}::${m[2]}`);
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
+ }
435
447
  }
436
- }
437
-
438
- // Match PHP property calls: $this->property->method(
439
- const propCalls = sanitizedSlice.matchAll(/\$this->([a-zA-Z0-9_]+)->([a-zA-Z0-9_]+)\s*\(/g);
440
- for (const m of propCalls) {
441
- const propName = m[1];
442
- const methodName = m[2];
443
- const inferredClass = propName.charAt(0).toUpperCase() + propName.slice(1);
444
- outboundCalls.add(`${inferredClass}::${methodName}`);
445
- }
446
448
 
447
- // Match TS/JS property calls: this.property.method(
448
- const tsPropCalls = sanitizedSlice.matchAll(/\bthis\.([a-zA-Z0-9_]+)\.([a-zA-Z0-9_]+)\s*\(/g);
449
- for (const m of tsPropCalls) {
450
- const propName = m[1];
451
- const methodName = m[2];
452
- if (propName !== "db") {
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];
453
454
  const inferredClass = propName.charAt(0).toUpperCase() + propName.slice(1);
454
455
  outboundCalls.add(`${inferredClass}::${methodName}`);
455
456
  }
457
+
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
+ }
456
468
  }
469
+ } catch {
470
+ // Graceful fallback if file not physically accessible
457
471
  }
458
- } catch {
459
- // Graceful fallback if file not physically accessible
460
- }
461
-
462
- const depRepo = new DependencyRepository(this.db);
463
- const connectedFiles = depRepo.getFileConnections(row.file_path);
464
472
 
465
- // 4. Resolve Outbound Calls to target symbol line ranges
466
- const resolvedOutboundCalls: ResolvedOutboundCall[] = [];
467
- const lookupStmt = this.db.query<
468
- {
469
- name: string;
470
- line_start: number;
471
- line_end: number;
472
- line_count: number;
473
- file_path: string;
474
- domain_name: string;
475
- },
476
- [string, string, string, string]
477
- >(`
478
- SELECT
479
- s.name,
480
- s.line_start,
481
- s.line_end,
482
- s.line_count,
483
- f.path AS file_path,
484
- d.name AS domain_name
485
- FROM symbols s
486
- JOIN files f ON s.file_id = f.id
487
- JOIN domains d ON f.domain_id = d.id
488
- WHERE (s.name = ? OR s.name LIKE ? OR s.name LIKE ?)
489
- ORDER BY CASE WHEN s.name = ? THEN 1 ELSE 2 END, s.line_start ASC
490
- LIMIT 1
491
- `);
492
-
493
- for (const call of outboundCalls) {
494
- try {
495
- const target = lookupStmt.get(call, `%::${call}`, `${call}::%`, call);
496
- if (target) {
497
- resolvedOutboundCalls.push({
498
- symbol: call,
499
- target_file: target.file_path,
500
- target_range: {
501
- start_line: target.line_start,
502
- end_line: target.line_end,
503
- total_lines: target.line_count,
504
- },
505
- domain: target.domain_name,
506
- is_resolved: true,
507
- });
508
- } else {
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
+ });
523
+ }
524
+ } catch {
509
525
  resolvedOutboundCalls.push({
510
526
  symbol: call,
511
527
  is_resolved: false,
512
528
  });
513
529
  }
514
- } catch {
515
- resolvedOutboundCalls.push({
516
- symbol: call,
517
- is_resolved: false,
518
- });
519
530
  }
520
531
  }
521
532
 
@@ -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 {