septum 0.1.4 → 0.1.6

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