codebrief 1.1.0 → 1.2.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
@@ -79,6 +79,7 @@ var init_utils = __esm({
79
79
  });
80
80
 
81
81
  // src/index.ts
82
+ init_utils();
82
83
  import path8 from "path";
83
84
  import * as p4 from "@clack/prompts";
84
85
  import pc3 from "picocolors";
@@ -305,7 +306,10 @@ async function detectContext(rootDir, onProgress) {
305
306
  "**/vendor/**",
306
307
  "**/__pycache__/**",
307
308
  "**/venv/**",
308
- "**/.venv/**"
309
+ "**/.venv/**",
310
+ "**/.Trash/**",
311
+ "**/Library/**",
312
+ "**/.git/**"
309
313
  ],
310
314
  stats: true
311
315
  }
@@ -859,21 +863,34 @@ function computePageRank(files, edges, iterations = 5, damping = 0.85) {
859
863
  }
860
864
  async function buildImportGraph(rootDir, language, onProgress) {
861
865
  const globs = getSourceGlob(language);
862
- const files = await fg2(globs, {
863
- cwd: rootDir,
864
- ignore: [
865
- "**/node_modules/**",
866
- "**/dist/**",
867
- "**/build/**",
868
- "**/.next/**",
869
- "**/target/**",
870
- "**/vendor/**",
871
- "**/__pycache__/**",
872
- "**/venv/**",
873
- "**/.venv/**"
874
- ],
875
- absolute: false
876
- });
866
+ let files;
867
+ try {
868
+ files = await fg2(globs, {
869
+ cwd: rootDir,
870
+ ignore: [
871
+ "**/node_modules/**",
872
+ "**/dist/**",
873
+ "**/build/**",
874
+ "**/.next/**",
875
+ "**/target/**",
876
+ "**/vendor/**",
877
+ "**/__pycache__/**",
878
+ "**/venv/**",
879
+ "**/.venv/**",
880
+ "**/.Trash/**",
881
+ "**/Library/**",
882
+ "**/.git/**"
883
+ ],
884
+ absolute: false
885
+ });
886
+ } catch (err) {
887
+ const code = err.code;
888
+ if (code === "EPERM" || code === "EACCES") {
889
+ onProgress?.("Warning: permission error scanning files \u2014 returning empty graph");
890
+ return { edges: [], inDegree: /* @__PURE__ */ new Map(), centrality: /* @__PURE__ */ new Map(), externalImportCounts: /* @__PURE__ */ new Map() };
891
+ }
892
+ throw err;
893
+ }
877
894
  onProgress?.(`Found ${files.length} source files to analyze`);
878
895
  const fileSet = new Set(files);
879
896
  const edges = [];
@@ -1401,7 +1418,10 @@ async function generateSnapshot(ctx, customPaths, graph, maxTokens, onProgress,
1401
1418
  "**/build/**",
1402
1419
  "**/*.test.*",
1403
1420
  "**/*.spec.*",
1404
- "**/__tests__/**"
1421
+ "**/__tests__/**",
1422
+ "**/.Trash/**",
1423
+ "**/Library/**",
1424
+ "**/.git/**"
1405
1425
  ],
1406
1426
  absolute: false
1407
1427
  });
