@agilsee/mcp-orchestrator 0.5.0
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/bin/cli.js +490 -0
- package/dist/index.js +454 -0
- package/dist/memory/memory-manager.js +234 -0
- package/dist/server/web-server.js +574 -0
- package/dist/tools/aggregate-patterns.js +101 -0
- package/dist/tools/analyze-history.js +213 -0
- package/dist/tools/auto-dispatch.js +199 -0
- package/dist/tools/check-energy.js +49 -0
- package/dist/tools/cross-search.js +171 -0
- package/dist/tools/get-focus.js +7 -0
- package/dist/tools/get-identity.js +7 -0
- package/dist/tools/get-project-status.js +35 -0
- package/dist/tools/list-projects.js +21 -0
- package/dist/tools/list-recent-tasks.js +59 -0
- package/dist/tools/log-insight.js +43 -0
- package/dist/tools/qcc-create.js +82 -0
- package/dist/tools/qcc-status.js +164 -0
- package/dist/tools/qcc-update.js +188 -0
- package/dist/tools/smart-bootstrap.js +255 -0
- package/dist/tools/summarize-session.js +161 -0
- package/dist/tools/switch-focus.js +40 -0
- package/dist/tools/workflow-router.js +438 -0
- package/package.json +44 -0
- package/templates/index.ts.template +42 -0
- package/templates/shared/get-claude-md.ts +12 -0
- package/templates/shared/get-current-state.ts +21 -0
- package/templates/shared/get-mistakes.ts +18 -0
- package/templates/shared/log-task.ts +27 -0
- package/templates/shared/predict-impact.ts +67 -0
- package/templates/shared/record-mistake.ts +40 -0
- package/templates/shared/update-state.ts +83 -0
- package/templates/stacks/express/config.json +9 -0
- package/templates/stacks/express/list-routes.ts +56 -0
- package/templates/stacks/express/symbol-index.ts +70 -0
- package/templates/stacks/laravel/config.json +9 -0
- package/templates/stacks/laravel/list-routes.ts +19 -0
- package/templates/stacks/laravel/symbol-index.ts +64 -0
- package/templates/stacks/nextjs/config.json +9 -0
- package/templates/stacks/nextjs/list-routes.ts +67 -0
- package/templates/stacks/nextjs/symbol-index.ts +78 -0
- package/templates/stacks/react/config.json +10 -0
- package/templates/stacks/react/list-routes.ts +44 -0
- package/templates/stacks/react/symbol-index.ts +81 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from "fs/promises";
|
|
2
|
+
import { join, dirname } from "path";
|
|
3
|
+
|
|
4
|
+
interface RecordMistakeArgs {
|
|
5
|
+
title: string;
|
|
6
|
+
found_during: string;
|
|
7
|
+
file: string;
|
|
8
|
+
root_cause: string;
|
|
9
|
+
fix: string;
|
|
10
|
+
impact: "low" | "medium" | "high";
|
|
11
|
+
related?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function recordMistake(docsPath: string, args: RecordMistakeArgs) {
|
|
15
|
+
const path = join(docsPath, "MISTAKES.md");
|
|
16
|
+
await mkdir(dirname(path), { recursive: true });
|
|
17
|
+
|
|
18
|
+
let content: string;
|
|
19
|
+
try {
|
|
20
|
+
content = await readFile(path, "utf-8");
|
|
21
|
+
} catch {
|
|
22
|
+
content = "# Known Mistakes & Gotchas\n\n> Auto-updated oleh agent saat menemukan bug/gotcha.\n\n";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
26
|
+
const entry = [
|
|
27
|
+
`## [${today}] — ${args.title}`,
|
|
28
|
+
`- **Ditemukan saat**: ${args.found_during}`,
|
|
29
|
+
`- **File**: ${args.file}`,
|
|
30
|
+
`- **Root cause**: ${args.root_cause}`,
|
|
31
|
+
`- **Fix**: ${args.fix}`,
|
|
32
|
+
`- **Impact**: ${args.impact}`,
|
|
33
|
+
args.related ? `- **Related**: ${args.related}` : null,
|
|
34
|
+
"",
|
|
35
|
+
].filter(Boolean).join("\n");
|
|
36
|
+
|
|
37
|
+
content = content.trimEnd() + "\n\n" + entry + "\n";
|
|
38
|
+
await writeFile(path, content, "utf-8");
|
|
39
|
+
return { file: path, title: args.title, message: `Recorded mistake: ${args.title}` };
|
|
40
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from "fs/promises";
|
|
2
|
+
import { join, dirname } from "path";
|
|
3
|
+
|
|
4
|
+
interface UpdateStateArgs {
|
|
5
|
+
action: "add_task" | "complete_task" | "update_task";
|
|
6
|
+
task_id: string;
|
|
7
|
+
title?: string;
|
|
8
|
+
status?: string;
|
|
9
|
+
scope?: string;
|
|
10
|
+
files_changed?: string[];
|
|
11
|
+
notes?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function updateState(docsPath: string, args: UpdateStateArgs) {
|
|
15
|
+
const path = join(docsPath, "CURRENT_STATE.md");
|
|
16
|
+
await mkdir(dirname(path), { recursive: true });
|
|
17
|
+
|
|
18
|
+
let content: string;
|
|
19
|
+
try {
|
|
20
|
+
content = await readFile(path, "utf-8");
|
|
21
|
+
} catch {
|
|
22
|
+
content = `# Current State\nLast Updated: ${new Date().toISOString().slice(0, 10)}\n\n## Task Aktif\n\n(tidak ada)\n\n## Terakhir Selesai\n\n(belum ada)\n\n## Blockers\n\n(tidak ada)\n`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
26
|
+
content = content.replace(/Last Updated:.*/, `Last Updated: ${today}`);
|
|
27
|
+
|
|
28
|
+
switch (args.action) {
|
|
29
|
+
case "add_task": {
|
|
30
|
+
const taskBlock = buildTaskBlock(args);
|
|
31
|
+
const activeHeader = "## Task Aktif";
|
|
32
|
+
const activeIdx = content.indexOf(activeHeader);
|
|
33
|
+
if (activeIdx !== -1) {
|
|
34
|
+
const afterHeader = activeIdx + activeHeader.length;
|
|
35
|
+
const nextSection = content.indexOf("\n## ", afterHeader + 1);
|
|
36
|
+
const sectionContent = nextSection !== -1 ? content.slice(afterHeader, nextSection) : content.slice(afterHeader);
|
|
37
|
+
const cleaned = sectionContent.replace(/\n\(tidak ada\)\n?/, "\n");
|
|
38
|
+
const replacement = cleaned.trimEnd() + "\n\n" + taskBlock + "\n";
|
|
39
|
+
content = nextSection !== -1 ? content.slice(0, afterHeader) + replacement + "\n" + content.slice(nextSection) : content.slice(0, afterHeader) + replacement;
|
|
40
|
+
}
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
case "complete_task": {
|
|
44
|
+
const taskPattern = new RegExp(`### ${esc(args.task_id)}[^]*?(?=\\n### |\\n## |$)`, "m");
|
|
45
|
+
const match = content.match(taskPattern);
|
|
46
|
+
const completedEntry = [`### ${args.task_id}${args.title ? " " + args.title : ""}`, `- **Status**: done`, `- **Selesai**: ${today}`, args.notes ? `- **Notes**: ${args.notes}` : null].filter(Boolean).join("\n");
|
|
47
|
+
if (match) {
|
|
48
|
+
content = content.replace(match[0], "").replace(/\n{3,}/g, "\n\n");
|
|
49
|
+
}
|
|
50
|
+
const compIdx = content.indexOf("## Terakhir Selesai");
|
|
51
|
+
if (compIdx !== -1) {
|
|
52
|
+
const afterComp = compIdx + "## Terakhir Selesai".length;
|
|
53
|
+
const cleaned = content.slice(afterComp).replace(/^\n\(belum ada\)\n?/, "\n");
|
|
54
|
+
content = content.slice(0, afterComp) + "\n\n" + completedEntry + cleaned;
|
|
55
|
+
}
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
case "update_task": {
|
|
59
|
+
const taskPattern = new RegExp(`(### ${esc(args.task_id)}[^\n]*)(\n[^]*?)(?=\n### |\n## |$)`, "m");
|
|
60
|
+
const match = content.match(taskPattern);
|
|
61
|
+
if (match) {
|
|
62
|
+
let taskBody = match[2];
|
|
63
|
+
if (args.status) taskBody = taskBody.replace(/- \*\*Status\*\*:.*/, `- **Status**: ${args.status}`);
|
|
64
|
+
if (args.notes) taskBody = taskBody.trimEnd() + `\n- **Update ${today}**: ${args.notes}\n`;
|
|
65
|
+
content = content.replace(match[0], match[1] + taskBody);
|
|
66
|
+
}
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
await writeFile(path, content, "utf-8");
|
|
72
|
+
return { file: path, action: args.action, task_id: args.task_id, message: `State updated: ${args.action} ${args.task_id}` };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function buildTaskBlock(args: UpdateStateArgs): string {
|
|
76
|
+
const lines = [`### ${args.task_id}${args.title ? " " + args.title : ""}`, `- **Status**: ${args.status ?? "in progress"}`];
|
|
77
|
+
if (args.scope) lines.push(`- **Scope**: ${args.scope}`);
|
|
78
|
+
if (args.files_changed?.length) lines.push(`- **Files**: ${args.files_changed.join(", ")}`);
|
|
79
|
+
if (args.notes) lines.push(`- **Notes**: ${args.notes}`);
|
|
80
|
+
return lines.join("\n");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function esc(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"stack": "express",
|
|
3
|
+
"label": "Express.js (Node)",
|
|
4
|
+
"detect_package_deps": ["express"],
|
|
5
|
+
"scan_roots": ["src", "routes", "controllers", "middleware", "models", "services", "utils", "lib"],
|
|
6
|
+
"file_extensions": [".ts", ".js"],
|
|
7
|
+
"symbol_index": true,
|
|
8
|
+
"tools": ["find_symbol", "get_symbol_body", "list_routes", "predict_impact"]
|
|
9
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from "fs/promises";
|
|
2
|
+
import { join, relative } from "path";
|
|
3
|
+
|
|
4
|
+
interface RouteEntry { method: string; path: string; handler: string; file: string; line: number; }
|
|
5
|
+
|
|
6
|
+
const IGNORED = new Set(["node_modules", ".git", "dist", "build"]);
|
|
7
|
+
const ROUTE_DIRS = ["routes", "src/routes", "src/api", "api"];
|
|
8
|
+
const EXTS = [".ts", ".js"];
|
|
9
|
+
|
|
10
|
+
export async function listRoutes(projectPath: string) {
|
|
11
|
+
const routes: RouteEntry[] = [];
|
|
12
|
+
|
|
13
|
+
for (const dir of ROUTE_DIRS) {
|
|
14
|
+
const full = join(projectPath, dir);
|
|
15
|
+
try { await stat(full); await scanRouteDir(full, projectPath, routes); } catch {}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Also scan entry files
|
|
19
|
+
for (const entry of ["app.ts", "app.js", "server.ts", "server.js", "index.ts", "index.js", "src/app.ts", "src/app.js", "src/index.ts", "src/index.js"]) {
|
|
20
|
+
const full = join(projectPath, entry);
|
|
21
|
+
try { await stat(full); await scanRouteFile(full, projectPath, routes); } catch {}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return { count: routes.length, routes };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function scanRouteDir(dir: string, root: string, out: RouteEntry[]) {
|
|
28
|
+
let entries;
|
|
29
|
+
try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
30
|
+
for (const e of entries) {
|
|
31
|
+
if (e.name.startsWith(".") || IGNORED.has(e.name)) continue;
|
|
32
|
+
const full = join(dir, e.name);
|
|
33
|
+
if (e.isDirectory()) await scanRouteDir(full, root, out);
|
|
34
|
+
else if (e.isFile() && EXTS.some((ext) => e.name.endsWith(ext))) await scanRouteFile(full, root, out);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function scanRouteFile(file: string, root: string, out: RouteEntry[]) {
|
|
39
|
+
let content: string;
|
|
40
|
+
try { content = await readFile(file, "utf-8"); } catch { return; }
|
|
41
|
+
const lines = content.split("\n");
|
|
42
|
+
// Match: router.get('/path', handler) / app.post('/path', middleware, handler)
|
|
43
|
+
const routeRe = /(?:router|app|route)\.(get|post|put|patch|delete|all|use)\s*\(\s*['"`]([^'"`]+)['"`]/i;
|
|
44
|
+
|
|
45
|
+
for (let i = 0; i < lines.length; i++) {
|
|
46
|
+
const m = lines[i].match(routeRe);
|
|
47
|
+
if (!m) continue;
|
|
48
|
+
out.push({
|
|
49
|
+
method: m[1].toUpperCase(),
|
|
50
|
+
path: m[2],
|
|
51
|
+
handler: lines[i].trim().slice(0, 150),
|
|
52
|
+
file: relative(root, file).replace(/\\/g, "/"),
|
|
53
|
+
line: i + 1,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { readdir, readFile } from "fs/promises";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
|
|
4
|
+
export interface SymbolEntry { name: string; kind: "function" | "class" | "middleware" | "type"; file: string; line: number; }
|
|
5
|
+
|
|
6
|
+
const IGNORED = new Set(["node_modules", ".git", "dist", "build", "coverage", "__tests__", "test"]);
|
|
7
|
+
const ROOTS = ["src", "routes", "controllers", "middleware", "models", "services", "utils", "lib", "helpers"];
|
|
8
|
+
const EXTS = [".ts", ".js"];
|
|
9
|
+
|
|
10
|
+
export class SymbolIndex {
|
|
11
|
+
private symbols: SymbolEntry[] = [];
|
|
12
|
+
private built = false;
|
|
13
|
+
private buildPromise: Promise<void> | null = null;
|
|
14
|
+
constructor(private projectPath: string) {}
|
|
15
|
+
get size() { return this.symbols.length; }
|
|
16
|
+
|
|
17
|
+
async ensureBuilt() {
|
|
18
|
+
if (this.built) return;
|
|
19
|
+
if (this.buildPromise) return this.buildPromise;
|
|
20
|
+
this.buildPromise = this.build();
|
|
21
|
+
await this.buildPromise;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
private async build() {
|
|
25
|
+
for (const root of ROOTS) {
|
|
26
|
+
try { await this.scanDir(join(this.projectPath, root)); } catch {}
|
|
27
|
+
}
|
|
28
|
+
this.built = true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private async scanDir(dir: string): Promise<void> {
|
|
32
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
33
|
+
for (const e of entries) {
|
|
34
|
+
if (e.name.startsWith(".") || IGNORED.has(e.name)) continue;
|
|
35
|
+
const full = join(dir, e.name);
|
|
36
|
+
if (e.isDirectory()) await this.scanDir(full);
|
|
37
|
+
else if (e.isFile() && EXTS.some((ext) => e.name.endsWith(ext))) await this.indexFile(full);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private async indexFile(file: string) {
|
|
42
|
+
let content: string;
|
|
43
|
+
try { content = await readFile(file, "utf-8"); } catch { return; }
|
|
44
|
+
const lines = content.split("\n");
|
|
45
|
+
for (let i = 0; i < lines.length; i++) {
|
|
46
|
+
const line = lines[i];
|
|
47
|
+
const fn = line.match(/(?:export\s+)?(?:async\s+)?function\s+(\w+)/);
|
|
48
|
+
if (fn) { this.symbols.push({ name: fn[1], kind: "function", file, line: i + 1 }); continue; }
|
|
49
|
+
const cls = line.match(/(?:export\s+)?class\s+(\w+)/);
|
|
50
|
+
if (cls) { this.symbols.push({ name: cls[1], kind: "class", file, line: i + 1 }); continue; }
|
|
51
|
+
// Arrow function exports: export const xxx = (req, res) / export const xxx = async (
|
|
52
|
+
const arrow = line.match(/(?:export\s+)?(?:const|let)\s+(\w+)\s*=\s*(?:async\s+)?\(/);
|
|
53
|
+
if (arrow) { this.symbols.push({ name: arrow[1], kind: "function", file, line: i + 1 }); continue; }
|
|
54
|
+
const type = line.match(/export\s+(?:type|interface)\s+(\w+)/);
|
|
55
|
+
if (type) { this.symbols.push({ name: type[1], kind: "type", file, line: i + 1 }); }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async rebuild() {
|
|
60
|
+
this.symbols = []; this.built = false; this.buildPromise = null;
|
|
61
|
+
const start = Date.now();
|
|
62
|
+
await this.ensureBuilt();
|
|
63
|
+
return { count: this.symbols.length, ms: Date.now() - start };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
find(name: string, kind?: string): SymbolEntry[] {
|
|
67
|
+
const k = kind && kind !== "any" ? kind : null;
|
|
68
|
+
return this.symbols.filter((s) => s.name === name && (!k || s.kind === k));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"stack": "laravel",
|
|
3
|
+
"label": "Laravel (PHP)",
|
|
4
|
+
"detect": ["composer.json", "artisan"],
|
|
5
|
+
"scan_roots": ["app", "routes", "resources", "config"],
|
|
6
|
+
"file_extensions": [".php", ".blade.php", ".js"],
|
|
7
|
+
"symbol_index": true,
|
|
8
|
+
"tools": ["find_symbol", "get_symbol_body", "list_routes", "predict_impact", "generate_system_map"]
|
|
9
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { readFile } from "fs/promises";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
|
|
4
|
+
export async function listRoutes(projectPath: string) {
|
|
5
|
+
const path = join(projectPath, "routes", "web.php");
|
|
6
|
+
const content = await readFile(path, "utf-8");
|
|
7
|
+
const lines = content.split("\n");
|
|
8
|
+
const routes: Array<{ method: string; uri: string; handler: string; name: string | null; line: number }> = [];
|
|
9
|
+
const routeRe = /Route::(get|post|put|patch|delete|any|match)\s*\(\s*['"]([^'"]+)['"]\s*,\s*(?:\[([^\]]+)\]|['"]([^'"]+)['"])/;
|
|
10
|
+
const nameRe = /->name\(\s*['"]([^'"]+)['"]\s*\)/;
|
|
11
|
+
|
|
12
|
+
for (let i = 0; i < lines.length; i++) {
|
|
13
|
+
const m = lines[i].match(routeRe);
|
|
14
|
+
if (!m) continue;
|
|
15
|
+
const nm = lines[i].match(nameRe);
|
|
16
|
+
routes.push({ method: m[1].toUpperCase(), uri: m[2], handler: (m[3] || m[4] || "").trim(), name: nm ? nm[1] : null, line: i + 1 });
|
|
17
|
+
}
|
|
18
|
+
return { file: path, count: routes.length, routes };
|
|
19
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { readdir, readFile } from "fs/promises";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
|
|
4
|
+
export interface SymbolEntry { name: string; kind: "function" | "method" | "class"; file: string; line: number; }
|
|
5
|
+
|
|
6
|
+
const IGNORED = new Set(["vendor", "node_modules", ".git", "storage", "bootstrap", "public", "database", "tests"]);
|
|
7
|
+
const ROOTS = ["app", "routes"];
|
|
8
|
+
|
|
9
|
+
export class SymbolIndex {
|
|
10
|
+
private symbols: SymbolEntry[] = [];
|
|
11
|
+
private built = false;
|
|
12
|
+
private buildPromise: Promise<void> | null = null;
|
|
13
|
+
constructor(private projectPath: string) {}
|
|
14
|
+
get size() { return this.symbols.length; }
|
|
15
|
+
|
|
16
|
+
async ensureBuilt() {
|
|
17
|
+
if (this.built) return;
|
|
18
|
+
if (this.buildPromise) return this.buildPromise;
|
|
19
|
+
this.buildPromise = this.build();
|
|
20
|
+
await this.buildPromise;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
private async build() {
|
|
24
|
+
for (const root of ROOTS) {
|
|
25
|
+
try { await this.scanDir(join(this.projectPath, root)); } catch {}
|
|
26
|
+
}
|
|
27
|
+
this.built = true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private async scanDir(dir: string): Promise<void> {
|
|
31
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
32
|
+
for (const e of entries) {
|
|
33
|
+
if (e.name.startsWith(".") || IGNORED.has(e.name)) continue;
|
|
34
|
+
const full = join(dir, e.name);
|
|
35
|
+
if (e.isDirectory()) await this.scanDir(full);
|
|
36
|
+
else if (e.isFile() && e.name.endsWith(".php")) await this.indexFile(full);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private async indexFile(file: string) {
|
|
41
|
+
let content: string;
|
|
42
|
+
try { content = await readFile(file, "utf-8"); } catch { return; }
|
|
43
|
+
const lines = content.split("\n");
|
|
44
|
+
for (let i = 0; i < lines.length; i++) {
|
|
45
|
+
const line = lines[i];
|
|
46
|
+
const cls = line.match(/^\s*(?:abstract\s+|final\s+)?class\s+(\w+)/);
|
|
47
|
+
if (cls) { this.symbols.push({ name: cls[1], kind: "class", file, line: i + 1 }); continue; }
|
|
48
|
+
const fn = line.match(/^\s*(?:(?:public|protected|private|static|abstract|final)\s+)*function\s+(\w+)\s*\(/);
|
|
49
|
+
if (fn) { this.symbols.push({ name: fn[1], kind: /^\s*(public|protected|private|static|abstract|final)/.test(line) ? "method" : "function", file, line: i + 1 }); }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async rebuild() {
|
|
54
|
+
this.symbols = []; this.built = false; this.buildPromise = null;
|
|
55
|
+
const start = Date.now();
|
|
56
|
+
await this.ensureBuilt();
|
|
57
|
+
return { count: this.symbols.length, ms: Date.now() - start };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
find(name: string, kind?: string): SymbolEntry[] {
|
|
61
|
+
const k = kind && kind !== "any" ? kind : null;
|
|
62
|
+
return this.symbols.filter((s) => s.name === name && (!k || s.kind === k));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"stack": "nextjs",
|
|
3
|
+
"label": "Next.js (React)",
|
|
4
|
+
"detect": ["next.config.js", "next.config.mjs", "next.config.ts"],
|
|
5
|
+
"scan_roots": ["src", "app", "pages", "components", "lib", "hooks", "utils"],
|
|
6
|
+
"file_extensions": [".ts", ".tsx", ".js", ".jsx"],
|
|
7
|
+
"symbol_index": true,
|
|
8
|
+
"tools": ["find_symbol", "get_symbol_body", "list_routes", "predict_impact"]
|
|
9
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { readdir, stat } from "fs/promises";
|
|
2
|
+
import { join, relative } from "path";
|
|
3
|
+
|
|
4
|
+
interface PageRoute { path: string; file: string; type: "page" | "api" | "layout" | "loading" | "error"; }
|
|
5
|
+
|
|
6
|
+
const IGNORED = new Set(["node_modules", ".git", ".next", "dist"]);
|
|
7
|
+
|
|
8
|
+
export async function listRoutes(projectPath: string) {
|
|
9
|
+
const routes: PageRoute[] = [];
|
|
10
|
+
|
|
11
|
+
// App Router (app/)
|
|
12
|
+
const appDir = join(projectPath, "app");
|
|
13
|
+
try { await stat(appDir); await scanAppDir(appDir, projectPath, "", routes); } catch {}
|
|
14
|
+
|
|
15
|
+
// Pages Router (pages/)
|
|
16
|
+
const pagesDir = join(projectPath, "pages");
|
|
17
|
+
try { await stat(pagesDir); await scanPagesDir(pagesDir, projectPath, "", routes); } catch {}
|
|
18
|
+
|
|
19
|
+
// src/app/ and src/pages/
|
|
20
|
+
const srcAppDir = join(projectPath, "src", "app");
|
|
21
|
+
try { await stat(srcAppDir); await scanAppDir(srcAppDir, projectPath, "", routes); } catch {}
|
|
22
|
+
const srcPagesDir = join(projectPath, "src", "pages");
|
|
23
|
+
try { await stat(srcPagesDir); await scanPagesDir(srcPagesDir, projectPath, "", routes); } catch {}
|
|
24
|
+
|
|
25
|
+
return { count: routes.length, routes };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function scanAppDir(dir: string, root: string, prefix: string, out: PageRoute[]) {
|
|
29
|
+
let entries;
|
|
30
|
+
try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
31
|
+
for (const e of entries) {
|
|
32
|
+
if (e.name.startsWith(".") || IGNORED.has(e.name)) continue;
|
|
33
|
+
const full = join(dir, e.name);
|
|
34
|
+
if (e.isDirectory()) {
|
|
35
|
+
const segment = e.name.startsWith("(") ? "" : "/" + e.name.replace(/^\[\.\.\./, ":").replace(/^\[/, ":").replace(/\]$/, "");
|
|
36
|
+
await scanAppDir(full, root, prefix + segment, out);
|
|
37
|
+
} else if (e.isFile()) {
|
|
38
|
+
const name = e.name.replace(/\.(tsx?|jsx?|mdx?)$/, "");
|
|
39
|
+
let type: PageRoute["type"] = "page";
|
|
40
|
+
if (name === "page") type = "page";
|
|
41
|
+
else if (name === "layout") type = "layout";
|
|
42
|
+
else if (name === "loading") type = "loading";
|
|
43
|
+
else if (name === "error") type = "error";
|
|
44
|
+
else if (name === "route") type = "api";
|
|
45
|
+
else continue;
|
|
46
|
+
out.push({ path: prefix || "/", file: relative(root, full).replace(/\\/g, "/"), type });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function scanPagesDir(dir: string, root: string, prefix: string, out: PageRoute[]) {
|
|
52
|
+
let entries;
|
|
53
|
+
try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
54
|
+
for (const e of entries) {
|
|
55
|
+
if (e.name.startsWith(".") || e.name.startsWith("_") || IGNORED.has(e.name)) continue;
|
|
56
|
+
const full = join(dir, e.name);
|
|
57
|
+
if (e.isDirectory()) {
|
|
58
|
+
const segment = "/" + e.name.replace(/^\[\.\.\./, ":").replace(/^\[/, ":").replace(/\]$/, "");
|
|
59
|
+
await scanPagesDir(full, root, prefix + segment, out);
|
|
60
|
+
} else if (e.isFile()) {
|
|
61
|
+
const name = e.name.replace(/\.(tsx?|jsx?|mdx?)$/, "");
|
|
62
|
+
const isApi = full.includes(join("pages", "api"));
|
|
63
|
+
const route = name === "index" ? (prefix || "/") : prefix + "/" + name.replace(/^\[\.\.\./, ":").replace(/^\[/, ":").replace(/\]$/, "");
|
|
64
|
+
out.push({ path: route, file: relative(root, full).replace(/\\/g, "/"), type: isApi ? "api" : "page" });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { readdir, readFile } from "fs/promises";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
|
|
4
|
+
export interface SymbolEntry { name: string; kind: "function" | "component" | "class" | "hook" | "type"; file: string; line: number; }
|
|
5
|
+
|
|
6
|
+
const IGNORED = new Set(["node_modules", ".git", ".next", "dist", "build", "public", "coverage", "__tests__"]);
|
|
7
|
+
const ROOTS = ["src", "app", "pages", "components", "lib", "hooks", "utils"];
|
|
8
|
+
const EXTS = [".ts", ".tsx", ".js", ".jsx"];
|
|
9
|
+
|
|
10
|
+
export class SymbolIndex {
|
|
11
|
+
private symbols: SymbolEntry[] = [];
|
|
12
|
+
private built = false;
|
|
13
|
+
private buildPromise: Promise<void> | null = null;
|
|
14
|
+
constructor(private projectPath: string) {}
|
|
15
|
+
get size() { return this.symbols.length; }
|
|
16
|
+
|
|
17
|
+
async ensureBuilt() {
|
|
18
|
+
if (this.built) return;
|
|
19
|
+
if (this.buildPromise) return this.buildPromise;
|
|
20
|
+
this.buildPromise = this.build();
|
|
21
|
+
await this.buildPromise;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
private async build() {
|
|
25
|
+
for (const root of ROOTS) {
|
|
26
|
+
try { await this.scanDir(join(this.projectPath, root)); } catch {}
|
|
27
|
+
}
|
|
28
|
+
this.built = true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private async scanDir(dir: string): Promise<void> {
|
|
32
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
33
|
+
for (const e of entries) {
|
|
34
|
+
if (e.name.startsWith(".") || IGNORED.has(e.name)) continue;
|
|
35
|
+
const full = join(dir, e.name);
|
|
36
|
+
if (e.isDirectory()) await this.scanDir(full);
|
|
37
|
+
else if (e.isFile() && EXTS.some((ext) => e.name.endsWith(ext))) await this.indexFile(full);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private async indexFile(file: string) {
|
|
42
|
+
let content: string;
|
|
43
|
+
try { content = await readFile(file, "utf-8"); } catch { return; }
|
|
44
|
+
const lines = content.split("\n");
|
|
45
|
+
for (let i = 0; i < lines.length; i++) {
|
|
46
|
+
const line = lines[i];
|
|
47
|
+
// React component: export default function ComponentName / export function ComponentName / const ComponentName =
|
|
48
|
+
const compExport = line.match(/export\s+(?:default\s+)?function\s+([A-Z]\w+)/);
|
|
49
|
+
if (compExport) { this.symbols.push({ name: compExport[1], kind: "component", file, line: i + 1 }); continue; }
|
|
50
|
+
const compConst = line.match(/export\s+(?:const|let)\s+([A-Z]\w+)\s*[=:]/);
|
|
51
|
+
if (compConst) { this.symbols.push({ name: compConst[1], kind: "component", file, line: i + 1 }); continue; }
|
|
52
|
+
// Hook: export function useXxx
|
|
53
|
+
const hook = line.match(/export\s+(?:default\s+)?function\s+(use[A-Z]\w+)/);
|
|
54
|
+
if (hook) { this.symbols.push({ name: hook[1], kind: "hook", file, line: i + 1 }); continue; }
|
|
55
|
+
// Regular function: export function xxx / function xxx
|
|
56
|
+
const fn = line.match(/(?:export\s+)?(?:async\s+)?function\s+([a-z]\w+)/);
|
|
57
|
+
if (fn) { this.symbols.push({ name: fn[1], kind: "function", file, line: i + 1 }); continue; }
|
|
58
|
+
// Class
|
|
59
|
+
const cls = line.match(/(?:export\s+)?class\s+(\w+)/);
|
|
60
|
+
if (cls) { this.symbols.push({ name: cls[1], kind: "class", file, line: i + 1 }); continue; }
|
|
61
|
+
// Type/Interface
|
|
62
|
+
const type = line.match(/export\s+(?:type|interface)\s+(\w+)/);
|
|
63
|
+
if (type) { this.symbols.push({ name: type[1], kind: "type", file, line: i + 1 }); }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async rebuild() {
|
|
68
|
+
this.symbols = []; this.built = false; this.buildPromise = null;
|
|
69
|
+
const start = Date.now();
|
|
70
|
+
await this.ensureBuilt();
|
|
71
|
+
return { count: this.symbols.length, ms: Date.now() - start };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
find(name: string, kind?: string): SymbolEntry[] {
|
|
75
|
+
const k = kind && kind !== "any" ? kind : null;
|
|
76
|
+
return this.symbols.filter((s) => s.name === name && (!k || s.kind === k));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"stack": "react",
|
|
3
|
+
"label": "React (Vite/CRA)",
|
|
4
|
+
"detect_package_deps": ["react"],
|
|
5
|
+
"detect_not": ["next"],
|
|
6
|
+
"scan_roots": ["src", "components", "hooks", "utils", "lib", "contexts", "pages", "features", "store"],
|
|
7
|
+
"file_extensions": [".ts", ".tsx", ".js", ".jsx"],
|
|
8
|
+
"symbol_index": true,
|
|
9
|
+
"tools": ["find_symbol", "get_symbol_body", "list_routes", "predict_impact"]
|
|
10
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { readFile, stat } from "fs/promises";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
|
|
4
|
+
interface RouteEntry { path: string; component: string; file: string; line: number; }
|
|
5
|
+
|
|
6
|
+
export async function listRoutes(projectPath: string) {
|
|
7
|
+
const routes: RouteEntry[] = [];
|
|
8
|
+
|
|
9
|
+
// Try common router config locations
|
|
10
|
+
const candidates = [
|
|
11
|
+
"src/App.tsx", "src/App.jsx", "src/App.ts", "src/App.js",
|
|
12
|
+
"src/routes.tsx", "src/routes.ts", "src/routes.jsx", "src/routes.js",
|
|
13
|
+
"src/router.tsx", "src/router.ts", "src/router.jsx", "src/router.js",
|
|
14
|
+
"src/routes/index.tsx", "src/routes/index.ts",
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
for (const candidate of candidates) {
|
|
18
|
+
const full = join(projectPath, candidate);
|
|
19
|
+
try {
|
|
20
|
+
await stat(full);
|
|
21
|
+
const content = await readFile(full, "utf-8");
|
|
22
|
+
const lines = content.split("\n");
|
|
23
|
+
|
|
24
|
+
// Match: <Route path="/xxx" / path: "/xxx" / { path: "/xxx"
|
|
25
|
+
const routeRe = /(?:path\s*[=:]\s*['"`]([^'"`]+)['"`]|<Route\s[^>]*path\s*=\s*['"`]([^'"`]+)['"`])/;
|
|
26
|
+
const compRe = /(?:element\s*[=:]\s*[{<]?\s*(\w+)|component\s*[=:]\s*[{]?\s*(\w+))/;
|
|
27
|
+
|
|
28
|
+
for (let i = 0; i < lines.length; i++) {
|
|
29
|
+
const rm = lines[i].match(routeRe);
|
|
30
|
+
if (!rm) continue;
|
|
31
|
+
const path = rm[1] || rm[2];
|
|
32
|
+
const cm = lines[i].match(compRe);
|
|
33
|
+
routes.push({
|
|
34
|
+
path,
|
|
35
|
+
component: cm ? (cm[1] || cm[2] || "unknown") : "unknown",
|
|
36
|
+
file: candidate,
|
|
37
|
+
line: i + 1,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
} catch { continue; }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { count: routes.length, routes };
|
|
44
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { readdir, readFile } from "fs/promises";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
|
|
4
|
+
export interface SymbolEntry { name: string; kind: "component" | "hook" | "function" | "class" | "type" | "context"; file: string; line: number; }
|
|
5
|
+
|
|
6
|
+
const IGNORED = new Set(["node_modules", ".git", "dist", "build", "public", "coverage", "__tests__", "test"]);
|
|
7
|
+
const ROOTS = ["src", "components", "hooks", "utils", "lib", "contexts", "pages", "features", "store"];
|
|
8
|
+
const EXTS = [".ts", ".tsx", ".js", ".jsx"];
|
|
9
|
+
|
|
10
|
+
export class SymbolIndex {
|
|
11
|
+
private symbols: SymbolEntry[] = [];
|
|
12
|
+
private built = false;
|
|
13
|
+
private buildPromise: Promise<void> | null = null;
|
|
14
|
+
constructor(private projectPath: string) {}
|
|
15
|
+
get size() { return this.symbols.length; }
|
|
16
|
+
|
|
17
|
+
async ensureBuilt() {
|
|
18
|
+
if (this.built) return;
|
|
19
|
+
if (this.buildPromise) return this.buildPromise;
|
|
20
|
+
this.buildPromise = this.build();
|
|
21
|
+
await this.buildPromise;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
private async build() {
|
|
25
|
+
for (const root of ROOTS) {
|
|
26
|
+
try { await this.scanDir(join(this.projectPath, root)); } catch {}
|
|
27
|
+
}
|
|
28
|
+
this.built = true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private async scanDir(dir: string): Promise<void> {
|
|
32
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
33
|
+
for (const e of entries) {
|
|
34
|
+
if (e.name.startsWith(".") || IGNORED.has(e.name)) continue;
|
|
35
|
+
const full = join(dir, e.name);
|
|
36
|
+
if (e.isDirectory()) await this.scanDir(full);
|
|
37
|
+
else if (e.isFile() && EXTS.some((ext) => e.name.endsWith(ext))) await this.indexFile(full);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private async indexFile(file: string) {
|
|
42
|
+
let content: string;
|
|
43
|
+
try { content = await readFile(file, "utf-8"); } catch { return; }
|
|
44
|
+
const lines = content.split("\n");
|
|
45
|
+
for (let i = 0; i < lines.length; i++) {
|
|
46
|
+
const line = lines[i];
|
|
47
|
+
// Context: export const XxxContext = createContext
|
|
48
|
+
const ctx = line.match(/export\s+const\s+(\w+Context)\s*=\s*(?:React\.)?createContext/);
|
|
49
|
+
if (ctx) { this.symbols.push({ name: ctx[1], kind: "context", file, line: i + 1 }); continue; }
|
|
50
|
+
// Hook: export function useXxx
|
|
51
|
+
const hook = line.match(/(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+(use[A-Z]\w+)/);
|
|
52
|
+
if (hook) { this.symbols.push({ name: hook[1], kind: "hook", file, line: i + 1 }); continue; }
|
|
53
|
+
// Component: export function/const ComponentName (PascalCase)
|
|
54
|
+
const compFn = line.match(/export\s+(?:default\s+)?function\s+([A-Z]\w+)/);
|
|
55
|
+
if (compFn) { this.symbols.push({ name: compFn[1], kind: "component", file, line: i + 1 }); continue; }
|
|
56
|
+
const compConst = line.match(/export\s+(?:const|let)\s+([A-Z]\w+)\s*[=:]/);
|
|
57
|
+
if (compConst && !line.includes("createContext")) { this.symbols.push({ name: compConst[1], kind: "component", file, line: i + 1 }); continue; }
|
|
58
|
+
// Regular function
|
|
59
|
+
const fn = line.match(/(?:export\s+)?(?:async\s+)?function\s+([a-z]\w+)/);
|
|
60
|
+
if (fn) { this.symbols.push({ name: fn[1], kind: "function", file, line: i + 1 }); continue; }
|
|
61
|
+
// Class
|
|
62
|
+
const cls = line.match(/(?:export\s+)?class\s+(\w+)/);
|
|
63
|
+
if (cls) { this.symbols.push({ name: cls[1], kind: "class", file, line: i + 1 }); continue; }
|
|
64
|
+
// Type/Interface
|
|
65
|
+
const type = line.match(/export\s+(?:type|interface)\s+(\w+)/);
|
|
66
|
+
if (type) { this.symbols.push({ name: type[1], kind: "type", file, line: i + 1 }); }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async rebuild() {
|
|
71
|
+
this.symbols = []; this.built = false; this.buildPromise = null;
|
|
72
|
+
const start = Date.now();
|
|
73
|
+
await this.ensureBuilt();
|
|
74
|
+
return { count: this.symbols.length, ms: Date.now() - start };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
find(name: string, kind?: string): SymbolEntry[] {
|
|
78
|
+
const k = kind && kind !== "any" ? kind : null;
|
|
79
|
+
return this.symbols.filter((s) => s.name === name && (!k || s.kind === k));
|
|
80
|
+
}
|
|
81
|
+
}
|