webmcp-gauge 0.1.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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +121 -0
  3. package/action.yml +162 -0
  4. package/bin/webmcp-gauge.mjs +544 -0
  5. package/bin/webmcp-gauge.test.mjs +354 -0
  6. package/browser/launch.mjs +188 -0
  7. package/browser/serve.mjs +78 -0
  8. package/browser/session.mjs +210 -0
  9. package/browser/webmcp.mjs +432 -0
  10. package/browser/webmcp.test.mjs +299 -0
  11. package/core/args.mjs +93 -0
  12. package/core/args.test.mjs +85 -0
  13. package/core/capture-seam.test.mjs +86 -0
  14. package/core/cohort.mjs +432 -0
  15. package/core/cohort.test.mjs +370 -0
  16. package/core/gallery.mjs +145 -0
  17. package/core/gallery.test.mjs +128 -0
  18. package/core/gate.mjs +164 -0
  19. package/core/gate.test.mjs +213 -0
  20. package/core/lint.mjs +381 -0
  21. package/core/lint.test.mjs +346 -0
  22. package/core/orchestrate.mjs +128 -0
  23. package/core/orchestrate.test.mjs +191 -0
  24. package/core/stats.mjs +172 -0
  25. package/core/stats.test.mjs +156 -0
  26. package/core/sweep.mjs +274 -0
  27. package/core/sweep.test.mjs +162 -0
  28. package/core/taxonomy.mjs +175 -0
  29. package/core/taxonomy.test.mjs +198 -0
  30. package/core/trial.mjs +248 -0
  31. package/core/visibility.mjs +163 -0
  32. package/core/visibility.test.mjs +164 -0
  33. package/docs/concept.md +468 -0
  34. package/docs/explainer.md +161 -0
  35. package/docs/getting-started.md +331 -0
  36. package/fixtures/README.md +42 -0
  37. package/fixtures/airlock.utterances.json +284 -0
  38. package/fixtures/broken/compose.mjs +52 -0
  39. package/fixtures/broken/compose.test.mjs +270 -0
  40. package/fixtures/broken/sample-expenses.csv +966 -0
  41. package/fixtures/broken/tools.json +1311 -0
  42. package/fixtures/broken/twin.html +482 -0
  43. package/fixtures/broken/widget.html +62 -0
  44. package/fixtures/gallery/gallery.html +56 -0
  45. package/judges/openai-compatible.mjs +145 -0
  46. package/package.json +53 -0
  47. package/report/badge.mjs +110 -0
  48. package/report/badge.test.mjs +97 -0
  49. package/report/emit.mjs +282 -0
  50. package/report/published-runs.test.mjs +77 -0
  51. package/report/scorecard.mjs +157 -0
  52. package/report/scorecard.test.mjs +130 -0
