septum 0.1.2 → 0.1.4

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 (46) hide show
  1. package/README.md +7 -2
  2. package/package.json +1 -1
  3. package/src/cli/commands/slice.ts +4 -46
  4. package/src/cli/commands/topology.ts +119 -0
  5. package/src/cli/index.ts +11 -0
  6. package/src/cli/templates/skill.ts +7 -4
  7. package/src/core/config/loader.ts +21 -8
  8. package/src/core/database/repositories/dependency.repository.ts +70 -0
  9. package/src/core/database/repositories/environment.repository.ts +207 -0
  10. package/src/core/database/repositories/file.repository.ts +7 -0
  11. package/src/core/database/repositories/symbol.repository.ts +28 -9
  12. package/src/core/database/repository.ts +19 -1
  13. package/src/core/database/schema.ts +153 -120
  14. package/src/core/discovery/topology-detector.ts +71 -3
  15. package/src/core/ingestion/pipeline.ts +98 -1
  16. package/src/core/parser/behavioral-analyzer.ts +119 -0
  17. package/src/core/parser/boundary-tracker.ts +160 -0
  18. package/src/core/parser/extractors/base.ts +2 -0
  19. package/src/core/parser/extractors/blade.ts +158 -0
  20. package/src/core/parser/extractors/go.ts +5 -1
  21. package/src/core/parser/extractors/laravel-semantic.ts +506 -31
  22. package/src/core/parser/extractors/php.ts +5 -1
  23. package/src/core/parser/extractors/python.ts +3 -1
  24. package/src/core/parser/extractors/registry.ts +21 -0
  25. package/src/core/parser/extractors/typescript.ts +5 -1
  26. package/src/core/parser/schema/providers/laravel-migration-provider.ts +215 -0
  27. package/src/core/parser/schema/providers/prisma-schema-provider.ts +89 -0
  28. package/src/core/parser/schema/schema-provider-registry.ts +43 -0
  29. package/src/core/parser/schema/schema-provider.interface.ts +33 -0
  30. package/src/core/parser/tree-sitter.ts +2 -0
  31. package/src/core/resolver/path-resolver.ts +147 -0
  32. package/src/core/resolver/vertical-slice-tracer.ts +56 -8
  33. package/src/core/topology/environment-detector.ts +860 -0
  34. package/src/core/topology/types.ts +83 -0
  35. package/src/index.ts +3 -0
  36. package/src/mcp/schemas.ts +264 -46
  37. package/src/mcp/server.ts +130 -26
  38. package/src/mcp/tools/check-boundary.ts +58 -8
  39. package/src/mcp/tools/get-domain-catalog.ts +31 -1
  40. package/src/mcp/tools/get-environment-topology.ts +141 -0
  41. package/src/mcp/tools/get-symbol-hotspots.ts +13 -3
  42. package/src/mcp/tools/locate-symbol.ts +119 -3
  43. package/src/mcp/tools/register-domain.ts +1 -1
  44. package/src/mcp/tools/trace-vertical-slice.ts +11 -65
  45. package/src/types/index.ts +20 -1
  46. package/src/version.ts +3 -3
@@ -46,4 +46,25 @@ export class SemanticSliceExtractorRegistry {
46
46
 
47
47
  return combinedSlices;
48
48
  }
49
+
50
+ /**
51
+ * Synchronous execution of all matched semantic extractors.
52
+ */
53
+ public static extractAllSlicesSync(
54
+ projectRoot: string,
55
+ framework?: string,
56
+ domainId?: number
57
+ ): VerticalSliceCandidate[] {
58
+ const matched = this.getExtractors(projectRoot, framework);
59
+ const combinedSlices: VerticalSliceCandidate[] = [];
60
+
61
+ for (const extractor of matched) {
62
+ const res = extractor.extractSlices(projectRoot, domainId);
63
+ if (Array.isArray(res)) {
64
+ combinedSlices.push(...res);
65
+ }
66
+ }
67
+
68
+ return combinedSlices;
69
+ }
49
70
  }
