@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/dist/cli.mjs CHANGED
@@ -40,6 +40,12 @@ function parseCliArgs(args, env = process.env) {
40
40
  case "--fail-on-check-error":
41
41
  options.failOnCheckError = true;
42
42
  break;
43
+ case "--min-pass-rate":
44
+ options.minPassRate = readRatio(args, ++index, arg);
45
+ break;
46
+ case "--min-score-average":
47
+ options.minScoreAverage = readRatio(args, ++index, arg);
48
+ break;
43
49
  case "--max-annotations":
44
50
  options.maxAnnotations = readInteger(args, ++index, arg);
45
51
  break;
@@ -99,6 +105,14 @@ function readInteger(args, index, flag) {
99
105
  }
100
106
  return Number(rawValue);
101
107
  }
108
+ function readRatio(args, index, flag) {
109
+ const rawValue = readValue(args, index, flag);
110
+ const parsed = Number(rawValue);
111
+ if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) {
112
+ throw new Error(`Invalid ratio for ${flag}`);
113
+ }
114
+ return parsed;
115
+ }
102
116
 
103
117
  // src/report.ts
104
118
  import { appendFile } from "fs/promises";
@@ -190,9 +204,10 @@ var DEFAULT_MAX_CHECK_ANNOTATIONS = 50;
190
204
  var MAX_CHECK_FIELD_LENGTH = 64e3;
