@mcptoolshop/registry-stats 3.0.0 → 3.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/README.md +67 -10
- package/dist/cli.js +4 -3
- package/dist/index.cjs +499 -3
- package/dist/index.d.cts +178 -1
- package/dist/index.d.ts +178 -1
- package/dist/index.js +489 -3
- package/package.json +1 -1
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
|
|
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,481 @@ 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
|
+
var MILESTONE_THRESHOLDS = [100, 500, 1e3, 5e3, 1e4, 5e4, 1e5, 5e5, 1e6];
|
|
736
|
+
function computeYearlyProgress(name, registry, monthlyHistory) {
|
|
737
|
+
const now = /* @__PURE__ */ new Date();
|
|
738
|
+
const currentYear = now.getFullYear();
|
|
739
|
+
const currentMonth = now.getMonth();
|
|
740
|
+
const prevYear = currentYear - 1;
|
|
741
|
+
const currentMonths = [];
|
|
742
|
+
const prevMonths = [];
|
|
743
|
+
for (const [monthKey, agg] of Object.entries(monthlyHistory)) {
|
|
744
|
+
const [yearStr, monthStr] = monthKey.split("-");
|
|
745
|
+
const year = parseInt(yearStr, 10);
|
|
746
|
+
const dl = agg.month || agg.week * 4;
|
|
747
|
+
if (year === currentYear) {
|
|
748
|
+
currentMonths.push({ month: monthKey, downloads: dl });
|
|
749
|
+
} else if (year === prevYear) {
|
|
750
|
+
prevMonths.push({ month: monthKey, downloads: dl });
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
currentMonths.sort((a, b) => a.month.localeCompare(b.month));
|
|
754
|
+
prevMonths.sort((a, b) => a.month.localeCompare(b.month));
|
|
755
|
+
const currentYearTotal = currentMonths.reduce((s, m) => s + m.downloads, 0);
|
|
756
|
+
const previousYearTotal = prevMonths.length > 0 ? prevMonths.reduce((s, m) => s + m.downloads, 0) : null;
|
|
757
|
+
const yoyGrowthPct = previousYearTotal !== null && previousYearTotal > 0 ? (currentYearTotal - previousYearTotal) / previousYearTotal * 100 : null;
|
|
758
|
+
const monthsElapsed = currentMonth + 1;
|
|
759
|
+
const monthlyRate = monthsElapsed > 0 ? currentYearTotal / monthsElapsed : 0;
|
|
760
|
+
const projectedYearEnd = Math.round(monthlyRate * 12);
|
|
761
|
+
const bestMonth = currentMonths.length > 0 ? currentMonths.reduce((best, m) => m.downloads > best.downloads ? m : best) : null;
|
|
762
|
+
const milestones = MILESTONE_THRESHOLDS.filter((t) => t <= projectedYearEnd * 2).map((threshold) => {
|
|
763
|
+
const crossed = currentYearTotal >= threshold;
|
|
764
|
+
const crossedAt = crossed ? currentMonths.find((_, idx) => {
|
|
765
|
+
const running = currentMonths.slice(0, idx + 1).reduce((s, m) => s + m.downloads, 0);
|
|
766
|
+
return running >= threshold;
|
|
767
|
+
})?.month : void 0;
|
|
768
|
+
return { threshold, crossed, ...crossedAt ? { crossedAt } : {} };
|
|
769
|
+
});
|
|
770
|
+
return {
|
|
771
|
+
name,
|
|
772
|
+
registry,
|
|
773
|
+
currentYearTotal,
|
|
774
|
+
previousYearTotal,
|
|
775
|
+
yoyGrowthPct: yoyGrowthPct !== null ? Math.round(yoyGrowthPct * 10) / 10 : null,
|
|
776
|
+
projectedYearEnd,
|
|
777
|
+
monthlyTotals: currentMonths,
|
|
778
|
+
bestMonth,
|
|
779
|
+
milestones
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
function computeHealthScore(name, registry, series, momentum) {
|
|
783
|
+
const empty = {
|
|
784
|
+
name,
|
|
785
|
+
registry,
|
|
786
|
+
score: 0,
|
|
787
|
+
grade: "F",
|
|
788
|
+
components: { activity: 0, consistency: 0, growth: 0, stability: 0 }
|
|
789
|
+
};
|
|
790
|
+
if (!series || series.length < 7) return empty;
|
|
791
|
+
const last7Sum = series.slice(-7).reduce((a, b) => a + b, 0);
|
|
792
|
+
const activity = Math.min(25, Math.round(Math.log10(last7Sum + 1) * 7));
|
|
793
|
+
const last14 = series.slice(-14);
|
|
794
|
+
const m = mean(last14);
|
|
795
|
+
const cv = m > 0 ? stddev(last14) / m : 1;
|
|
796
|
+
const consistency = Math.min(25, Math.round(Math.max(0, 25 - cv * 25)));
|
|
797
|
+
const growth = Math.round(Math.max(0, Math.min(25, (momentum + 100) / 8)));
|
|
798
|
+
const anomalies = detectAnomalies(series, 2.5);
|
|
799
|
+
const anomalyPenalty = Math.min(25, anomalies.length * 8);
|
|
800
|
+
const stability = Math.max(0, 25 - anomalyPenalty);
|
|
801
|
+
const score = activity + consistency + growth + stability;
|
|
802
|
+
const grade = score >= 80 ? "A" : score >= 60 ? "B" : score >= 40 ? "C" : score >= 20 ? "D" : "F";
|
|
803
|
+
return {
|
|
804
|
+
name,
|
|
805
|
+
registry,
|
|
806
|
+
score,
|
|
807
|
+
grade,
|
|
808
|
+
components: { activity, consistency, growth, stability }
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
function generateActionableAdvice(packages, healthScores, opts = {}) {
|
|
812
|
+
const advice = [];
|
|
813
|
+
const failing = healthScores.filter((h) => h.grade === "F");
|
|
814
|
+
if (failing.length > 0) {
|
|
815
|
+
advice.push({
|
|
816
|
+
type: "attention",
|
|
817
|
+
severity: "critical",
|
|
818
|
+
urgency: "immediate",
|
|
819
|
+
title: `${failing.length} package${failing.length > 1 ? "s" : ""} in critical health`,
|
|
820
|
+
detail: `${failing.map((h) => h.name).slice(0, 5).join(", ")} scored below 20/100.`,
|
|
821
|
+
action: "Review these packages for broken installs, outdated dependencies, or missing documentation. Consider archiving if no longer maintained.",
|
|
822
|
+
packages: failing.map((h) => h.name)
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
const steepDecline = packages.filter((p) => p.momentum < -50);
|
|
826
|
+
if (steepDecline.length > 0) {
|
|
827
|
+
advice.push({
|
|
828
|
+
type: "attention",
|
|
829
|
+
severity: "warning",
|
|
830
|
+
urgency: "this-week",
|
|
831
|
+
title: `${steepDecline.length} package${steepDecline.length > 1 ? "s" : ""} in rapid decline`,
|
|
832
|
+
detail: `${steepDecline.map((p) => p.name).slice(0, 3).join(", ")} lost significant download momentum.`,
|
|
833
|
+
action: "Check for competing packages, broken releases, or ecosystem changes. Push a patch release or announcement.",
|
|
834
|
+
packages: steepDecline.map((p) => p.name)
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
if (opts.gini !== void 0 && opts.gini > 0.7) {
|
|
838
|
+
advice.push({
|
|
839
|
+
type: "risk",
|
|
840
|
+
severity: opts.gini > 0.85 ? "warning" : "info",
|
|
841
|
+
urgency: "this-month",
|
|
842
|
+
title: "Portfolio concentration risk",
|
|
843
|
+
detail: `Gini coefficient ${opts.gini.toFixed(2)} \u2014 downloads are concentrated in a few packages.`,
|
|
844
|
+
action: "Promote underperforming packages in README badges, blog posts, and release notes of popular packages.",
|
|
845
|
+
metric: `Gini: ${opts.gini.toFixed(2)}`
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
if (opts.npmPct !== void 0 && opts.npmPct > 75) {
|
|
849
|
+
advice.push({
|
|
850
|
+
type: "risk",
|
|
851
|
+
severity: "info",
|
|
852
|
+
urgency: "this-month",
|
|
853
|
+
title: `${opts.npmPct}% of traffic from npm`,
|
|
854
|
+
detail: "Single-registry dependency increases blast radius if npm has outages or policy changes.",
|
|
855
|
+
action: "Cross-publish key packages to PyPI (via wrapper) or NuGet. Add install instructions for all registries in READMEs.",
|
|
856
|
+
metric: `npm share: ${opts.npmPct}%`
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
const surging = packages.filter((p) => p.momentum > 50);
|
|
860
|
+
if (surging.length > 0) {
|
|
861
|
+
advice.push({
|
|
862
|
+
type: "opportunity",
|
|
863
|
+
severity: "success",
|
|
864
|
+
urgency: "this-week",
|
|
865
|
+
title: `${surging.length} package${surging.length > 1 ? "s" : ""} surging`,
|
|
866
|
+
detail: `${surging.map((p) => p.name).slice(0, 3).join(", ")} have strong positive momentum.`,
|
|
867
|
+
action: 'Capitalize now: write a blog post, tweet, or submit to newsletters. Post a "Thank you" issue or discussion.',
|
|
868
|
+
packages: surging.map((p) => p.name)
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
const highVolume = packages.filter((p) => {
|
|
872
|
+
const pkg = packages.find((pp) => pp.name === p.name);
|
|
873
|
+
return pkg && pkg.forecast7.length > 0 && pkg.forecast7[6]?.predicted > 100;
|
|
874
|
+
});
|
|
875
|
+
for (const pkg of highVolume.slice(0, 3)) {
|
|
876
|
+
const weekSum = pkg.forecast7.reduce((s, f) => s + f.predicted, 0);
|
|
877
|
+
for (const threshold of [1e3, 5e3, 1e4]) {
|
|
878
|
+
if (weekSum >= threshold * 0.9 && weekSum <= threshold * 1.1) {
|
|
879
|
+
advice.push({
|
|
880
|
+
type: "milestone",
|
|
881
|
+
severity: "success",
|
|
882
|
+
urgency: "informational",
|
|
883
|
+
title: `${pkg.name} approaching ${threshold.toLocaleString()} weekly downloads`,
|
|
884
|
+
detail: `Forecasted at ~${weekSum.toLocaleString()} downloads next week.`,
|
|
885
|
+
action: "Prepare a milestone announcement and update the README with a downloads badge.",
|
|
886
|
+
packages: [pkg.name]
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
const dGrade = healthScores.filter((h) => h.grade === "D");
|
|
892
|
+
const easyWins = dGrade.filter((h) => {
|
|
893
|
+
return h.components.activity >= 10 && h.components.growth < 10;
|
|
894
|
+
});
|
|
895
|
+
if (easyWins.length > 0) {
|
|
896
|
+
advice.push({
|
|
897
|
+
type: "opportunity",
|
|
898
|
+
severity: "info",
|
|
899
|
+
urgency: "this-month",
|
|
900
|
+
title: `${easyWins.length} package${easyWins.length > 1 ? "s" : ""} with untapped potential`,
|
|
901
|
+
detail: `${easyWins.map((h) => h.name).slice(0, 3).join(", ")} have active users but stalled growth.`,
|
|
902
|
+
action: "Add new features, improve docs, or create tutorials. Small efforts can shift these from D to C+ quickly.",
|
|
903
|
+
packages: easyWins.map((h) => h.name)
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
const severityOrder = { critical: 0, warning: 1, success: 2, info: 3 };
|
|
907
|
+
advice.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
|
|
908
|
+
return advice;
|
|
909
|
+
}
|
|
910
|
+
function generateRecommendations(packages, opts = {}) {
|
|
911
|
+
const recs = [];
|
|
912
|
+
const declining = packages.filter((p) => p.momentum < -30);
|
|
913
|
+
if (declining.length > 0) {
|
|
914
|
+
const names = declining.slice(0, 3).map((p) => p.name).join(", ");
|
|
915
|
+
recs.push({
|
|
916
|
+
type: "attention",
|
|
917
|
+
priority: declining.some((p) => p.momentum < -60) ? "high" : "medium",
|
|
918
|
+
title: `${declining.length} package${declining.length > 1 ? "s" : ""} losing momentum`,
|
|
919
|
+
detail: `${names}${declining.length > 3 ? ` and ${declining.length - 3} more` : ""} show sustained decline. Consider: release updates, fix open issues, or update documentation.`,
|
|
920
|
+
metric: `Worst momentum: ${Math.min(...declining.map((p) => p.momentum))}`
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
if (opts.gini !== void 0 && opts.gini > 0.7) {
|
|
924
|
+
recs.push({
|
|
925
|
+
type: "risk",
|
|
926
|
+
priority: opts.gini > 0.85 ? "high" : "medium",
|
|
927
|
+
title: "High portfolio concentration",
|
|
928
|
+
detail: `Gini coefficient ${opts.gini.toFixed(2)} indicates downloads are heavily concentrated in a few packages. Diversify promotion efforts across the portfolio.`,
|
|
929
|
+
metric: `Gini: ${opts.gini.toFixed(2)}`
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
if (opts.npmPct !== void 0 && opts.npmPct > 75) {
|
|
933
|
+
recs.push({
|
|
934
|
+
type: "risk",
|
|
935
|
+
priority: "medium",
|
|
936
|
+
title: "Heavy npm dependency",
|
|
937
|
+
detail: `${opts.npmPct}% of downloads come from npm. Consider cross-publishing to PyPI and NuGet to reduce single-registry risk.`,
|
|
938
|
+
metric: `npm share: ${opts.npmPct}%`
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
const growing = packages.filter((p) => p.momentum > 40 && p.anomalies.some((a) => a.type === "spike"));
|
|
942
|
+
if (growing.length > 0) {
|
|
943
|
+
const names = growing.slice(0, 3).map((p) => p.name).join(", ");
|
|
944
|
+
recs.push({
|
|
945
|
+
type: "opportunity",
|
|
946
|
+
priority: "medium",
|
|
947
|
+
title: `${growing.length} package${growing.length > 1 ? "s" : ""} gaining traction`,
|
|
948
|
+
detail: `${names} show organic growth with download spikes. Capitalize with blog posts, social media, or conference talks.`,
|
|
949
|
+
metric: `Best momentum: ${Math.max(...growing.map((p) => p.momentum))}`
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
const forecastGrowing = packages.filter((p) => {
|
|
953
|
+
if (p.forecast7.length < 7) return false;
|
|
954
|
+
const lastActual = p.forecast7[0]?.predicted ?? 0;
|
|
955
|
+
const lastForecast = p.forecast7[6]?.predicted ?? 0;
|
|
956
|
+
return lastForecast > lastActual * 1.2;
|
|
957
|
+
});
|
|
958
|
+
if (forecastGrowing.length > 0) {
|
|
959
|
+
recs.push({
|
|
960
|
+
type: "growth",
|
|
961
|
+
priority: "low",
|
|
962
|
+
title: `${forecastGrowing.length} package${forecastGrowing.length > 1 ? "s" : ""} predicted to grow`,
|
|
963
|
+
detail: `Statistical models predict >20% growth in the next 7 days for ${forecastGrowing.slice(0, 3).map((p) => p.name).join(", ")}.`
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
const spiked = packages.filter((p) => p.anomalies.filter((a) => a.type === "spike").length >= 2);
|
|
967
|
+
if (spiked.length > 0) {
|
|
968
|
+
recs.push({
|
|
969
|
+
type: "attention",
|
|
970
|
+
priority: "low",
|
|
971
|
+
title: `${spiked.length} package${spiked.length > 1 ? "s" : ""} with repeated spikes`,
|
|
972
|
+
detail: `Multiple download spikes detected for ${spiked.slice(0, 3).map((p) => p.name).join(", ")}. Could indicate bot activity, viral posts, or dependency adoption.`
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
const priorityOrder = { high: 0, medium: 1, low: 2 };
|
|
976
|
+
recs.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]);
|
|
977
|
+
return recs;
|
|
978
|
+
}
|
|
979
|
+
function inferPortfolio(leaderboard, opts = {}) {
|
|
980
|
+
const packages = [];
|
|
981
|
+
for (const row of leaderboard) {
|
|
982
|
+
const series = row.range30;
|
|
983
|
+
if (!series || series.length < 7) {
|
|
984
|
+
packages.push({
|
|
985
|
+
name: row.name,
|
|
986
|
+
registry: row.registry,
|
|
987
|
+
forecast7: [],
|
|
988
|
+
anomalies: [],
|
|
989
|
+
trendSegments: [],
|
|
990
|
+
seasonality: null,
|
|
991
|
+
momentum: 0
|
|
992
|
+
});
|
|
993
|
+
continue;
|
|
994
|
+
}
|
|
995
|
+
packages.push({
|
|
996
|
+
name: row.name,
|
|
997
|
+
registry: row.registry,
|
|
998
|
+
forecast7: forecast(series, 7),
|
|
999
|
+
anomalies: detectAnomalies(series),
|
|
1000
|
+
trendSegments: segmentTrends(series),
|
|
1001
|
+
seasonality: detectSeasonality(series, 30),
|
|
1002
|
+
momentum: computeMomentum(series)
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
const forecastTotal7 = new Array(7).fill(0);
|
|
1006
|
+
for (const pkg of packages) {
|
|
1007
|
+
for (const pt of pkg.forecast7) {
|
|
1008
|
+
forecastTotal7[pt.day - 1] += pt.predicted;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
const totalWeek = leaderboard.reduce((s, r) => s + (r.week ?? 0), 0);
|
|
1012
|
+
let weightedMomentum = 0;
|
|
1013
|
+
for (const pkg of packages) {
|
|
1014
|
+
const row = leaderboard.find((r) => r.name === pkg.name);
|
|
1015
|
+
const weight = totalWeek > 0 ? (row?.week ?? 0) / totalWeek : 1 / packages.length;
|
|
1016
|
+
weightedMomentum += pkg.momentum * weight;
|
|
1017
|
+
}
|
|
1018
|
+
const decliningPct = packages.filter((p) => p.momentum < -20).length / Math.max(1, packages.length);
|
|
1019
|
+
const totalAnomalies = packages.reduce((s, p) => s + p.anomalies.length, 0);
|
|
1020
|
+
const anomalyDensity = totalAnomalies / Math.max(1, packages.length);
|
|
1021
|
+
const giniRisk = (opts.gini ?? 0) * 30;
|
|
1022
|
+
const declineRisk = decliningPct * 40;
|
|
1023
|
+
const anomalyRisk = Math.min(30, anomalyDensity * 10);
|
|
1024
|
+
const riskScore = Math.round(Math.max(0, Math.min(100, giniRisk + declineRisk + anomalyRisk)));
|
|
1025
|
+
const diversityTrend = "stable";
|
|
1026
|
+
const recommendations = generateRecommendations(packages, opts);
|
|
1027
|
+
const healthScores = leaderboard.map((row) => {
|
|
1028
|
+
const pkg = packages.find((p) => p.name === row.name);
|
|
1029
|
+
return computeHealthScore(row.name, row.registry, row.range30 ?? null, pkg?.momentum ?? 0);
|
|
1030
|
+
});
|
|
1031
|
+
const actionableAdvice = generateActionableAdvice(packages, healthScores, opts);
|
|
1032
|
+
return {
|
|
1033
|
+
packages,
|
|
1034
|
+
recommendations,
|
|
1035
|
+
forecastTotal7,
|
|
1036
|
+
riskScore,
|
|
1037
|
+
diversityTrend,
|
|
1038
|
+
portfolioMomentum: Math.round(weightedMomentum),
|
|
1039
|
+
healthScores,
|
|
1040
|
+
actionableAdvice
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
|
|
568
1044
|
// src/index.ts
|
|
569
1045
|
function createCache() {
|
|
570
1046
|
const store = /* @__PURE__ */ new Map();
|
|
@@ -761,11 +1237,21 @@ stats.mine = async function mine(maintainer, options) {
|
|
|
761
1237
|
export {
|
|
762
1238
|
RegistryError,
|
|
763
1239
|
calc,
|
|
1240
|
+
computeHealthScore,
|
|
1241
|
+
computeMomentum,
|
|
1242
|
+
computeYearlyProgress,
|
|
764
1243
|
createCache,
|
|
765
1244
|
createHandler,
|
|
766
1245
|
defaultConfig,
|
|
1246
|
+
detectAnomalies,
|
|
1247
|
+
detectSeasonality,
|
|
1248
|
+
forecast,
|
|
1249
|
+
generateActionableAdvice,
|
|
1250
|
+
generateRecommendations,
|
|
1251
|
+
inferPortfolio,
|
|
767
1252
|
loadConfig,
|
|
768
1253
|
registerProvider,
|
|
1254
|
+
segmentTrends,
|
|
769
1255
|
serve,
|
|
770
1256
|
starterConfig,
|
|
771
1257
|
stats
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mcptoolshop/registry-stats",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.2.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",
|