septum 0.1.2 → 0.1.4

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 (46) hide show
  1. package/README.md +7 -2
  2. package/package.json +1 -1
  3. package/src/cli/commands/slice.ts +4 -46
  4. package/src/cli/commands/topology.ts +119 -0
  5. package/src/cli/index.ts +11 -0
  6. package/src/cli/templates/skill.ts +7 -4
  7. package/src/core/config/loader.ts +21 -8
  8. package/src/core/database/repositories/dependency.repository.ts +70 -0
  9. package/src/core/database/repositories/environment.repository.ts +207 -0
  10. package/src/core/database/repositories/file.repository.ts +7 -0
  11. package/src/core/database/repositories/symbol.repository.ts +28 -9
  12. package/src/core/database/repository.ts +19 -1
  13. package/src/core/database/schema.ts +153 -120
  14. package/src/core/discovery/topology-detector.ts +71 -3
  15. package/src/core/ingestion/pipeline.ts +98 -1
  16. package/src/core/parser/behavioral-analyzer.ts +119 -0
  17. package/src/core/parser/boundary-tracker.ts +160 -0
  18. package/src/core/parser/extractors/base.ts +2 -0
  19. package/src/core/parser/extractors/blade.ts +158 -0
  20. package/src/core/parser/extractors/go.ts +5 -1
  21. package/src/core/parser/extractors/laravel-semantic.ts +506 -31
  22. package/src/core/parser/extractors/php.ts +5 -1
  23. package/src/core/parser/extractors/python.ts +3 -1
  24. package/src/core/parser/extractors/registry.ts +21 -0
  25. package/src/core/parser/extractors/typescript.ts +5 -1
  26. package/src/core/parser/schema/providers/laravel-migration-provider.ts +215 -0
  27. package/src/core/parser/schema/providers/prisma-schema-provider.ts +89 -0
  28. package/src/core/parser/schema/schema-provider-registry.ts +43 -0
  29. package/src/core/parser/schema/schema-provider.interface.ts +33 -0
  30. package/src/core/parser/tree-sitter.ts +2 -0
  31. package/src/core/resolver/path-resolver.ts +147 -0
  32. package/src/core/resolver/vertical-slice-tracer.ts +56 -8
  33. package/src/core/topology/environment-detector.ts +860 -0
  34. package/src/core/topology/types.ts +83 -0
  35. package/src/index.ts +3 -0
  36. package/src/mcp/schemas.ts +264 -46
  37. package/src/mcp/server.ts +130 -26
  38. package/src/mcp/tools/check-boundary.ts +58 -8
  39. package/src/mcp/tools/get-domain-catalog.ts +31 -1
  40. package/src/mcp/tools/get-environment-topology.ts +141 -0
  41. package/src/mcp/tools/get-symbol-hotspots.ts +13 -3
  42. package/src/mcp/tools/locate-symbol.ts +119 -3
  43. package/src/mcp/tools/register-domain.ts +1 -1
  44. package/src/mcp/tools/trace-vertical-slice.ts +11 -65
  45. package/src/types/index.ts +20 -1
  46. package/src/version.ts +3 -3
