vibe-splain 2.1.1 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -244,6 +244,163 @@ var VENDOR_SEGMENTS = /* @__PURE__ */ new Set([
244
244
  "third_party",
245
245
  "third-party"
246
246
  ]);
247
+ var PILLAR_KEYWORDS = {
248
+ "Auth": [
249
+ "passport",
250
+ "jsonwebtoken",
251
+ "bcrypt",
252
+ "bcryptjs",
253
+ "oauth",
254
+ "session",
255
+ "cookie-parser",
256
+ "next-auth",
257
+ "@auth/",
258
+ "lucia",
259
+ "clerk",
260
+ "@clerk/",
261
+ "supabase/auth",
262
+ "@supabase/auth-helpers",
263
+ "iron-session",
264
+ "jose",
265
+ "jwt",
266
+ "@auth/core",
267
+ "arctic"
268
+ ],
269
+ "Database": [
270
+ "prisma",
271
+ "@prisma/",
272
+ "mongoose",
273
+ "sequelize",
274
+ "typeorm",
275
+ "knex",
276
+ "pg",
277
+ "mysql",
278
+ "mysql2",
279
+ "better-sqlite3",
280
+ "drizzle-orm",
281
+ "drizzle",
282
+ "kysely",
283
+ "@supabase/supabase-js",
284
+ "mongodb",
285
+ "redis",
286
+ "ioredis"
287
+ ],
288
+ "Payments": [
289
+ "stripe",
290
+ "@stripe/",
291
+ "paypal",
292
+ "braintree",
293
+ "plaid",
294
+ "lemonsqueezy",
295
+ "@lemonsqueezy/",
296
+ "paddle",
297
+ "lemon-squeezy"
298
+ ],
299
+ "Routing": [
300
+ "express",
301
+ "fastify",
302
+ "koa",
303
+ "koa-router",
304
+ "next/router",
305
+ "next/navigation",
306
+ "react-router",
307
+ "@remix-run/",
308
+ "hono",
309
+ "express-rate-limit",
310
+ "cors",
311
+ "helmet"
312
+ ],
313
+ "Queue": [
314
+ "bull",
315
+ "bullmq",
316
+ "amqplib",
317
+ "kafkajs",
318
+ "kafka",
319
+ "upstash",
320
+ "@upstash/",
321
+ "bee-queue",
322
+ "agenda"
323
+ ],
324
+ "Storage": [
325
+ "aws-sdk",
326
+ "@aws-sdk/",
327
+ "multer",
328
+ "cloudinary",
329
+ "@google-cloud/storage",
330
+ "minio",
331
+ "@vercel/blob",
332
+ "sharp",
333
+ "imagekit"
334
+ ],
335
+ "Config": [
336
+ "dotenv",
337
+ "convict",
338
+ "env-var",
339
+ "@t3-oss/env",
340
+ "envalid"
341
+ ],
342
+ "Email": [
343
+ "nodemailer",
344
+ "resend",
345
+ "@sendgrid/",
346
+ "postmark",
347
+ "@resend/",
348
+ "mailgun"
349
+ ],
350
+ "Realtime": [
351
+ "socket.io",
352
+ "ws",
353
+ "pusher",
354
+ "ably",
355
+ "@supabase/realtime",
356
+ "socket.io-client"
357
+ ]
358
+ };
359
+ var PILLAR_PATH_PATTERNS = {
360
+ "Auth": /(?:^|[\/\\])(?:auth|login|signup|register|session|oauth)(?:[\/\\]|$)/i,
361
+ "Database": /(?:^|[\/\\])(?:db|database|models?|schema|migrations?|seeds?)(?:[\/\\]|$)/i,
362
+ "Payments": /(?:^|[\/\\])(?:pay|payments?|billing|checkout|subscriptions?|stripe)(?:[\/\\]|$)/i,
363
+ "Routing": /(?:^|[\/\\])(?:routes?|router|middleware|api)(?:[\/\\]|$)/i,
364
+ "Queue": /(?:^|[\/\\])(?:queues?|workers?|jobs?|consumers?|producers?)(?:[\/\\]|$)/i,
365
+ "Storage": /(?:^|[\/\\])(?:storage|uploads?|s3|blobs?|media)(?:[\/\\]|$)/i,
366
+ "Config": /(?:^|[\/\\])(?:config|env|settings?)(?:[\/\\]|$)/i,
367
+ "Email": /(?:^|[\/\\])(?:emails?|mail|notifications?)(?:[\/\\]|$)/i
368
+ };
369
+ var MEANINGLESS_SEGMENTS = /* @__PURE__ */ new Set([
370
+ "src",
371
+ "lib",
372
+ "app",
373
+ "pages",
374
+ "components",
375
+ "modules",
376
+ "features",
377
+ "core",
378
+ "common",
379
+ "shared",
380
+ "internal",
381
+ "pkg",
382
+ "packages"
383
+ ]);
384
+ function matchPillarByImports(importSpecs) {
385
+ const scores = /* @__PURE__ */ new Map();
386
+ for (const spec of importSpecs) {
387
+ for (const [pillar, keywords] of Object.entries(PILLAR_KEYWORDS)) {
388
+ if (keywords.some((kw) => spec === kw || spec.startsWith(kw + "/"))) {
389
+ scores.set(pillar, (scores.get(pillar) || 0) + 1);
390
+ }
391
+ }
392
+ }
393
+ if (scores.size === 0)
394
+ return null;
395
+ return [...scores.entries()].sort((a, b) => b[1] - a[1])[0][0];
396
+ }
397
+ function matchPillarByPath(relPath) {
398
+ for (const [pillar, pattern] of Object.entries(PILLAR_PATH_PATTERNS)) {
399
+ if (pattern.test(relPath))
400
+ return pillar;
401
+ }
402
+ return null;
403
+ }
247
404
  async function collectFiles(dir, projectRoot, acc) {
248
405
  let entries;
249
406
  try {
@@ -824,9 +981,9 @@ function detectCommunities(nodes, adjacency) {
824
981
  if (!neighbors || neighbors.size === 0)
825
982
  continue;
826
983
  const counts = /* @__PURE__ */ new Map();
827
- for (const nb of neighbors) {
984
+ for (const [nb, weight] of neighbors) {
828
985
  const l = label.get(nb);
829
- counts.set(l, (counts.get(l) || 0) + 1);
986
+ counts.set(l, (counts.get(l) || 0) + weight);
830
987
  }
831
988
  let best = label.get(node), bestCount = -1;
832
989
  for (const [l, c] of counts) {
@@ -1018,7 +1175,7 @@ async function scanProject(projectRoot) {
1018
1175
  const undirected = /* @__PURE__ */ new Map();
1019
1176
  for (const node of realNodes) {
1020
1177
  outEdges.set(node, /* @__PURE__ */ new Set());
1021
- undirected.set(node, /* @__PURE__ */ new Set());
1178
+ undirected.set(node, /* @__PURE__ */ new Map());
1022
1179
  }
1023
1180
  for (const w of work) {
1024
1181
  if (!realSet.has(w.rel))
@@ -1027,8 +1184,11 @@ async function scanProject(projectRoot) {
1027
1184
  if (!realSet.has(target))
1028
1185
  continue;
1029
1186
  outEdges.get(w.rel).add(target);
1030
- undirected.get(w.rel).add(target);
1031
- undirected.get(target).add(w.rel);
1187
+ const wDir = w.rel.split(sep)[0];
1188
+ const tDir = target.split(sep)[0];
1189
+ const weight = wDir === tDir ? 1 : 0.5;
1190
+ undirected.get(w.rel).set(target, weight);
1191
+ undirected.get(target).set(w.rel, weight);
1032
1192
  }
1033
1193
  }
1034
1194
  const ranks = pageRank(realNodes, outEdges);
@@ -1047,7 +1207,10 @@ async function scanProject(projectRoot) {
1047
1207
  publicSurface: w.ast.publicSurface,
1048
1208
  loc: w.ast.loc
1049
1209
  };
1050
- let gravityRaw = centrality * 50 + Math.log2(fanIn + 1) * 8 + Math.log2(w.ast.cyclomatic + 1) * 4 + Math.log2(w.ast.publicSurface + 1) * 3;
1210
+ const depthRatio = (w.ast.cyclomatic + w.ast.maxNesting * 2) / Math.max(1, w.ast.publicSurface);
1211
+ const depthFactor = Math.min(1, Math.log2(depthRatio + 1) / 3);
1212
+ const adjustedCentrality = centrality * (0.3 + 0.7 * depthFactor);
1213
+ let gravityRaw = adjustedCentrality * 50 + Math.log2(fanIn + 1) * 6 + Math.log2(w.ast.cyclomatic + 1) * 7 + Math.log2(w.ast.publicSurface + 1) * 2 + (w.ast.maxNesting >= 4 ? 5 : 0);
1051
1214
  if (!real)
1052
1215
  gravityRaw *= 0.2;
1053
1216
  const gravity = Math.max(0, Math.min(100, gravityRaw));
@@ -1060,7 +1223,9 @@ async function scanProject(projectRoot) {
1060
1223
  magicNumbers: w.ast.magicNumbers
1061
1224
  };
1062
1225
  const heat = real ? computeHeat(w.ast.smells) : 0;
1063
- const pillarHint = real ? `community-${communities.get(w.rel)}` : null;
1226
+ const keywordPillar = matchPillarByImports(w.importSpecs);
1227
+ const pathPillar = matchPillarByPath(w.rel);
1228
+ const pillarHint = real ? keywordPillar || pathPillar || `community-${communities.get(w.rel)}` : null;
1064
1229
  const fa = {
1065
1230
  path: w.abs,
1066
1231
  relativePath: w.rel,
@@ -1119,28 +1284,96 @@ async function scanProject(projectRoot) {
1119
1284
  graph
1120
1285
  };
1121
1286
  }
1122
- function buildPillars(real, communities, stack) {
1123
- const groups = /* @__PURE__ */ new Map();
1287
+ function buildPillars(real, communities, _stack) {
1288
+ const keywordGroups = /* @__PURE__ */ new Map();
1289
+ const unlabeled = [];
1124
1290
  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 {
1291
+ if (a.pillarHint && !a.pillarHint.startsWith("community-")) {
1292
+ if (!keywordGroups.has(a.pillarHint))
1293
+ keywordGroups.set(a.pillarHint, []);
1294
+ keywordGroups.get(a.pillarHint).push(a);
1295
+ } else {
1296
+ unlabeled.push(a);
1297
+ }
1298
+ }
1299
+ const pillars = [];
1300
+ for (const [name, files] of keywordGroups) {
1301
+ const sorted = [...files].sort((a, b) => b.gravity - a.gravity);
1302
+ pillars.push({
1137
1303
  name,
1138
- description: `Graph cluster of ${g.files.length} files centered on ${basename(top[0].relativePath)}.`,
1139
- memberFiles: top.map((f) => f.relativePath)
1140
- };
1304
+ description: `${name} subsystem: ${files.length} file${files.length > 1 ? "s" : ""} centered on ${basename(sorted[0].relativePath)}.`,
1305
+ memberFiles: sorted.map((f) => f.relativePath)
1306
+ });
1307
+ }
1308
+ if (unlabeled.length > 0) {
1309
+ const communityGroups = /* @__PURE__ */ new Map();
1310
+ for (const a of unlabeled) {
1311
+ const c = communities.get(a.relativePath);
1312
+ if (c === void 0)
1313
+ continue;
1314
+ if (!communityGroups.has(c))
1315
+ communityGroups.set(c, []);
1316
+ communityGroups.get(c).push(a);
1317
+ }
1318
+ const remainingSlots = Math.max(0, 6 - pillars.length);
1319
+ const sorted = [...communityGroups.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, remainingSlots);
1320
+ for (const g of sorted) {
1321
+ const top = [...g.files].sort((a, b) => b.gravity - a.gravity);
1322
+ const name = pillarNameFromCluster(top);
1323
+ const existing = pillars.find((p) => p.name === name);
1324
+ if (existing) {
1325
+ existing.memberFiles.push(...top.map((f) => f.relativePath));
1326
+ existing.description = `${name} subsystem: ${existing.memberFiles.length} files centered on ${basename(existing.memberFiles[0])}.`;
1327
+ } else {
1328
+ pillars.push({
1329
+ name,
1330
+ description: `${g.files.length} files centered on ${basename(top[0].relativePath)}.`,
1331
+ memberFiles: top.map((f) => f.relativePath)
1332
+ });
1333
+ }
1334
+ }
1335
+ }
1336
+ pillars.sort((a, b) => {
1337
+ const gravA = real.filter((f) => a.memberFiles.includes(f.relativePath)).reduce((s, f) => s + f.gravity, 0);
1338
+ const gravB = real.filter((f) => b.memberFiles.includes(f.relativePath)).reduce((s, f) => s + f.gravity, 0);
1339
+ return gravB - gravA;
1141
1340
  });
1142
- const seen = /* @__PURE__ */ new Set();
1341
+ if (pillars.length === 0 && real.length > 0) {
1342
+ pillars.push({ name: "Core", description: "Primary application code.", memberFiles: real.slice(0, 20).map((f) => f.relativePath) });
1343
+ }
1344
+ const finalPillars = [];
1143
1345
  for (const p of pillars) {
1346
+ if (p.memberFiles.length > 15) {
1347
+ const groups = /* @__PURE__ */ new Map();
1348
+ for (const f of p.memberFiles) {
1349
+ let bucket = "Core";
1350
+ if (f.includes("app/") || f.includes("pages/") || f.includes("routes/"))
1351
+ bucket = "Routing";
1352
+ else if (f.includes("components/") || f.includes("ui/"))
1353
+ bucket = "Components";
1354
+ else if (f.includes("hooks/") || f.includes("lib/") || f.includes("utils/"))
1355
+ bucket = "Logic";
1356
+ const d = basename(dirname(f));
1357
+ const key = `${p.name} (${bucket} - ${d})`;
1358
+ if (!groups.has(key))
1359
+ groups.set(key, []);
1360
+ groups.get(key).push(f);
1361
+ }
1362
+ for (const [key, files] of groups) {
1363
+ if (files.length > 0) {
1364
+ finalPillars.push({
1365
+ name: key,
1366
+ description: `Subdivided from ${p.name}`,
1367
+ memberFiles: files
1368
+ });
1369
+ }
1370
+ }
1371
+ } else {
1372
+ finalPillars.push(p);
1373
+ }
1374
+ }
1375
+ const seen = /* @__PURE__ */ new Set();
1376
+ for (const p of finalPillars) {
1144
1377
  let n = p.name, i = 2;
1145
1378
  while (seen.has(n)) {
1146
1379
  n = `${p.name} ${i++}`;
@@ -1148,24 +1381,35 @@ function buildPillars(real, communities, stack) {
1148
1381
  p.name = n;
1149
1382
  seen.add(n);
1150
1383
  }
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;
1384
+ return finalPillars;
1155
1385
  }
1156
- function pillarName(files, idx) {
1386
+ function pillarNameFromCluster(files) {
1387
+ const hintCounts = /* @__PURE__ */ new Map();
1388
+ for (const f of files) {
1389
+ if (f.pillarHint && !f.pillarHint.startsWith("community-")) {
1390
+ hintCounts.set(f.pillarHint, (hintCounts.get(f.pillarHint) || 0) + 1);
1391
+ }
1392
+ }
1393
+ if (hintCounts.size > 0) {
1394
+ const best = [...hintCounts.entries()].sort((a, b) => b[1] - a[1])[0];
1395
+ if (best[1] >= files.length * 0.4)
1396
+ return best[0];
1397
+ }
1157
1398
  const dirs = files.map((f) => dirname(f.relativePath)).filter((d) => d && d !== ".");
1158
1399
  if (dirs.length) {
1159
- const counts = /* @__PURE__ */ new Map();
1400
+ const segCounts = /* @__PURE__ */ new Map();
1160
1401
  for (const d of dirs) {
1161
- const seg = d.split(sep).pop();
1162
- counts.set(seg, (counts.get(seg) || 0) + 1);
1402
+ const segments = d.split(sep).filter((s) => !MEANINGLESS_SEGMENTS.has(s.toLowerCase()));
1403
+ const meaningful = segments.pop();
1404
+ if (meaningful)
1405
+ segCounts.set(meaningful, (segCounts.get(meaningful) || 0) + 1);
1163
1406
  }
1164
- const top = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];
1407
+ const top = [...segCounts.entries()].sort((a, b) => b[1] - a[1])[0];
1165
1408
  if (top)
1166
1409
  return titleCase(top[0]);
1167
1410
  }
1168
- return `Cluster ${idx + 1}`;
1411
+ const topFile = basename(files[0].relativePath, extname(files[0].relativePath));
1412
+ return titleCase(topFile);
1169
1413
  }
1170
1414
  function titleCase(s) {
1171
1415
  return s.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
@@ -1234,11 +1478,30 @@ async function readDossier(projectRoot) {
1234
1478
  }
1235
1479
  async function writeDossier(projectRoot, dossier) {
1236
1480
  await dossierMutex.runExclusive(async () => {
1481
+ for (const p of dossier.pillars) {
1482
+ p.decisions = p.decisions.filter((c) => !(c.severity === 1 && c.category === "Convention"));
1483
+ p.cardCount = p.decisions.length;
1484
+ }
1485
+ const uniqueCards = /* @__PURE__ */ new Map();
1486
+ for (const p of dossier.pillars) {
1487
+ for (const c of p.decisions)
1488
+ uniqueCards.set(c.id, c);
1489
+ }
1490
+ for (const c of dossier.wildDiscoveries)
1491
+ uniqueCards.set(c.id, c);
1492
+ const deltaTargets = Array.from(uniqueCards.values()).filter((c) => c.severity >= 4).map((c) => ({
1493
+ target_path: c.primaryFile,
1494
+ bottleneck_title: c.title,
1495
+ structural_intent: c.thesis,
1496
+ evidence_snippets: c.evidence
1497
+ }));
1237
1498
  const dir = join5(projectRoot, ".vibe-splainer");
1238
1499
  await mkdir3(dir, { recursive: true });
1239
1500
  const dossierPath = join5(dir, "dossier.json");
1240
1501
  const tmp = dossierPath + ".tmp";
1241
1502
  await writeFile4(tmp, JSON.stringify(dossier, null, 2), "utf8");
1503
+ const targetsPath = join5(dir, "delta_targets.json");
1504
+ await writeFile4(targetsPath, JSON.stringify(deltaTargets, null, 2), "utf8");
1242
1505
  const { rename } = await import("fs/promises");
1243
1506
  await rename(tmp, dossierPath);
1244
1507
  await regenerateUI(projectRoot, dossier);
@@ -1492,6 +1755,7 @@ async function handleGetFileContext(args) {
1492
1755
  heatSignals: evidence.heatSignals,
1493
1756
  importedBy: persisted?.importedBy ?? [],
1494
1757
  imports: persisted?.imports ?? [],
1758
+ pillarHint: persisted?.pillarHint ?? null,
1495
1759
  signature: evidence.signature,
1496
1760
  hotSpans: evidence.hotSpans,
1497
1761
  smellSpans: evidence.smellSpans
@@ -1531,7 +1795,7 @@ var writeDecisionCardTool = {
1531
1795
  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." },
1532
1796
  tradeoff: { type: "string", description: "What was given up, or why the obvious approach was rejected. Null only if genuinely none." },
1533
1797
  blastRadius: { type: "string", description: "What breaks if this changes. Ground it in the fan-in (importedBy) from get_file_context." },
1534
- confidence: { type: "string", enum: ["low", "medium", "high"] },
1798
+ confidence: { type: "string", enum: ["low", "medium", "high"], description: 'Do NOT default to "high". Reserve "high" ONLY for provable execution anti-patterns. Score subjective stylistic choices or abstractions as "low" or "medium".' },
1535
1799
  evidence: {
1536
1800
  type: "array",
1537
1801
  items: {
@@ -1720,17 +1984,17 @@ var inspectPillarTool = {
1720
1984
  };
1721
1985
  async function handleInspectPillar(args) {
1722
1986
  const projectRoot = args.projectRoot;
1723
- const pillarName2 = args.pillarName;
1724
- if (!projectRoot || !pillarName2)
1987
+ const pillarName = args.pillarName;
1988
+ if (!projectRoot || !pillarName)
1725
1989
  throw new Error("projectRoot and pillarName are required");
1726
1990
  const dossier = await readDossier(projectRoot);
1727
1991
  if (!dossier) {
1728
1992
  return { error: "No dossier found. Run scan_project first." };
1729
1993
  }
1730
- const pillar = dossier.pillars.find((p) => p.name === pillarName2);
1994
+ const pillar = dossier.pillars.find((p) => p.name === pillarName);
1731
1995
  if (!pillar) {
1732
1996
  return {
1733
- error: `Pillar "${pillarName2}" not found. Available pillars: ${dossier.pillars.map((p) => p.name).join(", ")}`
1997
+ error: `Pillar "${pillarName}" not found. Available pillars: ${dossier.pillars.map((p) => p.name).join(", ")}`
1734
1998
  };
1735
1999
  }
1736
2000
  return pillar;
@@ -39,6 +39,7 @@ export async function handleGetFileContext(args) {
39
39
  heatSignals: evidence.heatSignals,
40
40
  importedBy: persisted?.importedBy ?? [],
41
41
  imports: persisted?.imports ?? [],
42
+ pillarHint: persisted?.pillarHint ?? null,
42
43
  signature: evidence.signature,
43
44
  hotSpans: evidence.hotSpans,
44
45
  smellSpans: evidence.smellSpans,
@@ -47,6 +47,7 @@ export declare const writeDecisionCardTool: {
47
47
  confidence: {
48
48
  type: string;
49
49
  enum: string[];
50
+ description: string;
50
51
  };
51
52
  evidence: {
52
53
  type: string;
@@ -29,7 +29,7 @@ export const writeDecisionCardTool = {
29
29
  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." },
30
30
  tradeoff: { type: 'string', description: 'What was given up, or why the obvious approach was rejected. Null only if genuinely none.' },
31
31
  blastRadius: { type: 'string', description: 'What breaks if this changes. Ground it in the fan-in (importedBy) from get_file_context.' },
32
- confidence: { type: 'string', enum: ['low', 'medium', 'high'] },
32
+ confidence: { type: 'string', enum: ['low', 'medium', 'high'], description: 'Do NOT default to "high". Reserve "high" ONLY for provable execution anti-patterns. Score subjective stylistic choices or abstractions as "low" or "medium".' },
33
33
  evidence: {
34
34
  type: 'array',
35
35
  items: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibe-splain",
3
- "version": "2.1.1",
3
+ "version": "2.3.0",
4
4
  "description": "Architectural dossier engine for vibe-coded projects. Runs as an MCP server inside your coding agent.",
5
5
  "type": "module",
6
6
  "license": "MIT",