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.
- package/README.md +2 -0
- package/package.json +1 -1
- package/src/cli/templates/skill.ts +7 -4
- package/src/core/config/loader.ts +21 -8
- package/src/core/database/client.ts +1 -0
- package/src/core/database/repositories/dependency.repository.ts +70 -0
- package/src/core/database/repositories/file.repository.ts +7 -0
- package/src/core/database/repository.ts +13 -0
- package/src/core/discovery/topology-detector.ts +71 -3
- package/src/core/ingestion/pipeline.ts +98 -1
- package/src/core/parser/extractors/base.ts +1 -0
- package/src/core/parser/extractors/blade.ts +158 -0
- package/src/core/parser/extractors/laravel-semantic.ts +186 -27
- package/src/core/parser/extractors/registry.ts +21 -0
- package/src/core/parser/tree-sitter.ts +2 -0
- package/src/core/resolver/module-resolver.ts +30 -11
- package/src/core/resolver/path-resolver.ts +147 -0
- package/src/core/resolver/symbol-locator.ts +152 -14
- package/src/core/resolver/vertical-slice-tracer.ts +22 -7
- package/src/mcp/schemas.ts +216 -46
- package/src/mcp/server.ts +68 -24
- package/src/mcp/tools/check-boundary.ts +58 -8
- package/src/mcp/tools/get-domain-catalog.ts +31 -1
- package/src/mcp/tools/locate-symbol.ts +119 -3
- package/src/mcp/tools/trace-vertical-slice.ts +2 -1
- package/src/types/index.ts +4 -1
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
|
@@ -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
|
-
|
|
29
|
-
|
|
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
|
|
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(
|
|
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(
|
|
16
|
-
const dbConfig = ConfigLoader.loadFromDatabaseOrDefaults(
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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(
|
|
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
|
+
}
|