septum 0.1.2 → 0.1.3

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.
@@ -186,33 +186,12 @@ export class LaravelSemanticExtractor implements SemanticSliceExtractor {
186
186
  public parseRoutesContent(content: string): ParsedRoute[] {
187
187
  const routes: ParsedRoute[] = [];
188
188
 
189
- // Match Route::(get|post|put|patch|delete)('uri', [Controller::class, 'action'])->name('...')
190
- const methodRegex = /Route::(get|post|put|patch|delete|any)\s*\(\s*['"]([^'"]+)['"]\s*,\s*(?:\[\s*([A-Za-z0-9_]+)::class\s*,\s*['"]([A-Za-z0-9_]+)['"]\s*\]|['"]([A-Za-z0-9_]+)@([A-Za-z0-9_]+)['"])\s*\)(?:\s*->\s*name\s*\(\s*['"]([^'"]+)['"]\s*\))?/gi;
191
-
192
- let match;
193
- while ((match = methodRegex.exec(content)) !== null) {
194
- const httpMethod = match[1];
195
- const uri = match[2];
196
- const controllerClass = match[3] || match[5];
197
- const actionName = match[4] || match[6];
198
- const routeName = match[7];
199
-
200
- if (controllerClass && actionName) {
201
- routes.push({
202
- httpMethod,
203
- uri,
204
- routeName,
205
- controllerClass,
206
- actionName,
207
- });
208
- }
209
- }
210
-
211
- // Match Route::resource('orders', OrderController::class)
212
- const resourceRegex = /Route::resource\s*\(\s*['"]([^'"]+)['"]\s*,\s*([A-Za-z0-9_]+)::class\s*\)/gi;
213
- while ((match = resourceRegex.exec(content)) !== null) {
214
- const baseUri = match[1];
215
- const controllerClass = match[2];
189
+ // 1. Process Route::resource
190
+ const resourceRegex = /Route::resource\s*\(\s*['"]([^'"]+)['"]\s*,\s*([\\A-Za-z0-9_]+)::class\s*\)/gi;
191
+ let resMatch;
192
+ while ((resMatch = resourceRegex.exec(content)) !== null) {
193
+ const baseUri = resMatch[1].replace(/^\//, "");
194
+ const controllerClass = resMatch[2];
216
195
  const resourceActions = [
217
196
  { method: "GET", sub: "", action: "index" },
218
197
  { method: "GET", sub: "/create", action: "create" },
@@ -234,9 +213,189 @@ export class LaravelSemanticExtractor implements SemanticSliceExtractor {
234
213
  }
235
214
  }
236
215
 
216
+ // 2. Parse routes with group context tracking (prefix, controller, name prefix)
217
+ const lines = content.split(/\r?\n/);
218
+ interface GroupContext {
219
+ prefix: string;
220
+ controller: string;
221
+ namePrefix: string;
222
+ braceDepth: number;
223
+ }
224
+
225
+ const groupStack: GroupContext[] = [];
226
+ let currentBraceDepth = 0;
227
+ let statementBuffer = "";
228
+ let inStatement = false;
229
+
230
+ for (let i = 0; i < lines.length; i++) {
231
+ const line = lines[i];
232
+ const trimmed = line.trim();
233
+
234
+ // Check group opens: Route::...group(
235
+ if (trimmed.includes("Route::") && trimmed.includes("group(") && trimmed.includes("function")) {
236
+ const prefixMatch = /prefix\s*\(\s*['"]([^'"]+)['"]\s*\)/.exec(trimmed);
237
+ const controllerMatch = /controller\s*\(\s*([\\A-Za-z0-9_]+)::class\s*\)/.exec(trimmed);
238
+ const nameMatch = /name\s*\(\s*['"]([^'"]+)['"]\s*\)/.exec(trimmed);
239
+
240
+ const currentCtx = groupStack[groupStack.length - 1];
241
+ const newPrefix = [currentCtx?.prefix, prefixMatch ? prefixMatch[1] : ""]
242
+ .filter(Boolean)
243
+ .join("/");
244
+ const newController = controllerMatch ? controllerMatch[1] : (currentCtx?.controller || "");
245
+ const newNamePrefix = [currentCtx?.namePrefix, nameMatch ? nameMatch[1] : ""]
246
+ .filter(Boolean)
247
+ .join("");
248
+
249
+ groupStack.push({
250
+ prefix: newPrefix,
251
+ controller: newController,
252
+ namePrefix: newNamePrefix,
253
+ braceDepth: currentBraceDepth,
254
+ });
255
+ }
256
+
257
+ // Track brace depth
258
+ for (const char of line) {
259
+ if (char === "{") currentBraceDepth++;
260
+ if (char === "}") {
261
+ currentBraceDepth--;
262
+ if (groupStack.length > 0) {
263
+ const top = groupStack[groupStack.length - 1];
264
+ if (currentBraceDepth <= top.braceDepth) {
265
+ groupStack.pop();
266
+ }
267
+ }
268
+ }
269
+ }
270
+
271
+ // Route statement accumulation across lines
272
+ if (!inStatement && trimmed.startsWith("Route::") && !trimmed.includes("Route::resource") && !trimmed.includes("Route::group")) {
273
+ statementBuffer = trimmed;
274
+ inStatement = true;
275
+ } else if (inStatement) {
276
+ statementBuffer += " " + trimmed;
277
+ }
278
+
279
+ if (inStatement && statementBuffer.includes(";")) {
280
+ inStatement = false;
281
+ const stmt = statementBuffer;
282
+ statementBuffer = "";
283
+
284
+ const currentCtx = groupStack[groupStack.length - 1];
285
+ const parsed = this.parseSingleRouteStatement(stmt, currentCtx);
286
+ if (parsed) {
287
+ routes.push(parsed);
288
+ }
289
+ }
290
+ }
291
+
237
292
  return routes;
238
293
  }
239
294
 
295
+ private parseSingleRouteStatement(
296
+ stmt: string,
297
+ ctx?: { prefix: string; controller: string; namePrefix: string }
298
+ ): ParsedRoute | null {
299
+ const methodMatch = /Route::(get|post|put|patch|delete|any|options)\s*\(\s*['"]([^'"]+)['"]([\s\S]*)/i.exec(stmt);
300
+ if (!methodMatch) {
301
+ const viewMatch = /Route::view\s*\(\s*['"]([^'"]+)['"]\s*,\s*['"]([^'"]+)['"]/i.exec(stmt);
302
+ if (viewMatch) {
303
+ const rawUri = viewMatch[1];
304
+ const viewTarget = viewMatch[2];
305
+ const nameMatch = /->name\s*\(\s*['"]([^'"]+)['"]\s*\)/i.exec(stmt);
306
+ const fullUri = ctx?.prefix ? `/${ctx.prefix.replace(/^\/|\/$/g, "")}/${rawUri.replace(/^\//, "")}` : rawUri;
307
+ return {
308
+ httpMethod: "GET",
309
+ uri: fullUri.startsWith("/") ? fullUri : `/${fullUri}`,
310
+ routeName: (ctx?.namePrefix || "") + (nameMatch ? nameMatch[1] : ""),
311
+ controllerClass: "View",
312
+ actionName: viewTarget,
313
+ };
314
+ }
315
+ return null;
316
+ }
317
+
318
+ const httpMethod = methodMatch[1].toUpperCase();
319
+ let uri = methodMatch[2];
320
+ const rest = methodMatch[3];
321
+
322
+ if (ctx?.prefix) {
323
+ const cleanPrefix = ctx.prefix.replace(/^\/|\/$/g, "");
324
+ const cleanUri = uri.replace(/^\//, "");
325
+ uri = cleanUri ? `/${cleanPrefix}/${cleanUri}` : `/${cleanPrefix}`;
326
+ }
327
+ if (!uri.startsWith("/")) uri = `/${uri}`;
328
+
329
+ const nameMatch = /->name\s*\(\s*['"]([^'"]+)['"]\s*\)/i.exec(stmt);
330
+ const rawRouteName = nameMatch ? nameMatch[1] : undefined;
331
+ const fullRouteName = rawRouteName
332
+ ? (ctx?.namePrefix || "") + rawRouteName
333
+ : undefined;
334
+
335
+ // [Controller::class, 'action']
336
+ const arrayMatch = /\[\s*([\\A-Za-z0-9_]+)::class\s*,\s*['"]([A-Za-z0-9_]+)['"]\s*\]/.exec(rest);
337
+ if (arrayMatch) {
338
+ return {
339
+ httpMethod,
340
+ uri,
341
+ routeName: fullRouteName,
342
+ controllerClass: arrayMatch[1],
343
+ actionName: arrayMatch[2],
344
+ };
345
+ }
346
+
347
+ // 'Controller@action'
348
+ const stringActionMatch = /['"]([\\A-Za-z0-9_]+)@([A-Za-z0-9_]+)['"]/.exec(rest);
349
+ if (stringActionMatch) {
350
+ return {
351
+ httpMethod,
352
+ uri,
353
+ routeName: fullRouteName,
354
+ controllerClass: stringActionMatch[1],
355
+ actionName: stringActionMatch[2],
356
+ };
357
+ }
358
+
359
+ // Action inside controller group
360
+ if (ctx?.controller) {
361
+ const singleActionMatch = /,\s*['"]([A-Za-z0-9_]+)['"]/.exec(rest);
362
+ if (singleActionMatch) {
363
+ return {
364
+ httpMethod,
365
+ uri,
366
+ routeName: fullRouteName,
367
+ controllerClass: ctx.controller,
368
+ actionName: singleActionMatch[1],
369
+ };
370
+ }
371
+ }
372
+
373
+ // Invokable controller
374
+ const invokableMatch = /,\s*([\\A-Za-z0-9_]+)::class/.exec(rest);
375
+ if (invokableMatch) {
376
+ return {
377
+ httpMethod,
378
+ uri,
379
+ routeName: fullRouteName,
380
+ controllerClass: invokableMatch[1],
381
+ actionName: "__invoke",
382
+ };
383
+ }
384
+
385
+ // Closure
386
+ if (rest.includes("function")) {
387
+ return {
388
+ httpMethod,
389
+ uri,
390
+ routeName: fullRouteName,
391
+ controllerClass: "Closure",
392
+ actionName: "handle",
393
+ };
394
+ }
395
+
396
+ return null;
397
+ }
398
+
240
399
  private resolveController(controllerClass: string, actionName: string): { filePath?: string; line: number } {
241
400
  const simpleName = controllerClass.split(/\\|\//).pop() || controllerClass;
242
401
  const candidates = [
@@ -46,4 +46,25 @@ export class SemanticSliceExtractorRegistry {
46
46
 
47
47
  return combinedSlices;
48
48
  }
49
+
50
+ /**
51
+ * Synchronous execution of all matched semantic extractors.
52
+ */
53
+ public static extractAllSlicesSync(
54
+ projectRoot: string,
55
+ framework?: string,
56
+ domainId?: number
57
+ ): VerticalSliceCandidate[] {
58
+ const matched = this.getExtractors(projectRoot, framework);
59
+ const combinedSlices: VerticalSliceCandidate[] = [];
60
+
61
+ for (const extractor of matched) {
62
+ const res = extractor.extractSlices(projectRoot, domainId);
63
+ if (Array.isArray(res)) {
64
+ combinedSlices.push(...res);
65
+ }
66
+ }
67
+
68
+ return combinedSlices;
69
+ }
49
70
  }
@@ -1,5 +1,6 @@
1
1
  import type { ParsedFileAST } from "../../types/index.ts";
2
2
  import type { CodeExtractor } from "./extractors/base.ts";
3
+ import { BladeExtractor } from "./extractors/blade.ts";
3
4
  import { FrontendExtractor } from "./extractors/frontend.ts";
4
5
  import { GoExtractor } from "./extractors/go.ts";
5
6
  import { PHPExtractor } from "./extractors/php.ts";
@@ -11,6 +12,7 @@ export class ASTParserEngine {
11
12
 
12
13
  constructor() {
13
14
  this.extractors = [
15
+ new BladeExtractor(),
14
16
  new FrontendExtractor(),
15
17
  new TypeScriptExtractor(),
16
18
  new PHPExtractor(),
@@ -0,0 +1,147 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, normalize, resolve } from "node:path";
3
+
4
+ export const PROJECT_ROOT_MARKERS = [
5
+ "package.json",
6
+ "composer.json",
7
+ "go.mod",
8
+ "pyproject.toml",
9
+ "Cargo.toml",
10
+ ".git",
11
+ ".septum",
12
+ "pnpm-workspace.yaml",
13
+ "lerna.json",
14
+ "tsconfig.json",
15
+ "jsconfig.json",
16
+ ];
17
+
18
+ let lastKnownProjectRoot: string | undefined;
19
+
20
+ /**
21
+ * Normalizes and canonicalizes a path for cross-platform consistency.
22
+ * On Windows, uppercases drive letters (e.g. "c:\" -> "C:\") to eliminate map lookup misses.
23
+ */
24
+ export function canonicalizePath(targetPath: string): string {
25
+ if (!targetPath || typeof targetPath !== "string") {
26
+ return targetPath;
27
+ }
28
+ let abs = normalize(resolve(targetPath));
29
+ if (process.platform === "win32") {
30
+ abs = abs.replace(/^[a-zA-Z]:/, (m) => m.toUpperCase());
31
+ }
32
+ return abs;
33
+ }
34
+
35
+ export function setLastKnownProjectRoot(root: string): void {
36
+ if (root && typeof root === "string") {
37
+ lastKnownProjectRoot = canonicalizePath(root);
38
+ }
39
+ }
40
+
41
+ export function getLastKnownProjectRoot(): string | undefined {
42
+ return lastKnownProjectRoot;
43
+ }
44
+
45
+ export function clearLastKnownProjectRoot(): void {
46
+ lastKnownProjectRoot = undefined;
47
+ }
48
+
49
+ /**
50
+ * Checks whether a given directory is an MCP host / IDE installation directory
51
+ * (e.g. Antigravity Electron install directory) rather than a user project codebase.
52
+ */
53
+ export function isAppHostDirectory(dirPath: string): boolean {
54
+ try {
55
+ const norm = dirPath.toLowerCase().replace(/\\/g, "/");
56
+ if (norm.includes("/antigravity") || norm.includes("/appdata/local/programs/antigravity")) {
57
+ // Check if it has Electron app runtime signatures
58
+ if (
59
+ existsSync(join(dirPath, "resources", "app.asar")) ||
60
+ existsSync(join(dirPath, "resources", "app.asar.unpacked")) ||
61
+ existsSync(join(dirPath, "locales"))
62
+ ) {
63
+ return true;
64
+ }
65
+ }
66
+ } catch {
67
+ // Ignore error
68
+ }
69
+ return false;
70
+ }
71
+
72
+ /**
73
+ * Discovers the project root directory by walking up looking for marker files.
74
+ */
75
+ export function findProjectRoot(fromPath: string): string {
76
+ try {
77
+ let dir = resolve(fromPath);
78
+
79
+ if (existsSync(dir)) {
80
+ const stat = statSync(dir);
81
+ if (!stat.isDirectory()) {
82
+ dir = dirname(dir);
83
+ }
84
+ } else {
85
+ dir = dirname(dir);
86
+ }
87
+
88
+ let current = dir;
89
+ while (true) {
90
+ // Don't treat an app host directory as a project root
91
+ if (!isAppHostDirectory(current)) {
92
+ for (const marker of PROJECT_ROOT_MARKERS) {
93
+ if (existsSync(join(current, marker))) {
94
+ return canonicalizePath(current);
95
+ }
96
+ }
97
+ }
98
+
99
+ const parent = dirname(current);
100
+ if (parent === current) break;
101
+ current = parent;
102
+ }
103
+ } catch {
104
+ // Filesystem traversal error fallback
105
+ }
106
+
107
+ return canonicalizePath(dirname(resolve(fromPath)));
108
+ }
109
+
110
+ /**
111
+ * Ergonomically resolves the active workspace root:
112
+ * 1. Explicit pathHint (if provided and valid).
113
+ * 2. SEPTUM_WORKSPACE_ROOT environment variable.
114
+ * 3. lastKnownProjectRoot (if cached from earlier tool calls).
115
+ * 4. findProjectRoot from process.cwd() (if not an app host directory).
116
+ */
117
+ export function resolveWorkspaceRoot(pathHint?: string): string {
118
+ if (pathHint && typeof pathHint === "string" && pathHint.trim()) {
119
+ const trimmed = pathHint.trim().replace(/^['"]|['"]$/g, "");
120
+ const candidate = findProjectRoot(trimmed);
121
+ if (candidate && existsSync(candidate) && !isAppHostDirectory(candidate)) {
122
+ setLastKnownProjectRoot(candidate);
123
+ return candidate;
124
+ }
125
+ }
126
+
127
+ const envRoot = process.env.SEPTUM_WORKSPACE_ROOT || process.env.WORKSPACE_ROOT;
128
+ if (envRoot && existsSync(envRoot) && !isAppHostDirectory(envRoot)) {
129
+ const candidate = canonicalizePath(envRoot);
130
+ setLastKnownProjectRoot(candidate);
131
+ return candidate;
132
+ }
133
+
134
+ if (lastKnownProjectRoot && existsSync(lastKnownProjectRoot)) {
135
+ return lastKnownProjectRoot;
136
+ }
137
+
138
+ const cwd = process.cwd();
139
+ if (!isAppHostDirectory(cwd)) {
140
+ const candidate = findProjectRoot(cwd);
141
+ setLastKnownProjectRoot(candidate);
142
+ return candidate;
143
+ }
144
+
145
+ // Fallback if inside app host directory: keep looking in lastKnown or return cwd
146
+ return canonicalizePath(cwd);
147
+ }
@@ -5,9 +5,11 @@ import type {
5
5
  } from "../../types/index.ts";
6
6
  import type { SeptumRepository } from "../database/repository.ts";
7
7
  import { CallGraphTracer } from "./call-graph-tracer.ts";
8
+ import { SemanticSliceExtractorRegistry } from "../parser/extractors/registry.ts";
9
+ import { TopologyDetector } from "../discovery/topology-detector.ts";
8
10
 
9
11
  export class VerticalSliceTracer {
10
- constructor(private repo: SeptumRepository) {}
12
+ constructor(private repo: SeptumRepository, private projectRoot: string = process.cwd()) {}
11
13
 
12
14
  /**
13
15
  * Trace an end-to-end vertical slice from route/action/intent query.
@@ -25,16 +27,29 @@ export class VerticalSliceTracer {
25
27
  const lowerTrimmed = trimmed.toLowerCase();
26
28
  let allSlices = this.repo.getAllVerticalSlices();
27
29
 
28
- // Lazy fallback: If no slices cataloged, attempt heuristic call-graph tracing
29
- if (allSlices.length === 0) {
30
+ // Lazy on-demand fallback: If no slices cataloged, extract framework routes or trace call-graph
31
+ const hasCatalogData = typeof (this.repo as any).getAllDomains === "function" && this.repo.getAllDomains().length > 0;
32
+ if (allSlices.length === 0 && hasCatalogData) {
30
33
  try {
31
- const tracer = new CallGraphTracer(this.repo);
32
- const traced = tracer.traceAllSlices();
33
- if (traced.length > 0) {
34
- for (const s of traced) {
34
+ const topology = TopologyDetector.detect(this.projectRoot);
35
+ const semanticSlices = SemanticSliceExtractorRegistry.extractAllSlicesSync(
36
+ this.projectRoot,
37
+ topology.framework
38
+ );
39
+ if (semanticSlices.length > 0) {
40
+ for (const s of semanticSlices) {
35
41
  this.repo.upsertVerticalSlice(s);
36
42
  }
37
43
  allSlices = this.repo.getAllVerticalSlices();
44
+ } else {
45
+ const tracer = new CallGraphTracer(this.repo, this.projectRoot);
46
+ const traced = tracer.traceAllSlices();
47
+ if (traced.length > 0) {
48
+ for (const s of traced) {
49
+ this.repo.upsertVerticalSlice(s);
50
+ }
51
+ allSlices = this.repo.getAllVerticalSlices();
52
+ }
38
53
  }
39
54
  } catch (err: unknown) {
40
55
  const msg = err instanceof Error ? err.message : String(err);