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.
- package/README.md +36 -55
- package/package.json +3 -2
- package/src/cli/commands/check.ts +2 -0
- package/src/cli/commands/hook.ts +59 -12
- 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 +5 -67
- 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/staged-auditor.ts +5 -2
- package/src/core/database/client.ts +8 -0
- package/src/core/database/repositories/dependency.repository.ts +77 -13
- package/src/core/database/repositories/domain.repository.ts +86 -33
- package/src/core/database/repositories/symbol.repository.ts +128 -117
- 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 +228 -94
- 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/schema/providers/laravel-migration-provider.ts +4 -3
- package/src/core/parser/schema/providers/prisma-schema-provider.ts +2 -1
- package/src/core/resolver/call-graph-tracer.ts +4 -3
- package/src/core/resolver/path-resolver.ts +57 -4
- 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 +19 -6
- 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 +321 -535
- package/src/mcp/tools/boundary.ts +17 -0
- package/src/mcp/tools/check-boundary.ts +83 -11
- package/src/mcp/tools/domain.ts +40 -0
- package/src/mcp/tools/feature.ts +67 -0
- package/src/mcp/tools/get-domain-catalog.ts +9 -6
- 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 +3 -3
- package/src/mcp/tools/register-domain.ts +25 -9
- package/src/mcp/tools/register-feature.ts +34 -17
- 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 +1 -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
|
@@ -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
|
@@ -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
|
-
}
|
|
@@ -1,61 +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 { SymbolLocator } from "../../core/resolver/symbol-locator.ts";
|
|
5
|
-
|
|
6
|
-
export async function handleLocateCommand(
|
|
7
|
-
query: string,
|
|
8
|
-
options?: { domain?: string; json?: boolean }
|
|
9
|
-
): Promise<void> {
|
|
10
|
-
const config = ConfigLoader.load();
|
|
11
|
-
const db = new SeptumDatabase(config.settings.db_path);
|
|
12
|
-
const repo = new SeptumRepository(db.raw);
|
|
13
|
-
|
|
14
|
-
try {
|
|
15
|
-
const locator = new SymbolLocator(repo);
|
|
16
|
-
const result = locator.locate(query, options?.domain);
|
|
17
|
-
|
|
18
|
-
if (options?.json) {
|
|
19
|
-
console.log(JSON.stringify(result, null, 2));
|
|
20
|
-
return;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
if (result.found) {
|
|
24
|
-
console.log(`\n✅ Symbol Found:`);
|
|
25
|
-
console.log(` File: ${result.file_path}`);
|
|
26
|
-
if (result.exact_symbol) {
|
|
27
|
-
console.log(` Symbol: ${result.exact_symbol.name} [${result.exact_symbol.kind}]`);
|
|
28
|
-
console.log(` Signature: ${result.exact_symbol.signature}`);
|
|
29
|
-
console.log(` Lines: ${result.exact_symbol.line_start} - ${result.exact_symbol.line_end}`);
|
|
30
|
-
}
|
|
31
|
-
if (result.sibling_methods && result.sibling_methods.length > 0) {
|
|
32
|
-
console.log(`\n Available Methods in Container:`);
|
|
33
|
-
console.log(` ${result.sibling_methods.join(", ")}`);
|
|
34
|
-
}
|
|
35
|
-
} else {
|
|
36
|
-
console.log(`\n❌ Diagnostic Result:`);
|
|
37
|
-
if (result.file_path) {
|
|
38
|
-
console.log(` File: ${result.file_path}`);
|
|
39
|
-
console.log(` Container: ${result.container_name}`);
|
|
40
|
-
}
|
|
41
|
-
console.log(` Message: ${result.message}`);
|
|
42
|
-
|
|
43
|
-
if (result.sibling_methods && result.sibling_methods.length > 0) {
|
|
44
|
-
console.log(`\n Available Methods:`);
|
|
45
|
-
console.log(` ${result.sibling_methods.join(", ")}`);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
if (result.suggestions.length > 0) {
|
|
49
|
-
console.log(`\n Suggestions (Ranked by Match):`);
|
|
50
|
-
for (const sug of result.suggestions) {
|
|
51
|
-
const matchPercent = Math.round(sug.similarity_score * 100);
|
|
52
|
-
console.log(
|
|
53
|
-
` • ${sug.name} (${matchPercent}% match) [line ${sug.line_start}] -> ${sug.signature}`
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
} finally {
|
|
59
|
-
db.close();
|
|
60
|
-
}
|
|
61
|
-
}
|
|
@@ -1,104 +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 type { DomainCatalogResponse } from "../../types/index.ts";
|
|
5
|
-
|
|
6
|
-
export interface QueryCommandOptions {
|
|
7
|
-
json?: boolean;
|
|
8
|
-
full?: boolean;
|
|
9
|
-
file?: string;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export async function handleQueryCommand(
|
|
13
|
-
domainName?: string,
|
|
14
|
-
archetype?: string,
|
|
15
|
-
jsonOutputOrOptions?: boolean | QueryCommandOptions,
|
|
16
|
-
extraOptions?: QueryCommandOptions
|
|
17
|
-
): Promise<void> {
|
|
18
|
-
const options: QueryCommandOptions =
|
|
19
|
-
typeof jsonOutputOrOptions === "boolean"
|
|
20
|
-
? { json: jsonOutputOrOptions, ...extraOptions }
|
|
21
|
-
: { ...jsonOutputOrOptions, ...extraOptions };
|
|
22
|
-
|
|
23
|
-
const jsonOutput = Boolean(options.json);
|
|
24
|
-
const detailLevel: "compact" | "full" = options.full ? "full" : "compact";
|
|
25
|
-
const fileFilter = options.file;
|
|
26
|
-
|
|
27
|
-
const config = ConfigLoader.load();
|
|
28
|
-
const db = new SeptumDatabase(config.settings.db_path);
|
|
29
|
-
const repo = new SeptumRepository(db.raw);
|
|
30
|
-
|
|
31
|
-
if (!domainName) {
|
|
32
|
-
const allDomains = repo.getAllDomains();
|
|
33
|
-
if (allDomains.length === 0) {
|
|
34
|
-
console.log("[Septum] No domains found in database. Run 'septum ingest' first.");
|
|
35
|
-
db.close();
|
|
36
|
-
return;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
console.log("=== Registered Domains in Septum ===");
|
|
40
|
-
for (const d of allDomains) {
|
|
41
|
-
console.log(`- ${d.name} (root: ${d.root_path})`);
|
|
42
|
-
}
|
|
43
|
-
db.close();
|
|
44
|
-
return;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const catalog: DomainCatalogResponse | null = (repo.getDomainCatalog as any)(
|
|
48
|
-
domainName,
|
|
49
|
-
archetype,
|
|
50
|
-
{ detailLevel, fileFilter }
|
|
51
|
-
);
|
|
52
|
-
if (!catalog) {
|
|
53
|
-
console.error(`[Septum Error] Domain '${domainName}' not found in database. Run 'septum ingest' first.`);
|
|
54
|
-
db.close();
|
|
55
|
-
process.exit(1);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
if (jsonOutput) {
|
|
59
|
-
console.log(JSON.stringify(catalog, null, 2));
|
|
60
|
-
} else {
|
|
61
|
-
console.log(`=================================================`);
|
|
62
|
-
console.log(` Domain: ${catalog.domain.toUpperCase()} (Root: ${catalog.root})`);
|
|
63
|
-
console.log(`=================================================`);
|
|
64
|
-
console.log(` Detail Level: ${catalog.detail_level ?? detailLevel}`);
|
|
65
|
-
console.log(` Allowed Dependencies: [${catalog.allowed_dependencies.join(", ")}]`);
|
|
66
|
-
console.log(` Forbidden Dependencies: [${catalog.forbidden_dependencies.join(", ")}]`);
|
|
67
|
-
console.log(` Total Files Cataloged: ${catalog.total_files ?? catalog.files.length} (Showing: ${catalog.showing_files ?? catalog.files.length})`);
|
|
68
|
-
console.log(`-------------------------------------------------`);
|
|
69
|
-
|
|
70
|
-
for (const file of catalog.files) {
|
|
71
|
-
const lineInfo = file.line_count ? `, ${file.line_count} lines` : "";
|
|
72
|
-
console.log(`\n File: ${file.path} [archetype: ${file.archetype}${lineInfo}]`);
|
|
73
|
-
if (file.connections) {
|
|
74
|
-
const importsStr = file.connections.imports_from.length > 0 ? file.connections.imports_from.join(", ") : "none";
|
|
75
|
-
console.log(` ↔ Connections: imports [${importsStr}] | imported by ${file.connections.imported_by_count} file(s)`);
|
|
76
|
-
}
|
|
77
|
-
for (const sym of file.symbols) {
|
|
78
|
-
const spanStr = sym.line_span ? ` (L${sym.line_span})` : "";
|
|
79
|
-
const countsStr =
|
|
80
|
-
sym.methods_count !== undefined
|
|
81
|
-
? ` [${sym.methods_count} methods${sym.properties_count ? `, ${sym.properties_count} props` : ""}]`
|
|
82
|
-
: "";
|
|
83
|
-
console.log(` • ${sym.kind.toUpperCase()} ${sym.name}${spanStr}${countsStr}`);
|
|
84
|
-
if (sym.methods && sym.methods.length > 0) {
|
|
85
|
-
for (const m of sym.methods) {
|
|
86
|
-
console.log(` - ${m}`);
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
if (sym.signatures && sym.signatures.length > 0) {
|
|
90
|
-
for (const s of sym.signatures) {
|
|
91
|
-
console.log(` - ${s}`);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
if (catalog.agent_hint) {
|
|
97
|
-
console.log(`\n 💡 ${catalog.agent_hint}`);
|
|
98
|
-
}
|
|
99
|
-
console.log(`\n=================================================`);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
db.close();
|
|
103
|
-
}
|
|
104
|
-
|