septum 0.1.6 → 0.1.7

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.
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
- import { basename, dirname, join, relative, resolve } from "node:path";
2
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
3
3
  import type { ParsedFileAST } from "../../types/index.ts";
4
4
  import type { ValidatedSeptumConfig } from "../config/schema.ts";
5
5
  import type { SeptumRepository } from "../database/repository.ts";
@@ -8,6 +8,7 @@ import { SemanticSliceExtractorRegistry } from "../parser/extractors/registry.ts
8
8
  import { TopologyDetector } from "../discovery/topology-detector.ts";
9
9
  import { ASTParserEngine } from "../parser/tree-sitter.ts";
10
10
  import { CallGraphTracer } from "../resolver/call-graph-tracer.ts";
11
+ import { ModuleResolver } from "../resolver/module-resolver.ts";
11
12
  import { computeContentHash } from "./hasher.ts";
12
13
 
13
14
  export interface IngestionMetrics {
@@ -33,13 +34,15 @@ export class IngestionPipeline {
33
34
  }
34
35
  }
35
36
 
36
- public async run(config: ValidatedSeptumConfig): Promise<IngestionMetrics> {
37
+ public async run(config: ValidatedSeptumConfig, options: { verifyContent?: boolean } = {}): Promise<IngestionMetrics> {
38
+ this.ensureParserRevision();
37
39
  const startTime = performance.now();
38
40
  let filesScanned = 0;
39
41
  let filesUpdated = 0;
40
42
  let filesSkipped = 0;
41
43
  let symbolsIndexed = 0;
42
44
  let dependenciesIndexed = 0;
45
+ const unsupportedFiles: string[] = [];
43
46
 
44
47
  let domainNames = Object.keys(config.domains);
45
48
  const projectRoot = this.customWorkspaceRoot || process.cwd();
@@ -71,17 +74,16 @@ export class IngestionPipeline {
71
74
  const absDomainRoot = resolve(workspaceRoot, domainCfg.root);
72
75
 
73
76
  if (!existsSync(absDomainRoot)) {
77
+ this.repo.files.deleteFilesNotInPaths(domainId, []);
74
78
  continue;
75
79
  }
76
80
 
77
- const normCurrentRoot = domainCfg.root.replace(/\\/g, "/").replace(/^\.\/?/, "");
78
81
  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 + "/");
82
+ .filter(name => name !== domainName)
83
+ .map(name => relative(workspaceRoot, resolve(workspaceRoot, config.domains[name].root)).replace(/\\/g, "/"))
84
+ .filter(child => {
85
+ const fromParent = relative(absDomainRoot, resolve(workspaceRoot, child)).replace(/\\/g, "/");
86
+ return fromParent !== "" && fromParent !== ".." && !fromParent.startsWith("../") && !isAbsolute(fromParent);
85
87
  });
86
88
 
87
89
  const collectedFiles = this.collectFiles(
@@ -120,12 +122,13 @@ export class IngestionPipeline {
120
122
  continue;
121
123
  }
122
124
 
123
- const mtimeMs = Math.round(stat.mtimeMs);
125
+ const mtimeMs = stat.mtimeMs;
124
126
  const sizeBytes = stat.size;
125
127
  const existingFile = existingFilesMap.get(relPath);
126
128
 
127
129
  // Tier 1: Fast OS metadata check (inode mtime + size + domain match)
128
130
  if (
131
+ !options.verifyContent &&
129
132
  existingFile &&
130
133
  existingFile.domain_id === domainId &&
131
134
  existingFile.mtime_ms === mtimeMs &&
@@ -150,11 +153,17 @@ export class IngestionPipeline {
150
153
  continue;
151
154
  }
152
155
 
153
- filesUpdated++;
154
156
  const lineCount = content.split("\n").length;
155
157
  const archetype = detectArchetype(relPath, domainCfg.archetypes ?? {});
156
158
  const ast = await this.parserEngine.parseFile(fullPath, content);
159
+ if (ast.status === "unsupported") {
160
+ unsupportedFiles.push(relPath);
161
+ this.repo.deleteFileByPath(relPath);
162
+ continue;
163
+ }
164
+ if (ast.status === "degraded") throw new Error(`Cannot reliably index '${relPath}': parser is degraded.`);
157
165
 
166
+ filesUpdated++;
158
167
  pendingUpdates.push({
159
168
  fullPath,
160
169
  relPath,
@@ -229,6 +238,7 @@ export class IngestionPipeline {
229
238
  this.repo.setMeta("files_updated", String(filesUpdated));
230
239
  this.repo.setMeta("symbols_indexed", String(symbolsIndexed));
231
240
  this.repo.setMeta("dependencies_indexed", String(dependenciesIndexed));
241
+ this.repo.setMeta("unsupported_files", JSON.stringify(unsupportedFiles));
232
242
  });
233
243
 
234
244
  return {
@@ -514,15 +524,27 @@ export class IngestionPipeline {
514
524
  }
515
525
 
516
526
  private isSourceFile(path: string): boolean {
517
- return /\.(ts|tsx|js|jsx|mjs|cjs|php|py|go|rs|vue|astro|svelte)$/i.test(path);
527
+ return ASTParserEngine.isSourceFile(path);
528
+ }
529
+
530
+ private ensureParserRevision(): void {
531
+ const revision = "2";
532
+ if (this.repo.getMeta("parser_revision") === revision) return;
533
+ this.repo.runInTransaction(() => {
534
+ for (const file of this.repo.getAllFiles()) {
535
+ this.repo.upsertFile(file.domain_id, file.path, file.archetype, "", file.line_count, -1, -1);
536
+ }
537
+ this.repo.clearVerticalSlices();
538
+ this.repo.setMeta("parser_revision", revision);
539
+ });
518
540
  }
519
541
 
520
542
  /**
521
543
  * JIT single-file ingestion: quickly verifies and updates a single file's AST,
522
544
  * 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
545
  */
525
546
  public async ingestFile(targetFilePath: string, config: ValidatedSeptumConfig): Promise<boolean> {
547
+ this.ensureParserRevision();
526
548
  const fullPath = resolve(targetFilePath);
527
549
  if (!existsSync(fullPath)) return false;
528
550
 
@@ -534,7 +556,7 @@ export class IngestionPipeline {
534
556
  return false;
535
557
  }
536
558
 
537
- const mtimeMs = Math.round(stat.mtimeMs);
559
+ const mtimeMs = stat.mtimeMs;
538
560
  const sizeBytes = stat.size;
539
561
 
540
562
  const projectRoot = this.customWorkspaceRoot || process.cwd();
@@ -544,18 +566,8 @@ export class IngestionPipeline {
544
566
  return false;
545
567
  }
546
568
 
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
- }
569
+ const targetDomainName = new ModuleResolver(projectRoot, this.repo).resolveSourceDomain(fullPath, config);
570
+ const targetDomainCfg = targetDomainName ? config.domains[targetDomainName] : null;
559
571
 
560
572
  if (!targetDomainName || !targetDomainCfg) {
561
573
  return false;
@@ -564,20 +576,11 @@ export class IngestionPipeline {
564
576
  const domainId = this.repo.upsertDomain(targetDomainName, targetDomainCfg);
565
577
  const existingFile = this.repo.getFileByPath(relPath);
566
578
 
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
579
  const content = readFileSync(fullPath, "utf-8");
577
580
  const currentHash = computeContentHash(content);
578
581
 
579
582
  // Tier 2: Hash verification
580
- if (existingFile && existingFile.content_hash === currentHash) {
583
+ if (existingFile && existingFile.domain_id === domainId && existingFile.content_hash === currentHash) {
581
584
  this.repo.updateFileMetadataOnly(existingFile.id, mtimeMs, sizeBytes);
582
585
  return false;
583
586
  }
@@ -585,6 +588,14 @@ export class IngestionPipeline {
585
588
  const lineCount = content.split("\n").length;
586
589
  const archetype = detectArchetype(relPath, targetDomainCfg.archetypes ?? {});
587
590
  const ast = await this.parserEngine.parseFile(fullPath, content);
591
+ if (ast.status === "unsupported") {
592
+ this.repo.deleteFileByPath(relPath);
593
+ const unsupported = new Set<string>(JSON.parse(this.repo.getMeta("unsupported_files") || "[]"));
594
+ unsupported.add(relPath);
595
+ this.repo.setMeta("unsupported_files", JSON.stringify([...unsupported]));
596
+ return false;
597
+ }
598
+ if (ast.status === "degraded") throw new Error(`Cannot reliably index '${relPath}': parser is degraded.`);
588
599
 
589
600
  this.repo.runInTransaction(() => {
590
601
  const fileId = this.repo.upsertFile(
@@ -598,6 +609,9 @@ export class IngestionPipeline {
598
609
  );
599
610
  this.repo.replaceFileSymbols(fileId, ast.symbols);
600
611
  this.repo.replaceFileDependencies(fileId, ast.dependencies);
612
+ this.repo.clearVerticalSlices();
613
+ const unsupported = JSON.parse(this.repo.getMeta("unsupported_files") || "[]") as string[];
614
+ this.repo.setMeta("unsupported_files", JSON.stringify(unsupported.filter(path => path !== relPath)));
601
615
  });
602
616
 
603
617
  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 {
@@ -44,10 +44,10 @@ export class PHPExtractor implements CodeExtractor {
44
44
  this.extractFromAst(rootNode, content)
45
45
  );
46
46
  if (astResult) {
47
- return astResult;
47
+ return { ...astResult, status: "parsed" };
48
48
  }
49
49
 
50
- return this.extractFallback(content);
50
+ return { ...this.extractFallback(content), status: "degraded" };
51
51
  }
52
52
 
53
53
  private isExternalNamespace(cleaned: string): boolean {
@@ -79,10 +79,10 @@ export class PythonExtractor implements CodeExtractor {
79
79
  this.extractFromAst(rootNode, content)
80
80
  );
81
81
  if (astResult) {
82
- return astResult;
82
+ return { ...astResult, status: "parsed" };
83
83
  }
84
84
 
85
- return this.extractFallback(content);
85
+ return { ...this.extractFallback(content), status: "degraded" };
86
86
  }
87
87
 
88
88
  private extractFallback(content: string): ParsedFileAST {
@@ -46,10 +46,10 @@ export class TypeScriptExtractor implements CodeExtractor {
46
46
  );
47
47
 
48
48
  if (treeSitterResult) {
49
- return treeSitterResult;
49
+ return { ...treeSitterResult, status: "parsed" };
50
50
  }
51
51
 
52
- return this.extractFallback(content);
52
+ return { ...this.extractFallback(content), status: "degraded" };
53
53
  }
54
54
 
55
55
  private resolveLanguageKey(filePath: string): string {
@@ -8,6 +8,9 @@ import { PythonExtractor } from "./extractors/python.ts";
8
8
  import { TypeScriptExtractor } from "./extractors/typescript.ts";
9
9
 
10
10
  export class ASTParserEngine {
11
+ public static isSourceFile(filePath: string): boolean {
12
+ return /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|php|py|go|rs|java|cs|rb|vue|astro|svelte)$/i.test(filePath);
13
+ }
11
14
  private extractors: CodeExtractor[];
12
15
 
13
16
  constructor() {
@@ -24,12 +27,14 @@ export class ASTParserEngine {
24
27
  public async parseFile(filePath: string, content: string): Promise<ParsedFileAST> {
25
28
  for (const extractor of this.extractors) {
26
29
  if (extractor.canHandle(filePath)) {
27
- return extractor.extract(filePath, content);
30
+ const result = await extractor.extract(filePath, content);
31
+ return { status: "parsed", ...result };
28
32
  }
29
33
  }
30
34
 
31
35
  // Default fallback extractor for unknown file types
32
36
  return {
37
+ status: "unsupported",
33
38
  symbols: [],
34
39
  dependencies: [],
35
40
  };
@@ -0,0 +1,17 @@
1
+ import runtime from "web-tree-sitter/tree-sitter.wasm" with { type: "file" };
2
+ import typescript from "tree-sitter-wasms/out/tree-sitter-typescript.wasm" with { type: "file" };
3
+ import tsx from "tree-sitter-wasms/out/tree-sitter-tsx.wasm" with { type: "file" };
4
+ import javascript from "tree-sitter-wasms/out/tree-sitter-javascript.wasm" with { type: "file" };
5
+ import php from "tree-sitter-wasms/out/tree-sitter-php.wasm" with { type: "file" };
6
+ import python from "tree-sitter-wasms/out/tree-sitter-python.wasm" with { type: "file" };
7
+ import go from "tree-sitter-wasms/out/tree-sitter-go.wasm" with { type: "file" };
8
+
9
+ export const WASM_ASSETS: Record<string, string> = {
10
+ "tree-sitter.wasm": runtime,
11
+ "tree-sitter-typescript.wasm": typescript,
12
+ "tree-sitter-tsx.wasm": tsx,
13
+ "tree-sitter-javascript.wasm": javascript,
14
+ "tree-sitter-php.wasm": php,
15
+ "tree-sitter-python.wasm": python,
16
+ "tree-sitter-go.wasm": go,
17
+ };
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import Parser from "web-tree-sitter";
5
+ import { WASM_ASSETS } from "./wasm-assets.ts";
5
6
 
6
7
  const GRAMMAR_BY_KEY: Record<string, string> = {
7
8
  // Extensions
@@ -74,6 +75,8 @@ export function resolveWasmPath(fileName: string, isCoreRuntime: boolean = false
74
75
  candidates.push(path.join(envDir, "out", fileName));
75
76
  }
76
77
 
78
+ if (WASM_ASSETS[fileName]) candidates.push(WASM_ASSETS[fileName]);
79
+
77
80
  // Tier 2: Walk up from import.meta.dir to package root
78
81
  let currentDir = getModuleDir();
79
82
  for (let depth = 0; depth < 7; depth++) {
@@ -97,16 +97,7 @@ export class ModuleResolver {
97
97
  filePath: string,
98
98
  config: ValidatedSeptumConfig
99
99
  ): string | null {
100
- const absPath = this.toAbsolutePath(filePath);
101
-
102
- for (const [domainName, domainCfg] of Object.entries(config.domains)) {
103
- const domainAbsRoot = this.toAbsolutePath(domainCfg.root);
104
- if (this.isSubPathOrSame(absPath, domainAbsRoot)) {
105
- return domainName;
106
- }
107
- }
108
-
109
- return null;
100
+ return this.matchDomainByPath(filePath, config);
110
101
  }
111
102
 
112
103
  /**
@@ -81,6 +81,7 @@ export class VerticalSliceTracer {
81
81
  if (exact) {
82
82
  return this.formatSuccessResponse(trimmed, exact, "exact", undefined, effectiveMaxDepth);
83
83
  }
84
+ return { query: trimmed, found: false, max_depth_applied: effectiveMaxDepth, message: `No vertical slice found matching query '${trimmed}'.` };
84
85
  }
85
86
 
86
87
  // 2. URI-only match: "/orders" or "orders/{id}"
@@ -1,56 +1,72 @@
1
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { randomUUID } from "node:crypto";
3
4
  import type { ActiveFeatureSession } from "../../types/index.ts";
5
+ import { SeptumDatabase } from "../database/client.ts";
4
6
 
5
7
  export class SessionManager {
6
8
  private static readonly SESSION_FILE = ".septum/session.json";
7
9
 
8
- /**
9
- * Save the active feature session to .septum/session.json
10
- */
11
- public static saveActiveSession(workspaceRoot: string, session: ActiveFeatureSession): void {
12
- const septumDir = join(workspaceRoot, ".septum");
13
- if (!existsSync(septumDir)) {
14
- mkdirSync(septumDir, { recursive: true });
15
- }
16
- const sessionPath = join(workspaceRoot, SessionManager.SESSION_FILE);
17
- writeFileSync(sessionPath, JSON.stringify(session, null, 2), "utf-8");
10
+ public static getOwnerId(): string {
11
+ return process.env.SEPTUM_SESSION_ID || `process:${process.pid}`;
18
12
  }
19
13
 
20
- /**
21
- * Get the active feature session, or null if no session exists or file is invalid
22
- */
23
- public static getActiveSession(workspaceRoot: string): ActiveFeatureSession | null {
24
- const sessionPath = join(workspaceRoot, SessionManager.SESSION_FILE);
25
- if (!existsSync(sessionPath)) {
26
- return null;
14
+ private static mutate<T>(workspaceRoot: string, fn: () => T): T {
15
+ const db = new SeptumDatabase(join(workspaceRoot, ".septum/septum.db"));
16
+ try {
17
+ // Serialize ownership checks with writes across CLI and MCP processes.
18
+ return db.raw.transaction(fn).immediate();
19
+ } finally {
20
+ db.close();
27
21
  }
22
+ }
28
23
 
29
- try {
30
- const raw = readFileSync(sessionPath, "utf-8");
31
- const parsed = JSON.parse(raw) as ActiveFeatureSession;
32
- if (parsed && typeof parsed.feature_key === "string" && Array.isArray(parsed.touchpoints)) {
33
- return parsed;
24
+ public static saveActiveSession(
25
+ workspaceRoot: string,
26
+ session: ActiveFeatureSession,
27
+ ownerId: string = SessionManager.getOwnerId()
28
+ ): void {
29
+ SessionManager.mutate(workspaceRoot, () => {
30
+ const current = SessionManager.getActiveSession(workspaceRoot);
31
+ if (current && current.owner_id !== ownerId) {
32
+ throw new Error(`Workspace already has an active feature session owned by '${current.owner_id || "legacy-session"}'. Release it with its owner, or explicitly run 'septum feature clear --force'.`);
34
33
  }
35
- return null;
36
- } catch {
37
- return null;
38
- }
34
+ const septumDir = join(workspaceRoot, ".septum");
35
+ mkdirSync(septumDir, { recursive: true });
36
+ const sessionPath = join(workspaceRoot, SessionManager.SESSION_FILE);
37
+ const temporaryPath = `${sessionPath}.${randomUUID()}.tmp`;
38
+ try {
39
+ writeFileSync(temporaryPath, JSON.stringify({ ...session, owner_id: ownerId }, null, 2), "utf-8");
40
+ renameSync(temporaryPath, sessionPath);
41
+ } finally {
42
+ rmSync(temporaryPath, { force: true });
43
+ }
44
+ });
39
45
  }
40
46
 
41
- /**
42
- * Clear the active feature session
43
- */
44
- public static clearActiveSession(workspaceRoot: string): boolean {
47
+ public static getActiveSession(workspaceRoot: string): ActiveFeatureSession | null {
45
48
  const sessionPath = join(workspaceRoot, SessionManager.SESSION_FILE);
46
- if (existsSync(sessionPath)) {
47
- try {
48
- rmSync(sessionPath, { force: true });
49
- return true;
50
- } catch {
51
- return false;
49
+ if (!existsSync(sessionPath)) return null;
50
+ const parsed = JSON.parse(readFileSync(sessionPath, "utf-8")) as ActiveFeatureSession;
51
+ if (parsed && typeof parsed.feature_key === "string" && typeof parsed.domain === "string" && Array.isArray(parsed.touchpoints)) return parsed;
52
+ throw new Error(`Invalid active feature session in '${sessionPath}'. Release it explicitly with 'septum feature clear --force'.`);
53
+ }
54
+
55
+ public static clearActiveSession(
56
+ workspaceRoot: string,
57
+ ownerId: string = SessionManager.getOwnerId(),
58
+ force: boolean = false
59
+ ): boolean {
60
+ if (!existsSync(join(workspaceRoot, SessionManager.SESSION_FILE))) return false;
61
+ return SessionManager.mutate(workspaceRoot, () => {
62
+ const sessionPath = join(workspaceRoot, SessionManager.SESSION_FILE);
63
+ if (!existsSync(sessionPath)) return false;
64
+ if (!force) {
65
+ const current = SessionManager.getActiveSession(workspaceRoot);
66
+ if (current?.owner_id !== ownerId) throw new Error("Only the active session owner can release it. Use 'septum feature clear --force' for explicit recovery.");
52
67
  }
53
- }
54
- return false;
68
+ rmSync(sessionPath);
69
+ return true;
70
+ });
55
71
  }
56
72
  }