septum 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +38 -37
  2. package/package.json +6 -5
  3. package/src/cli/commands/check.ts +34 -122
  4. package/src/cli/commands/hook.ts +68 -16
  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 +7 -68
  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/evaluator.ts +21 -25
  15. package/src/core/boundary/staged-auditor.ts +50 -0
  16. package/src/core/config/loader.ts +21 -24
  17. package/src/core/database/client.ts +8 -0
  18. package/src/core/database/repositories/dependency.repository.ts +80 -16
  19. package/src/core/database/repositories/domain.repository.ts +86 -33
  20. package/src/core/database/repositories/symbol.repository.ts +147 -65
  21. package/src/core/database/repository.ts +17 -5
  22. package/src/core/database/schema.ts +1 -1
  23. package/src/core/discovery/topology-detector.ts +71 -19
  24. package/src/core/ingestion/pipeline.ts +274 -126
  25. package/src/core/parser/extractors/frontend.ts +1 -0
  26. package/src/core/parser/extractors/go.ts +2 -2
  27. package/src/core/parser/extractors/laravel-semantic.ts +9 -8
  28. package/src/core/parser/extractors/mcp-cli-semantic.ts +56 -16
  29. package/src/core/parser/extractors/nestjs-semantic.ts +3 -2
  30. package/src/core/parser/extractors/php.ts +2 -2
  31. package/src/core/parser/extractors/python.ts +2 -2
  32. package/src/core/parser/extractors/typescript.ts +2 -2
  33. package/src/core/parser/schema/providers/laravel-migration-provider.ts +4 -3
  34. package/src/core/parser/schema/providers/prisma-schema-provider.ts +2 -1
  35. package/src/core/parser/tree-sitter.ts +6 -1
  36. package/src/core/parser/wasm-assets.ts +17 -0
  37. package/src/core/parser/wasm-loader.ts +3 -0
  38. package/src/core/resolver/call-graph-tracer.ts +4 -3
  39. package/src/core/resolver/module-resolver.ts +1 -10
  40. package/src/core/resolver/path-resolver.ts +57 -4
  41. package/src/core/resolver/vertical-slice-tracer.ts +1 -0
  42. package/src/core/resources/ignore.ts +35 -0
  43. package/src/core/resources/limits.ts +14 -0
  44. package/src/core/resources/source-files.ts +62 -0
  45. package/src/core/resources/work-queue.ts +74 -0
  46. package/src/core/session/session-manager.ts +67 -38
  47. package/src/core/telemetry/telemetry.ts +12 -1
  48. package/src/core/topology/environment-detector.ts +21 -11
  49. package/src/mcp/schemas.ts +381 -8
  50. package/src/mcp/server.ts +326 -529
  51. package/src/mcp/tools/boundary.ts +17 -0
  52. package/src/mcp/tools/check-boundary.ts +97 -43
  53. package/src/mcp/tools/domain.ts +40 -0
  54. package/src/mcp/tools/feature.ts +67 -0
  55. package/src/mcp/tools/get-domain-catalog.ts +15 -7
  56. package/src/mcp/tools/get-environment-topology.ts +2 -2
  57. package/src/mcp/tools/get-feature-context.ts +9 -12
  58. package/src/mcp/tools/get-symbol-impact.ts +5 -2
  59. package/src/mcp/tools/get-symbol.ts +2 -1
  60. package/src/mcp/tools/locate-symbol.ts +28 -5
  61. package/src/mcp/tools/register-domain.ts +25 -9
  62. package/src/mcp/tools/register-feature.ts +40 -21
  63. package/src/mcp/tools/slice.ts +15 -0
  64. package/src/mcp/tools/symbol.ts +82 -0
  65. package/src/mcp/workspace-manager.ts +159 -0
  66. package/src/mcp/workspace-watcher.ts +202 -0
  67. package/src/types/index.ts +21 -1
  68. package/src/types/wasm.d.ts +4 -0
  69. package/src/cli/commands/ingest.ts +0 -29
  70. package/src/cli/commands/locate.ts +0 -61
  71. package/src/cli/commands/query.ts +0 -104
  72. package/src/cli/commands/slice.ts +0 -50
