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.
package/README.md CHANGED
@@ -61,6 +61,8 @@ Add Septum to your MCP client (`mcp.json` or `claude_desktop_config.json`):
61
61
  | `septum sync` | Fast incremental delta sync (< 15ms) for modified files |
62
62
  | `septum check --strict` | CI/CD gate: verify boundary integrity & fail on violations |
63
63
  | `septum query [domain]` | Inspect domain catalog, archetypes, and public signatures |
64
+ | `septum locate <query>` | Locate symbol, class, interface, method, or error source |
65
+ | `septum slice <route\|intent>` | Trace end-to-end vertical execution slice for route/intent |
64
66
  | `septum feature status\|clear` | Manage active feature lease and editing scope |
65
67
  | `septum serve` | Launch stdio MCP server for AI coding agents |
66
68
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "septum",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Deterministic Bounded-Context, Structural File Catalog & Anti-Poisoning Partition for AI Agents",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -25,11 +25,14 @@ Skill ini berakar langsung pada riset rekayasa perangkat lunak terkemuka:
25
25
 
26
26
  ## 2. Kapan Wajib Menggunakan (Trigger Moments)
27
27
 
28
- Gunakan skill ini saat:
29
- 1. Menghubungkan Septum pertama kali ke repositori baru atau codebase yang belum terpetakan.
28
+ > [!NOTE] Zero-Config Default (\`septum init\`)
29
+ > Untuk mayoritas proyek (library kecil/menengah, layered monolith standar, MVC, CLI, MCP tools), pengguna atau agen **cukup menjalankan \`septum init\`** di terminal. Perintah \`septum init\` secara default berjalan 100% otomatis tanpa wawancara manual (zero-config heuristic auto-detect & auto-ingest).
30
+
31
+ Gunakan alur 4 langkah skill ini HANYA saat:
32
+ 1. Memetakan domain bisnis kustom / modular DDD enterprise yang kompleks di mana batasan dependency non-standar perlu disepakati bersama Tech Lead.
30
33
  2. Memetakan ulang domain bisnis setelah terjadi refactoring arsitektur besar (*re-mapping*).
31
- 3. Mengonfigurasi fitur baru (\`features\` session) untuk mengunci daftar file yang boleh dimodifikasi (*allowed_touchpoints*) dan simbol yang wajib dipakai ulang (*reuse_symbols*).
32
- 4. Pengguna meminta inisialisasi cerdas berbasis pemahaman AI pada struktur proyek.
34
+ 3. Mengonfigurasi sesi fitur baru (\`features\` session) untuk mengunci daftar file yang boleh dimodifikasi (*allowed_touchpoints*) dan simbol yang wajib dipakai ulang (*reuse_symbols*).
35
+ 4. Pengguna secara eksplisit meminta konsultasi desain domain boundary interaktif.
33
36
 
34
37
  ---
35
38
 
@@ -1,31 +1,44 @@
1
1
  import { existsSync } from "node:fs";
2
+ import { isAbsolute, join } from "node:path";
2
3
  import { Database } from "bun:sqlite";
3
4
  import type { ValidatedSeptumConfig } from "./schema.ts";
4
5
  import { TopologyDetector } from "../discovery/topology-detector.ts";
5
6
  import { SeptumDatabase } from "../database/client.ts";
6
7
  import { SeptumRepository } from "../database/repository.ts";
8
+ import { resolveWorkspaceRoot, isAppHostDirectory } from "../resolver/path-resolver.ts";
7
9
 
8
10
  export class ConfigLoader {
9
11
  /**
10
12
  * Loads configuration with SQLite as the Single Source of Truth (SSOT).
11
13
  * Automatically executes Zero-Config TopologyDetector if the database has no domains.
12
14
  */
13
- public static load(dbPath: string = ".septum/septum.db"): ValidatedSeptumConfig {
15
+ public static load(
16
+ dbPath?: string,
17
+ projectRoot?: string
18
+ ): ValidatedSeptumConfig {
19
+ const root = resolveWorkspaceRoot(projectRoot);
20
+ const resolvedDbPath = dbPath
21
+ ? (isAbsolute(dbPath) ? dbPath : join(root, dbPath))
22
+ : join(root, ".septum", "septum.db");
23
+
14
24
  // 1. If database exists and has registered domains, load directly from SQLite SSOT
15
- if (existsSync(dbPath)) {
16
- const dbConfig = ConfigLoader.loadFromDatabaseOrDefaults(dbPath);
25
+ if (existsSync(resolvedDbPath)) {
26
+ const dbConfig = ConfigLoader.loadFromDatabaseOrDefaults(resolvedDbPath);
17
27
  if (Object.keys(dbConfig.domains).length > 0) {
18
28
  return dbConfig;
19
29
  }
20
30
  }
21
31
 
22
32
  // 2. Zero-Config Auto-Discovery: Discover project topology and persist to SQLite
23
- const septumDb = new SeptumDatabase(dbPath);
24
- const repo = new SeptumRepository(septumDb.raw);
25
- TopologyDetector.discoverAndPersist(repo, process.cwd());
26
- septumDb.close();
33
+ // Guard against running auto-discovery inside MCP host / IDE installation folders
34
+ if (!isAppHostDirectory(root)) {
35
+ const septumDb = new SeptumDatabase(resolvedDbPath);
36
+ const repo = new SeptumRepository(septumDb.raw);
37
+ TopologyDetector.discoverAndPersist(repo, root);
38
+ septumDb.close();
39
+ }
27
40
 
28
- return ConfigLoader.loadFromDatabaseOrDefaults(dbPath);
41
+ return ConfigLoader.loadFromDatabaseOrDefaults(resolvedDbPath);
29
42
  }
30
43
 
31
44
  public static getDefaultConfig(): ValidatedSeptumConfig {
@@ -276,4 +276,74 @@ export class DependencyRepository {
276
276
  };
277
277
  }
278
278
 
279
+ public getFileInboundImpact(filePath: string): {
280
+ direct_dependents_count: number;
281
+ top_consumers: string[];
282
+ symbols_count: number;
283
+ risk_level: "low" | "medium" | "high";
284
+ } {
285
+ const cleanPath = filePath.replace(/\\/g, "/");
286
+ const baseName = cleanPath.split("/").pop()?.replace(/\.[^.]+$/, "") || "";
287
+
288
+ const symbols = this.db
289
+ .query<{ name: string }, [string, string]>(
290
+ `SELECT s.name
291
+ FROM symbols s
292
+ JOIN files f ON s.file_id = f.id
293
+ WHERE f.path = ? OR f.path LIKE ?`
294
+ )
295
+ .all(cleanPath, `%${cleanPath}`);
296
+
297
+ const matchedFiles = new Set<string>();
298
+
299
+ if (baseName.length >= 3) {
300
+ const depRows = this.db
301
+ .query<{ file: string }, [string, string, string]>(
302
+ `SELECT DISTINCT f.path AS file
303
+ FROM dependencies d
304
+ JOIN files f ON d.source_file_id = f.id
305
+ WHERE (d.target_symbol_or_path LIKE ? OR d.target_symbol_or_path LIKE ?)
306
+ AND f.path NOT LIKE ?`
307
+ )
308
+ .all(`%${cleanPath}%`, `%${baseName}%`, `%${cleanPath}`);
309
+
310
+ for (const row of depRows) {
311
+ matchedFiles.add(row.file);
312
+ }
313
+ }
314
+
315
+ for (const sym of symbols.slice(0, 8)) {
316
+ const symName = sym.name.split("::").pop() || sym.name;
317
+ if (symName.length >= 4) {
318
+ const symDeps = this.db
319
+ .query<{ file: string }, [string, string]>(
320
+ `SELECT DISTINCT f.path AS file
321
+ FROM dependencies d
322
+ JOIN files f ON d.source_file_id = f.id
323
+ WHERE d.target_symbol_or_path LIKE ?
324
+ AND f.path NOT LIKE ?
325
+ LIMIT 10`
326
+ )
327
+ .all(`%${symName}%`, `%${cleanPath}`);
328
+ for (const r of symDeps) {
329
+ matchedFiles.add(r.file);
330
+ }
331
+ }
332
+ }
333
+
334
+ const consumerList = Array.from(matchedFiles);
335
+ let riskLevel: "low" | "medium" | "high" = "low";
336
+ if (consumerList.length >= 5) {
337
+ riskLevel = "high";
338
+ } else if (consumerList.length >= 2) {
339
+ riskLevel = "medium";
340
+ }
341
+
342
+ return {
343
+ direct_dependents_count: consumerList.length,
344
+ top_consumers: consumerList.slice(0, 5),
345
+ symbols_count: symbols.length,
346
+ risk_level: riskLevel,
347
+ };
348
+ }
279
349
  }
@@ -22,6 +22,13 @@ export class FileRepository {
22
22
  .all(domainId);
23
23
  }
24
24
 
25
+ public countFiles(): number {
26
+ const row = this.db
27
+ .query<{ count: number }, []>("SELECT COUNT(*) as count FROM files")
28
+ .get();
29
+ return row?.count ?? 0;
30
+ }
31
+
25
32
  public upsertFile(
26
33
  domainId: number,
27
34
  path: string,
@@ -75,6 +75,10 @@ export class SeptumRepository {
75
75
  return this.files.getFileByPath(path);
76
76
  }
77
77
 
78
+ public countFiles(): number {
79
+ return this.files.countFiles();
80
+ }
81
+
78
82
  public getFilesByDomain(domainId: number): FileRecord[] {
79
83
  return this.files.getFilesByDomain(domainId);
80
84
  }
@@ -226,6 +230,15 @@ export class SeptumRepository {
226
230
  return this.dependencies.getSymbolImpact(symbolName);
227
231
  }
228
232
 
233
+ public getFileInboundImpact(filePath: string): {
234
+ direct_dependents_count: number;
235
+ top_consumers: string[];
236
+ symbols_count: number;
237
+ risk_level: "low" | "medium" | "high";
238
+ } {
239
+ return this.dependencies.getFileInboundImpact(filePath);
240
+ }
241
+
229
242
  // --- Metadata Methods ---
230
243
  public getMeta(key: string): string | null {
231
244
  return this.meta.getMeta(key);
@@ -43,8 +43,74 @@ export class TopologyDetector {
43
43
  ".idea",
44
44
  ".agents",
45
45
  "bin",
46
+ "locales",
47
+ "resources",
48
+ "public",
49
+ "assets",
50
+ "static",
51
+ ".output",
52
+ ".nuxt",
53
+ ".next",
54
+ ".turbo",
55
+ ".cache",
46
56
  ]);
47
57
 
58
+ private static readonly SOURCE_EXTENSIONS = new Set([
59
+ ".ts",
60
+ ".js",
61
+ ".tsx",
62
+ ".jsx",
63
+ ".php",
64
+ ".py",
65
+ ".go",
66
+ ".rs",
67
+ ".vue",
68
+ ]);
69
+
70
+ /**
71
+ * Reads compilerOptions.paths from tsconfig.json or jsconfig.json.
72
+ */
73
+ public static readTsConfigPaths(projectRoot: string): Record<string, string[]> {
74
+ for (const file of ["tsconfig.json", "jsconfig.json"]) {
75
+ const fullPath = path.join(projectRoot, file);
76
+ if (fs.existsSync(fullPath)) {
77
+ try {
78
+ const raw = fs.readFileSync(fullPath, "utf-8");
79
+ const stripped = raw.replace(/\/\*[\s\S]*?\*\/|\/\/.*/g, "");
80
+ const parsed = JSON.parse(stripped);
81
+ if (parsed.compilerOptions?.paths) {
82
+ return parsed.compilerOptions.paths;
83
+ }
84
+ } catch {
85
+ // Ignore tsconfig parse error
86
+ }
87
+ }
88
+ }
89
+ return {};
90
+ }
91
+
92
+ /**
93
+ * Checks whether a directory recursively contains any source code files.
94
+ */
95
+ private static hasSourceFiles(dir: string): boolean {
96
+ try {
97
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
98
+ for (const entry of entries) {
99
+ if (entry.name.startsWith(".") || this.IGNORED_DIRS.has(entry.name)) continue;
100
+ const full = path.join(dir, entry.name);
101
+ if (entry.isFile()) {
102
+ const ext = path.extname(entry.name).toLowerCase();
103
+ if (this.SOURCE_EXTENSIONS.has(ext)) return true;
104
+ } else if (entry.isDirectory()) {
105
+ if (this.hasSourceFiles(full)) return true;
106
+ }
107
+ }
108
+ } catch {
109
+ // Ignore read errors
110
+ }
111
+ return false;
112
+ }
113
+
48
114
  /**
49
115
  * Detects project topology, classifies project type & architecture style, and maps domains.
50
116
  */
@@ -213,9 +279,11 @@ export class TopologyDetector {
213
279
  }
214
280
  }
215
281
 
216
- // 5. Fallback: Top-level directories
282
+ // 5. Fallback: Top-level directories that contain source code
217
283
  if (Object.keys(domains).length === 0) {
218
- const topDirs = this.getSubdirectories(projectRoot);
284
+ const topDirs = this.getSubdirectories(projectRoot).filter((d) =>
285
+ this.hasSourceFiles(path.join(projectRoot, d))
286
+ );
219
287
  for (const d of topDirs) {
220
288
  domains[d.toLowerCase()] = {
221
289
  root: d,
@@ -384,8 +452,8 @@ export class TopologyDetector {
384
452
  return "generic-php";
385
453
  }
386
454
  if (language === "typescript" || language === "javascript") {
455
+ const pkgJson = path.join(projectRoot, "package.json");
387
456
  try {
388
- const pkgJson = path.join(projectRoot, "package.json");
389
457
  if (fs.existsSync(pkgJson)) {
390
458
  const pkg = JSON.parse(fs.readFileSync(pkgJson, "utf-8"));
391
459
  const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
@@ -291,6 +291,19 @@ export class IngestionPipeline {
291
291
  middleware: "app/Http/Middleware/**",
292
292
  },
293
293
  };
294
+
295
+ if (existsSync(join(projectRoot, "resources/views"))) {
296
+ domains["views"] = {
297
+ root: "resources/views",
298
+ description: "Laravel Blade Templates and UI Views (auto-discovered)",
299
+ allowed_dependencies: [],
300
+ forbidden_dependencies: [],
301
+ archetypes: {
302
+ view: "resources/views/**/*.blade.php",
303
+ },
304
+ };
305
+ }
306
+
294
307
  return domains;
295
308
  }
296
309
 
@@ -433,6 +446,90 @@ export class IngestionPipeline {
433
446
  }
434
447
 
435
448
  private isSourceFile(path: string): boolean {
436
- return /\.(ts|tsx|js|jsx|mjs|cjs|php|py|go|rs)$/i.test(path);
449
+ return /\.(ts|tsx|js|jsx|mjs|cjs|php|py|go|rs|vue|astro|svelte)$/i.test(path);
450
+ }
451
+
452
+ /**
453
+ * JIT single-file ingestion: quickly verifies and updates a single file's AST,
454
+ * symbols, and dependencies in SQLite if its mtime or content hash has changed.
455
+ * Runs in < 5ms for rapid delta sync without full project scans.
456
+ */
457
+ public async ingestFile(targetFilePath: string, config: ValidatedSeptumConfig): Promise<boolean> {
458
+ const fullPath = resolve(targetFilePath);
459
+ if (!existsSync(fullPath)) return false;
460
+
461
+ let stat;
462
+ try {
463
+ stat = statSync(fullPath);
464
+ if (!stat.isFile() || !this.isSourceFile(fullPath)) return false;
465
+ } catch {
466
+ return false;
467
+ }
468
+
469
+ const mtimeMs = Math.round(stat.mtimeMs);
470
+ const sizeBytes = stat.size;
471
+
472
+ const projectRoot = this.customWorkspaceRoot || process.cwd();
473
+ const relPath = relative(projectRoot, fullPath).replace(/\\/g, "/");
474
+
475
+ let targetDomainName: string | null = null;
476
+ let targetDomainCfg = null;
477
+
478
+ for (const [dName, dCfg] of Object.entries(config.domains)) {
479
+ const absRoot = resolve(projectRoot, dCfg.root).replace(/\\/g, "/");
480
+ const normFullPath = fullPath.replace(/\\/g, "/");
481
+ if (normFullPath === absRoot || normFullPath.startsWith(absRoot + "/")) {
482
+ targetDomainName = dName;
483
+ targetDomainCfg = dCfg;
484
+ break;
485
+ }
486
+ }
487
+
488
+ if (!targetDomainName || !targetDomainCfg) {
489
+ targetDomainName = "core";
490
+ targetDomainCfg = { root: ".", allowed_dependencies: [], forbidden_dependencies: [], archetypes: {} };
491
+ }
492
+
493
+ const domainId = this.repo.upsertDomain(targetDomainName, targetDomainCfg);
494
+ const existingFile = this.repo.getFileByPath(relPath);
495
+
496
+ // Tier 1: Fast OS metadata check
497
+ if (
498
+ existingFile &&
499
+ existingFile.mtime_ms === mtimeMs &&
500
+ existingFile.size_bytes === sizeBytes
501
+ ) {
502
+ return false; // Completely up to date
503
+ }
504
+
505
+ const content = readFileSync(fullPath, "utf-8");
506
+ const currentHash = computeContentHash(content);
507
+
508
+ // Tier 2: Hash verification
509
+ if (existingFile && existingFile.content_hash === currentHash) {
510
+ this.repo.updateFileMetadataOnly(existingFile.id, mtimeMs, sizeBytes);
511
+ return false;
512
+ }
513
+
514
+ const lineCount = content.split("\n").length;
515
+ const archetype = detectArchetype(relPath, targetDomainCfg.archetypes ?? {});
516
+ const ast = await this.parserEngine.parseFile(fullPath, content);
517
+
518
+ this.repo.runInTransaction(() => {
519
+ const fileId = this.repo.upsertFile(
520
+ domainId,
521
+ relPath,
522
+ archetype,
523
+ currentHash,
524
+ lineCount,
525
+ mtimeMs,
526
+ sizeBytes
527
+ );
528
+ this.repo.replaceFileSymbols(fileId, ast.symbols);
529
+ this.repo.replaceFileDependencies(fileId, ast.dependencies);
530
+ });
531
+
532
+ return true;
437
533
  }
438
534
  }
535
+
@@ -28,6 +28,7 @@ export function detectArchetype(filePath: string, archetypesMap: Record<string,
28
28
  if (lower.includes("middleware")) return "middleware";
29
29
  if (lower.includes("request")) return "request";
30
30
  if (lower.includes("resource")) return "resource";
31
+ if (lower.includes(".blade.php") || lower.includes("views/") || lower.includes("templates/")) return "view";
31
32
  if (lower.includes("util") || lower.includes("helper")) return "util";
32
33
 
33
34
  return "unknown";
@@ -0,0 +1,158 @@
1
+ import type {
2
+ ExtractedDependency,
3
+ ExtractedSymbol,
4
+ ParsedFileAST,
5
+ } from "../../../types/index.ts";
6
+ import type { CodeExtractor } from "./base.ts";
7
+
8
+ export class BladeExtractor implements CodeExtractor {
9
+ public canHandle(filePath: string): boolean {
10
+ return /\.blade\.php$/i.test(filePath);
11
+ }
12
+
13
+ public async extract(filePath: string, content: string): Promise<ParsedFileAST> {
14
+ const symbols: ExtractedSymbol[] = [];
15
+ const dependencies: ExtractedDependency[] = [];
16
+
17
+ const lines = content.split(/\r?\n/);
18
+ const lineCount = lines.length;
19
+
20
+ // 1. Register the view itself as a top-level symbol
21
+ const viewName = this.normalizeViewName(filePath);
22
+ if (viewName) {
23
+ symbols.push({
24
+ name: viewName,
25
+ kind: "view",
26
+ signature: `@view(${viewName})`,
27
+ visibility: "public",
28
+ line_start: 1,
29
+ line_end: Math.max(1, lineCount),
30
+ line_count: Math.max(1, lineCount),
31
+ });
32
+
33
+ // Also register with view: prefix for explicit queries
34
+ symbols.push({
35
+ name: `view:${viewName}`,
36
+ kind: "view",
37
+ signature: `@view(${viewName})`,
38
+ visibility: "public",
39
+ line_start: 1,
40
+ line_end: Math.max(1, lineCount),
41
+ line_count: Math.max(1, lineCount),
42
+ });
43
+ }
44
+
45
+ // 2. Line-by-line scanning for Blade directives, components, and DOM IDs
46
+ for (let i = 0; i < lines.length; i++) {
47
+ const line = lines[i];
48
+ const trimmed = line.trim();
49
+ const lineNum = i + 1;
50
+
51
+ if (!trimmed) continue;
52
+
53
+ // Extends: @extends('layouts.app')
54
+ const extendsMatch = /@extends\s*\(\s*['"]([^'"]+)['"]\s*\)/.exec(trimmed);
55
+ if (extendsMatch) {
56
+ dependencies.push({
57
+ target: extendsMatch[1],
58
+ statement: trimmed,
59
+ line_number: lineNum,
60
+ is_external: false,
61
+ });
62
+ }
63
+
64
+ // Includes: @include('partials.store_card'), @includeIf, @includeWhen, @includeUnless
65
+ const includeRegex = /@include(?:If|When|Unless)?\s*\(\s*['"]([^'"]+)['"]/g;
66
+ let incMatch: RegExpExecArray | null;
67
+ while ((incMatch = includeRegex.exec(trimmed)) !== null) {
68
+ dependencies.push({
69
+ target: incMatch[1],
70
+ statement: trimmed,
71
+ line_number: lineNum,
72
+ is_external: false,
73
+ });
74
+ }
75
+
76
+ // Blade Components: <x-store.search-bar or <x-alert
77
+ const componentRegex = /<x-([a-zA-Z0-9_.\-]+)/g;
78
+ let compMatch: RegExpExecArray | null;
79
+ while ((compMatch = componentRegex.exec(trimmed)) !== null) {
80
+ const compTag = compMatch[1];
81
+ // Convert foo-bar to components.foo-bar or components.foo.bar
82
+ const compTarget = `components.${compTag.replace(/-/g, ".")}`;
83
+ dependencies.push({
84
+ target: compTarget,
85
+ statement: trimmed,
86
+ line_number: lineNum,
87
+ is_external: false,
88
+ });
89
+ symbols.push({
90
+ name: `<x-${compTag}>`,
91
+ kind: "component",
92
+ signature: `<x-${compTag}>`,
93
+ visibility: "public",
94
+ line_start: lineNum,
95
+ line_end: lineNum,
96
+ line_count: 1,
97
+ });
98
+ }
99
+
100
+ // Livewire: @livewire('search-dropdown')
101
+ const livewireMatch = /@livewire\s*\(\s*['"]([^'"]+)['"]/.exec(trimmed);
102
+ if (livewireMatch) {
103
+ dependencies.push({
104
+ target: `livewire:${livewireMatch[1]}`,
105
+ statement: trimmed,
106
+ line_number: lineNum,
107
+ is_external: false,
108
+ });
109
+ }
110
+
111
+ // Section: @section('content')
112
+ const sectionMatch = /@section\s*\(\s*['"]([^'"]+)['"]/.exec(trimmed);
113
+ if (sectionMatch) {
114
+ symbols.push({
115
+ name: `section:${sectionMatch[1]}`,
116
+ kind: "property",
117
+ signature: `@section('${sectionMatch[1]}')`,
118
+ visibility: "public",
119
+ line_start: lineNum,
120
+ line_end: lineNum,
121
+ line_count: 1,
122
+ });
123
+ }
124
+
125
+ // DOM ID attribute: id="laptop-slider" or id='laptop-slider'
126
+ const idMatch = /\bid=["']([a-zA-Z0-9_\-]+)["']/.exec(trimmed);
127
+ if (idMatch) {
128
+ const elementId = idMatch[1];
129
+ symbols.push({
130
+ name: `#${elementId}`,
131
+ kind: "property",
132
+ signature: `id="${elementId}"`,
133
+ visibility: "public",
134
+ line_start: lineNum,
135
+ line_end: lineNum,
136
+ line_count: 1,
137
+ });
138
+ }
139
+ }
140
+
141
+ return { symbols, dependencies };
142
+ }
143
+
144
+ /**
145
+ * Derive Laravel view name from path, e.g.:
146
+ * resources/views/partials/store_card.blade.php -> partials.store_card
147
+ */
148
+ private normalizeViewName(filePath: string): string | null {
149
+ const clean = filePath.replace(/\\/g, "/");
150
+ const viewsIdx = clean.indexOf("views/");
151
+ if (viewsIdx !== -1) {
152
+ const sub = clean.slice(viewsIdx + 6).replace(/\.blade\.php$/i, "");
153
+ return sub.replace(/\//g, ".");
154
+ }
155
+ const base = clean.split("/").pop()?.replace(/\.blade\.php$/i, "");
156
+ return base || null;
157
+ }
158
+ }