greenbump 0.5.0 → 0.7.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.
Files changed (74) hide show
  1. package/README.md +696 -66
  2. package/README.zh-CN.md +539 -100
  3. package/dist/agent/fixer.js +165 -12
  4. package/dist/agent/fixer.js.map +1 -1
  5. package/dist/agent/fixer.test.js +410 -0
  6. package/dist/agent/fixer.test.js.map +1 -0
  7. package/dist/cli/interactive.js +81 -0
  8. package/dist/cli/interactive.js.map +1 -0
  9. package/dist/cli.js +59 -0
  10. package/dist/cli.js.map +1 -1
  11. package/dist/engine/analyzers/changelog-parser.js +121 -0
  12. package/dist/engine/analyzers/changelog-parser.js.map +1 -0
  13. package/dist/engine/batch.js +24 -0
  14. package/dist/engine/batch.js.map +1 -1
  15. package/dist/engine/batch.test.js +59 -0
  16. package/dist/engine/batch.test.js.map +1 -0
  17. package/dist/engine/cache/manager.js +165 -0
  18. package/dist/engine/cache/manager.js.map +1 -0
  19. package/dist/engine/cache/manager.test.js +75 -0
  20. package/dist/engine/cache/manager.test.js.map +1 -0
  21. package/dist/engine/checks.test.js +92 -0
  22. package/dist/engine/checks.test.js.map +1 -0
  23. package/dist/engine/context/optimizer.js +189 -0
  24. package/dist/engine/context/optimizer.js.map +1 -0
  25. package/dist/engine/context/optimizer.test.js +81 -0
  26. package/dist/engine/context/optimizer.test.js.map +1 -0
  27. package/dist/engine/ecosystems/parsers.test.js +255 -0
  28. package/dist/engine/ecosystems/parsers.test.js.map +1 -0
  29. package/dist/engine/fixer/patterns.js +597 -0
  30. package/dist/engine/fixer/patterns.js.map +1 -0
  31. package/dist/engine/fixer/patterns.test.js +316 -0
  32. package/dist/engine/fixer/patterns.test.js.map +1 -0
  33. package/dist/engine/git.test.js +111 -0
  34. package/dist/engine/git.test.js.map +1 -0
  35. package/dist/engine/run.js +66 -1
  36. package/dist/engine/run.js.map +1 -1
  37. package/dist/engine/run.test.js +47 -0
  38. package/dist/engine/run.test.js.map +1 -0
  39. package/dist/engine/stats/recorder.js +48 -0
  40. package/dist/engine/stats/recorder.js.map +1 -0
  41. package/dist/engine/stats/recorder.test.js +71 -0
  42. package/dist/engine/stats/recorder.test.js.map +1 -0
  43. package/dist/engine/stats/report.js +157 -0
  44. package/dist/engine/stats/report.js.map +1 -0
  45. package/dist/engine/stats/report.test.js +83 -0
  46. package/dist/engine/stats/report.test.js.map +1 -0
  47. package/dist/engine/verifiers/ast-analyzer.js +212 -0
  48. package/dist/engine/verifiers/ast-analyzer.js.map +1 -0
  49. package/dist/engine/verifiers/ast-analyzer.test.js +96 -0
  50. package/dist/engine/verifiers/ast-analyzer.test.js.map +1 -0
  51. package/dist/engine/workspace.test.js +95 -0
  52. package/dist/engine/workspace.test.js.map +1 -0
  53. package/dist/exitcode.test.js +62 -0
  54. package/dist/exitcode.test.js.map +1 -0
  55. package/dist/format.js +15 -0
  56. package/dist/format.js.map +1 -1
  57. package/dist/report.test.js +56 -0
  58. package/dist/report.test.js.map +1 -0
  59. package/package.json +7 -4
  60. package/CHANGELOG.md +0 -136
  61. package/CONTRIBUTING.md +0 -52
  62. package/SECURITY.md +0 -38
  63. package/action-dist/index.cjs +0 -20730
  64. package/action.yml +0 -76
  65. package/competitive-analysis.md +0 -304
  66. package/examples/greenbump.yml +0 -37
  67. package/examples/legacy-repo/package.json +0 -12
  68. package/examples/legacy-repo/test.js +0 -17
  69. package/examples/multi-file-repo/models/todo.js +0 -8
  70. package/examples/multi-file-repo/models/user.js +0 -11
  71. package/examples/multi-file-repo/package.json +0 -12
  72. package/examples/multi-file-repo/services/session.js +0 -9
  73. package/examples/multi-file-repo/test.js +0 -19
  74. package/optimization-roadmap.md +0 -772