191
205
  function renderWorkflowCommands(report, options = {}) {
192
206
  const maxAnnotations = options.maxAnnotations ?? DEFAULT_MAX_WORKFLOW_ANNOTATIONS;
207
+ const command = caseAnnotationCommand(options.gate);
193
208
  return report.failures.filter(hasAnnotationLocation).slice(0, maxAnnotations).map(
194
209
  (testCase) => formatWorkflowCommand({
195
- command: "error",
210
+ command,
196
211
  properties: {
197
212
  file: testCase.displayFile,
198
213
  line: String(testCase.location.line),
@@ -208,11 +223,12 @@ function buildCheckAnnotations(report, options = {}) {
208
223
  options.maxAnnotations ?? DEFAULT_MAX_CHECK_ANNOTATIONS,
209
224
  DEFAULT_MAX_CHECK_ANNOTATIONS
210
225
  );
226
+ const annotationLevel = caseAnnotationLevel(options.gate);
211
227
  return report.failures.filter(hasAnnotationLocation).slice(0, maxAnnotations).map((testCase) => ({
212
228
  path: testCase.displayFile,
213
229
  start_line: testCase.location.line,
214
230
  end_line: testCase.location.line,
215
- annotation_level: "failure",
231
+ annotation_level: annotationLevel,
216
232
  title: truncate(
217
233
  `${testCase.primaryFailure?.judgeName ?? "vitest-evals"} - ${testCase.displayName}`,
218
234
  255
@@ -224,6 +240,12 @@ function buildCheckAnnotations(report, options = {}) {
224
240
  raw_details: truncate(formatRawDetails(testCase), MAX_CHECK_FIELD_LENGTH)
225
241
  }));
226
242
  }
243
+ function caseAnnotationCommand(gate) {
244
+ return gate?.ok ? "warning" : "error";
245
+ }
246
+ function caseAnnotationLevel(gate) {
247
+ return gate?.ok ? "warning" : "failure";
248
+ }
227
249
  function hasAnnotationLocation(testCase) {
228
250
  return Boolean(testCase.location);
229
251
  }
@@ -469,6 +491,164 @@ function toolCallCount(testCase) {
469
491
  return testCase.toolCalls.length;
470
492
  }
471
493
 
494
+ // src/gate.ts
495
+ function evaluateEvalGate(report, policy = {}) {
496
+ const minPassRate = resolveMinPassRate(policy);
497
+ const minScoreAverage = policy.minScoreAverage;
498
+ const enforced = minPassRate !== void 0 || minScoreAverage !== void 0 || policy.failOnFailures === true;
499
+ const passRate = computePassRate(report);
500
+ const counts = formatEvalCounts(report, passRate);
501
+ if (!enforced) {
502
+ const ok = report.status === "passed";
503
+ return {
504
+ ok,
505
+ status: ok ? "passed" : "failed",
506
+ enforced: false,
507
+ passRate,
508
+ title: defaultCheckTitle(report),
509
+ message: ok ? `eval report passed: ${counts}` : `eval report failed: ${counts}`
510
+ };
511
+ }
512
+ const nonEvalFailures = Math.max(
513
+ 0,
514
+ report.totals.failed - report.totals.evalFailed
515
+ );
516
+ if (nonEvalFailures > 0) {
517
+ return {
518
+ ok: false,
519
+ status: "failed",
520
+ enforced: true,
521
+ passRate,
522
+ title: "Eval report hard failure",
523
+ message: `${formatNumber(nonEvalFailures)} non-eval test failure${nonEvalFailures === 1 ? "" : "s"}; ${counts}`
524
+ };
525
+ }
526
+ if (report.totals.evalTotal === 0) {
527
+ return {
528
+ ok: false,
529
+ status: "failed",
530
+ enforced: true,
531
+ passRate: null,
532
+ title: "Eval report hard failure",
533
+ message: "no eval cases were reported"
534
+ };
535
+ }
536
+ if (report.status === "failed" && report.totals.failed === 0 && report.failures.length === 0) {
537
+ return {
538
+ ok: false,
539
+ status: "failed",
540
+ enforced: true,
541
+ passRate,
542
+ title: "Eval report hard failure",
543
+ message: `vitest run failed without counted test failures; ${counts}`
544
+ };
545
+ }
546
+ if (minPassRate !== void 0 && (passRate === null || passRate + Number.EPSILON < minPassRate)) {
547
+ return {
548
+ ok: false,
549
+ status: "failed",
550
+ enforced: true,
551
+ passRate,
552
+ title: `Eval pass rate ${formatPercent(passRate)} \u2014 required ${formatPercent(minPassRate)}`,
553
+ message: `eval pass rate below floor: ${counts}; required >= ${formatPercent(minPassRate)}`
554
+ };
555
+ }
556
+ if (minScoreAverage !== void 0) {
557
+ const average = report.score?.average;
558
+ if (average === void 0 || !Number.isFinite(average)) {
559
+ return {
560
+ ok: false,
561
+ status: "failed",
562
+ enforced: true,
563
+ passRate,
564
+ title: "Eval score gate failed",
565
+ message: `no score average available; required avg score >= ${formatScore(minScoreAverage)}`
566
+ };
567
+ }
568
+ if (average + Number.EPSILON < minScoreAverage) {
569
+ return {
570
+ ok: false,
571
+ status: "failed",
572
+ enforced: true,
573
+ passRate,
574
+ title: `Avg score ${formatScore(average)} \u2014 required ${formatScore(minScoreAverage)}`,
575
+ message: `avg score below floor: ${counts}; required avg score >= ${formatScore(minScoreAverage)}`
576
+ };
577
+ }
578
+ }
579
+ return {
580
+ ok: true,
581
+ status: "passed",
582
+ enforced: true,
583
+ passRate,
584
+ title: enforcedPassTitle(report, passRate, minPassRate, minScoreAverage),
585
+ message: `eval gate passed: ${counts}${formatFloorSuffix(minPassRate, minScoreAverage)}`
586
+ };
587
+ }
588
+ function computePassRate(report) {
589
+ if (report.totals.evalTotal <= 0) {
590
+ return null;
591
+ }
592
+ return report.totals.evalPassed / report.totals.evalTotal;
593
+ }
594
+ function formatPercent(value) {
595
+ if (value == null || !Number.isFinite(value)) {
596
+ return "n/a";
597
+ }
598
+ return `${(value * 100).toFixed(1)}%`;
599
+ }
600
+ function renderGateWorkflowCommand(gate) {
601
+ if (gate.ok || !gate.enforced) {
602
+ return void 0;
603
+ }
604
+ return `::error title=${escapeCommandProperty(gate.title)}::${escapeCommandData(gate.message)}`;
605
+ }
606
+ function resolveMinPassRate(policy) {
607
+ const configured = policy.minPassRate;
608
+ if (policy.failOnFailures) {
609
+ if (configured === void 0) {
610
+ return 1;
611
+ }
612
+ return Math.max(configured, 1);
613
+ }
614
+ return configured;
615
+ }
616
+ function defaultCheckTitle(report) {
617
+ if (report.failures.length === 0 && report.status === "passed") {
618
+ return "No eval failures";
619
+ }
620
+ if (report.failures.length === 0) {
621
+ return "Vitest run failed";
622
+ }
623
+ return `${report.failures.length} eval failure${report.failures.length === 1 ? "" : "s"}`;
624
+ }
625
+ function enforcedPassTitle(report, passRate, minPassRate, minScoreAverage) {
626
+ if (minPassRate !== void 0) {
627
+ return `Eval pass rate ${formatPercent(passRate)} \u2014 floor ${formatPercent(minPassRate)}`;
628
+ }
629
+ if (minScoreAverage !== void 0) {
630
+ return `Avg score ${formatScore(report.score?.average)} \u2014 floor ${formatScore(minScoreAverage)}`;
631
+ }
632
+ return defaultCheckTitle(report);
633
+ }
634
+ function formatEvalCounts(report, passRate) {
635
+ const scoreText = report.score?.average === void 0 ? "n/a" : formatScore(report.score.average);
636
+ const passRateText = passRate === null ? "n/a" : formatPercent(passRate);
637
+ return `${formatNumber(report.totals.evalPassed)}/${formatNumber(
638
+ report.totals.evalTotal
639
+ )} passed (${passRateText}), avg score ${scoreText}`;
640
+ }
641
+ function formatFloorSuffix(minPassRate, minScoreAverage) {
642
+ const parts = [];
643
+ if (minPassRate !== void 0) {
644
+ parts.push(`pass rate floor ${formatPercent(minPassRate)}`);
645
+ }
646
+ if (minScoreAverage !== void 0) {
647
+ parts.push(`avg score floor ${formatScore(minScoreAverage)}`);
648
+ }
649
+ return parts.length === 0 ? "" : `; ${parts.join(", ")}`;
650
+ }
651
+
472
652
  // src/summary.ts
473
653
  var DEFAULT_MAX_FAILURES = 20;
474
654
  var DEFAULT_MAX_REASON_CHARS = 8e3;
@@ -489,20 +669,22 @@ function renderJobSummary(report, options = {}) {
489
669
  const lines = [
490
670
  "# vitest-evals",
491
671
  "",
492
- ...renderSummaryTable(report, nonEvalFailures),
672
+ ...renderSummaryTable(report, nonEvalFailures, options.gate),
493
673
  "",
494
674
  ...renderScoreDistribution(report),
495
675
  "## Results",
496
676
  ""
497
677
  ];
498
678
  if (report.failures.length > 0) {
499
- lines.push("### Failures", "");
679
+ const failureHeading = options.gate?.ok === true ? "### Quality Misses" : "### Failures";
680
+ lines.push(failureHeading, "");
500
681
  failures.forEach((testCase, index) => {
501
682
  lines.push(...renderFailureDetails(testCase, index + 1, options), "");
502
683
  });
503
684
  if (report.failures.length > failures.length) {
685
+ const omittedLabel = options.gate?.ok === true ? "quality misses" : "failures";
504
686
  lines.push(
505
- `${report.failures.length - failures.length} more failures omitted from this summary.`,
687
+ `${report.failures.length - failures.length} more ${omittedLabel} omitted from this summary.`,
506
688
  ""
507
689
  );
508
690
  }
@@ -518,9 +700,9 @@ function renderJobSummary(report, options = {}) {
518
700
  function formatCountLine(passed, failed, total) {
519
701
  return `${formatNumber(passed)} passed, ${formatNumber(failed)} failed, ${formatNumber(total)} total`;
520
702
  }
521
- function renderSummaryTable(report, nonEvalFailures) {
703
+ function renderSummaryTable(report, nonEvalFailures, gate) {
522
704
  const rows = [
523
- ["Status", report.status],
705
+ ["Status", gate?.status ?? report.status],
524
706
  [
525
707
  "Evals",
526
708
  formatCountLine(
@@ -530,9 +712,20 @@ function renderSummaryTable(report, nonEvalFailures) {
530
712
  )
531
713
  ]
532
714
  ];
715
+ if (gate?.passRate !== void 0 && gate.passRate !== null) {
716
+ rows.push(["Pass Rate", formatPercent(gate.passRate)]);
717
+ } else if (report.totals.evalTotal > 0) {
718
+ rows.push([
719
+ "Pass Rate",
720
+ formatPercent(report.totals.evalPassed / report.totals.evalTotal)
721
+ ]);
722
+ }
533
723
  if (report.score) {
534
724
  rows.push(["Score", formatScoreSummary(report.score)]);
535
725
  }
726
+ if (gate?.enforced) {
727
+ rows.push(["Gate", gate.message]);
728
+ }
536
729
  if (nonEvalFailures > 0) {
537
730
  rows.push([
538
731
  "Other Failures",
@@ -809,23 +1002,25 @@ async function publishCheckRun(report, options = {}) {
809
1002
  };
810
1003
  }
811
1004
  function buildCheckRunPayload(report, options) {
1005
+ const gate = options.gate ?? evaluateEvalGate(report);
812
1006
  const annotations = buildCheckAnnotations(report, {
813
- maxAnnotations: options.maxAnnotations
1007
+ maxAnnotations: options.maxAnnotations,
1008
+ gate
814
1009
  });
815
- 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"}`;
816
1010
  return {
817
1011
  status: "completed",
818
- conclusion: report.status === "passed" ? "success" : "failure",
1012
+ conclusion: gate.ok ? "success" : "failure",
819
1013
  completed_at: (/* @__PURE__ */ new Date()).toISOString(),
820
1014
  output: {
821
- title,
1015
+ title: gate.title,
822
1016
  summary: truncateCheckSummary(
823
1017
  renderJobSummary(report, {
824
1018
  ...options,
825
1019
  maxFailures: options.maxFailures ?? 5,
826
1020
  maxReasonChars: options.maxReasonChars ?? 4e3,
827
1021
  maxOutputChars: options.maxOutputChars ?? 2e3,
828
- maxToolCalls: options.maxToolCalls ?? 10
1022
+ maxToolCalls: options.maxToolCalls ?? 10,
1023
+ gate
829
1024
  })
830
1025
  ),
831
1026
  annotations
@@ -924,11 +1119,17 @@ async function publishEvalReport(options) {
924
1119
  })
925
1120
  );
926
1121
  const report = mergeEvalReports(reports);
1122
+ const gate = evaluateEvalGate(report, {
1123
+ failOnFailures: options.failOnFailures,
1124
+ minPassRate: options.minPassRate,
1125
+ minScoreAverage: options.minScoreAverage
1126
+ });
927
1127
  const summary = renderJobSummary(report, {
928
1128
  maxFailures: options.maxFailures,
929
1129
  maxOutputChars: options.maxOutputChars,
930
1130
  maxReasonChars: options.maxReasonChars,
931
- maxToolCalls: options.maxToolCalls
1131
+ maxToolCalls: options.maxToolCalls,
1132
+ gate
932
1133
  });
933
1134
  if (options.summaryEnabled !== false) {
934
1135
  if (options.summaryPath) {
@@ -939,8 +1140,13 @@ async function publishEvalReport(options) {
939
1140
  }
940
1141
  }
941
1142
  if (options.annotations) {
1143
+ const gateCommand = renderGateWorkflowCommand(gate);
1144
+ if (gateCommand) {
1145
+ console.log(gateCommand);
1146
+ }
942
1147
  for (const command of renderWorkflowCommands(report, {
943
- maxAnnotations: options.maxAnnotations
1148
+ maxAnnotations: options.maxAnnotations,
1149
+ gate
944
1150
  })) {
945
1151
  console.log(command);
946
1152
  }
@@ -958,7 +1164,8 @@ async function publishEvalReport(options) {
958
1164
  name: options.checkName,
959
1165
  repository: options.repository,
960
1166
  sha: options.sha,
961
- token: options.token
1167
+ token: options.token,
1168
+ gate
962
1169
  });
963
1170
  if (checkRun.status === "skipped") {
964
1171
  options.warn?.(`GitHub Check Run skipped: ${checkRun.reason}`);
@@ -974,6 +1181,8 @@ async function publishEvalReport(options) {
974
1181
  return {
975
1182
  report,
976
1183
  resultFiles,
1184
+ gate,
1185
+ shouldFail: gate.enforced && !gate.ok,
977
1186
  checkRun
978
1187
  };
979
1188
  }
@@ -1000,6 +1209,9 @@ async function main() {
1000
1209
  checkRunId: options.checkRunId,
1001
1210
  checkName: options.checkName,
1002
1211
  failOnCheckError: options.failOnCheckError,
1212
+ failOnFailures: options.failOnFailures,
1213
+ minPassRate: options.minPassRate,
1214
+ minScoreAverage: options.minScoreAverage,
1003
1215
  maxAnnotations: options.maxAnnotations,
1004
1216
  maxFailures: options.maxFailures,
1005
1217
  repository: options.repository,
@@ -1007,7 +1219,8 @@ async function main() {
1007
1219
  token: options.token,
1008
1220
  warn
1009
1221
  });
1010
- if (options.failOnFailures && result.report.status === "failed") {
1222
+ if (result.shouldFail) {
1223
+ console.error(result.gate.message);
1011
1224
  process.exitCode = 1;
1012
1225
  }
1013
1226
  }
@@ -1029,7 +1242,9 @@ function usage() {
1029
1242
  " --annotations Emit GitHub workflow-command annotations",
1030
1243
  " --no-annotations Disable workflow-command annotations",
1031
1244
  " --check-run Publish a GitHub Check Run when configured",
1032
- " --fail-on-failures Exit non-zero when the combined report failed",
1245
+ " --fail-on-failures Exit non-zero when any eval case failed",
1246
+ " --min-pass-rate <0-1> Exit non-zero when eval pass rate is below this floor",
1247
+ " --min-score-average <0-1> Exit non-zero when average score is below this floor",
1033
1248
  " --fail-on-check-error Fail when Check Run publishing fails",
1034
1249
  " --check-run-id <id> Update an existing Check Run",
1035
1250
  " --check-name <name> Check Run name (default: vitest-evals)",