septum 0.1.4 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +105 -105
- package/bin/septum.ts +4 -4
- package/package.json +63 -63
- package/src/cli/commands/check.ts +161 -161
- package/src/cli/commands/hook.ts +188 -188
- package/src/cli/commands/ingest.ts +29 -29
- package/src/cli/commands/init.ts +211 -211
- package/src/cli/commands/locate.ts +61 -61
- package/src/cli/commands/query.ts +64 -64
- package/src/cli/commands/serve.ts +5 -5
- package/src/cli/commands/slice.ts +48 -48
- package/src/cli/commands/sync.ts +31 -31
- package/src/cli/index.ts +210 -210
- package/src/cli/templates/hooks.ts +75 -75
- package/src/cli/templates/rules.ts +38 -38
- package/src/cli/templates/skill.ts +69 -69
- package/src/core/boundary/evaluator.ts +232 -210
- package/src/core/config/loader.ts +97 -97
- package/src/core/config/schema.ts +39 -39
- package/src/core/database/client.ts +44 -44
- package/src/core/database/repositories/dependency.repository.ts +366 -349
- package/src/core/database/repositories/domain.repository.ts +218 -198
- package/src/core/database/repositories/file.repository.ts +116 -106
- package/src/core/database/repositories/meta.repository.ts +33 -33
- package/src/core/database/repositories/symbol.repository.ts +443 -443
- package/src/core/database/repository.ts +264 -260
- package/src/core/database/schema.ts +153 -153
- package/src/core/discovery/topology-detector.ts +486 -486
- package/src/core/ingestion/hasher.ts +5 -5
- package/src/core/ingestion/pipeline.ts +584 -535
- package/src/core/parser/boundary-tracker.ts +288 -288
- package/src/core/parser/extractors/base.ts +62 -62
- package/src/core/parser/extractors/blade.ts +158 -158
- package/src/core/parser/extractors/frontend.ts +194 -194
- package/src/core/parser/extractors/go.ts +229 -180
- package/src/core/parser/extractors/laravel-semantic.ts +1039 -901
- package/src/core/parser/extractors/mcp-cli-semantic.ts +199 -199
- package/src/core/parser/extractors/nestjs-semantic.ts +119 -119
- package/src/core/parser/extractors/php.ts +153 -153
- package/src/core/parser/extractors/python.ts +228 -228
- package/src/core/parser/extractors/registry.ts +70 -70
- package/src/core/parser/extractors/semantic-extractor.interface.ts +21 -21
- package/src/core/parser/extractors/typescript.ts +181 -181
- package/src/core/parser/tree-sitter.ts +41 -41
- package/src/core/resolver/call-graph-tracer.ts +666 -668
- package/src/core/resolver/module-resolver.ts +408 -342
- package/src/core/resolver/path-resolver.ts +147 -147
- package/src/core/resolver/symbol-locator.ts +543 -543
- package/src/core/resolver/vertical-slice-tracer.ts +301 -285
- package/src/core/session/session-manager.ts +56 -56
- package/src/core/telemetry/telemetry.ts +86 -86
- package/src/index.ts +21 -21
- package/src/mcp/schemas.ts +578 -286
- package/src/mcp/server.ts +875 -716
- package/src/mcp/tools/check-boundary.ts +171 -152
- package/src/mcp/tools/get-domain-catalog.ts +145 -121
- package/src/mcp/tools/get-feature-context.ts +212 -116
- package/src/mcp/tools/get-symbol-hotspots.ts +60 -59
- package/src/mcp/tools/get-symbol-impact.ts +28 -26
- package/src/mcp/tools/get-symbol.ts +65 -63
- package/src/mcp/tools/locate-symbol.ts +171 -190
- package/src/mcp/tools/register-domain.ts +68 -64
- package/src/mcp/tools/register-feature.ts +100 -0
- package/src/mcp/tools/trace-vertical-slice.ts +74 -48
- package/src/types/index.ts +335 -315
- package/src/version.ts +3 -3
|
@@ -1,901 +1,1039 @@
|
|
|
1
|
-
import * as fs from "node:fs";
|
|
2
|
-
import * as path from "node:path";
|
|
3
|
-
import type { ExecutionChainNode, VerticalSliceRecord } from "../../../types/index.ts";
|
|
4
|
-
import { BehavioralAnalyzer } from "../behavioral-analyzer.ts";
|
|
5
|
-
import { SchemaProviderRegistry } from "../schema/schema-provider-registry.ts";
|
|
6
|
-
import type {
|
|
7
|
-
SemanticSliceExtractor,
|
|
8
|
-
VerticalSliceCandidate,
|
|
9
|
-
} from "./semantic-extractor.interface.ts";
|
|
10
|
-
|
|
11
|
-
export interface ParsedRoute {
|
|
12
|
-
httpMethod: string;
|
|
13
|
-
uri: string;
|
|
14
|
-
routeName?: string;
|
|
15
|
-
controllerClass: string;
|
|
16
|
-
actionName: string;
|
|
17
|
-
file?: string;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export type LaravelSemanticExtractionResult = VerticalSliceCandidate[] & {
|
|
21
|
-
slices: VerticalSliceCandidate[];
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
export class LaravelSemanticExtractor implements SemanticSliceExtractor {
|
|
25
|
-
public readonly id = "laravel";
|
|
26
|
-
public readonly name = "Laravel MVC Semantic Extractor";
|
|
27
|
-
|
|
28
|
-
constructor(private projectRoot: string = process.cwd()) {}
|
|
29
|
-
|
|
30
|
-
public canHandle(projectRoot: string, framework?: string): boolean {
|
|
31
|
-
if (framework === "laravel") return true;
|
|
32
|
-
return (
|
|
33
|
-
fs.existsSync(path.join(projectRoot, "artisan")) ||
|
|
34
|
-
fs.existsSync(path.join(projectRoot, "routes/web.php")) ||
|
|
35
|
-
fs.existsSync(path.join(projectRoot, "routes/api.php"))
|
|
36
|
-
);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Scan route files and extract complete vertical slices.
|
|
41
|
-
*/
|
|
42
|
-
public extractSlices(
|
|
43
|
-
projectRootOrDomainId?: string | number,
|
|
44
|
-
maybeDomainId?: number
|
|
45
|
-
): LaravelSemanticExtractionResult {
|
|
46
|
-
let root = this.projectRoot;
|
|
47
|
-
let domainId: number | undefined;
|
|
48
|
-
|
|
49
|
-
if (typeof projectRootOrDomainId === "string") {
|
|
50
|
-
root = projectRootOrDomainId;
|
|
51
|
-
domainId = maybeDomainId;
|
|
52
|
-
} else if (typeof projectRootOrDomainId === "number") {
|
|
53
|
-
domainId = projectRootOrDomainId;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const routeFiles = [
|
|
57
|
-
path.join(root, "routes/web.php"),
|
|
58
|
-
path.join(root, "routes/api.php"),
|
|
59
|
-
].filter((f) => fs.existsSync(f));
|
|
60
|
-
|
|
61
|
-
const parsedRoutes: ParsedRoute[] = [];
|
|
62
|
-
|
|
63
|
-
for (const routeFile of routeFiles) {
|
|
64
|
-
const content = fs.readFileSync(routeFile, "utf-8");
|
|
65
|
-
parsedRoutes.push(...this.parseRoutesContent(content));
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const slices: Array<Omit<VerticalSliceRecord, "id" | "created_at">> = [];
|
|
69
|
-
|
|
70
|
-
for (const route of parsedRoutes) {
|
|
71
|
-
const controllerInfo = this.resolveController(route.controllerClass, route.actionName);
|
|
72
|
-
|
|
73
|
-
let formRequestInfo: {
|
|
74
|
-
className: string;
|
|
75
|
-
filePath?: string;
|
|
76
|
-
rules: Record<string, string>;
|
|
77
|
-
} | null = null;
|
|
78
|
-
|
|
79
|
-
let modelInfo: {
|
|
80
|
-
className: string;
|
|
81
|
-
filePath?: string;
|
|
82
|
-
fillable: string[];
|
|
83
|
-
casts: Record<string, string>;
|
|
84
|
-
} | null = null;
|
|
85
|
-
|
|
86
|
-
let frontendInfo: {
|
|
87
|
-
target: string;
|
|
88
|
-
props: string[];
|
|
89
|
-
} | null = null;
|
|
90
|
-
|
|
91
|
-
let
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
)
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
:
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
});
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
if (
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
return
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
private
|
|
497
|
-
const
|
|
498
|
-
const
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
const
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
const
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
const
|
|
674
|
-
const
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
const
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
}
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
return
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
private
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
):
|
|
741
|
-
const
|
|
742
|
-
if (
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
const
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
}
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { ExecutionChainNode, VerticalSliceRecord } from "../../../types/index.ts";
|
|
4
|
+
import { BehavioralAnalyzer } from "../behavioral-analyzer.ts";
|
|
5
|
+
import { SchemaProviderRegistry } from "../schema/schema-provider-registry.ts";
|
|
6
|
+
import type {
|
|
7
|
+
SemanticSliceExtractor,
|
|
8
|
+
VerticalSliceCandidate,
|
|
9
|
+
} from "./semantic-extractor.interface.ts";
|
|
10
|
+
|
|
11
|
+
export interface ParsedRoute {
|
|
12
|
+
httpMethod: string;
|
|
13
|
+
uri: string;
|
|
14
|
+
routeName?: string;
|
|
15
|
+
controllerClass: string;
|
|
16
|
+
actionName: string;
|
|
17
|
+
file?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type LaravelSemanticExtractionResult = VerticalSliceCandidate[] & {
|
|
21
|
+
slices: VerticalSliceCandidate[];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export class LaravelSemanticExtractor implements SemanticSliceExtractor {
|
|
25
|
+
public readonly id = "laravel";
|
|
26
|
+
public readonly name = "Laravel MVC Semantic Extractor";
|
|
27
|
+
|
|
28
|
+
constructor(private projectRoot: string = process.cwd()) {}
|
|
29
|
+
|
|
30
|
+
public canHandle(projectRoot: string, framework?: string): boolean {
|
|
31
|
+
if (framework === "laravel") return true;
|
|
32
|
+
return (
|
|
33
|
+
fs.existsSync(path.join(projectRoot, "artisan")) ||
|
|
34
|
+
fs.existsSync(path.join(projectRoot, "routes/web.php")) ||
|
|
35
|
+
fs.existsSync(path.join(projectRoot, "routes/api.php"))
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Scan route files and extract complete vertical slices.
|
|
41
|
+
*/
|
|
42
|
+
public extractSlices(
|
|
43
|
+
projectRootOrDomainId?: string | number,
|
|
44
|
+
maybeDomainId?: number
|
|
45
|
+
): LaravelSemanticExtractionResult {
|
|
46
|
+
let root = this.projectRoot;
|
|
47
|
+
let domainId: number | undefined;
|
|
48
|
+
|
|
49
|
+
if (typeof projectRootOrDomainId === "string") {
|
|
50
|
+
root = projectRootOrDomainId;
|
|
51
|
+
domainId = maybeDomainId;
|
|
52
|
+
} else if (typeof projectRootOrDomainId === "number") {
|
|
53
|
+
domainId = projectRootOrDomainId;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const routeFiles = [
|
|
57
|
+
path.join(root, "routes/web.php"),
|
|
58
|
+
path.join(root, "routes/api.php"),
|
|
59
|
+
].filter((f) => fs.existsSync(f));
|
|
60
|
+
|
|
61
|
+
const parsedRoutes: ParsedRoute[] = [];
|
|
62
|
+
|
|
63
|
+
for (const routeFile of routeFiles) {
|
|
64
|
+
const content = fs.readFileSync(routeFile, "utf-8");
|
|
65
|
+
parsedRoutes.push(...this.parseRoutesContent(content));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const slices: Array<Omit<VerticalSliceRecord, "id" | "created_at">> = [];
|
|
69
|
+
|
|
70
|
+
for (const route of parsedRoutes) {
|
|
71
|
+
const controllerInfo = this.resolveController(route.controllerClass, route.actionName);
|
|
72
|
+
|
|
73
|
+
let formRequestInfo: {
|
|
74
|
+
className: string;
|
|
75
|
+
filePath?: string;
|
|
76
|
+
rules: Record<string, string>;
|
|
77
|
+
} | null = null;
|
|
78
|
+
|
|
79
|
+
let modelInfo: {
|
|
80
|
+
className: string;
|
|
81
|
+
filePath?: string;
|
|
82
|
+
fillable: string[];
|
|
83
|
+
casts: Record<string, string>;
|
|
84
|
+
} | null = null;
|
|
85
|
+
|
|
86
|
+
let frontendInfo: {
|
|
87
|
+
target: string;
|
|
88
|
+
props: string[];
|
|
89
|
+
} | null = null;
|
|
90
|
+
|
|
91
|
+
let actionServices: Array<{
|
|
92
|
+
className: string;
|
|
93
|
+
filePath?: string;
|
|
94
|
+
isService: boolean;
|
|
95
|
+
}> = [];
|
|
96
|
+
|
|
97
|
+
let behavioralInfo: import("../behavioral-analyzer.ts").MethodBehavioralSummary | null = null;
|
|
98
|
+
if (controllerInfo.filePath && fs.existsSync(controllerInfo.filePath)) {
|
|
99
|
+
const controllerCode = fs.readFileSync(controllerInfo.filePath, "utf-8");
|
|
100
|
+
const actionBody = this.extractActionBody(controllerCode, route.actionName);
|
|
101
|
+
behavioralInfo = BehavioralAnalyzer.analyzeMethod(actionBody);
|
|
102
|
+
|
|
103
|
+
// 1. Detect FormRequest in method signature
|
|
104
|
+
const requestClassName = this.detectFormRequestClass(controllerCode, route.actionName);
|
|
105
|
+
if (requestClassName) {
|
|
106
|
+
const reqFile = this.findClassFile("Requests", requestClassName);
|
|
107
|
+
const rules = reqFile && fs.existsSync(reqFile) ? this.extractValidationRules(fs.readFileSync(reqFile, "utf-8")) : {};
|
|
108
|
+
formRequestInfo = {
|
|
109
|
+
className: requestClassName,
|
|
110
|
+
filePath: reqFile ? path.relative(this.projectRoot, reqFile) : undefined,
|
|
111
|
+
rules,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 2. Detect Model referenced in parameters or action body
|
|
116
|
+
const modelClassName = this.detectModelClass(controllerCode, route.actionName, actionBody);
|
|
117
|
+
if (modelClassName) {
|
|
118
|
+
const modFile = this.findClassFile("Models", modelClassName);
|
|
119
|
+
const modMetadata = modFile && fs.existsSync(modFile) ? this.extractModelMetadata(fs.readFileSync(modFile, "utf-8")) : { fillable: [], casts: {} };
|
|
120
|
+
modelInfo = {
|
|
121
|
+
className: modelClassName,
|
|
122
|
+
filePath: modFile ? path.relative(this.projectRoot, modFile) : undefined,
|
|
123
|
+
fillable: modMetadata.fillable,
|
|
124
|
+
casts: modMetadata.casts,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 3. Detect Inertia Render / View Target
|
|
129
|
+
frontendInfo = this.detectFrontendTarget(actionBody);
|
|
130
|
+
|
|
131
|
+
// 4. Detect Action & Service invocations
|
|
132
|
+
actionServices = this.detectActionAndServiceInvocations(
|
|
133
|
+
controllerCode,
|
|
134
|
+
route.actionName,
|
|
135
|
+
actionBody
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const chain: ExecutionChainNode[] = [
|
|
140
|
+
{
|
|
141
|
+
stage: "ingress",
|
|
142
|
+
symbol: `${route.httpMethod.toUpperCase()} ${route.uri.startsWith("/") ? route.uri : `/${route.uri}`}`,
|
|
143
|
+
file: route.file,
|
|
144
|
+
description: `HTTP Route: ${route.routeName || route.uri}`,
|
|
145
|
+
},
|
|
146
|
+
];
|
|
147
|
+
|
|
148
|
+
if (behavioralInfo && behavioralInfo.guards.length > 0) {
|
|
149
|
+
chain.push({
|
|
150
|
+
stage: "guard",
|
|
151
|
+
symbol: behavioralInfo.guards.join(", "),
|
|
152
|
+
description: "Authorization / Security Guard",
|
|
153
|
+
guards: behavioralInfo.guards,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (formRequestInfo) {
|
|
158
|
+
chain.push({
|
|
159
|
+
stage: "validation",
|
|
160
|
+
symbol: formRequestInfo.className,
|
|
161
|
+
file: formRequestInfo.filePath,
|
|
162
|
+
description: "Form Request Validation",
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
chain.push({
|
|
167
|
+
stage: "controller",
|
|
168
|
+
symbol: `${route.controllerClass}::${route.actionName}`,
|
|
169
|
+
file: controllerInfo.filePath ? path.relative(this.projectRoot, controllerInfo.filePath) : undefined,
|
|
170
|
+
line: controllerInfo.line,
|
|
171
|
+
description: `Controller Action`,
|
|
172
|
+
guards: behavioralInfo && behavioralInfo.guards.length > 0 ? behavioralInfo.guards : undefined,
|
|
173
|
+
mutations: behavioralInfo && behavioralInfo.mutations.length > 0 ? behavioralInfo.mutations : undefined,
|
|
174
|
+
transactions: behavioralInfo?.hasTransaction,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
if (actionServices && actionServices.length > 0) {
|
|
178
|
+
for (const asItem of actionServices) {
|
|
179
|
+
chain.push({
|
|
180
|
+
stage: asItem.isService ? "service" : "action",
|
|
181
|
+
symbol: asItem.className,
|
|
182
|
+
file: asItem.filePath,
|
|
183
|
+
description: asItem.isService ? "Domain Service Invocation" : "Action Invocation",
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (behavioralInfo && behavioralInfo.hasTransaction) {
|
|
189
|
+
chain.push({
|
|
190
|
+
stage: "transaction",
|
|
191
|
+
symbol: "DB::transaction",
|
|
192
|
+
description: "Database Transaction Scope",
|
|
193
|
+
transactions: true,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (modelInfo) {
|
|
198
|
+
const schema = SchemaProviderRegistry.resolveSchema(
|
|
199
|
+
root,
|
|
200
|
+
modelInfo.className,
|
|
201
|
+
modelInfo.filePath
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const columnsSummary =
|
|
205
|
+
schema && schema.fields.length > 0
|
|
206
|
+
? schema.fields.map((f) => `${f.name}:${f.type}${f.nullable ? "?" : ""}`).join(", ")
|
|
207
|
+
: undefined;
|
|
208
|
+
|
|
209
|
+
chain.push({
|
|
210
|
+
stage: "entity",
|
|
211
|
+
symbol: modelInfo.className,
|
|
212
|
+
file: modelInfo.filePath,
|
|
213
|
+
description: schema?.tableName ? `Eloquent Model (${schema.tableName})` : "Eloquent Model",
|
|
214
|
+
table: schema?.tableName,
|
|
215
|
+
schema_file: schema?.schemaSourceFile,
|
|
216
|
+
columns_summary: columnsSummary,
|
|
217
|
+
mutations: behavioralInfo && behavioralInfo.mutations.length > 0 ? behavioralInfo.mutations : undefined,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (behavioralInfo && behavioralInfo.dispatchedJobs.length > 0) {
|
|
222
|
+
for (const job of behavioralInfo.dispatchedJobs) {
|
|
223
|
+
const jobFile = this.findClassFile("Jobs", job);
|
|
224
|
+
chain.push({
|
|
225
|
+
stage: "job",
|
|
226
|
+
symbol: job,
|
|
227
|
+
file: jobFile ? path.relative(this.projectRoot, jobFile) : undefined,
|
|
228
|
+
description: "Background Queue Job Dispatched",
|
|
229
|
+
dispatches: [job],
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (behavioralInfo && behavioralInfo.emittedEvents.length > 0) {
|
|
235
|
+
for (const evt of behavioralInfo.emittedEvents) {
|
|
236
|
+
const evtFile = this.findClassFile("Events", evt);
|
|
237
|
+
chain.push({
|
|
238
|
+
stage: "event",
|
|
239
|
+
symbol: evt,
|
|
240
|
+
file: evtFile ? path.relative(this.projectRoot, evtFile) : undefined,
|
|
241
|
+
description: "Domain Event Emitted",
|
|
242
|
+
emits: [evt],
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (frontendInfo) {
|
|
248
|
+
chain.push({
|
|
249
|
+
stage: "egress",
|
|
250
|
+
symbol: frontendInfo.target,
|
|
251
|
+
description: "Inertia / Frontend Target",
|
|
252
|
+
payload_props: frontendInfo.props.length > 0 ? frontendInfo.props : undefined,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
slices.push({
|
|
257
|
+
domain_id: domainId ?? null,
|
|
258
|
+
feature_key: route.routeName || null,
|
|
259
|
+
http_method: route.httpMethod.toUpperCase(),
|
|
260
|
+
route_uri: route.uri.startsWith("/") ? route.uri : `/${route.uri}`,
|
|
261
|
+
route_name: route.routeName || null,
|
|
262
|
+
controller_class: route.controllerClass,
|
|
263
|
+
action_name: route.actionName,
|
|
264
|
+
controller_file: controllerInfo.filePath ? path.relative(this.projectRoot, controllerInfo.filePath) : null,
|
|
265
|
+
controller_line: controllerInfo.line,
|
|
266
|
+
architecture_style: "mvc",
|
|
267
|
+
entry_kind: "http_route",
|
|
268
|
+
execution_chain_json: JSON.stringify(chain),
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Also discover Omni-Trigger slices (Jobs, Console Commands, Event Listeners)
|
|
273
|
+
slices.push(...this.extractJobSlices(root, domainId));
|
|
274
|
+
slices.push(...this.extractCommandSlices(root, domainId));
|
|
275
|
+
slices.push(...this.extractListenerSlices(root, domainId));
|
|
276
|
+
|
|
277
|
+
return Object.assign(slices, { slices });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Parse Laravel Route declarations.
|
|
282
|
+
*/
|
|
283
|
+
public parseRoutesContent(content: string): ParsedRoute[] {
|
|
284
|
+
const routes: ParsedRoute[] = [];
|
|
285
|
+
|
|
286
|
+
// 1. Process Route::resource
|
|
287
|
+
const resourceRegex = /Route::resource\s*\(\s*['"]([^'"]+)['"]\s*,\s*([\\A-Za-z0-9_]+)::class\s*\)/gi;
|
|
288
|
+
let resMatch;
|
|
289
|
+
while ((resMatch = resourceRegex.exec(content)) !== null) {
|
|
290
|
+
const baseUri = resMatch[1].replace(/^\//, "");
|
|
291
|
+
const controllerClass = resMatch[2];
|
|
292
|
+
const resourceActions = [
|
|
293
|
+
{ method: "GET", sub: "", action: "index" },
|
|
294
|
+
{ method: "GET", sub: "/create", action: "create" },
|
|
295
|
+
{ method: "POST", sub: "", action: "store" },
|
|
296
|
+
{ method: "GET", sub: "/{id}", action: "show" },
|
|
297
|
+
{ method: "GET", sub: "/{id}/edit", action: "edit" },
|
|
298
|
+
{ method: "PUT", sub: "/{id}", action: "update" },
|
|
299
|
+
{ method: "DELETE", sub: "/{id}", action: "destroy" },
|
|
300
|
+
];
|
|
301
|
+
|
|
302
|
+
for (const res of resourceActions) {
|
|
303
|
+
routes.push({
|
|
304
|
+
httpMethod: res.method,
|
|
305
|
+
uri: `/${baseUri}${res.sub}`,
|
|
306
|
+
routeName: `${baseUri}.${res.action}`,
|
|
307
|
+
controllerClass,
|
|
308
|
+
actionName: res.action,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// 2. Parse routes with group context tracking (prefix, controller, name prefix)
|
|
314
|
+
const lines = content.split(/\r?\n/);
|
|
315
|
+
interface GroupContext {
|
|
316
|
+
prefix: string;
|
|
317
|
+
controller: string;
|
|
318
|
+
namePrefix: string;
|
|
319
|
+
braceDepth: number;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const groupStack: GroupContext[] = [];
|
|
323
|
+
let currentBraceDepth = 0;
|
|
324
|
+
let statementBuffer = "";
|
|
325
|
+
let inStatement = false;
|
|
326
|
+
|
|
327
|
+
for (let i = 0; i < lines.length; i++) {
|
|
328
|
+
const line = lines[i];
|
|
329
|
+
const trimmed = line.trim();
|
|
330
|
+
|
|
331
|
+
// Check group opens: Route::...group(
|
|
332
|
+
if (trimmed.includes("Route::") && trimmed.includes("group(") && trimmed.includes("function")) {
|
|
333
|
+
const prefixMatch = /prefix\s*\(\s*['"]([^'"]+)['"]\s*\)/.exec(trimmed);
|
|
334
|
+
const controllerMatch = /controller\s*\(\s*([\\A-Za-z0-9_]+)::class\s*\)/.exec(trimmed);
|
|
335
|
+
const nameMatch = /name\s*\(\s*['"]([^'"]+)['"]\s*\)/.exec(trimmed);
|
|
336
|
+
|
|
337
|
+
const currentCtx = groupStack[groupStack.length - 1];
|
|
338
|
+
const newPrefix = [currentCtx?.prefix, prefixMatch ? prefixMatch[1] : ""]
|
|
339
|
+
.filter(Boolean)
|
|
340
|
+
.join("/");
|
|
341
|
+
const newController = controllerMatch ? controllerMatch[1] : (currentCtx?.controller || "");
|
|
342
|
+
const newNamePrefix = [currentCtx?.namePrefix, nameMatch ? nameMatch[1] : ""]
|
|
343
|
+
.filter(Boolean)
|
|
344
|
+
.join("");
|
|
345
|
+
|
|
346
|
+
groupStack.push({
|
|
347
|
+
prefix: newPrefix,
|
|
348
|
+
controller: newController,
|
|
349
|
+
namePrefix: newNamePrefix,
|
|
350
|
+
braceDepth: currentBraceDepth,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Track brace depth
|
|
355
|
+
for (const char of line) {
|
|
356
|
+
if (char === "{") currentBraceDepth++;
|
|
357
|
+
if (char === "}") {
|
|
358
|
+
currentBraceDepth--;
|
|
359
|
+
if (groupStack.length > 0) {
|
|
360
|
+
const top = groupStack[groupStack.length - 1];
|
|
361
|
+
if (currentBraceDepth <= top.braceDepth) {
|
|
362
|
+
groupStack.pop();
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Route statement accumulation across lines
|
|
369
|
+
if (!inStatement && trimmed.startsWith("Route::") && !trimmed.includes("Route::resource") && !trimmed.includes("Route::group")) {
|
|
370
|
+
statementBuffer = trimmed;
|
|
371
|
+
inStatement = true;
|
|
372
|
+
} else if (inStatement) {
|
|
373
|
+
statementBuffer += " " + trimmed;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (inStatement && statementBuffer.includes(";")) {
|
|
377
|
+
inStatement = false;
|
|
378
|
+
const stmt = statementBuffer;
|
|
379
|
+
statementBuffer = "";
|
|
380
|
+
|
|
381
|
+
const currentCtx = groupStack[groupStack.length - 1];
|
|
382
|
+
const parsed = this.parseSingleRouteStatement(stmt, currentCtx);
|
|
383
|
+
if (parsed) {
|
|
384
|
+
routes.push(parsed);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return routes;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
private parseSingleRouteStatement(
|
|
393
|
+
stmt: string,
|
|
394
|
+
ctx?: { prefix: string; controller: string; namePrefix: string }
|
|
395
|
+
): ParsedRoute | null {
|
|
396
|
+
const methodMatch = /Route::(get|post|put|patch|delete|any|options)\s*\(\s*['"]([^'"]+)['"]([\s\S]*)/i.exec(stmt);
|
|
397
|
+
if (!methodMatch) {
|
|
398
|
+
const viewMatch = /Route::view\s*\(\s*['"]([^'"]+)['"]\s*,\s*['"]([^'"]+)['"]/i.exec(stmt);
|
|
399
|
+
if (viewMatch) {
|
|
400
|
+
const rawUri = viewMatch[1];
|
|
401
|
+
const viewTarget = viewMatch[2];
|
|
402
|
+
const nameMatch = /->name\s*\(\s*['"]([^'"]+)['"]\s*\)/i.exec(stmt);
|
|
403
|
+
const fullUri = ctx?.prefix ? `/${ctx.prefix.replace(/^\/|\/$/g, "")}/${rawUri.replace(/^\//, "")}` : rawUri;
|
|
404
|
+
return {
|
|
405
|
+
httpMethod: "GET",
|
|
406
|
+
uri: fullUri.startsWith("/") ? fullUri : `/${fullUri}`,
|
|
407
|
+
routeName: (ctx?.namePrefix || "") + (nameMatch ? nameMatch[1] : ""),
|
|
408
|
+
controllerClass: "View",
|
|
409
|
+
actionName: viewTarget,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const httpMethod = methodMatch[1].toUpperCase();
|
|
416
|
+
let uri = methodMatch[2];
|
|
417
|
+
const rest = methodMatch[3];
|
|
418
|
+
|
|
419
|
+
if (ctx?.prefix) {
|
|
420
|
+
const cleanPrefix = ctx.prefix.replace(/^\/|\/$/g, "");
|
|
421
|
+
const cleanUri = uri.replace(/^\//, "");
|
|
422
|
+
uri = cleanUri ? `/${cleanPrefix}/${cleanUri}` : `/${cleanPrefix}`;
|
|
423
|
+
}
|
|
424
|
+
if (!uri.startsWith("/")) uri = `/${uri}`;
|
|
425
|
+
|
|
426
|
+
const nameMatch = /->name\s*\(\s*['"]([^'"]+)['"]\s*\)/i.exec(stmt);
|
|
427
|
+
const rawRouteName = nameMatch ? nameMatch[1] : undefined;
|
|
428
|
+
const fullRouteName = rawRouteName
|
|
429
|
+
? (ctx?.namePrefix || "") + rawRouteName
|
|
430
|
+
: undefined;
|
|
431
|
+
|
|
432
|
+
// [Controller::class, 'action']
|
|
433
|
+
const arrayMatch = /\[\s*([\\A-Za-z0-9_]+)::class\s*,\s*['"]([A-Za-z0-9_]+)['"]\s*\]/.exec(rest);
|
|
434
|
+
if (arrayMatch) {
|
|
435
|
+
return {
|
|
436
|
+
httpMethod,
|
|
437
|
+
uri,
|
|
438
|
+
routeName: fullRouteName,
|
|
439
|
+
controllerClass: arrayMatch[1],
|
|
440
|
+
actionName: arrayMatch[2],
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// 'Controller@action'
|
|
445
|
+
const stringActionMatch = /['"]([\\A-Za-z0-9_]+)@([A-Za-z0-9_]+)['"]/.exec(rest);
|
|
446
|
+
if (stringActionMatch) {
|
|
447
|
+
return {
|
|
448
|
+
httpMethod,
|
|
449
|
+
uri,
|
|
450
|
+
routeName: fullRouteName,
|
|
451
|
+
controllerClass: stringActionMatch[1],
|
|
452
|
+
actionName: stringActionMatch[2],
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Action inside controller group
|
|
457
|
+
if (ctx?.controller) {
|
|
458
|
+
const singleActionMatch = /,\s*['"]([A-Za-z0-9_]+)['"]/.exec(rest);
|
|
459
|
+
if (singleActionMatch) {
|
|
460
|
+
return {
|
|
461
|
+
httpMethod,
|
|
462
|
+
uri,
|
|
463
|
+
routeName: fullRouteName,
|
|
464
|
+
controllerClass: ctx.controller,
|
|
465
|
+
actionName: singleActionMatch[1],
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Invokable controller
|
|
471
|
+
const invokableMatch = /,\s*([\\A-Za-z0-9_]+)::class/.exec(rest);
|
|
472
|
+
if (invokableMatch) {
|
|
473
|
+
return {
|
|
474
|
+
httpMethod,
|
|
475
|
+
uri,
|
|
476
|
+
routeName: fullRouteName,
|
|
477
|
+
controllerClass: invokableMatch[1],
|
|
478
|
+
actionName: "__invoke",
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Closure
|
|
483
|
+
if (rest.includes("function")) {
|
|
484
|
+
return {
|
|
485
|
+
httpMethod,
|
|
486
|
+
uri,
|
|
487
|
+
routeName: fullRouteName,
|
|
488
|
+
controllerClass: "Closure",
|
|
489
|
+
actionName: "handle",
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
private resolveController(controllerClass: string, actionName: string): { filePath?: string; line: number } {
|
|
497
|
+
const simpleName = controllerClass.split(/\\|\//).pop() || controllerClass;
|
|
498
|
+
const candidates = [
|
|
499
|
+
path.join(this.projectRoot, `app/Http/Controllers/${simpleName}.php`),
|
|
500
|
+
path.join(this.projectRoot, `app/Http/Controllers/Api/${simpleName}.php`),
|
|
501
|
+
path.join(this.projectRoot, `app/Http/Controllers/Admin/${simpleName}.php`),
|
|
502
|
+
];
|
|
503
|
+
|
|
504
|
+
for (const c of candidates) {
|
|
505
|
+
if (fs.existsSync(c)) {
|
|
506
|
+
const lines = fs.readFileSync(c, "utf-8").split("\n");
|
|
507
|
+
let targetLine = 1;
|
|
508
|
+
for (let i = 0; i < lines.length; i++) {
|
|
509
|
+
if (new RegExp(`function\\s+${actionName}\\s*\\(`, "i").test(lines[i])) {
|
|
510
|
+
targetLine = i + 1;
|
|
511
|
+
break;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return { filePath: c, line: targetLine };
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
return { line: 0 };
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
private extractActionBody(controllerCode: string, actionName: string): string {
|
|
522
|
+
const fnRegex = new RegExp(`public\\s+function\\s+${actionName}\\s*\\([^{]*\\)\\s*\\{`, "i");
|
|
523
|
+
const match = fnRegex.exec(controllerCode);
|
|
524
|
+
if (!match) return "";
|
|
525
|
+
|
|
526
|
+
const startIndex = match.index + match[0].length;
|
|
527
|
+
let braceDepth = 1;
|
|
528
|
+
let endIndex = startIndex;
|
|
529
|
+
|
|
530
|
+
while (braceDepth > 0 && endIndex < controllerCode.length) {
|
|
531
|
+
const char = controllerCode[endIndex];
|
|
532
|
+
if (char === "{") braceDepth++;
|
|
533
|
+
else if (char === "}") braceDepth--;
|
|
534
|
+
endIndex++;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
return controllerCode.substring(startIndex, endIndex - 1);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
private detectFormRequestClass(controllerCode: string, actionName: string): string | null {
|
|
541
|
+
const paramRegex = new RegExp(`public\\s+function\\s+${actionName}\\s*\\(([^)]*)\\)`, "i");
|
|
542
|
+
const match = paramRegex.exec(controllerCode);
|
|
543
|
+
if (!match) return null;
|
|
544
|
+
|
|
545
|
+
const params = match[1];
|
|
546
|
+
// Find class names ending with Request
|
|
547
|
+
const reqMatch = params.match(/([A-Za-z0-9_]+Request)\s+\$/);
|
|
548
|
+
if (reqMatch && reqMatch[1] !== "Request") {
|
|
549
|
+
return reqMatch[1];
|
|
550
|
+
}
|
|
551
|
+
return null;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
private detectModelClass(controllerCode: string, actionName: string, actionBody: string): string | null {
|
|
555
|
+
// 1. Method signature type-hints: (Order $order)
|
|
556
|
+
const paramRegex = new RegExp(`public\\s+function\\s+${actionName}\\s*\\(([^)]*)\\)`, "i");
|
|
557
|
+
const match = paramRegex.exec(controllerCode);
|
|
558
|
+
if (match) {
|
|
559
|
+
const parts = match[1].split(",");
|
|
560
|
+
for (const p of parts) {
|
|
561
|
+
const pMatch = p.trim().match(/^([A-Z][A-Za-z0-9_]+)\s+\$/);
|
|
562
|
+
if (
|
|
563
|
+
pMatch &&
|
|
564
|
+
!pMatch[1].endsWith("Request") &&
|
|
565
|
+
pMatch[1] !== "Request" &&
|
|
566
|
+
!/(Action|Service|UseCase)$/i.test(pMatch[1])
|
|
567
|
+
) {
|
|
568
|
+
return pMatch[1];
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// 2. Action body static calls: Order::find(), Order::create(), Order::query()
|
|
574
|
+
const staticModelMatch = actionBody.match(/([A-Z][A-Za-z0-9_]+)::(?:find|findOrFail|create|where|query)\s*\(/);
|
|
575
|
+
if (staticModelMatch && !["Inertia", "Route", "DB", "Auth", "Log", "Response"].includes(staticModelMatch[1])) {
|
|
576
|
+
return staticModelMatch[1];
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
return null;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
private detectFrontendTarget(actionBody: string): { target: string; props: string[] } | null {
|
|
583
|
+
// 1. Inertia::render('Orders/Show', ['order' => $order])
|
|
584
|
+
const inertiaMatch = actionBody.match(/Inertia::render\s*\(\s*['"]([^'"]+)['"](?:\s*,\s*\[([\s\S]*?)\])?\s*\)/);
|
|
585
|
+
if (inertiaMatch) {
|
|
586
|
+
const pageComponent = inertiaMatch[1]; // e.g. "Orders/Show"
|
|
587
|
+
const rawProps = inertiaMatch[2] || "";
|
|
588
|
+
|
|
589
|
+
// Extract prop keys: 'order' => ... or "order" => ... or order => ...
|
|
590
|
+
const propMatches = rawProps.matchAll(/['"]?([A-Za-z0-9_]+)['"]?\s*=>/g);
|
|
591
|
+
const props: string[] = [];
|
|
592
|
+
for (const pm of propMatches) {
|
|
593
|
+
if (!props.includes(pm[1])) {
|
|
594
|
+
props.push(pm[1]);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Check for Vue, TSX, JSX files in resources/js/Pages
|
|
599
|
+
const possibleExtensions = [".vue", ".tsx", ".jsx", ".svelte"];
|
|
600
|
+
let frontendPath = `resources/js/Pages/${pageComponent}`;
|
|
601
|
+
for (const ext of possibleExtensions) {
|
|
602
|
+
const fullCandidate = path.join(this.projectRoot, `resources/js/Pages/${pageComponent}${ext}`);
|
|
603
|
+
if (fs.existsSync(fullCandidate)) {
|
|
604
|
+
frontendPath = `resources/js/Pages/${pageComponent}${ext}`;
|
|
605
|
+
break;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
if (!frontendPath.includes(".")) {
|
|
609
|
+
frontendPath += ".vue"; // Default inertia convention
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
return {
|
|
613
|
+
target: frontendPath,
|
|
614
|
+
props,
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// 2. view('orders.show', compact('order'))
|
|
619
|
+
const viewMatch = actionBody.match(/view\s*\(\s*['"]([^'"]+)['"]/);
|
|
620
|
+
if (viewMatch) {
|
|
621
|
+
const viewPath = viewMatch[1].replace(/\./g, "/");
|
|
622
|
+
return {
|
|
623
|
+
target: `resources/views/${viewPath}.blade.php`,
|
|
624
|
+
props: [],
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
return null;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
private detectActionAndServiceInvocations(
|
|
632
|
+
controllerCode: string,
|
|
633
|
+
actionName: string,
|
|
634
|
+
actionBody: string
|
|
635
|
+
): Array<{
|
|
636
|
+
className: string;
|
|
637
|
+
filePath?: string;
|
|
638
|
+
isService: boolean;
|
|
639
|
+
}> {
|
|
640
|
+
const detected = new Map<string, { className: string; filePath?: string; isService: boolean }>();
|
|
641
|
+
|
|
642
|
+
// 1. Check method parameters of actionName
|
|
643
|
+
const methodRegex = new RegExp(
|
|
644
|
+
`(?:public|protected|private)?\\s*function\\s+${actionName}\\s*\\(([^)]*)\\)`,
|
|
645
|
+
"i"
|
|
646
|
+
);
|
|
647
|
+
const methodMatch = controllerCode.match(methodRegex);
|
|
648
|
+
if (methodMatch && methodMatch[1]) {
|
|
649
|
+
const params = methodMatch[1].split(",");
|
|
650
|
+
for (const param of params) {
|
|
651
|
+
const cleanParam = param.trim().replace(/^(?:public|protected|private)?\s*(?:readonly)?\s*/i, "").trim();
|
|
652
|
+
const parts = cleanParam.split(/\s+/);
|
|
653
|
+
if (parts.length >= 2) {
|
|
654
|
+
const typehint = parts[0].replace(/^\\/, "").trim();
|
|
655
|
+
if (/(Action|Service|UseCase)$/i.test(typehint)) {
|
|
656
|
+
const shortName = typehint.split("\\").pop() || typehint;
|
|
657
|
+
const isService = /Service$/i.test(shortName);
|
|
658
|
+
const folder = isService ? "Services" : "Actions";
|
|
659
|
+
const file = this.findClassFile(folder, shortName);
|
|
660
|
+
detected.set(shortName, {
|
|
661
|
+
className: shortName,
|
|
662
|
+
filePath: file ? path.relative(this.projectRoot, file) : undefined,
|
|
663
|
+
isService,
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// 2. Check constructor injection
|
|
671
|
+
const ctorMatch = controllerCode.match(/function\s+__construct\s*\(([^)]*)\)/i);
|
|
672
|
+
if (ctorMatch && ctorMatch[1]) {
|
|
673
|
+
const params = ctorMatch[1].split(",");
|
|
674
|
+
for (const param of params) {
|
|
675
|
+
const cleanParam = param.trim().replace(/^(?:public|protected|private)?\s*(?:readonly)?\s*/i, "").trim();
|
|
676
|
+
const parts = cleanParam.split(/\s+/);
|
|
677
|
+
if (parts.length >= 2) {
|
|
678
|
+
const typehint = parts[0].replace(/^\\/, "").trim();
|
|
679
|
+
if (/(Action|Service|UseCase)$/i.test(typehint)) {
|
|
680
|
+
const shortName = typehint.split("\\").pop() || typehint;
|
|
681
|
+
const isService = /Service$/i.test(shortName);
|
|
682
|
+
const propName = shortName.charAt(0).toLowerCase() + shortName.slice(1);
|
|
683
|
+
if (
|
|
684
|
+
actionBody.includes(shortName) ||
|
|
685
|
+
actionBody.includes(`$this->${propName}`) ||
|
|
686
|
+
actionBody.toLowerCase().includes(propName.toLowerCase())
|
|
687
|
+
) {
|
|
688
|
+
const folder = isService ? "Services" : "Actions";
|
|
689
|
+
const file = this.findClassFile(folder, shortName);
|
|
690
|
+
detected.set(shortName, {
|
|
691
|
+
className: shortName,
|
|
692
|
+
filePath: file ? path.relative(this.projectRoot, file) : undefined,
|
|
693
|
+
isService,
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// 3. Check static calls or class instantiations inside actionBody
|
|
702
|
+
const actionServicePattern = /\b([A-Z][a-zA-Z0-9_]*(?:Action|Service|UseCase))\b(?:::|::class|\s*\(|\s*;)/g;
|
|
703
|
+
let match: RegExpExecArray | null;
|
|
704
|
+
while ((match = actionServicePattern.exec(actionBody)) !== null) {
|
|
705
|
+
const cls = match[1];
|
|
706
|
+
if (!detected.has(cls)) {
|
|
707
|
+
const isService = /Service$/i.test(cls);
|
|
708
|
+
const folder = isService ? "Services" : "Actions";
|
|
709
|
+
const file = this.findClassFile(folder, cls);
|
|
710
|
+
detected.set(cls, {
|
|
711
|
+
className: cls,
|
|
712
|
+
filePath: file ? path.relative(this.projectRoot, file) : undefined,
|
|
713
|
+
isService,
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// 4. Also scan 'use' imports in controller for App\Actions\... or App\Services\...
|
|
719
|
+
const usePattern = /use\s+App\\(Actions|Services)\\(?:[a-zA-Z0-9_\\]+\\)?([A-Z][a-zA-Z0-9_]*);/g;
|
|
720
|
+
while ((match = usePattern.exec(controllerCode)) !== null) {
|
|
721
|
+
const folder = match[1];
|
|
722
|
+
const cls = match[2];
|
|
723
|
+
if (actionBody.includes(cls) && !detected.has(cls)) {
|
|
724
|
+
const isService = folder === "Services" || /Service$/i.test(cls);
|
|
725
|
+
const file = this.findClassFile(folder, cls);
|
|
726
|
+
detected.set(cls, {
|
|
727
|
+
className: cls,
|
|
728
|
+
filePath: file ? path.relative(this.projectRoot, file) : undefined,
|
|
729
|
+
isService,
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
return Array.from(detected.values());
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
private findClassFile(
|
|
738
|
+
subDir: "Requests" | "Models" | "Jobs" | "Events" | "Listeners" | "Console/Commands" | "Actions" | "Services" | string,
|
|
739
|
+
className: string
|
|
740
|
+
): string | null {
|
|
741
|
+
const directPath = path.join(this.projectRoot, `app/${subDir}/${className}.php`);
|
|
742
|
+
if (fs.existsSync(directPath)) return directPath;
|
|
743
|
+
|
|
744
|
+
// Direct app/ fallback for older Laravel
|
|
745
|
+
const flatPath = path.join(this.projectRoot, `app/${className}.php`);
|
|
746
|
+
if (fs.existsSync(flatPath)) return flatPath;
|
|
747
|
+
|
|
748
|
+
return null;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
private extractValidationRules(requestCode: string): Record<string, string> {
|
|
752
|
+
const rules: Record<string, string> = {};
|
|
753
|
+
const rulesMatch = requestCode.match(/function\s+rules\s*\(\s*\)[^{]*\{([^}]*)\}/i);
|
|
754
|
+
if (!rulesMatch) return rules;
|
|
755
|
+
|
|
756
|
+
const body = rulesMatch[1];
|
|
757
|
+
// Match 'status' => 'in:pending,paid' or 'status' => ['required', 'string']
|
|
758
|
+
const rulePairRegex = /['"]([A-Za-z0-9_]+)['"]\s*=>\s*(?:['"]([^'"]+)['"]|\[([^\]]+)\])/g;
|
|
759
|
+
let rm;
|
|
760
|
+
while ((rm = rulePairRegex.exec(body)) !== null) {
|
|
761
|
+
const field = rm[1];
|
|
762
|
+
const singleRule = rm[2];
|
|
763
|
+
const arrayRules = rm[3];
|
|
764
|
+
if (singleRule) {
|
|
765
|
+
rules[field] = singleRule;
|
|
766
|
+
} else if (arrayRules) {
|
|
767
|
+
rules[field] = arrayRules.replace(/['"\s]/g, "").replace(/,/g, "|");
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
return rules;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
private extractModelMetadata(modelCode: string): { fillable: string[]; casts: Record<string, string> } {
|
|
775
|
+
const fillable: string[] = [];
|
|
776
|
+
const casts: Record<string, string> = {};
|
|
777
|
+
|
|
778
|
+
// Match protected $fillable = [...]
|
|
779
|
+
const fillableMatch = modelCode.match(/\$fillable\s*=\s*\[([^\]]*)\]/);
|
|
780
|
+
if (fillableMatch) {
|
|
781
|
+
const items = fillableMatch[1].matchAll(/['"]([A-Za-z0-9_]+)['"]/g);
|
|
782
|
+
for (const it of items) {
|
|
783
|
+
fillable.push(it[1]);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// Match protected $casts = [...]
|
|
788
|
+
const castsMatch = modelCode.match(/\$casts\s*=\s*\[([^\]]*)\]/);
|
|
789
|
+
if (castsMatch) {
|
|
790
|
+
const castItems = castsMatch[1].matchAll(/['"]([A-Za-z0-9_]+)['"]\s*=>\s*(?:['"]([^'"]+)['"]|([A-Za-z0-9_]+)::class)/g);
|
|
791
|
+
for (const ci of castItems) {
|
|
792
|
+
casts[ci[1]] = ci[2] || ci[3];
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
return { fillable, casts };
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
private extractJobSlices(
|
|
800
|
+
root: string,
|
|
801
|
+
domainId?: number
|
|
802
|
+
): Array<Omit<VerticalSliceRecord, "id" | "created_at">> {
|
|
803
|
+
const jobsDir = path.join(root, "app/Jobs");
|
|
804
|
+
if (!fs.existsSync(jobsDir)) return [];
|
|
805
|
+
|
|
806
|
+
const jobFiles = fs.readdirSync(jobsDir).filter((f) => f.endsWith(".php"));
|
|
807
|
+
const slices: Array<Omit<VerticalSliceRecord, "id" | "created_at">> = [];
|
|
808
|
+
|
|
809
|
+
for (const file of jobFiles) {
|
|
810
|
+
const fullPath = path.join(jobsDir, file);
|
|
811
|
+
const content = fs.readFileSync(fullPath, "utf-8");
|
|
812
|
+
const classMatch = content.match(/class\s+([A-Za-z0-9_]+)/);
|
|
813
|
+
if (!classMatch) continue;
|
|
814
|
+
|
|
815
|
+
const className = classMatch[1];
|
|
816
|
+
const actionBody = this.extractActionBody(content, "handle");
|
|
817
|
+
const behavior = BehavioralAnalyzer.analyzeMethod(actionBody);
|
|
818
|
+
|
|
819
|
+
const chain: ExecutionChainNode[] = [
|
|
820
|
+
{
|
|
821
|
+
stage: "ingress",
|
|
822
|
+
symbol: `JOB ${className}`,
|
|
823
|
+
file: path.relative(this.projectRoot, fullPath),
|
|
824
|
+
description: `Queue Worker Trigger: ${className}`,
|
|
825
|
+
},
|
|
826
|
+
{
|
|
827
|
+
stage: "controller",
|
|
828
|
+
symbol: `${className}::handle`,
|
|
829
|
+
file: path.relative(this.projectRoot, fullPath),
|
|
830
|
+
description: "Queue Job Handler",
|
|
831
|
+
mutations: behavior.mutations.length ? behavior.mutations : undefined,
|
|
832
|
+
transactions: behavior.hasTransaction,
|
|
833
|
+
},
|
|
834
|
+
];
|
|
835
|
+
|
|
836
|
+
if (behavior.hasTransaction) {
|
|
837
|
+
chain.push({
|
|
838
|
+
stage: "transaction",
|
|
839
|
+
symbol: "DB::transaction",
|
|
840
|
+
description: "Database Transaction Scope",
|
|
841
|
+
transactions: true,
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
for (const evt of behavior.emittedEvents) {
|
|
846
|
+
const evtFile = this.findClassFile("Events", evt);
|
|
847
|
+
chain.push({
|
|
848
|
+
stage: "event",
|
|
849
|
+
symbol: evt,
|
|
850
|
+
file: evtFile ? path.relative(this.projectRoot, evtFile) : undefined,
|
|
851
|
+
description: "Domain Event Emitted",
|
|
852
|
+
emits: [evt],
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
slices.push({
|
|
857
|
+
domain_id: domainId ?? null,
|
|
858
|
+
feature_key: `job:${className}`,
|
|
859
|
+
http_method: "JOB",
|
|
860
|
+
route_uri: `job:${className}`,
|
|
861
|
+
route_name: `queue.${this.toSnakeCase(className)}`,
|
|
862
|
+
controller_class: className,
|
|
863
|
+
action_name: "handle",
|
|
864
|
+
controller_file: path.relative(this.projectRoot, fullPath),
|
|
865
|
+
controller_line: 1,
|
|
866
|
+
architecture_style: "clean",
|
|
867
|
+
entry_kind: "queue_job",
|
|
868
|
+
execution_chain_json: JSON.stringify(chain),
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
return slices;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
private extractCommandSlices(
|
|
876
|
+
root: string,
|
|
877
|
+
domainId?: number
|
|
878
|
+
): Array<Omit<VerticalSliceRecord, "id" | "created_at">> {
|
|
879
|
+
const commandsDir = path.join(root, "app/Console/Commands");
|
|
880
|
+
if (!fs.existsSync(commandsDir)) return [];
|
|
881
|
+
|
|
882
|
+
const files = fs.readdirSync(commandsDir).filter((f) => f.endsWith(".php"));
|
|
883
|
+
const slices: Array<Omit<VerticalSliceRecord, "id" | "created_at">> = [];
|
|
884
|
+
|
|
885
|
+
for (const file of files) {
|
|
886
|
+
const fullPath = path.join(commandsDir, file);
|
|
887
|
+
const content = fs.readFileSync(fullPath, "utf-8");
|
|
888
|
+
const classMatch = content.match(/class\s+([A-Za-z0-9_]+)/);
|
|
889
|
+
if (!classMatch) continue;
|
|
890
|
+
|
|
891
|
+
const className = classMatch[1];
|
|
892
|
+
const sigMatch = content.match(/protected\s+\$signature\s*=\s*['"]([^'"]+)['"]/);
|
|
893
|
+
const commandSignature = sigMatch ? sigMatch[1].split(/\s+/)[0] : this.toSnakeCase(className);
|
|
894
|
+
|
|
895
|
+
const actionBody = this.extractActionBody(content, "handle");
|
|
896
|
+
const behavior = BehavioralAnalyzer.analyzeMethod(actionBody);
|
|
897
|
+
|
|
898
|
+
const chain: ExecutionChainNode[] = [
|
|
899
|
+
{
|
|
900
|
+
stage: "ingress",
|
|
901
|
+
symbol: `CLI ${commandSignature}`,
|
|
902
|
+
file: path.relative(this.projectRoot, fullPath),
|
|
903
|
+
description: `Console Command: ${commandSignature}`,
|
|
904
|
+
},
|
|
905
|
+
{
|
|
906
|
+
stage: "controller",
|
|
907
|
+
symbol: `${className}::handle`,
|
|
908
|
+
file: path.relative(this.projectRoot, fullPath),
|
|
909
|
+
description: "Command Execution Handler",
|
|
910
|
+
mutations: behavior.mutations.length ? behavior.mutations : undefined,
|
|
911
|
+
transactions: behavior.hasTransaction,
|
|
912
|
+
},
|
|
913
|
+
];
|
|
914
|
+
|
|
915
|
+
if (behavior.hasTransaction) {
|
|
916
|
+
chain.push({
|
|
917
|
+
stage: "transaction",
|
|
918
|
+
symbol: "DB::transaction",
|
|
919
|
+
description: "Database Transaction Scope",
|
|
920
|
+
transactions: true,
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
for (const job of behavior.dispatchedJobs) {
|
|
925
|
+
const jobFile = this.findClassFile("Jobs", job);
|
|
926
|
+
chain.push({
|
|
927
|
+
stage: "job",
|
|
928
|
+
symbol: job,
|
|
929
|
+
file: jobFile ? path.relative(this.projectRoot, jobFile) : undefined,
|
|
930
|
+
description: "Dispatched Background Worker",
|
|
931
|
+
dispatches: [job],
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
slices.push({
|
|
936
|
+
domain_id: domainId ?? null,
|
|
937
|
+
feature_key: `command:${commandSignature}`,
|
|
938
|
+
http_method: "CLI",
|
|
939
|
+
route_uri: `command:${commandSignature}`,
|
|
940
|
+
route_name: `cli.${commandSignature}`,
|
|
941
|
+
controller_class: className,
|
|
942
|
+
action_name: "handle",
|
|
943
|
+
controller_file: path.relative(this.projectRoot, fullPath),
|
|
944
|
+
controller_line: 1,
|
|
945
|
+
architecture_style: "clean",
|
|
946
|
+
entry_kind: "scheduled_command",
|
|
947
|
+
execution_chain_json: JSON.stringify(chain),
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
return slices;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
private extractListenerSlices(
|
|
955
|
+
root: string,
|
|
956
|
+
domainId?: number
|
|
957
|
+
): Array<Omit<VerticalSliceRecord, "id" | "created_at">> {
|
|
958
|
+
const listenersDir = path.join(root, "app/Listeners");
|
|
959
|
+
if (!fs.existsSync(listenersDir)) return [];
|
|
960
|
+
|
|
961
|
+
const files = fs.readdirSync(listenersDir).filter((f) => f.endsWith(".php"));
|
|
962
|
+
const slices: Array<Omit<VerticalSliceRecord, "id" | "created_at">> = [];
|
|
963
|
+
|
|
964
|
+
for (const file of files) {
|
|
965
|
+
const fullPath = path.join(listenersDir, file);
|
|
966
|
+
const content = fs.readFileSync(fullPath, "utf-8");
|
|
967
|
+
const classMatch = content.match(/class\s+([A-Za-z0-9_]+)/);
|
|
968
|
+
if (!classMatch) continue;
|
|
969
|
+
|
|
970
|
+
const className = classMatch[1];
|
|
971
|
+
const actionBody = this.extractActionBody(content, "handle");
|
|
972
|
+
const behavior = BehavioralAnalyzer.analyzeMethod(actionBody);
|
|
973
|
+
|
|
974
|
+
const eventParamMatch = content.match(/public\s+function\s+handle\s*\(\s*([A-Za-z0-9_]+)\s+\$event\s*\)/i);
|
|
975
|
+
const listenedEvent = eventParamMatch ? eventParamMatch[1] : undefined;
|
|
976
|
+
|
|
977
|
+
const chain: ExecutionChainNode[] = [
|
|
978
|
+
{
|
|
979
|
+
stage: "ingress",
|
|
980
|
+
symbol: `EVENT ${listenedEvent || className}`,
|
|
981
|
+
file: path.relative(this.projectRoot, fullPath),
|
|
982
|
+
description: `Event Listener Trigger${listenedEvent ? ` for [${listenedEvent}]` : ""}`,
|
|
983
|
+
},
|
|
984
|
+
{
|
|
985
|
+
stage: "controller",
|
|
986
|
+
symbol: `${className}::handle`,
|
|
987
|
+
file: path.relative(this.projectRoot, fullPath),
|
|
988
|
+
description: "Event Listener Handler",
|
|
989
|
+
mutations: behavior.mutations.length ? behavior.mutations : undefined,
|
|
990
|
+
transactions: behavior.hasTransaction,
|
|
991
|
+
},
|
|
992
|
+
];
|
|
993
|
+
|
|
994
|
+
if (behavior.hasTransaction) {
|
|
995
|
+
chain.push({
|
|
996
|
+
stage: "transaction",
|
|
997
|
+
symbol: "DB::transaction",
|
|
998
|
+
description: "Database Transaction Scope",
|
|
999
|
+
transactions: true,
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
for (const job of behavior.dispatchedJobs) {
|
|
1004
|
+
const jobFile = this.findClassFile("Jobs", job);
|
|
1005
|
+
chain.push({
|
|
1006
|
+
stage: "job",
|
|
1007
|
+
symbol: job,
|
|
1008
|
+
file: jobFile ? path.relative(this.projectRoot, jobFile) : undefined,
|
|
1009
|
+
description: "Dispatched Background Worker",
|
|
1010
|
+
dispatches: [job],
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
slices.push({
|
|
1015
|
+
domain_id: domainId ?? null,
|
|
1016
|
+
feature_key: `listener:${className}`,
|
|
1017
|
+
http_method: "EVENT",
|
|
1018
|
+
route_uri: `event:${className}`,
|
|
1019
|
+
route_name: `event.${this.toSnakeCase(className)}`,
|
|
1020
|
+
controller_class: className,
|
|
1021
|
+
action_name: "handle",
|
|
1022
|
+
controller_file: path.relative(this.projectRoot, fullPath),
|
|
1023
|
+
controller_line: 1,
|
|
1024
|
+
architecture_style: "clean",
|
|
1025
|
+
entry_kind: "event_listener",
|
|
1026
|
+
execution_chain_json: JSON.stringify(chain),
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
return slices;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
private toSnakeCase(str: string): string {
|
|
1034
|
+
return str
|
|
1035
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
1036
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
|
|
1037
|
+
.toLowerCase();
|
|
1038
|
+
}
|
|
1039
|
+
}
|