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 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 join3, dirname, relative, extname } from "path";
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 readFile3, readdir } from "fs/promises";
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 readGraph(projectRoot) {
101
- const graphPath = join2(projectRoot, ".vibe-splainer", "graph.json");
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 readFile2(graphPath, "utf8");
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 writeGraph(projectRoot, graph) {
110
- const dir = join2(projectRoot, ".vibe-splainer");
111
- await mkdir(dir, { recursive: true });
112
- const graphPath = join2(dir, "graph.json");
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
- let wasmPath;
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
- const wasmsDir = dirname(require2.resolve("tree-sitter-wasms/package.json"));
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
- wasmPath = join3(__dirname, "../wasm", "tree-sitter-typescript.wasm");
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(["node_modules", "dist", "build", ".next", ".vibe-splainer", ".git", ".venv", "venv", "env", "__pycache__", ".idea", ".vscode", ".cache"]);
139
- var EXCLUDE_PATTERNS = [/\.test\./, /\.spec\./, /\.config\./, /\.lock$/, /\.min\.js$/, /\.d\.ts$/];
140
- var SUPPORTED_EXTENSIONS = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx"]);
141
- var PILLAR_KEYWORDS = {
142
- "Auth": ["passport", "jsonwebtoken", "bcrypt", "oauth", "session", "cookie-parser"],
143
- "Database": ["prisma", "mongoose", "sequelize", "typeorm", "knex", "pg", "mysql2"],
144
- "Payments": ["stripe", "paypal", "braintree", "plaid"],
145
- "Routing": ["express.Router", "fastify", "koa-router", "next/router"],
146
- "Queue": ["bull", "bullmq", "amqplib", "kafka", "redis"],
147
- "Storage": ["aws-sdk", "s3", "multer", "cloudinary", "@google-cloud/storage"],
148
- "Config": ["dotenv", "convict", "zod"]
149
- };
150
- async function collectFiles(dir, projectRoot) {
151
- const files = [];
152
- const entries = await readdir(dir, { withFileTypes: true });
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 = join3(dir, entry.name);
261
+ const fullPath = join4(dir, entry.name);
157
262
  if (entry.isDirectory()) {
158
- const subFiles = await collectFiles(fullPath, projectRoot);
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
- const relPath = relative(projectRoot, fullPath);
165
- if (EXCLUDE_PATTERNS.some((p) => p.test(relPath)))
268
+ if (EXCLUDE_FILE_PATTERNS.some((p) => p.test(entry.name)))
166
269
  continue;
167
- files.push(fullPath);
270
+ acc.push(fullPath);
168
271
  }
169
272
  }
170
- return files;
171
273
  }
172
- function detectPillars(source) {
173
- const pillars = [];
174
- for (const [pillar, keywords] of Object.entries(PILLAR_KEYWORDS)) {
175
- for (const kw of keywords) {
176
- if (source.includes(kw)) {
177
- if (!pillars.includes(pillar))
178
- pillars.push(pillar);
179
- break;
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
- return pillars;
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 computeNestingDepth(node, depth = 0) {
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
- if (nestingTypes.has(child.type)) {
205
- const childMax = computeNestingDepth(child, depth + 1);
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 countMutations(node) {
215
- let count = 0;
216
- if (node.type === "assignment_expression" || node.type === "augmented_assignment_expression") {
217
- const parent = node.parent;
218
- if (!parent || parent.type !== "variable_declarator" || !parent.parent || parent.parent.type !== "lexical_declaration" || parent.parent.children[0]?.text !== "const") {
219
- count++;
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
- for (const child of node.children) {
223
- count += countMutations(child);
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
- return count;
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 countImports(node) {
228
- let count = 0;
229
- for (const child of node.children) {
230
- if (child.type === "import_statement" || child.type === "import_declaration") {
231
- count++;
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
- return count;
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 extractImportPaths(source) {
237
- const paths = [];
238
- const importRegex = /(?:import|require)\s*\(?\s*['"]([^'"]+)['"]/g;
239
- let match;
240
- while ((match = importRegex.exec(source)) !== null) {
241
- paths.push(match[1]);
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
- return paths;
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
- async function scanProject(projectRoot) {
246
- const p = await initParser();
247
- const files = await collectFiles(projectRoot, projectRoot);
248
- const graph = { nodes: {}, edges: [] };
249
- const fileImportMap = /* @__PURE__ */ new Map();
250
- const reverseImportCount = /* @__PURE__ */ new Map();
251
- for (const file of files) {
252
- const source = await readFile3(file, "utf8");
253
- const relPath = relative(projectRoot, file);
254
- const importPaths = extractImportPaths(source);
255
- fileImportMap.set(file, importPaths);
256
- graph.nodes[relPath] = { imports: importPaths };
257
- for (const imp of importPaths) {
258
- if (imp.startsWith(".")) {
259
- const resolvedDir = dirname(file);
260
- const resolved = join3(resolvedDir, imp);
261
- for (const ext of [".ts", ".tsx", ".js", ".jsx", "/index.ts", "/index.tsx", "/index.js"]) {
262
- const candidate = resolved.endsWith(ext) ? resolved : resolved + ext;
263
- const relCandidate = relative(projectRoot, candidate);
264
- reverseImportCount.set(relCandidate, (reverseImportCount.get(relCandidate) || 0) + 1);
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 allAnalyzed = [];
270
- for (const file of files) {
271
- const source = await readFile3(file, "utf8");
272
- const relPath = relative(projectRoot, file);
273
- const pillars = detectPillars(source);
274
- let tree;
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
- tree = p.parse(source);
958
+ source = await readFile4(file, "utf8");
277
959
  } catch {
278
960
  continue;
279
961
  }
280
- const importCount = countImports(tree.rootNode);
281
- const reverseCount = reverseImportCount.get(relPath) || 0;
282
- const linkDensity = importCount + reverseCount;
283
- const nestingDepth = computeNestingDepth(tree.rootNode);
284
- const mutationCount = countMutations(tree.rootNode);
285
- const cognitiveWeight = linkDensity * 2 + nestingDepth + mutationCount * 1.5;
286
- const importPaths = fileImportMap.get(file) || [];
287
- for (const imp of importPaths) {
288
- graph.edges.push({ from: relPath, to: imp });
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 highGravityFiles = allAnalyzed.filter((f) => f.cognitiveWeight >= 15).sort((a, b) => b.cognitiveWeight - a.cognitiveWeight);
301
- const pillarMap = /* @__PURE__ */ new Map();
302
- const untaggedFiles = [];
303
- for (const file of highGravityFiles) {
304
- if (file.pillars.length > 0) {
305
- for (const pillar of file.pillars) {
306
- if (!pillarMap.has(pillar))
307
- pillarMap.set(pillar, []);
308
- pillarMap.get(pillar).push(file);
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
- untaggedFiles.push(file);
1000
+ isRealSource.set(w.rel, true);
1001
+ demoteReason.set(w.rel, null);
312
1002
  }
313
1003
  }
314
- const dirGroups = /* @__PURE__ */ new Map();
315
- for (const file of untaggedFiles) {
316
- const dir = dirname(file.relativePath);
317
- if (!dirGroups.has(dir))
318
- dirGroups.set(dir, []);
319
- dirGroups.get(dir).push(file);
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 pillarGroups = [];
322
- for (const [name, files2] of pillarMap) {
323
- pillarGroups.push({ name, files: files2 });
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 [name, files2] of dirGroups) {
326
- pillarGroups.push({ name, files: files2 });
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 wildCandidates = highGravityFiles.filter((f) => f.cognitiveWeight >= 25);
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
- const uiUrl = `file://${join3(projectRoot, ".vibe-splainer", "ui", "index.html")}`;
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: files.length,
334
- highGravityFiles,
335
- pillarGroups,
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 join4, dirname as dirname2 } from "path";
1220
+ import { join as join5, dirname as dirname2 } from "path";
345
1221
  import { fileURLToPath as fileURLToPath2 } from "url";
346
- import { readFile as readFile4, writeFile as writeFile3, mkdir as mkdir2 } from "fs/promises";
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 = join4(projectRoot, ".vibe-splainer", "dossier.json");
1227
+ const dossierPath = join5(projectRoot, ".vibe-splainer", "dossier.json");
352
1228
  try {
353
- const raw = await readFile4(dossierPath, "utf8");
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 = join4(projectRoot, ".vibe-splainer");
362
- await mkdir2(dir, { recursive: true });
363
- const dossierPath = join4(dir, "dossier.json");
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 writeFile3(tmp, JSON.stringify(dossier, null, 2), "utf8");
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 = join4(projectRoot, ".vibe-splainer", "ui");
373
- await mkdir2(uiDir, { recursive: true });
374
- let templateDir = join4(__dirname2, "ui");
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 = join4(__dirname2, "../../cli/dist/ui");
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 readFile4(join4(templateDir, "index.html"), "utf8");
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 writeFile3(join4(uiDir, "index.html"), html, "utf8");
387
- console.error("[vibe-splain] UI regenerated at", join4(uiDir, "index.html"));
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 readFile5 } from "fs/promises";
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 readFile5(filepath, "utf8");
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 its structural analysis. CALL THIS FIRST before any other tool. Returns High-Gravity files grouped by pillar, plus wildCandidates for unusual high-complexity files. After calling this tool, call get_file_context for each file in highGravityFiles, synthesize a narrative explaining WHY that code exists, then call write_decision_card to persist it. The uiUrl in the response is a file:// link \u2014 share it with the user so they can open the Dossier UI in their browser.",
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 existingDossier = await readDossier(projectRoot);
465
- const dossier = existingDossier || {
466
- version: "1.0.0",
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
- pillars: [],
470
- wildDiscoveries: [],
471
- stalePaths: []
1346
+ map: { ...result.map, brief },
1347
+ pillars: existing?.pillars ?? [],
1348
+ wildDiscoveries: existing?.wildDiscoveries ?? [],
1349
+ stalePaths: existing?.stalePaths ?? []
472
1350
  };
473
- dossier.scannedAt = (/* @__PURE__ */ new Date()).toISOString();
474
- for (const group of result.pillarGroups) {
475
- const existingPillar = dossier.pillars.find((p) => p.name === group.name);
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
- const watchPaths = result.highGravityFiles.map((f) => f.path);
482
- startWatcher(projectRoot, watchPaths);
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
- highGravityFiles: result.highGravityFiles.map((f) => ({
488
- relativePath: f.relativePath,
489
- cognitiveWeight: f.cognitiveWeight,
490
- pillars: f.pillars
491
- })),
492
- pillarGroups: result.pillarGroups.map((g) => ({
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
- cognitiveWeight: f.cognitiveWeight
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 readFile6 } from "fs/promises";
507
- import { join as join5, relative as relative2 } from "path";
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 the full source code of a specific high-gravity file, its cognitive weight breakdown, and its import graph neighbors. Call this for each file you want to synthesize a Decision Card for. Use the source + neighbors to understand what the code does and WHY it was written that way.",
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
- type: "string",
516
- description: "Absolute path to the project root"
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.startsWith("/") ? filePath : join5(projectRoot, filePath);
1476
+ const fullPath = isAbsolute(filePath) ? filePath : join6(projectRoot, filePath);
532
1477
  const relPath = relative2(projectRoot, fullPath);
533
- const source = await readFile6(fullPath, "utf8");
534
- const graph = await readGraph(projectRoot);
535
- const neighbors = [];
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
- return {
1482
+ const store = await readAnalysis(projectRoot);
1483
+ const persisted = store?.files[relPath];
1484
+ const result = {
545
1485
  filePath: relPath,
546
- source,
547
- lineCount: source.split("\n").length,
548
- neighbors: [...new Set(neighbors)]
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 readFile7 } from "fs/promises";
556
- import { join as join6 } from "path";
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 a Decision Card you have synthesized to the project's dossier. The narrative should be 3\u20135 sentences explaining WHY this code exists. Evidence must reference specific line ranges from the actual source. Diagrams are optional but use only stateDiagram-v2, flowchart TD, or linear A-->B-->C style, max 7 nodes. Will reject diagrams with more than 7 nodes.",
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
- type: "string",
565
- description: "Absolute path to the project root"
566
- },
567
- pillar: {
568
- type: "string",
569
- description: "The pillar this card belongs to (e.g., Auth, Database, etc.)"
570
- },
571
- title: {
572
- type: "string",
573
- description: "Short title for the decision card"
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", description: "Relative file path" },
585
- startLine: { type: "number", description: "Start line number" },
586
- endLine: { type: "number", description: "End line number" },
587
- snippet: { type: "string", description: "Code snippet from the file" }
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: "Array of evidence items referencing specific code"
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
- const fullPath = join6(projectRoot, e.file);
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
- let dossier = await readDossier(projectRoot);
636
- if (!dossier) {
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 existingPillar = dossier.pillars.find((p) => p.name === pillar);
647
- if (!existingPillar) {
648
- existingPillar = { name: pillar, cardCount: 0, decisions: [] };
649
- dossier.pillars.push(existingPillar);
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
- existingPillar.decisions.push(card);
652
- existingPillar.cardCount = existingPillar.decisions.length;
1628
+ bucket.decisions.push(card);
1629
+ bucket.cardCount = bucket.decisions.length;
653
1630
  await writeDossier(projectRoot, dossier);
654
- console.error(`[vibe-splain] Decision card written: "${title}" in pillar "${pillar}"`);
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
- title
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 pillarName = args.pillarName;
730
- if (!projectRoot || !pillarName)
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 === pillarName);
1722
+ const pillar = dossier.pillars.find((p) => p.name === pillarName2);
737
1723
  if (!pillar) {
738
1724
  return {
739
- error: `Pillar "${pillarName}" not found. Available pillars: ${dossier.pillars.map((p) => p.name).join(", ")}`
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.evidence.some((e) => e.file === filePath || filePath.endsWith(e.file))) {
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: "1.0.0" }, { capabilities: { tools: {}, prompts: {} } });
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: "Use the vibe-splain MCP tools to build a full architectural dossier for this project. Call scan_project first. Before writing any cards, define a strict set of 3-5 core architectural pillars, and force all categorizations into those predefined buckets to avoid fragmentation. Then for each high-gravity file, call get_file_context to read the source, synthesize a 3-5 sentence narrative explaining WHY the code exists, and call write_decision_card to persist it. When extracting evidence snippets, extract small, highly specific evidence snippets (5-20 lines). NEVER cite the entire file as evidence. If you find weird hacks, tech debt, or eccentric AI-generated code, document it as a Wild Discovery. Include Mermaid diagrams where they help explain data flow. When you're done, share the exact file:// UI link returned by the tool so I can view the dossier in my browser. Do NOT invent a localhost URL."
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
  ]