@@ -1,5 +1,9 @@
1
- import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
- import { basename, dirname, join, relative, resolve } from "node:path";
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";
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";
@@ -8,6 +12,7 @@ import { SemanticSliceExtractorRegistry } from "../parser/extractors/registry.ts
8
12
  import { TopologyDetector } from "../discovery/topology-detector.ts";
9
13
  import { ASTParserEngine } from "../parser/tree-sitter.ts";
10
14
  import { CallGraphTracer } from "../resolver/call-graph-tracer.ts";
15
+ import { ModuleResolver } from "../resolver/module-resolver.ts";
11
16
  import { computeContentHash } from "./hasher.ts";
12
17
 
13
18
  export interface IngestionMetrics {
@@ -33,16 +38,34 @@ export class IngestionPipeline {
33
38
  }
34
39
  }
35
40
 
36
- public async run(config: ValidatedSeptumConfig): 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> {
56
+ this.ensureParserRevision();
37
57
  const startTime = performance.now();
38
58
  let filesScanned = 0;
39
59
  let filesUpdated = 0;
40
60
  let filesSkipped = 0;
41
61
  let symbolsIndexed = 0;
42
62
  let dependenciesIndexed = 0;
63
+ const unsupportedFiles: string[] = [];
64
+ const skippedFiles: SkippedSourceFile[] = [];
65
+ let removedFiles = 0;
43
66
 
44
67
  let domainNames = Object.keys(config.domains);
45
- const projectRoot = this.customWorkspaceRoot || process.cwd();
68
+ const projectRoot = this.customWorkspaceRoot || resolveWorkspaceRoot();
46
69
 
47
70
  // Auto-discovery mode if no domains explicitly declared in config
48
71
  if (domainNames.length === 0) {
@@ -68,27 +91,42 @@ export class IngestionPipeline {
68
91
  const domainCfg = config.domains[domainName];
69
92
  const domainId = this.repo.upsertDomain(domainName, domainCfg);
70
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
+ }
71
98
  const absDomainRoot = resolve(workspaceRoot, domainCfg.root);
72
99
 
73
- if (!existsSync(absDomainRoot)) {
74
- 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}`);
75
114
  }
76
115
 
77
- const normCurrentRoot = domainCfg.root.replace(/\\/g, "/").replace(/^\.\/?/, "");
78
116
  const childDomainRoots = domainNames
79
- .filter((d) => d !== domainName)
80
- .map((d) => config.domains[d].root.replace(/\\/g, "/").replace(/^\.\/?/, ""))
81
- .filter((r) => {
82
- if (!r) return false;
83
- if (!normCurrentRoot) return true;
84
- return r.startsWith(normCurrentRoot + "/");
117
+ .filter(name => name !== domainName)
118
+ .map(name => relative(workspaceRoot, resolve(workspaceRoot, config.domains[name].root)).replace(/\\/g, "/"))
119
+ .filter(child => {
120
+ const fromParent = relative(absDomainRoot, resolve(workspaceRoot, child)).replace(/\\/g, "/");
121
+ return fromParent !== "" && fromParent !== ".." && !fromParent.startsWith("../") && !isAbsolute(fromParent);
85
122
  });
86
123
 
87
124
  const collectedFiles = this.collectFiles(
88
125
  absDomainRoot,
89
126
  workspaceRoot,
90
127
  config.settings.ignore_patterns,
91
- childDomainRoots
128
+ childDomainRoots,
129
+ options.signal
92
130
  );
93
131
  const activeRelPaths = new Set<string>();
94
132
 
@@ -109,8 +147,43 @@ export class IngestionPipeline {
109
147
  ast: ParsedFileAST;
110
148
  }> = [];
111
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
+
112
182
  for (const { fullPath, relPath } of collectedFiles) {
183
+ options.signal?.throwIfAborted();
184
+ if (pendingMetadataUpdates.length >= RESOURCE_LIMITS.batchFiles) commitBatch();
113
185
  filesScanned++;
186
+ if (filesScanned % RESOURCE_LIMITS.batchFiles === 0) { await new Promise<void>(done => setTimeout(done, 0)); options.signal?.throwIfAborted(); }
114
187
  activeRelPaths.add(relPath);
115
188
 
116
189
  let stat;
@@ -120,12 +193,13 @@ export class IngestionPipeline {
120
193
  continue;
121
194
  }
122
195
 
123
- const mtimeMs = Math.round(stat.mtimeMs);
196
+ const mtimeMs = stat.mtimeMs;
124
197
  const sizeBytes = stat.size;
125
198
  const existingFile = existingFilesMap.get(relPath);
126
199
 
127
200
  // Tier 1: Fast OS metadata check (inode mtime + size + domain match)
128
201
  if (
202
+ !options.verifyContent &&
129
203
  existingFile &&
130
204
  existingFile.domain_id === domainId &&
131
205
  existingFile.mtime_ms === mtimeMs &&
@@ -136,7 +210,24 @@ export class IngestionPipeline {
136
210
  }
137
211
 
138
212
  // Tier 2: Content Hash verification
139
- 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
+ }
140
231
  const currentHash = computeContentHash(content);
141
232
 
142
233
  if (
@@ -150,11 +241,19 @@ export class IngestionPipeline {
150
241
  continue;
151
242
  }
152
243
 
153
- filesUpdated++;
154
244
  const lineCount = content.split("\n").length;
155
245
  const archetype = detectArchetype(relPath, domainCfg.archetypes ?? {});
156
246
  const ast = await this.parserEngine.parseFile(fullPath, content);
247
+ if (ast.status === "unsupported") {
248
+ unsupportedFiles.push(relPath);
249
+ this.repo.deleteFileByPath(relPath);
250
+ this.repo.setMeta("slices_dirty", "1");
251
+ continue;
252
+ }
253
+ if (ast.status === "degraded") throw new Error(`Cannot reliably index '${relPath}': parser is degraded.`);
157
254
 
255
+ filesUpdated++;
256
+ batchBytes += sizeBytes;
158
257
  pendingUpdates.push({
159
258
  fullPath,
160
259
  relPath,
@@ -167,33 +266,13 @@ export class IngestionPipeline {
167
266
  });
168
267
  }
169
268
 
170
- // Batch ACID Transaction per domain
269
+ commitBatch();
171
270
  this.repo.runInTransaction(() => {
172
- for (const meta of pendingMetadataUpdates) {
173
- this.repo.updateFileMetadataOnly(meta.id, meta.mtimeMs, meta.sizeBytes);
174
- }
175
-
176
- for (const item of pendingUpdates) {
177
- const fileId = this.repo.upsertFile(
178
- domainId,
179
- item.relPath,
180
- item.archetype,
181
- item.contentHash,
182
- item.lineCount,
183
- item.mtimeMs,
184
- item.sizeBytes
185
- );
186
-
187
- this.repo.replaceFileSymbols(fileId, item.ast.symbols);
188
- this.repo.replaceFileDependencies(fileId, item.ast.dependencies);
189
-
190
- symbolsIndexed += item.ast.symbols.length;
191
- dependenciesIndexed += item.ast.dependencies.length;
192
- }
193
-
194
271
  // State Reconciliation: Prune deleted files from DB using pre-loaded existingFilesMap
195
272
  for (const [existingPath, existingRecord] of existingFilesMap.entries()) {
196
273
  if (existingRecord.domain_id === domainId && !activeRelPaths.has(existingPath)) {
274
+ removedFiles++;
275
+ this.repo.setMeta("slices_dirty", "1");
197
276
  this.repo.deleteFileByPath(existingPath);
198
277
  existingFilesMap.delete(existingPath);
199
278
  }
@@ -201,36 +280,78 @@ export class IngestionPipeline {
201
280
  });
202
281
  }
203
282
 
204
- // Framework-Agnostic Semantic Vertical Slice Extraction (via Registry Strategy)
205
- const detectedTopology = TopologyDetector.detect(projectRoot);
206
- const semanticSlices = await SemanticSliceExtractorRegistry.extractAllSlices(
207
- projectRoot,
208
- detectedTopology.framework
209
- );
210
-
211
- const slicesToPersist =
212
- semanticSlices.length > 0
213
- ? semanticSlices
214
- : new CallGraphTracer(this.repo, projectRoot).traceAllSlices();
215
-
216
- this.repo.runInTransaction(() => {
217
- this.repo.clearVerticalSlices();
218
- for (const slice of slicesToPersist) {
219
- this.repo.upsertVerticalSlice(slice);
220
- }
221
- });
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
+ }
222
327
 
328
+ skippedFiles.push(...JSON.parse(this.repo.getMeta("semantic_skipped_files") || "[]"));
223
329
  const durationMs = Math.round(performance.now() - startTime);
224
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
+
225
340
  // Update SSOT repo_meta watermark
226
341
  this.repo.runInTransaction(() => {
227
342
  this.repo.setMeta("last_sync", new Date().toISOString());
343
+ this.repo.setMeta("sync_duration_ms", String(durationMs));
228
344
  this.repo.setMeta("files_scanned", String(filesScanned));
229
345
  this.repo.setMeta("files_updated", String(filesUpdated));
230
346
  this.repo.setMeta("symbols_indexed", String(symbolsIndexed));
231
347
  this.repo.setMeta("dependencies_indexed", String(dependenciesIndexed));
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));
232
351
  });
233
352
 
353
+ this.repo.checkpoint();
354
+
234
355
  return {
235
356
  domains_processed: domainNames.length,
236
357
  files_scanned: filesScanned,
@@ -419,23 +540,43 @@ export class IngestionPipeline {
419
540
  dir: string,
420
541
  workspaceRoot: string,
421
542
  ignorePatterns: string[],
422
- childDomainRoots: string[] = []
543
+ childDomainRoots: string[] = [],
544
+ signal?: AbortSignal
423
545
  ): Array<{ fullPath: string; relPath: string }> {
424
546
  const results: Array<{ fullPath: string; relPath: string }> = [];
425
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
+
426
561
  const walk = (currentDir: string) => {
562
+ signal?.throwIfAborted();
427
563
  let entries: string[] = [];
428
564
  try {
429
565
  entries = readdirSync(currentDir);
430
- } catch {
431
- 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}`);
432
572
  }
