@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/index.js CHANGED
@@ -22,7 +22,12 @@ var index_exports = {};
22
22
  __export(index_exports, {
23
23
  buildCheckAnnotations: () => buildCheckAnnotations,
24
24
  collectEvalReport: () => collectEvalReport,
25
+ computePassRate: () => computePassRate,
26
+ evaluateEvalGate: () => evaluateEvalGate,
27
+ formatPercent: () => formatPercent,
25
28
  publishCheckRun: () => publishCheckRun,
29
+ publishEvalReport: () => publishEvalReport,
30
+ renderGateWorkflowCommand: () => renderGateWorkflowCommand,
26
31
  renderJobSummary: () => renderJobSummary,
27
32
  renderWorkflowCommands: () => renderWorkflowCommands
28
33
  });
@@ -297,9 +302,10 @@ var DEFAULT_MAX_CHECK_ANNOTATIONS = 50;
297
302
  var MAX_CHECK_FIELD_LENGTH = 64e3;
298
303
  function renderWorkflowCommands(report, options = {}) {
299
304
  const maxAnnotations = options.maxAnnotations ?? DEFAULT_MAX_WORKFLOW_ANNOTATIONS;
305
+ const command = caseAnnotationCommand(options.gate);
300
306
  return report.failures.filter(hasAnnotationLocation).slice(0, maxAnnotations).map(
301
307
  (testCase) => formatWorkflowCommand({
302
- command: "error",
308
+ command,
303
309
  properties: {
304
310
  file: testCase.displayFile,
305
311
  line: String(testCase.location.line),
@@ -315,11 +321,12 @@ function buildCheckAnnotations(report, options = {}) {
315
321
  options.maxAnnotations ?? DEFAULT_MAX_CHECK_ANNOTATIONS,
316
322
  DEFAULT_MAX_CHECK_ANNOTATIONS
317
323
  );
324
+ const annotationLevel = caseAnnotationLevel(options.gate);
318
325
  return report.failures.filter(hasAnnotationLocation).slice(0, maxAnnotations).map((testCase) => ({
319
326
  path: testCase.displayFile,
320
327
  start_line: testCase.location.line,
321
328
  end_line: testCase.location.line,
322
- annotation_level: "failure",
329
+ annotation_level: annotationLevel,
323
330
  title: truncate(
324
331
  `${testCase.primaryFailure?.judgeName ?? "vitest-evals"} - ${testCase.displayName}`,
325
332
  255
@@ -331,6 +338,12 @@ function buildCheckAnnotations(report, options = {}) {
331
338
  raw_details: truncate(formatRawDetails(testCase), MAX_CHECK_FIELD_LENGTH)
332
339
  }));
333
340
  }
341
+ function caseAnnotationCommand(gate) {
342
+ return gate?.ok ? "warning" : "error";
343
+ }
344
+ function caseAnnotationLevel(gate) {
345
+ return gate?.ok ? "warning" : "failure";
346
+ }
334
347
  function hasAnnotationLocation(testCase) {
335
348
  return Boolean(testCase.location);
336
349
  }
@@ -390,6 +403,164 @@ function formatWorkflowCommand({
390
403
  return `::${command} ${renderedProperties}::${escapeCommandData(message)}`;
391
404
  }
392
405
 
406
+ // src/gate.ts
407
+ function evaluateEvalGate(report, policy = {}) {
408
+ const minPassRate = resolveMinPassRate(policy);
409
+ const minScoreAverage = policy.minScoreAverage;
410
+ const enforced = minPassRate !== void 0 || minScoreAverage !== void 0 || policy.failOnFailures === true;
411
+ const passRate = computePassRate(report);
412
+ const counts = formatEvalCounts(report, passRate);
413
+ if (!enforced) {
414
+ const ok = report.status === "passed";
415
+ return {
416
+ ok,
417
+ status: ok ? "passed" : "failed",
418
+ enforced: false,
419
+ passRate,
420
+ title: defaultCheckTitle(report),
421
+ message: ok ? `eval report passed: ${counts}` : `eval report failed: ${counts}`
422
+ };
423
+ }
424
+ const nonEvalFailures = Math.max(
425
+ 0,
426
+ report.totals.failed - report.totals.evalFailed
427
+ );
428
+ if (nonEvalFailures > 0) {
429
+ return {
430
+ ok: false,
431
+ status: "failed",
432
+ enforced: true,
433
+ passRate,
434
+ title: "Eval report hard failure",
435
+ message: `${formatNumber(nonEvalFailures)} non-eval test failure${nonEvalFailures === 1 ? "" : "s"}; ${counts}`
436
+ };
437
+ }
438
+ if (report.totals.evalTotal === 0) {
439
+ return {
440
+ ok: false,
441
+ status: "failed",
442
+ enforced: true,
443
+ passRate: null,
444
+ title: "Eval report hard failure",
445
+ message: "no eval cases were reported"
446
+ };
447
+ }
448
+ if (report.status === "failed" && report.totals.failed === 0 && report.failures.length === 0) {
449
+ return {
450
+ ok: false,
451
+ status: "failed",
452
+ enforced: true,
453
+ passRate,
454
+ title: "Eval report hard failure",
455
+ message: `vitest run failed without counted test failures; ${counts}`
456
+ };
457
+ }
458
+ if (minPassRate !== void 0 && (passRate === null || passRate + Number.EPSILON < minPassRate)) {
459
+ return {
460
+ ok: false,
461
+ status: "failed",
462
+ enforced: true,
463
+ passRate,
464
+ title: `Eval pass rate ${formatPercent(passRate)} \u2014 required ${formatPercent(minPassRate)}`,
465
+ message: `eval pass rate below floor: ${counts}; required >= ${formatPercent(minPassRate)}`
466
+ };
467
+ }
468
+ if (minScoreAverage !== void 0) {
469
+ const average = report.score?.average;
470
+ if (average === void 0 || !Number.isFinite(average)) {
471
+ return {
472
+ ok: false,
473
+ status: "failed",
474
+ enforced: true,
475
+ passRate,
476
+ title: "Eval score gate failed",
477
+ message: `no score average available; required avg score >= ${formatScore(minScoreAverage)}`
478
+ };
479
+ }
480
+ if (average + Number.EPSILON < minScoreAverage) {
481
+ return {
482
+ ok: false,
483
+ status: "failed",
484
+ enforced: true,
485
+ passRate,
486
+ title: `Avg score ${formatScore(average)} \u2014 required ${formatScore(minScoreAverage)}`,
487
+ message: `avg score below floor: ${counts}; required avg score >= ${formatScore(minScoreAverage)}`
488
+ };
489
+ }
490
+ }
491
+ return {
492
+ ok: true,
493
+ status: "passed",
494
+ enforced: true,
495
+ passRate,
496
+ title: enforcedPassTitle(report, passRate, minPassRate, minScoreAverage),
497
+ message: `eval gate passed: ${counts}${formatFloorSuffix(minPassRate, minScoreAverage)}`
498
+ };
499
+ }
500
+ function computePassRate(report) {
501
+ if (report.totals.evalTotal <= 0) {
502
+ return null;
503
+ }
504
+ return report.totals.evalPassed / report.totals.evalTotal;
505
+ }
506
+ function formatPercent(value) {
507
+ if (value == null || !Number.isFinite(value)) {
508
+ return "n/a";
509
+ }
510
+ return `${(value * 100).toFixed(1)}%`;
511
+ }
512
+ function renderGateWorkflowCommand(gate) {
513
+ if (gate.ok || !gate.enforced) {
514
+ return void 0;
515
+ }
516
+ return `::error title=${escapeCommandProperty(gate.title)}::${escapeCommandData(gate.message)}`;
517
+ }
518
+ function resolveMinPassRate(policy) {
519
+ const configured = policy.minPassRate;
520
+ if (policy.failOnFailures) {
521
+ if (configured === void 0) {
522
+ return 1;
523
+ }
524
+ return Math.max(configured, 1);
525
+ }
526
+ return configured;
527
+ }
528
+ function defaultCheckTitle(report) {
529
+ if (report.failures.length === 0 && report.status === "passed") {
530
+ return "No eval failures";
531
+ }
532
+ if (report.failures.length === 0) {
533
+ return "Vitest run failed";
534
+ }
535
+ return `${report.failures.length} eval failure${report.failures.length === 1 ? "" : "s"}`;
536
+ }
537
+ function enforcedPassTitle(report, passRate, minPassRate, minScoreAverage) {
538
+ if (minPassRate !== void 0) {
539
+ return `Eval pass rate ${formatPercent(passRate)} \u2014 floor ${formatPercent(minPassRate)}`;
540
+ }
541
+ if (minScoreAverage !== void 0) {
542
+ return `Avg score ${formatScore(report.score?.average)} \u2014 floor ${formatScore(minScoreAverage)}`;
543
+ }
544
+ return defaultCheckTitle(report);
545
+ }
546
+ function formatEvalCounts(report, passRate) {
547
+ const scoreText = report.score?.average === void 0 ? "n/a" : formatScore(report.score.average);
548
+ const passRateText = passRate === null ? "n/a" : formatPercent(passRate);
549
+ return `${formatNumber(report.totals.evalPassed)}/${formatNumber(
550
+ report.totals.evalTotal
551
+ )} passed (${passRateText}), avg score ${scoreText}`;
552
+ }
553
+ function formatFloorSuffix(minPassRate, minScoreAverage) {
554
+ const parts = [];
555
+ if (minPassRate !== void 0) {
556
+ parts.push(`pass rate floor ${formatPercent(minPassRate)}`);
557
+ }
558
+ if (minScoreAverage !== void 0) {
559
+ parts.push(`avg score floor ${formatScore(minScoreAverage)}`);
560
+ }
561
+ return parts.length === 0 ? "" : `; ${parts.join(", ")}`;
562
+ }
563
+
393
564
  // src/summary.ts
394
565
  var DEFAULT_MAX_FAILURES = 20;
395
566
  var DEFAULT_MAX_REASON_CHARS = 8e3;
@@ -410,20 +581,22 @@ function renderJobSummary(report, options = {}) {
410
581
  const lines = [
411
582
  "# vitest-evals",
412
583
  "",
413
- ...renderSummaryTable(report, nonEvalFailures),
584
+ ...renderSummaryTable(report, nonEvalFailures, options.gate),
414
585
  "",
415
586
  ...renderScoreDistribution(report),
416
587
  "## Results",
417
588
  ""
418
589
  ];
419
590
  if (report.failures.length > 0) {
420
- lines.push("### Failures", "");
591
+ const failureHeading = options.gate?.ok === true ? "### Quality Misses" : "### Failures";
592
+ lines.push(failureHeading, "");
421
593
  failures.forEach((testCase, index) => {
422
594
  lines.push(...renderFailureDetails(testCase, index + 1, options), "");
423
595
  });
424
596
  if (report.failures.length > failures.length) {
597
+ const omittedLabel = options.gate?.ok === true ? "quality misses" : "failures";
425
598
  lines.push(
426
- `${report.failures.length - failures.length} more failures omitted from this summary.`,
599
+ `${report.failures.length - failures.length} more ${omittedLabel} omitted from this summary.`,
427
600
  ""
428
601
  );
429
602
  }
@@ -439,9 +612,9 @@ function renderJobSummary(report, options = {}) {
439
612
  function formatCountLine(passed, failed, total) {
440
613
  return `${formatNumber(passed)} passed, ${formatNumber(failed)} failed, ${formatNumber(total)} total`;
441
614
  }
442
- function renderSummaryTable(report, nonEvalFailures) {
615
+ function renderSummaryTable(report, nonEvalFailures, gate) {
443
616
  const rows = [
444
- ["Status", report.status],
617
+ ["Status", gate?.status ?? report.status],
445
618
  [
446
619
  "Evals",
447
620
  formatCountLine(
@@ -451,9 +624,20 @@ function renderSummaryTable(report, nonEvalFailures) {
451
624
  )
452
625
  ]
453
626
  ];
627
+ if (gate?.passRate !== void 0 && gate.passRate !== null) {
628
+ rows.push(["Pass Rate", formatPercent(gate.passRate)]);
629
+ } else if (report.totals.evalTotal > 0) {
630
+ rows.push([
631
+ "Pass Rate",
632
+ formatPercent(report.totals.evalPassed / report.totals.evalTotal)
633
+ ]);
634
+ }
454
635
  if (report.score) {
455
636
  rows.push(["Score", formatScoreSummary(report.score)]);
456
637
  }
638
+ if (gate?.enforced) {
639
+ rows.push(["Gate", gate.message]);
640
+ }
457
641
  if (nonEvalFailures > 0) {
458
642
  rows.push([
459
643
  "Other Failures",
@@ -730,23 +914,25 @@ async function publishCheckRun(report, options = {}) {
730
914
  };
731
915
  }
732
916
  function buildCheckRunPayload(report, options) {
917
+ const gate = options.gate ?? evaluateEvalGate(report);
733
918
  const annotations = buildCheckAnnotations(report, {
734
- maxAnnotations: options.maxAnnotations
919
+ maxAnnotations: options.maxAnnotations,
920
+ gate
735
921
  });
736
- 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"}`;
737
922
  return {
738
923
  status: "completed",
739
- conclusion: report.status === "passed" ? "success" : "failure",
924
+ conclusion: gate.ok ? "success" : "failure",
740
925
  completed_at: (/* @__PURE__ */ new Date()).toISOString(),
741
926
  output: {
742
- title,
927
+ title: gate.title,
743
928
  summary: truncateCheckSummary(
744
929
  renderJobSummary(report, {
745
930
  ...options,
746
931
  maxFailures: options.maxFailures ?? 5,
747
932
  maxReasonChars: options.maxReasonChars ?? 4e3,
748
933
  maxOutputChars: options.maxOutputChars ?? 2e3,
749
- maxToolCalls: options.maxToolCalls ?? 10
934
+ maxToolCalls: options.maxToolCalls ?? 10,
935
+ gate
750
936
  })
751
937
  ),
752
938
  annotations
@@ -759,11 +945,173 @@ function truncateCheckSummary(summary) {
759
945
  }
760
946
  return `${summary.slice(0, MAX_CHECK_SUMMARY_LENGTH - CHECK_SUMMARY_TRUNCATION_SUFFIX.length).trimEnd()}${CHECK_SUMMARY_TRUNCATION_SUFFIX}`;
761
947
  }
948
+
949
+ // src/report.ts
950
+ var import_promises = require("fs/promises");
951
+ var import_node = require("@vitest-evals/core/node");
952
+
953
+ // src/merge.ts
954
+ function mergeEvalReports(reports) {
955
+ const cases = reports.flatMap((report) => report.cases);
956
+ const failures = reports.flatMap((report) => report.failures);
957
+ const scoredCases = cases.map((testCase) => testCase.eval?.avgScore).filter(
958
+ (score) => typeof score === "number" && Number.isFinite(score)
959
+ );
960
+ const startedAtValues = reports.map((report) => report.startedAt).filter(
961
+ (startedAt2) => typeof startedAt2 === "number" && Number.isFinite(startedAt2)
962
+ );
963
+ const startedAt = startedAtValues.length > 0 ? Math.min(...startedAtValues) : void 0;
964
+ return {
965
+ status: reports.some((report) => report.status === "failed") ? "failed" : "passed",
966
+ startedAt,
967
+ durationMs: mergeDuration(reports),
968
+ totals: {
969
+ total: sum(reports, (report) => report.totals.total),
970
+ passed: sum(reports, (report) => report.totals.passed),
971
+ failed: sum(reports, (report) => report.totals.failed),
972
+ skipped: sum(reports, (report) => report.totals.skipped),
973
+ evalTotal: sum(reports, (report) => report.totals.evalTotal),
974
+ evalPassed: sum(reports, (report) => report.totals.evalPassed),
975
+ evalFailed: sum(reports, (report) => report.totals.evalFailed)
976
+ },
977
+ score: scoredCases.length > 0 ? {
978
+ average: scoredCases.reduce((total, score) => total + score, 0) / scoredCases.length,
979
+ minimum: Math.min(...scoredCases)
980
+ } : void 0,
981
+ usage: mergeUsage(reports.map((report) => report.usage)),
982
+ cases,
983
+ failures
984
+ };
985
+ }
986
+ function mergeUsage(usages) {
987
+ return {
988
+ inputTokens: sum(usages, (usage) => usage.inputTokens),
989
+ outputTokens: sum(usages, (usage) => usage.outputTokens),
990
+ reasoningTokens: sum(usages, (usage) => usage.reasoningTokens),
991
+ totalTokens: sum(usages, (usage) => usage.totalTokens),
992
+ toolCalls: sum(usages, (usage) => usage.toolCalls)
993
+ };
994
+ }
995
+ function mergeDuration(reports) {
996
+ const durations = reports.map((report) => report.durationMs).filter(
997
+ (durationMs) => typeof durationMs === "number" && Number.isFinite(durationMs)
998
+ );
999
+ const intervals = reports.map((report) => {
1000
+ if (typeof report.startedAt !== "number" || !Number.isFinite(report.startedAt) || typeof report.durationMs !== "number" || !Number.isFinite(report.durationMs)) {
1001
+ return void 0;
1002
+ }
1003
+ return {
1004
+ start: report.startedAt,
1005
+ end: report.startedAt + report.durationMs
1006
+ };
1007
+ }).filter(
1008
+ (interval) => Boolean(interval)
1009
+ );
1010
+ if (intervals.length > 0 && intervals.length === durations.length) {
1011
+ return Math.max(...intervals.map((interval) => interval.end)) - Math.min(...intervals.map((interval) => interval.start));
1012
+ }
1013
+ return durations.length > 0 ? durations.reduce((total, durationMs) => total + durationMs, 0) : void 0;
1014
+ }
1015
+ function sum(items, select) {
1016
+ return items.reduce((total, item) => total + select(item), 0);
1017
+ }
1018
+
1019
+ // src/report.ts
1020
+ async function publishEvalReport(options) {
1021
+ const resultFiles = await (0, import_node.resolveResultFiles)(options.resultPatterns, {
1022
+ cwd: options.cwd
1023
+ });
1024
+ if (resultFiles.length === 0) {
1025
+ throw new Error(
1026
+ `No eval result files matched: ${options.resultPatterns.join(", ")}`
1027
+ );
1028
+ }
1029
+ const reports = await Promise.all(
1030
+ resultFiles.map(async (resultFile) => {
1031
+ const json = await (0, import_node.readVitestJsonReportFile)(resultFile);
1032
+ return collectEvalReport(json, {
1033
+ workspace: options.workspace
1034
+ });
1035
+ })
1036
+ );
1037
+ const report = mergeEvalReports(reports);
1038
+ const gate = evaluateEvalGate(report, {
1039
+ failOnFailures: options.failOnFailures,
1040
+ minPassRate: options.minPassRate,
1041
+ minScoreAverage: options.minScoreAverage
1042
+ });
1043
+ const summary = renderJobSummary(report, {
1044
+ maxFailures: options.maxFailures,
1045
+ maxOutputChars: options.maxOutputChars,
1046
+ maxReasonChars: options.maxReasonChars,
1047
+ maxToolCalls: options.maxToolCalls,
1048
+ gate
1049
+ });
1050
+ if (options.summaryEnabled !== false) {
1051
+ if (options.summaryPath) {
1052
+ await (0, import_promises.appendFile)(options.summaryPath, `${summary}
1053
+ `);
1054
+ } else {
1055
+ console.log(summary);
1056
+ }
1057
+ }
1058
+ if (options.annotations) {
1059
+ const gateCommand = renderGateWorkflowCommand(gate);
1060
+ if (gateCommand) {
1061
+ console.log(gateCommand);
1062
+ }
1063
+ for (const command of renderWorkflowCommands(report, {
1064
+ maxAnnotations: options.maxAnnotations,
1065
+ gate
1066
+ })) {
1067
+ console.log(command);
1068
+ }
1069
+ }
1070
+ let checkRun;
1071
+ if (options.checkRun) {
1072
+ try {
1073
+ checkRun = await publishCheckRun(report, {
1074
+ checkRunId: options.checkRunId,
1075
+ maxAnnotations: options.maxAnnotations,
1076
+ maxFailures: options.maxFailures,
1077
+ maxOutputChars: options.maxOutputChars,
1078
+ maxReasonChars: options.maxReasonChars,
1079
+ maxToolCalls: options.maxToolCalls,
1080
+ name: options.checkName,
1081
+ repository: options.repository,
1082
+ sha: options.sha,
1083
+ token: options.token,
1084
+ gate
1085
+ });
1086
+ if (checkRun.status === "skipped") {
1087
+ options.warn?.(`GitHub Check Run skipped: ${checkRun.reason}`);
1088
+ }
1089
+ } catch (error) {
1090
+ const message = error instanceof Error ? error.message : String(error);
1091
+ if (options.failOnCheckError) {
1092
+ throw error;
1093
+ }
1094
+ options.warn?.(message);
1095
+ }
1096
+ }
1097
+ return {
1098
+ report,
1099
+ resultFiles,
1100
+ gate,
1101
+ shouldFail: gate.enforced && !gate.ok,
1102
+ checkRun
1103
+ };
1104
+ }
762
1105
  // Annotate the CommonJS export names for ESM import in node:
763
1106
  0 && (module.exports = {
764
1107
  buildCheckAnnotations,
765
1108
  collectEvalReport,
1109
+ computePassRate,
1110
+ evaluateEvalGate,
1111
+ formatPercent,
766
1112
  publishCheckRun,
1113
+ publishEvalReport,
1114
+ renderGateWorkflowCommand,
767
1115
  renderJobSummary,
768
1116
  renderWorkflowCommands
769
1117
  });