septum 0.1.4 → 0.1.6

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 (70) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +105 -105
  3. package/bin/septum.ts +4 -4
  4. package/package.json +63 -63
  5. package/src/cli/commands/check.ts +161 -161
  6. package/src/cli/commands/hook.ts +216 -188
  7. package/src/cli/commands/ingest.ts +29 -29
  8. package/src/cli/commands/init.ts +213 -211
  9. package/src/cli/commands/locate.ts +61 -61
  10. package/src/cli/commands/query.ts +104 -64
  11. package/src/cli/commands/serve.ts +5 -5
  12. package/src/cli/commands/slice.ts +50 -48
  13. package/src/cli/commands/sync.ts +31 -31
  14. package/src/cli/index.ts +220 -210
  15. package/src/cli/templates/hooks.ts +75 -75
  16. package/src/cli/templates/rules.ts +50 -38
  17. package/src/cli/templates/skill.ts +65 -69
  18. package/src/core/boundary/evaluator.ts +429 -210
  19. package/src/core/config/loader.ts +117 -97
  20. package/src/core/config/schema.ts +39 -39
  21. package/src/core/database/client.ts +44 -44
  22. package/src/core/database/repositories/dependency.repository.ts +552 -349
  23. package/src/core/database/repositories/domain.repository.ts +275 -198
  24. package/src/core/database/repositories/file.repository.ts +117 -106
  25. package/src/core/database/repositories/meta.repository.ts +33 -33
  26. package/src/core/database/repositories/symbol.repository.ts +482 -443
  27. package/src/core/database/repository.ts +281 -260
  28. package/src/core/database/schema.ts +154 -153
  29. package/src/core/discovery/topology-detector.ts +486 -486
  30. package/src/core/ingestion/hasher.ts +5 -5
  31. package/src/core/ingestion/pipeline.ts +606 -535
  32. package/src/core/parser/boundary-tracker.ts +433 -288
  33. package/src/core/parser/extractors/base.ts +62 -62
  34. package/src/core/parser/extractors/blade.ts +158 -158
  35. package/src/core/parser/extractors/frontend.ts +147 -194
  36. package/src/core/parser/extractors/go.ts +408 -180
  37. package/src/core/parser/extractors/laravel-semantic.ts +1045 -901
  38. package/src/core/parser/extractors/mcp-cli-semantic.ts +328 -199
  39. package/src/core/parser/extractors/nestjs-semantic.ts +134 -119
  40. package/src/core/parser/extractors/php.ts +415 -153
  41. package/src/core/parser/extractors/python.ts +460 -228
  42. package/src/core/parser/extractors/registry.ts +70 -70
  43. package/src/core/parser/extractors/semantic-extractor.interface.ts +21 -21
  44. package/src/core/parser/extractors/typescript.ts +668 -181
  45. package/src/core/parser/tree-sitter.ts +41 -41
  46. package/src/core/parser/wasm-loader.ts +271 -0
  47. package/src/core/resolver/call-graph-tracer.ts +698 -668
  48. package/src/core/resolver/module-resolver.ts +479 -342
  49. package/src/core/resolver/path-resolver.ts +147 -147
  50. package/src/core/resolver/symbol-locator.ts +543 -543
  51. package/src/core/resolver/vertical-slice-tracer.ts +323 -285
  52. package/src/core/session/session-manager.ts +56 -56
  53. package/src/core/telemetry/telemetry.ts +86 -86
  54. package/src/core/topology/environment-detector.ts +109 -6
  55. package/src/index.ts +21 -21
  56. package/src/mcp/schemas.ts +695 -286
  57. package/src/mcp/server.ts +927 -716
  58. package/src/mcp/tools/check-boundary.ts +307 -152
  59. package/src/mcp/tools/get-domain-catalog.ts +155 -121
  60. package/src/mcp/tools/get-environment-topology.ts +3 -6
  61. package/src/mcp/tools/get-feature-context.ts +227 -116
  62. package/src/mcp/tools/get-symbol-hotspots.ts +54 -59
  63. package/src/mcp/tools/get-symbol-impact.ts +28 -26
  64. package/src/mcp/tools/get-symbol.ts +65 -63
  65. package/src/mcp/tools/locate-symbol.ts +169 -190
  66. package/src/mcp/tools/register-domain.ts +59 -64
  67. package/src/mcp/tools/register-feature.ts +91 -0
  68. package/src/mcp/tools/trace-vertical-slice.ts +74 -48
  69. package/src/types/index.ts +366 -315
  70. package/src/version.ts +3 -3
