septum 0.1.1 → 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(),
@@ -201,23 +201,42 @@ export class ModuleResolver {
201
201
  return null;
202
202
  }
203
203
 
204
+ public toCanonicalPath(filePath: string): string {
205
+ let resolved = path.isAbsolute(filePath)
206
+ ? path.normalize(filePath)
207
+ : path.resolve(this.projectRoot, filePath);
208
+
209
+ if (process.platform === "win32") {
210
+ // Normalize drive letter to uppercase (e.g. c:\ -> C:\)
211
+ resolved = resolved.replace(/^[a-zA-Z]:/, (m) => m.toUpperCase());
212
+ }
213
+ return resolved;
214
+ }
215
+
204
216
  private isSubPathOrSame(targetPath: string, rootPath: string): boolean {
205
- const normalizedTarget = path.normalize(targetPath);
206
- const normalizedRoot = path.normalize(rootPath);
217
+ const canonicalTarget = this.toCanonicalPath(targetPath);
218
+ const canonicalRoot = this.toCanonicalPath(rootPath);
219
+
220
+ if (process.platform === "win32") {
221
+ const lowerTarget = canonicalTarget.toLowerCase();
222
+ const lowerRoot = canonicalRoot.toLowerCase();
223
+ if (lowerTarget === lowerRoot) return true;
224
+ const rootWithSep = lowerRoot.endsWith(path.sep)
225
+ ? lowerRoot
226
+ : lowerRoot + path.sep;
227
+ return lowerTarget.startsWith(rootWithSep);
228
+ }
207
229
 
208
- if (normalizedTarget === normalizedRoot) return true;
209
- const rootWithSep = normalizedRoot.endsWith(path.sep)
210
- ? normalizedRoot
211
- : normalizedRoot + path.sep;
230
+ if (canonicalTarget === canonicalRoot) return true;
231
+ const rootWithSep = canonicalRoot.endsWith(path.sep)
232
+ ? canonicalRoot
233
+ : canonicalRoot + path.sep;
212
234
 
213
- return normalizedTarget.startsWith(rootWithSep);
235
+ return canonicalTarget.startsWith(rootWithSep);
214
236
  }
215
237
 
216
238
  private toAbsolutePath(filePath: string): string {
217
- if (path.isAbsolute(filePath)) {
218
- return path.normalize(filePath);
219
- }
220
- return path.resolve(this.projectRoot, filePath);
239
+ return this.toCanonicalPath(filePath);
221
240
  }
222
241
 
223
242
  private extractSymbolName(target: string): string | null {
@@ -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
+ }