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.
Files changed (59) hide show
  1. package/README.md +36 -55
  2. package/package.json +3 -2
  3. package/src/cli/commands/check.ts +2 -0
  4. package/src/cli/commands/hook.ts +59 -12
  5. package/src/cli/commands/init.ts +440 -121
  6. package/src/cli/commands/sync.ts +27 -17
  7. package/src/cli/commands/topology.ts +89 -47
  8. package/src/cli/index.ts +5 -67
  9. package/src/cli/scaffold/harness-config.ts +697 -0
  10. package/src/cli/templates/hooks.ts +6 -6
  11. package/src/cli/templates/rules.ts +8 -8
  12. package/src/cli/templates/skill.ts +11 -18
  13. package/src/cli/ui/prompts.ts +122 -0
  14. package/src/core/boundary/staged-auditor.ts +5 -2
  15. package/src/core/database/client.ts +8 -0
  16. package/src/core/database/repositories/dependency.repository.ts +77 -13
  17. package/src/core/database/repositories/domain.repository.ts +86 -33
  18. package/src/core/database/repositories/symbol.repository.ts +128 -117
  19. package/src/core/database/repository.ts +17 -5
  20. package/src/core/database/schema.ts +1 -1
  21. package/src/core/discovery/topology-detector.ts +71 -19
  22. package/src/core/ingestion/pipeline.ts +228 -94
  23. package/src/core/parser/extractors/laravel-semantic.ts +9 -8
  24. package/src/core/parser/extractors/mcp-cli-semantic.ts +56 -16
  25. package/src/core/parser/extractors/nestjs-semantic.ts +3 -2
  26. package/src/core/parser/schema/providers/laravel-migration-provider.ts +4 -3
  27. package/src/core/parser/schema/providers/prisma-schema-provider.ts +2 -1
  28. package/src/core/resolver/call-graph-tracer.ts +4 -3
  29. package/src/core/resolver/path-resolver.ts +57 -4
  30. package/src/core/resources/ignore.ts +35 -0
  31. package/src/core/resources/limits.ts +14 -0
  32. package/src/core/resources/source-files.ts +62 -0
  33. package/src/core/resources/work-queue.ts +74 -0
  34. package/src/core/session/session-manager.ts +19 -6
  35. package/src/core/telemetry/telemetry.ts +12 -1
  36. package/src/core/topology/environment-detector.ts +21 -11
  37. package/src/mcp/schemas.ts +381 -8
  38. package/src/mcp/server.ts +321 -535
  39. package/src/mcp/tools/boundary.ts +17 -0
  40. package/src/mcp/tools/check-boundary.ts +83 -11
  41. package/src/mcp/tools/domain.ts +40 -0
  42. package/src/mcp/tools/feature.ts +67 -0
  43. package/src/mcp/tools/get-domain-catalog.ts +9 -6
  44. package/src/mcp/tools/get-environment-topology.ts +2 -2
  45. package/src/mcp/tools/get-feature-context.ts +9 -12
  46. package/src/mcp/tools/get-symbol-impact.ts +5 -2
  47. package/src/mcp/tools/get-symbol.ts +2 -1
  48. package/src/mcp/tools/locate-symbol.ts +3 -3
  49. package/src/mcp/tools/register-domain.ts +25 -9
  50. package/src/mcp/tools/register-feature.ts +34 -17
  51. package/src/mcp/tools/slice.ts +15 -0
  52. package/src/mcp/tools/symbol.ts +82 -0
  53. package/src/mcp/workspace-manager.ts +159 -0
  54. package/src/mcp/workspace-watcher.ts +202 -0
  55. package/src/types/index.ts +1 -0
  56. package/src/cli/commands/ingest.ts +0 -29
  57. package/src/cli/commands/locate.ts +0 -61
  58. package/src/cli/commands/query.ts +0 -104
  59. package/src/cli/commands/slice.ts +0 -50
@@ -1,5 +1,9 @@
1
- import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
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 || process.cwd();
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
- if (!existsSync(absDomainRoot)) {
77
- this.repo.files.deleteFilesNotInPaths(domainId, []);
78
- continue;
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
- const content = readFileSync(fullPath, "utf-8");
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
- // Batch ACID Transaction per domain
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
- // Framework-Agnostic Semantic Vertical Slice Extraction (via Registry Strategy)
214
- const detectedTopology = TopologyDetector.detect(projectRoot);
215
- const semanticSlices = await SemanticSliceExtractorRegistry.extractAllSlices(
216
- projectRoot,
217
- detectedTopology.framework
218
- );
219
-
220
- const slicesToPersist =
221
- semanticSlices.length > 0
222
- ? semanticSlices
223
- : new CallGraphTracer(this.repo, projectRoot).traceAllSlices();
224
-
225
- this.repo.runInTransaction(() => {
226
- this.repo.clearVerticalSlices();
227
- for (const slice of slicesToPersist) {
228
- this.repo.upsertVerticalSlice(slice);
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
- return;
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 (this.shouldIgnore(relPath, ignorePatterns)) {
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 = statSync(fullPath);
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
- // ignore unreadable files
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 process.cwd();
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 || process.cwd();
681
+ const projectRoot = this.customWorkspaceRoot || resolveWorkspaceRoot();
563
682
  const relPath = relative(projectRoot, fullPath).replace(/\\/g, "/");
564
683
 
565
- if (this.shouldIgnore(relPath, config.settings.ignore_patterns ?? [])) {
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
- const content = readFileSync(fullPath, "utf-8");
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 = fs.readFileSync(routeFile, "utf-8");
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 = fs.readFileSync(controllerInfo.filePath, "utf-8");
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(fs.readFileSync(reqFile, "utf-8")) : {};
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(fs.readFileSync(modFile, "utf-8")) : { fillable: [], casts: {} };
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 = fs.readFileSync(c, "utf-8").split("\n");
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 = fs.readFileSync(fullPath, "utf-8");
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 = fs.readFileSync(fullPath, "utf-8");
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 = fs.readFileSync(fullPath, "utf-8");
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(fs.readFileSync(pkgPath, "utf-8"));
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 = fs.readFileSync(fullPath, "utf-8");
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
- const toolIdentifier =
51
- registeredTools.get(snakeBase) || `${toolPrefix}_${snakeBase}`;
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 = fs.readFileSync(fullPath, "utf-8");
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(fs.readFileSync(pkgPath, "utf-8"));
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 = fs.readFileSync(serverPath, "utf-8");
261
- const nameRegex = /name:\s*['"]([a-zA-Z0-9_-]+)['"]/g;
262
- let match: RegExpExecArray | null;
263
- while ((match = nameRegex.exec(content)) !== null) {
264
- const fullToolName = match[1];
265
- const normalized = fullToolName.replace(/-/g, "_");
266
- map.set(normalized, fullToolName);
267
- const withoutPrefix = normalized.replace(/^[a-z0-9]+_/, "");
268
- if (withoutPrefix) {
269
- map.set(withoutPrefix, fullToolName);
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;