@@ -2963,47 +2983,21 @@ function printSummary(files, ctx, snapshot, analysis) {
2963
2983
  const savings = Math.round(
2964
2984
  (explorationTokens - afterTotal) / explorationTokens * 100
2965
2985
  );
2966
- const costRows = [
2967
- { label: "Always loaded", desc: "main context + global rule", value: `~${formatNumber(alwaysOnTokens)} tokens` }
2968
- ];
2969
- if (avgScopedTokens > 0) {
2970
- costRows.push({ label: "Per-task (avg)", desc: "1 scoped rule", value: `~${formatNumber(avgScopedTokens)} tokens` });
2971
- }
2972
- costRows.push({ label: "Exploration", desc: "mostly eliminated", value: `~${formatNumber(residualExploration)} tokens` });
2973
- const maxCostLabel = Math.max(...costRows.map((r) => r.label.length));
2974
- const maxCostDesc = Math.max(...costRows.map((r) => r.desc.length));
2975
- const maxCostValue = Math.max(...costRows.map((r) => r.value.length));
2976
- const afterContentWidth = maxCostLabel + 3 + maxCostDesc + 2 + maxCostValue;
2977
- const beforeValue = `~${formatNumber(explorationTokens)} tokens`;
2978
- const beforeLabel = "Exploration to understand codebase";
2979
- const beforeGap = afterContentWidth - beforeLabel.length - beforeValue.length;
2980
- console.log(pc.dim(" Before (no context files):"));
2981
- if (beforeGap >= 2) {
2982
- console.log(
2983
- ` ${beforeLabel}${" ".repeat(beforeGap)}${pc.red(beforeValue)}`
2984
- );
2985
- } else {
2986
- console.log(
2987
- ` ${beforeLabel} ${pc.red(beforeValue)}`
2988
- );
2989
- }
2990
- console.log("");
2991
- console.log(pc.dim(" After:"));
2992
- for (const row of costRows) {
2993
- console.log(
2994
- ` ${row.label.padEnd(maxCostLabel)} ${row.desc.padEnd(maxCostDesc)} ${pc.green(row.value.padStart(maxCostValue))}`
2995
- );
2996
- }
2997
- const totalText = `Total: ~${formatNumber(afterTotal)} tokens`;
2998
- const totalPad = afterContentWidth > totalText.length ? " ".repeat(afterContentWidth - totalText.length) : "";
2999
- console.log(
3000
- ` ${totalPad}${pc.bold(totalText)}`
3001
- );
2986
+ const BAR_MAX = 40;
2987
+ const maxVal = Math.max(explorationTokens, afterTotal);
2988
+ const beforeBarLen = Math.max(1, Math.round(explorationTokens / maxVal * BAR_MAX));
2989
+ const afterBarLen = Math.max(1, Math.round(afterTotal / maxVal * BAR_MAX));
2990
+ const BLOCK = "\u2588";
2991
+ const beforeBar = BLOCK.repeat(beforeBarLen);
2992
+ const afterBar = BLOCK.repeat(afterBarLen);
2993
+ const afterPad = " ".repeat(Math.max(0, beforeBarLen - afterBarLen));
2994
+ console.log(` Before: ${pc.red(beforeBar)} ~${formatNumber(explorationTokens)} tokens`);
2995
+ console.log(` After: ${pc.green(afterBar)}${afterPad} ~${formatNumber(afterTotal)} tokens`);
3002
2996
  console.log("");
3003
2997
  if (savings > 0) {
3004
2998
  console.log(
3005
2999
  pc.green(
3006
- ` Estimated savings: ~${savings}% fewer tokens before real work begins`
3000
+ ` Estimated savings: ~${savings}% fewer tokens`
3007
3001
  )
3008
3002
  );
3009
3003
  }
@@ -3047,20 +3041,39 @@ function printSummary(files, ctx, snapshot, analysis) {
3047
3041
  );
3048
3042
  }
3049
3043
  }
