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
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { shouldIgnoreSourcePath } from "../resources/ignore.ts";
|
|
2
|
+
import { RESOURCE_LIMITS } from "../resources/limits.ts";
|
|
3
|
+
import { readSourceFile, SourceFileLimitError, collectSourceSkips, type SkippedSourceFile, type SourceReadFingerprint } from "../resources/source-files.ts";
|
|
4
|
+
import { existsSync, readdirSync, readFileSync, statSync, lstatSync, realpathSync } from "node:fs";
|
|
2
5
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
6
|
+
import { isPathContained, resolveWorkspaceRoot } from "../resolver/path-resolver.ts";
|
|
3
7
|
import type { ParsedFileAST } from "../../types/index.ts";
|
|
4
8
|
import type { ValidatedSeptumConfig } from "../config/schema.ts";
|
|
5
9
|
import type { SeptumRepository } from "../database/repository.ts";
|
|
@@ -34,7 +38,21 @@ export class IngestionPipeline {
|
|
|
34
38
|
}
|
|
35
39
|
}
|
|
36
40
|
|
|
37
|
-
public async run(config: ValidatedSeptumConfig, options: { verifyContent?: boolean } = {}): Promise<IngestionMetrics> {
|
|
41
|
+
public async run(config: ValidatedSeptumConfig, options: { verifyContent?: boolean; signal?: AbortSignal } = {}): Promise<IngestionMetrics> {
|
|
42
|
+
this.repo.setMeta("sync_state", "running");
|
|
43
|
+
try {
|
|
44
|
+
const result = await this.runSync(config, options);
|
|
45
|
+
this.repo.setMeta("sync_state", "complete");
|
|
46
|
+
this.repo.setMeta("sync_error", "");
|
|
47
|
+
return result;
|
|
48
|
+
} catch (error) {
|
|
49
|
+
this.repo.setMeta("sync_state", "error");
|
|
50
|
+
this.repo.setMeta("sync_error", String(error));
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private async runSync(config: ValidatedSeptumConfig, options: { verifyContent?: boolean; signal?: AbortSignal }): Promise<IngestionMetrics> {
|
|
38
56
|
this.ensureParserRevision();
|
|
39
57
|
const startTime = performance.now();
|
|
40
58
|
let filesScanned = 0;
|
|
@@ -43,9 +61,11 @@ export class IngestionPipeline {
|
|
|
43
61
|
let symbolsIndexed = 0;
|
|
44
62
|
let dependenciesIndexed = 0;
|
|
45
63
|
const unsupportedFiles: string[] = [];
|
|
64
|
+
const skippedFiles: SkippedSourceFile[] = [];
|
|
65
|
+
let removedFiles = 0;
|
|
46
66
|
|
|
47
67
|
let domainNames = Object.keys(config.domains);
|
|
48
|
-
const projectRoot = this.customWorkspaceRoot ||
|
|
68
|
+
const projectRoot = this.customWorkspaceRoot || resolveWorkspaceRoot();
|
|
49
69
|
|
|
50
70
|
// Auto-discovery mode if no domains explicitly declared in config
|
|
51
71
|
if (domainNames.length === 0) {
|
|
@@ -71,11 +91,26 @@ export class IngestionPipeline {
|
|
|
71
91
|
const domainCfg = config.domains[domainName];
|
|
72
92
|
const domainId = this.repo.upsertDomain(domainName, domainCfg);
|
|
73
93
|
const workspaceRoot = this.resolveWorkspaceRoot(config, domainCfg.root);
|
|
94
|
+
// SV-02: Enforce workspace containment during ingestion (reject traversal/symlink escape)
|
|
95
|
+
if (!isPathContained(domainCfg.root, workspaceRoot)) {
|
|
96
|
+
throw new Error(`Domain root '${domainCfg.root}' for domain '${domainName}' escapes workspace boundary '${workspaceRoot}'.`);
|
|
97
|
+
}
|
|
74
98
|
const absDomainRoot = resolve(workspaceRoot, domainCfg.root);
|
|
75
99
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
100
|
+
try {
|
|
101
|
+
const rootStat = statSync(absDomainRoot);
|
|
102
|
+
if (!rootStat.isDirectory()) {
|
|
103
|
+
throw new Error(`Domain root '${domainCfg.root}' is not a directory.`);
|
|
104
|
+
}
|
|
105
|
+
} catch (err: any) {
|
|
106
|
+
if (err?.code === "ENOENT") {
|
|
107
|
+
removedFiles += this.repo.getAllFiles().filter(file => file.domain_id === domainId).length;
|
|
108
|
+
this.repo.files.deleteFilesNotInPaths(domainId, []);
|
|
109
|
+
if (removedFiles) this.repo.setMeta("slices_dirty", "1");
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
// SV-01: Permission or I/O failure (EACCES, EPERM, EIO). Do NOT prune, propagate error!
|
|
113
|
+
throw new Error(`Failed to access domain root '${domainCfg.root}' for domain '${domainName}': ${err.message || err}`);
|
|
79
114
|
}
|
|
80
115
|
|
|
81
116
|
const childDomainRoots = domainNames
|
|
@@ -90,7 +125,8 @@ export class IngestionPipeline {
|
|
|
90
125
|
absDomainRoot,
|
|
91
126
|
workspaceRoot,
|
|
92
127
|
config.settings.ignore_patterns,
|
|
93
|
-
childDomainRoots
|
|
128
|
+
childDomainRoots,
|
|
129
|
+
options.signal
|
|
94
130
|
);
|
|
95
131
|
const activeRelPaths = new Set<string>();
|
|
96
132
|
|
|
@@ -111,8 +147,43 @@ export class IngestionPipeline {
|
|
|
111
147
|
ast: ParsedFileAST;
|
|
112
148
|
}> = [];
|
|
113
149
|
|
|
150
|
+
let batchBytes = 0;
|
|
151
|
+
const commitBatch = () => {
|
|
152
|
+
this.repo.runInTransaction(() => {
|
|
153
|
+
for (const meta of pendingMetadataUpdates) {
|
|
154
|
+
this.repo.updateFileMetadataOnly(meta.id, meta.mtimeMs, meta.sizeBytes);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (pendingUpdates.length) this.repo.setMeta("slices_dirty", "1");
|
|
158
|
+
for (const item of pendingUpdates) {
|
|
159
|
+
const fileId = this.repo.upsertFile(
|
|
160
|
+
domainId,
|
|
161
|
+
item.relPath,
|
|
162
|
+
item.archetype,
|
|
163
|
+
item.contentHash,
|
|
164
|
+
item.lineCount,
|
|
165
|
+
item.mtimeMs,
|
|
166
|
+
item.sizeBytes
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
this.repo.replaceFileSymbols(fileId, item.ast.symbols);
|
|
170
|
+
this.repo.replaceFileDependencies(fileId, item.ast.dependencies);
|
|
171
|
+
|
|
172
|
+
symbolsIndexed += item.ast.symbols.length;
|
|
173
|
+
dependenciesIndexed += item.ast.dependencies.length;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
});
|
|
177
|
+
pendingUpdates.length = 0;
|
|
178
|
+
pendingMetadataUpdates.length = 0;
|
|
179
|
+
batchBytes = 0;
|
|
180
|
+
};
|
|
181
|
+
|
|
114
182
|
for (const { fullPath, relPath } of collectedFiles) {
|
|
183
|
+
options.signal?.throwIfAborted();
|
|
184
|
+
if (pendingMetadataUpdates.length >= RESOURCE_LIMITS.batchFiles) commitBatch();
|
|
115
185
|
filesScanned++;
|
|
186
|
+
if (filesScanned % RESOURCE_LIMITS.batchFiles === 0) { await new Promise<void>(done => setTimeout(done, 0)); options.signal?.throwIfAborted(); }
|
|
116
187
|
activeRelPaths.add(relPath);
|
|
117
188
|
|
|
118
189
|
let stat;
|
|
@@ -139,7 +210,24 @@ export class IngestionPipeline {
|
|
|
139
210
|
}
|
|
140
211
|
|
|
141
212
|
// Tier 2: Content Hash verification
|
|
142
|
-
|
|
213
|
+
if (sizeBytes > RESOURCE_LIMITS.sourceBytes) {
|
|
214
|
+
skippedFiles.push({path: relPath, reason: "file_too_large"});
|
|
215
|
+
if (existingFile) removedFiles++;
|
|
216
|
+
this.repo.deleteFileByPath(relPath);
|
|
217
|
+
this.repo.setMeta("slices_dirty", "1");
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (pendingUpdates.length >= RESOURCE_LIMITS.batchFiles || (batchBytes + sizeBytes > RESOURCE_LIMITS.batchBytes && pendingUpdates.length > 0)) { commitBatch(); await new Promise<void>(done => setTimeout(done, 0)); options.signal?.throwIfAborted(); }
|
|
221
|
+
let content: string;
|
|
222
|
+
try { content = readSourceFile(fullPath); }
|
|
223
|
+
catch (error) {
|
|
224
|
+
if (!(error instanceof SourceFileLimitError)) throw error;
|
|
225
|
+
skippedFiles.push({path: relPath, reason: "file_too_large"});
|
|
226
|
+
if (existingFile) removedFiles++;
|
|
227
|
+
this.repo.deleteFileByPath(relPath);
|
|
228
|
+
this.repo.setMeta("slices_dirty", "1");
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
143
231
|
const currentHash = computeContentHash(content);
|
|
144
232
|
|
|
145
233
|
if (
|
|
@@ -159,11 +247,13 @@ export class IngestionPipeline {
|
|
|
159
247
|
if (ast.status === "unsupported") {
|
|
160
248
|
unsupportedFiles.push(relPath);
|
|
161
249
|
this.repo.deleteFileByPath(relPath);
|
|
250
|
+
this.repo.setMeta("slices_dirty", "1");
|
|
162
251
|
continue;
|
|
163
252
|
}
|
|
164
253
|
if (ast.status === "degraded") throw new Error(`Cannot reliably index '${relPath}': parser is degraded.`);
|
|
165
254
|
|
|
166
255
|
filesUpdated++;
|
|
256
|
+
batchBytes += sizeBytes;
|
|
167
257
|
pendingUpdates.push({
|
|
168
258
|
fullPath,
|
|
169
259
|
relPath,
|
|
@@ -176,33 +266,13 @@ export class IngestionPipeline {
|
|
|
176
266
|
});
|
|
177
267
|
}
|
|
178
268
|
|
|
179
|
-
|
|
269
|
+
commitBatch();
|
|
180
270
|
this.repo.runInTransaction(() => {
|
|
181
|
-
for (const meta of pendingMetadataUpdates) {
|
|
182
|
-
this.repo.updateFileMetadataOnly(meta.id, meta.mtimeMs, meta.sizeBytes);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
for (const item of pendingUpdates) {
|
|
186
|
-
const fileId = this.repo.upsertFile(
|
|
187
|
-
domainId,
|
|
188
|
-
item.relPath,
|
|
189
|
-
item.archetype,
|
|
190
|
-
item.contentHash,
|
|
191
|
-
item.lineCount,
|
|
192
|
-
item.mtimeMs,
|
|
193
|
-
item.sizeBytes
|
|
194
|
-
);
|
|
195
|
-
|
|
196
|
-
this.repo.replaceFileSymbols(fileId, item.ast.symbols);
|
|
197
|
-
this.repo.replaceFileDependencies(fileId, item.ast.dependencies);
|
|
198
|
-
|
|
199
|
-
symbolsIndexed += item.ast.symbols.length;
|
|
200
|
-
dependenciesIndexed += item.ast.dependencies.length;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
271
|
// State Reconciliation: Prune deleted files from DB using pre-loaded existingFilesMap
|
|
204
272
|
for (const [existingPath, existingRecord] of existingFilesMap.entries()) {
|
|
205
273
|
if (existingRecord.domain_id === domainId && !activeRelPaths.has(existingPath)) {
|
|
274
|
+
removedFiles++;
|
|
275
|
+
this.repo.setMeta("slices_dirty", "1");
|
|
206
276
|
this.repo.deleteFileByPath(existingPath);
|
|
207
277
|
existingFilesMap.delete(existingPath);
|
|
208
278
|
}
|
|
@@ -210,37 +280,78 @@ export class IngestionPipeline {
|
|
|
210
280
|
});
|
|
211
281
|
}
|
|
212
282
|
|
|
213
|
-
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
)
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
283
|
+
options.signal?.throwIfAborted();
|
|
284
|
+
const topologyInputs = ["package.json", "composer.json", "nest-cli.json", "go.mod", "artisan", "tsconfig.json"]
|
|
285
|
+
.map(file => { const target = join(projectRoot, file); return existsSync(target) ? readSourceFile(target) : ""; });
|
|
286
|
+
const semanticRevision = computeContentHash(JSON.stringify([config.domains, topologyInputs]));
|
|
287
|
+
// Membership covers newly added semantic files outside registered domains.
|
|
288
|
+
const inventory = this.collectFiles(projectRoot, projectRoot, config.settings.ignore_patterns, [], options.signal).map(({fullPath, relPath}) => {
|
|
289
|
+
const stat = statSync(fullPath);
|
|
290
|
+
const fingerprint = `${stat.size}:${stat.mtimeMs}`;
|
|
291
|
+
return `${relPath}:${fingerprint}`;
|
|
292
|
+
}).sort();
|
|
293
|
+
const inventoryRevision = computeContentHash(JSON.stringify(inventory));
|
|
294
|
+
const priorInputs: SourceReadFingerprint[] = JSON.parse(this.repo.getMeta("semantic_inputs") || "[]");
|
|
295
|
+
let semanticInputChanged = false;
|
|
296
|
+
for (let index = 0; index < priorInputs.length; index++) {
|
|
297
|
+
if (index % RESOURCE_LIMITS.batchFiles === 0) { await new Promise<void>(done => setTimeout(done, 0)); options.signal?.throwIfAborted(); }
|
|
298
|
+
const input = priorInputs[index];
|
|
299
|
+
const file = resolve(projectRoot, input.path);
|
|
300
|
+
if (!existsSync(file)) { semanticInputChanged = true; break; }
|
|
301
|
+
const stat = statSync(file);
|
|
302
|
+
if (stat.size !== input.size || stat.mtimeMs !== input.mtime) { semanticInputChanged = true; break; }
|
|
303
|
+
if (!options.verifyContent) continue;
|
|
304
|
+
try { semanticInputChanged = computeContentHash(readSourceFile(file)) !== input.hash; }
|
|
305
|
+
catch (error) { if (error instanceof SourceFileLimitError) semanticInputChanged = input.hash !== "file_too_large"; else throw error; }
|
|
306
|
+
if (semanticInputChanged) break;
|
|
307
|
+
}
|
|
308
|
+
const extractorRevision = "1"; // Bump when extractor or inferred-chain semantics change.
|
|
309
|
+
if (this.repo.getMeta("semantic_extractor_revision") !== extractorRevision || this.repo.getMeta("semantic_inventory") !== inventoryRevision || semanticInputChanged || filesUpdated > 0 || removedFiles > 0 || this.repo.getMeta("slices_dirty") === "1" || this.repo.getMeta("semantic_revision") !== semanticRevision) {
|
|
310
|
+
const extracted = await collectSourceSkips(projectRoot, async () => {
|
|
311
|
+
const detectedTopology = TopologyDetector.detect(projectRoot);
|
|
312
|
+
const semanticSlices = await SemanticSliceExtractorRegistry.extractAllSlices(projectRoot, detectedTopology.framework);
|
|
313
|
+
return semanticSlices.length > 0 ? semanticSlices : new CallGraphTracer(this.repo, projectRoot).traceAllSlices();
|
|
314
|
+
});
|
|
315
|
+
skippedFiles.push(...extracted.skipped);
|
|
316
|
+
this.repo.runInTransaction(() => {
|
|
317
|
+
this.repo.clearVerticalSlices();
|
|
318
|
+
for (const slice of extracted.value) this.repo.upsertVerticalSlice(slice);
|
|
319
|
+
this.repo.setMeta("slices_dirty", "");
|
|
320
|
+
this.repo.setMeta("semantic_revision", semanticRevision);
|
|
321
|
+
this.repo.setMeta("semantic_extractor_revision", extractorRevision);
|
|
322
|
+
this.repo.setMeta("semantic_inventory", inventoryRevision);
|
|
323
|
+
this.repo.setMeta("semantic_inputs", JSON.stringify(extracted.inputs));
|
|
324
|
+
this.repo.setMeta("semantic_skipped_files", JSON.stringify(extracted.skipped));
|
|
325
|
+
});
|
|
326
|
+
}
|
|
231
327
|
|
|
328
|
+
skippedFiles.push(...JSON.parse(this.repo.getMeta("semantic_skipped_files") || "[]"));
|
|
232
329
|
const durationMs = Math.round(performance.now() - startTime);
|
|
233
330
|
|
|
331
|
+
// Identify uncovered eligible source files outside registered domains
|
|
332
|
+
const indexedPaths = new Set(this.repo.getAllFiles().map((file) => file.path));
|
|
333
|
+
const allWorkspaceFiles = this.collectFiles(projectRoot, projectRoot, config.settings.ignore_patterns, [], options.signal);
|
|
334
|
+
const unsupportedSet = new Set(unsupportedFiles);
|
|
335
|
+
const skippedSet = new Set(skippedFiles.map((file) => file.path));
|
|
336
|
+
const uncoveredFiles = allWorkspaceFiles
|
|
337
|
+
.map((file) => file.relPath)
|
|
338
|
+
.filter((rel) => !indexedPaths.has(rel) && !unsupportedSet.has(rel) && !skippedSet.has(rel));
|
|
339
|
+
|
|
234
340
|
// Update SSOT repo_meta watermark
|
|
235
341
|
this.repo.runInTransaction(() => {
|
|
236
342
|
this.repo.setMeta("last_sync", new Date().toISOString());
|
|
343
|
+
this.repo.setMeta("sync_duration_ms", String(durationMs));
|
|
237
344
|
this.repo.setMeta("files_scanned", String(filesScanned));
|
|
238
345
|
this.repo.setMeta("files_updated", String(filesUpdated));
|
|
239
346
|
this.repo.setMeta("symbols_indexed", String(symbolsIndexed));
|
|
240
347
|
this.repo.setMeta("dependencies_indexed", String(dependenciesIndexed));
|
|
241
348
|
this.repo.setMeta("unsupported_files", JSON.stringify(unsupportedFiles));
|
|
349
|
+
this.repo.setMeta("skipped_files", JSON.stringify([...new Map(skippedFiles.map(file => [file.path, file])).values()]));
|
|
350
|
+
this.repo.setMeta("uncovered_files", JSON.stringify(uncoveredFiles));
|
|
242
351
|
});
|
|
243
352
|
|
|
353
|
+
this.repo.checkpoint();
|
|
354
|
+
|
|
244
355
|
return {
|
|
245
356
|
domains_processed: domainNames.length,
|
|
246
357
|
files_scanned: filesScanned,
|
|
@@ -429,23 +540,43 @@ export class IngestionPipeline {
|
|
|
429
540
|
dir: string,
|
|
430
541
|
workspaceRoot: string,
|
|
431
542
|
ignorePatterns: string[],
|
|
432
|
-
childDomainRoots: string[] = []
|
|
543
|
+
childDomainRoots: string[] = [],
|
|
544
|
+
signal?: AbortSignal
|
|
433
545
|
): Array<{ fullPath: string; relPath: string }> {
|
|
434
546
|
const results: Array<{ fullPath: string; relPath: string }> = [];
|
|
435
547
|
|
|
548
|
+
// SV-02: Check if initial dir itself is an escaping symlink
|
|
549
|
+
try {
|
|
550
|
+
const dirLstat = lstatSync(dir);
|
|
551
|
+
if (dirLstat.isSymbolicLink()) {
|
|
552
|
+
const real = realpathSync(dir);
|
|
553
|
+
if (!isPathContained(real, workspaceRoot)) {
|
|
554
|
+
throw new Error(`Directory '${dir}' is a symlink escaping workspace boundary '${workspaceRoot}'.`);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
} catch (err: any) {
|
|
558
|
+
if (err?.code !== "ENOENT") throw err;
|
|
559
|
+
}
|
|
560
|
+
|
|
436
561
|
const walk = (currentDir: string) => {
|
|
562
|
+
signal?.throwIfAborted();
|
|
437
563
|
let entries: string[] = [];
|
|
438
564
|
try {
|
|
439
565
|
entries = readdirSync(currentDir);
|
|
440
|
-
} catch {
|
|
441
|
-
|
|
566
|
+
} catch (err: any) {
|
|
567
|
+
// SV-01: Do not swallow permission/IO errors; propagate so pipeline reports incomplete/error
|
|
568
|
+
if (err?.code === "ENOENT") {
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
throw new Error(`Failed to read directory '${currentDir}': ${err.message || err}`);
|
|
442
572
|
}
|
|
443
573
|
|
|
444
574
|
for (const entry of entries) {
|
|
575
|
+
signal?.throwIfAborted();
|
|
445
576
|
const fullPath = join(currentDir, entry);
|
|
446
577
|
const relPath = relative(workspaceRoot, fullPath).replace(/\\/g, "/");
|
|
447
578
|
|
|
448
|
-
if (
|
|
579
|
+
if (shouldIgnoreSourcePath(relPath, ignorePatterns)) {
|
|
449
580
|
continue;
|
|
450
581
|
}
|
|
451
582
|
|
|
@@ -454,14 +585,35 @@ export class IngestionPipeline {
|
|
|
454
585
|
}
|
|
455
586
|
|
|
456
587
|
try {
|
|
457
|
-
const stat =
|
|
588
|
+
const stat = lstatSync(fullPath);
|
|
589
|
+
if (stat.isSymbolicLink()) {
|
|
590
|
+
try {
|
|
591
|
+
const real = realpathSync(fullPath);
|
|
592
|
+
if (!isPathContained(real, workspaceRoot)) {
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
const targetStat = statSync(real);
|
|
596
|
+
if (targetStat.isDirectory()) {
|
|
597
|
+
continue;
|
|
598
|
+
} else if (targetStat.isFile() && this.isSourceFile(real)) {
|
|
599
|
+
results.push({ fullPath, relPath });
|
|
600
|
+
}
|
|
601
|
+
} catch {
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
|
|
458
607
|
if (stat.isDirectory()) {
|
|
459
608
|
walk(fullPath);
|
|
460
609
|
} else if (stat.isFile() && this.isSourceFile(fullPath)) {
|
|
461
610
|
results.push({ fullPath, relPath });
|
|
462
611
|
}
|
|
463
|
-
} catch {
|
|
464
|
-
|
|
612
|
+
} catch (err: any) {
|
|
613
|
+
if (err?.code === "ENOENT") {
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
throw new Error(`Failed to stat path '${fullPath}': ${err.message || err}`);
|
|
465
617
|
}
|
|
466
618
|
}
|
|
467
619
|
};
|
|
@@ -481,47 +633,14 @@ export class IngestionPipeline {
|
|
|
481
633
|
if (config.settings.db_path && config.settings.db_path !== ":memory:") {
|
|
482
634
|
const dbDir = dirname(resolve(config.settings.db_path));
|
|
483
635
|
const candidate = basename(dbDir) === ".septum" ? dirname(dbDir) : dbDir;
|
|
484
|
-
const absDomainRoot = resolve(domainRoot);
|
|
636
|
+
const absDomainRoot = isAbsolute(domainRoot) ? domainRoot : resolve(candidate, domainRoot);
|
|
485
637
|
if (absDomainRoot.startsWith(candidate)) {
|
|
486
638
|
return candidate;
|
|
487
639
|
}
|
|
488
640
|
}
|
|
489
|
-
return
|
|
641
|
+
return resolveWorkspaceRoot();
|
|
490
642
|
}
|
|
491
643
|
|
|
492
|
-
private shouldIgnore(filePath: string, ignorePatterns: string[]): boolean {
|
|
493
|
-
const normPath = filePath.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
494
|
-
for (const pattern of ignorePatterns) {
|
|
495
|
-
const normPattern = pattern.replace(/\\/g, "/").replace(/^\.\//, "").trim();
|
|
496
|
-
if (!normPattern) continue;
|
|
497
|
-
|
|
498
|
-
if (normPattern === "*" || normPattern === "**") {
|
|
499
|
-
return true;
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
if (normPath === normPattern) return true;
|
|
503
|
-
|
|
504
|
-
if (normPattern.endsWith("/**")) {
|
|
505
|
-
const prefix = normPattern.slice(0, -3);
|
|
506
|
-
if (normPath === prefix || normPath.startsWith(prefix + "/")) return true;
|
|
507
|
-
} else if (normPattern.endsWith("/*")) {
|
|
508
|
-
const prefix = normPattern.slice(0, -2);
|
|
509
|
-
if (normPath === prefix || normPath.startsWith(prefix + "/")) return true;
|
|
510
|
-
} else if (normPath.startsWith(normPattern + "/")) {
|
|
511
|
-
return true;
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
if (normPattern.startsWith("*.") && normPath.endsWith(normPattern.slice(1))) {
|
|
515
|
-
return true;
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
const segments = normPath.split("/");
|
|
519
|
-
if (segments.includes(normPattern)) {
|
|
520
|
-
return true;
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
return false;
|
|
524
|
-
}
|
|
525
644
|
|
|
526
645
|
private isSourceFile(path: string): boolean {
|
|
527
646
|
return ASTParserEngine.isSourceFile(path);
|
|
@@ -559,10 +678,10 @@ export class IngestionPipeline {
|
|
|
559
678
|
const mtimeMs = stat.mtimeMs;
|
|
560
679
|
const sizeBytes = stat.size;
|
|
561
680
|
|
|
562
|
-
const projectRoot = this.customWorkspaceRoot ||
|
|
681
|
+
const projectRoot = this.customWorkspaceRoot || resolveWorkspaceRoot();
|
|
563
682
|
const relPath = relative(projectRoot, fullPath).replace(/\\/g, "/");
|
|
564
683
|
|
|
565
|
-
if (
|
|
684
|
+
if (shouldIgnoreSourcePath(relPath, config.settings.ignore_patterns ?? [])) {
|
|
566
685
|
return false;
|
|
567
686
|
}
|
|
568
687
|
|
|
@@ -576,7 +695,19 @@ export class IngestionPipeline {
|
|
|
576
695
|
const domainId = this.repo.upsertDomain(targetDomainName, targetDomainCfg);
|
|
577
696
|
const existingFile = this.repo.getFileByPath(relPath);
|
|
578
697
|
|
|
579
|
-
|
|
698
|
+
let content: string;
|
|
699
|
+
try { content = readSourceFile(fullPath); }
|
|
700
|
+
catch (error) {
|
|
701
|
+
if (!(error instanceof SourceFileLimitError)) throw error;
|
|
702
|
+
this.repo.runInTransaction(() => {
|
|
703
|
+
this.repo.deleteFileByPath(relPath);
|
|
704
|
+
this.repo.clearVerticalSlices();
|
|
705
|
+
this.repo.setMeta("slices_dirty", "1");
|
|
706
|
+
const skipped: SkippedSourceFile[] = JSON.parse(this.repo.getMeta("skipped_files") || "[]");
|
|
707
|
+
this.repo.setMeta("skipped_files", JSON.stringify([...skipped.filter(file => file.path !== relPath), {path: relPath, reason: "file_too_large"}]));
|
|
708
|
+
});
|
|
709
|
+
return false;
|
|
710
|
+
}
|
|
580
711
|
const currentHash = computeContentHash(content);
|
|
581
712
|
|
|
582
713
|
// Tier 2: Hash verification
|
|
@@ -610,6 +741,9 @@ export class IngestionPipeline {
|
|
|
610
741
|
this.repo.replaceFileSymbols(fileId, ast.symbols);
|
|
611
742
|
this.repo.replaceFileDependencies(fileId, ast.dependencies);
|
|
612
743
|
this.repo.clearVerticalSlices();
|
|
744
|
+
this.repo.setMeta("slices_dirty", "1");
|
|
745
|
+
const skipped: SkippedSourceFile[] = JSON.parse(this.repo.getMeta("skipped_files") || "[]");
|
|
746
|
+
this.repo.setMeta("skipped_files", JSON.stringify(skipped.filter(file => file.path !== relPath)));
|
|
613
747
|
const unsupported = JSON.parse(this.repo.getMeta("unsupported_files") || "[]") as string[];
|
|
614
748
|
this.repo.setMeta("unsupported_files", JSON.stringify(unsupported.filter(path => path !== relPath)));
|
|
615
749
|
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readOptionalSourceFile } from "../../resources/source-files.ts";
|
|
1
2
|
import * as fs from "node:fs";
|
|
2
3
|
import * as path from "node:path";
|
|
3
4
|
import type { ExecutionChainNode, VerticalSliceRecord } from "../../../types/index.ts";
|
|
@@ -62,7 +63,7 @@ export class LaravelSemanticExtractor implements SemanticSliceExtractor {
|
|
|
62
63
|
const parsedRoutes: ParsedRoute[] = [];
|
|
63
64
|
|
|
64
65
|
for (const routeFile of routeFiles) {
|
|
65
|
-
const content =
|
|
66
|
+
const content = readOptionalSourceFile(routeFile, "utf-8");
|
|
66
67
|
parsedRoutes.push(...this.parseRoutesContent(content));
|
|
67
68
|
}
|
|
68
69
|
|
|
@@ -97,7 +98,7 @@ export class LaravelSemanticExtractor implements SemanticSliceExtractor {
|
|
|
97
98
|
|
|
98
99
|
let behavioralInfo: import("../behavioral-analyzer.ts").MethodBehavioralSummary | null = null;
|
|
99
100
|
if (controllerInfo.filePath && fs.existsSync(controllerInfo.filePath)) {
|
|
100
|
-
const controllerCode =
|
|
101
|
+
const controllerCode = readOptionalSourceFile(controllerInfo.filePath, "utf-8");
|
|
101
102
|
const actionBody = this.extractActionBody(controllerCode, route.actionName);
|
|
102
103
|
behavioralInfo = BehavioralAnalyzer.analyzeMethod(actionBody);
|
|
103
104
|
|
|
@@ -105,7 +106,7 @@ export class LaravelSemanticExtractor implements SemanticSliceExtractor {
|
|
|
105
106
|
const requestClassName = this.detectFormRequestClass(controllerCode, route.actionName);
|
|
106
107
|
if (requestClassName) {
|
|
107
108
|
const reqFile = this.findClassFile("Requests", requestClassName);
|
|
108
|
-
const rules = reqFile && fs.existsSync(reqFile) ? this.extractValidationRules(
|
|
109
|
+
const rules = reqFile && fs.existsSync(reqFile) ? this.extractValidationRules(readOptionalSourceFile(reqFile, "utf-8")) : {};
|
|
109
110
|
formRequestInfo = {
|
|
110
111
|
className: requestClassName,
|
|
111
112
|
filePath: reqFile ? path.relative(this.projectRoot, reqFile) : undefined,
|
|
@@ -117,7 +118,7 @@ export class LaravelSemanticExtractor implements SemanticSliceExtractor {
|
|
|
117
118
|
const modelClassName = this.detectModelClass(controllerCode, route.actionName, actionBody);
|
|
118
119
|
if (modelClassName) {
|
|
119
120
|
const modFile = this.findClassFile("Models", modelClassName);
|
|
120
|
-
const modMetadata = modFile && fs.existsSync(modFile) ? this.extractModelMetadata(
|
|
121
|
+
const modMetadata = modFile && fs.existsSync(modFile) ? this.extractModelMetadata(readOptionalSourceFile(modFile, "utf-8")) : { fillable: [], casts: {} };
|
|
121
122
|
modelInfo = {
|
|
122
123
|
className: modelClassName,
|
|
123
124
|
filePath: modFile ? path.relative(this.projectRoot, modFile) : undefined,
|
|
@@ -509,7 +510,7 @@ export class LaravelSemanticExtractor implements SemanticSliceExtractor {
|
|
|
509
510
|
|
|
510
511
|
for (const c of candidates) {
|
|
511
512
|
if (fs.existsSync(c)) {
|
|
512
|
-
const lines =
|
|
513
|
+
const lines = readOptionalSourceFile(c, "utf-8").split("\n");
|
|
513
514
|
let targetLine = 1;
|
|
514
515
|
for (let i = 0; i < lines.length; i++) {
|
|
515
516
|
if (new RegExp(`function\\s+${actionName}\\s*\\(`, "i").test(lines[i])) {
|
|
@@ -814,7 +815,7 @@ export class LaravelSemanticExtractor implements SemanticSliceExtractor {
|
|
|
814
815
|
|
|
815
816
|
for (const file of jobFiles) {
|
|
816
817
|
const fullPath = path.join(jobsDir, file);
|
|
817
|
-
const content =
|
|
818
|
+
const content = readOptionalSourceFile(fullPath, "utf-8");
|
|
818
819
|
const classMatch = content.match(/class\s+([A-Za-z0-9_]+)/);
|
|
819
820
|
if (!classMatch) continue;
|
|
820
821
|
|
|
@@ -890,7 +891,7 @@ export class LaravelSemanticExtractor implements SemanticSliceExtractor {
|
|
|
890
891
|
|
|
891
892
|
for (const file of files) {
|
|
892
893
|
const fullPath = path.join(commandsDir, file);
|
|
893
|
-
const content =
|
|
894
|
+
const content = readOptionalSourceFile(fullPath, "utf-8");
|
|
894
895
|
const classMatch = content.match(/class\s+([A-Za-z0-9_]+)/);
|
|
895
896
|
if (!classMatch) continue;
|
|
896
897
|
|
|
@@ -969,7 +970,7 @@ export class LaravelSemanticExtractor implements SemanticSliceExtractor {
|
|
|
969
970
|
|
|
970
971
|
for (const file of files) {
|
|
971
972
|
const fullPath = path.join(listenersDir, file);
|
|
972
|
-
const content =
|
|
973
|
+
const content = readOptionalSourceFile(fullPath, "utf-8");
|
|
973
974
|
const classMatch = content.match(/class\s+([A-Za-z0-9_]+)/);
|
|
974
975
|
if (!classMatch) continue;
|
|
975
976
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { readOptionalSourceFile } from "../../resources/source-files.ts";
|
|
1
2
|
import * as fs from "node:fs";
|
|
2
3
|
import * as path from "node:path";
|
|
4
|
+
import { blankCommentsAndStrings } from "../boundary-tracker.ts";
|
|
3
5
|
import type { SemanticSliceExtractor, VerticalSliceCandidate } from "./semantic-extractor.interface.ts";
|
|
4
6
|
|
|
5
7
|
export class McpCliSemanticExtractor implements SemanticSliceExtractor {
|
|
@@ -18,7 +20,7 @@ export class McpCliSemanticExtractor implements SemanticSliceExtractor {
|
|
|
18
20
|
const pkgPath = path.join(projectRoot, "package.json");
|
|
19
21
|
if (fs.existsSync(pkgPath)) {
|
|
20
22
|
try {
|
|
21
|
-
const pkg = JSON.parse(
|
|
23
|
+
const pkg = JSON.parse(readOptionalSourceFile(pkgPath, "utf-8"));
|
|
22
24
|
if (pkg.bin || pkg.dependencies?.["@modelcontextprotocol/sdk"]) {
|
|
23
25
|
return true;
|
|
24
26
|
}
|
|
@@ -44,11 +46,13 @@ export class McpCliSemanticExtractor implements SemanticSliceExtractor {
|
|
|
44
46
|
for (const file of toolFiles) {
|
|
45
47
|
const fullPath = path.join(mcpToolsDir, file);
|
|
46
48
|
const relPath = path.relative(projectRoot, fullPath).replace(/\\/g, "/");
|
|
47
|
-
const content =
|
|
49
|
+
const content = readOptionalSourceFile(fullPath, "utf-8");
|
|
48
50
|
const baseName = path.basename(file, path.extname(file));
|
|
49
51
|
const snakeBase = baseName.replace(/-/g, "_");
|
|
50
|
-
|
|
51
|
-
|
|
52
|
+
|
|
53
|
+
// MCP-07: Only create ingress slices for whitelisted public MCP tools
|
|
54
|
+
const toolIdentifier = registeredTools.get(snakeBase) || registeredTools.get(`${toolPrefix}_${snakeBase}`);
|
|
55
|
+
if (!toolIdentifier) continue;
|
|
52
56
|
|
|
53
57
|
// Detect handler function (both sync and async) and its 1-indexed line number
|
|
54
58
|
const handlerMatch = /export\s+(?:async\s+)?function\s+([a-zA-Z0-9_]+)/.exec(content);
|
|
@@ -143,7 +147,7 @@ export class McpCliSemanticExtractor implements SemanticSliceExtractor {
|
|
|
143
147
|
for (const file of commandFiles) {
|
|
144
148
|
const fullPath = path.join(cliCommandsDir, file);
|
|
145
149
|
const relPath = path.relative(projectRoot, fullPath).replace(/\\/g, "/");
|
|
146
|
-
const content =
|
|
150
|
+
const content = readOptionalSourceFile(fullPath, "utf-8");
|
|
147
151
|
const commandName = path.basename(file, path.extname(file));
|
|
148
152
|
|
|
149
153
|
const handlerMatch = /export\s+(?:async\s+)?function\s+([a-zA-Z0-9_]+)/.exec(content);
|
|
@@ -235,7 +239,7 @@ export class McpCliSemanticExtractor implements SemanticSliceExtractor {
|
|
|
235
239
|
const pkgPath = path.join(projectRoot, "package.json");
|
|
236
240
|
if (fs.existsSync(pkgPath)) {
|
|
237
241
|
try {
|
|
238
|
-
const pkg = JSON.parse(
|
|
242
|
+
const pkg = JSON.parse(readOptionalSourceFile(pkgPath, "utf-8"));
|
|
239
243
|
const rawName = typeof pkg.name === "string" ? pkg.name.replace(/^@[^/]+\//, "") : "mcp";
|
|
240
244
|
const toolPrefix = rawName.replace(/[^a-zA-Z0-9_]/g, "_").toLowerCase() || "mcp";
|
|
241
245
|
let cliBinName = rawName;
|
|
@@ -257,17 +261,53 @@ export class McpCliSemanticExtractor implements SemanticSliceExtractor {
|
|
|
257
261
|
if (!fs.existsSync(serverPath)) return map;
|
|
258
262
|
|
|
259
263
|
try {
|
|
260
|
-
const content =
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
const
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
264
|
+
const content = readOptionalSourceFile(serverPath, "utf-8");
|
|
265
|
+
const masked = blankCommentsAndStrings(content);
|
|
266
|
+
const scan = (start: number, namesInArray: boolean) => {
|
|
267
|
+
let depth = 0;
|
|
268
|
+
const names: string[] = [];
|
|
269
|
+
const property = /\b([A-Za-z_]\w*)\s*:\s*(['"])?/y;
|
|
270
|
+
for (let i = start; i < masked.length; i++) {
|
|
271
|
+
const ch = masked[i];
|
|
272
|
+
if ("[{(".includes(ch)) depth++;
|
|
273
|
+
else if ("]})".includes(ch)) {
|
|
274
|
+
if (--depth === 0) break;
|
|
275
|
+
}
|
|
276
|
+
if (depth !== (namesInArray ? 2 : 1) || (i > 0 && /\w/.test(masked[i - 1]))) continue;
|
|
277
|
+
property.lastIndex = i;
|
|
278
|
+
const match = property.exec(masked);
|
|
279
|
+
if (!match) continue;
|
|
280
|
+
if (!namesInArray) names.push(match[1]);
|
|
281
|
+
else if (match[1] === "name" && match[2]) {
|
|
282
|
+
const quoteStart = i + match[0].lastIndexOf(match[2]);
|
|
283
|
+
const quoteEnd = masked.indexOf(match[2], quoteStart + 1);
|
|
284
|
+
const name = content.slice(quoteStart + 1, quoteEnd);
|
|
285
|
+
if (/^[A-Za-z0-9_-]+$/.test(name)) names.push(name);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
// Unbalanced declarations are not evidence of public registration.
|
|
289
|
+
if (depth !== 0) return;
|
|
290
|
+
for (const fullToolName of names) {
|
|
291
|
+
const normalized = fullToolName.replace(/-/g, "_");
|
|
292
|
+
map.set(normalized, fullToolName);
|
|
293
|
+
const withoutPrefix = normalized.replace(/^[a-z0-9]+_/, "");
|
|
294
|
+
if (withoutPrefix) map.set(withoutPrefix, fullToolName);
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
const listCall = /setRequestHandler\s*\(\s*ListToolsRequestSchema\b/.exec(masked);
|
|
298
|
+
if (listCall) {
|
|
299
|
+
const callStart = masked.indexOf("(", listCall.index);
|
|
300
|
+
let depth = 1;
|
|
301
|
+
let end = callStart + 1;
|
|
302
|
+
for (; end < masked.length && depth; end++) {
|
|
303
|
+
if (masked[end] === "(") depth++;
|
|
304
|
+
else if (masked[end] === ")") depth--;
|
|
270
305
|
}
|
|
306
|
+
const tools = /\btools\s*:\s*\[/.exec(masked.slice(callStart, end));
|
|
307
|
+
if (tools && depth === 0) scan(callStart + tools.index + tools[0].length - 1, true);
|
|
308
|
+
} else {
|
|
309
|
+
const schemas = /\bTOOL_SCHEMAS\s*=\s*\{/.exec(masked);
|
|
310
|
+
if (schemas) scan(schemas.index + schemas[0].length - 1, false);
|
|
271
311
|
}
|
|
272
312
|
} catch {}
|
|
273
313
|
return map;
|