septum 0.1.6 → 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.
package/src/mcp/server.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { existsSync, watch, type FSWatcher } from "node:fs";
2
- import { extname, relative, resolve } from "node:path";
1
+ import { existsSync, statSync, watch, type FSWatcher } from "node:fs";
2
+ import { relative, resolve } from "node:path";
3
3
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
5
  import {
@@ -12,6 +12,7 @@ import { ConfigLoader } from "../core/config/loader.ts";
12
12
  import { SeptumDatabase } from "../core/database/client.ts";
13
13
  import { SeptumRepository } from "../core/database/repository.ts";
14
14
  import { IngestionPipeline } from "../core/ingestion/pipeline.ts";
15
+ import { ASTParserEngine } from "../core/parser/tree-sitter.ts";
15
16
  import { SessionManager } from "../core/session/session-manager.ts";
16
17
  import { SeptumTelemetry } from "../core/telemetry/telemetry.ts";
17
18
  import { handleCheckBoundary } from "./tools/check-boundary.ts";
@@ -52,6 +53,8 @@ interface WorkspaceContext {
52
53
  repo: SeptumRepository;
53
54
  evaluator: BoundaryEvaluator;
54
55
  pipeline: IngestionPipeline;
56
+ ready: Promise<void>;
57
+ watcher: { flush: () => Promise<void>; close: () => void };
55
58
  }
56
59
 
57
60
  const workspaceContextCache = new Map<string, WorkspaceContext>();
@@ -127,6 +130,87 @@ export function extractPathHint(args: unknown): string | undefined {
127
130
  return undefined;
128
131
  }
129
132
 
133
+ function createWorkspaceWatcher(root: string, repo: SeptumRepository, pipeline: IngestionPipeline, ready: Promise<void>) {
134
+ const pending = new Set<string>();
135
+ let timer: ReturnType<typeof setTimeout> | null = null;
136
+ let flushing: Promise<void> | null = null;
137
+ let closed = false;
138
+ let needsFullSync = false;
139
+ const ignored = new Set(["node_modules", "vendor", ".git", ".septum", ".strata", "dist", "build", ".output", ".nuxt", ".next", ".cache", "coverage", ".turbo"]);
140
+ let watcher: FSWatcher | null = null;
141
+ let polling: ReturnType<typeof setInterval> | null = null;
142
+
143
+ const flush = async (): Promise<void> => {
144
+ if (timer) { clearTimeout(timer); timer = null; }
145
+ if (flushing) return flushing;
146
+ flushing = (async () => {
147
+ await ready;
148
+ if (!closed && (!watcher || needsFullSync || repo.getMeta("sync_error"))) {
149
+ needsFullSync = false;
150
+ await pipeline.run(ConfigLoader.load(undefined, root), { verifyContent: true });
151
+ repo.setMeta("sync_error", "");
152
+ }
153
+ while (!closed && pending.size > 0) {
154
+ const paths = [...pending];
155
+ pending.clear();
156
+ const config = ConfigLoader.load(undefined, root);
157
+ for (const filename of paths) {
158
+ const target = resolve(root, filename);
159
+ const relPath = relative(root, target).replace(/\\/g, "/");
160
+ if (!existsSync(target)) {
161
+ repo.runInTransaction(() => { repo.deleteFileByPath(relPath); repo.clearVerticalSlices();
162
+ const unsupported = JSON.parse(repo.getMeta("unsupported_files") || "[]") as string[];
163
+ repo.setMeta("unsupported_files", JSON.stringify(unsupported.filter(path => path !== relPath))); });
164
+ } else {
165
+ await pipeline.ingestFile(target, config);
166
+ }
167
+ }
168
+ }
169
+ })();
170
+ try { await flushing; } finally { flushing = null; }
171
+ };
172
+
173
+ const usePolling = (error: unknown) => {
174
+ watcher?.close();
175
+ watcher = null;
176
+ repo.setMeta("watcher_mode", "polling");
177
+ console.error(`[Septum Watcher Warning] Using polling: ${String(error)}`);
178
+ if (!polling) polling = setInterval(() => {
179
+ void flush().catch(failure => repo.setMeta("sync_error", String(failure)));
180
+ }, 1000);
181
+ };
182
+
183
+ try {
184
+ watcher = watch(root, { recursive: true }, (_event, filename) => {
185
+ if (!filename || closed) return;
186
+ const norm = filename.replace(/\\/g, "/");
187
+ if (norm.split("/").some(segment => ignored.has(segment))) return;
188
+ const target = resolve(root, norm);
189
+ let directory = false;
190
+ try { directory = statSync(target).isDirectory(); } catch { /* The path may have been removed before the event arrives. */ }
191
+ if (directory || (!ASTParserEngine.isSourceFile(norm) && (repo.getAllFiles().some(file => file.path.startsWith(norm + "/")) || (JSON.parse(repo.getMeta("unsupported_files") || "[]") as string[]).some(file => file.startsWith(norm + "/"))))) {
192
+ needsFullSync = true;
193
+ } else if (ASTParserEngine.isSourceFile(norm)) {
194
+ pending.add(norm);
195
+ } else {
196
+ return;
197
+ }
198
+ if (timer) clearTimeout(timer);
199
+ timer = setTimeout(() => {
200
+ void flush().catch(error => {
201
+ repo.setMeta("sync_error", String(error));
202
+ console.error(`[Septum Watcher Warning] ${String(error)}`);
203
+ });
204
+ }, 300);
205
+ });
206
+ repo.setMeta("watcher_mode", "native");
207
+ watcher.on("error", usePolling);
208
+ } catch (error) {
209
+ usePolling(error);
210
+ }
211
+ return { flush, close: () => { closed = true; if (timer) clearTimeout(timer); if (polling) clearInterval(polling); watcher?.close(); } };
212
+ }
213
+
130
214
  function getWorkspaceContext(pathHint?: string): WorkspaceContext {
131
215
  const root = resolveWorkspaceRoot(pathHint);
132
216
  let ctx = workspaceContextCache.get(root);
@@ -142,13 +226,16 @@ function getWorkspaceContext(pathHint?: string): WorkspaceContext {
142
226
  const evaluator = new BoundaryEvaluator(repo, new ModuleResolver(root, repo));
143
227
  const pipeline = new IngestionPipeline(repo, root);
144
228
 
145
- ctx = { root, config, db, repo, evaluator, pipeline };
229
+ const ready = pipeline.run(config, { verifyContent: true }).then(() => {});
230
+ const watcher = createWorkspaceWatcher(root, repo, pipeline, ready);
231
+ ctx = { root, config, db, repo, evaluator, pipeline, ready, watcher };
146
232
  workspaceContextCache.set(root, ctx);
147
233
  return ctx;
148
234
  }
149
235
 
150
236
  export async function runMCPServer(): Promise<void> {
151
237
  const initialCtx = getWorkspaceContext();
238
+ await initialCtx.ready;
152
239
 
153
240
  const server = new Server(
154
241
  {
@@ -571,6 +658,11 @@ export async function runMCPServer(): Promise<void> {
571
658
  const startTime = performance.now();
572
659
  const pathHint = extractPathHint(args as Record<string, any>);
573
660
  const ctx = getWorkspaceContext(pathHint);
661
+ await ctx.ready;
662
+ await ctx.watcher.flush();
663
+ if (ctx.repo.getMeta("sync_error")) throw new Error(`Workspace index synchronization failed: ${ctx.repo.getMeta("sync_error")}`);
664
+ ctx.config = ConfigLoader.load(undefined, ctx.root);
665
+ ctx.evaluator.getResolver().setRepository(ctx.repo);
574
666
 
575
667
  const activeSession = SessionManager.getActiveSession(ctx.root);
576
668
  const sessionPayload = activeSession
@@ -772,7 +864,7 @@ export async function runMCPServer(): Promise<void> {
772
864
  metrics: { bytes_out: bytesOut, lines_out: linesOut },
773
865
  session: sessionPayload,
774
866
  status: (response as { isError?: boolean } | undefined)?.isError ? "error" : "success",
775
- });
867
+ }, ctx.root);
776
868
 
777
869
  return response;
778
870
  } catch (err) {
@@ -790,7 +882,7 @@ export async function runMCPServer(): Promise<void> {
790
882
  message: errorInstance.message,
791
883
  stack: errorInstance.stack,
792
884
  },
793
- });
885
+ }, ctx.root);
794
886
 
