@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.mjs CHANGED
@@ -269,9 +269,10 @@ var DEFAULT_MAX_CHECK_ANNOTATIONS = 50;
269
269
  var MAX_CHECK_FIELD_LENGTH = 64e3;
270
270
  function renderWorkflowCommands(report, options = {}) {
271
271
  const maxAnnotations = options.maxAnnotations ?? DEFAULT_MAX_WORKFLOW_ANNOTATIONS;
272
+ const command = caseAnnotationCommand(options.gate);
272
273
  return report.failures.filter(hasAnnotationLocation).slice(0, maxAnnotations).map(
273
274
  (testCase) => formatWorkflowCommand({
274
- command: "error",
275
+ command,
275
276
  properties: {
276
277
  file: testCase.displayFile,
277
278
  line: String(testCase.location.line),
@@ -287,11 +288,12 @@ function buildCheckAnnotations(report, options = {}) {
287
288
  options.maxAnnotations ?? DEFAULT_MAX_CHECK_ANNOTATIONS,
288
289
  DEFAULT_MAX_CHECK_ANNOTATIONS
289
290
  );
291
+ const annotationLevel = caseAnnotationLevel(options.gate);
290
292
  return report.failures.filter(hasAnnotationLocation).slice(0, maxAnnotations).map((testCase) => ({
291
293
  path: testCase.displayFile,
292
294
  start_line: testCase.location.line,
293
295
  end_line: testCase.location.line,
294
- annotation_level: "failure",
296
+ annotation_level: annotationLevel,
295
297
  title: truncate(
296
298
  `${testCase.primaryFailure?.judgeName ?? "vitest-evals"} - ${testCase.displayName}`,
297
299
  255
@@ -303,6 +305,12 @@ function buildCheckAnnotations(report, options = {}) {
303
305
  raw_details: truncate(formatRawDetails(testCase), MAX_CHECK_FIELD_LENGTH)
304
306
  }));
305
307
  }
308
+ function caseAnnotationCommand(gate) {
309
+ return gate?.ok ? "warning" : "error";
310
+ }
311
+ function caseAnnotationLevel(gate) {
312
+ return gate?.ok ? "warning" : "failure";
313
+ }
306
314
  function hasAnnotationLocation(testCase) {
307
315
  return Boolean(testCase.location);
308
316
  }
@@ -362,6 +370,164 @@ function formatWorkflowCommand({
362
370
  return `::${command} ${renderedProperties}::${escapeCommandData(message)}`;
363
371
  }
364
372
 
373
+ // src/gate.ts
374
+ function evaluateEvalGate(report, policy = {}) {
375
+ const minPassRate = resolveMinPassRate(policy);
376
+ const minScoreAverage = policy.minScoreAverage;
377
+ const enforced = minPassRate !== void 0 || minScoreAverage !== void 0 || policy.failOnFailures === true;
378
+ const passRate = computePassRate(report);
379
+ const counts = formatEvalCounts(report, passRate);
380
+ if (!enforced) {
381
+ const ok = report.status === "passed";
382
+ return {
383
+ ok,
384
+ status: ok ? "passed" : "failed",
385
+ enforced: false,
386
+ passRate,
387
+ title: defaultCheckTitle(report),
388
+ message: ok ? `eval report passed: ${counts}` : `eval report failed: ${counts}`
389
+ };
390
+ }
391
+ const nonEvalFailures = Math.max(
392
+ 0,
393
+ report.totals.failed - report.totals.evalFailed
394
+ );
395
+ if (nonEvalFailures > 0) {
396
+ return {
397
+ ok: false,
398
+ status: "failed",
399
+ enforced: true,
400
+ passRate,
401
+ title: "Eval report hard failure",
402
+ message: `${formatNumber(nonEvalFailures)} non-eval test failure${nonEvalFailures === 1 ? "" : "s"}; ${counts}`
403
+ };
404
+ }
405
+ if (report.totals.evalTotal === 0) {
406
+ return {
407
+ ok: false,
408
+ status: "failed",
409
+ enforced: true,
410
+ passRate: null,
411
+ title: "Eval report hard failure",
412
+ message: "no eval cases were reported"
413
+ };
414
+ }
415
+ if (report.status === "failed" && report.totals.failed === 0 && report.failures.length === 0) {
416
+ return {
417
+ ok: false,
418
+ status: "failed",
419
+ enforced: true,
420
+ passRate,
421
+ title: "Eval report hard failure",
422
+ message: `vitest run failed without counted test failures; ${counts}`
423
+ };
424
+ }
425
+ if (minPassRate !== void 0 && (passRate === null || passRate + Number.EPSILON < minPassRate)) {
426
+ return {
427
+ ok: false,
428
+ status: "failed",
429
+ enforced: true,
430
+ passRate,
431
+ title: `Eval pass rate ${formatPercent(passRate)} \u2014 required ${formatPercent(minPassRate)}`,
432
+ message: `eval pass rate below floor: ${counts}; required >= ${formatPercent(minPassRate)}`
433
+ };
434
+ }
435
+ if (minScoreAverage !== void 0) {
436
+ const average = report.score?.average;
437
+ if (average === void 0 || !Number.isFinite(average)) {
438
+ return {
439
+ ok: false,
440
+ status: "failed",
441
+ enforced: true,
442
+ passRate,
443
+ title: "Eval score gate failed",
444
+ message: `no score average available; required avg score >= ${formatScore(minScoreAverage)}`
445
+ };
446
+ }
447
+ if (average + Number.EPSILON < minScoreAverage) {
448
+ return {
449
+ ok: false,
450
+ status: "failed",
451
+ enforced: true,
452
+ passRate,
453
+ title: `Avg score ${formatScore(average)} \u2014 required ${formatScore(minScoreAverage)}`,
454
+ message: `avg score below floor: ${counts}; required avg score >= ${formatScore(minScoreAverage)}`
455
+ };
456
+ }
457
+ }
458
+ return {
459
+ ok: true,
460
+ status: "passed",
461
+ enforced: true,
462
+ passRate,
463
+ title: enforcedPassTitle(report, passRate, minPassRate, minScoreAverage),
464
+ message: `eval gate passed: ${counts}${formatFloorSuffix(minPassRate, minScoreAverage)}`
465
+ };
466
+ }
467
+ function computePassRate(report) {
468
+ if (report.totals.evalTotal <= 0) {
469
+ return null;
470
+ }
471
+ return report.totals.evalPassed / report.totals.evalTotal;
472
+ }
473
+ function formatPercent(value) {
474
+ if (value == null || !Number.isFinite(value)) {
475
+ return "n/a";
476
+ }
477
+ return `${(value * 100).toFixed(1)}%`;
478
+ }
479
+ function renderGateWorkflowCommand(gate) {
480
+ if (gate.ok || !gate.enforced) {
481
+ return void 0;
482
+ }
483
+ return `::error title=${escapeCommandProperty(gate.title)}::${escapeCommandData(gate.message)}`;
484
+ }
485
+ function resolveMinPassRate(policy) {
486
+ const configured = policy.minPassRate;
487
+ if (policy.failOnFailures) {
488
+ if (configured === void 0) {
489
+ return 1;
490
+ }
491
+ return Math.max(configured, 1);
492
+ }
493
+ return configured;
494
+ }
495
+ function defaultCheckTitle(report) {
496
+ if (report.failures.length === 0 && report.status === "passed") {
497
+ return "No eval failures";
498
+ }
499
+ if (report.failures.length === 0) {
500
+ return "Vitest run failed";
501
+ }
502
+ return `${report.failures.length} eval failure${report.failures.length === 1 ? "" : "s"}`;
503
+ }
504
+ function enforcedPassTitle(report, passRate, minPassRate, minScoreAverage) {
505
+ if (minPassRate !== void 0) {
506
+ return `Eval pass rate ${formatPercent(passRate)} \u2014 floor ${formatPercent(minPassRate)}`;
507
+ }
508
+ if (minScoreAverage !== void 0) {
509
+ return `Avg score ${formatScore(report.score?.average)} \u2014 floor ${formatScore(minScoreAverage)}`;
510
+ }
511
+ return defaultCheckTitle(report);
512
+ }
513
+ function formatEvalCounts(report, passRate) {
514
+ const scoreText = report.score?.average === void 0 ? "n/a" : formatScore(report.score.average);
515
+ const passRateText = passRate === null ? "n/a" : formatPercent(passRate);
516
+ return `${formatNumber(report.totals.evalPassed)}/${formatNumber(
517
+ report.totals.evalTotal
518
+ )} passed (${passRateText}), avg score ${scoreText}`;
519
+ }
520
+ function formatFloorSuffix(minPassRate, minScoreAverage) {
521
+ const parts = [];
522
+ if (minPassRate !== void 0) {
523
+ parts.push(`pass rate floor ${formatPercent(minPassRate)}`);
524
+ }
525
+ if (minScoreAverage !== void 0) {
526
+ parts.push(`avg score floor ${formatScore(minScoreAverage)}`);
527
+ }
528
+ return parts.length === 0 ? "" : `; ${parts.join(", ")}`;
529
+ }
530
+
365
531
  // src/summary.ts
366
532
  var DEFAULT_MAX_FAILURES = 20;
367
533
  var DEFAULT_MAX_REASON_CHARS = 8e3;
@@ -382,20 +548,22 @@ function renderJobSummary(report, options = {}) {
382
548
  const lines = [
383
549
  "# vitest-evals",
384
550
  "",
385
- ...renderSummaryTable(report, nonEvalFailures),
551
+ ...renderSummaryTable(report, nonEvalFailures, options.gate),
386
552
  "",
387
553
  ...renderScoreDistribution(report),
388
554
  "## Results",
389
555
  ""
390
556
  ];
391
557
  if (report.failures.length > 0) {
392
- lines.push("### Failures", "");
558
+ const failureHeading = options.gate?.ok === true ? "### Quality Misses" : "### Failures";
559
+ lines.push(failureHeading, "");
393
560
  failures.forEach((testCase, index) => {
394
561
  lines.push(...renderFailureDetails(testCase, index + 1, options), "");
395
562
  });
396
563
  if (report.failures.length > failures.length) {
564
+ const omittedLabel = options.gate?.ok === true ? "quality misses" : "failures";
397
565
  lines.push(
398
- `${report.failures.length - failures.length} more failures omitted from this summary.`,
566
+ `${report.failures.length - failures.length} more ${omittedLabel} omitted from this summary.`,
399
567
  ""
400
568
  );
401
569
  }
@@ -411,9 +579,9 @@ function renderJobSummary(report, options = {}) {
411
579
  function formatCountLine(passed, failed, total) {
412
580
  return `${formatNumber(passed)} passed, ${formatNumber(failed)} failed, ${formatNumber(total)} total`;
413
581
  }
414
- function renderSummaryTable(report, nonEvalFailures) {
582
+ function renderSummaryTable(report, nonEvalFailures, gate) {
415
583
  const rows = [
416
- ["Status", report.status],
584
+ ["Status", gate?.status ?? report.status],
417
585
  [
418
586
  "Evals",
419
587
  formatCountLine(
@@ -423,9 +591,20 @@ function renderSummaryTable(report, nonEvalFailures) {
423
591
  )
424
592
  ]
425
593
  ];
594
+ if (gate?.passRate !== void 0 && gate.passRate !== null) {
595
+ rows.push(["Pass Rate", formatPercent(gate.passRate)]);
596
+ } else if (report.totals.evalTotal > 0) {
597
+ rows.push([
598
+ "Pass Rate",
599
+ formatPercent(report.totals.evalPassed / report.totals.evalTotal)
600
+ ]);
601
+ }
426
602
  if (report.score) {
427
603
  rows.push(["Score", formatScoreSummary(report.score)]);
428
604
  }
605
+ if (gate?.enforced) {
606
+ rows.push(["Gate", gate.message]);
607
+ }
429
608
  if (nonEvalFailures > 0) {
430
609
  rows.push([
431
610
  "Other Failures",
@@ -702,23 +881,25 @@ async function publishCheckRun(report, options = {}) {
702
881
  };
703
882
  }
704
883
  function buildCheckRunPayload(report, options) {
884
+ const gate = options.gate ?? evaluateEvalGate(report);
705
885
  const annotations = buildCheckAnnotations(report, {
706
- maxAnnotations: options.maxAnnotations
886
+ maxAnnotations: options.maxAnnotations,
887
+ gate
707
888
  });
708
- 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"}`;
709
889
  return {
710
890
  status: "completed",
711
- conclusion: report.status === "passed" ? "success" : "failure",
891
+ conclusion: gate.ok ? "success" : "failure",
712
892
  completed_at: (/* @__PURE__ */ new Date()).toISOString(),
713
893
  output: {
714
- title,
894
+ title: gate.title,
715
895
  summary: truncateCheckSummary(
716
896
  renderJobSummary(report, {
717
897
  ...options,
718
898
  maxFailures: options.maxFailures ?? 5,
719
899
  maxReasonChars: options.maxReasonChars ?? 4e3,
720
900
  maxOutputChars: options.maxOutputChars ?? 2e3,
721
- maxToolCalls: options.maxToolCalls ?? 10
901
+ maxToolCalls: options.maxToolCalls ?? 10,
902
+ gate
722
903
  })
723
904
  ),
724
905
  annotations
@@ -731,10 +912,175 @@ function truncateCheckSummary(summary) {
731
912
  }
732
913
  return `${summary.slice(0, MAX_CHECK_SUMMARY_LENGTH - CHECK_SUMMARY_TRUNCATION_SUFFIX.length).trimEnd()}${CHECK_SUMMARY_TRUNCATION_SUFFIX}`;
733
914
  }
915
+
916
+ // src/report.ts
917
+ import { appendFile } from "fs/promises";
918
+ import {
919
+ readVitestJsonReportFile,
920
+ resolveResultFiles
921
+ } from "@vitest-evals/core/node";
922
+
923
+ // src/merge.ts
924
+ function mergeEvalReports(reports) {
925
+ const cases = reports.flatMap((report) => report.cases);
926
+ const failures = reports.flatMap((report) => report.failures);
927
+ const scoredCases = cases.map((testCase) => testCase.eval?.avgScore).filter(
928
+ (score) => typeof score === "number" && Number.isFinite(score)
929
+ );
930
+ const startedAtValues = reports.map((report) => report.startedAt).filter(
931
+ (startedAt2) => typeof startedAt2 === "number" && Number.isFinite(startedAt2)
932
+ );
933
+ const startedAt = startedAtValues.length > 0 ? Math.min(...startedAtValues) : void 0;
934
+ return {
935
+ status: reports.some((report) => report.status === "failed") ? "failed" : "passed",
936
+ startedAt,
937
+ durationMs: mergeDuration(reports),
938
+ totals: {
939
+ total: sum(reports, (report) => report.totals.total),
940
+ passed: sum(reports, (report) => report.totals.passed),
941
+ failed: sum(reports, (report) => report.totals.failed),
942
+ skipped: sum(reports, (report) => report.totals.skipped),
943
+ evalTotal: sum(reports, (report) => report.totals.evalTotal),
944
+ evalPassed: sum(reports, (report) => report.totals.evalPassed),
945
+ evalFailed: sum(reports, (report) => report.totals.evalFailed)
946
+ },
947
+ score: scoredCases.length > 0 ? {
948
+ average: scoredCases.reduce((total, score) => total + score, 0) / scoredCases.length,
949
+ minimum: Math.min(...scoredCases)
950
+ } : void 0,
951
+ usage: mergeUsage(reports.map((report) => report.usage)),
952
+ cases,
953
+ failures
954
+ };
955
+ }
956
+ function mergeUsage(usages) {
957
+ return {
958
+ inputTokens: sum(usages, (usage) => usage.inputTokens),
959
+ outputTokens: sum(usages, (usage) => usage.outputTokens),
960
+ reasoningTokens: sum(usages, (usage) => usage.reasoningTokens),
961
+ totalTokens: sum(usages, (usage) => usage.totalTokens),
962
+ toolCalls: sum(usages, (usage) => usage.toolCalls)
963
+ };
964
+ }
965
+ function mergeDuration(reports) {
966
+ const durations = reports.map((report) => report.durationMs).filter(
967
+ (durationMs) => typeof durationMs === "number" && Number.isFinite(durationMs)
968
+ );
969
+ const intervals = reports.map((report) => {
970
+ if (typeof report.startedAt !== "number" || !Number.isFinite(report.startedAt) || typeof report.durationMs !== "number" || !Number.isFinite(report.durationMs)) {
971
+ return void 0;
972
+ }
973
+ return {
974
+ start: report.startedAt,
975
+ end: report.startedAt + report.durationMs
976
+ };
977
+ }).filter(
978
+ (interval) => Boolean(interval)
979
+ );
980
+ if (intervals.length > 0 && intervals.length === durations.length) {
981
+ return Math.max(...intervals.map((interval) => interval.end)) - Math.min(...intervals.map((interval) => interval.start));
982
+ }
983
+ return durations.length > 0 ? durations.reduce((total, durationMs) => total + durationMs, 0) : void 0;
984
+ }
985
+ function sum(items, select) {
986
+ return items.reduce((total, item) => total + select(item), 0);
987
+ }
988
+
989
+ // src/report.ts
990
+ async function publishEvalReport(options) {
991
+ const resultFiles = await resolveResultFiles(options.resultPatterns, {
992
+ cwd: options.cwd
993
+ });
994
+ if (resultFiles.length === 0) {
995
+ throw new Error(
996
+ `No eval result files matched: ${options.resultPatterns.join(", ")}`
997
+ );
998
+ }
999
+ const reports = await Promise.all(
1000
+ resultFiles.map(async (resultFile) => {
1001
+ const json = await readVitestJsonReportFile(resultFile);
1002
+ return collectEvalReport(json, {
1003
+ workspace: options.workspace
1004
+ });
1005
+ })
1006
+ );
1007
+ const report = mergeEvalReports(reports);
1008
+ const gate = evaluateEvalGate(report, {
1009
+ failOnFailures: options.failOnFailures,
1010
+ minPassRate: options.minPassRate,
1011
+ minScoreAverage: options.minScoreAverage
1012
+ });
1013
+ const summary = renderJobSummary(report, {
1014
+ maxFailures: options.maxFailures,
1015
+ maxOutputChars: options.maxOutputChars,
1016
+ maxReasonChars: options.maxReasonChars,
1017
+ maxToolCalls: options.maxToolCalls,
1018
+ gate
1019
+ });
1020
+ if (options.summaryEnabled !== false) {
1021
+ if (options.summaryPath) {
1022
+ await appendFile(options.summaryPath, `${summary}
1023
+ `);
1024
+ } else {
1025
+ console.log(summary);
1026
+ }
1027
+ }
1028
+ if (options.annotations) {
1029
+ const gateCommand = renderGateWorkflowCommand(gate);
1030
+ if (gateCommand) {
1031
+ console.log(gateCommand);
1032
+ }
1033
+ for (const command of renderWorkflowCommands(report, {
1034
+ maxAnnotations: options.maxAnnotations,
1035
+ gate
1036
+ })) {
1037
+ console.log(command);
1038
+ }
1039
+ }
1040
+ let checkRun;
1041
+ if (options.checkRun) {
1042
+ try {
1043
+ checkRun = await publishCheckRun(report, {
1044
+ checkRunId: options.checkRunId,
1045
+ maxAnnotations: options.maxAnnotations,
1046
+ maxFailures: options.maxFailures,
1047
+ maxOutputChars: options.maxOutputChars,
1048
+ maxReasonChars: options.maxReasonChars,
1049
+ maxToolCalls: options.maxToolCalls,
1050
+ name: options.checkName,
1051
+ repository: options.repository,
1052
+ sha: options.sha,
1053
+ token: options.token,
1054
+ gate
1055
+ });
1056
+ if (checkRun.status === "skipped") {
1057
+ options.warn?.(`GitHub Check Run skipped: ${checkRun.reason}`);
1058
+ }
1059
+ } catch (error) {
1060
+ const message = error instanceof Error ? error.message : String(error);
1061
+ if (options.failOnCheckError) {
1062
+ throw error;
1063
+ }
1064
+ options.warn?.(message);
1065
+ }
1066
+ }
1067
+ return {
1068
+ report,
1069
+ resultFiles,
1070
+ gate,
1071
+ shouldFail: gate.enforced && !gate.ok,
1072
+ checkRun
1073
+ };
1074
+ }
734
1075
  export {
735
1076
  buildCheckAnnotations,
736
1077
  collectEvalReport,
1078
+ computePassRate,
1079
+ evaluateEvalGate,
1080
+ formatPercent,
737
1081
  publishCheckRun,
1082
+ publishEvalReport,
1083
+ renderGateWorkflowCommand,
738
1084
  renderJobSummary,
739
1085
  renderWorkflowCommands
740
1086
  };