@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/dist/index.cjs CHANGED
@@ -22,11 +22,21 @@ var index_exports = {};
22
22
  __export(index_exports, {
23
23
  RegistryError: () => RegistryError,
24
24
  calc: () => calc,
25
+ computeHealthScore: () => computeHealthScore,
26
+ computeMomentum: () => computeMomentum,
27
+ computeYearlyProgress: () => computeYearlyProgress,
25
28
  createCache: () => createCache,
26
29
  createHandler: () => createHandler,
27
30
  defaultConfig: () => defaultConfig,
31
+ detectAnomalies: () => detectAnomalies,
32
+ detectSeasonality: () => detectSeasonality,
33
+ forecast: () => forecast,
34
+ generateActionableAdvice: () => generateActionableAdvice,
35
+ generateRecommendations: () => generateRecommendations,
36
+ inferPortfolio: () => inferPortfolio,
28
37
  loadConfig: () => loadConfig,
29
38
  registerProvider: () => registerProvider,
39
+ segmentTrends: () => segmentTrends,
30
40
  serve: () => serve,
31
41
  starterConfig: () => starterConfig,
32
42
  stats: () => stats
@@ -69,7 +79,7 @@ async function fetchWithRetry(url, registry, init) {
69
79
  let lastError;
70
80
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
71
81
  await acquireSlot(registry);
72
- const res = await fetch(url, init);
82
+ const res = await fetch(url, { signal: AbortSignal.timeout(3e4), ...init });
73
83
  if (res.status === 404) return null;
74
84
  if (res.ok) return res.json();
75
85
  const retryAfter = res.headers.get("retry-after");
@@ -91,7 +101,7 @@ async function fetchWithRetry(url, registry, init) {
91
101
  async function fetchDirect(url, registry, init) {
92
102
  let lastError;
93
103
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
94
- const res = await fetch(url, init);
104
+ const res = await fetch(url, { signal: AbortSignal.timeout(3e4), ...init });
95
105
  if (res.status === 404) return null;
96
106
  if (res.ok) return res.json();
97
107
  const retryAfter = res.headers.get("retry-after");
@@ -313,7 +323,8 @@ var docker = {
313
323
  if (options?.dockerToken) {
314
324
  headers["Authorization"] = `Bearer ${options.dockerToken}`;
315
325
  }
316
- const json2 = await fetchWithRetry(`${API4}/${pkg}`, "docker", { headers });
326
+ const safePkg = pkg.split("/").map((s) => encodeURIComponent(s)).join("/");
327
+ const json2 = await fetchWithRetry(`${API4}/${safePkg}`, "docker", { headers });
317
328
  if (!json2 || !json2.name || !json2.namespace) return null;
318
329
  return {
319
330
  registry: "docker",
@@ -600,6 +611,481 @@ Endpoints:`);
600
611
  return server;
601
612
  }
602
613
 
614
+ // src/inference.ts
615
+ function mean(arr) {
616
+ if (arr.length === 0) return 0;
617
+ return arr.reduce((a, b) => a + b, 0) / arr.length;
618
+ }
619
+ function stddev(arr) {
620
+ if (arr.length < 2) return 0;
621
+ const m = mean(arr);
622
+ const variance = arr.reduce((s, v) => s + (v - m) ** 2, 0) / arr.length;
623
+ return Math.sqrt(variance);
624
+ }
625
+ function linearRegression(ys) {
626
+ const n = ys.length;
627
+ if (n < 2) return { slope: 0, intercept: ys[0] ?? 0, r2: 0 };
628
+ let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0;
629
+ for (let i = 0; i < n; i++) {
630
+ sumX += i;
631
+ sumY += ys[i];
632
+ sumXY += i * ys[i];
633
+ sumX2 += i * i;
634
+ }
635
+ const denom = n * sumX2 - sumX * sumX;
636
+ if (denom === 0) return { slope: 0, intercept: sumY / n, r2: 0 };
637
+ const slope = (n * sumXY - sumX * sumY) / denom;
638
+ const intercept = (sumY - slope * sumX) / n;
639
+ const meanY = sumY / n;
640
+ let ssTot = 0, ssRes = 0;
641
+ for (let i = 0; i < n; i++) {
642
+ ssTot += (ys[i] - meanY) ** 2;
643
+ ssRes += (ys[i] - (intercept + slope * i)) ** 2;
644
+ }
645
+ const r2 = ssTot > 0 ? 1 - ssRes / ssTot : 0;
646
+ return { slope, intercept, r2 };
647
+ }
648
+ var DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
649
+ function forecast(series, days = 7) {
650
+ if (series.length < 7) return [];
651
+ const window = series.slice(-Math.min(14, series.length));
652
+ const n = window.length;
653
+ const weights = window.map((_, i) => Math.exp(0.1 * (i - n + 1)));
654
+ const totalW = weights.reduce((a, b) => a + b, 0);
655
+ let wSumX = 0, wSumY = 0, wSumXY = 0, wSumX2 = 0;
656
+ for (let i = 0; i < n; i++) {
657
+ const w = weights[i];
658
+ wSumX += w * i;
659
+ wSumY += w * window[i];
660
+ wSumXY += w * i * window[i];
661
+ wSumX2 += w * i * i;
662
+ }
663
+ const denom = totalW * wSumX2 - wSumX * wSumX;
664
+ let slope, intercept;
665
+ if (Math.abs(denom) < 1e-10) {
666
+ slope = 0;
667
+ intercept = wSumY / totalW;
668
+ } else {
669
+ slope = (totalW * wSumXY - wSumX * wSumY) / denom;
670
+ intercept = (wSumY - slope * wSumX) / totalW;
671
+ }
672
+ let ssRes = 0;
673
+ for (let i = 0; i < n; i++) {
674
+ ssRes += weights[i] * (window[i] - (intercept + slope * i)) ** 2;
675
+ }
676
+ const rse = Math.sqrt(ssRes / Math.max(1, totalW - 2));
677
+ const z80 = 1.28;
678
+ const results = [];
679
+ for (let d = 1; d <= days; d++) {
680
+ const x = n - 1 + d;
681
+ const predicted = Math.max(0, Math.round(intercept + slope * x));
682
+ const margin = Math.round(z80 * rse * Math.sqrt(1 + 1 / n + (x - n / 2) ** 2 / (n * n / 12)));
683
+ results.push({
684
+ day: d,
685
+ predicted,
686
+ lower: Math.max(0, predicted - margin),
687
+ upper: predicted + margin
688
+ });
689
+ }
690
+ return results;
691
+ }
692
+ function detectAnomalies(series, threshold = 2) {
693
+ if (series.length < 7) return [];
694
+ const anomalies = [];
695
+ const windowSize = Math.min(14, Math.floor(series.length * 0.7));
696
+ for (let i = windowSize; i < series.length; i++) {
697
+ const window = series.slice(i - windowSize, i);
698
+ const m = mean(window);
699
+ const s = stddev(window);
700
+ if (s < 1) continue;
701
+ const zscore = (series[i] - m) / s;
702
+ if (Math.abs(zscore) >= threshold) {
703
+ anomalies.push({
704
+ day: i,
705
+ value: series[i],
706
+ expected: Math.round(m),
707
+ zscore: Math.round(zscore * 10) / 10,
708
+ type: zscore > 0 ? "spike" : "drop"
709
+ });
710
+ }
711
+ }
712
+ return anomalies;
713
+ }
714
+ function segmentTrends(series, minSegmentLength = 5) {
715
+ if (series.length < minSegmentLength) return [];
716
+ const segments = [];
717
+ let segStart = 0;
718
+ while (segStart < series.length - minSegmentLength + 1) {
719
+ let bestEnd = segStart + minSegmentLength - 1;
720
+ const initialSlope = linearRegression(series.slice(segStart, segStart + minSegmentLength)).slope;
721
+ const initialDir = initialSlope > 0.5 ? "up" : initialSlope < -0.5 ? "down" : "flat";
722
+ for (let end = segStart + minSegmentLength; end < series.length; end++) {
723
+ const seg2 = series.slice(segStart, end + 1);
724
+ const { slope: slope2 } = linearRegression(seg2);
725
+ const dir = slope2 > 0.5 ? "up" : slope2 < -0.5 ? "down" : "flat";
726
+ if (dir !== initialDir) break;
727
+ bestEnd = end;
728
+ }
729
+ const seg = series.slice(segStart, bestEnd + 1);
730
+ const { slope } = linearRegression(seg);
731
+ const direction = slope > 0.5 ? "up" : slope < -0.5 ? "down" : "flat";
732
+ segments.push({
733
+ start: segStart,
734
+ end: bestEnd,
735
+ direction,
736
+ slope: Math.round(slope * 10) / 10,
737
+ magnitude: Math.round(seg[seg.length - 1] - seg[0])
738
+ });
739
+ segStart = bestEnd + 1;
740
+ }
741
+ return segments;
742
+ }
743
+ function detectSeasonality(series, startDaysAgo) {
744
+ if (series.length < 14) return null;
745
+ const buckets = [[], [], [], [], [], [], []];
746
+ const today = /* @__PURE__ */ new Date();
747
+ for (let i = 0; i < series.length; i++) {
748
+ const date = new Date(today);
749
+ date.setDate(date.getDate() - (startDaysAgo - i));
750
+ const dow = date.getDay();
751
+ buckets[dow].push(series[i]);
752
+ }
753
+ const dayAvgs = buckets.map((b) => Math.round(mean(b)));
754
+ const overallMean = mean(dayAvgs);
755
+ const maxAvg = Math.max(...dayAvgs);
756
+ const minAvg = Math.min(...dayAvgs);
757
+ if (overallMean < 1 || (maxAvg - minAvg) / overallMean < 0.15) return null;
758
+ const peakIdx = dayAvgs.indexOf(maxAvg);
759
+ return {
760
+ dayOfWeek: dayAvgs,
761
+ peakDay: DAY_NAMES[peakIdx]
762
+ };
763
+ }
764
+ function computeMomentum(series) {
765
+ if (series.length < 14) return 0;
766
+ const last7 = series.slice(-7);
767
+ const prev7 = series.slice(-14, -7);
768
+ const last7Sum = last7.reduce((a, b) => a + b, 0);
769
+ const prev7Sum = prev7.reduce((a, b) => a + b, 0);
770
+ const dampK = 10;
771
+ const dirScore = prev7Sum > dampK ? Math.max(-40, Math.min(40, (last7Sum - prev7Sum) / Math.sqrt(prev7Sum + dampK) * 4)) : last7Sum > 0 ? 20 : 0;
772
+ const { slope: recentSlope } = linearRegression(last7);
773
+ const { slope: prevSlope } = linearRegression(prev7);
774
+ const accelScore = Math.max(-20, Math.min(20, (recentSlope - prevSlope) * 2));
775
+ const cv = last7Sum > 0 ? stddev(last7) / mean(last7) : 1;
776
+ const consistencyScore = Math.max(0, 20 - cv * 20);
777
+ const volumeScore = last7Sum > 0 ? Math.min(20, Math.log10(last7Sum + 1) * 5) : 0;
778
+ return Math.round(Math.max(-100, Math.min(100, dirScore + accelScore + consistencyScore + volumeScore)));
779
+ }
780
+ var MILESTONE_THRESHOLDS = [100, 500, 1e3, 5e3, 1e4, 5e4, 1e5, 5e5, 1e6];
781
+ function computeYearlyProgress(name, registry, monthlyHistory) {
782
+ const now = /* @__PURE__ */ new Date();
783
+ const currentYear = now.getFullYear();
784
+ const currentMonth = now.getMonth();
785
+ const prevYear = currentYear - 1;
786
+ const currentMonths = [];
787
+ const prevMonths = [];
788
+ for (const [monthKey, agg] of Object.entries(monthlyHistory)) {
789
+ const [yearStr, monthStr] = monthKey.split("-");
790
+ const year = parseInt(yearStr, 10);
791
+ const dl = agg.month || agg.week * 4;
792
+ if (year === currentYear) {
793
+ currentMonths.push({ month: monthKey, downloads: dl });
794
+ } else if (year === prevYear) {
795
+ prevMonths.push({ month: monthKey, downloads: dl });
796
+ }
797
+ }
798
+ currentMonths.sort((a, b) => a.month.localeCompare(b.month));
799
+ prevMonths.sort((a, b) => a.month.localeCompare(b.month));
800
+ const currentYearTotal = currentMonths.reduce((s, m) => s + m.downloads, 0);
801
+ const previousYearTotal = prevMonths.length > 0 ? prevMonths.reduce((s, m) => s + m.downloads, 0) : null;
802
+ const yoyGrowthPct = previousYearTotal !== null && previousYearTotal > 0 ? (currentYearTotal - previousYearTotal) / previousYearTotal * 100 : null;
803
+ const monthsElapsed = currentMonth + 1;
804
+ const monthlyRate = monthsElapsed > 0 ? currentYearTotal / monthsElapsed : 0;
805
+ const projectedYearEnd = Math.round(monthlyRate * 12);
806
+ const bestMonth = currentMonths.length > 0 ? currentMonths.reduce((best, m) => m.downloads > best.downloads ? m : best) : null;
807
+ const milestones = MILESTONE_THRESHOLDS.filter((t) => t <= projectedYearEnd * 2).map((threshold) => {
808
+ const crossed = currentYearTotal >= threshold;
809
+ const crossedAt = crossed ? currentMonths.find((_, idx) => {
810
+ const running = currentMonths.slice(0, idx + 1).reduce((s, m) => s + m.downloads, 0);
811
+ return running >= threshold;
812
+ })?.month : void 0;
813
+ return { threshold, crossed, ...crossedAt ? { crossedAt } : {} };
814
+ });
815
+ return {
816
+ name,
817
+ registry,
818
+ currentYearTotal,
819
+ previousYearTotal,
820
+ yoyGrowthPct: yoyGrowthPct !== null ? Math.round(yoyGrowthPct * 10) / 10 : null,
821
+ projectedYearEnd,
822
+ monthlyTotals: currentMonths,
823
+ bestMonth,
824
+ milestones
825
+ };
826
+ }
827
+ function computeHealthScore(name, registry, series, momentum) {
828
+ const empty = {
829
+ name,
830
+ registry,
831
+ score: 0,
832
+ grade: "F",
833
+ components: { activity: 0, consistency: 0, growth: 0, stability: 0 }
834
+ };
835
+ if (!series || series.length < 7) return empty;
836
+ const last7Sum = series.slice(-7).reduce((a, b) => a + b, 0);
837
+ const activity = Math.min(25, Math.round(Math.log10(last7Sum + 1) * 7));
838
+ const last14 = series.slice(-14);
839
+ const m = mean(last14);
840
+ const cv = m > 0 ? stddev(last14) / m : 1;
841
+ const consistency = Math.min(25, Math.round(Math.max(0, 25 - cv * 25)));
842
+ const growth = Math.round(Math.max(0, Math.min(25, (momentum + 100) / 8)));
843
+ const anomalies = detectAnomalies(series, 2.5);
844
+ const anomalyPenalty = Math.min(25, anomalies.length * 8);
845
+ const stability = Math.max(0, 25 - anomalyPenalty);
846
+ const score = activity + consistency + growth + stability;
847
+ const grade = score >= 80 ? "A" : score >= 60 ? "B" : score >= 40 ? "C" : score >= 20 ? "D" : "F";
848
+ return {
849
+ name,
850
+ registry,
851
+ score,
852
+ grade,
853
+ components: { activity, consistency, growth, stability }
854
+ };
855
+ }
856
+ function generateActionableAdvice(packages, healthScores, opts = {}) {
857
+ const advice = [];
858
+ const failing = healthScores.filter((h) => h.grade === "F");
859
+ if (failing.length > 0) {
860
+ advice.push({
861
+ type: "attention",
862
+ severity: "critical",
863
+ urgency: "immediate",
864
+ title: `${failing.length} package${failing.length > 1 ? "s" : ""} in critical health`,
865
+ detail: `${failing.map((h) => h.name).slice(0, 5).join(", ")} scored below 20/100.`,
866
+ action: "Review these packages for broken installs, outdated dependencies, or missing documentation. Consider archiving if no longer maintained.",
867
+ packages: failing.map((h) => h.name)
868
+ });
869
+ }
870
+ const steepDecline = packages.filter((p) => p.momentum < -50);
871
+ if (steepDecline.length > 0) {
872
+ advice.push({
873
+ type: "attention",
874
+ severity: "warning",
875
+ urgency: "this-week",
876
+ title: `${steepDecline.length} package${steepDecline.length > 1 ? "s" : ""} in rapid decline`,
877
+ detail: `${steepDecline.map((p) => p.name).slice(0, 3).join(", ")} lost significant download momentum.`,
878
+ action: "Check for competing packages, broken releases, or ecosystem changes. Push a patch release or announcement.",
879
+ packages: steepDecline.map((p) => p.name)
880
+ });
881
+ }
882
+ if (opts.gini !== void 0 && opts.gini > 0.7) {
883
+ advice.push({
884
+ type: "risk",
885
+ severity: opts.gini > 0.85 ? "warning" : "info",
886
+ urgency: "this-month",
887
+ title: "Portfolio concentration risk",
888
+ detail: `Gini coefficient ${opts.gini.toFixed(2)} \u2014 downloads are concentrated in a few packages.`,
889
+ action: "Promote underperforming packages in README badges, blog posts, and release notes of popular packages.",
890
+ metric: `Gini: ${opts.gini.toFixed(2)}`
891
+ });
892
+ }
893
+ if (opts.npmPct !== void 0 && opts.npmPct > 75) {
894
+ advice.push({
895
+ type: "risk",
896
+ severity: "info",
897
+ urgency: "this-month",
898
+ title: `${opts.npmPct}% of traffic from npm`,
899
+ detail: "Single-registry dependency increases blast radius if npm has outages or policy changes.",
900
+ action: "Cross-publish key packages to PyPI (via wrapper) or NuGet. Add install instructions for all registries in READMEs.",
901
+ metric: `npm share: ${opts.npmPct}%`
902
+ });
903
+ }
904
+ const surging = packages.filter((p) => p.momentum > 50);
905
+ if (surging.length > 0) {
906
+ advice.push({
907
+ type: "opportunity",
908
+ severity: "success",
909
+ urgency: "this-week",
910
+ title: `${surging.length} package${surging.length > 1 ? "s" : ""} surging`,
911
+ detail: `${surging.map((p) => p.name).slice(0, 3).join(", ")} have strong positive momentum.`,
912
+ action: 'Capitalize now: write a blog post, tweet, or submit to newsletters. Post a "Thank you" issue or discussion.',
913
+ packages: surging.map((p) => p.name)
914
+ });
915
+ }
916
+ const highVolume = packages.filter((p) => {
917
+ const pkg = packages.find((pp) => pp.name === p.name);
918
+ return pkg && pkg.forecast7.length > 0 && pkg.forecast7[6]?.predicted > 100;
919
+ });
920
+ for (const pkg of highVolume.slice(0, 3)) {
921
+ const weekSum = pkg.forecast7.reduce((s, f) => s + f.predicted, 0);
922
+ for (const threshold of [1e3, 5e3, 1e4]) {
923
+ if (weekSum >= threshold * 0.9 && weekSum <= threshold * 1.1) {
924
+ advice.push({
925
+ type: "milestone",
926
+ severity: "success",
927
+ urgency: "informational",
928
+ title: `${pkg.name} approaching ${threshold.toLocaleString()} weekly downloads`,
929
+ detail: `Forecasted at ~${weekSum.toLocaleString()} downloads next week.`,
930
+ action: "Prepare a milestone announcement and update the README with a downloads badge.",
931
+ packages: [pkg.name]
932
+ });
933
+ }
934
+ }
935
+ }
936
+ const dGrade = healthScores.filter((h) => h.grade === "D");
937
+ const easyWins = dGrade.filter((h) => {
938
+ return h.components.activity >= 10 && h.components.growth < 10;
939
+ });
940
+ if (easyWins.length > 0) {
941
+ advice.push({
942
+ type: "opportunity",
943
+ severity: "info",
944
+ urgency: "this-month",
945
+ title: `${easyWins.length} package${easyWins.length > 1 ? "s" : ""} with untapped potential`,
946
+ detail: `${easyWins.map((h) => h.name).slice(0, 3).join(", ")} have active users but stalled growth.`,
947
+ action: "Add new features, improve docs, or create tutorials. Small efforts can shift these from D to C+ quickly.",
948
+ packages: easyWins.map((h) => h.name)
949
+ });
950
+ }
951
+ const severityOrder = { critical: 0, warning: 1, success: 2, info: 3 };
952
+ advice.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
953
+ return advice;
954
+ }
955
+ function generateRecommendations(packages, opts = {}) {
956
+ const recs = [];
957
+ const declining = packages.filter((p) => p.momentum < -30);
958
+ if (declining.length > 0) {
959
+ const names = declining.slice(0, 3).map((p) => p.name).join(", ");
960
+ recs.push({
961
+ type: "attention",
962
+ priority: declining.some((p) => p.momentum < -60) ? "high" : "medium",
963
+ title: `${declining.length} package${declining.length > 1 ? "s" : ""} losing momentum`,
964
+ detail: `${names}${declining.length > 3 ? ` and ${declining.length - 3} more` : ""} show sustained decline. Consider: release updates, fix open issues, or update documentation.`,
965
+ metric: `Worst momentum: ${Math.min(...declining.map((p) => p.momentum))}`
966
+ });
967
+ }
968
+ if (opts.gini !== void 0 && opts.gini > 0.7) {
969
+ recs.push({
970
+ type: "risk",
971
+ priority: opts.gini > 0.85 ? "high" : "medium",
972
+ title: "High portfolio concentration",
973
+ detail: `Gini coefficient ${opts.gini.toFixed(2)} indicates downloads are heavily concentrated in a few packages. Diversify promotion efforts across the portfolio.`,
974
+ metric: `Gini: ${opts.gini.toFixed(2)}`
975
+ });
976
+ }
977
+ if (opts.npmPct !== void 0 && opts.npmPct > 75) {
978
+ recs.push({
979
+ type: "risk",
980
+ priority: "medium",
981
+ title: "Heavy npm dependency",
982
+ detail: `${opts.npmPct}% of downloads come from npm. Consider cross-publishing to PyPI and NuGet to reduce single-registry risk.`,
983
+ metric: `npm share: ${opts.npmPct}%`
984
+ });
985
+ }
986
+ const growing = packages.filter((p) => p.momentum > 40 && p.anomalies.some((a) => a.type === "spike"));
987
+ if (growing.length > 0) {
988
+ const names = growing.slice(0, 3).map((p) => p.name).join(", ");
989
+ recs.push({
990
+ type: "opportunity",
991
+ priority: "medium",
992
+ title: `${growing.length} package${growing.length > 1 ? "s" : ""} gaining traction`,
993
+ detail: `${names} show organic growth with download spikes. Capitalize with blog posts, social media, or conference talks.`,
994
+ metric: `Best momentum: ${Math.max(...growing.map((p) => p.momentum))}`
995
+ });
996
+ }
997
+ const forecastGrowing = packages.filter((p) => {
998
+ if (p.forecast7.length < 7) return false;
999
+ const lastActual = p.forecast7[0]?.predicted ?? 0;
1000
+ const lastForecast = p.forecast7[6]?.predicted ?? 0;
1001
+ return lastForecast > lastActual * 1.2;
1002
+ });
1003
+ if (forecastGrowing.length > 0) {
1004
+ recs.push({
1005
+ type: "growth",
1006
+ priority: "low",
1007
+ title: `${forecastGrowing.length} package${forecastGrowing.length > 1 ? "s" : ""} predicted to grow`,
1008
+ detail: `Statistical models predict >20% growth in the next 7 days for ${forecastGrowing.slice(0, 3).map((p) => p.name).join(", ")}.`
1009
+ });
1010
+ }
1011
+ const spiked = packages.filter((p) => p.anomalies.filter((a) => a.type === "spike").length >= 2);
1012
+ if (spiked.length > 0) {
1013
+ recs.push({
1014
+ type: "attention",
1015
+ priority: "low",
1016
+ title: `${spiked.length} package${spiked.length > 1 ? "s" : ""} with repeated spikes`,
1017
+ detail: `Multiple download spikes detected for ${spiked.slice(0, 3).map((p) => p.name).join(", ")}. Could indicate bot activity, viral posts, or dependency adoption.`
1018
+ });
1019
+ }
1020
+ const priorityOrder = { high: 0, medium: 1, low: 2 };
1021
+ recs.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]);
1022
+ return recs;
1023
+ }
1024
+ function inferPortfolio(leaderboard, opts = {}) {
1025
+ const packages = [];
1026
+ for (const row of leaderboard) {
1027
+ const series = row.range30;
1028
+ if (!series || series.length < 7) {
1029
+ packages.push({
1030
+ name: row.name,
1031
+ registry: row.registry,
1032
+ forecast7: [],
1033
+ anomalies: [],
1034
+ trendSegments: [],
1035
+ seasonality: null,
1036
+ momentum: 0
1037
+ });
1038
+ continue;
1039
+ }
1040
+ packages.push({
1041
+ name: row.name,
1042
+ registry: row.registry,
1043
+ forecast7: forecast(series, 7),
1044
+ anomalies: detectAnomalies(series),
1045
+ trendSegments: segmentTrends(series),
1046
+ seasonality: detectSeasonality(series, 30),
1047
+ momentum: computeMomentum(series)
1048
+ });
1049
+ }
1050
+ const forecastTotal7 = new Array(7).fill(0);
1051
+ for (const pkg of packages) {
1052
+ for (const pt of pkg.forecast7) {
1053
+ forecastTotal7[pt.day - 1] += pt.predicted;
1054
+ }
1055
+ }
1056
+ const totalWeek = leaderboard.reduce((s, r) => s + (r.week ?? 0), 0);
1057
+ let weightedMomentum = 0;
1058
+ for (const pkg of packages) {
1059
+ const row = leaderboard.find((r) => r.name === pkg.name);
1060
+ const weight = totalWeek > 0 ? (row?.week ?? 0) / totalWeek : 1 / packages.length;
1061
+ weightedMomentum += pkg.momentum * weight;
1062
+ }
1063
+ const decliningPct = packages.filter((p) => p.momentum < -20).length / Math.max(1, packages.length);
1064
+ const totalAnomalies = packages.reduce((s, p) => s + p.anomalies.length, 0);
1065
+ const anomalyDensity = totalAnomalies / Math.max(1, packages.length);
1066
+ const giniRisk = (opts.gini ?? 0) * 30;
1067
+ const declineRisk = decliningPct * 40;
1068
+ const anomalyRisk = Math.min(30, anomalyDensity * 10);
1069
+ const riskScore = Math.round(Math.max(0, Math.min(100, giniRisk + declineRisk + anomalyRisk)));
1070
+ const diversityTrend = "stable";
1071
+ const recommendations = generateRecommendations(packages, opts);
1072
+ const healthScores = leaderboard.map((row) => {
1073
+ const pkg = packages.find((p) => p.name === row.name);
1074
+ return computeHealthScore(row.name, row.registry, row.range30 ?? null, pkg?.momentum ?? 0);
1075
+ });
1076
+ const actionableAdvice = generateActionableAdvice(packages, healthScores, opts);
1077
+ return {
1078
+ packages,
1079
+ recommendations,
1080
+ forecastTotal7,
1081
+ riskScore,
1082
+ diversityTrend,
1083
+ portfolioMomentum: Math.round(weightedMomentum),
1084
+ healthScores,
1085
+ actionableAdvice
1086
+ };
1087
+ }
1088
+
603
1089
  // src/index.ts
604
1090
  function createCache() {
605
1091
  const store = /* @__PURE__ */ new Map();
@@ -797,11 +1283,21 @@ stats.mine = async function mine(maintainer, options) {
797
1283
  0 && (module.exports = {
798
1284
  RegistryError,
799
1285
  calc,
1286
+ computeHealthScore,
1287
+ computeMomentum,
1288
+ computeYearlyProgress,
800
1289
  createCache,
801
1290
  createHandler,
802
1291
  defaultConfig,
1292
+ detectAnomalies,
1293
+ detectSeasonality,
1294
+ forecast,
1295
+ generateActionableAdvice,
1296
+ generateRecommendations,
1297
+ inferPortfolio,
803
1298
  loadConfig,
804
1299
  registerProvider,
1300
+ segmentTrends,
805
1301
  serve,
806
1302
  starterConfig,
807
1303
  stats