795
887
  return {
796
888
  isError: true,
@@ -806,84 +898,10 @@ export async function runMCPServer(): Promise<void> {
806
898
 
807
899
  const transport = new StdioServerTransport();
808
900
 
809
- // In-process transparent background watcher for auto-syncing codebase changes
810
- let fsWatcher: FSWatcher | null = null;
811
- let debounceTimer: NodeJS.Timeout | null = null;
812
- const SUPPORTED_EXTENSIONS = new Set([".ts", ".js", ".tsx", ".jsx", ".php", ".py", ".go"]);
813
- const IGNORED_SEGMENTS = new Set([
814
- "node_modules",
815
- ".git",
816
- ".septum",
817
- ".strata",
818
- "dist",
819
- ".output",
820
- ".nuxt",
821
- ".next",
822
- ".cache",
823
- "coverage",
824
- ".turbo",
825
- ]);
826
-
827
- try {
828
- const bgPipeline = initialCtx.pipeline;
829
- const watchRoot = initialCtx.root;
830
-
831
- fsWatcher = watch(watchRoot, { recursive: true }, (_event, filename) => {
832
- if (!filename) return;
833
-
834
- const norm = filename.replace(/\\/g, "/");
835
- const parts = norm.split("/");
836
- if (parts.some((p) => IGNORED_SEGMENTS.has(p))) return;
837
-
838
- const ext = extname(norm).toLowerCase();
839
- if (!SUPPORTED_EXTENSIONS.has(ext)) return;
840
-
841
- if (debounceTimer) clearTimeout(debounceTimer);
842
- debounceTimer = setTimeout(async () => {
843
- try {
844
- const freshConfig = ConfigLoader.load(undefined, watchRoot);
845
- const targetPath = resolve(watchRoot, filename);
846
- const relPath = relative(watchRoot, targetPath).replace(/\\/g, "/");
847
- if (!existsSync(targetPath)) {
848
- if (typeof (initialCtx.repo as any).deleteFileByPath === "function") {
849
- (initialCtx.repo as any).deleteFileByPath(relPath);
850
- } else if (typeof initialCtx.repo.deleteFile === "function") {
851
- initialCtx.repo.deleteFile(relPath);
852
- }
853
- return;
854
- }
855
- const updated = await bgPipeline.ingestFile(targetPath, freshConfig);
856
- if (updated) {
857
- console.error(
858
- `[Septum Watcher] Auto-synced modified file '${filename}' via incremental delta sync.`
859
- );
860
- }
861
- } catch (err: unknown) {
862
- const msg = err instanceof Error ? err.message : String(err);
863
- console.error(`[Septum Watcher Warning] Background auto-sync failed: ${msg}`);
864
- }
865
- }, 300);
866
- });
867
-
868
- fsWatcher.on("error", (err: Error) => {
869
- console.error(`[Septum Watcher] File watcher error: ${err.message}`);
870
- });
871
- } catch (err: unknown) {
872
- const msg = err instanceof Error ? err.message : String(err);
873
- console.error(`[Septum Watcher Warning] Could not start filesystem watcher: ${msg}`);
874
- }
875
-
876
901
  const cleanup = () => {
877
- if (debounceTimer) clearTimeout(debounceTimer);
878
- if (fsWatcher) {
879
- try {
880
- fsWatcher.close();
881
- } catch {
882
- // Ignore watcher close errors during shutdown
883
- }
884
- }
885
902
  for (const ctx of workspaceContextCache.values()) {
886
903
  try {
904
+ ctx.watcher.close();
887
905
  ctx.db.close();
888
906
  } catch (err) {
889
907
  // Ignore close error during shutdown
@@ -896,16 +914,9 @@ export async function runMCPServer(): Promise<void> {
896
914
  process.on("SIGINT", cleanup);
897
915
  process.on("SIGTERM", cleanup);
898
916
  process.on("beforeExit", () => {
899
- if (debounceTimer) clearTimeout(debounceTimer);
900
- if (fsWatcher) {
901
- try {
902
- fsWatcher.close();
903
- } catch {
904
- // Ignore watcher close errors
905
- }
906
- }
907
917
  for (const ctx of workspaceContextCache.values()) {
908
918
  try {
919
+ ctx.watcher.close();
909
920
  ctx.db.close();
910
921
  } catch {
911
922
  // Ignore close error
@@ -1,4 +1,5 @@
1
- import { execSync } from "node:child_process";
1
+ import { relative, resolve } from "node:path";
2
+ import { auditStagedFiles } from "../../core/boundary/staged-auditor.ts";
2
3
  import type { BoundaryEvaluator } from "../../core/boundary/evaluator.ts";
3
4
  import type { ValidatedSeptumConfig } from "../../core/config/schema.ts";
4
5
  import type { SeptumRepository } from "../../core/database/repository.ts";
@@ -7,7 +8,7 @@ import { normalizeBoundaryArgs, type CheckBoundaryArgs } from "../schemas.ts";
7
8
 
8
9
  export type { CheckBoundaryArgs } from "../schemas.ts";
9
10
 
10
- export function handleCheckBoundary(
11
+ export async function handleCheckBoundary(
11
12
  evaluator: BoundaryEvaluator,
12
13
  config: ValidatedSeptumConfig,
13
14
  rawArgs: CheckBoundaryArgs & Record<string, unknown>,
@@ -19,6 +20,17 @@ export function handleCheckBoundary(
19
20
  const proposedImports = args.proposed_imports ?? [];
20
21
  const featureKey = args.feature_key;
21
22
 
23
+ const unsupported = JSON.parse(repo?.getMeta("unsupported_files") || "[]") as string[];
24
+ const requested = [...(Array.isArray(args.file_path) ? args.file_path : args.file_path ? [args.file_path] : []), ...(args.file_paths || [])]
25
+ .map(file => relative(root, resolve(root, file)).replace(/\\/g, "/"));
26
+ const relevantUnsupported = unsupported.filter(file =>
27
+ (!args.domain || evaluator.resolveSourceDomain(file, config) === args.domain) &&
28
+ (requested.length === 0 || requested.includes(file))
29
+ );
30
+ if (relevantUnsupported.length > 0 && args.scope !== "staged" && !args.staged) {
31
+ return { content: [{ type: "text" as const, text: JSON.stringify({ status: "unknown", unsupported_files: relevantUnsupported, message: "Boundary audit is incomplete because source files have no supported parser." }) }] };
32
+ }
33
+
22
34
  const filesToCheck: string[] = [];
23
35
  if (args.file_paths && Array.isArray(args.file_paths) && args.file_paths.length > 0) {
24
36
  filesToCheck.push(...args.file_paths);
@@ -36,20 +48,7 @@ export function handleCheckBoundary(
36
48
  args.scope ?? (args.staged ? "staged" : args.domain ? "domain" : "all");
37
49
 
38
50
  if (effectiveScope === "staged") {
39
- let stagedFiles: string[] = [];
40
- try {
41
- const gitOut = execSync("git diff --name-only --cached", {
42
- cwd: root,
43
- encoding: "utf-8",
44
- timeout: 5000,
45
- });
46
- stagedFiles = gitOut
47
- .split("\n")
48
- .map((f) => f.trim())
49
- .filter(Boolean);
50
- } catch {
51
- stagedFiles = [];
52
- }
51
+ const { files: stagedFiles, violations: stagedViolations } = await auditStagedFiles(evaluator, config, root, featureKey, repo);
53
52
 
54
53
  if (stagedFiles.length === 0) {
55
54
  return {
@@ -75,23 +74,6 @@ export function handleCheckBoundary(
75
74
  };
76
75
  }
77
76
 
78
- const codebaseViolations = evaluator.auditCodebase(config);
79
- const stagedNormalized = new Set(
80
- stagedFiles.map((f) => f.replace(/\\/g, "/").replace(/^\.\//, ""))
81
- );
82
- const stagedViolations: BoundaryViolation[] = [];
83
-
84
- for (const filePath of stagedFiles) {
85
- const evalViolations = evaluator.evaluate(filePath, proposedImports, featureKey, config);
86
- stagedViolations.push(...evalViolations);
87
- }
88
- for (const v of codebaseViolations) {
89
- const normFile = v.file.replace(/\\/g, "/").replace(/^\.\//, "");
90
- if (stagedNormalized.has(normFile)) {
91
- stagedViolations.push(v);
92
- }
93
- }
94
-
95
77
  const filteredViolations = args.domain
96
78
  ? stagedViolations.filter(
97
79
  (v) =>
@@ -15,6 +15,10 @@ export function handleGetDomainCatalog(
15
15
  ) {
16
16
  const targetRoot = args.workspace_path || workspaceRoot || (repo as any)?.projectRoot || process.cwd();
17
17
  const domainName = args.domain ?? args.name;
18
+ const indexStatus = {
19
+ unsupported_files: JSON.parse(repo.getMeta("unsupported_files") || "[]"),
20
+ watcher_mode: repo.getMeta("watcher_mode") || "manual",
21
+ };
18
22
 
19
23
  if (!domainName) {
20
24
  const allDomains = repo.getAllDomains();
@@ -55,6 +59,7 @@ export function handleGetDomainCatalog(
55
59
  : repo.getAllVerticalSlices().length;
56
60
 
57
61
  const macroMap = {
62
+ index_status: indexStatus,
58
63
  telescope_view: {
59
64
  project_type: projectType,
60
65
  framework,
@@ -129,7 +134,7 @@ export function handleGetDomainCatalog(
129
134
  content: [
130
135
  {
131
136
  type: "text" as const,
132
- text: JSON.stringify(catalog, null, 2),
137
+ text: JSON.stringify({ ...catalog, index_status: indexStatus }, null, 2),
133
138
  },
134
139
  ],
135
140
  };
@@ -100,18 +100,41 @@ export async function handleLocateSymbol(
100
100
  }
101
101
  }
102
102
 
103
+ const exactLineCount = result.exact_symbol
104
+ ? result.exact_symbol.line_end - result.exact_symbol.line_start + 1
105
+ : 0;
106
+ const complexitySmell =
107
+ exactLineCount > 300
108
+ ? {
109
+ is_high_complexity: true,
110
+ threshold: 300,
111
+ total_lines: exactLineCount,
112
+ warning: `Symbol '${result.exact_symbol?.name}' spans ${exactLineCount} lines (exceeds 300-line threshold). Candidate for decomposition/refactoring.`,
113
+ }
114
+ : undefined;
115
+
103
116
  const suggestions = (result.suggestions || []).slice(0, limit);
104
- const summary = result.found
117
+ let summary = result.found
105
118
  ? `[FOUND] ${result.exact_symbol?.name || query} in ${result.file_path} (lines ${result.exact_symbol?.line_start}-${result.exact_symbol?.line_end})`
106
119
  : `[NOT FOUND] Symbol '${query}' not found. ${suggestions.length > 0 ? `Top suggestions: ${suggestions.map((s) => s.name).slice(0, 3).join(", ")}` : "No close suggestions."}`;
107
120
 
121
+ if (complexitySmell) {
122
+ summary += ` | ⚠️ High-Complexity Smell: spans ${exactLineCount} lines (> 300L)`;
123
+ }
124
+
108
125
  const payload = {
109
126
  status: result.found ? "success" : "not_found",
110
127
  found: result.found,
111
128
  query,
112
129
  file_path: result.file_path || null,
113
130
  container_name: result.container_name || null,
114
- exact_symbol: result.exact_symbol || null,
131
+ exact_symbol: result.exact_symbol
132
+ ? {
133
+ ...result.exact_symbol,
134
+ total_lines: exactLineCount,
135
+ }
136
+ : null,
137
+ complexity_smell: complexitySmell,
115
138
  sibling_methods: result.sibling_methods || [],
116
139
  suggestions,
117
140
  summary,
@@ -41,6 +41,14 @@ export async function handleRegisterFeature(
41
41
  output_contract: args.output_contract || {},
42
42
  };
43
43
 
44
+ // Record Active Feature Session Lease (.septum/session.json)
45
+ SessionManager.saveActiveSession(workspaceRoot, {
46
+ feature_key: featureKey,
47
+ domain,
48
+ touchpoints: featureConfig.allowed_touchpoints,
49
+ locked_at: new Date().toISOString(),
50
+ });
51
+
44
52
  if (!config.features) {
45
53
  config.features = {};
46
54
  }
@@ -59,13 +67,7 @@ export async function handleRegisterFeature(
59
67
  }
60
68
  }
61
69
 
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
- });
70
+
69
71
 
70
72
  return {
71
73
  content: [
@@ -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,6 +351,8 @@ 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
  }
@@ -358,7 +377,7 @@ export interface GetSymbolImpactResponse {
358
377
  warning?: string;
359
378
  impact_summary: {
360
379
  total_dependents: number;
361
- risk_level: "low" | "medium" | "high";
380
+ risk_level: "low" | "medium" | "high" | "unknown";
362
381
  affected_files_count: number;
363
382
  };
364
383
  inbound_callers: InboundCaller[];
@@ -0,0 +1,4 @@
1
+ declare module "*.wasm" {
2
+ const path: string;
3
+ export default path;
4
+ }