433
573
 
434
574
  for (const entry of entries) {
575
+ signal?.throwIfAborted();
435
576
  const fullPath = join(currentDir, entry);
436
577
  const relPath = relative(workspaceRoot, fullPath).replace(/\\/g, "/");
437
578
 
438
- if (this.shouldIgnore(relPath, ignorePatterns)) {
579
+ if (shouldIgnoreSourcePath(relPath, ignorePatterns)) {
439
580
  continue;
440
581
  }
441
582
 
@@ -444,14 +585,35 @@ export class IngestionPipeline {
444
585
  }
445
586
 
446
587
  try {
447
- 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
+
448
607
  if (stat.isDirectory()) {
449
608
  walk(fullPath);
450
609
  } else if (stat.isFile() && this.isSourceFile(fullPath)) {
451
610
  results.push({ fullPath, relPath });
452
611
  }
453
- } catch {
454
- // 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}`);
455
617
  }
456
618
  }
457
619
  };
@@ -471,58 +633,37 @@ export class IngestionPipeline {
471
633
  if (config.settings.db_path && config.settings.db_path !== ":memory:") {
472
634
  const dbDir = dirname(resolve(config.settings.db_path));
473
635
  const candidate = basename(dbDir) === ".septum" ? dirname(dbDir) : dbDir;
474
- const absDomainRoot = resolve(domainRoot);
636
+ const absDomainRoot = isAbsolute(domainRoot) ? domainRoot : resolve(candidate, domainRoot);
475
637
  if (absDomainRoot.startsWith(candidate)) {
476
638
  return candidate;
477
639
  }
478
640
  }
479
- return process.cwd();
641
+ return resolveWorkspaceRoot();
480
642
  }