@@ -0,0 +1,47 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { computeNeedsReview } from "./run.js";
4
+ test("computeNeedsReview: unverifiable always needs review, regardless of anything else", () => {
5
+ assert.equal(computeNeedsReview({ unverifiable: true, neededFix: false, fixed: true, testFilesTouched: [] }), true);
6
+ });
7
+ test("computeNeedsReview: clean upgrade (no fix needed) never needs review", () => {
8
+ assert.equal(computeNeedsReview({ unverifiable: false, neededFix: false, fixed: true, testFilesTouched: [] }), false);
9
+ });
10
+ test("computeNeedsReview: fixed with a test file touched needs review", () => {
11
+ assert.equal(computeNeedsReview({
12
+ unverifiable: false,
13
+ neededFix: true,
14
+ fixed: true,
15
+ testFilesTouched: ["src/foo.test.ts"],
16
+ }), true);
17
+ });
18
+ test("computeNeedsReview: fixed with no test file touched does not need review", () => {
19
+ assert.equal(computeNeedsReview({ unverifiable: false, neededFix: true, fixed: true, testFilesTouched: [] }), false);
20
+ });
21
+ test("computeNeedsReview: fix loop failed (unfixed) never needs review — that's exit code 2's job, not 3's", () => {
22
+ assert.equal(computeNeedsReview({
23
+ unverifiable: false,
24
+ neededFix: true,
25
+ fixed: false,
26
+ testFilesTouched: ["src/foo.test.ts"],
27
+ }), false);
28
+ });
29
+ test("computeNeedsReview: budgetExceeded always needs review, even on a clean/no-fix-needed run", () => {
30
+ assert.equal(computeNeedsReview({
31
+ unverifiable: false,
32
+ neededFix: false,
33
+ fixed: true,
34
+ testFilesTouched: [],
35
+ budgetExceeded: true,
36
+ }), true);
37
+ });
38
+ test("computeNeedsReview: budgetExceeded still needs review even when fixed with no test file touched", () => {
39
+ assert.equal(computeNeedsReview({
40
+ unverifiable: false,
41
+ neededFix: true,
42
+ fixed: true,
43
+ testFilesTouched: [],
44
+ budgetExceeded: true,
45
+ }), true);
46
+ });
47
+ //# sourceMappingURL=run.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"run.test.js","sourceRoot":"","sources":["../../src/engine/run.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,MAAM,MAAM,oBAAoB,CAAC;AACxC,OAAO,EAAE,kBAAkB,EAAE,MAAM,UAAU,CAAC;AAE9C,IAAI,CAAC,mFAAmF,EAAE,GAAG,EAAE;IAC7F,MAAM,CAAC,KAAK,CACV,kBAAkB,CAAC,EAAE,YAAY,EAAE,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,gBAAgB,EAAE,EAAE,EAAE,CAAC,EAC/F,IAAI,CACL,CAAC;AACJ,CAAC,CAAC,CAAC;AAEH,IAAI,CAAC,sEAAsE,EAAE,GAAG,EAAE;IAChF,MAAM,CAAC,KAAK,CACV,kBAAkB,CAAC,EAAE,YAAY,EAAE,KAAK,EAAE,SAAS,EAAE,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,gBAAgB,EAAE,EAAE,EAAE,CAAC,EAChG,KAAK,CACN,CAAC;AACJ,CAAC,CAAC,CAAC;AAEH,IAAI,CAAC,iEAAiE,EAAE,GAAG,EAAE;IAC3E,MAAM,CAAC,KAAK,CACV,kBAAkB,CAAC;QACjB,YAAY,EAAE,KAAK;QACnB,SAAS,EAAE,IAAI;QACf,KAAK,EAAE,IAAI;QACX,gBAAgB,EAAE,CAAC,iBAAiB,CAAC;KACtC,CAAC,EACF,IAAI,CACL,CAAC;AACJ,CAAC,CAAC,CAAC;AAEH,IAAI,CAAC,0EAA0E,EAAE,GAAG,EAAE;IACpF,MAAM,CAAC,KAAK,CACV,kBAAkB,CAAC,EAAE,YAAY,EAAE,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,gBAAgB,EAAE,EAAE,EAAE,CAAC,EAC/F,KAAK,CACN,CAAC;AACJ,CAAC,CAAC,CAAC;AAEH,IAAI,CAAC,sGAAsG,EAAE,GAAG,EAAE;IAChH,MAAM,CAAC,KAAK,CACV,kBAAkB,CAAC;QACjB,YAAY,EAAE,KAAK;QACnB,SAAS,EAAE,IAAI;QACf,KAAK,EAAE,KAAK;QACZ,gBAAgB,EAAE,CAAC,iBAAiB,CAAC;KACtC,CAAC,EACF,KAAK,CACN,CAAC;AACJ,CAAC,CAAC,CAAC;AAEH,IAAI,CAAC,2FAA2F,EAAE,GAAG,EAAE;IACrG,MAAM,CAAC,KAAK,CACV,kBAAkB,CAAC;QACjB,YAAY,EAAE,KAAK;QACnB,SAAS,EAAE,KAAK;QAChB,KAAK,EAAE,IAAI;QACX,gBAAgB,EAAE,EAAE;QACpB,cAAc,EAAE,IAAI;KACrB,CAAC,EACF,IAAI,CACL,CAAC;AACJ,CAAC,CAAC,CAAC;AAEH,IAAI,CAAC,iGAAiG,EAAE,GAAG,EAAE;IAC3G,MAAM,CAAC,KAAK,CACV,kBAAkB,CAAC;QACjB,YAAY,EAAE,KAAK;QACnB,SAAS,EAAE,IAAI;QACf,KAAK,EAAE,IAAI;QACX,gBAAgB,EAAE,EAAE;QACpB,cAAc,EAAE,IAAI;KACrB,CAAC,EACF,IAAI,CACL,CAAC;AACJ,CAAC,CAAC,CAAC"}
@@ -0,0 +1,48 @@
1
+ import { mkdir, appendFile, readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ function statsDir() {
5
+ return process.env.GREENBUMP_STATS_DIR ?? join(homedir(), ".greenbump");
6
+ }
7
+ function runsPath() {
8
+ return join(statsDir(), "runs.jsonl");
9
+ }
10
+ /**
11
+ * Append one run record. Best-effort: a write failure (disk full,
12
+ * permissions) is logged once and swallowed — stats must never fail a run.
13
+ */
14
+ export async function recordRun(rec, onLog) {
15
+ try {
16
+ await mkdir(statsDir(), { recursive: true });
17
+ await appendFile(runsPath(), JSON.stringify(rec) + "\n", "utf8");
18
+ }
19
+ catch (err) {
20
+ onLog?.(`stats: failed to record run (${err.message})`);
21
+ }
22
+ }
23
+ /** Read all recorded runs, skipping any corrupt/partial lines. */
24
+ export async function loadRuns() {
25
+ let raw;
26
+ try {
27
+ raw = await readFile(runsPath(), "utf8");
28
+ }
29
+ catch {
30
+ return [];
31
+ }
32
+ const runs = [];
33
+ for (const line of raw.split("\n")) {
34
+ const trimmed = line.trim();
35
+ if (!trimmed)
36
+ continue;
37
+ try {
38
+ runs.push(JSON.parse(trimmed));
39
+ }
40
+ catch {
41
+ // corrupt line (e.g. truncated by a crash mid-write) — skip it
42
+ }
43
+ }
44
+ return runs;
45
+ }
46
+ /** Test helper: nothing to reset (statsDir() re-reads the env every call), kept for symmetry with the cache module. */
47
+ export function resetStatsForTests() { }
48
+ //# sourceMappingURL=recorder.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"recorder.js","sourceRoot":"","sources":["../../../src/engine/stats/recorder.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAC/D,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AA6BlC,SAAS,QAAQ;IACf,OAAO,OAAO,CAAC,GAAG,CAAC,mBAAmB,IAAI,IAAI,CAAC,OAAO,EAAE,EAAE,YAAY,CAAC,CAAC;AAC1E,CAAC;AAED,SAAS,QAAQ;IACf,OAAO,IAAI,CAAC,QAAQ,EAAE,EAAE,YAAY,CAAC,CAAC;AACxC,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,SAAS,CAAC,GAAc,EAAE,KAA2B;IACzE,IAAI,CAAC;QACH,MAAM,KAAK,CAAC,QAAQ,EAAE,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QAC7C,MAAM,UAAU,CAAC,QAAQ,EAAE,EAAE,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,GAAG,IAAI,EAAE,MAAM,CAAC,CAAC;IACnE,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,KAAK,EAAE,CAAC,gCAAiC,GAAa,CAAC,OAAO,GAAG,CAAC,CAAC;IACrE,CAAC;AACH,CAAC;AAED,kEAAkE;AAClE,MAAM,CAAC,KAAK,UAAU,QAAQ;IAC5B,IAAI,GAAW,CAAC;IAChB,IAAI,CAAC;QACH,GAAG,GAAG,MAAM,QAAQ,CAAC,QAAQ,EAAE,EAAE,MAAM,CAAC,CAAC;IAC3C,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,CAAC;IACZ,CAAC;IACD,MAAM,IAAI,GAAgB,EAAE,CAAC;IAC7B,KAAK,MAAM,IAAI,IAAI,GAAG,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;QACnC,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC;QAC5B,IAAI,CAAC,OAAO;YAAE,SAAS;QACvB,IAAI,CAAC;YACH,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,OAAO,CAAc,CAAC,CAAC;QAC9C,CAAC;QAAC,MAAM,CAAC;YACP,+DAA+D;QACjE,CAAC;IACH,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,uHAAuH;AACvH,MAAM,UAAU,kBAAkB,KAAU,CAAC"}
@@ -0,0 +1,71 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtemp, rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { recordRun, loadRuns } from "./recorder.js";
7
+ async function withStatsDir(fn) {
8
+ const dir = await mkdtemp(join(tmpdir(), "greenbump-stats-test-"));
9
+ const prev = process.env.GREENBUMP_STATS_DIR;
10
+ process.env.GREENBUMP_STATS_DIR = dir;
11
+ try {
12
+ await fn();
13
+ }
14
+ finally {
15
+ if (prev === undefined)
16
+ delete process.env.GREENBUMP_STATS_DIR;
17
+ else
18
+ process.env.GREENBUMP_STATS_DIR = prev;
19
+ await rm(dir, { recursive: true, force: true });
20
+ }
21
+ }
22
+ const sampleRecord = {
23
+ ts: Date.now(),
24
+ dep: "react-dom",
25
+ from: "18.3.1",
26
+ to: "19.2.0",
27
+ tier: 1,
28
+ inputTokens: 0,
29
+ outputTokens: 0,
30
+ avoidedLlmCall: true,
31
+ durationMs: 5000,
32
+ fixed: true,
33
+ needsReview: false,
34
+ };
35
+ test("recordRun + loadRuns: round-trips a record", async () => {
36
+ await withStatsDir(async () => {
37
+ assert.deepEqual(await loadRuns(), []);
38
+ await recordRun(sampleRecord);
39
+ const runs = await loadRuns();
40
+ assert.equal(runs.length, 1);
41
+ assert.deepEqual(runs[0], sampleRecord);
42
+ });
43
+ });
44
+ test("recordRun: appends rather than overwrites", async () => {
45
+ await withStatsDir(async () => {
46
+ await recordRun(sampleRecord);
47
+ await recordRun({ ...sampleRecord, dep: "vue" });
48
+ const runs = await loadRuns();
49
+ assert.equal(runs.length, 2);
50
+ assert.equal(runs[0].dep, "react-dom");
51
+ assert.equal(runs[1].dep, "vue");
52
+ });
53
+ });
54
+ test("loadRuns: skips corrupt lines instead of throwing", async () => {
55
+ await withStatsDir(async () => {
56
+ await recordRun(sampleRecord);
57
+ const { appendFile } = await import("node:fs/promises");
58
+ const dir = process.env.GREENBUMP_STATS_DIR;
59
+ await appendFile(join(dir, "runs.jsonl"), "{not valid json\n", "utf8");
60
+ await recordRun({ ...sampleRecord, dep: "axios" });
61
+ const runs = await loadRuns();
62
+ assert.equal(runs.length, 2);
63
+ assert.deepEqual(runs.map((r) => r.dep), ["react-dom", "axios"]);
64
+ });
65
+ });
66
+ test("loadRuns: missing file returns empty array, not an error", async () => {
67
+ await withStatsDir(async () => {
68
+ assert.deepEqual(await loadRuns(), []);
69
+ });
70
+ });
71
+ //# sourceMappingURL=recorder.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"recorder.test.js","sourceRoot":"","sources":["../../../src/engine/stats/recorder.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,157 @@
1
+ import pc from "picocolors";
2
+ import { loadRuns } from "./recorder.js";
3
+ /**
4
+ * Rough $/M-token pricing for common models, used only to turn "tokens
5
+ * avoided" into a ballpark dollar figure for `greenbump --stats`. Prices
6
+ * change and vary by provider tier — this is explicitly an estimate, never
7
+ * shown without that label.
8
+ */
9
+ const PRICING = {
10
+ "claude-opus": { inputPerM: 15, outputPerM: 75 },
11
+ "claude-sonnet": { inputPerM: 3, outputPerM: 15 },
12
+ "claude-haiku": { inputPerM: 0.8, outputPerM: 4 },
13
+ "gpt-4o-mini": { inputPerM: 0.15, outputPerM: 0.6 },
14
+ "gpt-4o": { inputPerM: 2.5, outputPerM: 10 },
15
+ deepseek: { inputPerM: 0.27, outputPerM: 1.1 },
16
+ };
17
+ /** Median across the built-in pricing table — the fallback estimate when we don't know (or have never spent on) a specific model. */
18
+ function medianPricing() {
19
+ const entries = Object.values(PRICING);
20
+ const mid = (nums) => {
21
+ const sorted = [...nums].sort((a, b) => a - b);
22
+ const m = Math.floor(sorted.length / 2);
23
+ return sorted.length % 2 ? sorted[m] : (sorted[m - 1] + sorted[m]) / 2;
24
+ };
25
+ return {
26
+ inputPerM: mid(entries.map((e) => e.inputPerM)),
27
+ outputPerM: mid(entries.map((e) => e.outputPerM)),
28
+ };
29
+ }
30
+ function pricingFor(model) {
31
+ if (!model)
32
+ return medianPricing();
33
+ const key = Object.keys(PRICING).find((k) => model.toLowerCase().includes(k));
34
+ return key ? PRICING[key] : medianPricing();
35
+ }
36
+ function estimateCostUsd(inputTokens, outputTokens, model) {
37
+ const p = pricingFor(model);
38
+ return (inputTokens / 1_000_000) * p.inputPerM + (outputTokens / 1_000_000) * p.outputPerM;
39
+ }
40
+ const EMPTY_SUMMARY = {
41
+ windowDays: 0,
42
+ totalRuns: 0,
43
+ fixedRuns: 0,
44
+ fixRate: 0,
45
+ tierBreakdown: { 1: 0, 2: 0, 3: 0, 4: 0 },
46
+ llmCallsAvoided: 0,
47
+ cacheHits: 0,
48
+ actualTokens: { input: 0, output: 0 },
49
+ actualCostUsd: 0,
50
+ avoidedTokensEstimate: { input: 0, output: 0 },
51
+ estimatedSavedUsd: 0,
52
+ };
53
+ /** Aggregate recorded runs from the last `days` days into a stats summary. */
54
+ export function summarize(runs, days = 30) {
55
+ const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
56
+ const inWindow = runs.filter((r) => r.ts >= cutoff);
57
+ if (inWindow.length === 0)
58
+ return { ...EMPTY_SUMMARY, windowDays: days };
59
+ const tierBreakdown = { 1: 0, 2: 0, 3: 0, 4: 0 };
60
+ let fixedRuns = 0;
61
+ let llmCallsAvoided = 0;
62
+ let cacheHits = 0;
63
+ let actualInput = 0;
64
+ let actualOutput = 0;
65
+ let actualCostUsd = 0;
66
+ const tier4RunsForAverage = [];
67
+ for (const r of inWindow) {
68
+ if (r.fixed)
69
+ fixedRuns++;
70
+ if (r.tier)
71
+ tierBreakdown[r.tier]++;
72
+ if (r.cacheHit)
73
+ cacheHits++;
74
+ if (r.avoidedLlmCall) {
75
+ llmCallsAvoided++;
76
+ }
77
+ else if (r.tier === 4) {
78
+ actualInput += r.inputTokens;
79
+ actualOutput += r.outputTokens;
80
+ actualCostUsd += estimateCostUsd(r.inputTokens, r.outputTokens, r.model);
81
+ tier4RunsForAverage.push(r);
82
+ }
83
+ }
84
+ // Estimate what the avoided runs *would* have cost, using this window's
85
+ // own average tier-4 spend when available (most accurate), or a
86
+ // conservative default typical-fix estimate otherwise.
87
+ const avgInput = tier4RunsForAverage.length > 0
88
+ ? actualInput / tier4RunsForAverage.length
89
+ : 20_000;
90
+ const avgOutput = tier4RunsForAverage.length > 0
91
+ ? actualOutput / tier4RunsForAverage.length
92
+ : 5_000;
93
+ const avgModel = tier4RunsForAverage[0]?.model;
94
+ const avoidedTokensEstimate = {
95
+ input: Math.round(avgInput * llmCallsAvoided),
96
+ output: Math.round(avgOutput * llmCallsAvoided),
97
+ };
98
+ const estimatedSavedUsd = estimateCostUsd(avoidedTokensEstimate.input, avoidedTokensEstimate.output, avgModel);
99
+ return {
100
+ windowDays: days,
101
+ totalRuns: inWindow.length,
102
+ fixedRuns,
103
+ fixRate: fixedRuns / inWindow.length,
104
+ tierBreakdown,
105
+ llmCallsAvoided,
106
+ cacheHits,
107
+ actualTokens: { input: actualInput, output: actualOutput },
108
+ actualCostUsd,
109
+ avoidedTokensEstimate,
110
+ estimatedSavedUsd,
111
+ };
112
+ }
113
+ export async function loadAndSummarize(days = 30) {
114
+ return summarize(await loadRuns(), days);
115
+ }
116
+ const TIER_NAMES = {
117
+ 1: "codemod",
118
+ 2: "learned pattern",
119
+ 3: "cached fix",
120
+ 4: "LLM",
121
+ };
122
+ /** Render the stats summary for terminal output (used by `greenbump --stats`). */
123
+ export function formatStatsReport(s) {
124
+ const lines = [];
125
+ if (s.totalRuns === 0) {
126
+ lines.push(pc.dim(`No runs recorded in the last ${s.windowDays} days.`));
127
+ return lines.join("\n");
128
+ }
129
+ lines.push(pc.bold(`greenbump usage — last ${s.windowDays} days`));
130
+ lines.push("");
131
+ lines.push(`${pc.dim("runs")} ${s.totalRuns}`);
132
+ lines.push(`${pc.dim("fixed")} ${s.fixedRuns}/${s.totalRuns} (${(s.fixRate * 100).toFixed(0)}%)`);
133
+ lines.push("");
134
+ lines.push(pc.bold("fix tiers:"));
135
+ for (const tier of [1, 2, 3, 4]) {
136
+ const count = s.tierBreakdown[tier];
137
+ if (count === 0)
138
+ continue;
139
+ const label = tier < 4 ? pc.green(TIER_NAMES[tier]) : TIER_NAMES[tier];
140
+ lines.push(` ${label.padEnd(24)} ${count}`);
141
+ }
142
+ lines.push("");
143
+ lines.push(`${pc.dim("LLM calls avoided")} ${pc.green(String(s.llmCallsAvoided))} (tiers 1-3 — $0 spent)`);
144
+ if (s.cacheHits > 0) {
145
+ lines.push(`${pc.dim("cache hits")} ${s.cacheHits}`);
146
+ }
147
+ lines.push("");
148
+ lines.push(pc.bold("tokens & cost:"));
149
+ lines.push(` ${pc.dim("actually spent")} ${s.actualTokens.input.toLocaleString()} in / ${s.actualTokens.output.toLocaleString()} out (~$${s.actualCostUsd.toFixed(2)})`);
150
+ if (s.llmCallsAvoided > 0) {
151
+ lines.push(` ${pc.dim("avoided (estimate)")} ${s.avoidedTokensEstimate.input.toLocaleString()} in / ${s.avoidedTokensEstimate.output.toLocaleString()} out (~$${s.estimatedSavedUsd.toFixed(2)})`);
152
+ lines.push("");
153
+ lines.push(pc.green(` ~$${s.estimatedSavedUsd.toFixed(2)} saved by free fix tiers (estimate, not a bill)`));
154
+ }
155
+ return lines.join("\n");
156
+ }
157
+ //# sourceMappingURL=report.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"report.js","sourceRoot":"","sources":["../../../src/engine/stats/report.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,83 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { summarize, formatStatsReport } from "./report.js";
4
+ const now = Date.now();
5
+ const daysAgo = (n) => now - n * 24 * 60 * 60 * 1000;
6
+ function rec(overrides) {
7
+ return {
8
+ ts: now,
9
+ dep: "react-dom",
10
+ from: "18.3.1",
11
+ to: "19.2.0",
12
+ inputTokens: 0,
13
+ outputTokens: 0,
14
+ avoidedLlmCall: false,
15
+ durationMs: 1000,
16
+ fixed: true,
17
+ needsReview: false,
18
+ ...overrides,
19
+ };
20
+ }
21
+ test("summarize: empty input returns a zeroed summary, not a crash", () => {
22
+ const s = summarize([], 30);
23
+ assert.equal(s.totalRuns, 0);
24
+ assert.equal(s.fixRate, 0);
25
+ assert.equal(s.estimatedSavedUsd, 0);
26
+ });
27
+ test("summarize: counts tiers and fix rate correctly", () => {
28
+ const runs = [
29
+ rec({ tier: 1, avoidedLlmCall: true }),
30
+ rec({ tier: 1, avoidedLlmCall: true }),
31
+ rec({ tier: 4, inputTokens: 20000, outputTokens: 5000, model: "claude-sonnet" }),
32
+ rec({ fixed: false, tier: 4, inputTokens: 20000, outputTokens: 5000, model: "claude-sonnet" }),
33
+ ];
34
+ const s = summarize(runs, 30);
35
+ assert.equal(s.totalRuns, 4);
36
+ assert.equal(s.fixedRuns, 3);
37
+ assert.equal(s.fixRate, 0.75);
38
+ assert.equal(s.tierBreakdown[1], 2);
39
+ assert.equal(s.tierBreakdown[4], 2);
40
+ assert.equal(s.llmCallsAvoided, 2);
41
+ });
42
+ test("summarize: excludes runs outside the requested window", () => {
43
+ const runs = [rec({ ts: daysAgo(1) }), rec({ ts: daysAgo(45) })];
44
+ const s = summarize(runs, 30);
45
+ assert.equal(s.totalRuns, 1);
46
+ });
47
+ test("summarize: estimates saved cost using this window's own tier-4 average when available", () => {
48
+ const runs = [
49
+ rec({ tier: 4, inputTokens: 10000, outputTokens: 2000, model: "claude-sonnet" }),
50
+ rec({ tier: 1, avoidedLlmCall: true }),
51
+ ];
52
+ const s = summarize(runs, 30);
53
+ assert.equal(s.avoidedTokensEstimate.input, 10000);
54
+ assert.equal(s.avoidedTokensEstimate.output, 2000);
55
+ assert.ok(s.estimatedSavedUsd > 0);
56
+ });
57
+ test("summarize: falls back to a conservative default estimate with no tier-4 history", () => {
58
+ const runs = [rec({ tier: 1, avoidedLlmCall: true })];
59
+ const s = summarize(runs, 30);
60
+ assert.equal(s.avoidedTokensEstimate.input, 20000);
61
+ assert.ok(s.estimatedSavedUsd > 0);
62
+ });
63
+ test("summarize: cache hits are counted independently of tier", () => {
64
+ const runs = [rec({ tier: 3, avoidedLlmCall: true, cacheHit: true })];
65
+ const s = summarize(runs, 30);
66
+ assert.equal(s.cacheHits, 1);
67
+ });
68
+ test("formatStatsReport: empty summary renders a friendly message, no crash", () => {
69
+ const s = summarize([], 30);
70
+ const out = formatStatsReport(s);
71
+ assert.match(out, /No runs recorded/);
72
+ });
73
+ test("formatStatsReport: non-empty summary mentions avoided calls and estimate label", () => {
74
+ const runs = [
75
+ rec({ tier: 1, avoidedLlmCall: true }),
76
+ rec({ tier: 4, inputTokens: 20000, outputTokens: 5000, model: "claude-sonnet" }),
77
+ ];
78
+ const s = summarize(runs, 30);
79
+ const out = formatStatsReport(s);
80
+ assert.match(out, /LLM calls avoided/);
81
+ assert.match(out, /estimate/);
82
+ });
83
+ //# sourceMappingURL=report.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"report.test.js","sourceRoot":"","sources":["../../../src/engine/stats/report.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,212 @@
1
+ import { readFile, readdir, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import ts from "typescript";
4
+ const ANALYZABLE_EXT = new Set([".ts", ".tsx", ".mts", ".cts"]);
5
+ const MAX_FILE_SIZE = 500_000;
6
+ /** Extract the exported symbol table from a TS/TSX file. */
7
+ function extractExports(code, filename) {
8
+ const symbols = new Map();
9
+ let sourceFile;
10
+ try {
11
+ sourceFile = ts.createSourceFile(filename, code, ts.ScriptTarget.Latest, true);
12
+ }
13
+ catch {
14
+ return symbols;
15
+ }
16
+ const hasExportModifier = (node) => {
17
+ const mods = ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined;
18
+ return mods?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) ?? false;
19
+ };
20
+ const isDefault = (node) => {
21
+ const mods = ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined;
22
+ return mods?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword) ?? false;
23
+ };
24
+ const visit = (node) => {
25
+ // export function foo(...) / export class Foo / export const foo
26
+ if ((ts.isFunctionDeclaration(node) ||
27
+ ts.isClassDeclaration(node) ||
28
+ ts.isInterfaceDeclaration(node) ||
29
+ ts.isTypeAliasDeclaration(node) ||
30
+ ts.isEnumDeclaration(node)) &&
31
+ hasExportModifier(node)) {
32
+ const name = isDefault(node) ? "default" : node.name?.getText(sourceFile);
33
+ if (name) {
34
+ symbols.set(name, {
35
+ name,
36
+ kind: ts.isFunctionDeclaration(node)
37
+ ? "function"
38
+ : ts.isClassDeclaration(node)
39
+ ? "class"
40
+ : ts.isInterfaceDeclaration(node)
41
+ ? "interface"
42
+ : ts.isTypeAliasDeclaration(node)
43
+ ? "type"
44
+ : "enum",
45
+ signature: ts.isFunctionDeclaration(node) ? signatureOf(node, sourceFile) : undefined,
46
+ });
47
+ }
48
+ }
49
+ // export const foo = ...
50
+ if (ts.isVariableStatement(node) && hasExportModifier(node)) {
51
+ for (const decl of node.declarationList.declarations) {
52
+ const name = decl.name.getText(sourceFile);
53
+ symbols.set(name, {
54
+ name,
55
+ kind: "const",
56
+ signature: decl.type?.getText(sourceFile),
57
+ });
58
+ }
59
+ }
60
+ // export { a, b } / export { a as b }
61
+ if (ts.isExportDeclaration(node) && node.exportClause && ts.isNamedExports(node.exportClause)) {
62
+ for (const el of node.exportClause.elements) {
63
+ const name = el.name.getText(sourceFile);
64
+ symbols.set(name, { name, kind: "unknown" });
65
+ }
66
+ }
67
+ // export default <expr>
68
+ if (ts.isExportAssignment(node) && !node.isExportEquals) {
69
+ symbols.set("default", { name: "default", kind: "unknown" });
70
+ }
71
+ ts.forEachChild(node, visit);
72
+ };
73
+ visit(sourceFile);
74
+ return symbols;
75
+ }
76
+ function signatureOf(fn, sf) {
77
+ const params = fn.parameters.map((p) => {
78
+ const type = p.type ? p.type.getText(sf) : "any";
79
+ return `${p.name.getText(sf)}: ${type}`;
80
+ });
81
+ const ret = fn.type ? fn.type.getText(sf) : "void";
82
+ return `(${params.join(", ")}) => ${ret}`;
83
+ }
84
+ /**
85
+ * Compare the exported API surface before vs after the fix.
86
+ * `changedFiles` are the files the fix agent touched (project-relative).
87
+ * For each, we compare git HEAD content (pre-upgrade... actually pre-fix)
88
+ * against current working-tree content.
89
+ */
90
+ export async function analyzeApiChanges(cwd, changedFiles, readBefore) {
91
+ const changes = [];
92
+ for (const file of changedFiles) {
93
+ const ext = file.slice(file.lastIndexOf("."));
94
+ if (!ANALYZABLE_EXT.has(ext))
95
+ continue;
96
+ const abs = join(cwd, file);
97
+ let after;
98
+ try {
99
+ const s = await stat(abs);
100
+ if (s.size > MAX_FILE_SIZE)
101
+ continue;
102
+ after = await readFile(abs, "utf8");
103
+ }
104
+ catch {
105
+ continue; // file deleted or unreadable — covered by change-detector
106
+ }
107
+ const before = await readBefore(file);
108
+ if (before === null)
109
+ continue; // new file — nothing to compare
110
+ const beforeExports = extractExports(before, file);
111
+ const afterExports = extractExports(after, file);
112
+ // Removed exports
113
+ for (const [name, sym] of beforeExports) {
114
+ if (!afterExports.has(name)) {
115
+ changes.push({
116
+ kind: sym.kind === "type" || sym.kind === "interface" ? "export-type-removed" : "export-removed",
117
+ file,
118
+ symbol: name,
119
+ detail: `${sym.kind} \`${name}\` was exported before the fix but is no longer exported`,
120
+ severity: name === "default" ? "critical" : "warning",
121
+ });
122
+ continue;
123
+ }
124
+ // Signature changes on functions
125
+ const afterSym = afterExports.get(name);
126
+ if (sym.kind === "function" &&
127
+ afterSym.kind === "function" &&
128
+ sym.signature &&
129
+ afterSym.signature &&
130
+ sym.signature !== afterSym.signature) {
131
+ changes.push({
132
+ kind: "signature-changed",
133
+ file,
134
+ symbol: name,
135
+ detail: `\`${name}\` signature changed: ${sym.signature} → ${afterSym.signature}`,
136
+ severity: "warning",
137
+ });
138
+ }
139
+ }
140
+ // Newly-loosened types: any introduced where a concrete type existed
141
+ const loosened = detectTypeLoosening(before, after, file);
142
+ changes.push(...loosened);
143
+ }
144
+ const hasCritical = changes.some((c) => c.severity === "critical");
145
+ const criticalCount = changes.filter((c) => c.severity === "critical").length;
146
+ const warningCount = changes.filter((c) => c.severity === "warning").length;
147
+ const summary = changes.length === 0
148
+ ? "no API surface changes detected"
149
+ : `${criticalCount} critical, ${warningCount} warning(s) — exported API changed`;
150
+ return { changes, hasCritical, summary };
151
+ }
152
+ /** Detect `any` annotations introduced by the fix (silencing the compiler). */
153
+ function detectTypeLoosening(before, after, file) {
154
+ const out = [];
155
+ const countAny = (code) => {
156
+ const m = code.match(/:\s*any\b|<any>|as any\b/g);
157
+ return m ? m.length : 0;
158
+ };
159
+ const beforeCount = countAny(before);
160
+ const afterCount = countAny(after);
161
+ if (afterCount > beforeCount) {
162
+ out.push({
163
+ kind: "type-loosened-to-any",
164
+ file,
165
+ symbol: "(multiple)",
166
+ detail: `fix introduced ${afterCount - beforeCount} new \`any\` annotation(s) — may be masking a real type error`,
167
+ severity: "warning",
168
+ });
169
+ }
170
+ return out;
171
+ }
172
+ /** Read a file's content from git HEAD (used as the "before" state). */
173
+ export async function readFromGitHead(cwd, path) {
174
+ const { execFile } = await import("node:child_process");
175
+ return new Promise((resolve) => {
176
+ execFile("git", ["show", `HEAD:${path}`], { cwd, maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => {
177
+ if (err)
178
+ resolve(null);
179
+ else
180
+ resolve(stdout);
181
+ });
182
+ });
183
+ }
184
+ /** List TS/TSX files in the project (for full-project scans, if needed). */
185
+ export async function listAnalyzableFiles(cwd, root = ".") {
186
+ const IGNORE = new Set(["node_modules", ".git", "dist", "build", ".next", "coverage"]);
187
+ const out = [];
188
+ async function walk(dir) {
189
+ let entries;
190
+ try {
191
+ entries = await readdir(join(cwd, dir), { withFileTypes: true });
192
+ }
193
+ catch {
194
+ return;
195
+ }
196
+ for (const e of entries) {
197
+ if (IGNORE.has(e.name))
198
+ continue;
199
+ const rel = dir === "." ? e.name : `${dir}/${e.name}`;
200
+ if (e.isDirectory())
201
+ await walk(rel);
202
+ else {
203
+ const ext = e.name.slice(e.name.lastIndexOf("."));
204
+ if (ANALYZABLE_EXT.has(ext))
205
+ out.push(rel);
206
+ }
207
+ }
208
+ }
209
+ await walk(root);
210
+ return out;
211
+ }
212
+ //# sourceMappingURL=ast-analyzer.js.map