package/core/gate.mjs ADDED
@@ -0,0 +1,164 @@
1
+ /**
2
+ * What an exit code is allowed to mean.
3
+ *
4
+ * A gate is only useful if `1` means one thing. Until now any harness failure
5
+ * exited 1, so "two trials need a --resume" and "the invocation rate fell off a
6
+ * cliff" produced the same signal, and a CI job could not tell a regression from
7
+ * a flaky provider. The codes are therefore split by what the run *knows*:
8
+ *
9
+ * 0 every planned trial was measured, and nothing gated fell below the threshold
10
+ * 1 every planned trial was measured, and a rate is below --fail-under
11
+ * 2 the run cannot answer: planned trials have no measurement, or bad usage
12
+ *
13
+ * Incomplete outranks breach deliberately. Gaps are not random - a judge outage or
14
+ * a page that never loaded can take out one tool's utterances and nothing else, so
15
+ * a rate computed over a run with holes is a rate over a denominator the run did
16
+ * not choose. Reporting that as a regression would be lying with a plausible
17
+ * number, and the whole point of the taxonomy is that a non-measurement is not an
18
+ * outcome.
19
+ *
20
+ * The threshold is compared against the **point rate**, not the Wilson lower
21
+ * bound. Gating on the bound was considered and rejected: 20 of 20 has a lower
22
+ * bound of 83.9%, so a page that never failed once would breach `--fail-under 0.9`
23
+ * on sample size alone. The interval is printed beside the rate instead, so a
24
+ * breach that sits inside the noise is visible to the person reading it.
25
+ */
26
+
27
+ export const EXIT = { pass: 0, breach: 1, incomplete: 2 };
28
+
29
+ const percent = (value) => `${(value * 100).toFixed(1)}%`;
30
+
31
+ /**
32
+ * `--fail-under 90` is the mistake this exists to catch: it parses as a number,
33
+ * compares against every rate, and fails the build forever. Rates are fractions
34
+ * here and nowhere else in the CLI, so the boundary check belongs at the boundary.
35
+ */
36
+ export const parseFailUnder = (raw) => {
37
+ if (raw === undefined || raw === null) return null;
38
+ if (raw === true) throw new RangeError('--fail-under needs a rate, e.g. --fail-under 0.9');
39
+
40
+ const value = Number(raw);
41
+ if (!Number.isFinite(value) || value < 0 || value > 1) {
42
+ const hint = Number.isFinite(value) && value > 1 && value <= 100 ? ` — use ${value / 100}, not ${value}` : '';
43
+ throw new RangeError(`--fail-under takes a rate between 0 and 1, not '${raw}'${hint}`);
44
+ }
45
+ return value;
46
+ };
47
+
48
+ const countKinds = (failures) => {
49
+ const kinds = {};
50
+ for (const failure of failures) {
51
+ const kind = failure.kind ?? 'unknown';
52
+ kinds[kind] = (kinds[kind] ?? 0) + 1;
53
+ }
54
+ return Object.entries(kinds)
55
+ .sort((a, b) => b[1] - a[1])
56
+ .map(([kind, count]) => `${count} ${kind}`)
57
+ .join(', ');
58
+ };
59
+
60
+ /**
61
+ * Decides one exit code for a finished run. Pure: it reads the report and returns
62
+ * a verdict, so the CI contract is testable without a browser or a judge.
63
+ */
64
+ export const gateRun = ({ report, failUnder = null }) => {
65
+ const threshold = parseFailUnder(failUnder);
66
+ const perTool = report.invocation?.perTool ?? [];
67
+ const outstandingFailures = report.harnessFailures ?? [];
68
+ const coverage = report.coverage ?? null;
69
+ const missingTrials = coverage?.missingTrials ?? 0;
70
+ const expectedTrials = coverage?.expectedTrials ?? null;
71
+
72
+ const coverageNote =
73
+ expectedTrials === null
74
+ ? 'coverage unknown'
75
+ : `${expectedTrials - missingTrials}/${expectedTrials} planned trials measured`;
76
+
77
+ const breaches = (threshold === null ? [] : perTool)
78
+ .filter((tool) => tool.invocation?.rate !== null && tool.invocation?.rate < threshold)
79
+ .map((tool) => ({
80
+ tool: tool.tool,
81
+ rate: tool.invocation.rate,
82
+ low: tool.invocation.low,
83
+ high: tool.invocation.high,
84
+ trials: tool.trials,
85
+ }))
86
+ .sort((a, b) => a.rate - b.rate);
87
+
88
+ const nothingToGate = threshold !== null && perTool.length === 0;
89
+
90
+ const verdict = {
91
+ failUnder: threshold,
92
+ gatedTools: threshold === null ? 0 : perTool.length,
93
+ missingTrials,
94
+ outstandingFailures: outstandingFailures.length,
95
+ breaches,
96
+ };
97
+
98
+ if (missingTrials > 0 || outstandingFailures.length > 0 || nothingToGate) {
99
+ const reasons = [];
100
+ if (missingTrials > 0) {
101
+ const withCause = outstandingFailures.length;
102
+ reasons.push(
103
+ `${missingTrials} of ${expectedTrials ?? '?'} planned trials produced no measurement` +
104
+ (withCause > 0 ? ` (${countKinds(outstandingFailures)})` : ' with no logged cause')
105
+ );
106
+ } else if (outstandingFailures.length > 0) {
107
+ reasons.push(
108
+ `${outstandingFailures.length} logged harness failure${outstandingFailures.length === 1 ? '' : 's'} outside the measured set (${countKinds(outstandingFailures)})`
109
+ );
110
+ }
111
+ if (nothingToGate) {
112
+ reasons.push(
113
+ `no tool trials were measured, so --fail-under ${percent(threshold)} cannot be evaluated`
114
+ );
115
+ }
116
+ return {
117
+ ...verdict,
118
+ code: EXIT.incomplete,
119
+ status: 'incomplete',
120
+ summary: `INCOMPLETE — ${reasons.join('; ')}. Re-run with --resume; this is not a threshold breach.`,
121
+ };
122
+ }
123
+
124
+ if (breaches.length > 0) {
125
+ const worst = breaches[0];
126
+ return {
127
+ ...verdict,
128
+ code: EXIT.breach,
129
+ status: 'breach',
130
+ summary:
131
+ `FAIL — ${breaches.length} of ${perTool.length} tool${perTool.length === 1 ? '' : 's'} below --fail-under ${percent(threshold)}: ` +
132
+ breaches
133
+ .map(
134
+ (breach) =>
135
+ `\`${breach.tool}\` ${percent(breach.rate)} [${percent(breach.low)}, ${percent(breach.high)}] over ${breach.trials} trials`
136
+ )
137
+ .join(', ') +
138
+ `. ${coverageNote}, so the number is the page's, not the harness's.` +
139
+ (worst.high >= threshold
140
+ ? ` Note ${worst.tool}'s interval still reaches ${percent(worst.high)}: the breach is inside the noise at this sample size.`
141
+ : ''),
142
+ };
143
+ }
144
+
145
+ if (threshold === null) {
146
+ return {
147
+ ...verdict,
148
+ code: EXIT.pass,
149
+ status: 'pass',
150
+ summary: `COMPLETE — ${coverageNote}, no --fail-under given, so nothing was gated.`,
151
+ };
152
+ }
153
+
154
+ const lowest = [...perTool].sort(
155
+ (a, b) => (a.invocation.rate ?? 1) - (b.invocation.rate ?? 1)
156
+ )[0];
157
+
158
+ return {
159
+ ...verdict,
160
+ code: EXIT.pass,
161
+ status: 'pass',
162
+ summary: `PASS — all ${perTool.length} tools at or above --fail-under ${percent(threshold)} (lowest \`${lowest.tool}\` ${percent(lowest.invocation.rate)}), ${coverageNote}.`,
163
+ };
164
+ };
@@ -0,0 +1,213 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { buildReport } from '../report/emit.mjs';
4
+ import { EXIT, gateRun, parseFailUnder } from './gate.mjs';
5
+
6
+ const fixture = { version: '1.3.0', frozen: true, authoring: { modelId: 'author-model' } };
7
+
8
+ /**
9
+ * Built through the real emitter rather than hand-shaped, so the gate is tested
10
+ * against the report contract it will actually be handed. A rename in emit.mjs
11
+ * should break these tests loudly instead of leaving the gate reading undefined.
12
+ */
13
+ const reportOf = ({ records = [], coverage = null, harnessFailures = [] } = {}) =>
14
+ buildReport({
15
+ fixture,
16
+ records,
17
+ harnessFailures,
18
+ coverage,
19
+ judge: { model: 'judge-model', baseUrl: 'https://example.invalid/v1' },
20
+ settings: { url: 'https://example.invalid/', sessions: 1, repeatsPerSession: 1, concurrency: 1 },
21
+ timing: { startedAt: '2026-08-30T00:00:00.000Z', finishedAt: '2026-08-30T00:01:00.000Z', elapsedMs: 60000 },
22
+ });
23
+
24
+ const toolTrials = ({ tool, ok, total, session = 1 }) =>
25
+ Array.from({ length: total }, (_, index) => ({
26
+ session,
27
+ repeat: 1,
28
+ kind: 'tool',
29
+ expectedTool: tool,
30
+ utteranceId: `${tool}-${String(index + 1).padStart(2, '0')}`,
31
+ tag: 'plain',
32
+ outcome: index < ok ? 'ok' : 'wrong_tool',
33
+ }));
34
+
35
+ const complete = (expectedTrials) => ({ expectedTrials, measuredTrials: expectedTrials, missingTrials: 0, missing: [] });
36
+
37
+ test('a complete run with every tool above the threshold exits 0', () => {
38
+ const verdict = gateRun({
39
+ report: reportOf({
40
+ records: [
41
+ ...toolTrials({ tool: 'top_expenses', ok: 20, total: 20 }),
42
+ ...toolTrials({ tool: 'filter_rows', ok: 19, total: 20 }),
43
+ ],
44
+ coverage: complete(40),
45
+ }),
46
+ failUnder: 0.9,
47
+ });
48
+
49
+ assert.equal(verdict.code, EXIT.pass);
50
+ assert.equal(verdict.status, 'pass');
51
+ assert.equal(verdict.gatedTools, 2);
52
+ assert.deepEqual(verdict.breaches, []);
53
+ assert.match(verdict.summary, /^PASS —/);
54
+ assert.match(verdict.summary, /40\/40 planned trials measured/);
55
+ });
56
+
57
+ test('a rate below --fail-under exits 1 and names the tool, its interval and its trial count', () => {
58
+ const verdict = gateRun({
59
+ report: reportOf({
60
+ records: [
61
+ ...toolTrials({ tool: 'top_expenses', ok: 20, total: 20 }),
62
+ ...toolTrials({ tool: 'sum_by_category', ok: 16, total: 20 }),
63
+ ],
64
+ coverage: complete(40),
65
+ }),
66
+ failUnder: 0.9,
67
+ });
68
+
69
+ assert.equal(verdict.code, EXIT.breach);
70
+ assert.equal(verdict.status, 'breach');
71
+ assert.equal(verdict.breaches.length, 1);
72
+ assert.equal(verdict.breaches[0].tool, 'sum_by_category');
73
+ assert.equal(verdict.breaches[0].trials, 20);
74
+ assert.match(verdict.summary, /^FAIL —/);
75
+ assert.match(verdict.summary, /sum_by_category` 80\.0% \[\d+\.\d%, \d+\.\d%\] over 20 trials/);
76
+ });
77
+
78
+ test('a breach whose interval still reaches the threshold says so, rather than implying certainty', () => {
79
+ // 17/20 = 85%, and the Wilson upper bound is above 90%, so the breach is inside
80
+ // the noise. Exit 1 is still correct - the measured rate is the measured rate -
81
+ // but a report that hid the overlap would invite a wrong conclusion.
82
+ const verdict = gateRun({
83
+ report: reportOf({ records: toolTrials({ tool: 'filter_rows', ok: 17, total: 20 }), coverage: complete(20) }),
84
+ failUnder: 0.9,
85
+ });
86
+
87
+ assert.equal(verdict.code, EXIT.breach);
88
+ assert.ok(verdict.breaches[0].high > 0.9, 'this fixture is only interesting if the interval overlaps');
89
+ assert.match(verdict.summary, /inside the noise at this sample size/);
90
+ });
91
+
92
+ test('the threshold is compared against the point rate, not the Wilson lower bound', () => {
93
+ // 20/20 has a lower bound of 83.9%. Gating on the bound would fail a page that
94
+ // never missed once, purely on sample size, so the point rate is what is gated.
95
+ const report = reportOf({
96
+ records: toolTrials({ tool: 'top_expenses', ok: 20, total: 20 }),
97
+ coverage: complete(20),
98
+ });
99
+ const [tool] = report.invocation.perTool;
100
+ assert.equal(tool.invocation.rate, 1);
101
+ assert.ok(
102
+ tool.invocation.low < 0.9,
103
+ `20/20 has a lower bound of ${tool.invocation.low}; the test is only meaningful while that is under the threshold`
104
+ );
105
+
106
+ assert.equal(gateRun({ report, failUnder: 0.9 }).code, EXIT.pass);
107
+ });
108
+
109
+ test('a rate exactly at the threshold passes: below the line is a breach, on it is not', () => {
110
+ const verdict = gateRun({
111
+ report: reportOf({ records: toolTrials({ tool: 'filter_rows', ok: 18, total: 20 }), coverage: complete(20) }),
112
+ failUnder: 0.9,
113
+ });
114
+
115
+ assert.equal(verdict.code, EXIT.pass);
116
+ });
117
+
118
+ test('planned trials with no measurement exit 2, however healthy the rates look', () => {
119
+ const verdict = gateRun({
120
+ report: reportOf({
121
+ records: toolTrials({ tool: 'top_expenses', ok: 18, total: 18 }),
122
+ coverage: { expectedTrials: 20, measuredTrials: 18, missingTrials: 2, missing: ['1:1:top_expenses-19', '1:1:top_expenses-20'] },
123
+ harnessFailures: [
124
+ { session: 1, repeat: 1, utteranceId: 'top_expenses-19', kind: 'judge_unavailable', error: 'fetch failed' },
125
+ { session: 1, repeat: 1, utteranceId: 'top_expenses-20', kind: 'trial_threw', error: 'timed out waiting for Page.loadEventFired' },
126
+ ],
127
+ }),
128
+ failUnder: 0.9,
129
+ });
130
+
131
+ assert.equal(verdict.code, EXIT.incomplete);
132
+ assert.equal(verdict.status, 'incomplete');
133
+ assert.equal(verdict.missingTrials, 2);
134
+ assert.match(verdict.summary, /2 of 20 planned trials produced no measurement/);
135
+ assert.match(verdict.summary, /judge_unavailable/);
136
+ assert.match(verdict.summary, /not a threshold breach/);
137
+ });
138
+
139
+ test('incomplete outranks a breach: a run with holes cannot certify a regression', () => {
140
+ const verdict = gateRun({
141
+ report: reportOf({
142
+ records: toolTrials({ tool: 'sum_by_category', ok: 10, total: 18 }),
143
+ coverage: { expectedTrials: 20, measuredTrials: 18, missingTrials: 2, missing: [] },
144
+ }),
145
+ failUnder: 0.9,
146
+ });
147
+
148
+ assert.equal(verdict.code, EXIT.incomplete);
149
+ // The breach is still recorded - it is the reason to re-run, not a number to publish.
150
+ assert.equal(verdict.breaches.length, 1);
151
+ assert.match(verdict.summary, /with no logged cause/);
152
+ });
153
+
154
+ test('missing trials exit 2 even with no threshold set — the old behaviour was to exit 1', () => {
155
+ const verdict = gateRun({
156
+ report: reportOf({
157
+ records: toolTrials({ tool: 'top_expenses', ok: 19, total: 19 }),
158
+ coverage: { expectedTrials: 20, measuredTrials: 19, missingTrials: 1, missing: ['1:1:top_expenses-20'] },
159
+ }),
160
+ });
161
+
162
+ assert.equal(verdict.code, EXIT.incomplete);
163
+ assert.equal(verdict.failUnder, null);
164
+ assert.equal(verdict.gatedTools, 0);
165
+ });
166
+
167
+ test('a complete run with no threshold exits 0 and says nothing was gated', () => {
168
+ const verdict = gateRun({
169
+ report: reportOf({ records: toolTrials({ tool: 'top_expenses', ok: 12, total: 20 }), coverage: complete(20) }),
170
+ });
171
+
172
+ assert.equal(verdict.code, EXIT.pass);
173
+ assert.match(verdict.summary, /no --fail-under given, so nothing was gated/);
174
+ assert.deepEqual(verdict.breaches, []);
175
+ });
176
+
177
+ test('an outstanding harness failure exits 2 even when coverage was not computed', () => {
178
+ const verdict = gateRun({
179
+ report: reportOf({
180
+ records: toolTrials({ tool: 'top_expenses', ok: 20, total: 20 }),
181
+ harnessFailures: [{ session: 1, repeat: 1, utteranceId: 'filter_rows-03', kind: 'judge_timeout', error: 'timeout' }],
182
+ }),
183
+ failUnder: 0.9,
184
+ });
185
+
186
+ assert.equal(verdict.code, EXIT.incomplete);
187
+ assert.match(verdict.summary, /outside the measured set \(1 judge_timeout\)/);
188
+ });
189
+
190
+ test('a threshold with no tool trials at all exits 2 rather than vacuously passing', () => {
191
+ const verdict = gateRun({ report: reportOf({ coverage: complete(0) }), failUnder: 0.9 });
192
+
193
+ assert.equal(verdict.code, EXIT.incomplete);
194
+ assert.match(verdict.summary, /cannot be evaluated/);
195
+ });
196
+
197
+ test('parseFailUnder accepts fractions and refuses percentages, which would fail every build', () => {
198
+ assert.equal(parseFailUnder(undefined), null);
199
+ assert.equal(parseFailUnder(null), null);
200
+ assert.equal(parseFailUnder('0.9'), 0.9);
201
+ assert.equal(parseFailUnder(0), 0);
202
+ assert.equal(parseFailUnder('1'), 1);
203
+
204
+ assert.throws(() => parseFailUnder('90'), /use 0\.9, not 90/);
205
+ assert.throws(() => parseFailUnder('101'), RangeError);
206
+ assert.throws(() => parseFailUnder('-0.1'), RangeError);
207
+ assert.throws(() => parseFailUnder('nine tenths'), RangeError);
208
+ assert.throws(() => parseFailUnder(true), /needs a rate/);
209
+ });
210
+
211
+ test('gateRun refuses a nonsensical threshold rather than gating on it', () => {
212
+ assert.throws(() => gateRun({ report: reportOf({ coverage: complete(0) }), failUnder: 90 }), RangeError);
213
+ });