481
643
 
482
- private shouldIgnore(filePath: string, ignorePatterns: string[]): boolean {
483
- const normPath = filePath.replace(/\\/g, "/").replace(/^\.\//, "");
484
- for (const pattern of ignorePatterns) {
485
- const normPattern = pattern.replace(/\\/g, "/").replace(/^\.\//, "").trim();
486
- if (!normPattern) continue;
487
-
488
- if (normPattern === "*" || normPattern === "**") {
489
- return true;
490
- }
491
-
492
- if (normPath === normPattern) return true;
493
-
494
- if (normPattern.endsWith("/**")) {
495
- const prefix = normPattern.slice(0, -3);
496
- if (normPath === prefix || normPath.startsWith(prefix + "/")) return true;
497
- } else if (normPattern.endsWith("/*")) {
498
- const prefix = normPattern.slice(0, -2);
499
- if (normPath === prefix || normPath.startsWith(prefix + "/")) return true;
500
- } else if (normPath.startsWith(normPattern + "/")) {
501
- return true;
502
- }
503
-
504
- if (normPattern.startsWith("*.") && normPath.endsWith(normPattern.slice(1))) {
505
- return true;
506
- }
507
644
 
508
- const segments = normPath.split("/");
509
- if (segments.includes(normPattern)) {
510
- return true;
511
- }
512
- }
513
- return false;
645
+ private isSourceFile(path: string): boolean {
646
+ return ASTParserEngine.isSourceFile(path);
514
647
  }
515
648
 
516
- private isSourceFile(path: string): boolean {
517
- return /\.(ts|tsx|js|jsx|mjs|cjs|php|py|go|rs|vue|astro|svelte)$/i.test(path);
649
+ private ensureParserRevision(): void {
650
+ const revision = "2";
651
+ if (this.repo.getMeta("parser_revision") === revision) return;
652
+ this.repo.runInTransaction(() => {
653
+ for (const file of this.repo.getAllFiles()) {
654
+ this.repo.upsertFile(file.domain_id, file.path, file.archetype, "", file.line_count, -1, -1);
655
+ }
656
+ this.repo.clearVerticalSlices();
657
+ this.repo.setMeta("parser_revision", revision);
658
+ });
518
659
  }
519
660
 
520
661
  /**
521
662
  * JIT single-file ingestion: quickly verifies and updates a single file's AST,
522
663
  * symbols, and dependencies in SQLite if its mtime or content hash has changed.
523
- * Runs in < 5ms for rapid delta sync without full project scans.
524
664
  */
525
665
  public async ingestFile(targetFilePath: string, config: ValidatedSeptumConfig): Promise<boolean> {
666
+ this.ensureParserRevision();
526
667
  const fullPath = resolve(targetFilePath);
527
668
  if (!existsSync(fullPath)) return false;
528
669
 
@@ -534,28 +675,18 @@ export class IngestionPipeline {
534
675
  return false;
535
676
  }
536
677
 
537
- const mtimeMs = Math.round(stat.mtimeMs);
678
+ const mtimeMs = stat.mtimeMs;
538
679
  const sizeBytes = stat.size;
539
680
 
540
- const projectRoot = this.customWorkspaceRoot || process.cwd();
681
+ const projectRoot = this.customWorkspaceRoot || resolveWorkspaceRoot();
541
682
  const relPath = relative(projectRoot, fullPath).replace(/\\/g, "/");
542
683
 
543
- if (this.shouldIgnore(relPath, config.settings.ignore_patterns ?? [])) {
684
+ if (shouldIgnoreSourcePath(relPath, config.settings.ignore_patterns ?? [])) {
544
685
  return false;
545
686
  }
546
687
 
547
- let targetDomainName: string | null = null;
548
- let targetDomainCfg = null;
549
-
550
- for (const [dName, dCfg] of Object.entries(config.domains)) {
551
- const absRoot = resolve(projectRoot, dCfg.root).replace(/\\/g, "/");
552
- const normFullPath = fullPath.replace(/\\/g, "/");
553
- if (normFullPath === absRoot || normFullPath.startsWith(absRoot + "/")) {
554
- targetDomainName = dName;
555
- targetDomainCfg = dCfg;
556
- break;
557
- }
558
- }
688
+ const targetDomainName = new ModuleResolver(projectRoot, this.repo).resolveSourceDomain(fullPath, config);
689
+ const targetDomainCfg = targetDomainName ? config.domains[targetDomainName] : null;
559
690
 
560
691
  if (!targetDomainName || !targetDomainCfg) {
561
692
  return false;
@@ -564,20 +695,23 @@ export class IngestionPipeline {
564
695
  const domainId = this.repo.upsertDomain(targetDomainName, targetDomainCfg);
565
696
  const existingFile = this.repo.getFileByPath(relPath);
566
697
 
567
- // Tier 1: Fast OS metadata check
568
- if (
569
- existingFile &&
570
- existingFile.mtime_ms === mtimeMs &&
571
- existingFile.size_bytes === sizeBytes
572
- ) {
573
- return false; // Completely up to date
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;
574
710
  }
575
-
576
- const content = readFileSync(fullPath, "utf-8");
577
711
  const currentHash = computeContentHash(content);
578
712
 
579
713
  // Tier 2: Hash verification
580
- if (existingFile && existingFile.content_hash === currentHash) {
714
+ if (existingFile && existingFile.domain_id === domainId && existingFile.content_hash === currentHash) {
581
715
  this.repo.updateFileMetadataOnly(existingFile.id, mtimeMs, sizeBytes);
582
716
  return false;
583
717
  }
@@ -585,6 +719,14 @@ export class IngestionPipeline {
585
719
  const lineCount = content.split("\n").length;
586
720
  const archetype = detectArchetype(relPath, targetDomainCfg.archetypes ?? {});
587
721
  const ast = await this.parserEngine.parseFile(fullPath, content);
722
+ if (ast.status === "unsupported") {
723
+ this.repo.deleteFileByPath(relPath);
724
+ const unsupported = new Set<string>(JSON.parse(this.repo.getMeta("unsupported_files") || "[]"));
725
+ unsupported.add(relPath);
726
+ this.repo.setMeta("unsupported_files", JSON.stringify([...unsupported]));
727
+ return false;
728
+ }
729
+ if (ast.status === "degraded") throw new Error(`Cannot reliably index '${relPath}': parser is degraded.`);
588
730
 
589
731
  this.repo.runInTransaction(() => {
590
732
  const fileId = this.repo.upsertFile(
@@ -598,6 +740,12 @@ export class IngestionPipeline {
598
740
  );
599
741
  this.repo.replaceFileSymbols(fileId, ast.symbols);
600
742
  this.repo.replaceFileDependencies(fileId, ast.dependencies);
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)));
747
+ const unsupported = JSON.parse(this.repo.getMeta("unsupported_files") || "[]") as string[];
748
+ this.repo.setMeta("unsupported_files", JSON.stringify(unsupported.filter(path => path !== relPath)));
601
749
  });
602
750
 
603
751
  return true;
@@ -123,6 +123,7 @@ export class FrontendExtractor implements CodeExtractor {
123
123
  dependencies: ExtractedDependency[]
124
124
  ): Promise<void> {
125
125
  const parsed = await this.tsExtractor.extract("component-script.ts", scriptContent);
126
+ if (parsed.status === "degraded") throw new Error("Cannot reliably parse component script.");
126
127
  const delta = Math.max(0, startLine - 1);
127
128
 
128
129
  for (const dep of parsed.dependencies) {
@@ -67,10 +67,10 @@ export class GoExtractor implements CodeExtractor {
67
67
  this.extractFromAst(rootNode, filePath, content)
68
68
  );
69
69
  if (astResult) {
70
- return astResult;
70
+ return { ...astResult, status: "parsed" };
71
71
  }
72
72
 
73
- return this.extractFallback(filePath, content);
73
+ return { ...this.extractFallback(filePath, content), status: "degraded" };
74
74
  }
75
75
 
76
76
  private extractFallback(filePath: string, content: string): ParsedFileAST {