@@ -1,6 +1,8 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import type { VerticalSliceRecord } from "../../../types/index.ts";
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";
4
6
  import type {
5
7
  SemanticSliceExtractor,
6
8
  VerticalSliceCandidate,
@@ -12,6 +14,7 @@ export interface ParsedRoute {
12
14
  routeName?: string;
13
15
  controllerClass: string;
14
16
  actionName: string;
17
+ file?: string;
15
18
  }
16
19
 
17
20
  export type LaravelSemanticExtractionResult = VerticalSliceCandidate[] & {
@@ -85,9 +88,11 @@ export class LaravelSemanticExtractor implements SemanticSliceExtractor {
85
88
  props: string[];
86
89
  } | null = null;
87
90
 
91
+ let behavioralInfo: import("../behavioral-analyzer.ts").MethodBehavioralSummary | null = null;
88
92
  if (controllerInfo.filePath && fs.existsSync(controllerInfo.filePath)) {
89
93
  const controllerCode = fs.readFileSync(controllerInfo.filePath, "utf-8");
90
94
  const actionBody = this.extractActionBody(controllerCode, route.actionName);
95
+ behavioralInfo = BehavioralAnalyzer.analyzeMethod(actionBody);
91
96
 
92
97
  // 1. Detect FormRequest in method signature
93
98
  const requestClassName = this.detectFormRequestClass(controllerCode, route.actionName);
@@ -118,7 +123,7 @@ export class LaravelSemanticExtractor implements SemanticSliceExtractor {
118
123
  frontendInfo = this.detectFrontendTarget(actionBody);
119
124
  }
120
125
 
121
- const chain: Array<{ stage: string; symbol: string; file?: string; line?: number; description?: string }> = [
126
+ const chain: ExecutionChainNode[] = [
122
127
  {
123
128
  stage: "ingress",
124
129
  symbol: `${route.httpMethod.toUpperCase()} ${route.uri.startsWith("/") ? route.uri : `/${route.uri}`}`,
@@ -127,6 +132,15 @@ export class LaravelSemanticExtractor implements SemanticSliceExtractor {
127
132
  },
128
133
  ];
129
134
 
135
+ if (behavioralInfo && behavioralInfo.guards.length > 0) {
136
+ chain.push({
137
+ stage: "guard",
138
+ symbol: behavioralInfo.guards.join(", "),
139
+ description: "Authorization / Security Guard",
140
+ guards: behavioralInfo.guards,
141
+ });
142
+ }
143
+
130
144
  if (formRequestInfo) {
131
145
  chain.push({
132
146
  stage: "validation",
@@ -142,17 +156,70 @@ export class LaravelSemanticExtractor implements SemanticSliceExtractor {
142
156
  file: controllerInfo.filePath ? path.relative(this.projectRoot, controllerInfo.filePath) : undefined,
143
157
  line: controllerInfo.line,
144
158
  description: `Controller Action`,
159
+ guards: behavioralInfo && behavioralInfo.guards.length > 0 ? behavioralInfo.guards : undefined,
160
+ mutations: behavioralInfo && behavioralInfo.mutations.length > 0 ? behavioralInfo.mutations : undefined,
161
+ transactions: behavioralInfo?.hasTransaction,
145
162
  });
146
163
 
164
+ if (behavioralInfo && behavioralInfo.hasTransaction) {
165
+ chain.push({
166
+ stage: "transaction",
167
+ symbol: "DB::transaction",
168
+ description: "Database Transaction Scope",
169
+ transactions: true,
170
+ });
171
+ }
172
+
147
173
  if (modelInfo) {
174
+ const schema = SchemaProviderRegistry.resolveSchema(
175
+ root,
176
+ modelInfo.className,
177
+ modelInfo.filePath
178
+ );
179
+
180
+ const columnsSummary =
181
+ schema && schema.fields.length > 0
182
+ ? schema.fields.map((f) => `${f.name}:${f.type}${f.nullable ? "?" : ""}`).join(", ")
183
+ : undefined;
184
+
148
185
  chain.push({
149
186
  stage: "entity",
150
187
  symbol: modelInfo.className,
151
188
  file: modelInfo.filePath,
152
- description: "Eloquent Model",
189
+ description: schema?.tableName ? `Eloquent Model (${schema.tableName})` : "Eloquent Model",
190
+ table: schema?.tableName,
191
+ schema_file: schema?.schemaSourceFile,
192
+ columns_summary: columnsSummary,
193
+ mutations: behavioralInfo && behavioralInfo.mutations.length > 0 ? behavioralInfo.mutations : undefined,
153
194
  });
154
195
  }
155
196
 
197
+ if (behavioralInfo && behavioralInfo.dispatchedJobs.length > 0) {
198
+ for (const job of behavioralInfo.dispatchedJobs) {
199
+ const jobFile = this.findClassFile("Jobs", job);
200
+ chain.push({
201
+ stage: "job",
202
+ symbol: job,
203
+ file: jobFile ? path.relative(this.projectRoot, jobFile) : undefined,
204
+ description: "Background Queue Job Dispatched",
205
+ dispatches: [job],
206
+ });
207
+ }
208
+ }
209
+
210
+ if (behavioralInfo && behavioralInfo.emittedEvents.length > 0) {
211
+ for (const evt of behavioralInfo.emittedEvents) {
212
+ const evtFile = this.findClassFile("Events", evt);
213
+ chain.push({
214
+ stage: "event",
215
+ symbol: evt,
216
+ file: evtFile ? path.relative(this.projectRoot, evtFile) : undefined,
217
+ description: "Domain Event Emitted",
218
+ emits: [evt],
219
+ });
220
+ }
221
+ }
222
+
156
223
  if (frontendInfo) {
157
224
  chain.push({
158
225
  stage: "egress",
@@ -177,6 +244,11 @@ export class LaravelSemanticExtractor implements SemanticSliceExtractor {
177
244
  });
178
245
  }
179
246
 
247
+ // Also discover Omni-Trigger slices (Jobs, Console Commands, Event Listeners)
248
+ slices.push(...this.extractJobSlices(root, domainId));
249
+ slices.push(...this.extractCommandSlices(root, domainId));
250
+ slices.push(...this.extractListenerSlices(root, domainId));
251
+
180
252
  return Object.assign(slices, { slices });
181
253
  }
182
254
 
@@ -186,33 +258,12 @@ export class LaravelSemanticExtractor implements SemanticSliceExtractor {
186
258
  public parseRoutesContent(content: string): ParsedRoute[] {
187
259
  const routes: ParsedRoute[] = [];
188
260
 
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];
261
+ // 1. Process Route::resource
262
+ const resourceRegex = /Route::resource\s*\(\s*['"]([^'"]+)['"]\s*,\s*([\\A-Za-z0-9_]+)::class\s*\)/gi;
263
+ let resMatch;
264
+ while ((resMatch = resourceRegex.exec(content)) !== null) {
265
+ const baseUri = resMatch[1].replace(/^\//, "");
266
+ const controllerClass = resMatch[2];
216
267
  const resourceActions = [
217
268
  { method: "GET", sub: "", action: "index" },
218
269
  { method: "GET", sub: "/create", action: "create" },
@@ -234,9 +285,189 @@ export class LaravelSemanticExtractor implements SemanticSliceExtractor {
234
285
  }
235
286
  }
236
287
 
288
+ // 2. Parse routes with group context tracking (prefix, controller, name prefix)
289
+ const lines = content.split(/\r?\n/);
290
+ interface GroupContext {
291
+ prefix: string;
292
+ controller: string;
293
+ namePrefix: string;
294
+ braceDepth: number;
295
+ }
296
+
297
+ const groupStack: GroupContext[] = [];
298
+ let currentBraceDepth = 0;
299
+ let statementBuffer = "";
300
+ let inStatement = false;
301
+
302
+ for (let i = 0; i < lines.length; i++) {
303
+ const line = lines[i];
304
+ const trimmed = line.trim();
305
+
306
+ // Check group opens: Route::...group(
307
+ if (trimmed.includes("Route::") && trimmed.includes("group(") && trimmed.includes("function")) {
308
+ const prefixMatch = /prefix\s*\(\s*['"]([^'"]+)['"]\s*\)/.exec(trimmed);
309
+ const controllerMatch = /controller\s*\(\s*([\\A-Za-z0-9_]+)::class\s*\)/.exec(trimmed);
310
+ const nameMatch = /name\s*\(\s*['"]([^'"]+)['"]\s*\)/.exec(trimmed);
311
+
312
+ const currentCtx = groupStack[groupStack.length - 1];
313
+ const newPrefix = [currentCtx?.prefix, prefixMatch ? prefixMatch[1] : ""]
314
+ .filter(Boolean)
315
+ .join("/");
316
+ const newController = controllerMatch ? controllerMatch[1] : (currentCtx?.controller || "");
317
+ const newNamePrefix = [currentCtx?.namePrefix, nameMatch ? nameMatch[1] : ""]
318
+ .filter(Boolean)
319
+ .join("");
320
+
321
+ groupStack.push({
322
+ prefix: newPrefix,
323
+ controller: newController,
324
+ namePrefix: newNamePrefix,
325
+ braceDepth: currentBraceDepth,
326
+ });
327
+ }
328
+
329
+ // Track brace depth
330
+ for (const char of line) {
331
+ if (char === "{") currentBraceDepth++;
332
+ if (char === "}") {
333
+ currentBraceDepth--;
334
+ if (groupStack.length > 0) {
335
+ const top = groupStack[groupStack.length - 1];
336
+ if (currentBraceDepth <= top.braceDepth) {
337
+ groupStack.pop();
338
+ }
339
+ }
340
+ }
341
+ }
342
+
343
+ // Route statement accumulation across lines
344
+ if (!inStatement && trimmed.startsWith("Route::") && !trimmed.includes("Route::resource") && !trimmed.includes("Route::group")) {
345
+ statementBuffer = trimmed;
346
+ inStatement = true;
347
+ } else if (inStatement) {
348
+ statementBuffer += " " + trimmed;
349
+ }
350
+
351
+ if (inStatement && statementBuffer.includes(";")) {
352
+ inStatement = false;
353
+ const stmt = statementBuffer;
354
+ statementBuffer = "";
355
+
356
+ const currentCtx = groupStack[groupStack.length - 1];
357
+ const parsed = this.parseSingleRouteStatement(stmt, currentCtx);
358
+ if (parsed) {
359
+ routes.push(parsed);
360
+ }
361
+ }
362
+ }
363
+
237
364
  return routes;
238
365
  }
239
366
 
367
+ private parseSingleRouteStatement(
368
+ stmt: string,
369
+ ctx?: { prefix: string; controller: string; namePrefix: string }
370
+ ): ParsedRoute | null {
371
+ const methodMatch = /Route::(get|post|put|patch|delete|any|options)\s*\(\s*['"]([^'"]+)['"]([\s\S]*)/i.exec(stmt);
372
+ if (!methodMatch) {
373
+ const viewMatch = /Route::view\s*\(\s*['"]([^'"]+)['"]\s*,\s*['"]([^'"]+)['"]/i.exec(stmt);
374
+ if (viewMatch) {
375
+ const rawUri = viewMatch[1];
376
+ const viewTarget = viewMatch[2];
377
+ const nameMatch = /->name\s*\(\s*['"]([^'"]+)['"]\s*\)/i.exec(stmt);
378
+ const fullUri = ctx?.prefix ? `/${ctx.prefix.replace(/^\/|\/$/g, "")}/${rawUri.replace(/^\//, "")}` : rawUri;
379
+ return {
380
+ httpMethod: "GET",
381
+ uri: fullUri.startsWith("/") ? fullUri : `/${fullUri}`,
382
+ routeName: (ctx?.namePrefix || "") + (nameMatch ? nameMatch[1] : ""),
383
+ controllerClass: "View",
384
+ actionName: viewTarget,
385
+ };
386
+ }
387
+ return null;
388
+ }
389
+
390
+ const httpMethod = methodMatch[1].toUpperCase();
391
+ let uri = methodMatch[2];
392
+ const rest = methodMatch[3];
393
+
394
+ if (ctx?.prefix) {
395
+ const cleanPrefix = ctx.prefix.replace(/^\/|\/$/g, "");
396
+ const cleanUri = uri.replace(/^\//, "");
397
+ uri = cleanUri ? `/${cleanPrefix}/${cleanUri}` : `/${cleanPrefix}`;
398
+ }
399
+ if (!uri.startsWith("/")) uri = `/${uri}`;
400
+
401
+ const nameMatch = /->name\s*\(\s*['"]([^'"]+)['"]\s*\)/i.exec(stmt);
402
+ const rawRouteName = nameMatch ? nameMatch[1] : undefined;
403
+ const fullRouteName = rawRouteName
404
+ ? (ctx?.namePrefix || "") + rawRouteName
405
+ : undefined;
406
+
407
+ // [Controller::class, 'action']
408
+ const arrayMatch = /\[\s*([\\A-Za-z0-9_]+)::class\s*,\s*['"]([A-Za-z0-9_]+)['"]\s*\]/.exec(rest);
409
+ if (arrayMatch) {
410
+ return {
411
+ httpMethod,
412
+ uri,
413
+ routeName: fullRouteName,
414
+ controllerClass: arrayMatch[1],
415
+ actionName: arrayMatch[2],
416
+ };
417
+ }
418
+
419
+ // 'Controller@action'
420
+ const stringActionMatch = /['"]([\\A-Za-z0-9_]+)@([A-Za-z0-9_]+)['"]/.exec(rest);
421
+ if (stringActionMatch) {
422
+ return {
423
+ httpMethod,
424
+ uri,
425
+ routeName: fullRouteName,
426
+ controllerClass: stringActionMatch[1],
427
+ actionName: stringActionMatch[2],
428
+ };
429
+ }
430
+
431
+ // Action inside controller group
432
+ if (ctx?.controller) {
433
+ const singleActionMatch = /,\s*['"]([A-Za-z0-9_]+)['"]/.exec(rest);
434
+ if (singleActionMatch) {
435
+ return {
436
+ httpMethod,
437
+ uri,
438
+ routeName: fullRouteName,
439
+ controllerClass: ctx.controller,
440
+ actionName: singleActionMatch[1],
441
+ };
442
+ }
443
+ }
444
+
445
+ // Invokable controller
446
+ const invokableMatch = /,\s*([\\A-Za-z0-9_]+)::class/.exec(rest);
447
+ if (invokableMatch) {
448
+ return {
449
+ httpMethod,
450
+ uri,
451
+ routeName: fullRouteName,
452
+ controllerClass: invokableMatch[1],
453
+ actionName: "__invoke",
454
+ };
455
+ }
456
+
457
+ // Closure
458
+ if (rest.includes("function")) {
459
+ return {
460
+ httpMethod,
461
+ uri,
462
+ routeName: fullRouteName,
463
+ controllerClass: "Closure",
464
+ actionName: "handle",
465
+ };
466
+ }
467
+
468
+ return null;
469
+ }
470
+
240
471
  private resolveController(controllerClass: string, actionName: string): { filePath?: string; line: number } {
241
472
  const simpleName = controllerClass.split(/\\|\//).pop() || controllerClass;
242
473
  const candidates = [
@@ -365,7 +596,10 @@ export class LaravelSemanticExtractor implements SemanticSliceExtractor {
365
596
  return null;
366
597
  }
367
598
 
368
- private findClassFile(subDir: "Requests" | "Models", className: string): string | null {
599
+ private findClassFile(
600
+ subDir: "Requests" | "Models" | "Jobs" | "Events" | "Listeners" | "Console/Commands",
601
+ className: string
602
+ ): string | null {
369
603
  const directPath = path.join(this.projectRoot, `app/${subDir}/${className}.php`);
370
604
  if (fs.existsSync(directPath)) return directPath;
371
605
 
@@ -423,4 +657,245 @@ export class LaravelSemanticExtractor implements SemanticSliceExtractor {
423
657
 
424
658
  return { fillable, casts };
425
659
  }
660
+
661
+ private extractJobSlices(
662
+ root: string,
663
+ domainId?: number
664
+ ): Array<Omit<VerticalSliceRecord, "id" | "created_at">> {
665
+ const jobsDir = path.join(root, "app/Jobs");
666
+ if (!fs.existsSync(jobsDir)) return [];
667
+
668
+ const jobFiles = fs.readdirSync(jobsDir).filter((f) => f.endsWith(".php"));
669
+ const slices: Array<Omit<VerticalSliceRecord, "id" | "created_at">> = [];
670
+
671
+ for (const file of jobFiles) {
672
+ const fullPath = path.join(jobsDir, file);
673
+ const content = fs.readFileSync(fullPath, "utf-8");
674
+ const classMatch = content.match(/class\s+([A-Za-z0-9_]+)/);
675
+ if (!classMatch) continue;
676
+
677
+ const className = classMatch[1];
678
+ const actionBody = this.extractActionBody(content, "handle");
679
+ const behavior = BehavioralAnalyzer.analyzeMethod(actionBody);
680
+
681
+ const chain: ExecutionChainNode[] = [
682
+ {
683
+ stage: "ingress",
684
+ symbol: `JOB ${className}`,
685
+ file: path.relative(this.projectRoot, fullPath),
686
+ description: `Queue Worker Trigger: ${className}`,
687
+ },
688
+ {
689
+ stage: "controller",
690
+ symbol: `${className}::handle`,
691
+ file: path.relative(this.projectRoot, fullPath),
692
+ description: "Queue Job Handler",
693
+ mutations: behavior.mutations.length ? behavior.mutations : undefined,
694
+ transactions: behavior.hasTransaction,
695
+ },
696
+ ];
697
+
698
+ if (behavior.hasTransaction) {
699
+ chain.push({
700
+ stage: "transaction",
701
+ symbol: "DB::transaction",
702
+ description: "Database Transaction Scope",
703
+ transactions: true,
704
+ });
705
+ }
706
+
707
+ for (const evt of behavior.emittedEvents) {
708
+ const evtFile = this.findClassFile("Events", evt);
709
+ chain.push({
710
+ stage: "event",
711
+ symbol: evt,
712
+ file: evtFile ? path.relative(this.projectRoot, evtFile) : undefined,
713
+ description: "Domain Event Emitted",
714
+ emits: [evt],
715
+ });
716
+ }
717
+
718
+ slices.push({
719
+ domain_id: domainId ?? null,
720
+ feature_key: `job:${className}`,
721
+ http_method: "JOB",
722
+ route_uri: `job:${className}`,
723
+ route_name: `queue.${this.toSnakeCase(className)}`,
724
+ controller_class: className,
725
+ action_name: "handle",
726
+ controller_file: path.relative(this.projectRoot, fullPath),
727
+ controller_line: 1,
728
+ architecture_style: "clean",
729
+ entry_kind: "queue_job",
730
+ execution_chain_json: JSON.stringify(chain),
731
+ });
732
+ }
733
+
734
+ return slices;
735
+ }
736
+
737
+ private extractCommandSlices(
738
+ root: string,
739
+ domainId?: number
740
+ ): Array<Omit<VerticalSliceRecord, "id" | "created_at">> {
741
+ const commandsDir = path.join(root, "app/Console/Commands");
742
+ if (!fs.existsSync(commandsDir)) return [];
743
+
744
+ const files = fs.readdirSync(commandsDir).filter((f) => f.endsWith(".php"));
745
+ const slices: Array<Omit<VerticalSliceRecord, "id" | "created_at">> = [];
746
+
747
+ for (const file of files) {
748
+ const fullPath = path.join(commandsDir, file);
749
+ const content = fs.readFileSync(fullPath, "utf-8");
750
+ const classMatch = content.match(/class\s+([A-Za-z0-9_]+)/);
751
+ if (!classMatch) continue;
752
+
753
+ const className = classMatch[1];
754
+ const sigMatch = content.match(/protected\s+\$signature\s*=\s*['"]([^'"]+)['"]/);
755
+ const commandSignature = sigMatch ? sigMatch[1].split(/\s+/)[0] : this.toSnakeCase(className);
756
+
757
+ const actionBody = this.extractActionBody(content, "handle");
758
+ const behavior = BehavioralAnalyzer.analyzeMethod(actionBody);
759
+
760
+ const chain: ExecutionChainNode[] = [
761
+ {
762
+ stage: "ingress",
763
+ symbol: `CLI ${commandSignature}`,
764
+ file: path.relative(this.projectRoot, fullPath),
765
+ description: `Console Command: ${commandSignature}`,
766
+ },
767
+ {
768
+ stage: "controller",
769
+ symbol: `${className}::handle`,
770
+ file: path.relative(this.projectRoot, fullPath),
771
+ description: "Command Execution Handler",
772
+ mutations: behavior.mutations.length ? behavior.mutations : undefined,
773
+ transactions: behavior.hasTransaction,
774
+ },
775
+ ];
776
+
777
+ if (behavior.hasTransaction) {
778
+ chain.push({
779
+ stage: "transaction",
780
+ symbol: "DB::transaction",
781
+ description: "Database Transaction Scope",
782
+ transactions: true,
783
+ });
784
+ }
785
+
786
+ for (const job of behavior.dispatchedJobs) {
787
+ const jobFile = this.findClassFile("Jobs", job);
788
+ chain.push({
789
+ stage: "job",
790
+ symbol: job,
791
+ file: jobFile ? path.relative(this.projectRoot, jobFile) : undefined,
792
+ description: "Dispatched Background Worker",
793
+ dispatches: [job],
794
+ });
795
+ }
796
+
797
+ slices.push({
798
+ domain_id: domainId ?? null,
799
+ feature_key: `command:${commandSignature}`,
800
+ http_method: "CLI",
801
+ route_uri: `command:${commandSignature}`,
802
+ route_name: `cli.${commandSignature}`,
803
+ controller_class: className,
804
+ action_name: "handle",
805
+ controller_file: path.relative(this.projectRoot, fullPath),
806
+ controller_line: 1,
807
+ architecture_style: "clean",
808
+ entry_kind: "scheduled_command",
809
+ execution_chain_json: JSON.stringify(chain),
810
+ });
811
+ }
812
+
813
+ return slices;
814
+ }
815
+
816
+ private extractListenerSlices(
817
+ root: string,
818
+ domainId?: number
819
+ ): Array<Omit<VerticalSliceRecord, "id" | "created_at">> {
820
+ const listenersDir = path.join(root, "app/Listeners");
821
+ if (!fs.existsSync(listenersDir)) return [];
822
+
823
+ const files = fs.readdirSync(listenersDir).filter((f) => f.endsWith(".php"));
824
+ const slices: Array<Omit<VerticalSliceRecord, "id" | "created_at">> = [];
825
+
826
+ for (const file of files) {
827
+ const fullPath = path.join(listenersDir, file);
828
+ const content = fs.readFileSync(fullPath, "utf-8");
829
+ const classMatch = content.match(/class\s+([A-Za-z0-9_]+)/);
830
+ if (!classMatch) continue;
831
+
832
+ const className = classMatch[1];
833
+ const actionBody = this.extractActionBody(content, "handle");
834
+ const behavior = BehavioralAnalyzer.analyzeMethod(actionBody);
835
+
836
+ const eventParamMatch = content.match(/public\s+function\s+handle\s*\(\s*([A-Za-z0-9_]+)\s+\$event\s*\)/i);
837
+ const listenedEvent = eventParamMatch ? eventParamMatch[1] : undefined;
838
+
839
+ const chain: ExecutionChainNode[] = [
840
+ {
841
+ stage: "ingress",
842
+ symbol: `EVENT ${listenedEvent || className}`,
843
+ file: path.relative(this.projectRoot, fullPath),
844
+ description: `Event Listener Trigger${listenedEvent ? ` for [${listenedEvent}]` : ""}`,
845
+ },
846
+ {
847
+ stage: "controller",
848
+ symbol: `${className}::handle`,
849
+ file: path.relative(this.projectRoot, fullPath),
850
+ description: "Event Listener Handler",
851
+ mutations: behavior.mutations.length ? behavior.mutations : undefined,
852
+ transactions: behavior.hasTransaction,
853
+ },
854
+ ];
855
+
856
+ if (behavior.hasTransaction) {
857
+ chain.push({
858
+ stage: "transaction",
859
+ symbol: "DB::transaction",
860
+ description: "Database Transaction Scope",
861
+ transactions: true,
862
+ });
863
+ }
864
+
865
+ for (const job of behavior.dispatchedJobs) {
866
+ const jobFile = this.findClassFile("Jobs", job);
867
+ chain.push({
868
+ stage: "job",
869
+ symbol: job,
870
+ file: jobFile ? path.relative(this.projectRoot, jobFile) : undefined,
871
+ description: "Dispatched Background Worker",
872
+ dispatches: [job],
873
+ });
874
+ }
875
+
876
+ slices.push({
877
+ domain_id: domainId ?? null,
878
+ feature_key: `listener:${className}`,
879
+ http_method: "EVENT",
880
+ route_uri: `event:${className}`,
881
+ route_name: `event.${this.toSnakeCase(className)}`,
882
+ controller_class: className,
883
+ action_name: "handle",
884
+ controller_file: path.relative(this.projectRoot, fullPath),
885
+ controller_line: 1,
886
+ architecture_style: "clean",
887
+ entry_kind: "event_listener",
888
+ execution_chain_json: JSON.stringify(chain),
889
+ });
890
+ }
891
+
892
+ return slices;
893
+ }
894
+
895
+ private toSnakeCase(str: string): string {
896
+ return str
897
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
898
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
899
+ .toLowerCase();
900
+ }
426
901
  }
@@ -1,5 +1,5 @@
1
1
  import type { ExtractedDependency, ExtractedSymbol, ParsedFileAST, Visibility } from "../../../types/index.ts";
2
- import { findBraceBlockEnd } from "../boundary-tracker.ts";
2
+ import { findBraceBlockEnd, calculateControlFlowNesting } from "../boundary-tracker.ts";
3
3
  import type { CodeExtractor } from "./base.ts";
4
4
 
5
5
  export class PHPExtractor implements CodeExtractor {
@@ -112,6 +112,7 @@ export class PHPExtractor implements CodeExtractor {
112
112
  const params = methodMatch[3].trim();
113
113
  const retType = methodMatch[4] ? `: ${methodMatch[4].trim()}` : "";
114
114
  const endLine = findBraceBlockEnd(lines, i);
115
+ const nestingDepth = calculateControlFlowNesting(lines, i, endLine - 1);
115
116
  symbols.push({
116
117
  name: `${currentContainer}::${name}`,
117
118
  kind: "method",
@@ -120,6 +121,7 @@ export class PHPExtractor implements CodeExtractor {
120
121
  line_start: lineNum,
121
122
  line_end: endLine,
122
123
  line_count: Math.max(1, endLine - lineNum + 1),
124
+ nesting_depth: nestingDepth,
123
125
  });
124
126
  continue;
125
127
  }
@@ -132,6 +134,7 @@ export class PHPExtractor implements CodeExtractor {
132
134
  const params = funcMatch[2].trim();
133
135
  const retType = funcMatch[3] ? `: ${funcMatch[3].trim()}` : "";
134
136
  const endLine = findBraceBlockEnd(lines, i);
137
+ const nestingDepth = calculateControlFlowNesting(lines, i, endLine - 1);
135
138
  symbols.push({
136
139
  name,
137
140
  kind: "function",
@@ -140,6 +143,7 @@ export class PHPExtractor implements CodeExtractor {
140
143
  line_start: lineNum,
141
144
  line_end: endLine,
142
145
  line_count: Math.max(1, endLine - lineNum + 1),
146
+ nesting_depth: nestingDepth,
143
147
  });
144
148
  }
145
149
  }
@@ -1,5 +1,5 @@
1
1
  import type { ExtractedDependency, ExtractedSymbol, ParsedFileAST, Visibility } from "../../../types/index.ts";
2
- import { findPythonBlockEnd } from "../boundary-tracker.ts";
2
+ import { findPythonBlockEnd, calculatePythonNesting } from "../boundary-tracker.ts";
3
3
  import type { CodeExtractor } from "./base.ts";
4
4
 
5
5
  const PYTHON_STDLIB_AND_EXTERNALS = new Set([
@@ -206,6 +206,7 @@ export class PythonExtractor implements CodeExtractor {
206
206
  : "public";
207
207
 
208
208
  const endLine = findPythonBlockEnd(lines, i);
209
+ const nestingDepth = calculatePythonNesting(lines, i, endLine - 1);
209
210
  symbols.push({
210
211
  name: isMethod ? `${currentClass}::${funcName}` : funcName,
211
212
  kind: isMethod ? "method" : "function",
@@ -214,6 +215,7 @@ export class PythonExtractor implements CodeExtractor {
214
215
  line_start: lineNum,
215
216
  line_end: endLine,
216
217
  line_count: Math.max(1, endLine - lineNum + 1),
218
+ nesting_depth: nestingDepth,
217
219
  });
218
220
  }
219
221
  }