septum 0.1.0 → 0.1.2

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 (65) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +100 -100
  3. package/bin/septum.ts +4 -4
  4. package/package.json +63 -62
  5. package/src/cli/commands/check.ts +161 -161
  6. package/src/cli/commands/hook.ts +188 -188
  7. package/src/cli/commands/ingest.ts +29 -29
  8. package/src/cli/commands/init.ts +211 -211
  9. package/src/cli/commands/locate.ts +61 -61
  10. package/src/cli/commands/query.ts +64 -64
  11. package/src/cli/commands/serve.ts +5 -5
  12. package/src/cli/commands/slice.ts +90 -90
  13. package/src/cli/commands/sync.ts +31 -31
  14. package/src/cli/index.ts +199 -178
  15. package/src/cli/templates/hooks.ts +75 -75
  16. package/src/cli/templates/rules.ts +38 -38
  17. package/src/cli/templates/skill.ts +66 -66
  18. package/src/core/boundary/evaluator.ts +210 -210
  19. package/src/core/config/loader.ts +84 -84
  20. package/src/core/config/schema.ts +39 -39
  21. package/src/core/database/client.ts +44 -132
  22. package/src/core/database/repositories/dependency.repository.ts +279 -279
  23. package/src/core/database/repositories/domain.repository.ts +198 -198
  24. package/src/core/database/repositories/file.repository.ts +99 -99
  25. package/src/core/database/repositories/meta.repository.ts +33 -33
  26. package/src/core/database/repositories/symbol.repository.ts +424 -424
  27. package/src/core/database/repository.ts +242 -242
  28. package/src/core/database/schema.ts +120 -0
  29. package/src/core/discovery/topology-detector.ts +418 -418
  30. package/src/core/ingestion/hasher.ts +5 -5
  31. package/src/core/ingestion/pipeline.ts +438 -438
  32. package/src/core/parser/boundary-tracker.ts +128 -128
  33. package/src/core/parser/extractors/base.ts +60 -60
  34. package/src/core/parser/extractors/frontend.ts +194 -194
  35. package/src/core/parser/extractors/go.ts +176 -176
  36. package/src/core/parser/extractors/laravel-semantic.ts +426 -426
  37. package/src/core/parser/extractors/mcp-cli-semantic.ts +199 -199
  38. package/src/core/parser/extractors/nestjs-semantic.ts +119 -119
  39. package/src/core/parser/extractors/php.ts +149 -149
  40. package/src/core/parser/extractors/python.ts +226 -226
  41. package/src/core/parser/extractors/registry.ts +49 -49
  42. package/src/core/parser/extractors/semantic-extractor.interface.ts +21 -21
  43. package/src/core/parser/extractors/typescript.ts +177 -177
  44. package/src/core/parser/tree-sitter.ts +39 -39
  45. package/src/core/resolver/call-graph-tracer.ts +668 -668
  46. package/src/core/resolver/module-resolver.ts +342 -323
  47. package/src/core/resolver/symbol-locator.ts +543 -405
  48. package/src/core/resolver/vertical-slice-tracer.ts +237 -236
  49. package/src/core/session/session-manager.ts +56 -56
  50. package/src/core/telemetry/telemetry.ts +86 -86
  51. package/src/index.ts +18 -18
  52. package/src/mcp/schemas.ts +68 -68
  53. package/src/mcp/server.ts +612 -602
  54. package/src/mcp/tools/check-boundary.ts +102 -102
  55. package/src/mcp/tools/get-domain-catalog.ts +91 -91
  56. package/src/mcp/tools/get-feature-context.ts +116 -116
  57. package/src/mcp/tools/get-symbol-hotspots.ts +49 -49
  58. package/src/mcp/tools/get-symbol-impact.ts +26 -26
  59. package/src/mcp/tools/get-symbol.ts +63 -63
  60. package/src/mcp/tools/locate-symbol.ts +74 -74
  61. package/src/mcp/tools/register-domain.ts +64 -64
  62. package/src/mcp/tools/trace-vertical-slice.ts +102 -102
  63. package/src/types/index.ts +296 -296
  64. package/src/version.ts +3 -0
  65. package/src/core/database/schema.sql +0 -92
