vibe-splain 1.1.0 → 2.0.1
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/dist/index.js +1350 -297
- package/dist/mcp/server.js +66 -2
- package/dist/mcp/tools/get_file_context.d.ts +4 -0
- package/dist/mcp/tools/get_file_context.js +30 -27
- package/dist/mcp/tools/get_project_map.d.ts +15 -0
- package/dist/mcp/tools/get_project_map.js +41 -0
- package/dist/mcp/tools/mark_stale.js +8 -1
- package/dist/mcp/tools/scan_project.js +27 -31
- package/dist/mcp/tools/set_project_brief.d.ts +19 -0
- package/dist/mcp/tools/set_project_brief.js +42 -0
- package/dist/mcp/tools/write_decision_card.d.ts +29 -5
- package/dist/mcp/tools/write_decision_card.js +86 -65
- package/dist/ui/index.html +279 -278
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -88,269 +88,1145 @@ import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema
|
|
|
88
88
|
|
|
89
89
|
// ../brain/dist/scanner.js
|
|
90
90
|
import Parser from "web-tree-sitter";
|
|
91
|
-
import { join as
|
|
91
|
+
import { join as join4, dirname, relative, extname, basename, sep } from "path";
|
|
92
92
|
import { fileURLToPath } from "url";
|
|
93
93
|
import { createRequire } from "module";
|
|
94
|
-
import { readFile as
|
|
94
|
+
import { readFile as readFile4, readdir } from "fs/promises";
|
|
95
95
|
import { existsSync as existsSync2 } from "fs";
|
|
96
96
|
|
|
97
97
|
// ../brain/dist/graph.js
|
|
98
98
|
import { join as join2 } from "path";
|
|
99
99
|
import { readFile as readFile2, writeFile as writeFile2, mkdir } from "fs/promises";
|
|
100
|
-
async function
|
|
101
|
-
const
|
|
100
|
+
async function writeGraph(projectRoot, graph) {
|
|
101
|
+
const dir = join2(projectRoot, ".vibe-splainer");
|
|
102
|
+
await mkdir(dir, { recursive: true });
|
|
103
|
+
const graphPath = join2(dir, "graph.json");
|
|
104
|
+
await writeFile2(graphPath, JSON.stringify(graph, null, 2), "utf8");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ../brain/dist/analysis.js
|
|
108
|
+
import { join as join3 } from "path";
|
|
109
|
+
import { readFile as readFile3, writeFile as writeFile3, mkdir as mkdir2 } from "fs/promises";
|
|
110
|
+
async function readAnalysis(projectRoot) {
|
|
111
|
+
const p = join3(projectRoot, ".vibe-splainer", "analysis.json");
|
|
102
112
|
try {
|
|
103
|
-
const raw = await
|
|
113
|
+
const raw = await readFile3(p, "utf8");
|
|
104
114
|
return JSON.parse(raw);
|
|
105
115
|
} catch {
|
|
106
116
|
return null;
|
|
107
117
|
}
|
|
108
118
|
}
|
|
109
|
-
async function
|
|
110
|
-
const dir =
|
|
111
|
-
await
|
|
112
|
-
|
|
113
|
-
await writeFile2(graphPath, JSON.stringify(graph, null, 2), "utf8");
|
|
119
|
+
async function writeAnalysis(projectRoot, store) {
|
|
120
|
+
const dir = join3(projectRoot, ".vibe-splainer");
|
|
121
|
+
await mkdir2(dir, { recursive: true });
|
|
122
|
+
await writeFile3(join3(dir, "analysis.json"), JSON.stringify(store, null, 2), "utf8");
|
|
114
123
|
}
|
|
115
124
|
|
|
116
125
|
// ../brain/dist/scanner.js
|
|
117
126
|
var __dirname = dirname(fileURLToPath(import.meta.url));
|
|
118
127
|
var require2 = createRequire(import.meta.url);
|
|
119
128
|
var parser = null;
|
|
129
|
+
var langCache = /* @__PURE__ */ new Map();
|
|
130
|
+
var EXT_LANG = {
|
|
131
|
+
".ts": "typescript",
|
|
132
|
+
".tsx": "tsx",
|
|
133
|
+
".js": "javascript",
|
|
134
|
+
".jsx": "tsx",
|
|
135
|
+
".mjs": "javascript",
|
|
136
|
+
".cjs": "javascript",
|
|
137
|
+
".py": "python",
|
|
138
|
+
".go": "go",
|
|
139
|
+
".rs": "rust",
|
|
140
|
+
".java": "java"
|
|
141
|
+
};
|
|
142
|
+
var LANG_WASM = {
|
|
143
|
+
typescript: "tree-sitter-typescript.wasm",
|
|
144
|
+
tsx: "tree-sitter-tsx.wasm",
|
|
145
|
+
javascript: "tree-sitter-javascript.wasm",
|
|
146
|
+
python: "tree-sitter-python.wasm",
|
|
147
|
+
go: "tree-sitter-go.wasm",
|
|
148
|
+
rust: "tree-sitter-rust.wasm",
|
|
149
|
+
java: "tree-sitter-java.wasm"
|
|
150
|
+
};
|
|
151
|
+
var SUPPORTED_EXTENSIONS = new Set(Object.keys(EXT_LANG));
|
|
152
|
+
function resolveWasm(file) {
|
|
153
|
+
try {
|
|
154
|
+
const wasmsDir = dirname(require2.resolve("tree-sitter-wasms/package.json"));
|
|
155
|
+
const p = join4(wasmsDir, "out", file);
|
|
156
|
+
if (existsSync2(p))
|
|
157
|
+
return p;
|
|
158
|
+
} catch {
|
|
159
|
+
}
|
|
160
|
+
const local = join4(__dirname, "../wasm", file);
|
|
161
|
+
return existsSync2(local) ? local : null;
|
|
162
|
+
}
|
|
163
|
+
async function getLanguage(lang) {
|
|
164
|
+
const cached = langCache.get(lang);
|
|
165
|
+
if (cached)
|
|
166
|
+
return cached;
|
|
167
|
+
const wasm = resolveWasm(LANG_WASM[lang]);
|
|
168
|
+
if (!wasm) {
|
|
169
|
+
console.error(`[vibe-splain] grammar missing for ${lang} (${LANG_WASM[lang]}); skipping language`);
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
const loaded = await Parser.Language.load(wasm);
|
|
174
|
+
langCache.set(lang, loaded);
|
|
175
|
+
return loaded;
|
|
176
|
+
} catch (err) {
|
|
177
|
+
console.error(`[vibe-splain] failed to load grammar for ${lang}:`, err instanceof Error ? err.message : err);
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
120
181
|
async function initParser() {
|
|
121
182
|
if (parser)
|
|
122
183
|
return parser;
|
|
123
184
|
await Parser.init();
|
|
124
185
|
parser = new Parser();
|
|
125
|
-
|
|
186
|
+
const ts = await getLanguage("typescript");
|
|
187
|
+
if (ts)
|
|
188
|
+
parser.setLanguage(ts);
|
|
189
|
+
return parser;
|
|
190
|
+
}
|
|
191
|
+
async function parseAs(lang, source) {
|
|
192
|
+
const p = await initParser();
|
|
193
|
+
const language = await getLanguage(lang);
|
|
194
|
+
if (!language)
|
|
195
|
+
return null;
|
|
196
|
+
p.setLanguage(language);
|
|
126
197
|
try {
|
|
127
|
-
|
|
128
|
-
wasmPath = join3(wasmsDir, "out", "tree-sitter-typescript.wasm");
|
|
129
|
-
if (!existsSync2(wasmPath))
|
|
130
|
-
throw new Error("WASM not found in package");
|
|
198
|
+
return p.parse(source);
|
|
131
199
|
} catch {
|
|
132
|
-
|
|
200
|
+
return null;
|
|
133
201
|
}
|
|
134
|
-
const Lang = await Parser.Language.load(wasmPath);
|
|
135
|
-
parser.setLanguage(Lang);
|
|
136
|
-
return parser;
|
|
137
202
|
}
|
|
138
|
-
var EXCLUDE_DIRS = /* @__PURE__ */ new Set([
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
"
|
|
143
|
-
"
|
|
144
|
-
"
|
|
145
|
-
"
|
|
146
|
-
"
|
|
147
|
-
"
|
|
148
|
-
"
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
203
|
+
var EXCLUDE_DIRS = /* @__PURE__ */ new Set([
|
|
204
|
+
"node_modules",
|
|
205
|
+
"dist",
|
|
206
|
+
"build",
|
|
207
|
+
".next",
|
|
208
|
+
"out",
|
|
209
|
+
".vibe-splainer",
|
|
210
|
+
".git",
|
|
211
|
+
".venv",
|
|
212
|
+
"venv",
|
|
213
|
+
"env",
|
|
214
|
+
"__pycache__",
|
|
215
|
+
".idea",
|
|
216
|
+
".vscode",
|
|
217
|
+
".cache",
|
|
218
|
+
"site-packages",
|
|
219
|
+
"target",
|
|
220
|
+
".tox",
|
|
221
|
+
".mypy_cache",
|
|
222
|
+
".pytest_cache"
|
|
223
|
+
]);
|
|
224
|
+
var EXCLUDE_FILE_PATTERNS = [/\.lock$/, /\.min\.[a-z]+$/, /\.d\.ts$/];
|
|
225
|
+
var DEMOTE_SEGMENTS = /* @__PURE__ */ new Set([
|
|
226
|
+
"docs",
|
|
227
|
+
"doc",
|
|
228
|
+
"examples",
|
|
229
|
+
"example",
|
|
230
|
+
"samples",
|
|
231
|
+
"sample",
|
|
232
|
+
"mockup",
|
|
233
|
+
"mockups",
|
|
234
|
+
"fixtures",
|
|
235
|
+
"fixture",
|
|
236
|
+
"__generated__",
|
|
237
|
+
"__mocks__"
|
|
238
|
+
]);
|
|
239
|
+
var VENDOR_SEGMENTS = /* @__PURE__ */ new Set([
|
|
240
|
+
"node_modules",
|
|
241
|
+
"vendor",
|
|
242
|
+
"vendored",
|
|
243
|
+
"site-packages",
|
|
244
|
+
"third_party",
|
|
245
|
+
"third-party"
|
|
246
|
+
]);
|
|
247
|
+
async function collectFiles(dir, projectRoot, acc) {
|
|
248
|
+
let entries;
|
|
249
|
+
try {
|
|
250
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
251
|
+
} catch {
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
153
254
|
for (const entry of entries) {
|
|
255
|
+
if (entry.name.startsWith(".") && entry.name !== ".") {
|
|
256
|
+
if (entry.isDirectory())
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
154
259
|
if (EXCLUDE_DIRS.has(entry.name))
|
|
155
260
|
continue;
|
|
156
|
-
const fullPath =
|
|
261
|
+
const fullPath = join4(dir, entry.name);
|
|
157
262
|
if (entry.isDirectory()) {
|
|
158
|
-
|
|
159
|
-
files.push(...subFiles);
|
|
263
|
+
await collectFiles(fullPath, projectRoot, acc);
|
|
160
264
|
} else if (entry.isFile()) {
|
|
161
265
|
const ext = extname(entry.name);
|
|
162
266
|
if (!SUPPORTED_EXTENSIONS.has(ext))
|
|
163
267
|
continue;
|
|
164
|
-
|
|
165
|
-
if (EXCLUDE_PATTERNS.some((p) => p.test(relPath)))
|
|
268
|
+
if (EXCLUDE_FILE_PATTERNS.some((p) => p.test(entry.name)))
|
|
166
269
|
continue;
|
|
167
|
-
|
|
270
|
+
acc.push(fullPath);
|
|
168
271
|
}
|
|
169
272
|
}
|
|
170
|
-
return files;
|
|
171
273
|
}
|
|
172
|
-
function
|
|
173
|
-
const
|
|
174
|
-
for (const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
274
|
+
function pathDemoteReason(relPath) {
|
|
275
|
+
const segs = relPath.split(sep);
|
|
276
|
+
for (const s of segs) {
|
|
277
|
+
if (VENDOR_SEGMENTS.has(s))
|
|
278
|
+
return `vendored code (${s})`;
|
|
279
|
+
if (s.endsWith(".venv") || s === "venv" || s === "env")
|
|
280
|
+
return "virtual environment";
|
|
281
|
+
}
|
|
282
|
+
for (const s of segs) {
|
|
283
|
+
if (DEMOTE_SEGMENTS.has(s.toLowerCase()))
|
|
284
|
+
return `non-application path segment (${s})`;
|
|
285
|
+
}
|
|
286
|
+
const base = basename(relPath);
|
|
287
|
+
if (/\.min\./.test(base))
|
|
288
|
+
return "minified bundle";
|
|
289
|
+
if (/\.generated\./.test(base))
|
|
290
|
+
return "generated file";
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
function extractImports(source, lang) {
|
|
294
|
+
const specs = [];
|
|
295
|
+
if (lang === "python") {
|
|
296
|
+
const re2 = /^[ \t]*(?:from[ \t]+([.\w]+)[ \t]+import|import[ \t]+([.\w][.\w ,]*))/gm;
|
|
297
|
+
let m2;
|
|
298
|
+
while ((m2 = re2.exec(source)) !== null) {
|
|
299
|
+
if (m2[1]) {
|
|
300
|
+
specs.push(m2[1]);
|
|
301
|
+
} else if (m2[2]) {
|
|
302
|
+
for (const part of m2[2].split(",")) {
|
|
303
|
+
const name = part.trim().split(/\s+as\s+/)[0].trim();
|
|
304
|
+
if (name)
|
|
305
|
+
specs.push(name);
|
|
306
|
+
}
|
|
180
307
|
}
|
|
181
308
|
}
|
|
309
|
+
return specs;
|
|
182
310
|
}
|
|
183
|
-
|
|
311
|
+
if (lang === "go") {
|
|
312
|
+
const re2 = /"([^"]+)"/g;
|
|
313
|
+
const importBlock = source.match(/import\s*\(([\s\S]*?)\)/g) || [];
|
|
314
|
+
for (const block of importBlock) {
|
|
315
|
+
let m3;
|
|
316
|
+
while ((m3 = re2.exec(block)) !== null)
|
|
317
|
+
specs.push(m3[1]);
|
|
318
|
+
}
|
|
319
|
+
const single = /import\s+(?:\w+\s+)?"([^"]+)"/g;
|
|
320
|
+
let m2;
|
|
321
|
+
while ((m2 = single.exec(source)) !== null)
|
|
322
|
+
specs.push(m2[1]);
|
|
323
|
+
return specs;
|
|
324
|
+
}
|
|
325
|
+
if (lang === "rust") {
|
|
326
|
+
const re2 = /\b(?:use|mod)\s+([\w:]+)/g;
|
|
327
|
+
let m2;
|
|
328
|
+
while ((m2 = re2.exec(source)) !== null)
|
|
329
|
+
specs.push(m2[1]);
|
|
330
|
+
return specs;
|
|
331
|
+
}
|
|
332
|
+
if (lang === "java") {
|
|
333
|
+
const re2 = /import\s+(?:static\s+)?([\w.]+)/g;
|
|
334
|
+
let m2;
|
|
335
|
+
while ((m2 = re2.exec(source)) !== null)
|
|
336
|
+
specs.push(m2[1]);
|
|
337
|
+
return specs;
|
|
338
|
+
}
|
|
339
|
+
const re = /(?:import|export)\s[^;]*?from\s*['"]([^'"]+)['"]|(?:import|require)\s*\(\s*['"]([^'"]+)['"]/g;
|
|
340
|
+
let m;
|
|
341
|
+
while ((m = re.exec(source)) !== null) {
|
|
342
|
+
specs.push(m[1] || m[2]);
|
|
343
|
+
}
|
|
344
|
+
return specs;
|
|
345
|
+
}
|
|
346
|
+
var JS_EXTS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
|
|
347
|
+
function resolveImport(spec, fromAbs, lang, projectRoot, fileSet, basenameIndex) {
|
|
348
|
+
if (lang === "python") {
|
|
349
|
+
return resolvePython(spec, fromAbs, projectRoot, fileSet);
|
|
350
|
+
}
|
|
351
|
+
if (lang === "typescript" || lang === "tsx" || lang === "javascript") {
|
|
352
|
+
if (!spec.startsWith("."))
|
|
353
|
+
return null;
|
|
354
|
+
const base = join4(dirname(fromAbs), spec);
|
|
355
|
+
return tryJsCandidates(base, projectRoot, fileSet);
|
|
356
|
+
}
|
|
357
|
+
return resolveGeneric(spec, projectRoot, fileSet, basenameIndex);
|
|
358
|
+
}
|
|
359
|
+
function tryJsCandidates(base, projectRoot, fileSet) {
|
|
360
|
+
const candidates = [];
|
|
361
|
+
for (const ext of JS_EXTS)
|
|
362
|
+
candidates.push(base + ext);
|
|
363
|
+
for (const ext of JS_EXTS)
|
|
364
|
+
candidates.push(join4(base, "index" + ext));
|
|
365
|
+
candidates.unshift(base);
|
|
366
|
+
for (const c of candidates) {
|
|
367
|
+
const rel = relative(projectRoot, c);
|
|
368
|
+
if (fileSet.has(rel))
|
|
369
|
+
return rel;
|
|
370
|
+
}
|
|
371
|
+
return null;
|
|
184
372
|
}
|
|
185
|
-
function
|
|
373
|
+
function resolvePython(spec, fromAbs, projectRoot, fileSet) {
|
|
374
|
+
let modulePath;
|
|
375
|
+
if (spec.startsWith(".")) {
|
|
376
|
+
const dots = spec.match(/^\.+/)[0].length;
|
|
377
|
+
let dir = dirname(fromAbs);
|
|
378
|
+
for (let i = 1; i < dots; i++)
|
|
379
|
+
dir = dirname(dir);
|
|
380
|
+
const rest = spec.slice(dots).replace(/\./g, sep);
|
|
381
|
+
modulePath = rest ? join4(dir, rest) : dir;
|
|
382
|
+
} else {
|
|
383
|
+
modulePath = join4(projectRoot, spec.replace(/\./g, sep));
|
|
384
|
+
}
|
|
385
|
+
const candidates = [modulePath + ".py", join4(modulePath, "__init__.py")];
|
|
386
|
+
for (const c of candidates) {
|
|
387
|
+
const rel = relative(projectRoot, c);
|
|
388
|
+
if (fileSet.has(rel))
|
|
389
|
+
return rel;
|
|
390
|
+
}
|
|
391
|
+
if (!spec.startsWith(".")) {
|
|
392
|
+
const last = spec.split(".").pop();
|
|
393
|
+
void last;
|
|
394
|
+
}
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
function resolveGeneric(spec, projectRoot, fileSet, basenameIndex) {
|
|
398
|
+
const normalized = spec.replace(/^crate::/, "").replace(/::/g, "/").replace(/\./g, "/");
|
|
399
|
+
const parts = normalized.split("/").filter(Boolean);
|
|
400
|
+
if (parts.length === 0)
|
|
401
|
+
return null;
|
|
402
|
+
const last = parts[parts.length - 1];
|
|
403
|
+
for (const rel of fileSet) {
|
|
404
|
+
const noExt = rel.slice(0, rel.length - extname(rel).length);
|
|
405
|
+
if (noExt.endsWith(parts.join(sep)))
|
|
406
|
+
return rel;
|
|
407
|
+
}
|
|
408
|
+
const byBase = basenameIndex.get(last);
|
|
409
|
+
if (byBase && byBase.length === 1)
|
|
410
|
+
return byBase[0];
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
var FUNCTION_TYPES = /* @__PURE__ */ new Set([
|
|
414
|
+
"function_declaration",
|
|
415
|
+
"function",
|
|
416
|
+
"function_expression",
|
|
417
|
+
"arrow_function",
|
|
418
|
+
"method_definition",
|
|
419
|
+
"function_definition",
|
|
420
|
+
"method_declaration",
|
|
421
|
+
"func_literal",
|
|
422
|
+
"function_item",
|
|
423
|
+
"closure_expression",
|
|
424
|
+
"constructor_declaration",
|
|
425
|
+
"generator_function_declaration",
|
|
426
|
+
"generator_function"
|
|
427
|
+
]);
|
|
428
|
+
var NESTING_TYPES = /* @__PURE__ */ new Set([
|
|
429
|
+
"function_declaration",
|
|
430
|
+
"function",
|
|
431
|
+
"arrow_function",
|
|
432
|
+
"function_expression",
|
|
433
|
+
"method_definition",
|
|
434
|
+
"function_definition",
|
|
435
|
+
"method_declaration",
|
|
436
|
+
"function_item",
|
|
437
|
+
"class_declaration",
|
|
438
|
+
"class",
|
|
439
|
+
"class_definition",
|
|
440
|
+
"class_item",
|
|
441
|
+
"if_statement",
|
|
442
|
+
"if_expression",
|
|
443
|
+
"for_statement",
|
|
444
|
+
"for_in_statement",
|
|
445
|
+
"for_expression",
|
|
446
|
+
"enhanced_for_statement",
|
|
447
|
+
"while_statement",
|
|
448
|
+
"while_expression",
|
|
449
|
+
"do_statement",
|
|
450
|
+
"switch_statement",
|
|
451
|
+
"match_expression",
|
|
452
|
+
"match_arm",
|
|
453
|
+
"try_statement",
|
|
454
|
+
"catch_clause",
|
|
455
|
+
"except_clause",
|
|
456
|
+
"loop_expression",
|
|
457
|
+
"block"
|
|
458
|
+
]);
|
|
459
|
+
var DECISION_TYPES = /* @__PURE__ */ new Set([
|
|
460
|
+
"if_statement",
|
|
461
|
+
"if_expression",
|
|
462
|
+
"elif_clause",
|
|
463
|
+
"for_statement",
|
|
464
|
+
"for_in_statement",
|
|
465
|
+
"for_expression",
|
|
466
|
+
"enhanced_for_statement",
|
|
467
|
+
"while_statement",
|
|
468
|
+
"while_expression",
|
|
469
|
+
"do_statement",
|
|
470
|
+
"loop_expression",
|
|
471
|
+
"case",
|
|
472
|
+
"switch_case",
|
|
473
|
+
"case_clause",
|
|
474
|
+
"match_arm",
|
|
475
|
+
"catch_clause",
|
|
476
|
+
"except_clause",
|
|
477
|
+
"communication_case",
|
|
478
|
+
"conditional_expression",
|
|
479
|
+
"ternary_expression"
|
|
480
|
+
]);
|
|
481
|
+
var CATCH_TYPES = /* @__PURE__ */ new Set(["catch_clause", "except_clause"]);
|
|
482
|
+
var LONG_FN_LOC = 60;
|
|
483
|
+
var DEEP_NESTING = 5;
|
|
484
|
+
var GOD_FILE_LOC = 400;
|
|
485
|
+
var GOD_FILE_EXPORTS = 8;
|
|
486
|
+
function nodeLOC(node) {
|
|
487
|
+
return node.endPosition.row - node.startPosition.row + 1;
|
|
488
|
+
}
|
|
489
|
+
function countDecisions(node) {
|
|
490
|
+
let count = 0;
|
|
491
|
+
const walk = (n) => {
|
|
492
|
+
if (DECISION_TYPES.has(n.type))
|
|
493
|
+
count++;
|
|
494
|
+
if (n.type === "binary_expression") {
|
|
495
|
+
const op = n.children.find((c) => c.type === "&&" || c.type === "||");
|
|
496
|
+
if (op)
|
|
497
|
+
count++;
|
|
498
|
+
}
|
|
499
|
+
if (n.type === "boolean_operator")
|
|
500
|
+
count++;
|
|
501
|
+
for (const c of n.children)
|
|
502
|
+
walk(c);
|
|
503
|
+
};
|
|
504
|
+
walk(node);
|
|
505
|
+
return count;
|
|
506
|
+
}
|
|
507
|
+
function computeNesting(node, depth) {
|
|
186
508
|
let maxDepth = depth;
|
|
187
|
-
const nestingTypes = /* @__PURE__ */ new Set([
|
|
188
|
-
"function_declaration",
|
|
189
|
-
"function",
|
|
190
|
-
"arrow_function",
|
|
191
|
-
"method_definition",
|
|
192
|
-
"class_declaration",
|
|
193
|
-
"class",
|
|
194
|
-
"if_statement",
|
|
195
|
-
"for_statement",
|
|
196
|
-
"for_in_statement",
|
|
197
|
-
"while_statement",
|
|
198
|
-
"do_statement",
|
|
199
|
-
"switch_statement",
|
|
200
|
-
"try_statement",
|
|
201
|
-
"catch_clause"
|
|
202
|
-
]);
|
|
203
509
|
for (const child of node.children) {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
maxDepth = Math.max(maxDepth, childMax);
|
|
207
|
-
} else {
|
|
208
|
-
const childMax = computeNestingDepth(child, depth);
|
|
209
|
-
maxDepth = Math.max(maxDepth, childMax);
|
|
210
|
-
}
|
|
510
|
+
const nextDepth = NESTING_TYPES.has(child.type) ? depth + 1 : depth;
|
|
511
|
+
maxDepth = Math.max(maxDepth, computeNesting(child, nextDepth));
|
|
211
512
|
}
|
|
212
513
|
return maxDepth;
|
|
213
514
|
}
|
|
214
|
-
function
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
515
|
+
function firstLine(s) {
|
|
516
|
+
return s.split("\n")[0];
|
|
517
|
+
}
|
|
518
|
+
function stripLeadingComments(snippet) {
|
|
519
|
+
const lines = snippet.split("\n");
|
|
520
|
+
let i = 0;
|
|
521
|
+
let inBlock = false;
|
|
522
|
+
while (i < lines.length) {
|
|
523
|
+
const t = lines[i].trim();
|
|
524
|
+
if (inBlock) {
|
|
525
|
+
if (t.includes("*/"))
|
|
526
|
+
inBlock = false;
|
|
527
|
+
i++;
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
if (t === "") {
|
|
531
|
+
i++;
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
if (t.startsWith("//") || t.startsWith("#")) {
|
|
535
|
+
i++;
|
|
536
|
+
continue;
|
|
220
537
|
}
|
|
538
|
+
if (t.startsWith("/*")) {
|
|
539
|
+
inBlock = !t.includes("*/");
|
|
540
|
+
i++;
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
if (t.startsWith('"""') || t.startsWith("'''")) {
|
|
544
|
+
const q = t.slice(0, 3);
|
|
545
|
+
if (t.length > 3 && t.endsWith(q)) {
|
|
546
|
+
i++;
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
i++;
|
|
550
|
+
while (i < lines.length && !lines[i].includes(q))
|
|
551
|
+
i++;
|
|
552
|
+
i++;
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
break;
|
|
221
556
|
}
|
|
222
|
-
|
|
223
|
-
|
|
557
|
+
return lines.slice(i).join("\n");
|
|
558
|
+
}
|
|
559
|
+
var TODO_RE = /\b(TODO|FIXME|HACK|XXX|KLUDGE)\b|@deprecated/;
|
|
560
|
+
var SUPPRESS_RE = /@ts-ignore|@ts-nocheck|eslint-disable|:\s*any\b|#\s*type:\s*ignore|type:\s*ignore|#\s*nosec/;
|
|
561
|
+
function collectFunctionNodes(root) {
|
|
562
|
+
const out = [];
|
|
563
|
+
const walk = (n) => {
|
|
564
|
+
if (FUNCTION_TYPES.has(n.type))
|
|
565
|
+
out.push(n);
|
|
566
|
+
for (const c of n.children)
|
|
567
|
+
walk(c);
|
|
568
|
+
};
|
|
569
|
+
walk(root);
|
|
570
|
+
return out;
|
|
571
|
+
}
|
|
572
|
+
function catchIsSwallowed(node, lang) {
|
|
573
|
+
const bodyText = node.text;
|
|
574
|
+
const inner = bodyText.replace(/^[^{:]*[{:]/, "");
|
|
575
|
+
const meaningful = inner.split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("//") && !l.startsWith("#") && l !== "}" && l !== "pass");
|
|
576
|
+
if (meaningful.length === 0)
|
|
577
|
+
return true;
|
|
578
|
+
const onlyLogs = meaningful.every((l) => /^(console\.(log|error|warn|info)|print|println!?|System\.out|logger?\.)/.test(l) || l === "pass" || l === "{" || l === "});" || l === ")" || l === "`");
|
|
579
|
+
return onlyLogs;
|
|
580
|
+
}
|
|
581
|
+
function analyzeAst(source, lang, tree) {
|
|
582
|
+
const root = tree.rootNode;
|
|
583
|
+
const lines = source.split("\n");
|
|
584
|
+
const loc = lines.length;
|
|
585
|
+
const cyclomatic = countDecisions(root);
|
|
586
|
+
const maxNesting = computeNesting(root, 0);
|
|
587
|
+
const smells = [];
|
|
588
|
+
let todos = 0, suppressions = 0;
|
|
589
|
+
for (let i = 0; i < lines.length; i++) {
|
|
590
|
+
const line = lines[i];
|
|
591
|
+
if (TODO_RE.test(line)) {
|
|
592
|
+
todos++;
|
|
593
|
+
smells.push({ kind: "todo", line: i + 1, endLine: i + 1, text: line.trim().slice(0, 200), severity: 2, note: "unfinished / known-bad marker" });
|
|
594
|
+
}
|
|
595
|
+
if (SUPPRESS_RE.test(line)) {
|
|
596
|
+
suppressions++;
|
|
597
|
+
smells.push({ kind: "suppression", line: i + 1, endLine: i + 1, text: line.trim().slice(0, 200), severity: 3, note: "type/lint safety suppressed" });
|
|
598
|
+
}
|
|
224
599
|
}
|
|
225
|
-
|
|
600
|
+
let magicNumbers = 0;
|
|
601
|
+
const magicWalk = (n) => {
|
|
602
|
+
if (n.type === "number" || n.type === "integer_literal" || n.type === "float_literal" || n.type === "int_literal") {
|
|
603
|
+
const v = n.text.replace(/_/g, "");
|
|
604
|
+
if (!["0", "1", "2", "-1", "100", "1000"].includes(v) && /^\d{2,}$/.test(v)) {
|
|
605
|
+
magicNumbers++;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
for (const c of n.children)
|
|
609
|
+
magicWalk(c);
|
|
610
|
+
};
|
|
611
|
+
magicWalk(root);
|
|
612
|
+
if (magicNumbers > 6) {
|
|
613
|
+
smells.push({ kind: "magic-number", line: 1, endLine: 1, text: `${magicNumbers} unexplained numeric literals`, severity: 2, note: "many magic numbers \u2014 extract named constants" });
|
|
614
|
+
}
|
|
615
|
+
let swallowedCatches = 0;
|
|
616
|
+
const catchWalk = (n) => {
|
|
617
|
+
if (CATCH_TYPES.has(n.type) && catchIsSwallowed(n, lang)) {
|
|
618
|
+
swallowedCatches++;
|
|
619
|
+
smells.push({
|
|
620
|
+
kind: "swallowed-catch",
|
|
621
|
+
line: n.startPosition.row + 1,
|
|
622
|
+
endLine: n.endPosition.row + 1,
|
|
623
|
+
text: firstLine(n.text).trim().slice(0, 200),
|
|
624
|
+
severity: 4,
|
|
625
|
+
note: "catch block swallows error silently"
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
for (const c of n.children)
|
|
629
|
+
catchWalk(c);
|
|
630
|
+
};
|
|
631
|
+
catchWalk(root);
|
|
632
|
+
const fnNodes = collectFunctionNodes(root);
|
|
633
|
+
let longFunctions = 0;
|
|
634
|
+
const scored = [];
|
|
635
|
+
for (const fn of fnNodes) {
|
|
636
|
+
const bodyLOC = nodeLOC(fn);
|
|
637
|
+
const decisions = countDecisions(fn);
|
|
638
|
+
scored.push({ node: fn, decisions, bodyLOC, score: decisions + bodyLOC });
|
|
639
|
+
if (bodyLOC > LONG_FN_LOC) {
|
|
640
|
+
longFunctions++;
|
|
641
|
+
smells.push({
|
|
642
|
+
kind: "long-function",
|
|
643
|
+
line: fn.startPosition.row + 1,
|
|
644
|
+
endLine: fn.endPosition.row + 1,
|
|
645
|
+
text: firstLine(fn.text).trim().slice(0, 200),
|
|
646
|
+
severity: 3,
|
|
647
|
+
note: `function body is ${bodyLOC} lines`
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
if (maxNesting > DEEP_NESTING) {
|
|
652
|
+
smells.push({ kind: "deep-nesting", line: 1, endLine: 1, text: `nesting depth ${maxNesting}`, severity: 3, note: `control flow nested ${maxNesting} levels deep` });
|
|
653
|
+
}
|
|
654
|
+
const exported = collectExports(root, lang);
|
|
655
|
+
const publicSurface = exported.length;
|
|
656
|
+
const signature = exported.map((e) => e.text).join("\n").slice(0, 4e3);
|
|
657
|
+
if (loc > GOD_FILE_LOC && publicSurface > GOD_FILE_EXPORTS) {
|
|
658
|
+
smells.push({
|
|
659
|
+
kind: "god-file",
|
|
660
|
+
line: 1,
|
|
661
|
+
endLine: 1,
|
|
662
|
+
text: `${loc} LOC, ${publicSurface} exports`,
|
|
663
|
+
severity: 4,
|
|
664
|
+
note: `god-file: ${loc} lines exporting ${publicSurface} symbols`
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
scored.sort((a, b) => b.score - a.score);
|
|
668
|
+
const hotSpans = scored.slice(0, 3).filter((s) => s.bodyLOC >= 4).map((s) => {
|
|
669
|
+
const raw = source.split("\n").slice(s.node.startPosition.row, s.node.endPosition.row + 1).join("\n");
|
|
670
|
+
const snippet = stripLeadingComments(raw).slice(0, 2e3);
|
|
671
|
+
return {
|
|
672
|
+
startLine: s.node.startPosition.row + 1,
|
|
673
|
+
endLine: s.node.endPosition.row + 1,
|
|
674
|
+
snippet,
|
|
675
|
+
reason: `high complexity: ${s.decisions} decision branches across ${s.bodyLOC} lines`
|
|
676
|
+
};
|
|
677
|
+
});
|
|
678
|
+
return {
|
|
679
|
+
language: lang,
|
|
680
|
+
loc,
|
|
681
|
+
cyclomatic,
|
|
682
|
+
maxNesting,
|
|
683
|
+
publicSurface,
|
|
684
|
+
exportedNames: exported.map((e) => e.name),
|
|
685
|
+
signature,
|
|
686
|
+
longFunctions,
|
|
687
|
+
magicNumbers,
|
|
688
|
+
swallowedCatches,
|
|
689
|
+
smells,
|
|
690
|
+
hotSpans
|
|
691
|
+
};
|
|
226
692
|
}
|
|
227
|
-
function
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
693
|
+
function collectExports(root, lang) {
|
|
694
|
+
const out = [];
|
|
695
|
+
const seen = /* @__PURE__ */ new Set();
|
|
696
|
+
const push = (name, node) => {
|
|
697
|
+
if (!name || seen.has(name))
|
|
698
|
+
return;
|
|
699
|
+
seen.add(name);
|
|
700
|
+
out.push({ name, text: firstLine(node.text).trim().slice(0, 200) });
|
|
701
|
+
};
|
|
702
|
+
if (lang === "python") {
|
|
703
|
+
for (const c of root.children) {
|
|
704
|
+
if (c.type === "function_definition" || c.type === "class_definition") {
|
|
705
|
+
const name = c.childForFieldName("name")?.text;
|
|
706
|
+
if (name && !name.startsWith("_"))
|
|
707
|
+
push(name, c);
|
|
708
|
+
}
|
|
232
709
|
}
|
|
710
|
+
return out;
|
|
233
711
|
}
|
|
234
|
-
|
|
712
|
+
if (lang === "go") {
|
|
713
|
+
const walk2 = (n) => {
|
|
714
|
+
if (n.type === "function_declaration" || n.type === "method_declaration" || n.type === "type_declaration") {
|
|
715
|
+
const name = n.childForFieldName("name")?.text;
|
|
716
|
+
if (name && /^[A-Z]/.test(name))
|
|
717
|
+
push(name, n);
|
|
718
|
+
}
|
|
719
|
+
for (const c of n.children)
|
|
720
|
+
walk2(c);
|
|
721
|
+
};
|
|
722
|
+
walk2(root);
|
|
723
|
+
return out;
|
|
724
|
+
}
|
|
725
|
+
if (lang === "rust") {
|
|
726
|
+
const walk2 = (n) => {
|
|
727
|
+
if (/_item$/.test(n.type) && n.children.some((c) => c.type === "visibility_modifier")) {
|
|
728
|
+
const name = n.childForFieldName("name")?.text;
|
|
729
|
+
push(name, n);
|
|
730
|
+
}
|
|
731
|
+
for (const c of n.children)
|
|
732
|
+
walk2(c);
|
|
733
|
+
};
|
|
734
|
+
walk2(root);
|
|
735
|
+
return out;
|
|
736
|
+
}
|
|
737
|
+
if (lang === "java") {
|
|
738
|
+
const walk2 = (n) => {
|
|
739
|
+
if ((n.type === "method_declaration" || n.type === "class_declaration") && /\bpublic\b/.test(firstLine(n.text))) {
|
|
740
|
+
const name = n.childForFieldName("name")?.text;
|
|
741
|
+
push(name, n);
|
|
742
|
+
}
|
|
743
|
+
for (const c of n.children)
|
|
744
|
+
walk2(c);
|
|
745
|
+
};
|
|
746
|
+
walk2(root);
|
|
747
|
+
return out;
|
|
748
|
+
}
|
|
749
|
+
const walk = (n) => {
|
|
750
|
+
if (n.type === "export_statement") {
|
|
751
|
+
const decl = n.childForFieldName("declaration");
|
|
752
|
+
if (decl) {
|
|
753
|
+
const name = decl.childForFieldName("name")?.text;
|
|
754
|
+
if (name)
|
|
755
|
+
push(name, decl);
|
|
756
|
+
for (const c of decl.namedChildren) {
|
|
757
|
+
const dn = c.childForFieldName("name")?.text;
|
|
758
|
+
if (dn)
|
|
759
|
+
push(dn, c);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
for (const spec of n.descendantsOfType("export_specifier")) {
|
|
763
|
+
push(spec.childForFieldName("name")?.text, spec);
|
|
764
|
+
}
|
|
765
|
+
if (n.text.includes("export default"))
|
|
766
|
+
push("default", n);
|
|
767
|
+
}
|
|
768
|
+
for (const c of n.children)
|
|
769
|
+
walk(c);
|
|
770
|
+
};
|
|
771
|
+
walk(root);
|
|
772
|
+
return out;
|
|
235
773
|
}
|
|
236
|
-
function
|
|
237
|
-
const
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
774
|
+
function pageRank(nodes, outEdges, damping = 0.85, iters = 20) {
|
|
775
|
+
const n = nodes.length;
|
|
776
|
+
const rank = /* @__PURE__ */ new Map();
|
|
777
|
+
if (n === 0)
|
|
778
|
+
return rank;
|
|
779
|
+
for (const node of nodes)
|
|
780
|
+
rank.set(node, 1 / n);
|
|
781
|
+
const inEdges = /* @__PURE__ */ new Map();
|
|
782
|
+
for (const node of nodes)
|
|
783
|
+
inEdges.set(node, []);
|
|
784
|
+
const outCount = /* @__PURE__ */ new Map();
|
|
785
|
+
for (const [from, tos] of outEdges) {
|
|
786
|
+
const valid = [...tos].filter((t) => rank.has(t));
|
|
787
|
+
outCount.set(from, valid.length);
|
|
788
|
+
for (const to of valid)
|
|
789
|
+
inEdges.get(to).push(from);
|
|
242
790
|
}
|
|
243
|
-
|
|
791
|
+
for (let it = 0; it < iters; it++) {
|
|
792
|
+
const next = /* @__PURE__ */ new Map();
|
|
793
|
+
let dangling = 0;
|
|
794
|
+
for (const node of nodes) {
|
|
795
|
+
if ((outCount.get(node) || 0) === 0)
|
|
796
|
+
dangling += rank.get(node);
|
|
797
|
+
}
|
|
798
|
+
for (const node of nodes) {
|
|
799
|
+
let sum = 0;
|
|
800
|
+
for (const from of inEdges.get(node)) {
|
|
801
|
+
sum += rank.get(from) / (outCount.get(from) || 1);
|
|
802
|
+
}
|
|
803
|
+
next.set(node, (1 - damping) / n + damping * (sum + dangling / n));
|
|
804
|
+
}
|
|
805
|
+
for (const node of nodes)
|
|
806
|
+
rank.set(node, next.get(node));
|
|
807
|
+
}
|
|
808
|
+
let max = 0;
|
|
809
|
+
for (const v of rank.values())
|
|
810
|
+
max = Math.max(max, v);
|
|
811
|
+
if (max > 0)
|
|
812
|
+
for (const node of nodes)
|
|
813
|
+
rank.set(node, rank.get(node) / max);
|
|
814
|
+
return rank;
|
|
244
815
|
}
|
|
245
|
-
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
816
|
+
function detectCommunities(nodes, adjacency) {
|
|
817
|
+
const label = /* @__PURE__ */ new Map();
|
|
818
|
+
nodes.forEach((node, i) => label.set(node, i));
|
|
819
|
+
const order = [...nodes];
|
|
820
|
+
for (let pass = 0; pass < 10; pass++) {
|
|
821
|
+
let changed = false;
|
|
822
|
+
for (const node of order) {
|
|
823
|
+
const neighbors = adjacency.get(node);
|
|
824
|
+
if (!neighbors || neighbors.size === 0)
|
|
825
|
+
continue;
|
|
826
|
+
const counts = /* @__PURE__ */ new Map();
|
|
827
|
+
for (const nb of neighbors) {
|
|
828
|
+
const l = label.get(nb);
|
|
829
|
+
counts.set(l, (counts.get(l) || 0) + 1);
|
|
830
|
+
}
|
|
831
|
+
let best = label.get(node), bestCount = -1;
|
|
832
|
+
for (const [l, c] of counts) {
|
|
833
|
+
if (c > bestCount || c === bestCount && l < best) {
|
|
834
|
+
best = l;
|
|
835
|
+
bestCount = c;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
if (best !== label.get(node)) {
|
|
839
|
+
label.set(node, best);
|
|
840
|
+
changed = true;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
if (!changed)
|
|
844
|
+
break;
|
|
845
|
+
}
|
|
846
|
+
return label;
|
|
847
|
+
}
|
|
848
|
+
async function detectStackAndEntrypoints(projectRoot, files) {
|
|
849
|
+
const stack = /* @__PURE__ */ new Set();
|
|
850
|
+
const entrypoints = /* @__PURE__ */ new Set();
|
|
851
|
+
const rel = (abs) => relative(projectRoot, abs);
|
|
852
|
+
const pkgPath = join4(projectRoot, "package.json");
|
|
853
|
+
if (existsSync2(pkgPath)) {
|
|
854
|
+
try {
|
|
855
|
+
const pkg = JSON.parse(await readFile4(pkgPath, "utf8"));
|
|
856
|
+
stack.add("Node.js");
|
|
857
|
+
const deps = { ...pkg.dependencies || {}, ...pkg.devDependencies || {} };
|
|
858
|
+
for (const known of ["react", "next", "vue", "svelte", "express", "fastify", "typescript", "vite"]) {
|
|
859
|
+
if (deps[known])
|
|
860
|
+
stack.add(known === "next" ? "Next.js" : known[0].toUpperCase() + known.slice(1));
|
|
861
|
+
}
|
|
862
|
+
const addEntry = (p) => {
|
|
863
|
+
if (!p)
|
|
864
|
+
return;
|
|
865
|
+
const abs = join4(projectRoot, p);
|
|
866
|
+
const r = relative(projectRoot, abs);
|
|
867
|
+
if (files.includes(abs))
|
|
868
|
+
entrypoints.add(r);
|
|
869
|
+
};
|
|
870
|
+
addEntry(pkg.main);
|
|
871
|
+
if (typeof pkg.bin === "string")
|
|
872
|
+
addEntry(pkg.bin);
|
|
873
|
+
else if (pkg.bin)
|
|
874
|
+
for (const v of Object.values(pkg.bin))
|
|
875
|
+
addEntry(v);
|
|
876
|
+
} catch {
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
const pyproject = join4(projectRoot, "pyproject.toml");
|
|
880
|
+
const setupPy = join4(projectRoot, "setup.py");
|
|
881
|
+
const requirements = join4(projectRoot, "requirements.txt");
|
|
882
|
+
if (existsSync2(pyproject) || existsSync2(setupPy) || existsSync2(requirements)) {
|
|
883
|
+
stack.add("Python");
|
|
884
|
+
let reqText = "";
|
|
885
|
+
for (const f of [pyproject, requirements]) {
|
|
886
|
+
if (existsSync2(f)) {
|
|
887
|
+
try {
|
|
888
|
+
reqText += await readFile4(f, "utf8");
|
|
889
|
+
} catch {
|
|
265
890
|
}
|
|
266
891
|
}
|
|
267
892
|
}
|
|
893
|
+
for (const known of ["pygame", "PySide6", "PyQt5", "PyQt6", "flask", "django", "fastapi", "numpy", "pandas", "torch", "tensorflow"]) {
|
|
894
|
+
if (new RegExp(known, "i").test(reqText))
|
|
895
|
+
stack.add(known);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
if (existsSync2(join4(projectRoot, "go.mod")))
|
|
899
|
+
stack.add("Go");
|
|
900
|
+
if (existsSync2(join4(projectRoot, "Cargo.toml")))
|
|
901
|
+
stack.add("Rust");
|
|
902
|
+
if (existsSync2(join4(projectRoot, "pom.xml")) || existsSync2(join4(projectRoot, "build.gradle")))
|
|
903
|
+
stack.add("Java");
|
|
904
|
+
for (const abs of files) {
|
|
905
|
+
const r = rel(abs);
|
|
906
|
+
const base = basename(r);
|
|
907
|
+
if (base === "main.py" || base === "__main__.py")
|
|
908
|
+
entrypoints.add(r);
|
|
909
|
+
if (/^index\.(ts|tsx|js|jsx|mjs|cjs)$/.test(base) && dirname(r).split(sep).length <= 2)
|
|
910
|
+
entrypoints.add(r);
|
|
911
|
+
if (base === "main.go" && r.includes("cmd" + sep))
|
|
912
|
+
entrypoints.add(r);
|
|
913
|
+
if (base === "main.go" && !r.includes(sep))
|
|
914
|
+
entrypoints.add(r);
|
|
915
|
+
if (base === "main.rs" || base === "lib.rs")
|
|
916
|
+
entrypoints.add(r);
|
|
917
|
+
}
|
|
918
|
+
return { stack: [...stack], entrypoints };
|
|
919
|
+
}
|
|
920
|
+
var SMELL_WEIGHT = {
|
|
921
|
+
"todo": 3,
|
|
922
|
+
"suppression": 5,
|
|
923
|
+
"swallowed-catch": 10,
|
|
924
|
+
"deep-nesting": 6,
|
|
925
|
+
"long-function": 5,
|
|
926
|
+
"magic-number": 3,
|
|
927
|
+
"god-file": 14
|
|
928
|
+
};
|
|
929
|
+
function computeHeat(smells) {
|
|
930
|
+
let sum = 0;
|
|
931
|
+
for (const s of smells)
|
|
932
|
+
sum += s.severity * SMELL_WEIGHT[s.kind];
|
|
933
|
+
return Math.min(100, sum);
|
|
934
|
+
}
|
|
935
|
+
async function scanProject(projectRoot) {
|
|
936
|
+
await initParser();
|
|
937
|
+
const abs = [];
|
|
938
|
+
await collectFiles(projectRoot, projectRoot, abs);
|
|
939
|
+
const fileSet = new Set(abs.map((f) => relative(projectRoot, f)));
|
|
940
|
+
const basenameIndex = /* @__PURE__ */ new Map();
|
|
941
|
+
for (const rel of fileSet) {
|
|
942
|
+
const b = basename(rel).slice(0, basename(rel).length - extname(rel).length);
|
|
943
|
+
if (!basenameIndex.has(b))
|
|
944
|
+
basenameIndex.set(b, []);
|
|
945
|
+
basenameIndex.get(b).push(rel);
|
|
268
946
|
}
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
const
|
|
274
|
-
|
|
947
|
+
const { stack, entrypoints } = await detectStackAndEntrypoints(projectRoot, abs);
|
|
948
|
+
const work = [];
|
|
949
|
+
const graph = { nodes: {}, edges: [] };
|
|
950
|
+
for (const file of abs) {
|
|
951
|
+
const rel = relative(projectRoot, file);
|
|
952
|
+
const ext = extname(file);
|
|
953
|
+
const lang = EXT_LANG[ext];
|
|
954
|
+
if (!lang)
|
|
955
|
+
continue;
|
|
956
|
+
let source;
|
|
275
957
|
try {
|
|
276
|
-
|
|
958
|
+
source = await readFile4(file, "utf8");
|
|
277
959
|
} catch {
|
|
278
960
|
continue;
|
|
279
961
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
}
|
|
290
|
-
allAnalyzed.push({
|
|
291
|
-
path: file,
|
|
292
|
-
relativePath: relPath,
|
|
293
|
-
cognitiveWeight,
|
|
294
|
-
linkDensity,
|
|
295
|
-
nestingDepth,
|
|
296
|
-
mutationCount,
|
|
297
|
-
pillars
|
|
298
|
-
});
|
|
962
|
+
if (/if\s+__name__\s*==\s*['"]__main__['"]/.test(source) || /^#![^\n]*\b(node|python\d?)\b/.test(source)) {
|
|
963
|
+
entrypoints.add(rel);
|
|
964
|
+
}
|
|
965
|
+
const tree = await parseAs(lang, source);
|
|
966
|
+
if (!tree)
|
|
967
|
+
continue;
|
|
968
|
+
const ast = analyzeAst(source, lang, tree);
|
|
969
|
+
const importSpecs = extractImports(source, lang);
|
|
970
|
+
graph.nodes[rel] = { imports: importSpecs };
|
|
971
|
+
work.push({ abs: file, rel, lang, source, ast, importSpecs, pathDemote: pathDemoteReason(rel) });
|
|
299
972
|
}
|
|
300
|
-
const
|
|
301
|
-
const
|
|
302
|
-
const
|
|
303
|
-
for (const
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
973
|
+
const importedBy = /* @__PURE__ */ new Map();
|
|
974
|
+
const importsResolved = /* @__PURE__ */ new Map();
|
|
975
|
+
const fanOut = /* @__PURE__ */ new Map();
|
|
976
|
+
for (const w of work) {
|
|
977
|
+
importedBy.set(w.rel, /* @__PURE__ */ new Set());
|
|
978
|
+
importsResolved.set(w.rel, /* @__PURE__ */ new Set());
|
|
979
|
+
}
|
|
980
|
+
for (const w of work) {
|
|
981
|
+
const distinctModules = /* @__PURE__ */ new Set();
|
|
982
|
+
for (const spec of w.importSpecs) {
|
|
983
|
+
distinctModules.add(spec);
|
|
984
|
+
const target = resolveImport(spec, w.abs, w.lang, projectRoot, fileSet, basenameIndex);
|
|
985
|
+
if (target && target !== w.rel && importedBy.has(target)) {
|
|
986
|
+
importedBy.get(target).add(w.rel);
|
|
987
|
+
importsResolved.get(w.rel).add(target);
|
|
988
|
+
graph.edges.push({ from: w.rel, to: target });
|
|
309
989
|
}
|
|
990
|
+
}
|
|
991
|
+
fanOut.set(w.rel, distinctModules.size);
|
|
992
|
+
}
|
|
993
|
+
const isRealSource = /* @__PURE__ */ new Map();
|
|
994
|
+
const demoteReason = /* @__PURE__ */ new Map();
|
|
995
|
+
for (const w of work) {
|
|
996
|
+
if (w.pathDemote) {
|
|
997
|
+
isRealSource.set(w.rel, false);
|
|
998
|
+
demoteReason.set(w.rel, w.pathDemote);
|
|
310
999
|
} else {
|
|
311
|
-
|
|
1000
|
+
isRealSource.set(w.rel, true);
|
|
1001
|
+
demoteReason.set(w.rel, null);
|
|
312
1002
|
}
|
|
313
1003
|
}
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
if (
|
|
318
|
-
|
|
319
|
-
|
|
1004
|
+
for (const w of work) {
|
|
1005
|
+
if (!isRealSource.get(w.rel))
|
|
1006
|
+
continue;
|
|
1007
|
+
if (entrypoints.has(w.rel))
|
|
1008
|
+
continue;
|
|
1009
|
+
const inbound = [...importedBy.get(w.rel)].filter((src) => isRealSource.get(src));
|
|
1010
|
+
if (inbound.length === 0) {
|
|
1011
|
+
isRealSource.set(w.rel, false);
|
|
1012
|
+
demoteReason.set(w.rel, "no inbound references from application code");
|
|
1013
|
+
}
|
|
320
1014
|
}
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
1015
|
+
const realNodes = work.filter((w) => isRealSource.get(w.rel)).map((w) => w.rel);
|
|
1016
|
+
const realSet = new Set(realNodes);
|
|
1017
|
+
const outEdges = /* @__PURE__ */ new Map();
|
|
1018
|
+
const undirected = /* @__PURE__ */ new Map();
|
|
1019
|
+
for (const node of realNodes) {
|
|
1020
|
+
outEdges.set(node, /* @__PURE__ */ new Set());
|
|
1021
|
+
undirected.set(node, /* @__PURE__ */ new Set());
|
|
324
1022
|
}
|
|
325
|
-
for (const
|
|
326
|
-
|
|
1023
|
+
for (const w of work) {
|
|
1024
|
+
if (!realSet.has(w.rel))
|
|
1025
|
+
continue;
|
|
1026
|
+
for (const target of importsResolved.get(w.rel)) {
|
|
1027
|
+
if (!realSet.has(target))
|
|
1028
|
+
continue;
|
|
1029
|
+
outEdges.get(w.rel).add(target);
|
|
1030
|
+
undirected.get(w.rel).add(target);
|
|
1031
|
+
undirected.get(target).add(w.rel);
|
|
1032
|
+
}
|
|
327
1033
|
}
|
|
328
|
-
const
|
|
1034
|
+
const ranks = pageRank(realNodes, outEdges);
|
|
1035
|
+
const communities = detectCommunities(realNodes, undirected);
|
|
1036
|
+
const analyses = [];
|
|
1037
|
+
const persisted = {};
|
|
1038
|
+
for (const w of work) {
|
|
1039
|
+
const real = isRealSource.get(w.rel);
|
|
1040
|
+
const fanIn = [...importedBy.get(w.rel)].filter((src) => isRealSource.get(src)).length;
|
|
1041
|
+
const centrality = real ? ranks.get(w.rel) || 0 : 0;
|
|
1042
|
+
const gravitySignals = {
|
|
1043
|
+
fanIn,
|
|
1044
|
+
fanOut: fanOut.get(w.rel) || 0,
|
|
1045
|
+
centrality,
|
|
1046
|
+
cyclomatic: w.ast.cyclomatic,
|
|
1047
|
+
publicSurface: w.ast.publicSurface,
|
|
1048
|
+
loc: w.ast.loc
|
|
1049
|
+
};
|
|
1050
|
+
let gravityRaw = centrality * 50 + Math.log2(fanIn + 1) * 8 + Math.log2(w.ast.cyclomatic + 1) * 4 + Math.log2(w.ast.publicSurface + 1) * 3;
|
|
1051
|
+
if (!real)
|
|
1052
|
+
gravityRaw *= 0.2;
|
|
1053
|
+
const gravity = Math.max(0, Math.min(100, gravityRaw));
|
|
1054
|
+
const heatSignals = {
|
|
1055
|
+
todos: w.ast.smells.filter((s) => s.kind === "todo").length,
|
|
1056
|
+
suppressions: w.ast.smells.filter((s) => s.kind === "suppression").length,
|
|
1057
|
+
swallowedCatches: w.ast.swallowedCatches,
|
|
1058
|
+
maxNesting: w.ast.maxNesting,
|
|
1059
|
+
longFunctions: w.ast.longFunctions,
|
|
1060
|
+
magicNumbers: w.ast.magicNumbers
|
|
1061
|
+
};
|
|
1062
|
+
const heat = real ? computeHeat(w.ast.smells) : 0;
|
|
1063
|
+
const pillarHint = real ? `community-${communities.get(w.rel)}` : null;
|
|
1064
|
+
const fa = {
|
|
1065
|
+
path: w.abs,
|
|
1066
|
+
relativePath: w.rel,
|
|
1067
|
+
language: w.lang,
|
|
1068
|
+
isRealSource: real,
|
|
1069
|
+
demoteReason: demoteReason.get(w.rel) || null,
|
|
1070
|
+
gravity,
|
|
1071
|
+
heat,
|
|
1072
|
+
gravitySignals,
|
|
1073
|
+
heatSignals,
|
|
1074
|
+
smells: w.ast.smells,
|
|
1075
|
+
pillarHint
|
|
1076
|
+
};
|
|
1077
|
+
analyses.push(fa);
|
|
1078
|
+
persisted[w.rel] = {
|
|
1079
|
+
relativePath: w.rel,
|
|
1080
|
+
language: w.lang,
|
|
1081
|
+
isRealSource: real,
|
|
1082
|
+
demoteReason: demoteReason.get(w.rel) || null,
|
|
1083
|
+
gravity,
|
|
1084
|
+
heat,
|
|
1085
|
+
gravitySignals,
|
|
1086
|
+
heatSignals,
|
|
1087
|
+
smells: w.ast.smells,
|
|
1088
|
+
pillarHint,
|
|
1089
|
+
importedBy: [...importedBy.get(w.rel)].filter((src) => isRealSource.get(src)),
|
|
1090
|
+
imports: [...importsResolved.get(w.rel)]
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
const realAnalyses = analyses.filter((a) => a.isRealSource).sort((a, b) => b.gravity - a.gravity);
|
|
1094
|
+
const wildCandidates = realAnalyses.filter((a) => a.heat >= 60 || a.smells.some((s) => s.severity >= 4)).sort((a, b) => b.heat - a.heat);
|
|
1095
|
+
const pillars = buildPillars(realAnalyses, communities, stack);
|
|
1096
|
+
const topGravity = realAnalyses.slice(0, 12).map((a) => a.relativePath);
|
|
1097
|
+
const topHeat = wildCandidates.slice(0, 12).map((a) => a.relativePath);
|
|
1098
|
+
const map = {
|
|
1099
|
+
stack,
|
|
1100
|
+
entrypoints: [...entrypoints],
|
|
1101
|
+
pillars,
|
|
1102
|
+
fileCount: work.length,
|
|
1103
|
+
realSourceCount: realAnalyses.length,
|
|
1104
|
+
topGravity,
|
|
1105
|
+
topHeat,
|
|
1106
|
+
brief: null
|
|
1107
|
+
};
|
|
329
1108
|
await writeGraph(projectRoot, graph);
|
|
330
|
-
|
|
1109
|
+
await writeAnalysis(projectRoot, { files: persisted });
|
|
1110
|
+
const uiUrl = `file://${join4(projectRoot, ".vibe-splainer", "ui", "index.html")}`;
|
|
331
1111
|
return {
|
|
332
1112
|
projectRoot,
|
|
333
|
-
totalFilesScanned:
|
|
334
|
-
|
|
335
|
-
|
|
1113
|
+
totalFilesScanned: work.length,
|
|
1114
|
+
realSourceCount: realAnalyses.length,
|
|
1115
|
+
files: realAnalyses,
|
|
1116
|
+
map,
|
|
336
1117
|
wildCandidates,
|
|
337
1118
|
uiUrl,
|
|
338
1119
|
graph
|
|
339
1120
|
};
|
|
340
1121
|
}
|
|
1122
|
+
function buildPillars(real, communities, stack) {
|
|
1123
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1124
|
+
for (const a of real) {
|
|
1125
|
+
const c = communities.get(a.relativePath);
|
|
1126
|
+
if (c === void 0)
|
|
1127
|
+
continue;
|
|
1128
|
+
if (!groups.has(c))
|
|
1129
|
+
groups.set(c, []);
|
|
1130
|
+
groups.get(c).push(a);
|
|
1131
|
+
}
|
|
1132
|
+
const sorted = [...groups.entries()].map(([id, files]) => ({ id, files, weight: files.reduce((s, f) => s + f.gravity, 0) })).filter((g) => g.files.length >= 2).sort((a, b) => b.weight - a.weight).slice(0, 6);
|
|
1133
|
+
const pillars = sorted.map((g, idx) => {
|
|
1134
|
+
const top = [...g.files].sort((a, b) => b.gravity - a.gravity);
|
|
1135
|
+
const name = pillarName(top, idx);
|
|
1136
|
+
return {
|
|
1137
|
+
name,
|
|
1138
|
+
description: `Graph cluster of ${g.files.length} files centered on ${basename(top[0].relativePath)}.`,
|
|
1139
|
+
memberFiles: top.map((f) => f.relativePath)
|
|
1140
|
+
};
|
|
1141
|
+
});
|
|
1142
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1143
|
+
for (const p of pillars) {
|
|
1144
|
+
let n = p.name, i = 2;
|
|
1145
|
+
while (seen.has(n)) {
|
|
1146
|
+
n = `${p.name} ${i++}`;
|
|
1147
|
+
}
|
|
1148
|
+
p.name = n;
|
|
1149
|
+
seen.add(n);
|
|
1150
|
+
}
|
|
1151
|
+
if (pillars.length === 0 && real.length > 0) {
|
|
1152
|
+
pillars.push({ name: "Core", description: "Primary application code.", memberFiles: real.slice(0, 20).map((f) => f.relativePath) });
|
|
1153
|
+
}
|
|
1154
|
+
return pillars;
|
|
1155
|
+
}
|
|
1156
|
+
function pillarName(files, idx) {
|
|
1157
|
+
const dirs = files.map((f) => dirname(f.relativePath)).filter((d) => d && d !== ".");
|
|
1158
|
+
if (dirs.length) {
|
|
1159
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1160
|
+
for (const d of dirs) {
|
|
1161
|
+
const seg = d.split(sep).pop();
|
|
1162
|
+
counts.set(seg, (counts.get(seg) || 0) + 1);
|
|
1163
|
+
}
|
|
1164
|
+
const top = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];
|
|
1165
|
+
if (top)
|
|
1166
|
+
return titleCase(top[0]);
|
|
1167
|
+
}
|
|
1168
|
+
return `Cluster ${idx + 1}`;
|
|
1169
|
+
}
|
|
1170
|
+
function titleCase(s) {
|
|
1171
|
+
return s.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
1172
|
+
}
|
|
1173
|
+
async function getFileAnalysis(absPath) {
|
|
1174
|
+
const ext = extname(absPath);
|
|
1175
|
+
const lang = EXT_LANG[ext];
|
|
1176
|
+
if (!lang)
|
|
1177
|
+
return null;
|
|
1178
|
+
let source;
|
|
1179
|
+
try {
|
|
1180
|
+
source = await readFile4(absPath, "utf8");
|
|
1181
|
+
} catch {
|
|
1182
|
+
return null;
|
|
1183
|
+
}
|
|
1184
|
+
const tree = await parseAs(lang, source);
|
|
1185
|
+
if (!tree)
|
|
1186
|
+
return null;
|
|
1187
|
+
const ast = analyzeAst(source, lang, tree);
|
|
1188
|
+
const lines = source.split("\n");
|
|
1189
|
+
const smellSpans = ast.smells.map((s) => {
|
|
1190
|
+
const start = Math.max(0, s.line - 1 - 3);
|
|
1191
|
+
const end = Math.min(lines.length, s.endLine + 3);
|
|
1192
|
+
return {
|
|
1193
|
+
startLine: start + 1,
|
|
1194
|
+
endLine: end,
|
|
1195
|
+
snippet: lines.slice(start, end).join("\n").slice(0, 1200),
|
|
1196
|
+
reason: `${s.kind}: ${s.note}`
|
|
1197
|
+
};
|
|
1198
|
+
});
|
|
1199
|
+
const heatSignals = {
|
|
1200
|
+
todos: ast.smells.filter((s) => s.kind === "todo").length,
|
|
1201
|
+
suppressions: ast.smells.filter((s) => s.kind === "suppression").length,
|
|
1202
|
+
swallowedCatches: ast.swallowedCatches,
|
|
1203
|
+
maxNesting: ast.maxNesting,
|
|
1204
|
+
longFunctions: ast.longFunctions,
|
|
1205
|
+
magicNumbers: ast.magicNumbers
|
|
1206
|
+
};
|
|
1207
|
+
return {
|
|
1208
|
+
language: lang,
|
|
1209
|
+
signature: ast.signature,
|
|
1210
|
+
hotSpans: ast.hotSpans,
|
|
1211
|
+
smellSpans,
|
|
1212
|
+
heatSignals,
|
|
1213
|
+
loc: ast.loc,
|
|
1214
|
+
cyclomatic: ast.cyclomatic
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
341
1217
|
|
|
342
1218
|
// ../brain/dist/dossier.js
|
|
343
1219
|
import { Mutex } from "async-mutex";
|
|
344
|
-
import { join as
|
|
1220
|
+
import { join as join5, dirname as dirname2 } from "path";
|
|
345
1221
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
346
|
-
import { readFile as
|
|
1222
|
+
import { readFile as readFile5, writeFile as writeFile4, mkdir as mkdir3 } from "fs/promises";
|
|
347
1223
|
import { existsSync as existsSync3, cpSync } from "fs";
|
|
348
1224
|
var __dirname2 = dirname2(fileURLToPath2(import.meta.url));
|
|
349
1225
|
var dossierMutex = new Mutex();
|
|
350
1226
|
async function readDossier(projectRoot) {
|
|
351
|
-
const dossierPath =
|
|
1227
|
+
const dossierPath = join5(projectRoot, ".vibe-splainer", "dossier.json");
|
|
352
1228
|
try {
|
|
353
|
-
const raw = await
|
|
1229
|
+
const raw = await readFile5(dossierPath, "utf8");
|
|
354
1230
|
return JSON.parse(raw);
|
|
355
1231
|
} catch {
|
|
356
1232
|
return null;
|
|
@@ -358,33 +1234,33 @@ async function readDossier(projectRoot) {
|
|
|
358
1234
|
}
|
|
359
1235
|
async function writeDossier(projectRoot, dossier) {
|
|
360
1236
|
await dossierMutex.runExclusive(async () => {
|
|
361
|
-
const dir =
|
|
362
|
-
await
|
|
363
|
-
const dossierPath =
|
|
1237
|
+
const dir = join5(projectRoot, ".vibe-splainer");
|
|
1238
|
+
await mkdir3(dir, { recursive: true });
|
|
1239
|
+
const dossierPath = join5(dir, "dossier.json");
|
|
364
1240
|
const tmp = dossierPath + ".tmp";
|
|
365
|
-
await
|
|
1241
|
+
await writeFile4(tmp, JSON.stringify(dossier, null, 2), "utf8");
|
|
366
1242
|
const { rename } = await import("fs/promises");
|
|
367
1243
|
await rename(tmp, dossierPath);
|
|
368
1244
|
await regenerateUI(projectRoot, dossier);
|
|
369
1245
|
});
|
|
370
1246
|
}
|
|
371
1247
|
async function regenerateUI(projectRoot, dossier) {
|
|
372
|
-
const uiDir =
|
|
373
|
-
await
|
|
374
|
-
let templateDir =
|
|
1248
|
+
const uiDir = join5(projectRoot, ".vibe-splainer", "ui");
|
|
1249
|
+
await mkdir3(uiDir, { recursive: true });
|
|
1250
|
+
let templateDir = join5(__dirname2, "ui");
|
|
375
1251
|
if (!existsSync3(templateDir)) {
|
|
376
|
-
templateDir =
|
|
1252
|
+
templateDir = join5(__dirname2, "../../cli/dist/ui");
|
|
377
1253
|
}
|
|
378
1254
|
if (!existsSync3(templateDir)) {
|
|
379
1255
|
console.error("[vibe-splain] UI template not found at", templateDir, "- skipping UI regeneration");
|
|
380
1256
|
return;
|
|
381
1257
|
}
|
|
382
1258
|
cpSync(templateDir, uiDir, { recursive: true });
|
|
383
|
-
let html = await
|
|
1259
|
+
let html = await readFile5(join5(templateDir, "index.html"), "utf8");
|
|
384
1260
|
const injection = `<script>window.__VIBE_DOSSIER__ = ${JSON.stringify(dossier)};</script>`;
|
|
385
1261
|
html = html.replace("<!-- VIBE_DOSSIER_INJECTION_POINT -->", injection);
|
|
386
|
-
await
|
|
387
|
-
console.error("[vibe-splain] UI regenerated at",
|
|
1262
|
+
await writeFile4(join5(uiDir, "index.html"), html, "utf8");
|
|
1263
|
+
console.error("[vibe-splain] UI regenerated at", join5(uiDir, "index.html"));
|
|
388
1264
|
}
|
|
389
1265
|
function validateMermaidNodeCount(diagram) {
|
|
390
1266
|
if (!diagram)
|
|
@@ -404,7 +1280,7 @@ function validateMermaidNodeCount(diagram) {
|
|
|
404
1280
|
// ../brain/dist/watcher.js
|
|
405
1281
|
import chokidar from "chokidar";
|
|
406
1282
|
import { createHash } from "crypto";
|
|
407
|
-
import { readFile as
|
|
1283
|
+
import { readFile as readFile6 } from "fs/promises";
|
|
408
1284
|
function startWatcher(projectRoot, watchedPaths) {
|
|
409
1285
|
const watcher = chokidar.watch(watchedPaths.length > 0 ? watchedPaths : projectRoot, {
|
|
410
1286
|
ignoreInitial: true,
|
|
@@ -416,7 +1292,7 @@ function startWatcher(projectRoot, watchedPaths) {
|
|
|
416
1292
|
const dossier = await readDossier(projectRoot);
|
|
417
1293
|
if (!dossier)
|
|
418
1294
|
return;
|
|
419
|
-
const content = await
|
|
1295
|
+
const content = await readFile6(filepath, "utf8");
|
|
420
1296
|
const newHash = createHash("sha256").update(content).digest("hex");
|
|
421
1297
|
let mutated = false;
|
|
422
1298
|
for (const pillar of dossier.pillars) {
|
|
@@ -443,7 +1319,7 @@ function startWatcher(projectRoot, watchedPaths) {
|
|
|
443
1319
|
// dist/mcp/tools/scan_project.js
|
|
444
1320
|
var scanProjectTool = {
|
|
445
1321
|
name: "scan_project",
|
|
446
|
-
description: "Scans a codebase and returns
|
|
1322
|
+
description: "Scans a codebase (TS/JS/Python/Go/Rust/Java) and returns a structural analysis. CALL THIS FIRST, then call get_project_map. Files are scored on two axes: GRAVITY (importance \u2014 fan-in + PageRank centrality) and HEAT (smell/tech-debt). Mockups, vendored code, and orphan files are demoted (isRealSource:false) so cards target the real application. After scanning, call get_project_map to get the fixed pillar set, Start-Here (top gravity) and Wild-Discovery (top heat) lists. The uiUrl is a file:// link \u2014 share it with the user.",
|
|
447
1323
|
inputSchema: {
|
|
448
1324
|
type: "object",
|
|
449
1325
|
properties: {
|
|
@@ -461,64 +1337,132 @@ async function handleScanProject(args) {
|
|
|
461
1337
|
throw new Error("projectRoot is required");
|
|
462
1338
|
console.error(`[vibe-splain] Scanning project: ${projectRoot}`);
|
|
463
1339
|
const result = await scanProject(projectRoot);
|
|
464
|
-
const
|
|
465
|
-
const
|
|
466
|
-
|
|
1340
|
+
const existing = await readDossier(projectRoot);
|
|
1341
|
+
const brief = existing?.map?.brief ?? null;
|
|
1342
|
+
const dossier = {
|
|
1343
|
+
version: "2.0.0",
|
|
467
1344
|
scannedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
468
1345
|
projectRoot,
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
1346
|
+
map: { ...result.map, brief },
|
|
1347
|
+
pillars: existing?.pillars ?? [],
|
|
1348
|
+
wildDiscoveries: existing?.wildDiscoveries ?? [],
|
|
1349
|
+
stalePaths: existing?.stalePaths ?? []
|
|
472
1350
|
};
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
if (!existingPillar) {
|
|
477
|
-
dossier.pillars.push({ name: group.name, cardCount: 0, decisions: [] });
|
|
1351
|
+
for (const def of result.map.pillars) {
|
|
1352
|
+
if (!dossier.pillars.find((p) => p.name === def.name)) {
|
|
1353
|
+
dossier.pillars.push({ name: def.name, cardCount: 0, decisions: [] });
|
|
478
1354
|
}
|
|
479
1355
|
}
|
|
480
1356
|
await writeDossier(projectRoot, dossier);
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
console.error(`[vibe-splain] Scan complete. ${result.totalFilesScanned} files scanned, ${result.highGravityFiles.length} high-gravity files found.`);
|
|
1357
|
+
startWatcher(projectRoot, result.files.map((f) => f.path));
|
|
1358
|
+
console.error(`[vibe-splain] Scan complete. ${result.totalFilesScanned} files, ${result.realSourceCount} real-source, ${result.wildCandidates.length} wild candidates.`);
|
|
484
1359
|
return {
|
|
485
1360
|
projectRoot: result.projectRoot,
|
|
486
1361
|
totalFilesScanned: result.totalFilesScanned,
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
name: g.name,
|
|
494
|
-
fileCount: g.files.length,
|
|
495
|
-
files: g.files.map((f) => f.relativePath)
|
|
496
|
-
})),
|
|
497
|
-
wildCandidates: result.wildCandidates.map((f) => ({
|
|
1362
|
+
realSourceCount: result.realSourceCount,
|
|
1363
|
+
stack: result.map.stack,
|
|
1364
|
+
entrypoints: result.map.entrypoints,
|
|
1365
|
+
pillars: result.map.pillars.map((p) => ({ name: p.name, fileCount: p.memberFiles.length })),
|
|
1366
|
+
startHere: result.map.topGravity,
|
|
1367
|
+
wildDiscoveryCandidates: result.wildCandidates.map((f) => ({
|
|
498
1368
|
relativePath: f.relativePath,
|
|
499
|
-
|
|
1369
|
+
heat: Math.round(f.heat),
|
|
1370
|
+
gravity: Math.round(f.gravity),
|
|
1371
|
+
topSmells: f.smells.filter((s) => s.severity >= 3).slice(0, 3).map((s) => s.note)
|
|
500
1372
|
})),
|
|
1373
|
+
nextStep: "Call get_project_map, write a project brief via set_project_brief, THEN write cards starting from the Start-Here files.",
|
|
501
1374
|
uiUrl: result.uiUrl
|
|
502
1375
|
};
|
|
503
1376
|
}
|
|
504
1377
|
|
|
1378
|
+
// dist/mcp/tools/get_project_map.js
|
|
1379
|
+
var getProjectMapTool = {
|
|
1380
|
+
name: "get_project_map",
|
|
1381
|
+
description: "Returns the project map produced by scan_project: the detected stack, entrypoints, the FIXED set of architectural pillars (you may NOT invent others \u2014 write_decision_card rejects unknown pillars), the Start-Here files (highest gravity = most depended-upon), and the Wild-Discovery candidates (highest heat = most tech debt). BEFORE writing any card you MUST: read this map, write a 3-5 sentence project brief, and persist it via set_project_brief.",
|
|
1382
|
+
inputSchema: {
|
|
1383
|
+
type: "object",
|
|
1384
|
+
properties: {
|
|
1385
|
+
projectRoot: { type: "string", description: "Absolute path to the project root" }
|
|
1386
|
+
},
|
|
1387
|
+
required: ["projectRoot"]
|
|
1388
|
+
}
|
|
1389
|
+
};
|
|
1390
|
+
async function handleGetProjectMap(args) {
|
|
1391
|
+
const projectRoot = args.projectRoot;
|
|
1392
|
+
if (!projectRoot)
|
|
1393
|
+
throw new Error("projectRoot is required");
|
|
1394
|
+
const dossier = await readDossier(projectRoot);
|
|
1395
|
+
if (!dossier || !dossier.map) {
|
|
1396
|
+
return { error: "No project map found. Run scan_project first." };
|
|
1397
|
+
}
|
|
1398
|
+
const m = dossier.map;
|
|
1399
|
+
return {
|
|
1400
|
+
stack: m.stack,
|
|
1401
|
+
entrypoints: m.entrypoints,
|
|
1402
|
+
fileCount: m.fileCount,
|
|
1403
|
+
realSourceCount: m.realSourceCount,
|
|
1404
|
+
pillars: m.pillars.map((p) => ({
|
|
1405
|
+
name: p.name,
|
|
1406
|
+
description: p.description,
|
|
1407
|
+
memberFiles: p.memberFiles
|
|
1408
|
+
})),
|
|
1409
|
+
legalPillarNames: m.pillars.map((p) => p.name),
|
|
1410
|
+
startHere: m.topGravity,
|
|
1411
|
+
wildDiscoveryCandidates: m.topHeat,
|
|
1412
|
+
brief: m.brief,
|
|
1413
|
+
nextStep: m.brief ? "Brief is set. Work the Start-Here files first via get_file_context, then write_decision_card." : "Write a 3-5 sentence brief and call set_project_brief BEFORE any card."
|
|
1414
|
+
};
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
// dist/mcp/tools/set_project_brief.js
|
|
1418
|
+
var setProjectBriefTool = {
|
|
1419
|
+
name: "set_project_brief",
|
|
1420
|
+
description: "Persists your 3-5 sentence project brief into the dossier (and regenerates the UI). Call this AFTER get_project_map and BEFORE writing any decision card. The brief must say: what this project IS, the real stack, and \u2014 critically \u2014 which files are the actual application vs. mockups/generated/vendored noise.",
|
|
1421
|
+
inputSchema: {
|
|
1422
|
+
type: "object",
|
|
1423
|
+
properties: {
|
|
1424
|
+
projectRoot: { type: "string", description: "Absolute path to the project root" },
|
|
1425
|
+
brief: { type: "string", description: "3-5 sentence project brief. What is this, the real stack, app vs. noise." }
|
|
1426
|
+
},
|
|
1427
|
+
required: ["projectRoot", "brief"]
|
|
1428
|
+
}
|
|
1429
|
+
};
|
|
1430
|
+
async function handleSetProjectBrief(args) {
|
|
1431
|
+
const projectRoot = args.projectRoot;
|
|
1432
|
+
const brief = args.brief;
|
|
1433
|
+
if (!projectRoot || !brief)
|
|
1434
|
+
throw new Error("projectRoot and brief are required");
|
|
1435
|
+
const dossier = await readDossier(projectRoot);
|
|
1436
|
+
if (!dossier || !dossier.map) {
|
|
1437
|
+
return { error: "No project map found. Run scan_project first." };
|
|
1438
|
+
}
|
|
1439
|
+
dossier.map.brief = brief;
|
|
1440
|
+
await writeDossier(projectRoot, dossier);
|
|
1441
|
+
const documented = new Set([...dossier.pillars.flatMap((p) => p.decisions), ...dossier.wildDiscoveries].map((c) => c.primaryFile).filter(Boolean));
|
|
1442
|
+
const startHere = dossier.map.topGravity.filter((f) => !documented.has(f));
|
|
1443
|
+
const wild = dossier.map.topHeat.filter((f) => !documented.has(f));
|
|
1444
|
+
const worklist = [.../* @__PURE__ */ new Set([...startHere, ...wild])];
|
|
1445
|
+
return {
|
|
1446
|
+
success: true,
|
|
1447
|
+
brief,
|
|
1448
|
+
remainingFiles: worklist,
|
|
1449
|
+
legalPillarNames: dossier.map.pillars.map((p) => p.name),
|
|
1450
|
+
nextStep: worklist.length === 0 ? "All files documented. Share the file:// UI link from scan_project." : `Brief saved. DO NOT STOP and DO NOT ask the user what to do next. Now loop: for EACH of the ${worklist.length} files in remainingFiles, call get_file_context then write_decision_card. Start with "${worklist[0]}". Continue until every file has a card, then share the file:// UI link.`
|
|
1451
|
+
};
|
|
1452
|
+
}
|
|
1453
|
+
|
|
505
1454
|
// dist/mcp/tools/get_file_context.js
|
|
506
|
-
import { readFile as
|
|
507
|
-
import { join as
|
|
1455
|
+
import { readFile as readFile7 } from "fs/promises";
|
|
1456
|
+
import { join as join6, relative as relative2, isAbsolute } from "path";
|
|
508
1457
|
var getFileContextTool = {
|
|
509
1458
|
name: "get_file_context",
|
|
510
|
-
description: "Returns
|
|
1459
|
+
description: "Returns PRE-EXTRACTED evidence for a file so you do not have to read the whole thing and paraphrase its header comment. Returns: gravity/heat scores + signals, importedBy (named fan-in \u2014 use this for blastRadius), hotSpans (the gnarliest function bodies, comment-stripped, each with a reason), smellSpans (located tech debt with \xB13 lines of context), and signature (the exported API surface). Base your evidence on hotSpans/smellSpans \u2014 NEVER on header comments. Pass { full: true } only if you truly need the raw source.",
|
|
511
1460
|
inputSchema: {
|
|
512
1461
|
type: "object",
|
|
513
1462
|
properties: {
|
|
514
|
-
projectRoot: {
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
},
|
|
518
|
-
filePath: {
|
|
519
|
-
type: "string",
|
|
520
|
-
description: "Relative or absolute path to the file"
|
|
521
|
-
}
|
|
1463
|
+
projectRoot: { type: "string", description: "Absolute path to the project root" },
|
|
1464
|
+
filePath: { type: "string", description: "Relative or absolute path to the file" },
|
|
1465
|
+
full: { type: "boolean", description: "Set true to also return the raw source. Default false." }
|
|
522
1466
|
},
|
|
523
1467
|
required: ["projectRoot", "filePath"]
|
|
524
1468
|
}
|
|
@@ -526,97 +1470,128 @@ var getFileContextTool = {
|
|
|
526
1470
|
async function handleGetFileContext(args) {
|
|
527
1471
|
const projectRoot = args.projectRoot;
|
|
528
1472
|
const filePath = args.filePath;
|
|
1473
|
+
const full = args.full === true;
|
|
529
1474
|
if (!projectRoot || !filePath)
|
|
530
1475
|
throw new Error("projectRoot and filePath are required");
|
|
531
|
-
const fullPath = filePath
|
|
1476
|
+
const fullPath = isAbsolute(filePath) ? filePath : join6(projectRoot, filePath);
|
|
532
1477
|
const relPath = relative2(projectRoot, fullPath);
|
|
533
|
-
const
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
if (graph) {
|
|
537
|
-
for (const edge of graph.edges) {
|
|
538
|
-
if (edge.from === relPath)
|
|
539
|
-
neighbors.push(edge.to);
|
|
540
|
-
if (edge.to === relPath || edge.to.endsWith(relPath))
|
|
541
|
-
neighbors.push(edge.from);
|
|
542
|
-
}
|
|
1478
|
+
const evidence = await getFileAnalysis(fullPath);
|
|
1479
|
+
if (!evidence) {
|
|
1480
|
+
throw new Error(`Could not analyze ${relPath} (unsupported language or parse failure).`);
|
|
543
1481
|
}
|
|
544
|
-
|
|
1482
|
+
const store = await readAnalysis(projectRoot);
|
|
1483
|
+
const persisted = store?.files[relPath];
|
|
1484
|
+
const result = {
|
|
545
1485
|
filePath: relPath,
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
1486
|
+
language: evidence.language,
|
|
1487
|
+
gravity: persisted ? Math.round(persisted.gravity) : null,
|
|
1488
|
+
heat: persisted ? Math.round(persisted.heat) : null,
|
|
1489
|
+
isRealSource: persisted?.isRealSource ?? null,
|
|
1490
|
+
demoteReason: persisted?.demoteReason ?? null,
|
|
1491
|
+
gravitySignals: persisted?.gravitySignals ?? null,
|
|
1492
|
+
heatSignals: evidence.heatSignals,
|
|
1493
|
+
importedBy: persisted?.importedBy ?? [],
|
|
1494
|
+
imports: persisted?.imports ?? [],
|
|
1495
|
+
signature: evidence.signature,
|
|
1496
|
+
hotSpans: evidence.hotSpans,
|
|
1497
|
+
smellSpans: evidence.smellSpans
|
|
549
1498
|
};
|
|
1499
|
+
if (full) {
|
|
1500
|
+
result.source = await readFile7(fullPath, "utf8");
|
|
1501
|
+
}
|
|
1502
|
+
return result;
|
|
550
1503
|
}
|
|
551
1504
|
|
|
552
1505
|
// dist/mcp/tools/write_decision_card.js
|
|
553
1506
|
import { v4 as uuidv4 } from "uuid";
|
|
554
1507
|
import { createHash as createHash2 } from "crypto";
|
|
555
|
-
import { readFile as
|
|
556
|
-
import { join as
|
|
1508
|
+
import { readFile as readFile8 } from "fs/promises";
|
|
1509
|
+
import { join as join7 } from "path";
|
|
1510
|
+
var CATEGORIES = ["Bottleneck", "Hack", "Smart-Move", "Risk", "Convention", "Dead-Weight"];
|
|
557
1511
|
var writeDecisionCardTool = {
|
|
558
1512
|
name: "write_decision_card",
|
|
559
|
-
description: "Persists
|
|
1513
|
+
description: "Persists ONE Decision Card about ONE file. This is a hostile architecture review, not documentation. The thesis must be a VERDICT, not a description. The pillar MUST be one of the names from get_project_map (free-form is rejected). One card per file (duplicates rejected). Evidence must come from get_file_context hotSpans/smellSpans \u2014 never the header comment, never the whole file.",
|
|
560
1514
|
inputSchema: {
|
|
561
1515
|
type: "object",
|
|
562
1516
|
properties: {
|
|
563
|
-
projectRoot: {
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
},
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
},
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
},
|
|
575
|
-
narrative: {
|
|
576
|
-
type: "string",
|
|
577
|
-
description: "3-5 sentences explaining WHY this code exists"
|
|
578
|
-
},
|
|
1517
|
+
projectRoot: { type: "string" },
|
|
1518
|
+
pillar: { type: "string", description: "MUST be one of the pillar names from get_project_map. Free-form values are rejected." },
|
|
1519
|
+
primaryFile: { type: "string", description: "The single file this card is about (relative path). Used to reject duplicate cards." },
|
|
1520
|
+
title: { type: "string" },
|
|
1521
|
+
thesis: { type: "string", description: "ONE sharp sentence. A verdict, not a description. Take a position. Bad: 'This file implements a panel system.' Good: 'A 600-line god-component that owns drag, zoom, persistence AND the host bridge \u2014 the single highest-risk refactor in the app.'" },
|
|
1522
|
+
category: { type: "string", enum: CATEGORIES },
|
|
1523
|
+
severity: { type: "integer", minimum: 1, maximum: 5 },
|
|
1524
|
+
narrative: { type: "string", description: "3-5 sentences. WHY it exists and WHY it's built this way. Do NOT restate the file's header comments." },
|
|
1525
|
+
tradeoff: { type: "string", description: "What was given up, or why the obvious approach was rejected. Null only if genuinely none." },
|
|
1526
|
+
blastRadius: { type: "string", description: "What breaks if this changes. Ground it in the fan-in (importedBy) from get_file_context." },
|
|
1527
|
+
confidence: { type: "string", enum: ["low", "medium", "high"] },
|
|
579
1528
|
evidence: {
|
|
580
1529
|
type: "array",
|
|
581
1530
|
items: {
|
|
582
1531
|
type: "object",
|
|
583
1532
|
properties: {
|
|
584
|
-
file: { type: "string"
|
|
585
|
-
startLine: { type: "number"
|
|
586
|
-
endLine: { type: "number"
|
|
587
|
-
snippet: { type: "string"
|
|
1533
|
+
file: { type: "string" },
|
|
1534
|
+
startLine: { type: "number" },
|
|
1535
|
+
endLine: { type: "number" },
|
|
1536
|
+
snippet: { type: "string" }
|
|
588
1537
|
},
|
|
589
1538
|
required: ["file", "startLine", "endLine", "snippet"]
|
|
590
1539
|
},
|
|
591
|
-
description: "
|
|
1540
|
+
description: "Use hotSpans/smellSpans from get_file_context. NEVER cite header comments or the whole file."
|
|
592
1541
|
},
|
|
593
|
-
diagram: {
|
|
594
|
-
type: "string",
|
|
595
|
-
description: "Optional Mermaid diagram (stateDiagram-v2, flowchart TD, or linear style). Max 7 nodes."
|
|
596
|
-
}
|
|
1542
|
+
diagram: { type: "string", description: "Optional. stateDiagram-v2 / flowchart TD / linear. Max 7 nodes." }
|
|
597
1543
|
},
|
|
598
|
-
required: ["projectRoot", "pillar", "title", "narrative", "evidence"]
|
|
1544
|
+
required: ["projectRoot", "pillar", "primaryFile", "title", "thesis", "category", "severity", "narrative", "confidence", "evidence"]
|
|
599
1545
|
}
|
|
600
1546
|
};
|
|
601
1547
|
async function handleWriteDecisionCard(args) {
|
|
602
1548
|
const projectRoot = args.projectRoot;
|
|
603
1549
|
const pillar = args.pillar;
|
|
1550
|
+
const primaryFile = args.primaryFile;
|
|
604
1551
|
const title = args.title;
|
|
1552
|
+
const thesis = args.thesis;
|
|
1553
|
+
const category = args.category;
|
|
1554
|
+
const severity = args.severity;
|
|
605
1555
|
const narrative = args.narrative;
|
|
1556
|
+
const tradeoff = args.tradeoff || null;
|
|
1557
|
+
const blastRadius = args.blastRadius || null;
|
|
1558
|
+
const confidence = args.confidence || "medium";
|
|
606
1559
|
const evidence = args.evidence;
|
|
607
1560
|
const diagram = args.diagram || null;
|
|
608
|
-
if (!projectRoot || !pillar || !title || !narrative || !evidence) {
|
|
609
|
-
throw new Error("projectRoot, pillar, title, narrative, and evidence are required");
|
|
1561
|
+
if (!projectRoot || !pillar || !primaryFile || !title || !thesis || !category || !narrative || !evidence) {
|
|
1562
|
+
throw new Error("projectRoot, pillar, primaryFile, title, thesis, category, narrative, and evidence are required");
|
|
1563
|
+
}
|
|
1564
|
+
if (!CATEGORIES.includes(category)) {
|
|
1565
|
+
throw new Error(`Invalid category "${category}". Must be one of: ${CATEGORIES.join(", ")}`);
|
|
610
1566
|
}
|
|
611
1567
|
if (diagram && !validateMermaidNodeCount(diagram)) {
|
|
612
1568
|
throw new Error("Mermaid diagram exceeds maximum of 7 nodes. Simplify the diagram.");
|
|
613
1569
|
}
|
|
1570
|
+
const dossier = await readDossier(projectRoot);
|
|
1571
|
+
if (!dossier || !dossier.map) {
|
|
1572
|
+
throw new Error("No project map found. Run scan_project and set_project_brief before writing cards.");
|
|
1573
|
+
}
|
|
1574
|
+
const legalPillars = dossier.map.pillars.map((p) => p.name);
|
|
1575
|
+
if (!legalPillars.includes(pillar)) {
|
|
1576
|
+
throw new Error(`Pillar "${pillar}" is not a legal pillar. Use one of: ${legalPillars.join(", ")}. Pillars are fixed by the scan \u2014 you may not invent new ones.`);
|
|
1577
|
+
}
|
|
1578
|
+
const existing = [...dossier.pillars.flatMap((p) => p.decisions), ...dossier.wildDiscoveries].find((c) => c.primaryFile === primaryFile);
|
|
1579
|
+
if (existing) {
|
|
1580
|
+
if (existing.status === "fresh") {
|
|
1581
|
+
throw new Error(`A card already exists for "${primaryFile}". One card per file. To revise it, call mark_stale on this file and rewrite, or pick a different file.`);
|
|
1582
|
+
}
|
|
1583
|
+
for (const p of dossier.pillars)
|
|
1584
|
+
p.decisions = p.decisions.filter((c) => c.id !== existing.id);
|
|
1585
|
+
dossier.wildDiscoveries = dossier.wildDiscoveries.filter((c) => c.id !== existing.id);
|
|
1586
|
+
}
|
|
1587
|
+
const store = await readAnalysis(projectRoot);
|
|
1588
|
+
const persisted = store?.files[primaryFile];
|
|
1589
|
+
const gravity = persisted ? Math.round(persisted.gravity) : void 0;
|
|
1590
|
+
const heat = persisted ? Math.round(persisted.heat) : void 0;
|
|
614
1591
|
let combinedContent = "";
|
|
615
1592
|
for (const e of evidence) {
|
|
616
1593
|
try {
|
|
617
|
-
|
|
618
|
-
const content = await readFile7(fullPath, "utf8");
|
|
619
|
-
combinedContent += content;
|
|
1594
|
+
combinedContent += await readFile8(join7(projectRoot, e.file), "utf8");
|
|
620
1595
|
} catch {
|
|
621
1596
|
combinedContent += e.snippet;
|
|
622
1597
|
}
|
|
@@ -626,37 +1601,48 @@ async function handleWriteDecisionCard(args) {
|
|
|
626
1601
|
id: uuidv4(),
|
|
627
1602
|
pillar,
|
|
628
1603
|
title,
|
|
1604
|
+
thesis,
|
|
1605
|
+
category,
|
|
1606
|
+
severity,
|
|
629
1607
|
narrative,
|
|
1608
|
+
tradeoff,
|
|
1609
|
+
blastRadius,
|
|
1610
|
+
confidence,
|
|
630
1611
|
evidence,
|
|
631
1612
|
diagram,
|
|
1613
|
+
gravity,
|
|
1614
|
+
heat,
|
|
1615
|
+
primaryFile,
|
|
632
1616
|
status: "fresh",
|
|
633
1617
|
lastScannedHash: hash
|
|
634
1618
|
};
|
|
635
|
-
|
|
636
|
-
if (
|
|
637
|
-
dossier
|
|
638
|
-
version: "1.0.0",
|
|
639
|
-
scannedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
640
|
-
projectRoot,
|
|
641
|
-
pillars: [],
|
|
642
|
-
wildDiscoveries: [],
|
|
643
|
-
stalePaths: []
|
|
644
|
-
};
|
|
1619
|
+
const isWild = severity >= 4 || heat !== void 0 && heat >= 60;
|
|
1620
|
+
if (isWild) {
|
|
1621
|
+
dossier.wildDiscoveries.push(card);
|
|
645
1622
|
}
|
|
646
|
-
let
|
|
647
|
-
if (!
|
|
648
|
-
|
|
649
|
-
dossier.pillars.push(
|
|
1623
|
+
let bucket = dossier.pillars.find((p) => p.name === pillar);
|
|
1624
|
+
if (!bucket) {
|
|
1625
|
+
bucket = { name: pillar, cardCount: 0, decisions: [] };
|
|
1626
|
+
dossier.pillars.push(bucket);
|
|
650
1627
|
}
|
|
651
|
-
|
|
652
|
-
|
|
1628
|
+
bucket.decisions.push(card);
|
|
1629
|
+
bucket.cardCount = bucket.decisions.length;
|
|
653
1630
|
await writeDossier(projectRoot, dossier);
|
|
654
|
-
console.error(`[vibe-splain]
|
|
1631
|
+
console.error(`[vibe-splain] Card written: "${title}" [${category} sev${severity}] in "${pillar}"${isWild ? " (Wild Discovery)" : ""}`);
|
|
1632
|
+
const documented = new Set([...dossier.pillars.flatMap((p) => p.decisions), ...dossier.wildDiscoveries].map((c) => c.primaryFile).filter(Boolean));
|
|
1633
|
+
const remaining = [.../* @__PURE__ */ new Set([...dossier.map.topGravity, ...dossier.map.topHeat])].filter((f) => !documented.has(f));
|
|
655
1634
|
return {
|
|
656
1635
|
success: true,
|
|
657
1636
|
cardId: card.id,
|
|
658
1637
|
pillar,
|
|
659
|
-
|
|
1638
|
+
primaryFile,
|
|
1639
|
+
category,
|
|
1640
|
+
severity,
|
|
1641
|
+
wildDiscovery: isWild,
|
|
1642
|
+
gravity,
|
|
1643
|
+
heat,
|
|
1644
|
+
remainingFiles: remaining,
|
|
1645
|
+
nextStep: remaining.length === 0 ? "Every Start-Here and Wild-Discovery file now has a card. Share the file:// UI link from scan_project. Done." : `Card saved. DO NOT STOP. ${remaining.length} files left. Next: call get_file_context then write_decision_card for "${remaining[0]}".`
|
|
660
1646
|
};
|
|
661
1647
|
}
|
|
662
1648
|
|
|
@@ -726,17 +1712,17 @@ var inspectPillarTool = {
|
|
|
726
1712
|
};
|
|
727
1713
|
async function handleInspectPillar(args) {
|
|
728
1714
|
const projectRoot = args.projectRoot;
|
|
729
|
-
const
|
|
730
|
-
if (!projectRoot || !
|
|
1715
|
+
const pillarName2 = args.pillarName;
|
|
1716
|
+
if (!projectRoot || !pillarName2)
|
|
731
1717
|
throw new Error("projectRoot and pillarName are required");
|
|
732
1718
|
const dossier = await readDossier(projectRoot);
|
|
733
1719
|
if (!dossier) {
|
|
734
1720
|
return { error: "No dossier found. Run scan_project first." };
|
|
735
1721
|
}
|
|
736
|
-
const pillar = dossier.pillars.find((p) => p.name ===
|
|
1722
|
+
const pillar = dossier.pillars.find((p) => p.name === pillarName2);
|
|
737
1723
|
if (!pillar) {
|
|
738
1724
|
return {
|
|
739
|
-
error: `Pillar "${
|
|
1725
|
+
error: `Pillar "${pillarName2}" not found. Available pillars: ${dossier.pillars.map((p) => p.name).join(", ")}`
|
|
740
1726
|
};
|
|
741
1727
|
}
|
|
742
1728
|
return pillar;
|
|
@@ -802,14 +1788,19 @@ async function handleMarkStale(args) {
|
|
|
802
1788
|
}
|
|
803
1789
|
let staleCount = 0;
|
|
804
1790
|
for (const filePath of filePaths) {
|
|
1791
|
+
const matches = (card) => card.primaryFile === filePath || filePath.endsWith(card.primaryFile || "\0") || card.evidence.some((e) => e.file === filePath || filePath.endsWith(e.file));
|
|
805
1792
|
for (const pillar of dossier.pillars) {
|
|
806
1793
|
for (const card of pillar.decisions) {
|
|
807
|
-
if (card
|
|
1794
|
+
if (matches(card)) {
|
|
808
1795
|
card.status = "stale";
|
|
809
1796
|
staleCount++;
|
|
810
1797
|
}
|
|
811
1798
|
}
|
|
812
1799
|
}
|
|
1800
|
+
for (const card of dossier.wildDiscoveries) {
|
|
1801
|
+
if (matches(card))
|
|
1802
|
+
card.status = "stale";
|
|
1803
|
+
}
|
|
813
1804
|
if (!dossier.stalePaths.includes(filePath)) {
|
|
814
1805
|
dossier.stalePaths.push(filePath);
|
|
815
1806
|
}
|
|
@@ -825,6 +1816,8 @@ async function handleMarkStale(args) {
|
|
|
825
1816
|
// dist/mcp/server.js
|
|
826
1817
|
var ALL_TOOLS = [
|
|
827
1818
|
scanProjectTool,
|
|
1819
|
+
getProjectMapTool,
|
|
1820
|
+
setProjectBriefTool,
|
|
828
1821
|
getFileContextTool,
|
|
829
1822
|
writeDecisionCardTool,
|
|
830
1823
|
getStrategicOverviewTool,
|
|
@@ -834,6 +1827,8 @@ var ALL_TOOLS = [
|
|
|
834
1827
|
];
|
|
835
1828
|
var TOOL_HANDLERS = {
|
|
836
1829
|
scan_project: handleScanProject,
|
|
1830
|
+
get_project_map: handleGetProjectMap,
|
|
1831
|
+
set_project_brief: handleSetProjectBrief,
|
|
837
1832
|
get_file_context: handleGetFileContext,
|
|
838
1833
|
write_decision_card: handleWriteDecisionCard,
|
|
839
1834
|
get_strategic_overview: handleGetStrategicOverview,
|
|
@@ -844,7 +1839,7 @@ var TOOL_HANDLERS = {
|
|
|
844
1839
|
async function startMCPServer() {
|
|
845
1840
|
await initParser();
|
|
846
1841
|
console.error("[vibe-splain] Tree-Sitter parser initialized");
|
|
847
|
-
const server = new Server({ name: "vibe-splain", version: "
|
|
1842
|
+
const server = new Server({ name: "vibe-splain", version: "2.0.0" }, { capabilities: { tools: {}, prompts: {} } });
|
|
848
1843
|
server.setRequestHandler(ListPromptsRequestSchema, async () => ({
|
|
849
1844
|
prompts: [
|
|
850
1845
|
{
|
|
@@ -864,7 +1859,65 @@ async function startMCPServer() {
|
|
|
864
1859
|
role: "user",
|
|
865
1860
|
content: {
|
|
866
1861
|
type: "text",
|
|
867
|
-
text:
|
|
1862
|
+
text: `You are a skeptical staff engineer doing a HOSTILE architecture review of this codebase.
|
|
1863
|
+
You are NOT writing documentation. You are finding the load-bearing walls, the landmines,
|
|
1864
|
+
and the clever moves, and you are taking positions on them.
|
|
1865
|
+
|
|
1866
|
+
PROCESS \u2014 follow in order:
|
|
1867
|
+
1. Call scan_project, then get_project_map. The map gives you: the detected stack,
|
|
1868
|
+
the FIXED set of pillars (you may not invent others), the Start-Here files (highest
|
|
1869
|
+
gravity = most depended-upon), and Wild-Discovery candidates (highest heat = most smell).
|
|
1870
|
+
2. Read the map's stack and entrypoints. Write a 3-5 sentence project brief: what IS this,
|
|
1871
|
+
what's the real stack, and \u2014 critically \u2014 which files are the actual application vs.
|
|
1872
|
+
mockups/generated/vendored noise. Pass it via set_project_brief. Do this BEFORE any card.
|
|
1873
|
+
3. Work the Start-Here files first (highest gravity), then the Wild-Discovery files.
|
|
1874
|
+
For each, call get_file_context. It returns hotSpans (the gnarliest functions) and
|
|
1875
|
+
smellSpans (located tech debt) \u2014 base your evidence on THOSE, never on header comments.
|
|
1876
|
+
4. Write one decision card per file via write_decision_card.
|
|
1877
|
+
|
|
1878
|
+
This is an AUTONOMOUS loop. Every tool response includes a \`nextStep\` and often a
|
|
1879
|
+
\`remainingFiles\` list \u2014 OBEY them. Do NOT stop, summarize, or ask the user "how would
|
|
1880
|
+
you like to proceed" until every Start-Here and Wild-Discovery file has a card. Writing
|
|
1881
|
+
the brief is the START of the work, not the end. Keep calling get_file_context +
|
|
1882
|
+
write_decision_card until remainingFiles is empty.
|
|
1883
|
+
|
|
1884
|
+
RULES FOR EVERY CARD \u2014 non-negotiable:
|
|
1885
|
+
- The \`thesis\` is a VERDICT in one sentence. Take a position. If you can't, you don't
|
|
1886
|
+
understand the file yet \u2014 read more.
|
|
1887
|
+
- Pick a \`category\`: Bottleneck, Hack, Smart-Move, Risk, Convention, or Dead-Weight.
|
|
1888
|
+
- \`blastRadius\` must reference the real fan-in (get_file_context.importedBy).
|
|
1889
|
+
- NEVER paraphrase the file's own comments. If the insight is already in a // block,
|
|
1890
|
+
it is not insight \u2014 go deeper into the logic.
|
|
1891
|
+
- Evidence = 5-20 lines of the ACTUAL interesting code (hotSpans/smellSpans). Never the
|
|
1892
|
+
whole file, never the doc-header.
|
|
1893
|
+
- For every Wild-Discovery candidate, name the specific smell and rate its severity.
|
|
1894
|
+
|
|
1895
|
+
\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1896
|
+
EXAMPLE \u2014 what GOOD vs BAD looks like:
|
|
1897
|
+
|
|
1898
|
+
BAD (rejected \u2014 this is a book report):
|
|
1899
|
+
title: "Panel Component Framework"
|
|
1900
|
+
narrative: "This module establishes the structural framework for the panel-based
|
|
1901
|
+
interface. It defines the generic Panel shell that standardizes look and feel..."
|
|
1902
|
+
\u2192 Restates the header comment. No position. No risk. No tradeoff. Worthless.
|
|
1903
|
+
|
|
1904
|
+
GOOD (accepted):
|
|
1905
|
+
title: "Panel shell carries 14 props and 6 tools in one file"
|
|
1906
|
+
thesis: "cipher-panels-a.jsx is a god-file: one 600-line module owns the shared shell
|
|
1907
|
+
AND three unrelated generators, so any panel change risks all of them."
|
|
1908
|
+
category: "Risk" severity: 4
|
|
1909
|
+
narrative: "Panel was built as a single shell to guarantee visual consistency, but the
|
|
1910
|
+
three generators (Palette/Vibe/Pocket) were folded in beside it instead of
|
|
1911
|
+
split out. The shell threads 14 props through every tool, so the generators
|
|
1912
|
+
are now coupled to the shell's drag/compact state they don't use."
|
|
1913
|
+
tradeoff: "Bought consistency and one import site; paid with a module no one can change
|
|
1914
|
+
safely and props that leak shell concerns into pure generators."
|
|
1915
|
+
blastRadius: "Imported by cipher-shell.jsx (the app root) \u2014 a regression here is a
|
|
1916
|
+
full-app regression."
|
|
1917
|
+
evidence: [ the 14-param Panel signature; the prop-drill into PalettePanel ]
|
|
1918
|
+
\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1919
|
+
|
|
1920
|
+
When done, share the exact file:// UI link returned by scan_project. Never invent a URL.`
|
|
868
1921
|
}
|
|
869
1922
|
}
|
|
870
1923
|
]
|