septum 0.1.2 → 0.1.3
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 +2 -0
- package/package.json +1 -1
- 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/file.repository.ts +7 -0
- package/src/core/database/repository.ts +13 -0
- package/src/core/discovery/topology-detector.ts +71 -3
- package/src/core/ingestion/pipeline.ts +98 -1
- package/src/core/parser/extractors/base.ts +1 -0
- package/src/core/parser/extractors/blade.ts +158 -0
- package/src/core/parser/extractors/laravel-semantic.ts +186 -27
- package/src/core/parser/extractors/registry.ts +21 -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 +22 -7
- package/src/mcp/schemas.ts +216 -46
- package/src/mcp/server.ts +68 -24
- package/src/mcp/tools/check-boundary.ts +58 -8
- package/src/mcp/tools/get-domain-catalog.ts +31 -1
- package/src/mcp/tools/locate-symbol.ts +119 -3
- package/src/mcp/tools/trace-vertical-slice.ts +2 -1
- package/src/types/index.ts +4 -1
|
@@ -186,33 +186,12 @@ export class LaravelSemanticExtractor implements SemanticSliceExtractor {
|
|
|
186
186
|
public parseRoutesContent(content: string): ParsedRoute[] {
|
|
187
187
|
const routes: ParsedRoute[] = [];
|
|
188
188
|
|
|
189
|
-
//
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const
|
|
195
|
-
const uri = match[2];
|
|
196
|
-
const controllerClass = match[3] || match[5];
|
|
197
|
-
const actionName = match[4] || match[6];
|
|
198
|
-
const routeName = match[7];
|
|
199
|
-
|
|
200
|
-
if (controllerClass && actionName) {
|
|
201
|
-
routes.push({
|
|
202
|
-
httpMethod,
|
|
203
|
-
uri,
|
|
204
|
-
routeName,
|
|
205
|
-
controllerClass,
|
|
206
|
-
actionName,
|
|
207
|
-
});
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
// Match Route::resource('orders', OrderController::class)
|
|
212
|
-
const resourceRegex = /Route::resource\s*\(\s*['"]([^'"]+)['"]\s*,\s*([A-Za-z0-9_]+)::class\s*\)/gi;
|
|
213
|
-
while ((match = resourceRegex.exec(content)) !== null) {
|
|
214
|
-
const baseUri = match[1];
|
|
215
|
-
const controllerClass = match[2];
|
|
189
|
+
// 1. Process Route::resource
|
|
190
|
+
const resourceRegex = /Route::resource\s*\(\s*['"]([^'"]+)['"]\s*,\s*([\\A-Za-z0-9_]+)::class\s*\)/gi;
|
|
191
|
+
let resMatch;
|
|
192
|
+
while ((resMatch = resourceRegex.exec(content)) !== null) {
|
|
193
|
+
const baseUri = resMatch[1].replace(/^\//, "");
|
|
194
|
+
const controllerClass = resMatch[2];
|
|
216
195
|
const resourceActions = [
|
|
217
196
|
{ method: "GET", sub: "", action: "index" },
|
|
218
197
|
{ method: "GET", sub: "/create", action: "create" },
|
|
@@ -234,9 +213,189 @@ export class LaravelSemanticExtractor implements SemanticSliceExtractor {
|
|
|
234
213
|
}
|
|
235
214
|
}
|
|
236
215
|
|
|
216
|
+
// 2. Parse routes with group context tracking (prefix, controller, name prefix)
|
|
217
|
+
const lines = content.split(/\r?\n/);
|
|
218
|
+
interface GroupContext {
|
|
219
|
+
prefix: string;
|
|
220
|
+
controller: string;
|
|
221
|
+
namePrefix: string;
|
|
222
|
+
braceDepth: number;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const groupStack: GroupContext[] = [];
|
|
226
|
+
let currentBraceDepth = 0;
|
|
227
|
+
let statementBuffer = "";
|
|
228
|
+
let inStatement = false;
|
|
229
|
+
|
|
230
|
+
for (let i = 0; i < lines.length; i++) {
|
|
231
|
+
const line = lines[i];
|
|
232
|
+
const trimmed = line.trim();
|
|
233
|
+
|
|
234
|
+
// Check group opens: Route::...group(
|
|
235
|
+
if (trimmed.includes("Route::") && trimmed.includes("group(") && trimmed.includes("function")) {
|
|
236
|
+
const prefixMatch = /prefix\s*\(\s*['"]([^'"]+)['"]\s*\)/.exec(trimmed);
|
|
237
|
+
const controllerMatch = /controller\s*\(\s*([\\A-Za-z0-9_]+)::class\s*\)/.exec(trimmed);
|
|
238
|
+
const nameMatch = /name\s*\(\s*['"]([^'"]+)['"]\s*\)/.exec(trimmed);
|
|
239
|
+
|
|
240
|
+
const currentCtx = groupStack[groupStack.length - 1];
|
|
241
|
+
const newPrefix = [currentCtx?.prefix, prefixMatch ? prefixMatch[1] : ""]
|
|
242
|
+
.filter(Boolean)
|
|
243
|
+
.join("/");
|
|
244
|
+
const newController = controllerMatch ? controllerMatch[1] : (currentCtx?.controller || "");
|
|
245
|
+
const newNamePrefix = [currentCtx?.namePrefix, nameMatch ? nameMatch[1] : ""]
|
|
246
|
+
.filter(Boolean)
|
|
247
|
+
.join("");
|
|
248
|
+
|
|
249
|
+
groupStack.push({
|
|
250
|
+
prefix: newPrefix,
|
|
251
|
+
controller: newController,
|
|
252
|
+
namePrefix: newNamePrefix,
|
|
253
|
+
braceDepth: currentBraceDepth,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Track brace depth
|
|
258
|
+
for (const char of line) {
|
|
259
|
+
if (char === "{") currentBraceDepth++;
|
|
260
|
+
if (char === "}") {
|
|
261
|
+
currentBraceDepth--;
|
|
262
|
+
if (groupStack.length > 0) {
|
|
263
|
+
const top = groupStack[groupStack.length - 1];
|
|
264
|
+
if (currentBraceDepth <= top.braceDepth) {
|
|
265
|
+
groupStack.pop();
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Route statement accumulation across lines
|
|
272
|
+
if (!inStatement && trimmed.startsWith("Route::") && !trimmed.includes("Route::resource") && !trimmed.includes("Route::group")) {
|
|
273
|
+
statementBuffer = trimmed;
|
|
274
|
+
inStatement = true;
|
|
275
|
+
} else if (inStatement) {
|
|
276
|
+
statementBuffer += " " + trimmed;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (inStatement && statementBuffer.includes(";")) {
|
|
280
|
+
inStatement = false;
|
|
281
|
+
const stmt = statementBuffer;
|
|
282
|
+
statementBuffer = "";
|
|
283
|
+
|
|
284
|
+
const currentCtx = groupStack[groupStack.length - 1];
|
|
285
|
+
const parsed = this.parseSingleRouteStatement(stmt, currentCtx);
|
|
286
|
+
if (parsed) {
|
|
287
|
+
routes.push(parsed);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
237
292
|
return routes;
|
|
238
293
|
}
|
|
239
294
|
|
|
295
|
+
private parseSingleRouteStatement(
|
|
296
|
+
stmt: string,
|
|
297
|
+
ctx?: { prefix: string; controller: string; namePrefix: string }
|
|
298
|
+
): ParsedRoute | null {
|
|
299
|
+
const methodMatch = /Route::(get|post|put|patch|delete|any|options)\s*\(\s*['"]([^'"]+)['"]([\s\S]*)/i.exec(stmt);
|
|
300
|
+
if (!methodMatch) {
|
|
301
|
+
const viewMatch = /Route::view\s*\(\s*['"]([^'"]+)['"]\s*,\s*['"]([^'"]+)['"]/i.exec(stmt);
|
|
302
|
+
if (viewMatch) {
|
|
303
|
+
const rawUri = viewMatch[1];
|
|
304
|
+
const viewTarget = viewMatch[2];
|
|
305
|
+
const nameMatch = /->name\s*\(\s*['"]([^'"]+)['"]\s*\)/i.exec(stmt);
|
|
306
|
+
const fullUri = ctx?.prefix ? `/${ctx.prefix.replace(/^\/|\/$/g, "")}/${rawUri.replace(/^\//, "")}` : rawUri;
|
|
307
|
+
return {
|
|
308
|
+
httpMethod: "GET",
|
|
309
|
+
uri: fullUri.startsWith("/") ? fullUri : `/${fullUri}`,
|
|
310
|
+
routeName: (ctx?.namePrefix || "") + (nameMatch ? nameMatch[1] : ""),
|
|
311
|
+
controllerClass: "View",
|
|
312
|
+
actionName: viewTarget,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const httpMethod = methodMatch[1].toUpperCase();
|
|
319
|
+
let uri = methodMatch[2];
|
|
320
|
+
const rest = methodMatch[3];
|
|
321
|
+
|
|
322
|
+
if (ctx?.prefix) {
|
|
323
|
+
const cleanPrefix = ctx.prefix.replace(/^\/|\/$/g, "");
|
|
324
|
+
const cleanUri = uri.replace(/^\//, "");
|
|
325
|
+
uri = cleanUri ? `/${cleanPrefix}/${cleanUri}` : `/${cleanPrefix}`;
|
|
326
|
+
}
|
|
327
|
+
if (!uri.startsWith("/")) uri = `/${uri}`;
|
|
328
|
+
|
|
329
|
+
const nameMatch = /->name\s*\(\s*['"]([^'"]+)['"]\s*\)/i.exec(stmt);
|
|
330
|
+
const rawRouteName = nameMatch ? nameMatch[1] : undefined;
|
|
331
|
+
const fullRouteName = rawRouteName
|
|
332
|
+
? (ctx?.namePrefix || "") + rawRouteName
|
|
333
|
+
: undefined;
|
|
334
|
+
|
|
335
|
+
// [Controller::class, 'action']
|
|
336
|
+
const arrayMatch = /\[\s*([\\A-Za-z0-9_]+)::class\s*,\s*['"]([A-Za-z0-9_]+)['"]\s*\]/.exec(rest);
|
|
337
|
+
if (arrayMatch) {
|
|
338
|
+
return {
|
|
339
|
+
httpMethod,
|
|
340
|
+
uri,
|
|
341
|
+
routeName: fullRouteName,
|
|
342
|
+
controllerClass: arrayMatch[1],
|
|
343
|
+
actionName: arrayMatch[2],
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// 'Controller@action'
|
|
348
|
+
const stringActionMatch = /['"]([\\A-Za-z0-9_]+)@([A-Za-z0-9_]+)['"]/.exec(rest);
|
|
349
|
+
if (stringActionMatch) {
|
|
350
|
+
return {
|
|
351
|
+
httpMethod,
|
|
352
|
+
uri,
|
|
353
|
+
routeName: fullRouteName,
|
|
354
|
+
controllerClass: stringActionMatch[1],
|
|
355
|
+
actionName: stringActionMatch[2],
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Action inside controller group
|
|
360
|
+
if (ctx?.controller) {
|
|
361
|
+
const singleActionMatch = /,\s*['"]([A-Za-z0-9_]+)['"]/.exec(rest);
|
|
362
|
+
if (singleActionMatch) {
|
|
363
|
+
return {
|
|
364
|
+
httpMethod,
|
|
365
|
+
uri,
|
|
366
|
+
routeName: fullRouteName,
|
|
367
|
+
controllerClass: ctx.controller,
|
|
368
|
+
actionName: singleActionMatch[1],
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Invokable controller
|
|
374
|
+
const invokableMatch = /,\s*([\\A-Za-z0-9_]+)::class/.exec(rest);
|
|
375
|
+
if (invokableMatch) {
|
|
376
|
+
return {
|
|
377
|
+
httpMethod,
|
|
378
|
+
uri,
|
|
379
|
+
routeName: fullRouteName,
|
|
380
|
+
controllerClass: invokableMatch[1],
|
|
381
|
+
actionName: "__invoke",
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Closure
|
|
386
|
+
if (rest.includes("function")) {
|
|
387
|
+
return {
|
|
388
|
+
httpMethod,
|
|
389
|
+
uri,
|
|
390
|
+
routeName: fullRouteName,
|
|
391
|
+
controllerClass: "Closure",
|
|
392
|
+
actionName: "handle",
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
|
|
240
399
|
private resolveController(controllerClass: string, actionName: string): { filePath?: string; line: number } {
|
|
241
400
|
const simpleName = controllerClass.split(/\\|\//).pop() || controllerClass;
|
|
242
401
|
const candidates = [
|
|
@@ -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,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
|
+
}
|
|
@@ -5,9 +5,11 @@ import type {
|
|
|
5
5
|
} from "../../types/index.ts";
|
|
6
6
|
import type { SeptumRepository } from "../database/repository.ts";
|
|
7
7
|
import { CallGraphTracer } from "./call-graph-tracer.ts";
|
|
8
|
+
import { SemanticSliceExtractorRegistry } from "../parser/extractors/registry.ts";
|
|
9
|
+
import { TopologyDetector } from "../discovery/topology-detector.ts";
|
|
8
10
|
|
|
9
11
|
export class VerticalSliceTracer {
|
|
10
|
-
constructor(private repo: SeptumRepository) {}
|
|
12
|
+
constructor(private repo: SeptumRepository, private projectRoot: string = process.cwd()) {}
|
|
11
13
|
|
|
12
14
|
/**
|
|
13
15
|
* Trace an end-to-end vertical slice from route/action/intent query.
|
|
@@ -25,16 +27,29 @@ export class VerticalSliceTracer {
|
|
|
25
27
|
const lowerTrimmed = trimmed.toLowerCase();
|
|
26
28
|
let allSlices = this.repo.getAllVerticalSlices();
|
|
27
29
|
|
|
28
|
-
// Lazy fallback: If no slices cataloged,
|
|
29
|
-
|
|
30
|
+
// Lazy on-demand fallback: If no slices cataloged, extract framework routes or trace call-graph
|
|
31
|
+
const hasCatalogData = typeof (this.repo as any).getAllDomains === "function" && this.repo.getAllDomains().length > 0;
|
|
32
|
+
if (allSlices.length === 0 && hasCatalogData) {
|
|
30
33
|
try {
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
const topology = TopologyDetector.detect(this.projectRoot);
|
|
35
|
+
const semanticSlices = SemanticSliceExtractorRegistry.extractAllSlicesSync(
|
|
36
|
+
this.projectRoot,
|
|
37
|
+
topology.framework
|
|
38
|
+
);
|
|
39
|
+
if (semanticSlices.length > 0) {
|
|
40
|
+
for (const s of semanticSlices) {
|
|
35
41
|
this.repo.upsertVerticalSlice(s);
|
|
36
42
|
}
|
|
37
43
|
allSlices = this.repo.getAllVerticalSlices();
|
|
44
|
+
} else {
|
|
45
|
+
const tracer = new CallGraphTracer(this.repo, this.projectRoot);
|
|
46
|
+
const traced = tracer.traceAllSlices();
|
|
47
|
+
if (traced.length > 0) {
|
|
48
|
+
for (const s of traced) {
|
|
49
|
+
this.repo.upsertVerticalSlice(s);
|
|
50
|
+
}
|
|
51
|
+
allSlices = this.repo.getAllVerticalSlices();
|
|
52
|
+
}
|
|
38
53
|
}
|
|
39
54
|
} catch (err: unknown) {
|
|
40
55
|
const msg = err instanceof Error ? err.message : String(err);
|