septum 0.1.0
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/LICENSE +21 -0
- package/README.md +100 -0
- package/bin/septum.ts +4 -0
- package/package.json +62 -0
- package/src/cli/commands/check.ts +161 -0
- package/src/cli/commands/hook.ts +188 -0
- package/src/cli/commands/ingest.ts +29 -0
- package/src/cli/commands/init.ts +211 -0
- package/src/cli/commands/locate.ts +61 -0
- package/src/cli/commands/query.ts +64 -0
- package/src/cli/commands/serve.ts +5 -0
- package/src/cli/commands/slice.ts +90 -0
- package/src/cli/commands/sync.ts +31 -0
- package/src/cli/index.ts +178 -0
- package/src/cli/templates/hooks.ts +75 -0
- package/src/cli/templates/rules.ts +38 -0
- package/src/cli/templates/skill.ts +66 -0
- package/src/core/boundary/evaluator.ts +210 -0
- package/src/core/config/loader.ts +84 -0
- package/src/core/config/schema.ts +39 -0
- package/src/core/database/client.ts +132 -0
- package/src/core/database/repositories/dependency.repository.ts +279 -0
- package/src/core/database/repositories/domain.repository.ts +198 -0
- package/src/core/database/repositories/file.repository.ts +99 -0
- package/src/core/database/repositories/meta.repository.ts +33 -0
- package/src/core/database/repositories/symbol.repository.ts +424 -0
- package/src/core/database/repository.ts +242 -0
- package/src/core/database/schema.sql +92 -0
- package/src/core/discovery/topology-detector.ts +418 -0
- package/src/core/ingestion/hasher.ts +5 -0
- package/src/core/ingestion/pipeline.ts +438 -0
- package/src/core/parser/boundary-tracker.ts +128 -0
- package/src/core/parser/extractors/base.ts +60 -0
- package/src/core/parser/extractors/frontend.ts +194 -0
- package/src/core/parser/extractors/go.ts +176 -0
- package/src/core/parser/extractors/laravel-semantic.ts +426 -0
- package/src/core/parser/extractors/mcp-cli-semantic.ts +199 -0
- package/src/core/parser/extractors/nestjs-semantic.ts +119 -0
- package/src/core/parser/extractors/php.ts +149 -0
- package/src/core/parser/extractors/python.ts +226 -0
- package/src/core/parser/extractors/registry.ts +49 -0
- package/src/core/parser/extractors/semantic-extractor.interface.ts +21 -0
- package/src/core/parser/extractors/typescript.ts +177 -0
- package/src/core/parser/tree-sitter.ts +39 -0
- package/src/core/resolver/call-graph-tracer.ts +668 -0
- package/src/core/resolver/module-resolver.ts +323 -0
- package/src/core/resolver/symbol-locator.ts +405 -0
- package/src/core/resolver/vertical-slice-tracer.ts +236 -0
- package/src/core/session/session-manager.ts +56 -0
- package/src/core/telemetry/telemetry.ts +86 -0
- package/src/index.ts +18 -0
- package/src/mcp/schemas.ts +68 -0
- package/src/mcp/server.ts +602 -0
- package/src/mcp/tools/check-boundary.ts +102 -0
- package/src/mcp/tools/get-domain-catalog.ts +91 -0
- package/src/mcp/tools/get-feature-context.ts +116 -0
- package/src/mcp/tools/get-symbol-hotspots.ts +49 -0
- package/src/mcp/tools/get-symbol-impact.ts +26 -0
- package/src/mcp/tools/get-symbol.ts +63 -0
- package/src/mcp/tools/locate-symbol.ts +74 -0
- package/src/mcp/tools/register-domain.ts +64 -0
- package/src/mcp/tools/trace-vertical-slice.ts +102 -0
- package/src/types/index.ts +296 -0
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, normalize, relative, resolve } from "node:path";
|
|
3
|
+
import type {
|
|
4
|
+
ExecutionChainNode,
|
|
5
|
+
FileRecord,
|
|
6
|
+
SymbolRecord,
|
|
7
|
+
VerticalSliceRecord,
|
|
8
|
+
} from "../../types/index.ts";
|
|
9
|
+
import type { SeptumRepository } from "../database/repository.ts";
|
|
10
|
+
|
|
11
|
+
export interface DiscoveredRoute {
|
|
12
|
+
method: string;
|
|
13
|
+
uri: string;
|
|
14
|
+
file: string;
|
|
15
|
+
line: number;
|
|
16
|
+
middlewareSymbols: string[];
|
|
17
|
+
handlerSymbol?: string;
|
|
18
|
+
handlerClass?: string;
|
|
19
|
+
handlerMethod?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class CallGraphTracer {
|
|
23
|
+
private fileMapByNormPath: Map<string, FileRecord> = new Map();
|
|
24
|
+
private symbolsByFileId: Map<number, SymbolRecord[]> = new Map();
|
|
25
|
+
private dependenciesByFileId: Map<
|
|
26
|
+
number,
|
|
27
|
+
Array<{ target: string; statement: string; line_number: number; is_external: boolean }>
|
|
28
|
+
> = new Map();
|
|
29
|
+
|
|
30
|
+
constructor(private repo: SeptumRepository, private projectRoot: string = process.cwd()) {}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Initializes internal fast lookup maps from SQLite repository data.
|
|
34
|
+
*/
|
|
35
|
+
public initMaps(): void {
|
|
36
|
+
const allSymbolsWithFiles = this.repo.getAllSymbolsWithFiles();
|
|
37
|
+
const allDeps = this.repo.getAllDependenciesWithDomains();
|
|
38
|
+
|
|
39
|
+
// Map files
|
|
40
|
+
const allDomains = this.repo.getAllDomains();
|
|
41
|
+
for (const domain of allDomains) {
|
|
42
|
+
const files = this.repo.getFilesByDomain(domain.id);
|
|
43
|
+
for (const f of files) {
|
|
44
|
+
const norm = this.normalizeFilePath(f.path);
|
|
45
|
+
this.fileMapByNormPath.set(norm, f);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Map symbols
|
|
50
|
+
for (const s of allSymbolsWithFiles) {
|
|
51
|
+
const existing = this.symbolsByFileId.get(s.file_id) || [];
|
|
52
|
+
existing.push(s);
|
|
53
|
+
this.symbolsByFileId.set(s.file_id, existing);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Map dependencies
|
|
57
|
+
for (const d of allDeps) {
|
|
58
|
+
const file = this.fileMapByNormPath.get(this.normalizeFilePath(d.source_file));
|
|
59
|
+
if (file) {
|
|
60
|
+
const existing = this.dependenciesByFileId.get(file.id) || [];
|
|
61
|
+
existing.push({
|
|
62
|
+
target: d.target,
|
|
63
|
+
statement: d.statement,
|
|
64
|
+
line_number: d.line_number,
|
|
65
|
+
is_external: d.is_external,
|
|
66
|
+
});
|
|
67
|
+
this.dependenciesByFileId.set(file.id, existing);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Discover declarative HTTP routes from known route/router files in the repository.
|
|
74
|
+
*/
|
|
75
|
+
public discoverDeclarativeRoutes(): DiscoveredRoute[] {
|
|
76
|
+
const discovered: DiscoveredRoute[] = [];
|
|
77
|
+
|
|
78
|
+
for (const [normPath, file] of this.fileMapByNormPath.entries()) {
|
|
79
|
+
const isRouteFile =
|
|
80
|
+
file.archetype === "route" ||
|
|
81
|
+
/routes?|router|endpoints?/i.test(normPath) ||
|
|
82
|
+
/\.(routes?|router)\.[tj]sx?$/i.test(normPath);
|
|
83
|
+
|
|
84
|
+
if (!isRouteFile) continue;
|
|
85
|
+
|
|
86
|
+
const fullPath = resolve(this.projectRoot, file.path);
|
|
87
|
+
if (!existsSync(fullPath)) continue;
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const content = readFileSync(fullPath, "utf-8");
|
|
91
|
+
const lines = content.split("\n");
|
|
92
|
+
|
|
93
|
+
for (let i = 0; i < lines.length; i++) {
|
|
94
|
+
const line = lines[i];
|
|
95
|
+
// Pattern: router.post('/api/orders', validateMiddleware, controller.action)
|
|
96
|
+
// or app.get('/orders', handler)
|
|
97
|
+
const match = line.match(
|
|
98
|
+
/(?:router|app|server|api)\.(get|post|put|patch|delete|options|head)\s*\(\s*['"`]([^'"`]+)['"`]\s*,\s*(.+)\)/i
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
if (match) {
|
|
102
|
+
const method = match[1].toUpperCase();
|
|
103
|
+
const uri = match[2];
|
|
104
|
+
const rawArgs = match[3];
|
|
105
|
+
|
|
106
|
+
const argTokens = rawArgs
|
|
107
|
+
.split(",")
|
|
108
|
+
.map((t) => t.trim())
|
|
109
|
+
.filter((t) => t.length > 0 && !t.startsWith("//"));
|
|
110
|
+
|
|
111
|
+
const middlewareSymbols: string[] = [];
|
|
112
|
+
let handlerSymbol: string | undefined;
|
|
113
|
+
let handlerClass: string | undefined;
|
|
114
|
+
let handlerMethod: string | undefined;
|
|
115
|
+
|
|
116
|
+
if (argTokens.length === 1) {
|
|
117
|
+
handlerSymbol = argTokens[0];
|
|
118
|
+
} else if (argTokens.length > 1) {
|
|
119
|
+
handlerSymbol = argTokens[argTokens.length - 1];
|
|
120
|
+
middlewareSymbols.push(...argTokens.slice(0, argTokens.length - 1));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (handlerSymbol) {
|
|
124
|
+
// Extract Class.method or classInstance.method
|
|
125
|
+
const parts = handlerSymbol.split(".");
|
|
126
|
+
if (parts.length === 2) {
|
|
127
|
+
handlerClass = parts[0];
|
|
128
|
+
handlerMethod = parts[1];
|
|
129
|
+
} else if (handlerSymbol.includes("::")) {
|
|
130
|
+
const colParts = handlerSymbol.split("::");
|
|
131
|
+
handlerClass = colParts[0];
|
|
132
|
+
handlerMethod = colParts[1];
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
discovered.push({
|
|
137
|
+
method,
|
|
138
|
+
uri,
|
|
139
|
+
file: file.path,
|
|
140
|
+
line: i + 1,
|
|
141
|
+
middlewareSymbols,
|
|
142
|
+
handlerSymbol,
|
|
143
|
+
handlerClass,
|
|
144
|
+
handlerMethod,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
} catch (err: unknown) {
|
|
149
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
150
|
+
console.error(`[Septum CallGraph Warning] Route discovery failed for ${file.path}: ${msg}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return discovered;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Build execution chains for all discovered entrypoints and controller actions.
|
|
159
|
+
*/
|
|
160
|
+
public traceAllSlices(): Array<Omit<VerticalSliceRecord, "id" | "created_at">> {
|
|
161
|
+
this.initMaps();
|
|
162
|
+
const slices: Array<Omit<VerticalSliceRecord, "id" | "created_at">> = [];
|
|
163
|
+
const declarativeRoutes = this.discoverDeclarativeRoutes();
|
|
164
|
+
|
|
165
|
+
// 1. Trace from declarative routes
|
|
166
|
+
for (const route of declarativeRoutes) {
|
|
167
|
+
const chain = this.traceChainFromRoute(route);
|
|
168
|
+
if (chain.length > 0) {
|
|
169
|
+
const sliceRecord = this.buildSliceRecordFromChain(
|
|
170
|
+
route.method,
|
|
171
|
+
route.uri,
|
|
172
|
+
chain,
|
|
173
|
+
"clean"
|
|
174
|
+
);
|
|
175
|
+
slices.push(sliceRecord);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 2. Trace controllers if routes were not explicitly bound or found
|
|
180
|
+
for (const [normPath, file] of this.fileMapByNormPath.entries()) {
|
|
181
|
+
const isController =
|
|
182
|
+
file.archetype === "controller" ||
|
|
183
|
+
/controller/i.test(normPath) ||
|
|
184
|
+
normPath.endsWith("Controller.ts") ||
|
|
185
|
+
normPath.endsWith("Controller.js");
|
|
186
|
+
|
|
187
|
+
if (!isController) continue;
|
|
188
|
+
|
|
189
|
+
const symbols = this.symbolsByFileId.get(file.id) || [];
|
|
190
|
+
const methods = symbols.filter((s) => s.kind === "method" || s.kind === "function");
|
|
191
|
+
|
|
192
|
+
const ctrlName =
|
|
193
|
+
symbols.find((s) => s.kind === "class")?.name ||
|
|
194
|
+
file.path.split("/").pop()?.replace(/\.[^.]+$/, "") ||
|
|
195
|
+
"Controller";
|
|
196
|
+
|
|
197
|
+
for (const m of methods) {
|
|
198
|
+
// Skip constructors or private helpers
|
|
199
|
+
if (m.name === "constructor" || m.name.startsWith("_")) continue;
|
|
200
|
+
|
|
201
|
+
// Check if this method was already covered by declarative routes
|
|
202
|
+
const alreadyCovered = slices.some(
|
|
203
|
+
(s) =>
|
|
204
|
+
s.controller_file === file.path &&
|
|
205
|
+
s.action_name.toLowerCase() === m.name.toLowerCase()
|
|
206
|
+
);
|
|
207
|
+
if (alreadyCovered) continue;
|
|
208
|
+
|
|
209
|
+
const chain = this.traceChainFromController(file, m);
|
|
210
|
+
if (chain.length > 0) {
|
|
211
|
+
const defaultMethod = /create|store|save|add/i.test(m.name)
|
|
212
|
+
? "POST"
|
|
213
|
+
: /update|edit|patch/i.test(m.name)
|
|
214
|
+
? "PUT"
|
|
215
|
+
: /delete|remove|destroy/i.test(m.name)
|
|
216
|
+
? "DELETE"
|
|
217
|
+
: "GET";
|
|
218
|
+
|
|
219
|
+
const inferredUri = this.inferRouteUri(file.path, m.name);
|
|
220
|
+
const sliceRecord = this.buildSliceRecordFromChain(
|
|
221
|
+
defaultMethod,
|
|
222
|
+
inferredUri,
|
|
223
|
+
chain,
|
|
224
|
+
"clean"
|
|
225
|
+
);
|
|
226
|
+
slices.push(sliceRecord);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return slices;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Trace execution chain starting from a declarative route.
|
|
236
|
+
*/
|
|
237
|
+
public traceChainFromRoute(route: DiscoveredRoute): ExecutionChainNode[] {
|
|
238
|
+
const chain: ExecutionChainNode[] = [];
|
|
239
|
+
|
|
240
|
+
// Stage 1: Ingress
|
|
241
|
+
chain.push({
|
|
242
|
+
stage: "ingress",
|
|
243
|
+
symbol: `${route.method} ${route.uri}`,
|
|
244
|
+
file: route.file,
|
|
245
|
+
line: route.line,
|
|
246
|
+
description: `HTTP Route Ingress: ${route.method} ${route.uri}`,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// Stage 2: Validation / Middleware
|
|
250
|
+
for (const mw of route.middlewareSymbols) {
|
|
251
|
+
const mwLoc = this.locateSymbol(mw, route.file);
|
|
252
|
+
chain.push({
|
|
253
|
+
stage: "validation",
|
|
254
|
+
symbol: mw,
|
|
255
|
+
file: mwLoc?.file,
|
|
256
|
+
line: mwLoc?.line,
|
|
257
|
+
description: `Request Validation / Middleware: ${mw}`,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Stage 3: Controller / Handler
|
|
262
|
+
let controllerFile: FileRecord | undefined;
|
|
263
|
+
let actionMethod = route.handlerMethod || route.handlerSymbol || "handle";
|
|
264
|
+
|
|
265
|
+
if (route.handlerClass) {
|
|
266
|
+
const found = this.findFileBySymbolOrName(route.handlerClass, route.file);
|
|
267
|
+
if (found) {
|
|
268
|
+
controllerFile = found.file;
|
|
269
|
+
if (!route.handlerMethod && found.symbol) {
|
|
270
|
+
actionMethod = found.symbol.name;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
} else if (route.handlerSymbol) {
|
|
274
|
+
const found = this.findFileBySymbolOrName(route.handlerSymbol, route.file);
|
|
275
|
+
if (found) {
|
|
276
|
+
controllerFile = found.file;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (controllerFile) {
|
|
281
|
+
const syms = this.symbolsByFileId.get(controllerFile.id) || [];
|
|
282
|
+
const methodSym = syms.find(
|
|
283
|
+
(s) => s.name.toLowerCase() === actionMethod.toLowerCase()
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
const ctrlName =
|
|
287
|
+
syms.find((s) => s.kind === "class")?.name ||
|
|
288
|
+
controllerFile.path.split("/").pop()?.replace(/\.[^.]+$/, "") ||
|
|
289
|
+
"Controller";
|
|
290
|
+
|
|
291
|
+
chain.push({
|
|
292
|
+
stage: "controller",
|
|
293
|
+
symbol: `${ctrlName}::${actionMethod}`,
|
|
294
|
+
file: controllerFile.path,
|
|
295
|
+
line: methodSym?.line_start || 1,
|
|
296
|
+
description: `Controller Action: ${ctrlName}::${actionMethod}`,
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// Forward reachability BFS from controller
|
|
300
|
+
this.traverseOutboundDependencies(controllerFile, chain);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return chain;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Trace execution chain starting from a controller method.
|
|
308
|
+
*/
|
|
309
|
+
public traceChainFromController(
|
|
310
|
+
controllerFile: FileRecord,
|
|
311
|
+
method: SymbolRecord
|
|
312
|
+
): ExecutionChainNode[] {
|
|
313
|
+
const chain: ExecutionChainNode[] = [];
|
|
314
|
+
const syms = this.symbolsByFileId.get(controllerFile.id) || [];
|
|
315
|
+
const ctrlName =
|
|
316
|
+
syms.find((s) => s.kind === "class")?.name ||
|
|
317
|
+
controllerFile.path.split("/").pop()?.replace(/\.[^.]+$/, "") ||
|
|
318
|
+
"Controller";
|
|
319
|
+
|
|
320
|
+
// Stage 1: Ingress (inferred)
|
|
321
|
+
const inferredUri = this.inferRouteUri(controllerFile.path, method.name);
|
|
322
|
+
const defaultMethod = /create|store|save|add/i.test(method.name)
|
|
323
|
+
? "POST"
|
|
324
|
+
: /update|edit|patch/i.test(method.name)
|
|
325
|
+
? "PUT"
|
|
326
|
+
: /delete|remove|destroy/i.test(method.name)
|
|
327
|
+
? "DELETE"
|
|
328
|
+
: "GET";
|
|
329
|
+
|
|
330
|
+
chain.push({
|
|
331
|
+
stage: "ingress",
|
|
332
|
+
symbol: `${defaultMethod} ${inferredUri}`,
|
|
333
|
+
file: controllerFile.path,
|
|
334
|
+
line: method.line_start,
|
|
335
|
+
description: `Inferred Ingress: ${defaultMethod} ${inferredUri}`,
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// Stage 3: Controller
|
|
339
|
+
chain.push({
|
|
340
|
+
stage: "controller",
|
|
341
|
+
symbol: `${ctrlName}::${method.name}`,
|
|
342
|
+
file: controllerFile.path,
|
|
343
|
+
line: method.line_start,
|
|
344
|
+
description: `Controller Action: ${ctrlName}::${method.name}`,
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// Forward reachability BFS
|
|
348
|
+
this.traverseOutboundDependencies(controllerFile, chain);
|
|
349
|
+
|
|
350
|
+
return chain;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Traverse outbound dependencies using BFS with cycle detection and max hop limit.
|
|
355
|
+
*/
|
|
356
|
+
private traverseOutboundDependencies(
|
|
357
|
+
startFile: FileRecord,
|
|
358
|
+
chain: ExecutionChainNode[],
|
|
359
|
+
maxDepth: number = 6
|
|
360
|
+
): void {
|
|
361
|
+
const visitedFiles = new Set<string>();
|
|
362
|
+
visitedFiles.add(startFile.path);
|
|
363
|
+
|
|
364
|
+
const queue: Array<{ file: FileRecord; depth: number }> = [
|
|
365
|
+
{ file: startFile, depth: 1 },
|
|
366
|
+
];
|
|
367
|
+
|
|
368
|
+
while (queue.length > 0) {
|
|
369
|
+
const current = queue.shift()!;
|
|
370
|
+
if (current.depth > maxDepth) break;
|
|
371
|
+
|
|
372
|
+
const deps = this.dependenciesByFileId.get(current.file.id) || [];
|
|
373
|
+
|
|
374
|
+
for (const dep of deps) {
|
|
375
|
+
if (dep.is_external) continue;
|
|
376
|
+
|
|
377
|
+
const resolvedFile = this.resolveImportToFile(current.file.path, dep.target);
|
|
378
|
+
if (!resolvedFile || visitedFiles.has(resolvedFile.path)) continue;
|
|
379
|
+
|
|
380
|
+
visitedFiles.add(resolvedFile.path);
|
|
381
|
+
|
|
382
|
+
const stage = this.detectStage(resolvedFile.path, resolvedFile.archetype);
|
|
383
|
+
const fileSymbols = this.symbolsByFileId.get(resolvedFile.id) || [];
|
|
384
|
+
const mainSymbol =
|
|
385
|
+
fileSymbols.find((s) => s.kind === "class" || s.kind === "interface") ||
|
|
386
|
+
fileSymbols.find((s) => s.kind === "function") ||
|
|
387
|
+
fileSymbols[0];
|
|
388
|
+
|
|
389
|
+
const symbolDisplay = mainSymbol
|
|
390
|
+
? `${mainSymbol.name}${mainSymbol.kind === "class" ? "::execute" : ""}`
|
|
391
|
+
: resolvedFile.path.split("/").pop()?.replace(/\.[^.]+$/, "") || "Module";
|
|
392
|
+
|
|
393
|
+
// Prevent adding duplicate stages if already present, or add as refinement
|
|
394
|
+
const existingStageNode = chain.find(
|
|
395
|
+
(c) => c.stage === stage && c.file === resolvedFile.path
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
if (!existingStageNode) {
|
|
399
|
+
chain.push({
|
|
400
|
+
stage,
|
|
401
|
+
symbol: symbolDisplay,
|
|
402
|
+
file: resolvedFile.path,
|
|
403
|
+
line: mainSymbol?.line_start || 1,
|
|
404
|
+
description: `${stage.toUpperCase()}: ${symbolDisplay}`,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
queue.push({ file: resolvedFile, depth: current.depth + 1 });
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Sort stages according to standard pipeline order
|
|
413
|
+
const stageOrder: Record<string, number> = {
|
|
414
|
+
ingress: 1,
|
|
415
|
+
validation: 2,
|
|
416
|
+
controller: 3,
|
|
417
|
+
orchestrator: 3,
|
|
418
|
+
use_case: 4,
|
|
419
|
+
service: 4,
|
|
420
|
+
domain: 5,
|
|
421
|
+
entity: 5,
|
|
422
|
+
egress: 6,
|
|
423
|
+
repository: 6,
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
chain.sort((a, b) => {
|
|
427
|
+
const orderA = stageOrder[a.stage] || 99;
|
|
428
|
+
const orderB = stageOrder[b.stage] || 99;
|
|
429
|
+
return orderA - orderB;
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Detect generic pipeline stage from file path and archetype.
|
|
435
|
+
*/
|
|
436
|
+
public detectStage(filePath: string, archetype: string): string {
|
|
437
|
+
const norm = filePath.toLowerCase();
|
|
438
|
+
|
|
439
|
+
if (
|
|
440
|
+
archetype === "validation" ||
|
|
441
|
+
archetype === "dto" ||
|
|
442
|
+
/validation|middleware|dto|request|schema/i.test(norm)
|
|
443
|
+
) {
|
|
444
|
+
return "validation";
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (archetype === "controller" || /controller|handler/i.test(norm)) {
|
|
448
|
+
return "controller";
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (
|
|
452
|
+
archetype === "service" ||
|
|
453
|
+
archetype === "use_case" ||
|
|
454
|
+
/use-?cases?|interactor|application|workflow/i.test(norm)
|
|
455
|
+
) {
|
|
456
|
+
return "use_case";
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (
|
|
460
|
+
archetype === "model" ||
|
|
461
|
+
archetype === "entity" ||
|
|
462
|
+
/domain|entities|entity|aggregate/i.test(norm)
|
|
463
|
+
) {
|
|
464
|
+
return "entity";
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (
|
|
468
|
+
archetype === "repository" ||
|
|
469
|
+
/repositories|repository|database|persistence|egress|adapter/i.test(norm)
|
|
470
|
+
) {
|
|
471
|
+
return "repository";
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
return "service";
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Resolve an import target path (relative or alias) to a FileRecord in the catalog.
|
|
479
|
+
*/
|
|
480
|
+
public resolveImportToFile(sourceFilePath: string, importTarget: string): FileRecord | undefined {
|
|
481
|
+
// 1. Relative import (./ or ../)
|
|
482
|
+
if (importTarget.startsWith(".")) {
|
|
483
|
+
const sourceDir = dirname(sourceFilePath);
|
|
484
|
+
const combined = normalize(join(sourceDir, importTarget)).replace(/\\/g, "/");
|
|
485
|
+
|
|
486
|
+
const candidates = [
|
|
487
|
+
combined,
|
|
488
|
+
`${combined}.ts`,
|
|
489
|
+
`${combined}.tsx`,
|
|
490
|
+
`${combined}.js`,
|
|
491
|
+
`${combined}.jsx`,
|
|
492
|
+
`${combined}/index.ts`,
|
|
493
|
+
`${combined}/index.js`,
|
|
494
|
+
];
|
|
495
|
+
|
|
496
|
+
for (const cand of candidates) {
|
|
497
|
+
const found = this.fileMapByNormPath.get(this.normalizeFilePath(cand));
|
|
498
|
+
if (found) return found;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// 2. Alias import (@/... or src/...)
|
|
503
|
+
const cleanAlias = importTarget.replace(/^@\//, "src/");
|
|
504
|
+
const aliasCandidates = [
|
|
505
|
+
cleanAlias,
|
|
506
|
+
`${cleanAlias}.ts`,
|
|
507
|
+
`${cleanAlias}.tsx`,
|
|
508
|
+
`${cleanAlias}.js`,
|
|
509
|
+
`${cleanAlias}.jsx`,
|
|
510
|
+
`${cleanAlias}/index.ts`,
|
|
511
|
+
`${cleanAlias}/index.js`,
|
|
512
|
+
];
|
|
513
|
+
|
|
514
|
+
for (const cand of aliasCandidates) {
|
|
515
|
+
const found = this.fileMapByNormPath.get(this.normalizeFilePath(cand));
|
|
516
|
+
if (found) return found;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// 3. Basename search fallback
|
|
520
|
+
const baseTarget = importTarget.split("/").pop()?.replace(/\.[^.]+$/, "");
|
|
521
|
+
if (baseTarget && baseTarget.length > 3) {
|
|
522
|
+
for (const [norm, file] of this.fileMapByNormPath.entries()) {
|
|
523
|
+
if (norm.includes(`/${baseTarget.toLowerCase()}.`) || norm.endsWith(`/${baseTarget.toLowerCase()}`)) {
|
|
524
|
+
return file;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
return undefined;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Locate a symbol in the codebase or relative to a file.
|
|
534
|
+
*/
|
|
535
|
+
private locateSymbol(
|
|
536
|
+
symbolName: string,
|
|
537
|
+
relativeToFile?: string
|
|
538
|
+
): { file: string; line: number } | null {
|
|
539
|
+
const cleanSymbol = symbolName.replace(/[()]/g, "").trim();
|
|
540
|
+
|
|
541
|
+
// Check imports of relativeToFile first
|
|
542
|
+
if (relativeToFile) {
|
|
543
|
+
const file = this.fileMapByNormPath.get(this.normalizeFilePath(relativeToFile));
|
|
544
|
+
if (file) {
|
|
545
|
+
const deps = this.dependenciesByFileId.get(file.id) || [];
|
|
546
|
+
for (const dep of deps) {
|
|
547
|
+
if (dep.target.includes(cleanSymbol) || dep.statement.includes(cleanSymbol)) {
|
|
548
|
+
const targetFile = this.resolveImportToFile(relativeToFile, dep.target);
|
|
549
|
+
if (targetFile) {
|
|
550
|
+
const syms = this.symbolsByFileId.get(targetFile.id) || [];
|
|
551
|
+
const s = syms.find((sym) => sym.name === cleanSymbol);
|
|
552
|
+
return {
|
|
553
|
+
file: targetFile.path,
|
|
554
|
+
line: s?.line_start || 1,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Global symbol search
|
|
563
|
+
for (const [fileId, syms] of this.symbolsByFileId.entries()) {
|
|
564
|
+
const match = syms.find((s) => s.name === cleanSymbol);
|
|
565
|
+
if (match) {
|
|
566
|
+
for (const file of this.fileMapByNormPath.values()) {
|
|
567
|
+
if (file.id === fileId) {
|
|
568
|
+
return { file: file.path, line: match.line_start };
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
return null;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Find FileRecord and Symbol by identifier or class name.
|
|
579
|
+
*/
|
|
580
|
+
private findFileBySymbolOrName(
|
|
581
|
+
ident: string,
|
|
582
|
+
contextFile?: string
|
|
583
|
+
): { file: FileRecord; symbol?: SymbolRecord } | null {
|
|
584
|
+
const cleanIdent = ident.replace(/controller$/i, "").toLowerCase();
|
|
585
|
+
|
|
586
|
+
// 1. Try imports of contextFile
|
|
587
|
+
if (contextFile) {
|
|
588
|
+
const srcFile = this.fileMapByNormPath.get(this.normalizeFilePath(contextFile));
|
|
589
|
+
if (srcFile) {
|
|
590
|
+
const deps = this.dependenciesByFileId.get(srcFile.id) || [];
|
|
591
|
+
for (const d of deps) {
|
|
592
|
+
if (d.statement.toLowerCase().includes(cleanIdent) || d.target.toLowerCase().includes(cleanIdent)) {
|
|
593
|
+
const resolved = this.resolveImportToFile(contextFile, d.target);
|
|
594
|
+
if (resolved) {
|
|
595
|
+
const syms = this.symbolsByFileId.get(resolved.id) || [];
|
|
596
|
+
const sym = syms.find(
|
|
597
|
+
(s) => s.name.toLowerCase() === ident.toLowerCase() || s.name.toLowerCase().includes(cleanIdent)
|
|
598
|
+
);
|
|
599
|
+
return { file: resolved, symbol: sym };
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// 2. Try match file path
|
|
607
|
+
for (const [norm, file] of this.fileMapByNormPath.entries()) {
|
|
608
|
+
if (norm.toLowerCase().includes(cleanIdent) && norm.toLowerCase().includes("controller")) {
|
|
609
|
+
const syms = this.symbolsByFileId.get(file.id) || [];
|
|
610
|
+
return { file, symbol: syms.find((s) => s.kind === "class") };
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Build a VerticalSliceRecord structure from an execution chain.
|
|
619
|
+
*/
|
|
620
|
+
private buildSliceRecordFromChain(
|
|
621
|
+
httpMethod: string,
|
|
622
|
+
routeUri: string,
|
|
623
|
+
chain: ExecutionChainNode[],
|
|
624
|
+
architectureStyle: string = "clean"
|
|
625
|
+
): Omit<VerticalSliceRecord, "id" | "created_at"> {
|
|
626
|
+
const controllerNode = chain.find((c) => c.stage === "controller" || c.stage === "orchestrator");
|
|
627
|
+
|
|
628
|
+
const ctrlParts = controllerNode?.symbol.split("::") || [];
|
|
629
|
+
const controllerClass = ctrlParts[0] || "Controller";
|
|
630
|
+
const actionName = ctrlParts[1] || "handle";
|
|
631
|
+
|
|
632
|
+
return {
|
|
633
|
+
domain_id: null,
|
|
634
|
+
feature_key: null,
|
|
635
|
+
http_method: httpMethod,
|
|
636
|
+
route_uri: routeUri,
|
|
637
|
+
route_name: `${httpMethod.toLowerCase()}.${routeUri.replace(/[^a-zA-Z0-9]/g, "_").replace(/^_+|_+$/g, "")}`,
|
|
638
|
+
controller_class: controllerClass,
|
|
639
|
+
action_name: actionName,
|
|
640
|
+
controller_file: controllerNode?.file || null,
|
|
641
|
+
controller_line: controllerNode?.line || 0,
|
|
642
|
+
architecture_style: architectureStyle,
|
|
643
|
+
entry_kind: "http_route",
|
|
644
|
+
execution_chain_json: JSON.stringify(chain),
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
private inferRouteUri(filePath: string, methodName: string): string {
|
|
649
|
+
const base = filePath
|
|
650
|
+
.split("/")
|
|
651
|
+
.pop()
|
|
652
|
+
?.replace(/controller\.[^.]+$/i, "")
|
|
653
|
+
.replace(/\.[^.]+$/, "")
|
|
654
|
+
.toLowerCase();
|
|
655
|
+
|
|
656
|
+
const resource = base ? `/${base}s` : "/resource";
|
|
657
|
+
if (/store|create/i.test(methodName)) return resource;
|
|
658
|
+
if (/show|get|find/i.test(methodName)) return `${resource}/:id`;
|
|
659
|
+
if (/update|edit|patch/i.test(methodName)) return `${resource}/:id`;
|
|
660
|
+
if (/delete|destroy|remove/i.test(methodName)) return `${resource}/:id`;
|
|
661
|
+
|
|
662
|
+
return `${resource}/${methodName}`;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
private normalizeFilePath(p: string): string {
|
|
666
|
+
return p.replace(/\\/g, "/").replace(/^\.\/?/, "").toLowerCase();
|
|
667
|
+
}
|
|
668
|
+
}
|