@mcptoolshop/registry-stats 2.3.0 → 3.1.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
@@ -34,7 +34,7 @@ async function fetchWithRetry(url, registry, init) {
34
34
  let lastError;
35
35
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
36
36
  await acquireSlot(registry);
37
- const res = await fetch(url, init);
37
+ const res = await fetch(url, { signal: AbortSignal.timeout(3e4), ...init });
38
38
  if (res.status === 404) return null;
39
39
  if (res.ok) return res.json();
40
40
  const retryAfter = res.headers.get("retry-after");
@@ -56,7 +56,7 @@ async function fetchWithRetry(url, registry, init) {
56
56
  async function fetchDirect(url, registry, init) {
57
57
  let lastError;
58
58
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
59
- const res = await fetch(url, init);
59
+ const res = await fetch(url, { signal: AbortSignal.timeout(3e4), ...init });
60
60
  if (res.status === 404) return null;
61
61
  if (res.ok) return res.json();
62
62
  const retryAfter = res.headers.get("retry-after");
@@ -278,7 +278,8 @@ var docker = {
278
278
  if (options?.dockerToken) {
279
279
  headers["Authorization"] = `Bearer ${options.dockerToken}`;
280
280
  }
281
- const json2 = await fetchWithRetry(`${API4}/${pkg}`, "docker", { headers });
281
+ const safePkg = pkg.split("/").map((s) => encodeURIComponent(s)).join("/");
282
+ const json2 = await fetchWithRetry(`${API4}/${safePkg}`, "docker", { headers });
282
283
  if (!json2 || !json2.name || !json2.namespace) return null;
283
284
  return {
284
285
  registry: "docker",
@@ -565,6 +566,299 @@ Endpoints:`);
565
566
  return server;
566
567
  }
567
568
 
569
+ // src/inference.ts
570
+ function mean(arr) {
571
+ if (arr.length === 0) return 0;
572
+ return arr.reduce((a, b) => a + b, 0) / arr.length;
573
+ }
574
+ function stddev(arr) {
575
+ if (arr.length < 2) return 0;
576
+ const m = mean(arr);
577
+ const variance = arr.reduce((s, v) => s + (v - m) ** 2, 0) / arr.length;
578
+ return Math.sqrt(variance);
579
+ }
580
+ function linearRegression(ys) {
581
+ const n = ys.length;
582
+ if (n < 2) return { slope: 0, intercept: ys[0] ?? 0, r2: 0 };
583
+ let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0;
584
+ for (let i = 0; i < n; i++) {
585
+ sumX += i;
586
+ sumY += ys[i];
587
+ sumXY += i * ys[i];
588
+ sumX2 += i * i;
589
+ }
590
+ const denom = n * sumX2 - sumX * sumX;
591
+ if (denom === 0) return { slope: 0, intercept: sumY / n, r2: 0 };
592
+ const slope = (n * sumXY - sumX * sumY) / denom;
593
+ const intercept = (sumY - slope * sumX) / n;
594
+ const meanY = sumY / n;
595
+ let ssTot = 0, ssRes = 0;
596
+ for (let i = 0; i < n; i++) {
597
+ ssTot += (ys[i] - meanY) ** 2;
598
+ ssRes += (ys[i] - (intercept + slope * i)) ** 2;
599
+ }
600
+ const r2 = ssTot > 0 ? 1 - ssRes / ssTot : 0;
601
+ return { slope, intercept, r2 };
602
+ }
603
+ var DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
604
+ function forecast(series, days = 7) {
605
+ if (series.length < 7) return [];
606
+ const window = series.slice(-Math.min(14, series.length));
607
+ const n = window.length;
608
+ const weights = window.map((_, i) => Math.exp(0.1 * (i - n + 1)));
609
+ const totalW = weights.reduce((a, b) => a + b, 0);
610
+ let wSumX = 0, wSumY = 0, wSumXY = 0, wSumX2 = 0;
611
+ for (let i = 0; i < n; i++) {
612
+ const w = weights[i];
613
+ wSumX += w * i;
614
+ wSumY += w * window[i];
615
+ wSumXY += w * i * window[i];
616
+ wSumX2 += w * i * i;
617
+ }
618
+ const denom = totalW * wSumX2 - wSumX * wSumX;
619
+ let slope, intercept;
620
+ if (Math.abs(denom) < 1e-10) {
621
+ slope = 0;
622
+ intercept = wSumY / totalW;
623
+ } else {
624
+ slope = (totalW * wSumXY - wSumX * wSumY) / denom;
625
+ intercept = (wSumY - slope * wSumX) / totalW;
626
+ }
627
+ let ssRes = 0;
628
+ for (let i = 0; i < n; i++) {
629
+ ssRes += weights[i] * (window[i] - (intercept + slope * i)) ** 2;
630
+ }
631
+ const rse = Math.sqrt(ssRes / Math.max(1, totalW - 2));
632
+ const z80 = 1.28;
633
+ const results = [];
634
+ for (let d = 1; d <= days; d++) {
635
+ const x = n - 1 + d;
636
+ const predicted = Math.max(0, Math.round(intercept + slope * x));
637
+ const margin = Math.round(z80 * rse * Math.sqrt(1 + 1 / n + (x - n / 2) ** 2 / (n * n / 12)));
638
+ results.push({
639
+ day: d,
640
+ predicted,
641
+ lower: Math.max(0, predicted - margin),
642
+ upper: predicted + margin
643
+ });
644
+ }
645
+ return results;
646
+ }
647
+ function detectAnomalies(series, threshold = 2) {
648
+ if (series.length < 7) return [];
649
+ const anomalies = [];
650
+ const windowSize = Math.min(14, Math.floor(series.length * 0.7));
651
+ for (let i = windowSize; i < series.length; i++) {
652
+ const window = series.slice(i - windowSize, i);
653
+ const m = mean(window);
654
+ const s = stddev(window);
655
+ if (s < 1) continue;
656
+ const zscore = (series[i] - m) / s;
657
+ if (Math.abs(zscore) >= threshold) {
658
+ anomalies.push({
659
+ day: i,
660
+ value: series[i],
661
+ expected: Math.round(m),
662
+ zscore: Math.round(zscore * 10) / 10,
663
+ type: zscore > 0 ? "spike" : "drop"
664
+ });
665
+ }
666
+ }
667
+ return anomalies;
668
+ }
669
+ function segmentTrends(series, minSegmentLength = 5) {
670
+ if (series.length < minSegmentLength) return [];
671
+ const segments = [];
672
+ let segStart = 0;
673
+ while (segStart < series.length - minSegmentLength + 1) {
674
+ let bestEnd = segStart + minSegmentLength - 1;
675
+ const initialSlope = linearRegression(series.slice(segStart, segStart + minSegmentLength)).slope;
676
+ const initialDir = initialSlope > 0.5 ? "up" : initialSlope < -0.5 ? "down" : "flat";
677
+ for (let end = segStart + minSegmentLength; end < series.length; end++) {
678
+ const seg2 = series.slice(segStart, end + 1);
679
+ const { slope: slope2 } = linearRegression(seg2);
680
+ const dir = slope2 > 0.5 ? "up" : slope2 < -0.5 ? "down" : "flat";
681
+ if (dir !== initialDir) break;
682
+ bestEnd = end;
683
+ }
684
+ const seg = series.slice(segStart, bestEnd + 1);
685
+ const { slope } = linearRegression(seg);
686
+ const direction = slope > 0.5 ? "up" : slope < -0.5 ? "down" : "flat";
687
+ segments.push({
688
+ start: segStart,
689
+ end: bestEnd,
690
+ direction,
691
+ slope: Math.round(slope * 10) / 10,
692
+ magnitude: Math.round(seg[seg.length - 1] - seg[0])
693
+ });
694
+ segStart = bestEnd + 1;
695
+ }
696
+ return segments;
697
+ }
698
+ function detectSeasonality(series, startDaysAgo) {
699
+ if (series.length < 14) return null;
700
+ const buckets = [[], [], [], [], [], [], []];
701
+ const today = /* @__PURE__ */ new Date();
702
+ for (let i = 0; i < series.length; i++) {
703
+ const date = new Date(today);
704
+ date.setDate(date.getDate() - (startDaysAgo - i));
705
+ const dow = date.getDay();
706
+ buckets[dow].push(series[i]);
707
+ }
708
+ const dayAvgs = buckets.map((b) => Math.round(mean(b)));
709
+ const overallMean = mean(dayAvgs);
710
+ const maxAvg = Math.max(...dayAvgs);
711
+ const minAvg = Math.min(...dayAvgs);
712
+ if (overallMean < 1 || (maxAvg - minAvg) / overallMean < 0.15) return null;
713
+ const peakIdx = dayAvgs.indexOf(maxAvg);
714
+ return {
715
+ dayOfWeek: dayAvgs,
716
+ peakDay: DAY_NAMES[peakIdx]
717
+ };
718
+ }
719
+ function computeMomentum(series) {
720
+ if (series.length < 14) return 0;
721
+ const last7 = series.slice(-7);
722
+ const prev7 = series.slice(-14, -7);
723
+ const last7Sum = last7.reduce((a, b) => a + b, 0);
724
+ const prev7Sum = prev7.reduce((a, b) => a + b, 0);
725
+ const dampK = 10;
726
+ const dirScore = prev7Sum > dampK ? Math.max(-40, Math.min(40, (last7Sum - prev7Sum) / Math.sqrt(prev7Sum + dampK) * 4)) : last7Sum > 0 ? 20 : 0;
727
+ const { slope: recentSlope } = linearRegression(last7);
728
+ const { slope: prevSlope } = linearRegression(prev7);
729
+ const accelScore = Math.max(-20, Math.min(20, (recentSlope - prevSlope) * 2));
730
+ const cv = last7Sum > 0 ? stddev(last7) / mean(last7) : 1;
731
+ const consistencyScore = Math.max(0, 20 - cv * 20);
732
+ const volumeScore = last7Sum > 0 ? Math.min(20, Math.log10(last7Sum + 1) * 5) : 0;
733
+ return Math.round(Math.max(-100, Math.min(100, dirScore + accelScore + consistencyScore + volumeScore)));
734
+ }
735
+ function generateRecommendations(packages, opts = {}) {
736
+ const recs = [];
737
+ const declining = packages.filter((p) => p.momentum < -30);
738
+ if (declining.length > 0) {
739
+ const names = declining.slice(0, 3).map((p) => p.name).join(", ");
740
+ recs.push({
741
+ type: "attention",
742
+ priority: declining.some((p) => p.momentum < -60) ? "high" : "medium",
743
+ title: `${declining.length} package${declining.length > 1 ? "s" : ""} losing momentum`,
744
+ detail: `${names}${declining.length > 3 ? ` and ${declining.length - 3} more` : ""} show sustained decline. Consider: release updates, fix open issues, or update documentation.`,
745
+ metric: `Worst momentum: ${Math.min(...declining.map((p) => p.momentum))}`
746
+ });
747
+ }
748
+ if (opts.gini !== void 0 && opts.gini > 0.7) {
749
+ recs.push({
750
+ type: "risk",
751
+ priority: opts.gini > 0.85 ? "high" : "medium",
752
+ title: "High portfolio concentration",
753
+ detail: `Gini coefficient ${opts.gini.toFixed(2)} indicates downloads are heavily concentrated in a few packages. Diversify promotion efforts across the portfolio.`,
754
+ metric: `Gini: ${opts.gini.toFixed(2)}`
755
+ });
756
+ }
757
+ if (opts.npmPct !== void 0 && opts.npmPct > 75) {
758
+ recs.push({
759
+ type: "risk",
760
+ priority: "medium",
761
+ title: "Heavy npm dependency",
762
+ detail: `${opts.npmPct}% of downloads come from npm. Consider cross-publishing to PyPI and NuGet to reduce single-registry risk.`,
763
+ metric: `npm share: ${opts.npmPct}%`
764
+ });
765
+ }
766
+ const growing = packages.filter((p) => p.momentum > 40 && p.anomalies.some((a) => a.type === "spike"));
767
+ if (growing.length > 0) {
768
+ const names = growing.slice(0, 3).map((p) => p.name).join(", ");
769
+ recs.push({
770
+ type: "opportunity",
771
+ priority: "medium",
772
+ title: `${growing.length} package${growing.length > 1 ? "s" : ""} gaining traction`,
773
+ detail: `${names} show organic growth with download spikes. Capitalize with blog posts, social media, or conference talks.`,
774
+ metric: `Best momentum: ${Math.max(...growing.map((p) => p.momentum))}`
775
+ });
776
+ }
777
+ const forecastGrowing = packages.filter((p) => {
778
+ if (p.forecast7.length < 7) return false;
779
+ const lastActual = p.forecast7[0]?.predicted ?? 0;
780
+ const lastForecast = p.forecast7[6]?.predicted ?? 0;
781
+ return lastForecast > lastActual * 1.2;
782
+ });
783
+ if (forecastGrowing.length > 0) {
784
+ recs.push({
785
+ type: "growth",
786
+ priority: "low",
787
+ title: `${forecastGrowing.length} package${forecastGrowing.length > 1 ? "s" : ""} predicted to grow`,
788
+ detail: `Statistical models predict >20% growth in the next 7 days for ${forecastGrowing.slice(0, 3).map((p) => p.name).join(", ")}.`
789
+ });
790
+ }
791
+ const spiked = packages.filter((p) => p.anomalies.filter((a) => a.type === "spike").length >= 2);
792
+ if (spiked.length > 0) {
793
+ recs.push({
794
+ type: "attention",
795
+ priority: "low",
796
+ title: `${spiked.length} package${spiked.length > 1 ? "s" : ""} with repeated spikes`,
797
+ detail: `Multiple download spikes detected for ${spiked.slice(0, 3).map((p) => p.name).join(", ")}. Could indicate bot activity, viral posts, or dependency adoption.`
798
+ });
799
+ }
800
+ const priorityOrder = { high: 0, medium: 1, low: 2 };
801
+ recs.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]);
802
+ return recs;
803
+ }
804
+ function inferPortfolio(leaderboard, opts = {}) {
805
+ const packages = [];
806
+ for (const row of leaderboard) {
807
+ const series = row.range30;
808
+ if (!series || series.length < 7) {
809
+ packages.push({
810
+ name: row.name,
811
+ registry: row.registry,
812
+ forecast7: [],
813
+ anomalies: [],
814
+ trendSegments: [],
815
+ seasonality: null,
816
+ momentum: 0
817
+ });
818
+ continue;
819
+ }
820
+ packages.push({
821
+ name: row.name,
822
+ registry: row.registry,
823
+ forecast7: forecast(series, 7),
824
+ anomalies: detectAnomalies(series),
825
+ trendSegments: segmentTrends(series),
826
+ seasonality: detectSeasonality(series, 30),
827
+ momentum: computeMomentum(series)
828
+ });
829
+ }
830
+ const forecastTotal7 = new Array(7).fill(0);
831
+ for (const pkg of packages) {
832
+ for (const pt of pkg.forecast7) {
833
+ forecastTotal7[pt.day - 1] += pt.predicted;
834
+ }
835
+ }
836
+ const totalWeek = leaderboard.reduce((s, r) => s + (r.week ?? 0), 0);
837
+ let weightedMomentum = 0;
838
+ for (const pkg of packages) {
839
+ const row = leaderboard.find((r) => r.name === pkg.name);
840
+ const weight = totalWeek > 0 ? (row?.week ?? 0) / totalWeek : 1 / packages.length;
841
+ weightedMomentum += pkg.momentum * weight;
842
+ }
843
+ const decliningPct = packages.filter((p) => p.momentum < -20).length / Math.max(1, packages.length);
844
+ const totalAnomalies = packages.reduce((s, p) => s + p.anomalies.length, 0);
845
+ const anomalyDensity = totalAnomalies / Math.max(1, packages.length);
846
+ const giniRisk = (opts.gini ?? 0) * 30;
847
+ const declineRisk = decliningPct * 40;
848
+ const anomalyRisk = Math.min(30, anomalyDensity * 10);
849
+ const riskScore = Math.round(Math.max(0, Math.min(100, giniRisk + declineRisk + anomalyRisk)));
850
+ const diversityTrend = "stable";
851
+ const recommendations = generateRecommendations(packages, opts);
852
+ return {
853
+ packages,
854
+ recommendations,
855
+ forecastTotal7,
856
+ riskScore,
857
+ diversityTrend,
858
+ portfolioMomentum: Math.round(weightedMomentum)
859
+ };
860
+ }
861
+
568
862
  // src/index.ts
569
863
  function createCache() {
570
864
  const store = /* @__PURE__ */ new Map();
@@ -761,11 +1055,18 @@ stats.mine = async function mine(maintainer, options) {
761
1055
  export {
762
1056
  RegistryError,
763
1057
  calc,
1058
+ computeMomentum,
764
1059
  createCache,
765
1060
  createHandler,
766
1061
  defaultConfig,
1062
+ detectAnomalies,
1063
+ detectSeasonality,
1064
+ forecast,
1065
+ generateRecommendations,
1066
+ inferPortfolio,
767
1067
  loadConfig,
768
1068
  registerProvider,
1069
+ segmentTrends,
769
1070
  serve,
770
1071
  starterConfig,
771
1072
  stats
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mcptoolshop/registry-stats",
3
- "version": "2.3.0",
3
+ "version": "3.1.0",
4
4
  "description": "Multi-registry download stats — engine, AI-powered dashboard, and desktop app for npm, PyPI, NuGet, VS Code Marketplace, and Docker Hub",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",