@wix/pathgrade 1.0.26 → 1.0.28

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.
Files changed (73) hide show
  1. package/README.md +14 -21
  2. package/dist/adapters/jest/invocation-adapter.js +5 -1
  3. package/dist/adapters/jest/reporter.js +4 -1
  4. package/dist/adapters/jest/results.js +8 -0
  5. package/dist/adapters/node-test/index.d.ts +5 -0
  6. package/dist/adapters/node-test/index.js +35 -7
  7. package/dist/adapters/node-test/invocation-adapter.js +3 -1
  8. package/dist/adapters/node-test/runner-adapter.js +22 -15
  9. package/dist/adapters/vitest/reporter.js +4 -1
  10. package/dist/agents/claude/sdk-message-projector.js +5 -0
  11. package/dist/agents/codex-app-server/agent.js +7 -57
  12. package/dist/agents/codex-app-server/turn-notifications.d.ts +6 -0
  13. package/dist/agents/codex-app-server/turn-notifications.js +51 -0
  14. package/dist/agents/codex-app-server/turn-state.d.ts +19 -0
  15. package/dist/agents/codex-app-server/turn-state.js +1 -0
  16. package/dist/agents/codex.js +1 -0
  17. package/dist/agents/cursor.js +1 -0
  18. package/dist/agents/opencode/protocol.d.ts +7 -0
  19. package/dist/agents/opencode/protocol.js +47 -0
  20. package/dist/agents/opencode.js +5 -49
  21. package/dist/analytics/engine.js +5 -2
  22. package/dist/commands/report.d.ts +10 -2
  23. package/dist/commands/report.js +41 -6
  24. package/dist/commands/run-args.d.ts +1 -0
  25. package/dist/commands/run-args.js +13 -0
  26. package/dist/commands/run-changed.js +5 -15
  27. package/dist/config/pathgrade.d.ts +3 -0
  28. package/dist/config/pathgrade.js +33 -1
  29. package/dist/pathgrade.js +32 -3
  30. package/dist/reporters/cli.js +13 -6
  31. package/dist/reporters/github-comment.d.ts +12 -3
  32. package/dist/reporters/github-comment.js +92 -18
  33. package/dist/reporters/loader.d.ts +1 -0
  34. package/dist/reporters/loader.js +27 -2
  35. package/dist/reporters/report-summary.js +13 -5
  36. package/dist/reporting/artifacts.js +5 -2
  37. package/dist/reporting/core.d.ts +1 -0
  38. package/dist/reporting/core.js +183 -105
  39. package/dist/reporting/types.d.ts +19 -3
  40. package/dist/runners/adapter-loader.js +17 -12
  41. package/dist/runners/direct-reporter-attempts.d.ts +1 -0
  42. package/dist/runners/direct-reporter-attempts.js +7 -0
  43. package/dist/runners/invocation.d.ts +2 -0
  44. package/dist/runners/model-builders.js +1 -0
  45. package/dist/runners/model-validation.js +31 -0
  46. package/dist/runners/model.d.ts +4 -0
  47. package/dist/runners/orchestrator.d.ts +2 -0
  48. package/dist/runners/orchestrator.js +11 -1
  49. package/dist/runners/repeated-attempts.d.ts +7 -0
  50. package/dist/runners/repeated-attempts.js +149 -0
  51. package/dist/runners/repeated-invocation.d.ts +7 -0
  52. package/dist/runners/repeated-invocation.js +129 -0
  53. package/dist/runners/report-projection.js +16 -6
  54. package/dist/runners/vitest-adapter.js +10 -0
  55. package/dist/runners/vitest-invocation.js +2 -0
  56. package/dist/sdk/agent-runtime-options.d.ts +12 -0
  57. package/dist/sdk/agent-runtime-options.js +67 -0
  58. package/dist/sdk/agent.js +11 -59
  59. package/dist/sdk/case-context.js +7 -2
  60. package/dist/sdk/evaluate.d.ts +2 -0
  61. package/dist/sdk/evaluate.js +14 -9
  62. package/dist/sdk/index.d.ts +2 -0
  63. package/dist/sdk/index.js +2 -0
  64. package/dist/sdk/lifecycle.js +16 -6
  65. package/dist/sdk/result-capture.js +4 -1
  66. package/dist/sdk/types.d.ts +2 -0
  67. package/dist/tool-event-results.d.ts +1 -1
  68. package/dist/tool-event-results.js +2 -1
  69. package/dist/tool-events.d.ts +5 -0
  70. package/dist/tool-events.js +5 -0
  71. package/dist/types.d.ts +34 -5
  72. package/dist/viewer.html +19 -19
  73. package/package.json +2 -2