3050
- const exportCoverage = analysis?.exportCoverage;
3051
- if (exportCoverage && exportCoverage.length > 0) {
3052
- const totalExports = exportCoverage.reduce((sum, e) => sum + e.totalExports, 0);
3053
- const totalUsed = exportCoverage.reduce((sum, e) => sum + e.usedExports, 0);
3054
- const unusedExports = totalExports - totalUsed;
3055
- const coveragePct = totalExports > 0 ? Math.round(totalUsed / totalExports * 100) : 100;
3056
- const filesWithUnused = exportCoverage.filter((e) => e.usedExports < e.totalExports).length;
3057
- if (unusedExports > 0) {
3058
- console.log("");
3059
- console.log(
3060
- pc.dim(
3061
- ` Export coverage: ${coveragePct}% of exports are used (${unusedExports} unused exports in ${filesWithUnused} file${filesWithUnused === 1 ? "" : "s"})`
3062
- )
3063
- );
3044
+ if (analysis) {
3045
+ const findings = [];
3046
+ if (analysis.circularDeps.length > 0) {
3047
+ for (const c of analysis.circularDeps.slice(0, 3)) {
3048
+ const names = c.chain.map((f) => f.split("/").pop()?.replace(/\.[jt]sx?$/, "") ?? f);
3049
+ findings.push(`${analysis.circularDeps.length > 1 ? "" : ""}1 circular dependency chain (${names.slice(0, 2).join(" \u2194 ")})`);
3050
+ }
3051
+ if (analysis.circularDeps.length > 1) {
3052
+ findings[0] = `${analysis.circularDeps.length} circular dependency chain${analysis.circularDeps.length === 1 ? "" : "s"}`;
3053
+ }
3054
+ }
3055
+ const highInstabilityFiles = analysis.instabilities.filter((f) => f.instability > 0.8);
3056
+ if (highInstabilityFiles.length > 0) {
3057
+ findings.push(`${highInstabilityFiles.length} high-instability file${highInstabilityFiles.length === 1 ? "" : "s"}`);
3058
+ }
3059
+ const ec = analysis.exportCoverage;
3060
+ if (ec && ec.length > 0) {
3061
+ const totalExports = ec.reduce((sum, e) => sum + e.totalExports, 0);
3062
+ const totalUsed = ec.reduce((sum, e) => sum + e.usedExports, 0);
3063
+ const unusedExports = totalExports - totalUsed;
3064
+ const filesWithUnused = ec.filter((e) => e.usedExports < e.totalExports).length;
3065
+ if (unusedExports > 0) {
3066
+ findings.push(`${unusedExports} unused export${unusedExports === 1 ? "" : "s"} in ${filesWithUnused} file${filesWithUnused === 1 ? "" : "s"}`);
3067
+ }
3068
+ }
3069
+ console.log("");
3070
+ if (findings.length > 0) {
3071
+ console.log(pc.yellow(` \u26A0 ${findings.length} finding${findings.length === 1 ? "" : "s"}`));
3072
+ for (const f of findings) {
3073
+ console.log(pc.dim(` \u25CF ${f}`));
3074
+ }
3075
+ } else {
3076
+ console.log(pc.green(` \u2713 No structural issues detected`));
3064
3077
  }
3065
3078
  }
3066
3079
  console.log("");
@@ -3395,6 +3408,19 @@ async function main() {
3395
3408
  const maxTokens = maxTokensArg ? parseInt(maxTokensArg.split("=")[1], 10) : void 0;
3396
3409
  const targetDir = args.find((a) => !a.startsWith("-") && a !== "-v") ?? process.cwd();
3397
3410
  const rootDir = path8.resolve(targetDir);
3411
+ const PROJECT_MARKERS = ["package.json", "go.mod", "Cargo.toml", "pyproject.toml", "requirements.txt"];
3412
+ const hasProjectMarker = (await Promise.all(
3413
+ PROJECT_MARKERS.map((f) => fileExists(path8.join(rootDir, f)))
3414
+ )).some(Boolean);
3415
+ if (!hasProjectMarker) {
3416
+ console.log("");
3417
+ p4.intro(pc3.bold(" codebrief "));
3418
+ p4.log.error(`No project found at ${pc3.cyan(rootDir)}`);
3419
+ p4.log.info(`Run ${pc3.bold("npx codebrief")} from a project directory, or pass a path:
3420
+ ${pc3.dim("npx codebrief ./my-project")}`);
3421
+ p4.outro("");
3422
+ process.exit(1);
3423
+ }
3398
3424
  const verboseLog = (msg) => {
3399
3425
  if (verbose) p4.log.info(pc3.dim(msg));
3400
3426
  };
@@ -3471,35 +3497,111 @@ async function main() {
3471
3497
  const fileCount = graph.centrality.size;
3472
3498
  spinner3.start(`Running PageRank on ${fileCount} files...`);
3473
3499
  const hubFiles = getHubFiles(graph);
3474
- verboseLog(`PageRank: found ${hubFiles.length} hub files`);
3475
- spinner3.message("Finding circular dependencies...");
3500
+ const topHubName = hubFiles[0]?.path ?? "";
3501
+ spinner3.stop(
3502
+ hubFiles.length > 0 ? `${pc3.green("PageRank")} found ${pc3.bold(String(hubFiles.length))} hub files` + (topHubName ? pc3.dim(` (top: ${topHubName})`) : "") : `${pc3.green("PageRank")} ${pc3.dim("no hub files detected")}`
3503
+ );
3504
+ if (verbose && hubFiles.length > 0) {
3505
+ for (const h of hubFiles.slice(0, 5)) {
3506
+ p4.log.info(pc3.dim(` ${h.path} (centrality: ${h.centrality.toFixed(3)}, imported by ${h.importedBy})`));
3507
+ }
3508
+ }
3509
+ spinner3.start("Finding circular dependencies...");
3476
3510
  const circularDeps = findCircularDeps(graph);
3477
- verboseLog(`Tarjan SCC: ${circularDeps.length === 0 ? "no cycles found" : `${circularDeps.length} cycle(s)`}`);
3478
- spinner3.message("Detecting architecture layers...");
3511
+ spinner3.stop(
3512
+ circularDeps.length === 0 ? `${pc3.green("Tarjan SCC")} no cycles found ${pc3.green("\u2713")}` : `${pc3.yellow("Tarjan SCC")} ${pc3.bold(String(circularDeps.length))} cycle${circularDeps.length === 1 ? "" : "s"} found`
3513
+ );
3514
+ if (verbose && circularDeps.length > 0) {
3515
+ for (const c of circularDeps.slice(0, 3)) {
3516
+ p4.log.info(pc3.dim(` ${c.chain.join(" \u2192 ")}`));
3517
+ }
3518
+ }
3519
+ spinner3.start("Detecting architecture layers...");
3479
3520
  const { layers, layerEdges } = detectArchitecturalLayers(graph);
3480
- verboseLog(`Layers: ${layers.map((l) => l.name).join(", ") || "none detected"}`);
3481
- spinner3.message("Computing instability metrics...");
3521
+ spinner3.stop(
3522
+ layers.length > 0 ? `${pc3.green("Layers")} ${layers.map((l) => l.name).join(" \u2192 ")}` : `${pc3.green("Layers")} ${pc3.dim("no clear layers detected")}`
3523
+ );
3524
+ if (verbose && layers.length > 0) {
3525
+ for (const l of layers) {
3526
+ p4.log.info(pc3.dim(` ${l.name}: ${l.files.length} files, depends on: ${l.dependsOn.join(", ") || "none"}`));
3527
+ }
3528
+ }
3529
+ spinner3.start("Computing instability metrics...");
3482
3530
  const instabilities = computeInstability(graph);
3483
- verboseLog(`Instability: ${instabilities.length} high-risk file(s)`);
3484
- spinner3.message("Detecting module communities...");
3531
+ const highInstability = instabilities.filter((f) => f.instability > 0.8);
3532
+ spinner3.stop(
3533
+ highInstability.length > 0 ? `${pc3.yellow("Instability")} ${pc3.bold(String(highInstability.length))} high-risk file${highInstability.length === 1 ? "" : "s"}` : `${pc3.green("Instability")} ${pc3.dim("all files within healthy range")} ${pc3.green("\u2713")}`
3534
+ );
3535
+ if (verbose && highInstability.length > 0) {
3536
+ for (const f of highInstability.slice(0, 5)) {
3537
+ p4.log.info(pc3.dim(` ${f.path} (I=${f.instability.toFixed(2)}, fan-in=${f.fanIn}, fan-out=${f.fanOut})`));
3538
+ }
3539
+ }
3540
+ spinner3.start("Detecting module communities...");
3485
3541
  const communities = detectCommunities(graph);
3486
- verboseLog(`Communities: ${communities.length} cluster(s)`);
3487
- spinner3.message("Computing export coverage...");
3542
+ spinner3.stop(
3543
+ communities.length > 0 ? `${pc3.green("Communities")} ${pc3.bold(String(communities.length))} module cluster${communities.length === 1 ? "" : "s"}` : `${pc3.green("Communities")} ${pc3.dim("single cohesive module")}`
3544
+ );
3545
+ if (verbose && communities.length > 0) {
3546
+ for (const c of communities.slice(0, 5)) {
3547
+ p4.log.info(pc3.dim(` ${c.label} (${c.files.length} files)`));
3548
+ }
3549
+ }
3550
+ spinner3.start("Computing export coverage...");
3488
3551
  const exportCoverage = computeExportCoverage(graph);
3489
- verboseLog(`Export coverage: ${exportCoverage.length} files analyzed`);
3490
- spinner3.message("Analyzing git history...");
3552
+ {
3553
+ const totalExp = exportCoverage.reduce((s, e) => s + e.totalExports, 0);
3554
+ const totalUsed = exportCoverage.reduce((s, e) => s + e.usedExports, 0);
3555
+ const unusedCount = totalExp - totalUsed;
3556
+ const filesWithUnused = exportCoverage.filter((e) => e.usedExports < e.totalExports).length;
3557
+ spinner3.stop(
3558
+ unusedCount > 0 ? `${pc3.yellow("Exports")} ${pc3.bold(String(unusedCount))} unused export${unusedCount === 1 ? "" : "s"} in ${filesWithUnused} file${filesWithUnused === 1 ? "" : "s"}` : `${pc3.green("Exports")} ${pc3.dim("all exports used")} ${pc3.green("\u2713")}`
3559
+ );
3560
+ if (verbose && unusedCount > 0) {
3561
+ for (const e of exportCoverage.filter((e2) => e2.usedExports < e2.totalExports).slice(0, 5)) {
3562
+ p4.log.info(pc3.dim(` ${e.file}: ${e.totalExports - e.usedExports} unused of ${e.totalExports}`));
3563
+ }
3564
+ }
3565
+ }
3566
+ spinner3.start("Analyzing git history...");
3491
3567
  const gitActivity = detected.isGitRepo ? analyzeGitActivity(rootDir, verbose ? verboseLog : spinnerProgress) : null;
3492
- if (gitActivity) verboseLog(`Git: ${gitActivity.hotFiles.length} active files, ${gitActivity.changeCoupling.length} coupled pairs`);
3568
+ if (gitActivity) {
3569
+ const coupledPairs = gitActivity.changeCoupling.length;
3570
+ spinner3.stop(
3571
+ `${pc3.green("Git (90d)")} ${pc3.bold(String(gitActivity.hotFiles.length))} active file${gitActivity.hotFiles.length === 1 ? "" : "s"}, ${pc3.bold(String(coupledPairs))} coupled pair${coupledPairs === 1 ? "" : "s"}`
3572
+ );
3573
+ if (verbose) {
3574
+ for (const h of gitActivity.hotFiles.slice(0, 5)) {
3575
+ p4.log.info(pc3.dim(` ${h.path} (${h.commits} commits, last: ${h.lastChanged})`));
3576
+ }
3577
+ }
3578
+ } else {
3579
+ spinner3.stop(`${pc3.green("Git")} ${pc3.dim("not a git repo \u2014 skipped")}`);
3580
+ }
3493
3581
  const analysis = { hubFiles, circularDeps, layers, layerEdges, gitActivity, instabilities, communities, exportCoverage };
3494
- const analysisParts = [];
3495
- if (hubFiles.length > 0) analysisParts.push(`${hubFiles.length} hub files`);
3496
- if (layers.length > 0) analysisParts.push(`${layers.length} layers`);
3497
- if (circularDeps.length > 0) analysisParts.push(`${circularDeps.length} circular dep${circularDeps.length === 1 ? "" : "s"}`);
3498
- if (communities.length > 0) analysisParts.push(`${communities.length} module cluster${communities.length === 1 ? "" : "s"}`);
3499
- if (gitActivity) analysisParts.push(`${gitActivity.hotFiles.length} active files`);
3500
- spinner3.stop(
3501
- analysisParts.length > 0 ? `Analysis: ${analysisParts.join(", ")}.` : "Analysis complete."
3502
- );
3582
+ {
3583
+ const reportLines = [];
3584
+ reportLines.push(` Files analyzed: ${fileCount}`);
3585
+ reportLines.push(` Import edges: ${graph.edges.length}`);
3586
+ reportLines.push(` External pkgs: ${graph.externalImportCounts.size}`);
3587
+ if (hubFiles.length > 0) {
3588
+ reportLines.push(` Hub files: ${hubFiles.length}` + (hubFiles[0] ? ` (most connected: ${hubFiles[0].path})` : ""));
3589
+ }
3590
+ if (layers.length > 0) {
3591
+ reportLines.push(` Architecture: ${layers.map((l) => l.name).join(" \u2192 ")}`);
3592
+ }
3593
+ reportLines.push(` Circular deps: ${circularDeps.length === 0 ? "none" : `${circularDeps.length} chain${circularDeps.length === 1 ? "" : "s"}`}`);
3594
+ if (gitActivity) {
3595
+ reportLines.push(` Hot files (90d): ${gitActivity.hotFiles.length}`);
3596
+ }
3597
+ p4.note(reportLines.join("\n"), "Analysis Report");
3598
+ if (circularDeps.length > 0) {
3599
+ for (const c of circularDeps.slice(0, 2)) {
3600
+ const shortChain = c.chain.map((f) => f.split("/").pop() ?? f);
3601
+ p4.log.warn(pc3.yellow(`Cycle: ${shortChain.join(" \u2192 ")}`));
3602
+ }
3603
+ }
3604
+ }
3503
3605
  const savedConfig = await loadConfig(rootDir);
3504
3606
  if (savedConfig?.snapshotHash) {
3505
3607
  const currentHash = await computeSnapshotHash(rootDir, detected.language);