@vitest-evals/github-reporter 0.15.0 → 0.16.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 CHANGED
@@ -36,6 +36,24 @@ steps:
36
36
  If configuration or permission is missing, the action keeps the job summary and
37
37
  workflow annotations and warns instead of failing.
38
38
 
39
+ ## Score and Pass-Rate Gates
40
+
41
+ ```yaml
42
+ - id: report
43
+ uses: getsentry/vitest-evals@v0
44
+ with:
45
+ results: eval-results/*.json
46
+ publish-check: true
47
+ min-pass-rate: 0.8
48
+ ```
49
+
50
+ - `fail-on-failures: true` requires every eval case to pass
51
+ - `min-pass-rate` and `min-score-average` set aggregate floors in the `0`-`1` range
52
+ - `status`, Check Run conclusion/title, and step exit follow the gate
53
+ - quality misses become warnings when the gate still passes
54
+ - non-eval / infrastructure failures still fail hard
55
+ - use `evals-failed` / `pass-rate` for raw tallies (`pass-rate` is a 0-1 ratio)
56
+
39
57
  ## Sharded Reports
40
58
 
41
59
  Upload one JSON artifact per shard, then publish one combined report from a
@@ -52,6 +70,7 @@ final reducer job:
52
70
  with:
53
71
  results: eval-results/*.json
54
72
  publish-check: true
73
+ min-pass-rate: 0.8
55
74
  ```
56
75
 
57
76
  ## Inputs
@@ -64,7 +83,9 @@ final reducer job:
64
83
  | `publish-check` | `false` | Publish one GitHub Check Run for the combined report. |
65
84
  | `check-name` | `vitest-evals` | Name of the GitHub Check Run. |
66
85
  | `github-token` | `${{ github.token }}` | Token used for Check Run publishing. |
67
- | `fail-on-failures` | `false` | Fail the action when the combined report failed. |
86
+ | `fail-on-failures` | `false` | Fail the action when any eval case failed. Equivalent to `min-pass-rate: 1`. |
87
+ | `min-pass-rate` | unset | Minimum fraction of eval cases that must pass (`0`-`1`). |
88
+ | `min-score-average` | unset | Minimum average eval score across scored cases (`0`-`1`). |
68
89
  | `max-annotations` | unset | Maximum number of failure annotations to publish. Check Run annotations are capped at 50 by GitHub. |
69
90
  | `max-failures` | unset | Maximum number of detailed failures to include in summaries and checks. |
70
91
 
package/dist/cli.js CHANGED
@@ -41,6 +41,12 @@ function parseCliArgs(args, env = process.env) {
41
41
  case "--fail-on-check-error":
42
42
  options.failOnCheckError = true;
43
43
  break;
44
+ case "--min-pass-rate":
45
+ options.minPassRate = readRatio(args, ++index, arg);
46
+ break;
47
+ case "--min-score-average":
48
+ options.minScoreAverage = readRatio(args, ++index, arg);
49
+ break;
44
50
  case "--max-annotations":
45
51
  options.maxAnnotations = readInteger(args, ++index, arg);
46
52
  break;
@@ -100,6 +106,14 @@ function readInteger(args, index, flag) {
100
106
  }
101
107
  return Number(rawValue);
102
108
  }
109
+ function readRatio(args, index, flag) {
110
+ const rawValue = readValue(args, index, flag);
111
+ const parsed = Number(rawValue);
112
+ if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) {
113
+ throw new Error(`Invalid ratio for ${flag}`);
114
+ }
115
+ return parsed;
116
+ }
103
117
 
104
118
  // src/report.ts
105
119
  var import_promises = require("fs/promises");
@@ -188,9 +202,10 @@ var DEFAULT_MAX_CHECK_ANNOTATIONS = 50;
188
202
  var MAX_CHECK_FIELD_LENGTH = 64e3;
189
203
  function renderWorkflowCommands(report, options = {}) {
190
204
  const maxAnnotations = options.maxAnnotations ?? DEFAULT_MAX_WORKFLOW_ANNOTATIONS;
205
+ const command = caseAnnotationCommand(options.gate);
191
206
  return report.failures.filter(hasAnnotationLocation).slice(0, maxAnnotations).map(
192
207
  (testCase) => formatWorkflowCommand({
193
- command: "error",
208
+ command,
194
209
  properties: {
195
210
  file: testCase.displayFile,
196
211
  line: String(testCase.location.line),
@@ -206,11 +221,12 @@ function buildCheckAnnotations(report, options = {}) {
206
221
  options.maxAnnotations ?? DEFAULT_MAX_CHECK_ANNOTATIONS,
207
222
  DEFAULT_MAX_CHECK_ANNOTATIONS
208
223
  );
224
+ const annotationLevel = caseAnnotationLevel(options.gate);
209
225
  return report.failures.filter(hasAnnotationLocation).slice(0, maxAnnotations).map((testCase) => ({
210
226
  path: testCase.displayFile,
211
227
  start_line: testCase.location.line,
212
228
  end_line: testCase.location.line,
213
- annotation_level: "failure",
229
+ annotation_level: annotationLevel,
214
230
  title: truncate(
215
231
  `${testCase.primaryFailure?.judgeName ?? "vitest-evals"} - ${testCase.displayName}`,
216
232
  255
@@ -222,6 +238,12 @@ function buildCheckAnnotations(report, options = {}) {
222
238
  raw_details: truncate(formatRawDetails(testCase), MAX_CHECK_FIELD_LENGTH)
223
239
  }));
224
240
  }
241
+ function caseAnnotationCommand(gate) {
242
+ return gate?.ok ? "warning" : "error";
243
+ }
244
+ function caseAnnotationLevel(gate) {
245
+ return gate?.ok ? "warning" : "failure";
246
+ }
225
247
  function hasAnnotationLocation(testCase) {
226
248
  return Boolean(testCase.location);
227
249
  }
@@ -465,6 +487,164 @@ function toolCallCount(testCase) {
465
487
  return testCase.toolCalls.length;
466
488
  }
467
489
 
490
+ // src/gate.ts
491
+ function evaluateEvalGate(report, policy = {}) {
492
+ const minPassRate = resolveMinPassRate(policy);
493
+ const minScoreAverage = policy.minScoreAverage;
494
+ const enforced = minPassRate !== void 0 || minScoreAverage !== void 0 || policy.failOnFailures === true;
495
+ const passRate = computePassRate(report);
496
+ const counts = formatEvalCounts(report, passRate);
497
+ if (!enforced) {
498
+ const ok = report.status === "passed";
499
+ return {
500
+ ok,
501
+ status: ok ? "passed" : "failed",
502
+ enforced: false,
503
+ passRate,
504
+ title: defaultCheckTitle(report),
505
+ message: ok ? `eval report passed: ${counts}` : `eval report failed: ${counts}`
506
+ };
507
+ }
508
+ const nonEvalFailures = Math.max(
509
+ 0,
510
+ report.totals.failed - report.totals.evalFailed
511
+ );
512
+ if (nonEvalFailures > 0) {
513
+ return {
514
+ ok: false,
515
+ status: "failed",
516
+ enforced: true,
517
+ passRate,
518
+ title: "Eval report hard failure",
519
+ message: `${formatNumber(nonEvalFailures)} non-eval test failure${nonEvalFailures === 1 ? "" : "s"}; ${counts}`
520
+ };
521
+ }
522
+ if (report.totals.evalTotal === 0) {
523
+ return {
524
+ ok: false,
525
+ status: "failed",
526
+ enforced: true,
527
+ passRate: null,
528
+ title: "Eval report hard failure",
529
+ message: "no eval cases were reported"
530
+ };
531
+ }
532
+ if (report.status === "failed" && report.totals.failed === 0 && report.failures.length === 0) {
533
+ return {
534
+ ok: false,
535
+ status: "failed",
536
+ enforced: true,
537
+ passRate,
538
+ title: "Eval report hard failure",
539
+ message: `vitest run failed without counted test failures; ${counts}`
540
+ };
541
+ }
542
+ if (minPassRate !== void 0 && (passRate === null || passRate + Number.EPSILON < minPassRate)) {
543
+ return {
544
+ ok: false,
545
+ status: "failed",
546
+ enforced: true,
547
+ passRate,
548
+ title: `Eval pass rate ${formatPercent(passRate)} \u2014 required ${formatPercent(minPassRate)}`,
549
+ message: `eval pass rate below floor: ${counts}; required >= ${formatPercent(minPassRate)}`
550
+ };
551
+ }
552
+ if (minScoreAverage !== void 0) {
553
+ const average = report.score?.average;
554
+ if (average === void 0 || !Number.isFinite(average)) {
555
+ return {
556
+ ok: false,
557
+ status: "failed",
558
+ enforced: true,
559
+ passRate,
560
+ title: "Eval score gate failed",
561
+ message: `no score average available; required avg score >= ${formatScore(minScoreAverage)}`
562
+ };
563
+ }
564
+ if (average + Number.EPSILON < minScoreAverage) {
565
+ return {
566
+ ok: false,
567
+ status: "failed",
568
+ enforced: true,
569
+ passRate,
570
+ title: `Avg score ${formatScore(average)} \u2014 required ${formatScore(minScoreAverage)}`,
571
+ message: `avg score below floor: ${counts}; required avg score >= ${formatScore(minScoreAverage)}`
572
+ };
573
+ }
574
+ }
575
+ return {
576
+ ok: true,
577
+ status: "passed",
578
+ enforced: true,
579
+ passRate,
580
+ title: enforcedPassTitle(report, passRate, minPassRate, minScoreAverage),
581
+ message: `eval gate passed: ${counts}${formatFloorSuffix(minPassRate, minScoreAverage)}`
582
+ };
583
+ }
584
+ function computePassRate(report) {
585
+ if (report.totals.evalTotal <= 0) {
586
+ return null;
587
+ }
588
+ return report.totals.evalPassed / report.totals.evalTotal;
589
+ }
590
+ function formatPercent(value) {
591
+ if (value == null || !Number.isFinite(value)) {
592
+ return "n/a";
593
+ }
594
+ return `${(value * 100).toFixed(1)}%`;
595
+ }
596
+ function renderGateWorkflowCommand(gate) {
597
+ if (gate.ok || !gate.enforced) {
598
+ return void 0;
599
+ }
600
+ return `::error title=${escapeCommandProperty(gate.title)}::${escapeCommandData(gate.message)}`;
601
+ }
602
+ function resolveMinPassRate(policy) {
603
+ const configured = policy.minPassRate;
604
+ if (policy.failOnFailures) {
605
+ if (configured === void 0) {
606
+ return 1;
607
+ }
608
+ return Math.max(configured, 1);
609
+ }
610
+ return configured;
611
+ }
612
+ function defaultCheckTitle(report) {
613
+ if (report.failures.length === 0 && report.status === "passed") {
614
+ return "No eval failures";
615
+ }
616
+ if (report.failures.length === 0) {
617
+ return "Vitest run failed";
618
+ }
619
+ return `${report.failures.length} eval failure${report.failures.length === 1 ? "" : "s"}`;
620
+ }
621
+ function enforcedPassTitle(report, passRate, minPassRate, minScoreAverage) {
622
+ if (minPassRate !== void 0) {
623
+ return `Eval pass rate ${formatPercent(passRate)} \u2014 floor ${formatPercent(minPassRate)}`;
624
+ }
625
+ if (minScoreAverage !== void 0) {
626
+ return `Avg score ${formatScore(report.score?.average)} \u2014 floor ${formatScore(minScoreAverage)}`;
627
+ }
628
+ return defaultCheckTitle(report);
629
+ }
630
+ function formatEvalCounts(report, passRate) {
631
+ const scoreText = report.score?.average === void 0 ? "n/a" : formatScore(report.score.average);
632
+ const passRateText = passRate === null ? "n/a" : formatPercent(passRate);
633
+ return `${formatNumber(report.totals.evalPassed)}/${formatNumber(
634
+ report.totals.evalTotal
635
+ )} passed (${passRateText}), avg score ${scoreText}`;
636
+ }
637
+ function formatFloorSuffix(minPassRate, minScoreAverage) {
638
+ const parts = [];
639
+ if (minPassRate !== void 0) {
640
+ parts.push(`pass rate floor ${formatPercent(minPassRate)}`);
641
+ }
642
+ if (minScoreAverage !== void 0) {
643
+ parts.push(`avg score floor ${formatScore(minScoreAverage)}`);
644
+ }
645
+ return parts.length === 0 ? "" : `; ${parts.join(", ")}`;
646
+ }
647
+
468
648
  // src/summary.ts
469
649
  var DEFAULT_MAX_FAILURES = 20;
470
650
  var DEFAULT_MAX_REASON_CHARS = 8e3;
@@ -485,20 +665,22 @@ function renderJobSummary(report, options = {}) {
485
665
  const lines = [
486
666
  "# vitest-evals",
487
667
  "",
488
- ...renderSummaryTable(report, nonEvalFailures),
668
+ ...renderSummaryTable(report, nonEvalFailures, options.gate),
489
669
  "",
490
670
  ...renderScoreDistribution(report),
491
671
  "## Results",
492
672
  ""
493
673
  ];
494
674
  if (report.failures.length > 0) {
495
- lines.push("### Failures", "");
675
+ const failureHeading = options.gate?.ok === true ? "### Quality Misses" : "### Failures";
676
+ lines.push(failureHeading, "");
496
677
  failures.forEach((testCase, index) => {
497
678
  lines.push(...renderFailureDetails(testCase, index + 1, options), "");
498
679
  });
499
680
  if (report.failures.length > failures.length) {
681
+ const omittedLabel = options.gate?.ok === true ? "quality misses" : "failures";
500
682
  lines.push(
501
- `${report.failures.length - failures.length} more failures omitted from this summary.`,
683
+ `${report.failures.length - failures.length} more ${omittedLabel} omitted from this summary.`,
502
684
  ""
503
685
  );
504
686
  }
@@ -514,9 +696,9 @@ function renderJobSummary(report, options = {}) {
514
696
  function formatCountLine(passed, failed, total) {
515
697
  return `${formatNumber(passed)} passed, ${formatNumber(failed)} failed, ${formatNumber(total)} total`;
516
698
  }
517
- function renderSummaryTable(report, nonEvalFailures) {
699
+ function renderSummaryTable(report, nonEvalFailures, gate) {
518
700
  const rows = [
519
- ["Status", report.status],
701
+ ["Status", gate?.status ?? report.status],
520
702
  [
521
703
  "Evals",
522
704
  formatCountLine(
@@ -526,9 +708,20 @@ function renderSummaryTable(report, nonEvalFailures) {
526
708
  )
527
709
  ]
528
710
  ];
711
+ if (gate?.passRate !== void 0 && gate.passRate !== null) {
712
+ rows.push(["Pass Rate", formatPercent(gate.passRate)]);
713
+ } else if (report.totals.evalTotal > 0) {
714
+ rows.push([
715
+ "Pass Rate",
716
+ formatPercent(report.totals.evalPassed / report.totals.evalTotal)
717
+ ]);
718
+ }
529
719
  if (report.score) {
530
720
  rows.push(["Score", formatScoreSummary(report.score)]);
531
721
  }
722
+ if (gate?.enforced) {
723
+ rows.push(["Gate", gate.message]);
724
+ }
532
725
  if (nonEvalFailures > 0) {
533
726
  rows.push([
534
727
  "Other Failures",
@@ -805,23 +998,25 @@ async function publishCheckRun(report, options = {}) {
805
998
  };
806
999
  }
807
1000
  function buildCheckRunPayload(report, options) {
1001
+ const gate = options.gate ?? evaluateEvalGate(report);
808
1002
  const annotations = buildCheckAnnotations(report, {
809
- maxAnnotations: options.maxAnnotations
1003
+ maxAnnotations: options.maxAnnotations,
1004
+ gate
810
1005
  });
811
- const title = report.failures.length === 0 && report.status === "passed" ? "No eval failures" : report.failures.length === 0 ? "Vitest run failed" : `${report.failures.length} eval failure${report.failures.length === 1 ? "" : "s"}`;
812
1006
  return {
813
1007
  status: "completed",
814
- conclusion: report.status === "passed" ? "success" : "failure",
1008
+ conclusion: gate.ok ? "success" : "failure",
815
1009
  completed_at: (/* @__PURE__ */ new Date()).toISOString(),