@@ -26,11 +26,24 @@ export function formatNoAffectedEvalsMarkdown(selection) {
26
26
  return lines.join('\n');
27
27
  }
28
28
  export function commentMarker(commentId) {
29
+ if (commentId.length === 0
30
+ || commentId.length > 128
31
+ || /[<>\r\n]/.test(commentId)
32
+ || commentId.includes('--')) {
33
+ throw new Error('Pathgrade comment id must be 1-128 characters and cannot contain HTML comment delimiters');
34
+ }
29
35
  return `<!-- pathgrade:${commentId} -->`;
30
36
  }
31
37
  function pct(n) {
32
38
  return `${(n * 100).toFixed(1)}%`;
33
39
  }
40
+ function formatPassAtK(value, reason) {
41
+ if (typeof value === 'number')
42
+ return `${pct(value)} (legacy v1)`;
43
+ if (value)
44
+ return Object.entries(value).map(([k, metric]) => `pass@${k} ${pct(metric)}`).join(', ');
45
+ return reason ? `Unavailable (${reason.replaceAll('_', ' ')})` : 'Unavailable';
46
+ }
34
47
  function durationSeconds(ms) {
35
48
  return `${(ms / 1000).toFixed(1)}s`;
36
49
  }
@@ -49,30 +62,36 @@ function escapeTableCell(value) {
49
62
  * posted as a GitHub issue comment body.
50
63
  */
51
64
  export function formatReportMarkdown(report, opts) {
52
- const totalTrials = report.groups.reduce((n, g) => n + g.trials.length, 0);
53
- const p = report.overall_pass_rate;
54
- const overallPassAtK = totalTrials > 0 ? 1 - Math.pow(1 - p, totalTrials) : 0;
55
- const overallPassPowK = totalTrials > 0 ? Math.pow(p, totalTrials) : 0;
65
+ const meanReward = report.overall_mean_reward ?? report.overall_pass_rate;
56
66
  const icon = report.status === 'pass' ? '✅' : '❌';
57
67
  const lines = [];
58
68
  lines.push(commentMarker(opts.commentId));
59
69
  lines.push('');
60
70
  lines.push(`### ${icon} Pathgrade report`);
61
71
  lines.push('');
62
- lines.push(`**Pass rate:** ${pct(p)} | ` +
63
- `**pass@${totalTrials}:** ${pct(overallPassAtK)} | ` +
64
- `**pass^${totalTrials}:** ${pct(overallPassPowK)}`);
72
+ lines.push(`**Last run:** \`${report.timestamp}\``);
73
+ lines.push('');
74
+ lines.push(`**Mean reward:** ${pct(meanReward)}` +
75
+ (report.attempts_requested ? ` | **Attempts:** ${report.attempts_completed ?? 0}/${report.attempts_requested}` : ''));
76
+ if (opts.detailsUrl) {
77
+ lines.push('');
78
+ lines.push(`**Details:** [Open the full report](${formatLinkDestination(opts.detailsUrl)})`);
79
+ }
80
+ if (opts.notice) {
81
+ lines.push('');
82
+ lines.push(`> ${opts.notice.replace(/[\r\n]+/g, ' ').trim()}`);
83
+ }
65
84
  if (report.threshold != null) {
66
85
  lines.push('');
67
86
  lines.push(`Threshold: ${pct(report.threshold)} — ${report.status.toUpperCase()}`);
68
87
  }
69
88
  lines.push('');
70
- lines.push('| Group | Pass rate | pass@k | pass^k | Skills | Avg duration |');
89
+ lines.push('| Group | Mean reward | Success rate | pass@k | Skills | Avg duration |');
71
90
  lines.push('|---|---|---|---|---|---|');
72
91
  for (const group of report.groups) {
73
92
  const skills = group.skills_used.length > 0 ? group.skills_used.join(', ') : '—';
74
93
  const avg = computeAvgDuration(group.trials);
75
- lines.push(`| ${escapeTableCell(group.task)} | ${pct(group.pass_rate)} | ${pct(group.pass_at_k)} | ${pct(group.pass_pow_k)} | ${escapeTableCell(skills)} | ${durationSeconds(avg)} |`);
94
+ lines.push(`| ${escapeTableCell(group.task)} | ${pct(group.mean_reward ?? group.pass_rate ?? 0)} | ${group.success_rate === undefined ? '—' : pct(group.success_rate)} | ${escapeTableCell(formatPassAtK(group.pass_at_k, group.pass_at_k_unavailable_reason))} | ${escapeTableCell(skills)} | ${durationSeconds(avg)} |`);
76
95
  }
77
96
  if (report.selection) {
78
97
  lines.push('');
@@ -100,14 +119,14 @@ export function formatSelectionSection(selection) {
100
119
  out.push('### Selection');
101
120
  const total = selection.selected.length + selection.skipped.length;
102
121
  if (selection.global_match) {
103
- out.push(`Ran **all ${total}** evals — global trigger \`${selection.global_match}\` matched.`);
122
+ out.push(`Selected **all ${total}** eval files — global trigger \`${selection.global_match}\` matched.`);
104
123
  return out.join('\n');
105
124
  }
106
125
  if (selection.skipped.length === 0 && selection.selected.length > 0) {
107
- out.push(`Ran **all ${total}** evals — every eval had a matching change.`);
126
+ out.push(`Selected **all ${total}** eval files — every eval had a matching change.`);
108
127
  return out.join('\n');
109
128
  }
110
- out.push(`Ran **${selection.selected.length} of ${total}** evals based on changes vs \`${selection.base_ref}\`.`);
129
+ out.push(`Selected **${selection.selected.length} of ${total}** eval files based on changes vs \`${selection.base_ref}\`.`);
111
130
  if (selection.skipped.length > 0) {
112
131
  out.push('');
113
132
  out.push(`<details><summary>${selection.skipped.length} skipped (unaffected)</summary>`);
@@ -171,13 +190,36 @@ function apiHeaders(token) {
171
190
  };
172
191
  }
173
192
  async function listPrComments(ctx) {
174
- const url = `https://api.github.com/repos/${ctx.owner}/${ctx.repo}/issues/${ctx.prNumber}/comments?per_page=100`;
193
+ const comments = [];
194
+ for (let page = 1; page <= 100; page += 1) {
195
+ const url = `https://api.github.com/repos/${ctx.owner}/${ctx.repo}/issues/${ctx.prNumber}/comments?per_page=100&page=${page}`;
196
+ const res = await fetch(url, { headers: apiHeaders(ctx.token) });
197
+ if (!res.ok) {
198
+ throw new Error(`GET ${url} returned ${res.status}`);
199
+ }
200
+ const data = await res.json();
201
+ if (!Array.isArray(data)) {
202
+ throw new Error(`GET ${url} returned a non-array response`);
203
+ }
204
+ for (const value of data) {
205
+ if (isGithubComment(value))
206
+ comments.push(value);
207
+ }
208
+ if (data.length < 100)
209
+ break;
210
+ }
211
+ return comments;
212
+ }
213
+ async function currentPrHeadSha(ctx) {
214
+ const url = `https://api.github.com/repos/${ctx.owner}/${ctx.repo}/pulls/${ctx.prNumber}`;
175
215
  const res = await fetch(url, { headers: apiHeaders(ctx.token) });
176
- if (!res.ok) {
216
+ if (!res.ok)
177
217
  throw new Error(`GET ${url} returned ${res.status}`);
218
+ const data = await res.json();
219
+ if (!isRecord(data) || !isRecord(data.head) || typeof data.head.sha !== 'string') {
220
+ throw new Error(`GET ${url} returned no pull-request head SHA`);
178
221
  }
179
- const data = (await res.json());
180
- return Array.isArray(data) ? data : [];
222
+ return data.head.sha;
181
223
  }
182
224
  async function createPrComment(ctx, body) {
183
225
  const url = `https://api.github.com/repos/${ctx.owner}/${ctx.repo}/issues/${ctx.prNumber}/comments`;
@@ -206,8 +248,8 @@ const GITHUB_COMMENT_MAX = 65536;
206
248
  const TRUNCATION_SENTINEL = '\n\n_…output truncated to fit GitHub\'s comment size limit — see the workflow artifacts for the full report._';
207
249
  /**
208
250
  * Find an existing PR comment carrying `<!-- pathgrade:${commentId} -->`
209
- * and update it; otherwise create a new one. Swallows all errors (logs
210
- * to stderr) `pathgrade report` must never fail CI.
251
+ * and update it; otherwise create a new one. Default mode logs and swallows
252
+ * transport errors; strict mode rethrows them for an owning orchestrator.
211
253
  *
212
254
  * Bodies longer than GitHub's 65,536-character limit are truncated with a
213
255
  * sentinel pointing to workflow artifacts, preserving the leading dedup
@@ -221,19 +263,51 @@ export async function postOrUpdateComment(ctx, opts) {
221
263
  : withMarker.slice(0, GITHUB_COMMENT_MAX - TRUNCATION_SENTINEL.length) +
222
264
  TRUNCATION_SENTINEL;
223
265
  try {
266
+ if (opts.expectedHeadSha) {
267
+ const currentHead = await currentPrHeadSha(ctx);
268
+ if (currentHead !== opts.expectedHeadSha)
269
+ return 'stale';
270
+ }
224
271
  const existing = await listPrComments(ctx);
225
272
  const match = existing.find((c) => typeof c.body === 'string' && c.body.includes(marker));
226
273
  if (match) {
227
274
  await updatePrComment(ctx, match.id, body);
275
+ return 'updated';
228
276
  }
229
277
  else {
230
278
  await createPrComment(ctx, body);
279
+ return 'created';
231
280
  }
232
281
  }
233
282
  catch (err) {
234
283
  const message = err instanceof Error ? err.message : String(err);
235
284
  console.error(`pathgrade report: failed to post PR comment — ${message}`);
285
+ if (opts.strict)
286
+ throw err;
287
+ }
288
+ }
289
+ function formatLinkDestination(value) {
290
+ let url;
291
+ try {
292
+ url = new URL(value);
236
293
  }
294
+ catch {
295
+ throw new Error('Pathgrade details URL must be an absolute HTTP(S) URL');
296
+ }
297
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
298
+ throw new Error('Pathgrade details URL must be an absolute HTTP(S) URL');
299
+ }
300
+ return url.toString().replace(/\)/g, '%29');
301
+ }
302
+ function isRecord(value) {
303
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
304
+ }
305
+ function isGithubComment(value) {
306
+ return isRecord(value)
307
+ && typeof value.id === 'number'
308
+ && Number.isSafeInteger(value.id)
309
+ && value.id > 0
310
+ && typeof value.body === 'string';
237
311
  }
238
312
  function formatGroupDetails(group) {
239
313
  const out = [];
@@ -2,6 +2,7 @@ import type { EvalReport } from '../types.js';
2
2
  export interface LoadedReport extends EvalReport {
3
3
  file: string;
4
4
  timestamp?: string;
5
+ status?: 'pass' | 'fail';
5
6
  }
6
7
  export declare function loadReports(resultsDir: string, opts?: {
7
8
  skipTraces?: boolean;
@@ -7,7 +7,13 @@ async function hydrateTraces(report, traceFile, resolved) {
7
7
  const traceTrials = await fs.readJSON(tracePath);
8
8
  if (!Array.isArray(traceTrials) || !Array.isArray(report.trials))
9
9
  return;
10
- report.trials = report.trials.map((t, i) => ({ ...t, ...traceTrials[i] }));
10
+ const tracesByAttemptId = new Map(traceTrials
11
+ .filter((trial) => typeof trial?.attempt_id === 'string')
12
+ .map((trial) => [trial.attempt_id, trial]));
13
+ report.trials = report.trials.map((trial, index) => ({
14
+ ...trial,
15
+ ...(trial.attempt_id ? tracesByAttemptId.get(trial.attempt_id) : traceTrials[index]),
16
+ }));
11
17
  }
12
18
  export async function loadReports(resultsDir, opts) {
13
19
  const resolved = path.resolve(resultsDir);
@@ -25,8 +31,15 @@ export async function loadReports(resultsDir, opts) {
25
31
  results.push({ file, ...raw });
26
32
  continue;
27
33
  }
34
+ if (raw.version !== 1 && raw.version !== 2)
35
+ continue;
28
36
  for (const group of raw.groups) {
29
- const report = { file, timestamp: raw.timestamp, ...group };
37
+ const report = {
38
+ file,
39
+ timestamp: raw.timestamp,
40
+ ...group,
41
+ status: groupStatus(raw, group),
42
+ };
30
43
  if (!opts?.skipTraces && group.trace_file) {
31
44
  await hydrateTraces(report, group.trace_file, resolved);
32
45
  }
@@ -37,3 +50,15 @@ export async function loadReports(resultsDir, opts) {
37
50
  }
38
51
  return results;
39
52
  }
53
+ function groupStatus(raw, group) {
54
+ if (group.status === 'pass' || group.status === 'fail')
55
+ return group.status;
56
+ const meanReward = group.mean_reward ?? group.pass_rate ?? 0;
57
+ if (typeof raw.threshold === 'number')
58
+ return meanReward >= raw.threshold ? 'pass' : 'fail';
59
+ const outcomes = Array.isArray(group.trials) ? group.trials.map((trial) => trial.runner_outcome) : [];
60
+ if (outcomes.length > 0 && outcomes.every((outcome) => (outcome === 'passed' || outcome === 'failed' || outcome === 'not-run'))) {
61
+ return outcomes.every((outcome) => outcome === 'passed') ? 'pass' : 'fail';
62
+ }
63
+ return meanReward >= 0.5 ? 'pass' : 'fail';
64
+ }
@@ -3,13 +3,21 @@ import { formatDiagnostics, formatDiagnosticsSummary } from './diagnostics.js';
3
3
  export function printReportSummary(groups, opts = {}) {
4
4
  console.log(`\n${fmt.bold('── pathgrade summary ')}${fmt.dim('─'.repeat(40))}\n`);
5
5
  for (const group of groups) {
6
- const prColor = group.pass_rate >= 0.5 ? fmt.green : fmt.red;
6
+ const rewardColor = group.mean_reward >= 0.5 ? fmt.green : fmt.red;
7
7
  console.log(` ${fmt.bold(group.task)}`);
8
- console.log(` ${fmt.dim('pass rate'.padEnd(12))} ${prColor((group.pass_rate * 100).toFixed(1) + '%')}`);
9
- console.log(` ${fmt.dim(`pass@${group.trial_count}`.padEnd(12))} ${(group.pass_at_k * 100).toFixed(1)}%`);
10
- console.log(` ${fmt.dim(`pass^${group.trial_count}`.padEnd(12))} ${(group.pass_pow_k * 100).toFixed(1)}%`);
8
+ console.log(` ${fmt.dim('mean reward'.padEnd(12))} ${rewardColor((group.mean_reward * 100).toFixed(1) + '%')}`);
9
+ console.log(` ${fmt.dim('success'.padEnd(12))} ${group.success_rate === undefined ? '—' : `${(group.success_rate * 100).toFixed(1)}%`}`);
10
+ if (group.pass_at_k) {
11
+ for (const [k, value] of Object.entries(group.pass_at_k)) {
12
+ console.log(` ${fmt.dim(`pass@${k}`.padEnd(12))} ${(value * 100).toFixed(1)}%`);
13
+ }
14
+ }
15
+ else {
16
+ const reason = group.pass_at_k_unavailable_reason?.replaceAll('_', ' ') ?? 'repeated binary attempts required';
17
+ console.log(` ${fmt.dim('pass@k'.padEnd(12))} — (${reason})`);
18
+ }
11
19
  console.log(` ${fmt.dim('avg time'.padEnd(12))} ${(group.average_duration_ms / 1000).toFixed(1)}s`);
12
- console.log(` ${fmt.dim('trials'.padEnd(12))} ${group.trial_count}`);
20
+ console.log(` ${fmt.dim('attempts'.padEnd(12))} ${group.trial_count}`);
13
21
  console.log();
14
22
  for (const diagnostic of group.diagnostics) {
15
23
  const shouldPrintFull = opts.forceVerbose
@@ -1,18 +1,21 @@
1
1
  import path from 'node:path';
2
2
  import fs from 'fs-extra';
3
+ import { collectSensitiveEnvValues, sanitizePersistenceValue, } from '../tool-event-results.js';
3
4
  export async function writePathgradeArtifacts(artifactRoot, built) {
4
5
  await fs.ensureDir(path.join(artifactRoot, 'traces'));
6
+ const sensitiveValues = collectSensitiveEnvValues(process.env);
7
+ const report = sanitizePersistenceValue(built.report, sensitiveValues);
5
8
  const gitignorePath = path.join(artifactRoot, '.gitignore');
6
9
  if (!(await fs.pathExists(gitignorePath))) {
7
10
  await fs.writeFile(gitignorePath, '*\n');
8
11
  }
9
12
  const traceFiles = [];
10
13
  for (const trace of built.traces) {
11
- await fs.writeJson(path.join(artifactRoot, trace.traceFile), trace.trials, { spaces: 2 });
14
+ await fs.writeJson(path.join(artifactRoot, trace.traceFile), sanitizePersistenceValue(trace.trials, sensitiveValues), { spaces: 2 });
12
15
  traceFiles.push(trace.traceFile);
13
16
  }
14
17
  const resultsPath = path.join(artifactRoot, 'results.json');
15
- await fs.writeJson(resultsPath, built.report, { spaces: 2 });
18
+ await fs.writeJson(resultsPath, report, { spaces: 2 });
16
19
  return {
17
20
  resultsPath,
18
21
  traceFiles,
@@ -1,2 +1,3 @@
1
1
  import type { PathgradeReportBuildResult, ReportRunInput } from './types.js';
2
2
  export declare function buildPathgradeReport(input: ReportRunInput): PathgradeReportBuildResult;
3
+ export declare function finiteSamplePassAtK(n: number, successes: number, k: number): number;