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.
- package/README.md +7 -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/cli/templates/skill.ts +7 -4
- package/src/core/config/loader.ts +21 -8
- package/src/core/database/repositories/dependency.repository.ts +70 -0
- package/src/core/database/repositories/environment.repository.ts +207 -0
- package/src/core/database/repositories/file.repository.ts +7 -0
- package/src/core/database/repositories/symbol.repository.ts +28 -9
- package/src/core/database/repository.ts +19 -1
- package/src/core/database/schema.ts +153 -120
- package/src/core/discovery/topology-detector.ts +71 -3
- package/src/core/ingestion/pipeline.ts +98 -1
- 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 +2 -0
- package/src/core/parser/extractors/blade.ts +158 -0
- package/src/core/parser/extractors/go.ts +5 -1
- package/src/core/parser/extractors/laravel-semantic.ts +506 -31
- package/src/core/parser/extractors/php.ts +5 -1
- package/src/core/parser/extractors/python.ts +3 -1
- package/src/core/parser/extractors/registry.ts +21 -0
- 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/parser/tree-sitter.ts +2 -0
- package/src/core/resolver/path-resolver.ts +147 -0
- package/src/core/resolver/vertical-slice-tracer.ts +56 -8
- 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 +264 -46
- package/src/mcp/server.ts +130 -26
- package/src/mcp/tools/check-boundary.ts +58 -8
- package/src/mcp/tools/get-domain-catalog.ts +31 -1
- package/src/mcp/tools/get-environment-topology.ts +141 -0
- package/src/mcp/tools/get-symbol-hotspots.ts +13 -3
- package/src/mcp/tools/locate-symbol.ts +119 -3
- package/src/mcp/tools/register-domain.ts +1 -1
- package/src/mcp/tools/trace-vertical-slice.ts +11 -65
- package/src/types/index.ts +20 -1
- 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
|
+
}
|