@@ -1,426 +1,426 @@
1
- import * as fs from "node:fs";
2
- import * as path from "node:path";
3
- import type { VerticalSliceRecord } from "../../../types/index.ts";
4
- import type {
5
- SemanticSliceExtractor,
6
- VerticalSliceCandidate,
7
- } from "./semantic-extractor.interface.ts";
8
-
9
- export interface ParsedRoute {
10
- httpMethod: string;
11
- uri: string;
12
- routeName?: string;
13
- controllerClass: string;
14
- actionName: string;
15
- }
16
-
17
- export type LaravelSemanticExtractionResult = VerticalSliceCandidate[] & {
18
- slices: VerticalSliceCandidate[];
19
- };
20
-
21
- export class LaravelSemanticExtractor implements SemanticSliceExtractor {
22
- public readonly id = "laravel";
23
- public readonly name = "Laravel MVC Semantic Extractor";
24
-
25
- constructor(private projectRoot: string = process.cwd()) {}
26
-
27
- public canHandle(projectRoot: string, framework?: string): boolean {
28
- if (framework === "laravel") return true;
29
- return (
30
- fs.existsSync(path.join(projectRoot, "artisan")) ||
31
- fs.existsSync(path.join(projectRoot, "routes/web.php")) ||
32
- fs.existsSync(path.join(projectRoot, "routes/api.php"))
33
- );
34
- }
35
-
36
- /**
37
- * Scan route files and extract complete vertical slices.
38
- */
39
- public extractSlices(
40
- projectRootOrDomainId?: string | number,
41
- maybeDomainId?: number
42
- ): LaravelSemanticExtractionResult {
43
- let root = this.projectRoot;
44
- let domainId: number | undefined;
45
-
46
- if (typeof projectRootOrDomainId === "string") {
47
- root = projectRootOrDomainId;
48
- domainId = maybeDomainId;
49
- } else if (typeof projectRootOrDomainId === "number") {
50
- domainId = projectRootOrDomainId;
51
- }
52
-
53
- const routeFiles = [
54
- path.join(root, "routes/web.php"),
55
- path.join(root, "routes/api.php"),
56
- ].filter((f) => fs.existsSync(f));
57
-
58
- const parsedRoutes: ParsedRoute[] = [];
59
-
60
- for (const routeFile of routeFiles) {
61
- const content = fs.readFileSync(routeFile, "utf-8");
62
- parsedRoutes.push(...this.parseRoutesContent(content));
63
- }
64
-
65
- const slices: Array<Omit<VerticalSliceRecord, "id" | "created_at">> = [];
66
-
67
- for (const route of parsedRoutes) {
68
- const controllerInfo = this.resolveController(route.controllerClass, route.actionName);
69
-
70
- let formRequestInfo: {
71
- className: string;
72
- filePath?: string;
73
- rules: Record<string, string>;
74
- } | null = null;
75
-
76
- let modelInfo: {
77
- className: string;
78
- filePath?: string;
79
- fillable: string[];
80
- casts: Record<string, string>;
81
- } | null = null;
82
-
83
- let frontendInfo: {
84
- target: string;
85
- props: string[];
86
- } | null = null;
87
-
88
- if (controllerInfo.filePath && fs.existsSync(controllerInfo.filePath)) {
89
- const controllerCode = fs.readFileSync(controllerInfo.filePath, "utf-8");
90
- const actionBody = this.extractActionBody(controllerCode, route.actionName);
91
-
92
- // 1. Detect FormRequest in method signature
93
- const requestClassName = this.detectFormRequestClass(controllerCode, route.actionName);
94
- if (requestClassName) {
95
- const reqFile = this.findClassFile("Requests", requestClassName);
96
- const rules = reqFile && fs.existsSync(reqFile) ? this.extractValidationRules(fs.readFileSync(reqFile, "utf-8")) : {};
97
- formRequestInfo = {
98
- className: requestClassName,
99
- filePath: reqFile ? path.relative(this.projectRoot, reqFile) : undefined,
100
- rules,
101
- };
102
- }
103
-
104
- // 2. Detect Model referenced in parameters or action body
105
- const modelClassName = this.detectModelClass(controllerCode, route.actionName, actionBody);
106
- if (modelClassName) {
107
- const modFile = this.findClassFile("Models", modelClassName);
108
- const modMetadata = modFile && fs.existsSync(modFile) ? this.extractModelMetadata(fs.readFileSync(modFile, "utf-8")) : { fillable: [], casts: {} };
109
- modelInfo = {
110
- className: modelClassName,
111
- filePath: modFile ? path.relative(this.projectRoot, modFile) : undefined,
112
- fillable: modMetadata.fillable,
113
- casts: modMetadata.casts,
114
- };
115
- }
116
-
117
- // 3. Detect Inertia Render / View Target
118
- frontendInfo = this.detectFrontendTarget(actionBody);
119
- }
120
-
121
- const chain: Array<{ stage: string; symbol: string; file?: string; line?: number; description?: string }> = [
122
- {
123
- stage: "ingress",
124
- symbol: `${route.httpMethod.toUpperCase()} ${route.uri.startsWith("/") ? route.uri : `/${route.uri}`}`,
125
- file: route.file,
126
- description: `HTTP Route: ${route.routeName || route.uri}`,
127
- },
128
- ];
129
-
130
- if (formRequestInfo) {
131
- chain.push({
132
- stage: "validation",
133
- symbol: formRequestInfo.className,
134
- file: formRequestInfo.filePath,
135
- description: "Form Request Validation",
136
- });
137
- }
138
-
139
- chain.push({
140
- stage: "controller",
141
- symbol: `${route.controllerClass}::${route.actionName}`,
142
- file: controllerInfo.filePath ? path.relative(this.projectRoot, controllerInfo.filePath) : undefined,
143
- line: controllerInfo.line,
144
- description: `Controller Action`,
145
- });
146
-
147
- if (modelInfo) {
148
- chain.push({
149
- stage: "entity",
150
- symbol: modelInfo.className,
151
- file: modelInfo.filePath,
152
- description: "Eloquent Model",
153
- });
154
- }
155
-
156
- if (frontendInfo) {
157
- chain.push({
158
- stage: "egress",
159
- symbol: frontendInfo.target,
160
- description: "Inertia / Frontend Target",
161
- });
162
- }
163
-
164
- slices.push({
165
- domain_id: domainId ?? null,
166
- feature_key: route.routeName || null,
167
- http_method: route.httpMethod.toUpperCase(),
168
- route_uri: route.uri.startsWith("/") ? route.uri : `/${route.uri}`,
169
- route_name: route.routeName || null,
170
- controller_class: route.controllerClass,
171
- action_name: route.actionName,
172
- controller_file: controllerInfo.filePath ? path.relative(this.projectRoot, controllerInfo.filePath) : null,
173
- controller_line: controllerInfo.line,
174
- architecture_style: "mvc",
175
- entry_kind: "http_route",
176
- execution_chain_json: JSON.stringify(chain),
177
- });
178
- }
179
-
180
- return Object.assign(slices, { slices });
181
- }
182
-
183
- /**
184
- * Parse Laravel Route declarations.
185
- */
186
- public parseRoutesContent(content: string): ParsedRoute[] {
187
- const routes: ParsedRoute[] = [];
188
-
189
- // Match Route::(get|post|put|patch|delete)('uri', [Controller::class, 'action'])->name('...')
190
- const methodRegex = /Route::(get|post|put|patch|delete|any)\s*\(\s*['"]([^'"]+)['"]\s*,\s*(?:\[\s*([A-Za-z0-9_]+)::class\s*,\s*['"]([A-Za-z0-9_]+)['"]\s*\]|['"]([A-Za-z0-9_]+)@([A-Za-z0-9_]+)['"])\s*\)(?:\s*->\s*name\s*\(\s*['"]([^'"]+)['"]\s*\))?/gi;
191
-
192
- let match;
193
- while ((match = methodRegex.exec(content)) !== null) {
194
- const httpMethod = match[1];
195
- const uri = match[2];
196
- const controllerClass = match[3] || match[5];
197
- const actionName = match[4] || match[6];
198
- const routeName = match[7];
199
-
200
- if (controllerClass && actionName) {
201
- routes.push({
202
- httpMethod,
203
- uri,
204
- routeName,
205
- controllerClass,
206
- actionName,
207
- });
208
- }
209
- }
210
-
211
- // Match Route::resource('orders', OrderController::class)
212
- const resourceRegex = /Route::resource\s*\(\s*['"]([^'"]+)['"]\s*,\s*([A-Za-z0-9_]+)::class\s*\)/gi;
213
- while ((match = resourceRegex.exec(content)) !== null) {
214
- const baseUri = match[1];
215
- const controllerClass = match[2];
216
- const resourceActions = [
217
- { method: "GET", sub: "", action: "index" },
218
- { method: "GET", sub: "/create", action: "create" },
219
- { method: "POST", sub: "", action: "store" },
220
- { method: "GET", sub: "/{id}", action: "show" },
221
- { method: "GET", sub: "/{id}/edit", action: "edit" },
222
- { method: "PUT", sub: "/{id}", action: "update" },
223
- { method: "DELETE", sub: "/{id}", action: "destroy" },
224
- ];
225
-
226
- for (const res of resourceActions) {
227
- routes.push({
228
- httpMethod: res.method,
229
- uri: `/${baseUri}${res.sub}`,
230
- routeName: `${baseUri}.${res.action}`,
231
- controllerClass,
232
- actionName: res.action,
233
- });
234
- }
235
- }
236
-
237
- return routes;
238
- }
239
-
240
- private resolveController(controllerClass: string, actionName: string): { filePath?: string; line: number } {
241
- const simpleName = controllerClass.split(/\\|\//).pop() || controllerClass;
242
- const candidates = [
243
- path.join(this.projectRoot, `app/Http/Controllers/${simpleName}.php`),
244
- path.join(this.projectRoot, `app/Http/Controllers/Api/${simpleName}.php`),
245
- path.join(this.projectRoot, `app/Http/Controllers/Admin/${simpleName}.php`),
246
- ];
247
-
248
- for (const c of candidates) {
249
- if (fs.existsSync(c)) {
250
- const lines = fs.readFileSync(c, "utf-8").split("\n");
251
- let targetLine = 1;
252
- for (let i = 0; i < lines.length; i++) {
253
- if (new RegExp(`function\\s+${actionName}\\s*\\(`, "i").test(lines[i])) {
254
- targetLine = i + 1;
255
- break;
256
- }
257
- }
258
- return { filePath: c, line: targetLine };
259
- }
260
- }
261
-
262
- return { line: 0 };
263
- }
264
-
265
- private extractActionBody(controllerCode: string, actionName: string): string {
266
- const fnRegex = new RegExp(`public\\s+function\\s+${actionName}\\s*\\([^{]*\\)\\s*\\{`, "i");
267
- const match = fnRegex.exec(controllerCode);
268
- if (!match) return "";
269
-
270
- const startIndex = match.index + match[0].length;
271
- let braceDepth = 1;
272
- let endIndex = startIndex;
273
-
274
- while (braceDepth > 0 && endIndex < controllerCode.length) {
275
- const char = controllerCode[endIndex];
276
- if (char === "{") braceDepth++;
277
- else if (char === "}") braceDepth--;
278
- endIndex++;
279
- }
280
-
281
- return controllerCode.substring(startIndex, endIndex - 1);
282
- }
283
-
284
- private detectFormRequestClass(controllerCode: string, actionName: string): string | null {
285
- const paramRegex = new RegExp(`public\\s+function\\s+${actionName}\\s*\\(([^)]*)\\)`, "i");
286
- const match = paramRegex.exec(controllerCode);
287
- if (!match) return null;
288
-
289
- const params = match[1];
290
- // Find class names ending with Request
291
- const reqMatch = params.match(/([A-Za-z0-9_]+Request)\s+\$/);
292
- if (reqMatch && reqMatch[1] !== "Request") {
293
- return reqMatch[1];
294
- }
295
- return null;
296
- }
297
-
298
- private detectModelClass(controllerCode: string, actionName: string, actionBody: string): string | null {
299
- // 1. Method signature type-hints: (Order $order)
300
- const paramRegex = new RegExp(`public\\s+function\\s+${actionName}\\s*\\(([^)]*)\\)`, "i");
301
- const match = paramRegex.exec(controllerCode);
302
- if (match) {
303
- const parts = match[1].split(",");
304
- for (const p of parts) {
305
- const pMatch = p.trim().match(/^([A-Z][A-Za-z0-9_]+)\s+\$/);
306
- if (pMatch && !pMatch[1].endsWith("Request") && pMatch[1] !== "Request") {
307
- return pMatch[1];
308
- }
309
- }
310
- }
311
-
312
- // 2. Action body static calls: Order::find(), Order::create(), Order::query()
313
- const staticModelMatch = actionBody.match(/([A-Z][A-Za-z0-9_]+)::(?:find|findOrFail|create|where|query)\s*\(/);
314
- if (staticModelMatch && !["Inertia", "Route", "DB", "Auth", "Log", "Response"].includes(staticModelMatch[1])) {
315
- return staticModelMatch[1];
316
- }
317
-
318
- return null;
319
- }
320
-
321
- private detectFrontendTarget(actionBody: string): { target: string; props: string[] } | null {
322
- // 1. Inertia::render('Orders/Show', ['order' => $order])
323
- const inertiaMatch = actionBody.match(/Inertia::render\s*\(\s*['"]([^'"]+)['"](?:\s*,\s*\[([^\]]*)\])?\s*\)/);
324
- if (inertiaMatch) {
325
- const pageComponent = inertiaMatch[1]; // e.g. "Orders/Show"
326
- const rawProps = inertiaMatch[2] || "";
327
-
328
- // Extract prop keys: 'order' => ...
329
- const propMatches = rawProps.matchAll(/['"]([A-Za-z0-9_]+)['"]\s*=>/g);
330
- const props: string[] = [];
331
- for (const pm of propMatches) {
332
- props.push(pm[1]);
333
- }
334
-
335
- // Check for Vue, TSX, JSX files in resources/js/Pages
336
- const possibleExtensions = [".vue", ".tsx", ".jsx", ".svelte"];
337
- let frontendPath = `resources/js/Pages/${pageComponent}`;
338
- for (const ext of possibleExtensions) {
339
- const fullCandidate = path.join(this.projectRoot, `resources/js/Pages/${pageComponent}${ext}`);
340
- if (fs.existsSync(fullCandidate)) {
341
- frontendPath = `resources/js/Pages/${pageComponent}${ext}`;
342
- break;
343
- }
344
- }
345
- if (!frontendPath.includes(".")) {
346
- frontendPath += ".vue"; // Default inertia convention
347
- }
348
-
349
- return {
350
- target: frontendPath,
351
- props,
352
- };
353
- }
354
-
355
- // 2. view('orders.show', compact('order'))
356
- const viewMatch = actionBody.match(/view\s*\(\s*['"]([^'"]+)['"]/);
357
- if (viewMatch) {
358
- const viewPath = viewMatch[1].replace(/\./g, "/");
359
- return {
360
- target: `resources/views/${viewPath}.blade.php`,
361
- props: [],
362
- };
363
- }
364
-
365
- return null;
366
- }
367
-
368
- private findClassFile(subDir: "Requests" | "Models", className: string): string | null {
369
- const directPath = path.join(this.projectRoot, `app/${subDir}/${className}.php`);
370
- if (fs.existsSync(directPath)) return directPath;
371
-
372
- // Direct app/ fallback for older Laravel
373
- const flatPath = path.join(this.projectRoot, `app/${className}.php`);
374
- if (fs.existsSync(flatPath)) return flatPath;
375
-
376
- return null;
377
- }
378
-
379
- private extractValidationRules(requestCode: string): Record<string, string> {
380
- const rules: Record<string, string> = {};
381
- const rulesMatch = requestCode.match(/function\s+rules\s*\(\s*\)[^{]*\{([^}]*)\}/i);
382
- if (!rulesMatch) return rules;
383
-
384
- const body = rulesMatch[1];
385
- // Match 'status' => 'in:pending,paid' or 'status' => ['required', 'string']
386
- const rulePairRegex = /['"]([A-Za-z0-9_]+)['"]\s*=>\s*(?:['"]([^'"]+)['"]|\[([^\]]+)\])/g;
387
- let rm;
388
- while ((rm = rulePairRegex.exec(body)) !== null) {
389
- const field = rm[1];
390
- const singleRule = rm[2];
391
- const arrayRules = rm[3];
392
- if (singleRule) {
393
- rules[field] = singleRule;
394
- } else if (arrayRules) {
395
- rules[field] = arrayRules.replace(/['"\s]/g, "").replace(/,/g, "|");
396
- }
397
- }
398
-
399
- return rules;
400
- }
401
-
402
- private extractModelMetadata(modelCode: string): { fillable: string[]; casts: Record<string, string> } {
403
- const fillable: string[] = [];
404
- const casts: Record<string, string> = {};
405
-
406
- // Match protected $fillable = [...]
407
- const fillableMatch = modelCode.match(/\$fillable\s*=\s*\[([^\]]*)\]/);
408
- if (fillableMatch) {
409
- const items = fillableMatch[1].matchAll(/['"]([A-Za-z0-9_]+)['"]/g);
410
- for (const it of items) {
411
- fillable.push(it[1]);
412
- }
413
- }
414
-
415
- // Match protected $casts = [...]
416
- const castsMatch = modelCode.match(/\$casts\s*=\s*\[([^\]]*)\]/);
417
- if (castsMatch) {
418
- const castItems = castsMatch[1].matchAll(/['"]([A-Za-z0-9_]+)['"]\s*=>\s*(?:['"]([^'"]+)['"]|([A-Za-z0-9_]+)::class)/g);
419
- for (const ci of castItems) {
420
- casts[ci[1]] = ci[2] || ci[3];
421
- }
422
- }
423
-
424
- return { fillable, casts };
425
- }
426
- }
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { VerticalSliceRecord } from "../../../types/index.ts";
4
+ import type {
5
+ SemanticSliceExtractor,
6
+ VerticalSliceCandidate,
7
+ } from "./semantic-extractor.interface.ts";
8
+
9
+ export interface ParsedRoute {
10
+ httpMethod: string;
11
+ uri: string;
12
+ routeName?: string;
13
+ controllerClass: string;
14
+ actionName: string;
15
+ }
16
+
17
+ export type LaravelSemanticExtractionResult = VerticalSliceCandidate[] & {
18
+ slices: VerticalSliceCandidate[];
19
+ };
20
+
21
+ export class LaravelSemanticExtractor implements SemanticSliceExtractor {
22
+ public readonly id = "laravel";
23
+ public readonly name = "Laravel MVC Semantic Extractor";
24
+
25
+ constructor(private projectRoot: string = process.cwd()) {}
26
+
27
+ public canHandle(projectRoot: string, framework?: string): boolean {
28
+ if (framework === "laravel") return true;
29
+ return (
30
+ fs.existsSync(path.join(projectRoot, "artisan")) ||
31
+ fs.existsSync(path.join(projectRoot, "routes/web.php")) ||
32
+ fs.existsSync(path.join(projectRoot, "routes/api.php"))
33
+ );
34
+ }
35
+
36
+ /**
37
+ * Scan route files and extract complete vertical slices.
38
+ */
39
+ public extractSlices(
40
+ projectRootOrDomainId?: string | number,
41
+ maybeDomainId?: number
42
+ ): LaravelSemanticExtractionResult {
43
+ let root = this.projectRoot;
44
+ let domainId: number | undefined;
45
+
46
+ if (typeof projectRootOrDomainId === "string") {
47
+ root = projectRootOrDomainId;
48
+ domainId = maybeDomainId;
49
+ } else if (typeof projectRootOrDomainId === "number") {
50
+ domainId = projectRootOrDomainId;
51
+ }
52
+
53
+ const routeFiles = [
54
+ path.join(root, "routes/web.php"),
55
+ path.join(root, "routes/api.php"),
56
+ ].filter((f) => fs.existsSync(f));
57
+
58
+ const parsedRoutes: ParsedRoute[] = [];
59
+
60
+ for (const routeFile of routeFiles) {
61
+ const content = fs.readFileSync(routeFile, "utf-8");
62
+ parsedRoutes.push(...this.parseRoutesContent(content));
63
+ }
64
+
65
+ const slices: Array<Omit<VerticalSliceRecord, "id" | "created_at">> = [];
66
+
67
+ for (const route of parsedRoutes) {
68
+ const controllerInfo = this.resolveController(route.controllerClass, route.actionName);
69
+
70
+ let formRequestInfo: {
71
+ className: string;
72
+ filePath?: string;
73
+ rules: Record<string, string>;
74
+ } | null = null;
75
+
76
+ let modelInfo: {
77
+ className: string;
78
+ filePath?: string;
79
+ fillable: string[];
80
+ casts: Record<string, string>;
81
+ } | null = null;
82
+
83
+ let frontendInfo: {
84
+ target: string;
85
+ props: string[];
86
+ } | null = null;
87
+
88
+ if (controllerInfo.filePath && fs.existsSync(controllerInfo.filePath)) {
89
+ const controllerCode = fs.readFileSync(controllerInfo.filePath, "utf-8");
90
+ const actionBody = this.extractActionBody(controllerCode, route.actionName);
91
+
92
+ // 1. Detect FormRequest in method signature
93
+ const requestClassName = this.detectFormRequestClass(controllerCode, route.actionName);
94
+ if (requestClassName) {
95
+ const reqFile = this.findClassFile("Requests", requestClassName);
96
+ const rules = reqFile && fs.existsSync(reqFile) ? this.extractValidationRules(fs.readFileSync(reqFile, "utf-8")) : {};
97
+ formRequestInfo = {
98
+ className: requestClassName,
99
+ filePath: reqFile ? path.relative(this.projectRoot, reqFile) : undefined,
100
+ rules,
101
+ };
102
+ }
103
+
104
+ // 2. Detect Model referenced in parameters or action body
105
+ const modelClassName = this.detectModelClass(controllerCode, route.actionName, actionBody);
106
+ if (modelClassName) {
107
+ const modFile = this.findClassFile("Models", modelClassName);
108
+ const modMetadata = modFile && fs.existsSync(modFile) ? this.extractModelMetadata(fs.readFileSync(modFile, "utf-8")) : { fillable: [], casts: {} };
109
+ modelInfo = {
110
+ className: modelClassName,
111
+ filePath: modFile ? path.relative(this.projectRoot, modFile) : undefined,
112
+ fillable: modMetadata.fillable,
113
+ casts: modMetadata.casts,
114
+ };
115
+ }
116
+
117
+ // 3. Detect Inertia Render / View Target
118
+ frontendInfo = this.detectFrontendTarget(actionBody);
119
+ }
120
+
121
+ const chain: Array<{ stage: string; symbol: string; file?: string; line?: number; description?: string }> = [
122
+ {
123
+ stage: "ingress",
124
+ symbol: `${route.httpMethod.toUpperCase()} ${route.uri.startsWith("/") ? route.uri : `/${route.uri}`}`,
125
+ file: route.file,
126
+ description: `HTTP Route: ${route.routeName || route.uri}`,
127
+ },
128
+ ];
129
+
130
+ if (formRequestInfo) {
131
+ chain.push({
132
+ stage: "validation",
133
+ symbol: formRequestInfo.className,
134
+ file: formRequestInfo.filePath,
135
+ description: "Form Request Validation",
136
+ });
137
+ }
138
+
139
+ chain.push({
140
+ stage: "controller",
141
+ symbol: `${route.controllerClass}::${route.actionName}`,
142
+ file: controllerInfo.filePath ? path.relative(this.projectRoot, controllerInfo.filePath) : undefined,
143
+ line: controllerInfo.line,
144
+ description: `Controller Action`,
145
+ });
146
+
147
+ if (modelInfo) {
148
+ chain.push({
149
+ stage: "entity",
150
+ symbol: modelInfo.className,
151
+ file: modelInfo.filePath,
152
+ description: "Eloquent Model",
153
+ });
154
+ }
155
+
156
+ if (frontendInfo) {
157
+ chain.push({
158
+ stage: "egress",
159
+ symbol: frontendInfo.target,
160
+ description: "Inertia / Frontend Target",
161
+ });
162
+ }
163
+
164
+ slices.push({
165
+ domain_id: domainId ?? null,
166
+ feature_key: route.routeName || null,
167
+ http_method: route.httpMethod.toUpperCase(),
168
+ route_uri: route.uri.startsWith("/") ? route.uri : `/${route.uri}`,
169
+ route_name: route.routeName || null,
170
+ controller_class: route.controllerClass,
171
+ action_name: route.actionName,
172
+ controller_file: controllerInfo.filePath ? path.relative(this.projectRoot, controllerInfo.filePath) : null,
173
+ controller_line: controllerInfo.line,
174
+ architecture_style: "mvc",
175
+ entry_kind: "http_route",
176
+ execution_chain_json: JSON.stringify(chain),
177
+ });
178
+ }
179
+
180
+ return Object.assign(slices, { slices });
181
+ }
182
+
183
+ /**
184
+ * Parse Laravel Route declarations.
185
+ */
186
+ public parseRoutesContent(content: string): ParsedRoute[] {
187
+ const routes: ParsedRoute[] = [];
188
+
189
+ // Match Route::(get|post|put|patch|delete)('uri', [Controller::class, 'action'])->name('...')
190
+ const methodRegex = /Route::(get|post|put|patch|delete|any)\s*\(\s*['"]([^'"]+)['"]\s*,\s*(?:\[\s*([A-Za-z0-9_]+)::class\s*,\s*['"]([A-Za-z0-9_]+)['"]\s*\]|['"]([A-Za-z0-9_]+)@([A-Za-z0-9_]+)['"])\s*\)(?:\s*->\s*name\s*\(\s*['"]([^'"]+)['"]\s*\))?/gi;
191
+
192
+ let match;
193
+ while ((match = methodRegex.exec(content)) !== null) {
194
+ const httpMethod = match[1];
195
+ const uri = match[2];
196
+ const controllerClass = match[3] || match[5];
197
+ const actionName = match[4] || match[6];
198
+ const routeName = match[7];
199
+
200
+ if (controllerClass && actionName) {
201
+ routes.push({
202
+ httpMethod,
203
+ uri,
204
+ routeName,
205
+ controllerClass,
206
+ actionName,
207
+ });
208
+ }
209
+ }
210
+
211
+ // Match Route::resource('orders', OrderController::class)
212
+ const resourceRegex = /Route::resource\s*\(\s*['"]([^'"]+)['"]\s*,\s*([A-Za-z0-9_]+)::class\s*\)/gi;
213
+ while ((match = resourceRegex.exec(content)) !== null) {
214
+ const baseUri = match[1];
215
+ const controllerClass = match[2];
216
+ const resourceActions = [
217
+ { method: "GET", sub: "", action: "index" },
218
+ { method: "GET", sub: "/create", action: "create" },
219
+ { method: "POST", sub: "", action: "store" },
220
+ { method: "GET", sub: "/{id}", action: "show" },
221
+ { method: "GET", sub: "/{id}/edit", action: "edit" },
222
+ { method: "PUT", sub: "/{id}", action: "update" },
223
+ { method: "DELETE", sub: "/{id}", action: "destroy" },
224
+ ];
225
+
226
+ for (const res of resourceActions) {
227
+ routes.push({
228
+ httpMethod: res.method,
229
+ uri: `/${baseUri}${res.sub}`,
230
+ routeName: `${baseUri}.${res.action}`,
231
+ controllerClass,
232
+ actionName: res.action,
233
+ });
234
+ }
235
+ }
236
+
237
+ return routes;
238
+ }
239
+
240
+ private resolveController(controllerClass: string, actionName: string): { filePath?: string; line: number } {
241
+ const simpleName = controllerClass.split(/\\|\//).pop() || controllerClass;
242
+ const candidates = [
243
+ path.join(this.projectRoot, `app/Http/Controllers/${simpleName}.php`),
244
+ path.join(this.projectRoot, `app/Http/Controllers/Api/${simpleName}.php`),
245
+ path.join(this.projectRoot, `app/Http/Controllers/Admin/${simpleName}.php`),
246
+ ];
247
+
248
+ for (const c of candidates) {
249
+ if (fs.existsSync(c)) {
250
+ const lines = fs.readFileSync(c, "utf-8").split("\n");
251
+ let targetLine = 1;
252
+ for (let i = 0; i < lines.length; i++) {
253
+ if (new RegExp(`function\\s+${actionName}\\s*\\(`, "i").test(lines[i])) {
254
+ targetLine = i + 1;
255
+ break;
256
+ }
257
+ }
258
+ return { filePath: c, line: targetLine };
259
+ }
260
+ }
261
+
262
+ return { line: 0 };
263
+ }
264
+
265
+ private extractActionBody(controllerCode: string, actionName: string): string {
266
+ const fnRegex = new RegExp(`public\\s+function\\s+${actionName}\\s*\\([^{]*\\)\\s*\\{`, "i");
267
+ const match = fnRegex.exec(controllerCode);
268
+ if (!match) return "";
269
+
270
+ const startIndex = match.index + match[0].length;
271
+ let braceDepth = 1;
272
+ let endIndex = startIndex;
273
+
274
+ while (braceDepth > 0 && endIndex < controllerCode.length) {
275
+ const char = controllerCode[endIndex];
276
+ if (char === "{") braceDepth++;
277
+ else if (char === "}") braceDepth--;
278
+ endIndex++;
279
+ }
280
+
281
+ return controllerCode.substring(startIndex, endIndex - 1);
282
+ }
283
+
284
+ private detectFormRequestClass(controllerCode: string, actionName: string): string | null {
285
+ const paramRegex = new RegExp(`public\\s+function\\s+${actionName}\\s*\\(([^)]*)\\)`, "i");
286
+ const match = paramRegex.exec(controllerCode);
287
+ if (!match) return null;
288
+
289
+ const params = match[1];
290
+ // Find class names ending with Request
291
+ const reqMatch = params.match(/([A-Za-z0-9_]+Request)\s+\$/);
292
+ if (reqMatch && reqMatch[1] !== "Request") {
293
+ return reqMatch[1];
294
+ }
295
+ return null;
296
+ }
297
+
298
+ private detectModelClass(controllerCode: string, actionName: string, actionBody: string): string | null {
299
+ // 1. Method signature type-hints: (Order $order)
300
+ const paramRegex = new RegExp(`public\\s+function\\s+${actionName}\\s*\\(([^)]*)\\)`, "i");
301
+ const match = paramRegex.exec(controllerCode);
302
+ if (match) {
303
+ const parts = match[1].split(",");
304
+ for (const p of parts) {
305
+ const pMatch = p.trim().match(/^([A-Z][A-Za-z0-9_]+)\s+\$/);
306
+ if (pMatch && !pMatch[1].endsWith("Request") && pMatch[1] !== "Request") {
307
+ return pMatch[1];
308
+ }
309
+ }
310
+ }
311
+
312
+ // 2. Action body static calls: Order::find(), Order::create(), Order::query()
313
+ const staticModelMatch = actionBody.match(/([A-Z][A-Za-z0-9_]+)::(?:find|findOrFail|create|where|query)\s*\(/);
314
+ if (staticModelMatch && !["Inertia", "Route", "DB", "Auth", "Log", "Response"].includes(staticModelMatch[1])) {
315
+ return staticModelMatch[1];
316
+ }
317
+
318
+ return null;
319
+ }
320
+
321
+ private detectFrontendTarget(actionBody: string): { target: string; props: string[] } | null {
322
+ // 1. Inertia::render('Orders/Show', ['order' => $order])
323
+ const inertiaMatch = actionBody.match(/Inertia::render\s*\(\s*['"]([^'"]+)['"](?:\s*,\s*\[([^\]]*)\])?\s*\)/);
324
+ if (inertiaMatch) {
325
+ const pageComponent = inertiaMatch[1]; // e.g. "Orders/Show"
326
+ const rawProps = inertiaMatch[2] || "";
327
+
328
+ // Extract prop keys: 'order' => ...
329
+ const propMatches = rawProps.matchAll(/['"]([A-Za-z0-9_]+)['"]\s*=>/g);
330
+ const props: string[] = [];
331
+ for (const pm of propMatches) {
332
+ props.push(pm[1]);
333
+ }
334
+
335
+ // Check for Vue, TSX, JSX files in resources/js/Pages
336
+ const possibleExtensions = [".vue", ".tsx", ".jsx", ".svelte"];
337
+ let frontendPath = `resources/js/Pages/${pageComponent}`;
338
+ for (const ext of possibleExtensions) {
339
+ const fullCandidate = path.join(this.projectRoot, `resources/js/Pages/${pageComponent}${ext}`);
340
+ if (fs.existsSync(fullCandidate)) {
341
+ frontendPath = `resources/js/Pages/${pageComponent}${ext}`;
342
+ break;
343
+ }
344
+ }
345
+ if (!frontendPath.includes(".")) {
346
+ frontendPath += ".vue"; // Default inertia convention
347
+ }
348
+
349
+ return {
350
+ target: frontendPath,
351
+ props,
352
+ };
353
+ }
354
+
355
+ // 2. view('orders.show', compact('order'))
356
+ const viewMatch = actionBody.match(/view\s*\(\s*['"]([^'"]+)['"]/);
357
+ if (viewMatch) {
358
+ const viewPath = viewMatch[1].replace(/\./g, "/");
359
+ return {
360
+ target: `resources/views/${viewPath}.blade.php`,
361
+ props: [],
362
+ };
363
+ }
364
+
365
+ return null;
366
+ }
367
+
368
+ private findClassFile(subDir: "Requests" | "Models", className: string): string | null {
369
+ const directPath = path.join(this.projectRoot, `app/${subDir}/${className}.php`);
370
+ if (fs.existsSync(directPath)) return directPath;
371
+
372
+ // Direct app/ fallback for older Laravel
373
+ const flatPath = path.join(this.projectRoot, `app/${className}.php`);
374
+ if (fs.existsSync(flatPath)) return flatPath;
375
+
376
+ return null;
377
+ }
378
+
379
+ private extractValidationRules(requestCode: string): Record<string, string> {
380
+ const rules: Record<string, string> = {};
381
+ const rulesMatch = requestCode.match(/function\s+rules\s*\(\s*\)[^{]*\{([^}]*)\}/i);
382
+ if (!rulesMatch) return rules;
383
+
384
+ const body = rulesMatch[1];
385
+ // Match 'status' => 'in:pending,paid' or 'status' => ['required', 'string']
386
+ const rulePairRegex = /['"]([A-Za-z0-9_]+)['"]\s*=>\s*(?:['"]([^'"]+)['"]|\[([^\]]+)\])/g;
387
+ let rm;
388
+ while ((rm = rulePairRegex.exec(body)) !== null) {
389
+ const field = rm[1];
390
+ const singleRule = rm[2];
391
+ const arrayRules = rm[3];
392
+ if (singleRule) {
393
+ rules[field] = singleRule;
394
+ } else if (arrayRules) {
395
+ rules[field] = arrayRules.replace(/['"\s]/g, "").replace(/,/g, "|");
396
+ }
397
+ }
398
+
399
+ return rules;
400
+ }
401
+
402
+ private extractModelMetadata(modelCode: string): { fillable: string[]; casts: Record<string, string> } {
403
+ const fillable: string[] = [];
404
+ const casts: Record<string, string> = {};
405
+
406
+ // Match protected $fillable = [...]
407
+ const fillableMatch = modelCode.match(/\$fillable\s*=\s*\[([^\]]*)\]/);
408
+ if (fillableMatch) {
409
+ const items = fillableMatch[1].matchAll(/['"]([A-Za-z0-9_]+)['"]/g);
410
+ for (const it of items) {
411
+ fillable.push(it[1]);
412
+ }
413
+ }
414
+
415
+ // Match protected $casts = [...]
416
+ const castsMatch = modelCode.match(/\$casts\s*=\s*\[([^\]]*)\]/);
417
+ if (castsMatch) {
418
+ const castItems = castsMatch[1].matchAll(/['"]([A-Za-z0-9_]+)['"]\s*=>\s*(?:['"]([^'"]+)['"]|([A-Za-z0-9_]+)::class)/g);
419
+ for (const ci of castItems) {
420
+ casts[ci[1]] = ci[2] || ci[3];
421
+ }
422
+ }
423
+
424
+ return { fillable, casts };
425
+ }
426
+ }