@@ -1,5 +1,5 @@
1
1
  import type { ExtractedDependency, ExtractedSymbol, ParsedFileAST, Visibility } from "../../../types/index.ts";
2
- import { findBraceBlockEnd } from "../boundary-tracker.ts";
2
+ import { findBraceBlockEnd, calculateControlFlowNesting } from "../boundary-tracker.ts";
3
3
  import type { CodeExtractor } from "./base.ts";
4
4
 
5
5
  export class TypeScriptExtractor implements CodeExtractor {
@@ -103,6 +103,7 @@ export class TypeScriptExtractor implements CodeExtractor {
103
103
  const params = fnMatch[2];
104
104
  const retType = fnMatch[3] ? `: ${fnMatch[3].trim()}` : "";
105
105
  const endLine = findBraceBlockEnd(lines, i);
106
+ const nestingDepth = calculateControlFlowNesting(lines, i, endLine - 1);
106
107
  symbols.push({
107
108
  name,
108
109
  kind: "function",
@@ -111,6 +112,7 @@ export class TypeScriptExtractor implements CodeExtractor {
111
112
  line_start: lineNum,
112
113
  line_end: endLine,
113
114
  line_count: Math.max(1, endLine - lineNum + 1),
115
+ nesting_depth: nestingDepth,
114
116
  });
115
117
  continue;
116
118
  }
@@ -159,6 +161,7 @@ export class TypeScriptExtractor implements CodeExtractor {
159
161
  const params = methodMatch[3];
160
162
  const retType = methodMatch[4] ? `: ${methodMatch[4].trim()}` : "";
161
163
  const endLine = findBraceBlockEnd(lines, i);
164
+ const nestingDepth = calculateControlFlowNesting(lines, i, endLine - 1);
162
165
  symbols.push({
163
166
  name: `${currentClass}::${name}`,
164
167
  kind: "method",
@@ -167,6 +170,7 @@ export class TypeScriptExtractor implements CodeExtractor {
167
170
  line_start: lineNum,
168
171
  line_end: endLine,
169
172
  line_count: Math.max(1, endLine - lineNum + 1),
173
+ nesting_depth: nestingDepth,
170
174
  });
171
175
  }
172
176
  }
@@ -0,0 +1,215 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type {
4
+ SchemaFieldSummary,
5
+ SchemaMetadata,
6
+ StackSchemaProvider,
7
+ } from "../schema-provider.interface.ts";
8
+
9
+ export class LaravelMigrationSchemaProvider implements StackSchemaProvider {
10
+ public readonly id = "laravel-migration";
11
+ public readonly name = "Laravel Migration Schema Provider";
12
+
13
+ public canHandle(projectRoot: string): boolean {
14
+ const migrationsDir = path.join(projectRoot, "database", "migrations");
15
+ return fs.existsSync(migrationsDir) || fs.existsSync(path.join(projectRoot, "artisan"));
16
+ }
17
+
18
+ public resolveEntitySchema(
19
+ projectRoot: string,
20
+ entitySymbol: string,
21
+ entityFilePath?: string
22
+ ): SchemaMetadata | null {
23
+ const migrationsDir = path.join(projectRoot, "database", "migrations");
24
+ if (!fs.existsSync(migrationsDir)) return null;
25
+
26
+ const tableName = this.resolveTableName(projectRoot, entitySymbol, entityFilePath);
27
+ if (!tableName) return null;
28
+
29
+ const migrationFiles = this.findMigrationFilesForTable(migrationsDir, tableName);
30
+ if (migrationFiles.length === 0) return null;
31
+
32
+ const fieldsMap = new Map<string, SchemaFieldSummary>();
33
+ let primarySourceFile = migrationFiles[0];
34
+
35
+ for (const migFile of migrationFiles) {
36
+ const fullPath = path.join(migrationsDir, migFile);
37
+ try {
38
+ const content = fs.readFileSync(fullPath, "utf-8");
39
+ this.extractColumnsFromMigration(content, tableName, fieldsMap);
40
+ } catch {
41
+ // Skip unreadable files
42
+ }
43
+ }
44
+
45
+ if (fieldsMap.size === 0) return null;
46
+
47
+ const relSourceFile = path.relative(projectRoot, path.join(migrationsDir, primarySourceFile));
48
+
49
+ return {
50
+ entityName: entitySymbol,
51
+ tableName,
52
+ schemaSourceFile: relSourceFile,
53
+ fields: Array.from(fieldsMap.values()),
54
+ };
55
+ }
56
+
57
+ private resolveTableName(
58
+ projectRoot: string,
59
+ entitySymbol: string,
60
+ entityFilePath?: string
61
+ ): string {
62
+ // 1. Check if model file defines explicit protected $table = '...';
63
+ if (entityFilePath) {
64
+ const fullEntityPath = path.isAbsolute(entityFilePath)
65
+ ? entityFilePath
66
+ : path.join(projectRoot, entityFilePath);
67
+ if (fs.existsSync(fullEntityPath)) {
68
+ try {
69
+ const content = fs.readFileSync(fullEntityPath, "utf-8");
70
+ const tableMatch = /protected\s+\$table\s*=\s*['"]([^'"]+)['"]/.exec(content);
71
+ if (tableMatch) {
72
+ return tableMatch[1];
73
+ }
74
+ } catch {}
75
+ }
76
+ }
77
+
78
+ // 2. Derive snake_case plural from entity name (e.g. OrderItem -> order_items)
79
+ return this.pluralize(this.toSnakeCase(entitySymbol));
80
+ }
81
+
82
+ private toSnakeCase(str: string): string {
83
+ return str
84
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
85
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
86
+ .toLowerCase();
87
+ }
88
+
89
+ private pluralize(snake: string): string {
90
+ if (snake.endsWith("y") && !/[aeiou]y$/i.test(snake)) {
91
+ return `${snake.slice(0, -1)}ies`;
92
+ }
93
+ if (/(?:s|x|z|ch|sh)$/i.test(snake)) {
94
+ return `${snake}es`;
95
+ }
96
+ return `${snake}s`;
97
+ }
98
+
99
+ private findMigrationFilesForTable(migrationsDir: string, tableName: string): string[] {
100
+ const files = fs.readdirSync(migrationsDir).filter((f) => f.endsWith(".php")).sort();
101
+ const matched: string[] = [];
102
+
103
+ // Priority 1: *_create_${tableName}_table.php
104
+ const createTarget = `_create_${tableName}_table.php`;
105
+ for (const f of files) {
106
+ if (f.endsWith(createTarget)) {
107
+ matched.push(f);
108
+ }
109
+ }
110
+
111
+ // Priority 2: Other migration files altering this table
112
+ for (const f of files) {
113
+ if (matched.includes(f)) continue;
114
+ if (f.includes(`_${tableName}_`)) {
115
+ matched.push(f);
116
+ }
117
+ }
118
+
119
+ // If still not found by filename, do a lightweight content search
120
+ if (matched.length === 0) {
121
+ for (const f of files) {
122
+ try {
123
+ const content = fs.readFileSync(path.join(migrationsDir, f), "utf-8");
124
+ if (
125
+ content.includes(`Schema::create('${tableName}'`) ||
126
+ content.includes(`Schema::create("${tableName}"`) ||
127
+ content.includes(`Schema::table('${tableName}'`) ||
128
+ content.includes(`Schema::table("${tableName}"`)
129
+ ) {
130
+ matched.push(f);
131
+ }
132
+ } catch {}
133
+ }
134
+ }
135
+
136
+ return matched;
137
+ }
138
+
139
+ private extractColumnsFromMigration(
140
+ content: string,
141
+ tableName: string,
142
+ fieldsMap: Map<string, SchemaFieldSummary>
143
+ ): void {
144
+ const lines = content.split("\n");
145
+
146
+ for (const rawLine of lines) {
147
+ const line = rawLine.trim();
148
+ if (!line.includes("$table->")) continue;
149
+
150
+ // Special shorthand: $table->id();
151
+ if (/\$table->id\s*\(\s*\)/.test(line)) {
152
+ fieldsMap.set("id", { name: "id", type: "id", isPrimary: true });
153
+ continue;
154
+ }
155
+
156
+ // Special shorthand: $table->id('custom_id');
157
+ const customIdMatch = /\$table->id\s*\(\s*['"]([^'"]+)['"]\s*\)/.exec(line);
158
+ if (customIdMatch) {
159
+ fieldsMap.set(customIdMatch[1], { name: customIdMatch[1], type: "id", isPrimary: true });
160
+ continue;
161
+ }
162
+
163
+ // Special shorthand: $table->timestamps();
164
+ if (/\$table->timestamps\s*\(/.test(line)) {
165
+ if (!fieldsMap.has("created_at")) {
166
+ fieldsMap.set("created_at", { name: "created_at", type: "timestamp", nullable: true });
167
+ }
168
+ if (!fieldsMap.has("updated_at")) {
169
+ fieldsMap.set("updated_at", { name: "updated_at", type: "timestamp", nullable: true });
170
+ }
171
+ continue;
172
+ }
173
+
174
+ // Special shorthand: $table->softDeletes();
175
+ if (/\$table->softDeletes\s*\(/.test(line)) {
176
+ if (!fieldsMap.has("deleted_at")) {
177
+ fieldsMap.set("deleted_at", { name: "deleted_at", type: "timestamp", nullable: true });
178
+ }
179
+ continue;
180
+ }
181
+
182
+ // Special shorthand: $table->rememberToken();
183
+ if (/\$table->rememberToken\s*\(/.test(line)) {
184
+ if (!fieldsMap.has("remember_token")) {
185
+ fieldsMap.set("remember_token", { name: "remember_token", type: "string", nullable: true });
186
+ }
187
+ continue;
188
+ }
189
+
190
+ // Standard column definitions: $table->type('column_name', ...)
191
+ const colMatch = /\$table->([a-zA-Z0-9_]+)\s*\(\s*['"]([^'"]+)['"]/.exec(line);
192
+ if (colMatch) {
193
+ const type = colMatch[1];
194
+ const name = colMatch[2];
195
+
196
+ // Skip non-column fluent methods like foreign, dropColumn, index, primary
197
+ if (["index", "unique", "primary", "foreign", "dropColumn", "dropForeign"].includes(type)) {
198
+ continue;
199
+ }
200
+
201
+ const nullable = line.includes("->nullable()");
202
+ const isPrimary = type === "increments" || type === "bigIncrements" || line.includes("->primary()");
203
+ const isForeign = type.startsWith("foreign") || name.endsWith("_id");
204
+
205
+ fieldsMap.set(name, {
206
+ name,
207
+ type,
208
+ nullable: nullable || undefined,
209
+ isPrimary: isPrimary || undefined,
210
+ isForeign: isForeign || undefined,
211
+ });
212
+ }
213
+ }
214
+ }
215
+ }
@@ -0,0 +1,89 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type {
4
+ SchemaFieldSummary,
5
+ SchemaMetadata,
6
+ StackSchemaProvider,
7
+ } from "../schema-provider.interface.ts";
8
+
9
+ export class PrismaSchemaProvider implements StackSchemaProvider {
10
+ public readonly id = "prisma";
11
+ public readonly name = "Prisma Schema Provider";
12
+
13
+ public canHandle(projectRoot: string): boolean {
14
+ return (
15
+ fs.existsSync(path.join(projectRoot, "prisma", "schema.prisma")) ||
16
+ fs.existsSync(path.join(projectRoot, "schema.prisma"))
17
+ );
18
+ }
19
+
20
+ public resolveEntitySchema(
21
+ projectRoot: string,
22
+ entitySymbol: string,
23
+ _entityFilePath?: string
24
+ ): SchemaMetadata | null {
25
+ const candidatePaths = [
26
+ path.join(projectRoot, "prisma", "schema.prisma"),
27
+ path.join(projectRoot, "schema.prisma"),
28
+ ];
29
+
30
+ let schemaPath: string | null = null;
31
+ for (const p of candidatePaths) {
32
+ if (fs.existsSync(p)) {
33
+ schemaPath = p;
34
+ break;
35
+ }
36
+ }
37
+
38
+ if (!schemaPath) return null;
39
+
40
+ try {
41
+ const content = fs.readFileSync(schemaPath, "utf-8");
42
+ const modelRegex = new RegExp(`model\\s+${entitySymbol}\\s*\\{([^}]+)\\}`, "m");
43
+ const match = modelRegex.exec(content);
44
+ if (!match) return null;
45
+
46
+ const body = match[1];
47
+ const lines = body.split("\n");
48
+ const fields: SchemaFieldSummary[] = [];
49
+
50
+ for (const rawLine of lines) {
51
+ const line = rawLine.trim();
52
+ if (!line || line.startsWith("//") || line.startsWith("@@")) continue;
53
+
54
+ // Pattern: fieldName FieldType attributes...
55
+ const parts = line.split(/\s+/);
56
+ if (parts.length >= 2) {
57
+ const name = parts[0];
58
+ let type = parts[1];
59
+ const nullable = type.endsWith("?");
60
+ if (nullable) {
61
+ type = type.slice(0, -1);
62
+ }
63
+
64
+ const isPrimary = line.includes("@id");
65
+ const isForeign = line.includes("@relation");
66
+
67
+ fields.push({
68
+ name,
69
+ type,
70
+ nullable: nullable || undefined,
71
+ isPrimary: isPrimary || undefined,
72
+ isForeign: isForeign || undefined,
73
+ });
74
+ }
75
+ }
76
+
77
+ if (fields.length === 0) return null;
78
+
79
+ return {
80
+ entityName: entitySymbol,
81
+ tableName: entitySymbol.toLowerCase(),
82
+ schemaSourceFile: path.relative(projectRoot, schemaPath),
83
+ fields,
84
+ };
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+ }
@@ -0,0 +1,43 @@
1
+ import type {
2
+ SchemaMetadata,
3
+ StackSchemaProvider,
4
+ } from "./schema-provider.interface.ts";
5
+ import { LaravelMigrationSchemaProvider } from "./providers/laravel-migration-provider.ts";
6
+ import { PrismaSchemaProvider } from "./providers/prisma-schema-provider.ts";
7
+
8
+ export class SchemaProviderRegistry {
9
+ private static providers: StackSchemaProvider[] = [
10
+ new LaravelMigrationSchemaProvider(),
11
+ new PrismaSchemaProvider(),
12
+ ];
13
+
14
+ /**
15
+ * Registers a custom schema provider.
16
+ */
17
+ public static register(provider: StackSchemaProvider): void {
18
+ this.providers.unshift(provider);
19
+ }
20
+
21
+ /**
22
+ * Resolves physical schema metadata across all registered providers.
23
+ */
24
+ public static resolveSchema(
25
+ projectRoot: string,
26
+ entitySymbol: string,
27
+ entityFilePath?: string
28
+ ): SchemaMetadata | null {
29
+ for (const provider of this.providers) {
30
+ if (provider.canHandle(projectRoot)) {
31
+ try {
32
+ const res = provider.resolveEntitySchema(projectRoot, entitySymbol, entityFilePath);
33
+ if (res && !(res instanceof Promise)) {
34
+ return res;
35
+ }
36
+ } catch {
37
+ // Graceful fallback
38
+ }
39
+ }
40
+ }
41
+ return null;
42
+ }
43
+ }
@@ -0,0 +1,33 @@
1
+ export interface SchemaFieldSummary {
2
+ name: string;
3
+ type: string;
4
+ nullable?: boolean;
5
+ isPrimary?: boolean;
6
+ isForeign?: boolean;
7
+ }
8
+
9
+ export interface SchemaMetadata {
10
+ entityName: string;
11
+ tableName: string;
12
+ schemaSourceFile: string;
13
+ fields: SchemaFieldSummary[];
14
+ }
15
+
16
+ export interface StackSchemaProvider {
17
+ readonly id: string;
18
+ readonly name: string;
19
+
20
+ /**
21
+ * Determines if this schema provider can handle the project.
22
+ */
23
+ canHandle(projectRoot: string): boolean;
24
+
25
+ /**
26
+ * Resolves physical schema metadata (table name, source file, fields) for a given entity symbol.
27
+ */
28
+ resolveEntitySchema(
29
+ projectRoot: string,
30
+ entitySymbol: string,
31
+ entityFilePath?: string
32
+ ): Promise<SchemaMetadata | null> | SchemaMetadata | null;
33
+ }
@@ -1,5 +1,6 @@
1
1
  import type { ParsedFileAST } from "../../types/index.ts";
2
2
  import type { CodeExtractor } from "./extractors/base.ts";
3
+ import { BladeExtractor } from "./extractors/blade.ts";
3
4
  import { FrontendExtractor } from "./extractors/frontend.ts";
4
5
  import { GoExtractor } from "./extractors/go.ts";
5
6
  import { PHPExtractor } from "./extractors/php.ts";
@@ -11,6 +12,7 @@ export class ASTParserEngine {
11
12
 
12
13
  constructor() {
13
14
  this.extractors = [
15
+ new BladeExtractor(),
14
16
  new FrontendExtractor(),
15
17
  new TypeScriptExtractor(),
16
18
  new PHPExtractor(),
@@ -0,0 +1,147 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, normalize, resolve } from "node:path";
3
+
4
+ export const PROJECT_ROOT_MARKERS = [
5
+ "package.json",
6
+ "composer.json",
7
+ "go.mod",
8
+ "pyproject.toml",
9
+ "Cargo.toml",
10
+ ".git",
11
+ ".septum",
12
+ "pnpm-workspace.yaml",
13
+ "lerna.json",
14
+ "tsconfig.json",
15
+ "jsconfig.json",
16
+ ];
17
+
18
+ let lastKnownProjectRoot: string | undefined;
19
+
20
+ /**
21
+ * Normalizes and canonicalizes a path for cross-platform consistency.
22
+ * On Windows, uppercases drive letters (e.g. "c:\" -> "C:\") to eliminate map lookup misses.
23
+ */
24
+ export function canonicalizePath(targetPath: string): string {
25
+ if (!targetPath || typeof targetPath !== "string") {
26
+ return targetPath;
27
+ }
28
+ let abs = normalize(resolve(targetPath));
29
+ if (process.platform === "win32") {
30
+ abs = abs.replace(/^[a-zA-Z]:/, (m) => m.toUpperCase());
31
+ }
32
+ return abs;
33
+ }
34
+
35
+ export function setLastKnownProjectRoot(root: string): void {
36
+ if (root && typeof root === "string") {
37
+ lastKnownProjectRoot = canonicalizePath(root);
38
+ }
39
+ }
40
+
41
+ export function getLastKnownProjectRoot(): string | undefined {
42
+ return lastKnownProjectRoot;
43
+ }
44
+
45
+ export function clearLastKnownProjectRoot(): void {
46
+ lastKnownProjectRoot = undefined;
47
+ }
48
+
49
+ /**
50
+ * Checks whether a given directory is an MCP host / IDE installation directory
51
+ * (e.g. Antigravity Electron install directory) rather than a user project codebase.
52
+ */
53
+ export function isAppHostDirectory(dirPath: string): boolean {
54
+ try {
55
+ const norm = dirPath.toLowerCase().replace(/\\/g, "/");
56
+ if (norm.includes("/antigravity") || norm.includes("/appdata/local/programs/antigravity")) {
57
+ // Check if it has Electron app runtime signatures
58
+ if (
59
+ existsSync(join(dirPath, "resources", "app.asar")) ||
60
+ existsSync(join(dirPath, "resources", "app.asar.unpacked")) ||
61
+ existsSync(join(dirPath, "locales"))
62
+ ) {
63
+ return true;
64
+ }
65
+ }
66
+ } catch {
67
+ // Ignore error
68
+ }
69
+ return false;
70
+ }
71
+
72
+ /**
73
+ * Discovers the project root directory by walking up looking for marker files.
74
+ */
75
+ export function findProjectRoot(fromPath: string): string {
76
+ try {
77
+ let dir = resolve(fromPath);
78
+
79
+ if (existsSync(dir)) {
80
+ const stat = statSync(dir);
81
+ if (!stat.isDirectory()) {
82
+ dir = dirname(dir);
83
+ }
84
+ } else {
85
+ dir = dirname(dir);
86
+ }
87
+
88
+ let current = dir;
89
+ while (true) {
90
+ // Don't treat an app host directory as a project root
91
+ if (!isAppHostDirectory(current)) {
92
+ for (const marker of PROJECT_ROOT_MARKERS) {
93
+ if (existsSync(join(current, marker))) {
94
+ return canonicalizePath(current);
95
+ }
96
+ }
97
+ }
98
+
99
+ const parent = dirname(current);
100
+ if (parent === current) break;
101
+ current = parent;
102
+ }
103
+ } catch {
104
+ // Filesystem traversal error fallback
105
+ }
106
+
107
+ return canonicalizePath(dirname(resolve(fromPath)));
108
+ }
109
+
110
+ /**
111
+ * Ergonomically resolves the active workspace root:
112
+ * 1. Explicit pathHint (if provided and valid).
113
+ * 2. SEPTUM_WORKSPACE_ROOT environment variable.
114
+ * 3. lastKnownProjectRoot (if cached from earlier tool calls).
115
+ * 4. findProjectRoot from process.cwd() (if not an app host directory).
116
+ */
117
+ export function resolveWorkspaceRoot(pathHint?: string): string {
118
+ if (pathHint && typeof pathHint === "string" && pathHint.trim()) {
119
+ const trimmed = pathHint.trim().replace(/^['"]|['"]$/g, "");
120
+ const candidate = findProjectRoot(trimmed);
121
+ if (candidate && existsSync(candidate) && !isAppHostDirectory(candidate)) {
122
+ setLastKnownProjectRoot(candidate);
123
+ return candidate;
124
+ }
125
+ }
126
+
127
+ const envRoot = process.env.SEPTUM_WORKSPACE_ROOT || process.env.WORKSPACE_ROOT;
128
+ if (envRoot && existsSync(envRoot) && !isAppHostDirectory(envRoot)) {
129
+ const candidate = canonicalizePath(envRoot);
130
+ setLastKnownProjectRoot(candidate);
131
+ return candidate;
132
+ }
133
+
134
+ if (lastKnownProjectRoot && existsSync(lastKnownProjectRoot)) {
135
+ return lastKnownProjectRoot;
136
+ }
137
+
138
+ const cwd = process.cwd();
139
+ if (!isAppHostDirectory(cwd)) {
140
+ const candidate = findProjectRoot(cwd);
141
+ setLastKnownProjectRoot(candidate);
142
+ return candidate;
143
+ }
144
+
145
+ // Fallback if inside app host directory: keep looking in lastKnown or return cwd
146
+ return canonicalizePath(cwd);
147
+ }