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.
- package/README.md +5 -2
- package/package.json +1 -1
- package/src/cli/commands/slice.ts +4 -46
- package/src/cli/commands/topology.ts +119 -0
- package/src/cli/index.ts +11 -0
- package/src/core/database/repositories/environment.repository.ts +207 -0
- package/src/core/database/repositories/symbol.repository.ts +28 -9
- package/src/core/database/repository.ts +6 -1
- package/src/core/database/schema.ts +153 -120
- package/src/core/parser/behavioral-analyzer.ts +119 -0
- package/src/core/parser/boundary-tracker.ts +160 -0
- package/src/core/parser/extractors/base.ts +1 -0
- package/src/core/parser/extractors/blade.ts +158 -158
- package/src/core/parser/extractors/go.ts +5 -1
- package/src/core/parser/extractors/laravel-semantic.ts +320 -4
- package/src/core/parser/extractors/php.ts +5 -1
- package/src/core/parser/extractors/python.ts +3 -1
- package/src/core/parser/extractors/typescript.ts +5 -1
- package/src/core/parser/schema/providers/laravel-migration-provider.ts +215 -0
- package/src/core/parser/schema/providers/prisma-schema-provider.ts +89 -0
- package/src/core/parser/schema/schema-provider-registry.ts +43 -0
- package/src/core/parser/schema/schema-provider.interface.ts +33 -0
- package/src/core/resolver/path-resolver.ts +147 -147
- package/src/core/resolver/vertical-slice-tracer.ts +34 -1
- package/src/core/topology/environment-detector.ts +860 -0
- package/src/core/topology/types.ts +83 -0
- package/src/index.ts +3 -0
- package/src/mcp/schemas.ts +48 -0
- package/src/mcp/server.ts +62 -2
- package/src/mcp/tools/get-environment-topology.ts +141 -0
- package/src/mcp/tools/get-symbol-hotspots.ts +13 -3
- package/src/mcp/tools/register-domain.ts +1 -1
- package/src/mcp/tools/trace-vertical-slice.ts +9 -64
- package/src/types/index.ts +16 -0
- 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
|
+
}
|