burnwatch 0.2.0 → 0.4.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
@@ -3,7 +3,8 @@ var CONFIDENCE_BADGES = {
3
3
  live: "\u2705 LIVE",
4
4
  calc: "\u{1F7E1} CALC",
5
5
  est: "\u{1F7E0} EST",
6
- blind: "\u{1F534} BLIND"
6
+ blind: "\u{1F534} BLIND",
7
+ excluded: "\u2B1A SKIP"
7
8
  };
8
9
 
9
10
  // src/core/registry.ts
@@ -59,7 +60,7 @@ import * as path2 from "path";
59
60
  function detectServices(projectRoot) {
60
61
  const registry = loadRegistry(projectRoot);
61
62
  const results = /* @__PURE__ */ new Map();
62
- const pkgDeps = scanPackageJson(projectRoot);
63
+ const pkgDeps = scanAllPackageJsons(projectRoot);
63
64
  for (const [serviceId, service] of registry) {
64
65
  const matchedPkgs = service.packageNames.filter(
65
66
  (pkg) => pkgDeps.has(pkg)
@@ -71,7 +72,7 @@ function detectServices(projectRoot) {
71
72
  );
72
73
  }
73
74
  }
74
- const envVars = new Set(Object.keys(process.env));
75
+ const envVars = collectEnvVars(projectRoot);
75
76
  for (const [serviceId, service] of registry) {
76
77
  const matchedEnvs = service.envPatterns.filter(
77
78
  (pattern) => envVars.has(pattern)
@@ -182,40 +183,118 @@ function getOrCreate(map, serviceId, service) {
182
183
  }
183
184
  return result;
184
185
  }
185
- function scanPackageJson(projectRoot) {
186
+ function scanAllPackageJsons(projectRoot) {
186
187
  const deps = /* @__PURE__ */ new Set();
187
- const pkgPath = path2.join(projectRoot, "package.json");
188
+ const pkgFiles = findFiles(projectRoot, "package.json", 4);
189
+ for (const pkgPath of pkgFiles) {
190
+ try {
191
+ const raw = fs2.readFileSync(pkgPath, "utf-8");
192
+ const pkg = JSON.parse(raw);
193
+ for (const name of Object.keys(pkg.dependencies ?? {})) deps.add(name);
194
+ for (const name of Object.keys(pkg.devDependencies ?? {})) deps.add(name);
195
+ } catch {
196
+ }
197
+ }
198
+ return deps;
199
+ }
200
+ function collectEnvVars(projectRoot) {
201
+ const envVars = new Set(Object.keys(process.env));
202
+ const envFiles = findEnvFiles(projectRoot, 3);
203
+ for (const envFile of envFiles) {
204
+ try {
205
+ const content = fs2.readFileSync(envFile, "utf-8");
206
+ const keys = content.split("\n").filter((line) => line.includes("=") && !line.startsWith("#")).map((line) => line.split("=")[0].trim()).filter(Boolean);
207
+ for (const key of keys) {
208
+ envVars.add(key);
209
+ }
210
+ } catch {
211
+ }
212
+ }
213
+ return envVars;
214
+ }
215
+ function findEnvFiles(dir, maxDepth) {
216
+ const results = [];
217
+ if (maxDepth <= 0) return results;
188
218
  try {
189
- const raw = fs2.readFileSync(pkgPath, "utf-8");
190
- const pkg = JSON.parse(raw);
191
- for (const name of Object.keys(pkg.dependencies ?? {})) deps.add(name);
192
- for (const name of Object.keys(pkg.devDependencies ?? {})) deps.add(name);
219
+ const entries = fs2.readdirSync(dir, { withFileTypes: true });
220
+ for (const entry of entries) {
221
+ if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist") continue;
222
+ const fullPath = path2.join(dir, entry.name);
223
+ if (entry.isDirectory()) {
224
+ results.push(...findEnvFiles(fullPath, maxDepth - 1));
225
+ } else if (entry.name.startsWith(".env")) {
226
+ results.push(fullPath);
227
+ }
228
+ }
193
229
  } catch {
194
230
  }
195
- return deps;
231
+ return results;
232
+ }
233
+ function findFiles(dir, fileName, maxDepth) {
234
+ const results = [];
235
+ if (maxDepth <= 0) return results;
236
+ try {
237
+ const entries = fs2.readdirSync(dir, { withFileTypes: true });
238
+ for (const entry of entries) {
239
+ if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist") continue;
240
+ const fullPath = path2.join(dir, entry.name);
241
+ if (entry.isDirectory()) {
242
+ results.push(...findFiles(fullPath, fileName, maxDepth - 1));
243
+ } else if (entry.name === fileName) {
244
+ results.push(fullPath);
245
+ }
246
+ }
247
+ } catch {
248
+ }
249
+ return results;
196
250
  }
197
251
  function scanImports(projectRoot) {
198
252
  const imports = /* @__PURE__ */ new Set();
199
- const srcDir = path2.join(projectRoot, "src");
200
- if (!fs2.existsSync(srcDir)) return imports;
201
- const files = walkDir(srcDir, /\.(ts|tsx|js|jsx|mjs|cjs)$/);
202
- for (const file of files) {
203
- try {
204
- const content = fs2.readFileSync(file, "utf-8");
205
- const importRegex = /(?:from\s+["']|require\s*\(\s*["'])([^./][^"']*?)(?:["'])/g;
206
- let match;
207
- while ((match = importRegex.exec(content)) !== null) {
208
- const pkg = match[1];
209
- if (pkg) {
210
- const parts = pkg.split("/");
211
- if (parts[0]?.startsWith("@") && parts.length >= 2) {
212
- imports.add(`${parts[0]}/${parts[1]}`);
213
- } else if (parts[0]) {
214
- imports.add(parts[0]);
253
+ const codeDirs = ["src", "app", "lib", "pages", "components", "utils", "services", "hooks"];
254
+ const dirsToScan = [];
255
+ for (const dir of codeDirs) {
256
+ const fullPath = path2.join(projectRoot, dir);
257
+ if (fs2.existsSync(fullPath)) {
258
+ dirsToScan.push(fullPath);
259
+ }
260
+ }
261
+ try {
262
+ const entries = fs2.readdirSync(projectRoot, { withFileTypes: true });
263
+ for (const entry of entries) {
264
+ if (!entry.isDirectory()) continue;
265
+ if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist" || entry.name.startsWith(".")) continue;
266
+ const subPkgPath = path2.join(projectRoot, entry.name, "package.json");
267
+ if (fs2.existsSync(subPkgPath)) {
268
+ for (const dir of codeDirs) {
269
+ const fullPath = path2.join(projectRoot, entry.name, dir);
270
+ if (fs2.existsSync(fullPath)) {
271
+ dirsToScan.push(fullPath);
215
272
  }
216
273
  }
217
274
  }
218
- } catch {
275
+ }
276
+ } catch {
277
+ }
278
+ for (const dir of dirsToScan) {
279
+ const files = walkDir(dir, /\.(ts|tsx|js|jsx|mjs|cjs)$/);
280
+ for (const file of files) {
281
+ try {
282
+ const content = fs2.readFileSync(file, "utf-8");
283
+ const importRegex = /(?:from\s+["']|require\s*\(\s*["'])([^./][^"']*?)(?:["'])/g;
284
+ let match;
285
+ while ((match = importRegex.exec(content)) !== null) {
286
+ const pkg = match[1];
287
+ if (pkg) {
288
+ const parts = pkg.split("/");
289
+ if (parts[0]?.startsWith("@") && parts.length >= 2) {
290
+ imports.add(`${parts[0]}/${parts[1]}`);
291
+ } else if (parts[0]) {
292
+ imports.add(parts[0]);
293
+ }
294
+ }
295
+ }
296
+ } catch {
297
+ }
219
298
  }
220
299
  }
221
300
  return imports;
@@ -483,6 +562,14 @@ function writeLedger(brief, projectRoot) {
483
562
  `| ${svc.serviceId} | ${spendStr} | ${badge} | ${budgetStr} | ${svc.statusLabel} |`
484
563
  );
485
564
  }
565
+ const impactAlert = brief.alerts.find(
566
+ (a) => a.serviceId === "_session_impact"
567
+ );
568
+ if (impactAlert) {
569
+ lines.push(
570
+ `| _projected impact_ | \u2014 | \u{1F4C8} EST | \u2014 | ${impactAlert.message} |`
571
+ );
572
+ }
486
573
  lines.push("");
487
574
  const totalStr = brief.totalIsEstimate ? `~$${brief.totalSpend.toFixed(2)}` : `$${brief.totalSpend.toFixed(2)}`;
488
575
  const marginStr = brief.estimateMargin > 0 ? ` (\xB1$${brief.estimateMargin.toFixed(0)} estimated margin)` : "";
@@ -543,8 +630,245 @@ function readLatestSnapshot(projectRoot) {
543
630
  return null;
544
631
  }
545
632
  }
633
+
634
+ // src/cost-impact.ts
635
+ var SERVICE_CALL_PATTERNS = {
636
+ anthropic: [
637
+ /\.messages\.create\s*\(/g,
638
+ /\.completions\.create\s*\(/g,
639
+ /anthropic\.\w+\.create\s*\(/g
640
+ ],
641
+ openai: [
642
+ /\.chat\.completions\.create\s*\(/g,
643
+ /\.completions\.create\s*\(/g,
644
+ /\.images\.generate\s*\(/g,
645
+ /\.embeddings\.create\s*\(/g,
646
+ /openai\.\w+\.create\s*\(/g
647
+ ],
648
+ "google-gemini": [
649
+ /\.generateContent\s*\(/g,
650
+ /\.generateContentStream\s*\(/g,
651
+ /model\.generate\w*\s*\(/g
652
+ ],
653
+ "voyage-ai": [
654
+ /\.embed\s*\(/g,
655
+ /voyageai\.embed\s*\(/g
656
+ ],
657
+ scrapfly: [
658
+ /\.scrape\s*\(/g,
659
+ /scrapfly\.scrape\s*\(/g,
660
+ /\.async_scrape\s*\(/g,
661
+ /ScrapeConfig\s*\(/g
662
+ ],
663
+ browserbase: [
664
+ /\.createSession\s*\(/g,
665
+ /\.sessions\.create\s*\(/g,
666
+ /stagehand\.act\s*\(/g,
667
+ /stagehand\.extract\s*\(/g
668
+ ],
669
+ upstash: [
670
+ /redis\.\w+\s*\(/g,
671
+ /\.set\s*\(/g,
672
+ /\.get\s*\(/g,
673
+ /\.incr\s*\(/g,
674
+ /\.hset\s*\(/g
675
+ ],
676
+ resend: [
677
+ /resend\.emails\.send\s*\(/g,
678
+ /\.emails\.send\s*\(/g
679
+ ],
680
+ stripe: [
681
+ /stripe\.charges\.create\s*\(/g,
682
+ /stripe\.paymentIntents\.create\s*\(/g,
683
+ /stripe\.checkout\.sessions\.create\s*\(/g
684
+ ],
685
+ supabase: [
686
+ /supabase\.from\s*\(/g,
687
+ /\.rpc\s*\(/g,
688
+ /supabase\.storage/g
689
+ ],
690
+ inngest: [
691
+ /inngest\.send\s*\(/g,
692
+ /\.createFunction\s*\(/g
693
+ ],
694
+ posthog: [
695
+ /posthog\.capture\s*\(/g,
696
+ /\.capture\s*\(/g
697
+ ],
698
+ aws: [
699
+ /\.send\s*\(new\s+\w+Command/g,
700
+ /s3Client\.send\s*\(/g,
701
+ /lambdaClient\.send\s*\(/g
702
+ ]
703
+ };
704
+ function detectMultipliers(content) {
705
+ const multipliers = [];
706
+ if (/for\s*\(.*;\s*\w+\s*<\s*(\w+)/g.test(content)) {
707
+ const loopMatch = content.match(/for\s*\(.*;\s*\w+\s*<\s*(\d+)/);
708
+ if (loopMatch) {
709
+ const bound = parseInt(loopMatch[1]);
710
+ if (bound > 1) {
711
+ multipliers.push({ label: `for loop (${bound} iterations)`, factor: bound });
712
+ }
713
+ } else {
714
+ multipliers.push({ label: "for loop (variable bound)", factor: 10 });
715
+ }
716
+ }
717
+ if (/\.\s*map\s*\(\s*(async\s*)?\(/g.test(content)) {
718
+ multipliers.push({ label: ".map() iteration", factor: 10 });
719
+ }
720
+ if (/\.\s*forEach\s*\(\s*(async\s*)?\(/g.test(content)) {
721
+ multipliers.push({ label: ".forEach() iteration", factor: 10 });
722
+ }
723
+ if (/for\s*\(\s*(const|let|var)\s+\w+\s+(of|in)\s+/g.test(content)) {
724
+ multipliers.push({ label: "for...of/in loop", factor: 10 });
725
+ }
726
+ if (/Promise\.all\s*\(/g.test(content)) {
727
+ multipliers.push({ label: "Promise.all (parallel batch)", factor: 10 });
728
+ }
729
+ if (/cron|schedule|interval|setInterval|every\s+\d+\s*(min|hour|day|sec)/gi.test(content)) {
730
+ if (/every\s+5\s*min/gi.test(content) || /\*\/5\s+\*\s+\*/g.test(content)) {
731
+ multipliers.push({ label: "cron: every 5 minutes", factor: 8640 });
732
+ } else if (/every\s+1?\s*hour/gi.test(content) || /0\s+\*\s+\*\s+\*/g.test(content)) {
733
+ multipliers.push({ label: "cron: hourly", factor: 720 });
734
+ } else if (/every\s+1?\s*day/gi.test(content) || /0\s+0\s+\*\s+\*/g.test(content)) {
735
+ multipliers.push({ label: "cron: daily", factor: 30 });
736
+ } else {
737
+ multipliers.push({ label: "scheduled execution", factor: 30 });
738
+ }
739
+ }
740
+ const batchMatch = content.match(/batch[_\s]?size\s*[=:]\s*(\d+)/i);
741
+ if (batchMatch) {
742
+ const batchSize = parseInt(batchMatch[1]);
743
+ if (batchSize > 1) {
744
+ multipliers.push({ label: `batch size: ${batchSize}`, factor: batchSize });
745
+ }
746
+ }
747
+ return multipliers;
748
+ }
749
+ var GOTCHA_MULTIPLIERS = {
750
+ scrapfly: {
751
+ low: 1,
752
+ high: 25,
753
+ explanation: "anti-bot bypass consumes 5-25x base credits"
754
+ },
755
+ browserbase: {
756
+ low: 1,
757
+ high: 5,
758
+ explanation: "session duration affects cost \u2014 long sessions burn more"
759
+ },
760
+ anthropic: {
761
+ low: 1,
762
+ high: 60,
763
+ explanation: "Haiku ~$0.25/MTok vs Opus ~$15/MTok (60x range)"
764
+ },
765
+ openai: {
766
+ low: 1,
767
+ high: 30,
768
+ explanation: "GPT-4 mini vs GPT-5 (30x cost range)"
769
+ },
770
+ stripe: {
771
+ low: 1,
772
+ high: 1.5,
773
+ explanation: "international cards add 1-1.5% extra"
774
+ }
775
+ };
776
+ function analyzeCostImpact(filePath, content, projectRoot) {
777
+ if (!/\.(ts|tsx|js|jsx|mjs|cjs)$/.test(filePath)) {
778
+ return [];
779
+ }
780
+ const registry = loadRegistry(projectRoot);
781
+ const impacts = [];
782
+ const multipliers = detectMultipliers(content);
783
+ for (const [serviceId, patterns] of Object.entries(SERVICE_CALL_PATTERNS)) {
784
+ let totalCalls = 0;
785
+ for (const pattern of patterns) {
786
+ pattern.lastIndex = 0;
787
+ const matches = content.match(pattern);
788
+ if (matches) {
789
+ totalCalls += matches.length;
790
+ }
791
+ }
792
+ if (totalCalls === 0) continue;
793
+ const service = registry.get(serviceId);
794
+ if (!service) continue;
795
+ const multiplierFactor = multipliers.length > 0 ? multipliers.reduce((max, m) => Math.max(max, m.factor), 1) : 1;
796
+ const baseMonthlyRuns = multipliers.some((m) => m.label.startsWith("cron")) ? 1 : 50;
797
+ const monthlyInvocations = totalCalls * multiplierFactor * baseMonthlyRuns;
798
+ const gotcha = GOTCHA_MULTIPLIERS[serviceId];
799
+ const unitRate = service.pricing?.unitRate ?? 0;
800
+ let costLow;
801
+ let costHigh;
802
+ if (unitRate > 0) {
803
+ costLow = monthlyInvocations * unitRate * (gotcha?.low ?? 1);
804
+ costHigh = monthlyInvocations * unitRate * (gotcha?.high ?? 1);
805
+ } else if (service.pricing?.monthlyBase !== void 0) {
806
+ costLow = 0;
807
+ costHigh = 0;
808
+ } else {
809
+ const typicalCallCosts = {
810
+ anthropic: 3e-3,
811
+ // ~$3/MTok * ~1K tokens average
812
+ openai: 2e-3,
813
+ "google-gemini": 1e-3,
814
+ scrapfly: 15e-5,
815
+ browserbase: 0.01,
816
+ resend: 1e-3,
817
+ stripe: 0.3
818
+ };
819
+ const perCall = typicalCallCosts[serviceId] ?? 1e-3;
820
+ costLow = monthlyInvocations * perCall * (gotcha?.low ?? 1);
821
+ costHigh = monthlyInvocations * perCall * (gotcha?.high ?? 1);
822
+ }
823
+ if (costLow === 0 && costHigh === 0) continue;
824
+ impacts.push({
825
+ serviceId,
826
+ serviceName: service.name,
827
+ filePath,
828
+ callCount: totalCalls,
829
+ multipliers: multipliers.map((m) => m.label),
830
+ multiplierFactor,
831
+ monthlyInvocations,
832
+ costLow,
833
+ costHigh,
834
+ rangeExplanation: gotcha?.explanation
835
+ });
836
+ }
837
+ return impacts;
838
+ }
839
+ function formatCostImpactCard(impacts, currentBudgets) {
840
+ const fileName = impacts[0]?.filePath.split("/").pop() ?? "unknown";
841
+ const lines = [];
842
+ lines.push(`[BURNWATCH] \u26A0\uFE0F Cost impact estimate for ${fileName}`);
843
+ for (const impact of impacts) {
844
+ const lowStr = impact.costLow < 1 ? `$${impact.costLow.toFixed(2)}` : `$${impact.costLow.toFixed(0)}`;
845
+ const highStr = impact.costHigh < 1 ? `$${impact.costHigh.toFixed(2)}` : `$${impact.costHigh.toFixed(0)}`;
846
+ const rangeStr = impact.costLow === impact.costHigh ? lowStr : `${lowStr}-${highStr}`;
847
+ lines.push(
848
+ ` ${impact.serviceName}: ~${impact.monthlyInvocations.toLocaleString()} calls/mo \u2192 ${rangeStr}/mo` + (impact.rangeExplanation ? ` (${impact.rangeExplanation})` : "")
849
+ );
850
+ const current = currentBudgets[impact.serviceId];
851
+ if (current) {
852
+ const budgetStr = current.budget ? `$${current.spend.toFixed(0)}/$${current.budget} budget` : `$${current.spend.toFixed(0)} (no budget set)`;
853
+ const pctStr = current.budget && current.budget > 0 ? ` (${(current.spend / current.budget * 100).toFixed(0)}%)` : "";
854
+ lines.push(` Current: ${budgetStr}${pctStr}`);
855
+ }
856
+ const registry = loadRegistry();
857
+ const service = registry.get(impact.serviceId);
858
+ if (service?.alternatives && service.alternatives.length > 0 && impact.costHigh > 10) {
859
+ const freeAlts = service.alternatives.filter(
860
+ (a) => a.includes("free") || a.includes("cheerio") || a.includes("playwright") || a.includes("self-hosted")
861
+ );
862
+ if (freeAlts.length > 0) {
863
+ lines.push(` Consider: ${freeAlts.join(", ")} for lower-cost alternative`);
864
+ }
865
+ }
866
+ }
867
+ return lines.join("\n");
868
+ }
546
869
  export {
547
870
  CONFIDENCE_BADGES,
871
+ analyzeCostImpact,
548
872
  buildBrief,
549
873
  buildSnapshot,
550
874
  detectInFileChange,
@@ -552,6 +876,7 @@ export {
552
876
  detectServices,
553
877
  ensureProjectDirs,
554
878
  formatBrief,
879
+ formatCostImpactCard,
555
880
  formatSpendCard,
556
881
  getAllServices,
557
882
  getService,