@@ -1,535 +1,606 @@
1
- import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
- import { basename, dirname, join, relative, resolve } from "node:path";
3
- import type { ParsedFileAST } from "../../types/index.ts";
4
- import type { ValidatedSeptumConfig } from "../config/schema.ts";
5
- import type { SeptumRepository } from "../database/repository.ts";
6
- import { detectArchetype } from "../parser/extractors/base.ts";
7
- import { SemanticSliceExtractorRegistry } from "../parser/extractors/registry.ts";
8
- import { TopologyDetector } from "../discovery/topology-detector.ts";
9
- import { ASTParserEngine } from "../parser/tree-sitter.ts";
10
- import { CallGraphTracer } from "../resolver/call-graph-tracer.ts";
11
- import { computeContentHash } from "./hasher.ts";
12
-
13
- export interface IngestionMetrics {
14
- domains_processed: number;
15
- files_scanned: number;
16
- files_updated: number;
17
- files_skipped: number;
18
- symbols_indexed: number;
19
- dependencies_indexed: number;
20
- duration_ms: number;
21
- }
22
-
23
- export class IngestionPipeline {
24
- private repo: SeptumRepository;
25
- private parserEngine: ASTParserEngine;
26
- private customWorkspaceRoot?: string;
27
-
28
- constructor(repo: SeptumRepository, workspaceRoot?: string) {
29
- this.repo = repo;
30
- this.parserEngine = new ASTParserEngine();
31
- if (workspaceRoot) {
32
- this.customWorkspaceRoot = resolve(workspaceRoot);
33
- }
34
- }
35
-
36
- public async run(config: ValidatedSeptumConfig): Promise<IngestionMetrics> {
37
- const startTime = performance.now();
38
- let filesScanned = 0;
39
- let filesUpdated = 0;
40
- let filesSkipped = 0;
41
- let symbolsIndexed = 0;
42
- let dependenciesIndexed = 0;
43
-
44
- let domainNames = Object.keys(config.domains);
45
- const projectRoot = this.customWorkspaceRoot || process.cwd();
46
-
47
- // Auto-discovery mode if no domains explicitly declared in config
48
- if (domainNames.length === 0) {
49
- const discovered = this.autoDiscoverDomains(projectRoot);
50
- for (const [dName, dCfg] of Object.entries(discovered)) {
51
- config.domains[dName] = {
52
- root: dCfg.root,
53
- description: dCfg.description,
54
- allowed_dependencies: dCfg.allowed_dependencies || [],
55
- forbidden_dependencies: dCfg.forbidden_dependencies || [],
56
- archetypes: dCfg.archetypes || {},
57
- };
58
- }
59
- domainNames = Object.keys(config.domains);
60
- }
61
-
62
- for (const domainName of domainNames) {
63
- const domainCfg = config.domains[domainName];
64
- const domainId = this.repo.upsertDomain(domainName, domainCfg);
65
-
66
- if (!existsSync(domainCfg.root)) {
67
- continue;
68
- }
69
-
70
- const workspaceRoot = this.resolveWorkspaceRoot(config, domainCfg.root);
71
- const normCurrentRoot = domainCfg.root.replace(/\\/g, "/").replace(/^\.\/?/, "");
72
- const childDomainRoots = domainNames
73
- .filter((d) => d !== domainName)
74
- .map((d) => config.domains[d].root.replace(/\\/g, "/").replace(/^\.\/?/, ""))
75
- .filter((r) => {
76
- if (!r) return false;
77
- if (!normCurrentRoot) return true;
78
- return r.startsWith(normCurrentRoot + "/");
79
- });
80
-
81
- const collectedFiles = this.collectFiles(
82
- domainCfg.root,
83
- workspaceRoot,
84
- config.settings.ignore_patterns,
85
- childDomainRoots
86
- );
87
- const activeRelPaths: string[] = [];
88
-
89
- const pendingUpdates: Array<{
90
- fullPath: string;
91
- relPath: string;
92
- contentHash: string;
93
- lineCount: number;
94
- archetype: ReturnType<typeof detectArchetype>;
95
- mtimeMs: number;
96
- sizeBytes: number;
97
- ast: ParsedFileAST;
98
- }> = [];
99
-
100
- for (const { fullPath, relPath } of collectedFiles) {
101
- filesScanned++;
102
- activeRelPaths.push(relPath);
103
-
104
- let stat;
105
- try {
106
- stat = statSync(fullPath);
107
- } catch {
108
- continue;
109
- }
110
-
111
- const mtimeMs = Math.round(stat.mtimeMs);
112
- const sizeBytes = stat.size;
113
- const existingFile = this.repo.getFileByPath(relPath);
114
-
115
- // Tier 1: Fast OS metadata check (inode mtime + size + domain match)
116
- if (
117
- existingFile &&
118
- existingFile.domain_id === domainId &&
119
- existingFile.mtime_ms === mtimeMs &&
120
- existingFile.size_bytes === sizeBytes
121
- ) {
122
- filesSkipped++;
123
- continue;
124
- }
125
-
126
- // Tier 2: Content Hash verification
127
- const content = readFileSync(fullPath, "utf-8");
128
- const currentHash = computeContentHash(content);
129
-
130
- if (
131
- existingFile &&
132
- existingFile.domain_id === domainId &&
133
- existingFile.content_hash === currentHash
134
- ) {
135
- // Content identical despite mtime bump (e.g. touch) - update metadata without re-parsing AST
136
- this.repo.updateFileMetadataOnly(existingFile.id, mtimeMs, sizeBytes);
137
- filesSkipped++;
138
- continue;
139
- }
140
-
141
- filesUpdated++;
142
- const lineCount = content.split("\n").length;
143
- const archetype = detectArchetype(relPath, domainCfg.archetypes ?? {});
144
- const ast = await this.parserEngine.parseFile(fullPath, content);
145
-
146
- pendingUpdates.push({
147
- fullPath,
148
- relPath,
149
- contentHash: currentHash,
150
- lineCount,
151
- archetype,
152
- mtimeMs,
153
- sizeBytes,
154
- ast,
155
- });
156
- }
157
-
158
- // Batch ACID Transaction per domain
159
- this.repo.runInTransaction(() => {
160
- for (const item of pendingUpdates) {
161
- const fileId = this.repo.upsertFile(
162
- domainId,
163
- item.relPath,
164
- item.archetype,
165
- item.contentHash,
166
- item.lineCount,
167
- item.mtimeMs,
168
- item.sizeBytes
169
- );
170
-
171
- this.repo.replaceFileSymbols(fileId, item.ast.symbols);
172
- this.repo.replaceFileDependencies(fileId, item.ast.dependencies);
173
-
174
- symbolsIndexed += item.ast.symbols.length;
175
- dependenciesIndexed += item.ast.dependencies.length;
176
- }
177
-
178
- // State Reconciliation: Prune deleted files from DB
179
- this.repo.deleteFilesNotInPaths(domainId, activeRelPaths);
180
- });
181
- }
182
-
183
- // Framework-Agnostic Semantic Vertical Slice Extraction (via Registry Strategy)
184
- const detectedTopology = TopologyDetector.detect(projectRoot);
185
- const semanticSlices = await SemanticSliceExtractorRegistry.extractAllSlices(
186
- projectRoot,
187
- detectedTopology.framework
188
- );
189
-
190
- this.repo.clearVerticalSlices();
191
-
192
- if (semanticSlices.length > 0) {
193
- for (const slice of semanticSlices) {
194
- this.repo.upsertVerticalSlice(slice);
195
- }
196
- } else {
197
- // Heuristic Call-Graph Tracer fallback for Clean/Hexagonal or generic architectures
198
- const callGraphTracer = new CallGraphTracer(this.repo, projectRoot);
199
- const graphSlices = callGraphTracer.traceAllSlices();
200
- for (const slice of graphSlices) {
201
- this.repo.upsertVerticalSlice(slice);
202
- }
203
- }
204
-
205
- const durationMs = Math.round(performance.now() - startTime);
206
-
207
- // Update SSOT repo_meta watermark
208
- this.repo.setMeta("last_sync", new Date().toISOString());
209
- this.repo.setMeta("files_scanned", String(filesScanned));
210
- this.repo.setMeta("files_updated", String(filesUpdated));
211
- this.repo.setMeta("symbols_indexed", String(symbolsIndexed));
212
- this.repo.setMeta("dependencies_indexed", String(dependenciesIndexed));
213
-
214
- return {
215
- domains_processed: domainNames.length,
216
- files_scanned: filesScanned,
217
- files_updated: filesUpdated,
218
- files_skipped: filesSkipped,
219
- symbols_indexed: symbolsIndexed,
220
- dependencies_indexed: dependenciesIndexed,
221
- duration_ms: durationMs,
222
- };
223
- }
224
-
225
- public autoDiscoverDomains(projectRoot: string): Record<string, import("../../types/index.ts").DomainConfig> {
226
- const domains: Record<string, import("../../types/index.ts").DomainConfig> = {};
227
-
228
- // 1. Modular / DDD check
229
- const candidateDirs = [
230
- { name: "domains", rel: "src/domains" },
231
- { name: "features", rel: "src/features" },
232
- { name: "modules", rel: "src/modules" },
233
- { name: "domain", rel: "app/Domain" },
234
- { name: "domains_app", rel: "app/Domains" },
235
- ];
236
-
237
- for (const c of candidateDirs) {
238
- const fullDir = join(projectRoot, c.rel);
239
- if (existsSync(fullDir)) {
240
- try {
241
- const subdirs = readdirSync(fullDir, { withFileTypes: true })
242
- .filter((d) => d.isDirectory())
243
- .map((d) => d.name);
244
-
245
- if (subdirs.length > 0) {
246
- for (const sub of subdirs) {
247
- domains[sub.toLowerCase()] = {
248
- root: `${c.rel}/${sub}`,
249
- description: `${sub} domain (auto-discovered)`,
250
- allowed_dependencies: [],
251
- forbidden_dependencies: [],
252
- archetypes: {
253
- service: "*Service.*",
254
- model: "*Model.*",
255
- repository: "*Repository.*",
256
- },
257
- };
258
- }
259
- return domains;
260
- }
261
- } catch {}
262
- }
263
- }
264
-
265
- // 2. Framework: Laravel
266
- const hasArtisan = existsSync(join(projectRoot, "artisan"));
267
- let isLaravel = hasArtisan;
268
- if (!isLaravel && existsSync(join(projectRoot, "composer.json"))) {
269
- try {
270
- const comp = readFileSync(join(projectRoot, "composer.json"), "utf-8");
271
- if (comp.includes("laravel/framework")) isLaravel = true;
272
- } catch {}
273
- }
274
-
275
- if (isLaravel) {
276
- domains["app"] = {
277
- root: "app",
278
- description: "Core Laravel Application (auto-discovered)",
279
- allowed_dependencies: [],
280
- forbidden_dependencies: [],
281
- archetypes: {
282
- controller: "app/Http/Controllers/**",
283
- service: "app/Services/**",
284
- model: "app/Models/**",
285
- repository: "app/Repositories/**",
286
- action: "app/Actions/**",
287
- job: "app/Jobs/**",
288
- event: "app/Events/**",
289
- listener: "app/Listeners/**",
290
- request: "app/Http/Requests/**",
291
- middleware: "app/Http/Middleware/**",
292
- },
293
- };
294
-
295
- if (existsSync(join(projectRoot, "resources/views"))) {
296
- domains["views"] = {
297
- root: "resources/views",
298
- description: "Laravel Blade Templates and UI Views (auto-discovered)",
299
- allowed_dependencies: [],
300
- forbidden_dependencies: [],
301
- archetypes: {
302
- view: "resources/views/**/*.blade.php",
303
- },
304
- };
305
- }
306
-
307
- return domains;
308
- }
309
-
310
- // 3. Framework: NestJS
311
- const hasNestCli = existsSync(join(projectRoot, "nest-cli.json"));
312
- let isNest = hasNestCli;
313
- if (!isNest && existsSync(join(projectRoot, "package.json"))) {
314
- try {
315
- const pkg = readFileSync(join(projectRoot, "package.json"), "utf-8");
316
- if (pkg.includes("@nestjs/core")) isNest = true;
317
- } catch {}
318
- }
319
-
320
- if (isNest) {
321
- domains["src"] = {
322
- root: "src",
323
- description: "NestJS Source Domain (auto-discovered)",
324
- allowed_dependencies: [],
325
- forbidden_dependencies: [],
326
- archetypes: {
327
- controller: "src/**/*.controller.ts",
328
- service: "src/**/*.service.ts",
329
- repository: "src/**/*.repository.ts",
330
- },
331
- };
332
- return domains;
333
- }
334
-
335
- // 4. Framework: Go
336
- if (existsSync(join(projectRoot, "go.mod"))) {
337
- if (existsSync(join(projectRoot, "internal"))) {
338
- domains["internal"] = {
339
- root: "internal",
340
- description: "Internal business logic packages",
341
- };
342
- }
343
- if (existsSync(join(projectRoot, "pkg"))) {
344
- domains["pkg"] = {
345
- root: "pkg",
346
- description: "Public library packages",
347
- };
348
- }
349
- if (existsSync(join(projectRoot, "cmd"))) {
350
- domains["cmd"] = {
351
- root: "cmd",
352
- description: "Application command entry points",
353
- };
354
- }
355
- if (Object.keys(domains).length > 0) return domains;
356
- }
357
-
358
- // 5. General project fallback (src or root)
359
- if (existsSync(join(projectRoot, "src"))) {
360
- domains["src"] = {
361
- root: "src",
362
- description: "Default Source Domain (auto-discovered)",
363
- };
364
- } else {
365
- domains["root"] = {
366
- root: ".",
367
- description: "Root Domain (auto-discovered)",
368
- };
369
- }
370
-
371
- return domains;
372
- }
373
-
374
- private collectFiles(
375
- dir: string,
376
- workspaceRoot: string,
377
- ignorePatterns: string[],
378
- childDomainRoots: string[] = []
379
- ): Array<{ fullPath: string; relPath: string }> {
380
- const results: Array<{ fullPath: string; relPath: string }> = [];
381
-
382
- const walk = (currentDir: string) => {
383
- let entries: string[] = [];
384
- try {
385
- entries = readdirSync(currentDir);
386
- } catch {
387
- return;
388
- }
389
-
390
- for (const entry of entries) {
391
- const fullPath = join(currentDir, entry);
392
- const relPath = relative(workspaceRoot, fullPath).replace(/\\/g, "/");
393
-
394
- if (this.shouldIgnore(relPath, ignorePatterns)) {
395
- continue;
396
- }
397
-
398
- if (childDomainRoots.some((child) => relPath === child || relPath.startsWith(child + "/"))) {
399
- continue;
400
- }
401
-
402
- try {
403
- const stat = statSync(fullPath);
404
- if (stat.isDirectory()) {
405
- walk(fullPath);
406
- } else if (stat.isFile() && this.isSourceFile(fullPath)) {
407
- results.push({ fullPath, relPath });
408
- }
409
- } catch {
410
- // ignore unreadable files
411
- }
412
- }
413
- };
414
-
415
- walk(dir);
416
- return results;
417
- }
418
-
419
- private resolveWorkspaceRoot(config: ValidatedSeptumConfig, domainRoot: string): string {
420
- if (this.customWorkspaceRoot) {
421
- return this.customWorkspaceRoot;
422
- }
423
- const settings = config.settings as Record<string, unknown> | undefined;
424
- if (typeof settings?.workspace_root === "string" && settings.workspace_root.trim()) {
425
- return resolve(settings.workspace_root);
426
- }
427
- if (config.settings.db_path && config.settings.db_path !== ":memory:") {
428
- const dbDir = dirname(resolve(config.settings.db_path));
429
- const candidate = basename(dbDir) === ".septum" ? dirname(dbDir) : dbDir;
430
- const absDomainRoot = resolve(domainRoot);
431
- if (absDomainRoot.startsWith(candidate)) {
432
- return candidate;
433
- }
434
- }
435
- return process.cwd();
436
- }
437
-
438
- private shouldIgnore(path: string, ignorePatterns: string[]): boolean {
439
- for (const pattern of ignorePatterns) {
440
- const cleanPattern = pattern.replace(/\/\*\*|\*|\*\*/g, "");
441
- if (path.includes(cleanPattern)) {
442
- return true;
443
- }
444
- }
445
- return false;
446
- }
447
-
448
- private isSourceFile(path: string): boolean {
449
- return /\.(ts|tsx|js|jsx|mjs|cjs|php|py|go|rs|vue|astro|svelte)$/i.test(path);
450
- }
451
-
452
- /**
453
- * JIT single-file ingestion: quickly verifies and updates a single file's AST,
454
- * symbols, and dependencies in SQLite if its mtime or content hash has changed.
455
- * Runs in < 5ms for rapid delta sync without full project scans.
456
- */
457
- public async ingestFile(targetFilePath: string, config: ValidatedSeptumConfig): Promise<boolean> {
458
- const fullPath = resolve(targetFilePath);
459
- if (!existsSync(fullPath)) return false;
460
-
461
- let stat;
462
- try {
463
- stat = statSync(fullPath);
464
- if (!stat.isFile() || !this.isSourceFile(fullPath)) return false;
465
- } catch {
466
- return false;
467
- }
468
-
469
- const mtimeMs = Math.round(stat.mtimeMs);
470
- const sizeBytes = stat.size;
471
-
472
- const projectRoot = this.customWorkspaceRoot || process.cwd();
473
- const relPath = relative(projectRoot, fullPath).replace(/\\/g, "/");
474
-
475
- let targetDomainName: string | null = null;
476
- let targetDomainCfg = null;
477
-
478
- for (const [dName, dCfg] of Object.entries(config.domains)) {
479
- const absRoot = resolve(projectRoot, dCfg.root).replace(/\\/g, "/");
480
- const normFullPath = fullPath.replace(/\\/g, "/");
481
- if (normFullPath === absRoot || normFullPath.startsWith(absRoot + "/")) {
482
- targetDomainName = dName;
483
- targetDomainCfg = dCfg;
484
- break;
485
- }
486
- }
487
-
488
- if (!targetDomainName || !targetDomainCfg) {
489
- targetDomainName = "core";
490
- targetDomainCfg = { root: ".", allowed_dependencies: [], forbidden_dependencies: [], archetypes: {} };
491
- }
492
-
493
- const domainId = this.repo.upsertDomain(targetDomainName, targetDomainCfg);
494
- const existingFile = this.repo.getFileByPath(relPath);
495
-
496
- // Tier 1: Fast OS metadata check
497
- if (
498
- existingFile &&
499
- existingFile.mtime_ms === mtimeMs &&
500
- existingFile.size_bytes === sizeBytes
501
- ) {
502
- return false; // Completely up to date
503
- }
504
-
505
- const content = readFileSync(fullPath, "utf-8");
506
- const currentHash = computeContentHash(content);
507
-
508
- // Tier 2: Hash verification
509
- if (existingFile && existingFile.content_hash === currentHash) {
510
- this.repo.updateFileMetadataOnly(existingFile.id, mtimeMs, sizeBytes);
511
- return false;
512
- }
513
-
514
- const lineCount = content.split("\n").length;
515
- const archetype = detectArchetype(relPath, targetDomainCfg.archetypes ?? {});
516
- const ast = await this.parserEngine.parseFile(fullPath, content);
517
-
518
- this.repo.runInTransaction(() => {
519
- const fileId = this.repo.upsertFile(
520
- domainId,
521
- relPath,
522
- archetype,
523
- currentHash,
524
- lineCount,
525
- mtimeMs,
526
- sizeBytes
527
- );
528
- this.repo.replaceFileSymbols(fileId, ast.symbols);
529
- this.repo.replaceFileDependencies(fileId, ast.dependencies);
530
- });
531
-
532
- return true;
533
- }
534
- }
535
-
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { basename, dirname, join, relative, resolve } from "node:path";
3
+ import type { ParsedFileAST } from "../../types/index.ts";
4
+ import type { ValidatedSeptumConfig } from "../config/schema.ts";
5
+ import type { SeptumRepository } from "../database/repository.ts";
6
+ import { detectArchetype } from "../parser/extractors/base.ts";
7
+ import { SemanticSliceExtractorRegistry } from "../parser/extractors/registry.ts";
8
+ import { TopologyDetector } from "../discovery/topology-detector.ts";
9
+ import { ASTParserEngine } from "../parser/tree-sitter.ts";
10
+ import { CallGraphTracer } from "../resolver/call-graph-tracer.ts";
11
+ import { computeContentHash } from "./hasher.ts";
12
+
13
+ export interface IngestionMetrics {
14
+ domains_processed: number;
15
+ files_scanned: number;
16
+ files_updated: number;
17
+ files_skipped: number;
18
+ symbols_indexed: number;
19
+ dependencies_indexed: number;
20
+ duration_ms: number;
21
+ }
22
+
23
+ export class IngestionPipeline {
24
+ private repo: SeptumRepository;
25
+ private parserEngine: ASTParserEngine;
26
+ private customWorkspaceRoot?: string;
27
+
28
+ constructor(repo: SeptumRepository, workspaceRoot?: string) {
29
+ this.repo = repo;
30
+ this.parserEngine = new ASTParserEngine();
31
+ if (workspaceRoot) {
32
+ this.customWorkspaceRoot = resolve(workspaceRoot);
33
+ }
34
+ }
35
+
36
+ public async run(config: ValidatedSeptumConfig): Promise<IngestionMetrics> {
37
+ const startTime = performance.now();
38
+ let filesScanned = 0;
39
+ let filesUpdated = 0;
40
+ let filesSkipped = 0;
41
+ let symbolsIndexed = 0;
42
+ let dependenciesIndexed = 0;
43
+
44
+ let domainNames = Object.keys(config.domains);
45
+ const projectRoot = this.customWorkspaceRoot || process.cwd();
46
+
47
+ // Auto-discovery mode if no domains explicitly declared in config
48
+ if (domainNames.length === 0) {
49
+ const discovered = this.autoDiscoverDomains(projectRoot);
50
+ for (const [dName, dCfg] of Object.entries(discovered)) {
51
+ config.domains[dName] = {
52
+ root: dCfg.root,
53
+ description: dCfg.description,
54
+ allowed_dependencies: dCfg.allowed_dependencies || [],
55
+ forbidden_dependencies: dCfg.forbidden_dependencies || [],
56
+ archetypes: dCfg.archetypes || {},
57
+ };
58
+ }
59
+ domainNames = Object.keys(config.domains);
60
+ }
61
+
62
+ const existingFilesMap = new Map<string, import("../../types/index.ts").FileRecord>();
63
+ for (const f of this.repo.getAllFiles()) {
64
+ existingFilesMap.set(f.path, f);
65
+ }
66
+
67
+ for (const domainName of domainNames) {
68
+ const domainCfg = config.domains[domainName];
69
+ const domainId = this.repo.upsertDomain(domainName, domainCfg);
70
+ const workspaceRoot = this.resolveWorkspaceRoot(config, domainCfg.root);
71
+ const absDomainRoot = resolve(workspaceRoot, domainCfg.root);
72
+
73
+ if (!existsSync(absDomainRoot)) {
74
+ continue;
75
+ }
76
+
77
+ const normCurrentRoot = domainCfg.root.replace(/\\/g, "/").replace(/^\.\/?/, "");
78
+ 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 + "/");
85
+ });
86
+
87
+ const collectedFiles = this.collectFiles(
88
+ absDomainRoot,
89
+ workspaceRoot,
90
+ config.settings.ignore_patterns,
91
+ childDomainRoots
92
+ );
93
+ const activeRelPaths = new Set<string>();
94
+
95
+ const pendingMetadataUpdates: Array<{
96
+ id: number;
97
+ mtimeMs: number;
98
+ sizeBytes: number;
99
+ }> = [];
100
+
101
+ const pendingUpdates: Array<{
102
+ fullPath: string;
103
+ relPath: string;
104
+ contentHash: string;
105
+ lineCount: number;
106
+ archetype: ReturnType<typeof detectArchetype>;
107
+ mtimeMs: number;
108
+ sizeBytes: number;
109
+ ast: ParsedFileAST;
110
+ }> = [];
111
+
112
+ for (const { fullPath, relPath } of collectedFiles) {
113
+ filesScanned++;
114
+ activeRelPaths.add(relPath);
115
+
116
+ let stat;
117
+ try {
118
+ stat = statSync(fullPath);
119
+ } catch {
120
+ continue;
121
+ }
122
+
123
+ const mtimeMs = Math.round(stat.mtimeMs);
124
+ const sizeBytes = stat.size;
125
+ const existingFile = existingFilesMap.get(relPath);
126
+
127
+ // Tier 1: Fast OS metadata check (inode mtime + size + domain match)
128
+ if (
129
+ existingFile &&
130
+ existingFile.domain_id === domainId &&
131
+ existingFile.mtime_ms === mtimeMs &&
132
+ existingFile.size_bytes === sizeBytes
133
+ ) {
134
+ filesSkipped++;
135
+ continue;
136
+ }
137
+
138
+ // Tier 2: Content Hash verification
139
+ const content = readFileSync(fullPath, "utf-8");
140
+ const currentHash = computeContentHash(content);
141
+
142
+ if (
143
+ existingFile &&
144
+ existingFile.domain_id === domainId &&
145
+ existingFile.content_hash === currentHash
146
+ ) {
147
+ // Content identical despite mtime bump (e.g. touch) - queue metadata update for batch transaction
148
+ pendingMetadataUpdates.push({ id: existingFile.id, mtimeMs, sizeBytes });
149
+ filesSkipped++;
150
+ continue;
151
+ }
152
+
153
+ filesUpdated++;
154
+ const lineCount = content.split("\n").length;
155
+ const archetype = detectArchetype(relPath, domainCfg.archetypes ?? {});
156
+ const ast = await this.parserEngine.parseFile(fullPath, content);
157
+
158
+ pendingUpdates.push({
159
+ fullPath,
160
+ relPath,
161
+ contentHash: currentHash,
162
+ lineCount,
163
+ archetype,
164
+ mtimeMs,
165
+ sizeBytes,
166
+ ast,
167
+ });
168
+ }
169
+
170
+ // Batch ACID Transaction per domain
171
+ 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
+ // State Reconciliation: Prune deleted files from DB using pre-loaded existingFilesMap
195
+ for (const [existingPath, existingRecord] of existingFilesMap.entries()) {
196
+ if (existingRecord.domain_id === domainId && !activeRelPaths.has(existingPath)) {
197
+ this.repo.deleteFileByPath(existingPath);
198
+ existingFilesMap.delete(existingPath);
199
+ }
200
+ }
201
+ });
202
+ }
203
+
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
+ });
222
+
223
+ const durationMs = Math.round(performance.now() - startTime);
224
+
225
+ // Update SSOT repo_meta watermark
226
+ this.repo.runInTransaction(() => {
227
+ this.repo.setMeta("last_sync", new Date().toISOString());
228
+ this.repo.setMeta("files_scanned", String(filesScanned));
229
+ this.repo.setMeta("files_updated", String(filesUpdated));
230
+ this.repo.setMeta("symbols_indexed", String(symbolsIndexed));
231
+ this.repo.setMeta("dependencies_indexed", String(dependenciesIndexed));
232
+ });
233
+
234
+ return {
235
+ domains_processed: domainNames.length,
236
+ files_scanned: filesScanned,
237
+ files_updated: filesUpdated,
238
+ files_skipped: filesSkipped,
239
+ symbols_indexed: symbolsIndexed,
240
+ dependencies_indexed: dependenciesIndexed,
241
+ duration_ms: durationMs,
242
+ };
243
+ }
244
+
245
+ public autoDiscoverDomains(projectRoot: string): Record<string, import("../../types/index.ts").DomainConfig> {
246
+ const domains: Record<string, import("../../types/index.ts").DomainConfig> = {};
247
+
248
+ // 1. Modular / DDD check
249
+ const candidateDirs = [
250
+ { name: "domains", rel: "src/domains" },
251
+ { name: "features", rel: "src/features" },
252
+ { name: "modules", rel: "src/modules" },
253
+ { name: "domain", rel: "app/Domain" },
254
+ { name: "domains_app", rel: "app/Domains" },
255
+ ];
256
+
257
+ for (const c of candidateDirs) {
258
+ const fullDir = join(projectRoot, c.rel);
259
+ if (existsSync(fullDir)) {
260
+ try {
261
+ const subdirs = readdirSync(fullDir, { withFileTypes: true })
262
+ .filter((d) => d.isDirectory())
263
+ .map((d) => d.name);
264
+
265
+ if (subdirs.length > 0) {
266
+ for (const sub of subdirs) {
267
+ domains[sub.toLowerCase()] = {
268
+ root: `${c.rel}/${sub}`,
269
+ description: `${sub} domain (auto-discovered)`,
270
+ allowed_dependencies: [],
271
+ forbidden_dependencies: [],
272
+ archetypes: {
273
+ service: "*Service.*",
274
+ model: "*Model.*",
275
+ repository: "*Repository.*",
276
+ },
277
+ };
278
+ }
279
+ return domains;
280
+ }
281
+ } catch {}
282
+ }
283
+ }
284
+
285
+ // 2. Framework: Laravel
286
+ const hasArtisan = existsSync(join(projectRoot, "artisan"));
287
+ let isLaravel = hasArtisan;
288
+ if (!isLaravel && existsSync(join(projectRoot, "composer.json"))) {
289
+ try {
290
+ const comp = readFileSync(join(projectRoot, "composer.json"), "utf-8");
291
+ if (comp.includes("laravel/framework")) isLaravel = true;
292
+ } catch {}
293
+ }
294
+
295
+ if (isLaravel) {
296
+ domains["app"] = {
297
+ root: "app",
298
+ description: "Core Laravel Application (auto-discovered)",
299
+ allowed_dependencies: [],
300
+ forbidden_dependencies: [],
301
+ archetypes: {
302
+ controller: "app/Http/Controllers/**",
303
+ service: "app/Services/**",
304
+ model: "app/Models/**",
305
+ repository: "app/Repositories/**",
306
+ action: "app/Actions/**",
307
+ job: "app/Jobs/**",
308
+ event: "app/Events/**",
309
+ listener: "app/Listeners/**",
310
+ request: "app/Http/Requests/**",
311
+ middleware: "app/Http/Middleware/**",
312
+ },
313
+ };
314
+
315
+ if (existsSync(join(projectRoot, "resources/views"))) {
316
+ domains["views"] = {
317
+ root: "resources/views",
318
+ description: "Laravel Blade Templates and UI Views (auto-discovered)",
319
+ allowed_dependencies: [],
320
+ forbidden_dependencies: [],
321
+ archetypes: {
322
+ view: "resources/views/**/*.blade.php",
323
+ },
324
+ };
325
+ }
326
+
327
+ if (existsSync(join(projectRoot, "routes"))) {
328
+ domains["routes"] = {
329
+ root: "routes",
330
+ description: "Laravel HTTP Route Definitions (auto-discovered)",
331
+ allowed_dependencies: [],
332
+ forbidden_dependencies: [],
333
+ archetypes: {
334
+ route: "routes/**/*.php",
335
+ },
336
+ };
337
+ }
338
+
339
+ if (existsSync(join(projectRoot, "database"))) {
340
+ domains["database"] = {
341
+ root: "database",
342
+ description: "Laravel Migrations and Database Schemas (auto-discovered)",
343
+ allowed_dependencies: [],
344
+ forbidden_dependencies: [],
345
+ archetypes: {
346
+ migration: "database/migrations/**/*.php",
347
+ },
348
+ };
349
+ }
350
+
351
+ return domains;
352
+ }
353
+
354
+ // 3. Framework: NestJS
355
+ const hasNestCli = existsSync(join(projectRoot, "nest-cli.json"));
356
+ let isNest = hasNestCli;
357
+ if (!isNest && existsSync(join(projectRoot, "package.json"))) {
358
+ try {
359
+ const pkg = readFileSync(join(projectRoot, "package.json"), "utf-8");
360
+ if (pkg.includes("@nestjs/core")) isNest = true;
361
+ } catch {}
362
+ }
363
+
364
+ if (isNest) {
365
+ domains["src"] = {
366
+ root: "src",
367
+ description: "NestJS Source Domain (auto-discovered)",
368
+ allowed_dependencies: [],
369
+ forbidden_dependencies: [],
370
+ archetypes: {
371
+ controller: "src/**/*.controller.ts",
372
+ service: "src/**/*.service.ts",
373
+ repository: "src/**/*.repository.ts",
374
+ },
375
+ };
376
+ return domains;
377
+ }
378
+
379
+ // 4. Framework: Go
380
+ if (existsSync(join(projectRoot, "go.mod"))) {
381
+ if (existsSync(join(projectRoot, "internal"))) {
382
+ domains["internal"] = {
383
+ root: "internal",
384
+ description: "Internal business logic packages",
385
+ };
386
+ }
387
+ if (existsSync(join(projectRoot, "pkg"))) {
388
+ domains["pkg"] = {
389
+ root: "pkg",
390
+ description: "Public library packages",
391
+ };
392
+ }
393
+ if (existsSync(join(projectRoot, "cmd"))) {
394
+ domains["cmd"] = {
395
+ root: "cmd",
396
+ description: "Application command entry points",
397
+ };
398
+ }
399
+ if (Object.keys(domains).length > 0) return domains;
400
+ }
401
+
402
+ // 5. General project fallback (src or root)
403
+ if (existsSync(join(projectRoot, "src"))) {
404
+ domains["src"] = {
405
+ root: "src",
406
+ description: "Default Source Domain (auto-discovered)",
407
+ };
408
+ } else {
409
+ domains["root"] = {
410
+ root: ".",
411
+ description: "Root Domain (auto-discovered)",
412
+ };
413
+ }
414
+
415
+ return domains;
416
+ }
417
+
418
+ private collectFiles(
419
+ dir: string,
420
+ workspaceRoot: string,
421
+ ignorePatterns: string[],
422
+ childDomainRoots: string[] = []
423
+ ): Array<{ fullPath: string; relPath: string }> {
424
+ const results: Array<{ fullPath: string; relPath: string }> = [];
425
+
426
+ const walk = (currentDir: string) => {
427
+ let entries: string[] = [];
428
+ try {
429
+ entries = readdirSync(currentDir);
430
+ } catch {
431
+ return;
432
+ }
433
+
434
+ for (const entry of entries) {
435
+ const fullPath = join(currentDir, entry);
436
+ const relPath = relative(workspaceRoot, fullPath).replace(/\\/g, "/");
437
+
438
+ if (this.shouldIgnore(relPath, ignorePatterns)) {
439
+ continue;
440
+ }
441
+
442
+ if (childDomainRoots.some((child) => relPath === child || relPath.startsWith(child + "/"))) {
443
+ continue;
444
+ }
445
+
446
+ try {
447
+ const stat = statSync(fullPath);
448
+ if (stat.isDirectory()) {
449
+ walk(fullPath);
450
+ } else if (stat.isFile() && this.isSourceFile(fullPath)) {
451
+ results.push({ fullPath, relPath });
452
+ }
453
+ } catch {
454
+ // ignore unreadable files
455
+ }
456
+ }
457
+ };
458
+
459
+ walk(dir);
460
+ return results;
461
+ }
462
+
463
+ private resolveWorkspaceRoot(config: ValidatedSeptumConfig, domainRoot: string): string {
464
+ if (this.customWorkspaceRoot) {
465
+ return this.customWorkspaceRoot;
466
+ }
467
+ const settings = config.settings as Record<string, unknown> | undefined;
468
+ if (typeof settings?.workspace_root === "string" && settings.workspace_root.trim()) {
469
+ return resolve(settings.workspace_root);
470
+ }
471
+ if (config.settings.db_path && config.settings.db_path !== ":memory:") {
472
+ const dbDir = dirname(resolve(config.settings.db_path));
473
+ const candidate = basename(dbDir) === ".septum" ? dirname(dbDir) : dbDir;
474
+ const absDomainRoot = resolve(domainRoot);
475
+ if (absDomainRoot.startsWith(candidate)) {
476
+ return candidate;
477
+ }
478
+ }
479
+ return process.cwd();
480
+ }
481
+
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
+
508
+ const segments = normPath.split("/");
509
+ if (segments.includes(normPattern)) {
510
+ return true;
511
+ }
512
+ }
513
+ return false;
514
+ }
515
+
516
+ private isSourceFile(path: string): boolean {
517
+ return /\.(ts|tsx|js|jsx|mjs|cjs|php|py|go|rs|vue|astro|svelte)$/i.test(path);
518
+ }
519
+
520
+ /**
521
+ * JIT single-file ingestion: quickly verifies and updates a single file's AST,
522
+ * 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
+ */
525
+ public async ingestFile(targetFilePath: string, config: ValidatedSeptumConfig): Promise<boolean> {
526
+ const fullPath = resolve(targetFilePath);
527
+ if (!existsSync(fullPath)) return false;
528
+
529
+ let stat;
530
+ try {
531
+ stat = statSync(fullPath);
532
+ if (!stat.isFile() || !this.isSourceFile(fullPath)) return false;
533
+ } catch {
534
+ return false;
535
+ }
536
+
537
+ const mtimeMs = Math.round(stat.mtimeMs);
538
+ const sizeBytes = stat.size;
539
+
540
+ const projectRoot = this.customWorkspaceRoot || process.cwd();
541
+ const relPath = relative(projectRoot, fullPath).replace(/\\/g, "/");
542
+
543
+ if (this.shouldIgnore(relPath, config.settings.ignore_patterns ?? [])) {
544
+ return false;
545
+ }
546
+
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
+ }
559
+
560
+ if (!targetDomainName || !targetDomainCfg) {
561
+ return false;
562
+ }
563
+
564
+ const domainId = this.repo.upsertDomain(targetDomainName, targetDomainCfg);
565
+ const existingFile = this.repo.getFileByPath(relPath);
566
+
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
574
+ }
575
+
576
+ const content = readFileSync(fullPath, "utf-8");
577
+ const currentHash = computeContentHash(content);
578
+
579
+ // Tier 2: Hash verification
580
+ if (existingFile && existingFile.content_hash === currentHash) {
581
+ this.repo.updateFileMetadataOnly(existingFile.id, mtimeMs, sizeBytes);
582
+ return false;
583
+ }
584
+
585
+ const lineCount = content.split("\n").length;
586
+ const archetype = detectArchetype(relPath, targetDomainCfg.archetypes ?? {});
587
+ const ast = await this.parserEngine.parseFile(fullPath, content);
588
+
589
+ this.repo.runInTransaction(() => {
590
+ const fileId = this.repo.upsertFile(
591
+ domainId,
592
+ relPath,
593
+ archetype,
594
+ currentHash,
595
+ lineCount,
596
+ mtimeMs,
597
+ sizeBytes
598
+ );
599
+ this.repo.replaceFileSymbols(fileId, ast.symbols);
600
+ this.repo.replaceFileDependencies(fileId, ast.dependencies);
601
+ });
602
+
603
+ return true;
604
+ }
605
+ }
606
+