816
1010
  output: {
817
- title,
1011
+ title: gate.title,
818
1012
  summary: truncateCheckSummary(
819
1013
  renderJobSummary(report, {
820
1014
  ...options,
821
1015
  maxFailures: options.maxFailures ?? 5,
822
1016
  maxReasonChars: options.maxReasonChars ?? 4e3,
823
1017
  maxOutputChars: options.maxOutputChars ?? 2e3,
824
- maxToolCalls: options.maxToolCalls ?? 10
1018
+ maxToolCalls: options.maxToolCalls ?? 10,
1019
+ gate
825
1020
  })
826
1021
  ),
827
1022
  annotations
@@ -920,11 +1115,17 @@ async function publishEvalReport(options) {
920
1115
  })
921
1116
  );
922
1117
  const report = mergeEvalReports(reports);
1118
+ const gate = evaluateEvalGate(report, {
1119
+ failOnFailures: options.failOnFailures,
1120
+ minPassRate: options.minPassRate,
1121
+ minScoreAverage: options.minScoreAverage
1122
+ });
923
1123
  const summary = renderJobSummary(report, {
924
1124
  maxFailures: options.maxFailures,
925
1125
  maxOutputChars: options.maxOutputChars,
926
1126
  maxReasonChars: options.maxReasonChars,
927
- maxToolCalls: options.maxToolCalls
1127
+ maxToolCalls: options.maxToolCalls,
1128
+ gate
928
1129
  });
