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.
- package/README.md +38 -37
- package/package.json +6 -5
- package/src/cli/commands/check.ts +34 -122
- package/src/cli/commands/hook.ts +68 -16
- package/src/cli/commands/init.ts +440 -121
- package/src/cli/commands/sync.ts +27 -17
- package/src/cli/commands/topology.ts +89 -47
- package/src/cli/index.ts +7 -68
- package/src/cli/scaffold/harness-config.ts +697 -0
- package/src/cli/templates/hooks.ts +6 -6
- package/src/cli/templates/rules.ts +8 -8
- package/src/cli/templates/skill.ts +11 -18
- package/src/cli/ui/prompts.ts +122 -0
- package/src/core/boundary/evaluator.ts +21 -25
- package/src/core/boundary/staged-auditor.ts +50 -0
- package/src/core/config/loader.ts +21 -24
- package/src/core/database/client.ts +8 -0
- package/src/core/database/repositories/dependency.repository.ts +80 -16
- package/src/core/database/repositories/domain.repository.ts +86 -33
- package/src/core/database/repositories/symbol.repository.ts +147 -65
- package/src/core/database/repository.ts +17 -5
- package/src/core/database/schema.ts +1 -1
- package/src/core/discovery/topology-detector.ts +71 -19
- package/src/core/ingestion/pipeline.ts +274 -126
- package/src/core/parser/extractors/frontend.ts +1 -0
- package/src/core/parser/extractors/go.ts +2 -2
- package/src/core/parser/extractors/laravel-semantic.ts +9 -8
- package/src/core/parser/extractors/mcp-cli-semantic.ts +56 -16
- package/src/core/parser/extractors/nestjs-semantic.ts +3 -2
- package/src/core/parser/extractors/php.ts +2 -2
- package/src/core/parser/extractors/python.ts +2 -2
- package/src/core/parser/extractors/typescript.ts +2 -2
- package/src/core/parser/schema/providers/laravel-migration-provider.ts +4 -3
- package/src/core/parser/schema/providers/prisma-schema-provider.ts +2 -1
- package/src/core/parser/tree-sitter.ts +6 -1
- package/src/core/parser/wasm-assets.ts +17 -0
- package/src/core/parser/wasm-loader.ts +3 -0
- package/src/core/resolver/call-graph-tracer.ts +4 -3
- package/src/core/resolver/module-resolver.ts +1 -10
- package/src/core/resolver/path-resolver.ts +57 -4
- package/src/core/resolver/vertical-slice-tracer.ts +1 -0
- package/src/core/resources/ignore.ts +35 -0
- package/src/core/resources/limits.ts +14 -0
- package/src/core/resources/source-files.ts +62 -0
- package/src/core/resources/work-queue.ts +74 -0
- package/src/core/session/session-manager.ts +67 -38
- package/src/core/telemetry/telemetry.ts +12 -1
- package/src/core/topology/environment-detector.ts +21 -11
- package/src/mcp/schemas.ts +381 -8
- package/src/mcp/server.ts +326 -529
- package/src/mcp/tools/boundary.ts +17 -0
- package/src/mcp/tools/check-boundary.ts +97 -43
- package/src/mcp/tools/domain.ts +40 -0
- package/src/mcp/tools/feature.ts +67 -0
- package/src/mcp/tools/get-domain-catalog.ts +15 -7
- package/src/mcp/tools/get-environment-topology.ts +2 -2
- package/src/mcp/tools/get-feature-context.ts +9 -12
- package/src/mcp/tools/get-symbol-impact.ts +5 -2
- package/src/mcp/tools/get-symbol.ts +2 -1
- package/src/mcp/tools/locate-symbol.ts +28 -5
- package/src/mcp/tools/register-domain.ts +25 -9
- package/src/mcp/tools/register-feature.ts +40 -21
- package/src/mcp/tools/slice.ts +15 -0
- package/src/mcp/tools/symbol.ts +82 -0
- package/src/mcp/workspace-manager.ts +159 -0
- package/src/mcp/workspace-watcher.ts +202 -0
- package/src/types/index.ts +21 -1
- package/src/types/wasm.d.ts +4 -0
- package/src/cli/commands/ingest.ts +0 -29
- package/src/cli/commands/locate.ts +0 -61
- package/src/cli/commands/query.ts +0 -104
- 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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
}
|
|
58
|
-
|
|
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
|
+
}
|
package/src/types/index.ts
CHANGED
|
@@ -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[];
|
|
@@ -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
|
-
}
|