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
@@ -18,6 +18,16 @@ export async function handleRegisterFeature(
18
18
  throw new Error("Missing required arguments: 'feature_key' and 'domain' are required.");
19
19
  }
20
20
 
21
+ // MCP-15: Validate domain is registered in DB or config
22
+ const registeredDomain = repo.getDomainByName(domain);
23
+ const configDomain = Object.keys(config.domains || {}).find(
24
+ (d) => d.toLowerCase() === domain.toLowerCase()
25
+ );
26
+ if (!registeredDomain && !configDomain) {
27
+ throw new Error(`Domain '${domain}' is not registered. Cannot lock feature to an unknown domain.`);
28
+ }
29
+ const canonicalDomain = registeredDomain?.name || configDomain || domain;
30
+
21
31
  const rawTouchpoints = args.allowed_touchpoints ?? [];
22
32
  const allowedTouchpoints = Array.isArray(rawTouchpoints)
23
33
  ? rawTouchpoints
@@ -25,6 +35,18 @@ export async function handleRegisterFeature(
25
35
  ? [rawTouchpoints]
26
36
  : [];
27
37
 
38
+ // MCP-15: Validate supported touchpoint glob patterns (exact, /*, /**)
39
+ for (const tp of allowedTouchpoints) {
40
+ const normalized = tp.replace(/\\/g, "/");
41
+ if (normalized.includes("*")) {
42
+ if (!normalized.endsWith("/*") && !normalized.endsWith("/**") && normalized !== "*" && normalized !== "**") {
43
+ throw new Error(
44
+ `Unsupported touchpoint glob pattern '${tp}'. Septum touchpoints support exact paths, '/*', and '/**'.`
45
+ );
46
+ }
47
+ }
48
+ }
49
+
28
50
  const rawReuse = args.reuse_symbols;
29
51
  const reuseSymbols: string[] = Array.isArray(rawReuse)
30
52
  ? rawReuse
@@ -33,7 +55,7 @@ export async function handleRegisterFeature(
33
55
  : [];
34
56
 
35
57
  const featureConfig = {
36
- domain,
58
+ domain: canonicalDomain,
37
59
  description: args.description || `Feature: ${featureKey}`,
38
60
  allowed_touchpoints: allowedTouchpoints,
39
61
  reuse_symbols: reuseSymbols,
@@ -41,31 +63,28 @@ export async function handleRegisterFeature(
41
63
  output_contract: args.output_contract || {},
42
64
  };
43
65
 
44
- if (!config.features) {
45
- config.features = {};
46
- }
47
- config.features[featureKey] = featureConfig;
48
-
49
- if (typeof (repo as any)?.upsertFeature === "function") {
50
- (repo as any).upsertFeature(featureKey, featureConfig);
51
- } else if (typeof repo?.setMeta === "function") {
52
- try {
66
+ SessionManager.saveActiveSession(workspaceRoot, {
67
+ feature_key: featureKey,
68
+ domain: canonicalDomain,
69
+ touchpoints: featureConfig.allowed_touchpoints,
70
+ locked_at: new Date().toISOString(),
71
+ }, SessionManager.getOwnerId(), {
72
+ repo,
73
+ save: () => {
53
74
  const existingRaw = repo.getMeta("features_json");
54
75
  const featuresMap = existingRaw ? JSON.parse(existingRaw) : {};
55
76
  featuresMap[featureKey] = featureConfig;
56
77
  repo.setMeta("features_json", JSON.stringify(featuresMap));
57
- } catch {
58
- // Ignore meta persistence errors on mock repositories
59
- }
78
+ },
79
+ });
80
+
81
+ // 3. Update in-memory config only after both SQLite and session lease succeeded
82
+ if (!config.features) {
83
+ config.features = {};
60
84
  }
85
+ config.features[featureKey] = featureConfig;
86
+
61
87
 
62
- // Record Active Feature Session Lease (.septum/session.json)
63
- SessionManager.saveActiveSession(workspaceRoot, {
64
- feature_key: featureKey,
65
- domain,
66
- touchpoints: featureConfig.allowed_touchpoints,
67
- locked_at: new Date().toISOString(),
68
- });
69
88
 
70
89
  return {
71
90
  content: [
@@ -77,7 +96,7 @@ export async function handleRegisterFeature(
77
96
  message: `Feature '${featureKey}' successfully registered in SQLite SSOT and session lease established.`,
78
97
  feature_key: featureKey,
79
98
  feature: featureKey,
80
- domain,
99
+ domain: canonicalDomain,
81
100
  touchpoints: featureConfig.allowed_touchpoints,
82
101
  reuse_symbols: featureConfig.reuse_symbols,
83
102
  },
@@ -0,0 +1,15 @@
1
+ import type { ValidatedSeptumConfig } from "../../core/config/schema.ts";
2
+ import type { SeptumRepository } from "../../core/database/repository.ts";
3
+ import type { SliceToolArgs } from "../schemas.ts";
4
+ import { handleTraceVerticalSlice } from "./trace-vertical-slice.ts";
5
+
6
+ export type { SliceToolArgs };
7
+
8
+ export function handleSliceTool(
9
+ repo: SeptumRepository,
10
+ config: ValidatedSeptumConfig,
11
+ args: SliceToolArgs,
12
+ workspaceRoot?: string
13
+ ) {
14
+ return handleTraceVerticalSlice(repo, config, args, workspaceRoot);
15
+ }
@@ -0,0 +1,82 @@
1
+ import type { ValidatedSeptumConfig } from "../../core/config/schema.ts";
2
+ import type { SeptumRepository } from "../../core/database/repository.ts";
3
+ import type { IngestionPipeline } from "../../core/ingestion/pipeline.ts";
4
+ import type { SymbolToolArgs } from "../schemas.ts";
5
+ import { handleLocateSymbol } from "./locate-symbol.ts";
6
+ import { handleGetSymbol } from "./get-symbol.ts";
7
+ import { handleGetSymbolImpact } from "./get-symbol-impact.ts";
8
+ import { handleGetSymbolHotspots } from "./get-symbol-hotspots.ts";
9
+
10
+ export type { SymbolToolArgs };
11
+
12
+ export async function handleSymbolTool(
13
+ repo: SeptumRepository,
14
+ config: ValidatedSeptumConfig,
15
+ args: SymbolToolArgs,
16
+ pipeline?: IngestionPipeline,
17
+ workspaceRoot?: string
18
+ ) {
19
+ const action = args.action || "locate";
20
+
21
+ switch (action) {
22
+ case "inspect": {
23
+ const symbol = args.symbol || args.query || "";
24
+ return handleGetSymbol(
25
+ repo,
26
+ config,
27
+ {
28
+ symbol,
29
+ query: symbol,
30
+ domain: args.domain,
31
+ include_dependencies: args.include_dependencies,
32
+ },
33
+ workspaceRoot
34
+ );
35
+ }
36
+
37
+ case "impact": {
38
+ const symbol = args.symbol || args.query || "";
39
+ return handleGetSymbolImpact(
40
+ repo,
41
+ config,
42
+ {
43
+ symbol,
44
+ query: symbol,
45
+ domain: args.domain,
46
+ },
47
+ workspaceRoot
48
+ );
49
+ }
50
+
51
+ case "hotspots":
52
+ return handleGetSymbolHotspots(
53
+ repo,
54
+ config,
55
+ {
56
+ min_lines: args.min_lines,
57
+ min_nesting: args.min_nesting,
58
+ kind: args.kind,
59
+ domain: args.domain,
60
+ limit: args.limit ?? 30,
61
+ sort_by: (args.sort_by === "line_count" ? "lines" : args.sort_by) as any,
62
+ workspace_path: args.workspace_path,
63
+ },
64
+ workspaceRoot
65
+ );
66
+
67
+ case "locate":
68
+ default:
69
+ return await handleLocateSymbol(
70
+ repo,
71
+ config,
72
+ {
73
+ query: args.query || args.symbol || "",
74
+ domain: args.domain,
75
+ limit: args.limit ?? 10,
76
+ workspace_path: args.workspace_path,
77
+ },
78
+ pipeline,
79
+ workspaceRoot
80
+ );
81
+ }
82
+ }
@@ -0,0 +1,159 @@
1
+ import { existsSync, realpathSync, statSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
4
+ import { ConfigLoader } from '../core/config/loader.ts';
5
+ import type { ValidatedSeptumConfig } from '../core/config/schema.ts';
6
+ import { SeptumDatabase } from '../core/database/client.ts';
7
+ import { SeptumRepository } from '../core/database/repository.ts';
8
+ import { BoundaryEvaluator } from '../core/boundary/evaluator.ts';
9
+ import { ModuleResolver } from '../core/resolver/module-resolver.ts';
10
+ import { findProjectRoot, isAppHostDirectory, isPathContained, PROJECT_ROOT_MARKERS, setLastKnownProjectRoot } from '../core/resolver/path-resolver.ts';
11
+ import { IngestionPipeline } from '../core/ingestion/pipeline.ts';
12
+ import { RESOURCE_LIMITS } from '../core/resources/limits.ts';
13
+ import { SerialWorkQueue } from '../core/resources/work-queue.ts';
14
+ import { WatchBudget, WorkspaceWatcher } from './workspace-watcher.ts';
15
+
16
+ export function resolveServerWorkspace(options: {workspacePath?: string; filePath?: string; fallbackRoot?: string; knownRoots?: string[]; initial?: boolean; cwd?: string} = {}): string {
17
+ const cwd = options.cwd || process.cwd();
18
+ const explicit = options.workspacePath || (!options.filePath ? process.env.SEPTUM_WORKSPACE_ROOT || process.env.WORKSPACE_ROOT : undefined);
19
+ let root: string;
20
+ if (explicit) {
21
+ root = resolve(cwd, explicit);
22
+ } else if (options.filePath && (isAbsolute(options.filePath) || /[\\/]/.test(options.filePath))) {
23
+ const absTarget = resolve(options.fallbackRoot || cwd, options.filePath);
24
+ // SV-04: Prefer known/registered workspace roots that already contain this file before falling back to nested markers
25
+ const containingKnown = options.knownRoots?.find(known => isPathContained(absTarget, known));
26
+ if (containingKnown && isWorkspaceInitialized(containingKnown)) {
27
+ root = containingKnown;
28
+ } else {
29
+ root = findProjectRoot(absTarget);
30
+ }
31
+ } else if (options.fallbackRoot) {
32
+ root = options.fallbackRoot;
33
+ } else {
34
+ if (!PROJECT_ROOT_MARKERS.some(marker => existsSync(join(cwd, marker)))) throw new Error('No project marker in CWD. Set SEPTUM_WORKSPACE_ROOT to an explicit workspace directory.');
35
+ root = cwd;
36
+ }
37
+ root = realpathSync(root);
38
+ if (!statSync(root).isDirectory() || root === dirname(root) || root === realpathSync(homedir()) || isAppHostDirectory(root)) throw new Error(`Unsafe Septum serve workspace: ${root}`);
39
+
40
+ // SV-04: Validate file-workspace consistency when both are present
41
+ if (options.workspacePath && options.filePath && isAbsolute(options.filePath)) {
42
+ if (!isPathContained(options.filePath, root)) {
43
+ throw new Error(`File '${options.filePath}' does not belong to workspace '${root}'.`);
44
+ }
45
+ }
46
+
47
+ setLastKnownProjectRoot(root);
48
+ return root;
49
+ }
50
+
51
+ export function isWorkspaceInitialized(root: string): boolean {
52
+ try {
53
+ // SV-03: Pure .septum directory without config or db is uninitialized
54
+ return existsSync(join(root, "septum.config.json")) ||
55
+ existsSync(join(root, ".septum", "septum.db"));
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ export interface WorkspaceContext {
62
+ root: string;
63
+ config: ValidatedSeptumConfig;
64
+ db: SeptumDatabase;
65
+ repo: SeptumRepository;
66
+ evaluator: BoundaryEvaluator;
67
+ pipeline: IngestionPipeline;
68
+ ready: Promise<void>;
69
+ watcher: WorkspaceWatcher;
70
+ }
71
+ interface Entry { context: WorkspaceContext; leases: number; lastUsed: number; initializing: boolean; abort: AbortController; disposal?: Promise<void> }
72
+ export interface WorkspaceLease { context: WorkspaceContext; release(): void }
73
+
74
+ export class WorkspaceManager {
75
+ private contexts = new Map<string, Entry>();
76
+ private queue = new SerialWorkQueue();
77
+ private budget = new WatchBudget();
78
+ private disposals = new Set<Promise<void>>();
79
+ private closed = false;
80
+ private closing: Promise<void> | null = null;
81
+ private idleTimer: ReturnType<typeof setInterval>;
82
+ initialRoot?: string;
83
+ lastRoot?: string;
84
+ get activeRoots(): string[] { return [...this.contexts.keys()]; }
85
+ constructor(private readonly now: () => number = Date.now) {
86
+ this.idleTimer = setInterval(() => { void this.expireIdle().catch(error => console.error(`[Septum Cleanup Warning] ${String(error)}`)); }, 60_000);
87
+ this.idleTimer.unref();
88
+ }
89
+ get size(): number { return this.contexts.size; }
90
+ get watchCount(): number { return this.budget.count; }
91
+ get syncDurationMs(): number { return Math.max(0, ...[...this.contexts.values()].map(entry => Number(entry.context.repo.getMeta("sync_duration_ms") || 0))); }
92
+ get pendingPaths(): number { return [...this.contexts.values()].reduce((n, entry) => n + entry.context.watcher.pendingCount, 0); }
93
+
94
+ acquire(rawRoot: string): WorkspaceLease {
95
+ if (this.closed) throw new Error('Septum server is shutting down');
96
+ const root = realpathSync(rawRoot);
97
+ let entry = this.contexts.get(root);
98
+ if (!entry) {
99
+ if (this.contexts.size >= RESOURCE_LIMITS.workspaces) {
100
+ const idle = [...this.contexts.values()].filter(value => !value.leases && !value.initializing && !value.context.watcher.isBusy).sort((a,b) => a.lastUsed - b.lastUsed)[0];
101
+ if (!idle) throw new Error('Workspace resource limit reached: all workspace slots are busy. Retry after active requests finish.');
102
+ this.dispose(idle);
103
+ }
104
+ const config = ConfigLoader.load(undefined, root);
105
+ const db = new SeptumDatabase(resolve(root, config.settings.db_path || '.septum/septum.db'));
106
+ try {
107
+ const repo = new SeptumRepository(db.raw);
108
+ const pipeline = new IngestionPipeline(repo, root);
109
+ const evaluator = new BoundaryEvaluator(repo, new ModuleResolver(root, repo));
110
+ const abort = new AbortController();
111
+ const ready = this.queue.run(async () => { abort.signal.throwIfAborted(); await pipeline.run(config, {verifyContent:true,signal:abort.signal}); });
112
+ const watcher = new WorkspaceWatcher(root, repo, pipeline, ready, this.queue, this.budget, () => ConfigLoader.load(undefined, root));
113
+ entry = {context:{root,config,db,repo,evaluator,pipeline,ready,watcher},leases:0,lastUsed:this.now(),initializing:true,abort};
114
+ this.contexts.set(root, entry);
115
+ const initialized = entry;
116
+ void ready.then(() => { initialized.initializing = false; }, () => { initialized.initializing = false; this.dispose(initialized); });
117
+ } catch (error) { db.close(); throw error; }
118
+ }
119
+ this.lastRoot = root;
120
+ entry.leases++; entry.lastUsed = this.now();
121
+ const leased = entry; let released = false;
122
+ return {context: entry.context, release: () => { if (!released) { released=true; leased.leases--; leased.lastUsed=this.now(); } }};
123
+ }
124
+
125
+ private dispose(entry: Entry): Promise<void> {
126
+ if (entry.disposal) return entry.disposal;
127
+ if (this.contexts.get(entry.context.root) === entry) this.contexts.delete(entry.context.root);
128
+ entry.abort.abort();
129
+ const idle = !entry.leases && !entry.initializing && !entry.context.watcher.isBusy;
130
+ const stop = entry.context.watcher.close();
131
+ // Eviction of an idle context closes its database before admitting another.
132
+ if (idle) entry.context.db.close();
133
+ const disposal = stop.then(async () => { if (!idle) { await this.queue.drain(); entry.context.db.close(); } });
134
+ entry.disposal = disposal;
135
+ this.disposals.add(disposal);
136
+ void disposal.finally(() => this.disposals.delete(disposal)).catch(error => console.error(`[Septum Cleanup Warning] ${String(error)}`));
137
+ return disposal;
138
+ }
139
+
140
+ async expireIdle(): Promise<void> {
141
+ const expired = [...this.contexts.values()].filter(entry => !entry.leases && !entry.initializing && !entry.context.watcher.isBusy && this.now() - entry.lastUsed >= RESOURCE_LIMITS.workspaceIdleMs);
142
+ await Promise.all(expired.map(entry => this.dispose(entry)));
143
+ }
144
+
145
+ runTool<T>(work: () => Promise<T>, signal?: AbortSignal): Promise<T> {
146
+ return this.queue.run(() => { if (this.closed) throw new Error("Septum server is shutting down"); return work(); }, signal);
147
+ }
148
+
149
+ close(): Promise<void> {
150
+ if (this.closing) return this.closing;
151
+ this.closed = true; clearInterval(this.idleTimer);
152
+ const pending = [...this.contexts.values()].map(entry => this.dispose(entry));
153
+ this.closing = Promise.allSettled([...pending, ...this.disposals, this.queue.drain()]).then(results => {
154
+ const failed = results.find(result => result.status === 'rejected');
155
+ if (failed?.status === 'rejected') throw failed.reason;
156
+ });
157
+ return this.closing;
158
+ }
159
+ }
@@ -0,0 +1,202 @@
1
+ import { existsSync, lstatSync, readdirSync, watch, type FSWatcher } from 'node:fs';
2
+ import { join, relative, resolve, isAbsolute } from 'node:path';
3
+ import type { ValidatedSeptumConfig } from '../core/config/schema.ts';
4
+ import type { SeptumRepository } from '../core/database/repository.ts';
5
+ import type { IngestionPipeline } from '../core/ingestion/pipeline.ts';
6
+ import { ASTParserEngine } from '../core/parser/tree-sitter.ts';
7
+ import { shouldIgnoreSourcePath } from '../core/resources/ignore.ts';
8
+ import { RESOURCE_LIMITS } from '../core/resources/limits.ts';
9
+ import { SerialWorkQueue } from '../core/resources/work-queue.ts';
10
+
11
+ export class WatchBudget {
12
+ count = 0;
13
+ constructor(private readonly limit = RESOURCE_LIMITS.watches) {}
14
+ take(): boolean { if (this.count >= this.limit) return false; this.count++; return true; }
15
+ release(): void { this.count--; }
16
+ }
17
+
18
+ export class WorkspaceWatcher {
19
+ private watches = new Map<string, FSWatcher>();
20
+ private pending = new Set<string>();
21
+ private timer: ReturnType<typeof setTimeout> | null = null;
22
+ private poll: ReturnType<typeof setInterval> | null = null;
23
+ private flushing: Promise<void> | null = null;
24
+ private closed = false;
25
+ private reconcile = false;
26
+ private lastVerify = 0;
27
+ private abort = new AbortController();
28
+ private closing: Promise<void> | null = null;
29
+ private config: ValidatedSeptumConfig;
30
+ mode: 'native' | 'polling' = 'native';
31
+
32
+ constructor(
33
+ private readonly root: string,
34
+ private readonly repo: SeptumRepository,
35
+ private readonly pipeline: IngestionPipeline,
36
+ private readonly ready: Promise<void>,
37
+ private readonly queue: SerialWorkQueue,
38
+ private readonly budget: WatchBudget,
39
+ private readonly loadConfig: () => ValidatedSeptumConfig,
40
+ ) {
41
+ this.config = this.loadConfig();
42
+ this.registerDirectories();
43
+ // Periodic content verification also protects native watchers from missed events.
44
+ this.poll = setInterval(() => { void this.flush(false).catch(error => this.recordError(error)); }, RESOURCE_LIMITS.pollMs);
45
+ this.poll.unref();
46
+ }
47
+
48
+ get isBusy(): boolean { return this.flushing !== null; }
49
+ get pendingCount(): number { return this.pending.size; }
50
+ get watchCount(): number { return this.watches.size; }
51
+
52
+ private recordError(error: unknown): void {
53
+ if (!this.closed) this.repo.setMeta('sync_error', String(error));
54
+ }
55
+
56
+ private clearWatches(): void {
57
+ for (const watcher of this.watches.values()) { watcher.close(); this.budget.release(); }
58
+ this.watches.clear();
59
+ }
60
+
61
+ private fallback(error: unknown): void {
62
+ this.clearWatches();
63
+ this.mode = 'polling';
64
+ this.repo.setMeta('watcher_mode', 'polling');
65
+ console.error(`[Septum Watcher Warning] Using bounded polling: ${String(error)}`);
66
+ }
67
+
68
+ private registerDirectories(): void {
69
+ if (this.closed || this.mode === 'polling') return;
70
+ const config = this.loadConfig();
71
+ this.config = config;
72
+ const seen = new Set<string>();
73
+ const stack = [this.root];
74
+ const domainRoots = Object.values(config.domains || {})
75
+ .map(d => relative(this.root, resolve(this.root, d.root)).replace(/\\/g, '/').replace(/^\/|\/$/g, ''))
76
+ .filter(dr => dr !== '.' && dr !== '');
77
+ try {
78
+ while (stack.length) {
79
+ const dir = stack.pop()!;
80
+ const rel = relative(this.root, dir).replace(/\\/g, '/');
81
+ if (rel && shouldIgnoreSourcePath(rel, config.settings.ignore_patterns)) continue;
82
+ const stat = lstatSync(dir);
83
+ if (!stat.isDirectory() || stat.isSymbolicLink()) continue;
84
+ seen.add(dir);
85
+ if (!this.watches.has(dir)) {
86
+ if (!this.budget.take()) throw new Error(`Native watch limit ${RESOURCE_LIMITS.watches} reached`);
87
+ let watcher: FSWatcher;
88
+ try {
89
+ watcher = watch(dir, {recursive: false}, (_event, filename) => {
90
+ if (filename) this.notify(relative(this.root, join(dir, String(filename))).replace(/\\/g, '/'));
91
+ else { this.reconcile = true; this.schedule(); }
92
+ });
93
+ } catch (error) { this.budget.release(); throw error; }
94
+ this.watches.set(dir, watcher);
95
+ watcher.on('error', error => { if (!this.closed) { this.fallback(error); this.reconcile = true; this.schedule(); } });
96
+ }
97
+ for (const entry of readdirSync(dir, {withFileTypes: true})) {
98
+ if (entry.isDirectory() && !entry.isSymbolicLink()) {
99
+ const childDir = join(dir, entry.name);
100
+ const childRel = relative(this.root, childDir).replace(/\\/g, '/');
101
+ if (shouldIgnoreSourcePath(childRel, config.settings.ignore_patterns)) continue;
102
+ if (domainRoots.length > 0) {
103
+ const isRelevant = domainRoots.some(dr => childRel === dr || childRel.startsWith(dr + '/') || dr.startsWith(childRel + '/'));
104
+ if (!isRelevant) continue;
105
+ }
106
+ stack.push(childDir);
107
+ }
108
+ }
109
+ }
110
+ for (const [dir, watcher] of this.watches) {
111
+ if (!seen.has(dir)) { watcher.close(); this.watches.delete(dir); this.budget.release(); }
112
+ }
113
+ this.repo.setMeta('watcher_mode', 'native');
114
+ } catch (error) { this.fallback(error); }
115
+ }
116
+
117
+ notify(filename: string): void {
118
+ if (this.closed || this.reconcile) return;
119
+ const norm = filename.replace(/\\/g, '/');
120
+ const target = resolve(this.root, norm);
121
+ const rel = relative(this.root, target);
122
+ if (isAbsolute(rel) || rel === '..' || rel.startsWith('../') || shouldIgnoreSourcePath(norm, this.config.settings.ignore_patterns)) return;
123
+ let directory = this.watches.has(target);
124
+ try { directory ||= lstatSync(target).isDirectory(); } catch { /* deletion */ }
125
+ if (directory || /(^|\/)(package\.json|composer\.json|nest-cli\.json|go\.mod|tsconfig\.json|artisan)$/.test(norm)) {
126
+ this.reconcile = true;
127
+ this.pending.clear();
128
+ } else if (ASTParserEngine.isSourceFile(norm)) {
129
+ if (this.pending.size >= RESOURCE_LIMITS.pendingPaths) { this.reconcile = true; this.pending.clear(); }
130
+ else this.pending.add(norm);
131
+ } else return;
132
+ this.schedule();
133
+ }
134
+
135
+ private schedule(): void {
136
+ if (this.timer) clearTimeout(this.timer);
137
+ this.timer = setTimeout(() => { this.timer = null; void this.flush(false).catch(error => this.recordError(error)); }, RESOURCE_LIMITS.debounceMs);
138
+ }
139
+
140
+ async flush(forceVerify = true, signal?: AbortSignal): Promise<void> {
141
+ if (this.closed) return;
142
+ signal?.throwIfAborted();
143
+ if (this.timer) { clearTimeout(this.timer); this.timer = null; }
144
+ if (this.flushing) {
145
+ await this.flushing;
146
+ signal?.throwIfAborted();
147
+ if (forceVerify || this.pending.size || this.reconcile) return this.flush(forceVerify, signal);
148
+ return;
149
+ }
150
+ // SV-07: forceVerify forces verification in both native and polling modes
151
+ const verify = forceVerify || this.mode === 'polling' || Date.now() - this.lastVerify >= RESOURCE_LIMITS.verifyMs;
152
+ const poll = this.mode === 'polling';
153
+ if (!poll && !verify && !this.reconcile && !this.pending.size && !this.repo.getMeta('sync_error')) return;
154
+ this.flushing = this.queue.run(async () => {
155
+ await this.ready;
156
+ signal?.throwIfAborted();
157
+ this.abort.signal.throwIfAborted();
158
+ const config = this.loadConfig();
159
+ if (poll || verify || this.reconcile || this.repo.getMeta('sync_error')) {
160
+ this.reconcile = false;
161
+ this.pending.clear();
162
+ await this.pipeline.run(config, {verifyContent: verify, signal: this.abort.signal});
163
+ if (verify) this.lastVerify = Date.now();
164
+ this.registerDirectories();
165
+ }
166
+ let sourceEvents = false;
167
+ while (this.pending.size) {
168
+ sourceEvents = true;
169
+ const paths = [...this.pending]; this.pending.clear();
170
+ for (const filename of paths) {
171
+ signal?.throwIfAborted();
172
+ this.abort.signal.throwIfAborted();
173
+ const target = resolve(this.root, filename);
174
+ if (existsSync(target)) await this.pipeline.ingestFile(target, config);
175
+ else this.repo.runInTransaction(() => {
176
+ this.repo.deleteFileByPath(filename); this.repo.clearVerticalSlices(); this.repo.setMeta('slices_dirty', '1');
177
+ const unsupported: string[] = JSON.parse(this.repo.getMeta('unsupported_files') || '[]');
178
+ this.repo.setMeta('unsupported_files', JSON.stringify(unsupported.filter(path => path !== filename)));
179
+ const skipped: {path: string; reason: string}[] = JSON.parse(this.repo.getMeta('skipped_files') || '[]');
180
+ this.repo.setMeta('skipped_files', JSON.stringify(skipped.filter(file => file.path !== filename)));
181
+ });
182
+ }
183
+ }
184
+ if (sourceEvents || this.repo.getMeta('slices_dirty') === '1') await this.pipeline.run(config, {signal: this.abort.signal});
185
+ }, signal);
186
+ try { await this.flushing; }
187
+ finally { this.flushing = null; }
188
+ // A directory event during sync must not be lost behind the coalesced flush.
189
+ if (!this.closed && (this.reconcile || this.pending.size)) await this.flush(false);
190
+ }
191
+
192
+ close(): Promise<void> {
193
+ if (this.closing) return this.closing;
194
+ this.closed = true;
195
+ this.abort.abort();
196
+ if (this.timer) clearTimeout(this.timer);
197
+ if (this.poll) clearInterval(this.poll);
198
+ this.clearWatches(); this.pending.clear();
199
+ this.closing = Promise.allSettled([this.ready, this.flushing]).then(() => {});
200
+ return this.closing;
201
+ }
202
+ }
@@ -129,6 +129,7 @@ export interface FileRecord {
129
129
  }
130
130
 
131
131
  export interface ActiveFeatureSession {
132
+ owner_id?: string;
132
133
  feature_key: string;
133
134
  domain: string;
134
135
  touchpoints: string[];
@@ -177,6 +178,7 @@ export interface ExtractedDependency {
177
178
  }
178
179
 
179
180
  export interface ParsedFileAST {
181
+ status?: "parsed" | "unsupported" | "degraded";
180
182
  symbols: ExtractedSymbol[];
181
183
  dependencies: ExtractedDependency[];
182
184
  }
@@ -324,6 +326,21 @@ export interface SymbolExactRange {
324
326
  total_lines: number;
325
327
  }
326
328
 
329
+ export interface ResolvedOutboundCall {
330
+ symbol: string;
331
+ target_file?: string;
332
+ target_range?: SymbolExactRange;
333
+ domain?: string;
334
+ is_resolved: boolean;
335
+ }
336
+
337
+ export interface SymbolComplexitySmell {
338
+ is_high_complexity: boolean;
339
+ threshold: number;
340
+ total_lines: number;
341
+ warning: string;
342
+ }
343
+
327
344
  export interface GetSymbolDetailsResponse {
328
345
  symbol: string;
329
346
  kind: SymbolKind | string;
@@ -334,11 +351,14 @@ export interface GetSymbolDetailsResponse {
334
351
  visibility?: Visibility | string;
335
352
  dependencies_injected: string[];
336
353
  outbound_calls: string[];
354
+ resolved_outbound_calls?: ResolvedOutboundCall[];
355
+ complexity_smell?: SymbolComplexitySmell;
337
356
  sibling_methods?: string[];
338
357
  connected_files?: FileConnectionsSummary;
339
358
  }
340
359
 
341
360
  export interface GetSymbolImpactArgs {
361
+ domain?: string;
342
362
  symbol: string;
343
363
  query?: string;
344
364
  workspace_path?: string;
@@ -358,7 +378,7 @@ export interface GetSymbolImpactResponse {
358
378
  warning?: string;
359
379
  impact_summary: {
360
380
  total_dependents: number;
361
- risk_level: "low" | "medium" | "high";
381
+ risk_level: "low" | "medium" | "high" | "unknown";
362
382
  affected_files_count: number;
363
383
  };
364
384
  inbound_callers: InboundCaller[];
@@ -0,0 +1,4 @@
1
+ declare module "*.wasm" {
2
+ const path: string;
3
+ export default path;
4
+ }
@@ -1,29 +0,0 @@
1
- import { ConfigLoader } from "../../core/config/loader.ts";
2
- import { SeptumDatabase } from "../../core/database/client.ts";
3
- import { SeptumRepository } from "../../core/database/repository.ts";
4
- import { IngestionPipeline } from "../../core/ingestion/pipeline.ts";
5
-
6
- export async function handleIngestCommand(): Promise<void> {
7
- console.log("[Septum] Starting codebase structural ingestion...");
8
-
9
- const config = ConfigLoader.load();
10
- const db = new SeptumDatabase(config.settings.db_path);
11
- const repo = new SeptumRepository(db.raw);
12
- const pipeline = new IngestionPipeline(repo);
13
-
14
- const metrics = await pipeline.run(config);
15
-
16
- console.log("=========================================");
17
- console.log(" Septum Ingestion Complete");
18
- console.log("=========================================");
19
- console.log(` Domains Processed: ${metrics.domains_processed}`);
20
- console.log(` Files Scanned: ${metrics.files_scanned}`);
21
- console.log(` Files Updated/Added: ${metrics.files_updated}`);
22
- console.log(` Files Cached/Skipped: ${metrics.files_skipped}`);
23
- console.log(` Symbols Indexed: ${metrics.symbols_indexed}`);
24
- console.log(` Dependencies Indexed: ${metrics.dependencies_indexed}`);
25
- console.log(` Execution Time: ${metrics.duration_ms} ms`);
26
- console.log("=========================================");
27
-
28
- db.close();
29
- }