929
1130
  if (options.summaryEnabled !== false) {
930
1131
  if (options.summaryPath) {
@@ -935,8 +1136,13 @@ async function publishEvalReport(options) {
935
1136
  }
936
1137
  }
937
1138
  if (options.annotations) {
1139
+ const gateCommand = renderGateWorkflowCommand(gate);
1140
+ if (gateCommand) {
1141
+ console.log(gateCommand);
1142
+ }
938
1143
  for (const command of renderWorkflowCommands(report, {
939
- maxAnnotations: options.maxAnnotations
1144
+ maxAnnotations: options.maxAnnotations,
1145
+ gate
940
1146
  })) {
941
1147
  console.log(command);
942
1148
  }
@@ -954,7 +1160,8 @@ async function publishEvalReport(options) {
954
1160
  name: options.checkName,
955
1161
  repository: options.repository,
956
1162
  sha: options.sha,
957
- token: options.token
1163
+ token: options.token,
1164
+ gate
958
1165
  });
959
1166
  if (checkRun.status === "skipped") {
960
1167
  options.warn?.(`GitHub Check Run skipped: ${checkRun.reason}`);
@@ -970,6 +1177,8 @@ async function publishEvalReport(options) {
970
1177
  return {
971
1178
  report,
972
1179
  resultFiles,
1180
+ gate,
1181
+ shouldFail: gate.enforced && !gate.ok,
973
1182
  checkRun
974
1183
  };
975
1184
  }
@@ -996,6 +1205,9 @@ async function main() {
996
1205
  checkRunId: options.checkRunId,
997
1206
  checkName: options.checkName,
998
1207
  failOnCheckError: options.failOnCheckError,
1208
+ failOnFailures: options.failOnFailures,
1209
+ minPassRate: options.minPassRate,
1210
+ minScoreAverage: options.minScoreAverage,
999
1211
  maxAnnotations: options.maxAnnotations,
1000
1212
  maxFailures: options.maxFailures,
1001
1213
  repository: options.repository,
@@ -1003,7 +1215,8 @@ async function main() {
1003
1215
  token: options.token,
1004
1216
  warn
1005
1217
  });
1006
- if (options.failOnFailures && result.report.status === "failed") {
1218
+ if (result.shouldFail) {
1219
+ console.error(result.gate.message);
1007
1220
  process.exitCode = 1;
1008
1221
  }
1009
1222
  }
@@ -1025,7 +1238,9 @@ function usage() {
1025
1238
  " --annotations Emit GitHub workflow-command annotations",
1026
1239
  " --no-annotations Disable workflow-command annotations",
1027
1240
  " --check-run Publish a GitHub Check Run when configured",
1028
- " --fail-on-failures Exit non-zero when the combined report failed",
1241
+ " --fail-on-failures Exit non-zero when any eval case failed",
1242
+ " --min-pass-rate <0-1> Exit non-zero when eval pass rate is below this floor",
1243
+ " --min-score-average <0-1> Exit non-zero when average score is below this floor",
1029
1244
  " --fail-on-check-error Fail when Check Run publishing fails",
1030
1245
  " --check-run-id <id> Update an existing Check Run",
1031
1246
  " --check-name <name> Check Run name (default: vitest-evals)",