septum 0.1.3 → 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 (35) hide show
  1. package/README.md +5 -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/core/database/repositories/environment.repository.ts +207 -0
  7. package/src/core/database/repositories/symbol.repository.ts +28 -9
  8. package/src/core/database/repository.ts +6 -1
  9. package/src/core/database/schema.ts +153 -120
  10. package/src/core/parser/behavioral-analyzer.ts +119 -0
  11. package/src/core/parser/boundary-tracker.ts +160 -0
  12. package/src/core/parser/extractors/base.ts +1 -0
  13. package/src/core/parser/extractors/blade.ts +158 -158
  14. package/src/core/parser/extractors/go.ts +5 -1
  15. package/src/core/parser/extractors/laravel-semantic.ts +320 -4
  16. package/src/core/parser/extractors/php.ts +5 -1
  17. package/src/core/parser/extractors/python.ts +3 -1
  18. package/src/core/parser/extractors/typescript.ts +5 -1
  19. package/src/core/parser/schema/providers/laravel-migration-provider.ts +215 -0
  20. package/src/core/parser/schema/providers/prisma-schema-provider.ts +89 -0
  21. package/src/core/parser/schema/schema-provider-registry.ts +43 -0
  22. package/src/core/parser/schema/schema-provider.interface.ts +33 -0
  23. package/src/core/resolver/path-resolver.ts +147 -147
  24. package/src/core/resolver/vertical-slice-tracer.ts +34 -1
  25. package/src/core/topology/environment-detector.ts +860 -0
  26. package/src/core/topology/types.ts +83 -0
  27. package/src/index.ts +3 -0
  28. package/src/mcp/schemas.ts +48 -0
  29. package/src/mcp/server.ts +62 -2
  30. package/src/mcp/tools/get-environment-topology.ts +141 -0
  31. package/src/mcp/tools/get-symbol-hotspots.ts +13 -3
  32. package/src/mcp/tools/register-domain.ts +1 -1
  33. package/src/mcp/tools/trace-vertical-slice.ts +9 -64
  34. package/src/types/index.ts +16 -0
  35. package/src/version.ts +3 -3
@@ -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,147 +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
- }
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
+ }