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.
- package/README.md +7 -2
- package/package.json +1 -1
- package/src/cli/commands/slice.ts +4 -46
- package/src/cli/commands/topology.ts +119 -0
- package/src/cli/index.ts +11 -0
- package/src/cli/templates/skill.ts +7 -4
- package/src/core/config/loader.ts +21 -8
- package/src/core/database/repositories/dependency.repository.ts +70 -0
- package/src/core/database/repositories/environment.repository.ts +207 -0
- package/src/core/database/repositories/file.repository.ts +7 -0
- package/src/core/database/repositories/symbol.repository.ts +28 -9
- package/src/core/database/repository.ts +19 -1
- package/src/core/database/schema.ts +153 -120
- package/src/core/discovery/topology-detector.ts +71 -3
- package/src/core/ingestion/pipeline.ts +98 -1
- package/src/core/parser/behavioral-analyzer.ts +119 -0
- package/src/core/parser/boundary-tracker.ts +160 -0
- package/src/core/parser/extractors/base.ts +2 -0
- package/src/core/parser/extractors/blade.ts +158 -0
- package/src/core/parser/extractors/go.ts +5 -1
- package/src/core/parser/extractors/laravel-semantic.ts +506 -31
- package/src/core/parser/extractors/php.ts +5 -1
- package/src/core/parser/extractors/python.ts +3 -1
- package/src/core/parser/extractors/registry.ts +21 -0
- package/src/core/parser/extractors/typescript.ts +5 -1
- package/src/core/parser/schema/providers/laravel-migration-provider.ts +215 -0
- package/src/core/parser/schema/providers/prisma-schema-provider.ts +89 -0
- package/src/core/parser/schema/schema-provider-registry.ts +43 -0
- package/src/core/parser/schema/schema-provider.interface.ts +33 -0
- package/src/core/parser/tree-sitter.ts +2 -0
- package/src/core/resolver/path-resolver.ts +147 -0
- package/src/core/resolver/vertical-slice-tracer.ts +56 -8
- package/src/core/topology/environment-detector.ts +860 -0
- package/src/core/topology/types.ts +83 -0
- package/src/index.ts +3 -0
- package/src/mcp/schemas.ts +264 -46
- package/src/mcp/server.ts +130 -26
- package/src/mcp/tools/check-boundary.ts +58 -8
- package/src/mcp/tools/get-domain-catalog.ts +31 -1
- package/src/mcp/tools/get-environment-topology.ts +141 -0
- package/src/mcp/tools/get-symbol-hotspots.ts +13 -3
- package/src/mcp/tools/locate-symbol.ts +119 -3
- package/src/mcp/tools/register-domain.ts +1 -1
- package/src/mcp/tools/trace-vertical-slice.ts +11 -65
- package/src/types/index.ts +20 -1
- 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:
|
|
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
|
-
//
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const
|
|
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(
|
|
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
|
}
|