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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -0
  3. package/bin/septum.ts +4 -0
  4. package/package.json +62 -0
  5. package/src/cli/commands/check.ts +161 -0
  6. package/src/cli/commands/hook.ts +188 -0
  7. package/src/cli/commands/ingest.ts +29 -0
  8. package/src/cli/commands/init.ts +211 -0
  9. package/src/cli/commands/locate.ts +61 -0
  10. package/src/cli/commands/query.ts +64 -0
  11. package/src/cli/commands/serve.ts +5 -0
  12. package/src/cli/commands/slice.ts +90 -0
  13. package/src/cli/commands/sync.ts +31 -0
  14. package/src/cli/index.ts +178 -0
  15. package/src/cli/templates/hooks.ts +75 -0
  16. package/src/cli/templates/rules.ts +38 -0
  17. package/src/cli/templates/skill.ts +66 -0
  18. package/src/core/boundary/evaluator.ts +210 -0
  19. package/src/core/config/loader.ts +84 -0
  20. package/src/core/config/schema.ts +39 -0
  21. package/src/core/database/client.ts +132 -0
  22. package/src/core/database/repositories/dependency.repository.ts +279 -0
  23. package/src/core/database/repositories/domain.repository.ts +198 -0
  24. package/src/core/database/repositories/file.repository.ts +99 -0
  25. package/src/core/database/repositories/meta.repository.ts +33 -0
  26. package/src/core/database/repositories/symbol.repository.ts +424 -0
  27. package/src/core/database/repository.ts +242 -0
  28. package/src/core/database/schema.sql +92 -0
  29. package/src/core/discovery/topology-detector.ts +418 -0
  30. package/src/core/ingestion/hasher.ts +5 -0
  31. package/src/core/ingestion/pipeline.ts +438 -0
  32. package/src/core/parser/boundary-tracker.ts +128 -0
  33. package/src/core/parser/extractors/base.ts +60 -0
  34. package/src/core/parser/extractors/frontend.ts +194 -0
  35. package/src/core/parser/extractors/go.ts +176 -0
  36. package/src/core/parser/extractors/laravel-semantic.ts +426 -0
  37. package/src/core/parser/extractors/mcp-cli-semantic.ts +199 -0
  38. package/src/core/parser/extractors/nestjs-semantic.ts +119 -0
  39. package/src/core/parser/extractors/php.ts +149 -0
  40. package/src/core/parser/extractors/python.ts +226 -0
  41. package/src/core/parser/extractors/registry.ts +49 -0
  42. package/src/core/parser/extractors/semantic-extractor.interface.ts +21 -0
  43. package/src/core/parser/extractors/typescript.ts +177 -0
  44. package/src/core/parser/tree-sitter.ts +39 -0
  45. package/src/core/resolver/call-graph-tracer.ts +668 -0
  46. package/src/core/resolver/module-resolver.ts +323 -0
  47. package/src/core/resolver/symbol-locator.ts +405 -0
  48. package/src/core/resolver/vertical-slice-tracer.ts +236 -0
  49. package/src/core/session/session-manager.ts +56 -0
  50. package/src/core/telemetry/telemetry.ts +86 -0
  51. package/src/index.ts +18 -0
  52. package/src/mcp/schemas.ts +68 -0
  53. package/src/mcp/server.ts +602 -0
  54. package/src/mcp/tools/check-boundary.ts +102 -0
  55. package/src/mcp/tools/get-domain-catalog.ts +91 -0
  56. package/src/mcp/tools/get-feature-context.ts +116 -0
  57. package/src/mcp/tools/get-symbol-hotspots.ts +49 -0
  58. package/src/mcp/tools/get-symbol-impact.ts +26 -0
  59. package/src/mcp/tools/get-symbol.ts +63 -0
  60. package/src/mcp/tools/locate-symbol.ts +74 -0
  61. package/src/mcp/tools/register-domain.ts +64 -0
  62. package/src/mcp/tools/trace-vertical-slice.ts +102 -0
  63. 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
+ }