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
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The published record's own invariants.
3
+ *
4
+ * `reports/README.md` states the rule: every run is `<page>-<set>-<judge>-<shape>`,
5
+ * and "`.json` is the machine record — same numbers, plus the per-tool outcome
6
+ * counts, the coverage diff and the gate verdict."
7
+ *
8
+ * That rule was written down and not enforced, and on 2026-09-05 a reviewer with
9
+ * no repository access found the gap by reading the draft alone: the Edge
10
+ * second-client run — the newest and most quotable result in the folder — had a
11
+ * write-up and no machine record. Its `report.json` had sat in `artifacts/edge-s1/`
12
+ * since 2026-09-03 and was never copied. Nothing failed, because nothing checked.
13
+ *
14
+ * So the rule is a test now. It is deliberately narrow: it does not validate the
15
+ * contents of a report (`emit` and `gate` own that), only that the pair exists and
16
+ * that a machine record is the schema it claims to be. A published rate whose
17
+ * numbers cannot be re-read by a machine is an anecdote with a table.
18
+ */
19
+ import { test } from 'node:test';
20
+ import assert from 'node:assert/strict';
21
+ import { readdir, readFile } from 'node:fs/promises';
22
+ import { fileURLToPath } from 'node:url';
23
+ import { dirname, resolve } from 'node:path';
24
+
25
+ const reportsDir = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'reports');
26
+
27
+ // The npm package ships without `reports/` — it carries the harness, not the
28
+ // published record. This test is about this *repository's* folder discipline,
29
+ // so on a package install there is nothing for it to check and it says so
30
+ // rather than failing.
31
+ const reportsStat = await readdir(reportsDir).then(
32
+ () => true,
33
+ () => false
34
+ );
35
+ const suite = reportsStat ? test : test.skip;
36
+
37
+ /**
38
+ * A run file names its four coordinates and its shape. Write-ups (`README`,
39
+ * `compatibility-matrix`, `spacing-2026-09-01`, …) are prose about runs and carry
40
+ * no machine record, so they are matched out by the shape suffix rather than by a
41
+ * list of exceptions that someone would have to remember to extend.
42
+ */
43
+ const RUN_FILE = /^[a-z0-9-]+-\d+\.\d+\.\d+-[a-z0-9.-]+-(s\d+r\d+|r\d+)(-[a-z0-9-]+)?\.md$/;
44
+
45
+ const listReports = async () => (await readdir(reportsDir)).sort();
46
+
47
+ suite('every published run write-up has its machine record beside it', async () => {
48
+ const files = await listReports();
49
+ const runs = files.filter((name) => RUN_FILE.test(name));
50
+
51
+ // If this ever reads zero, the pattern has drifted away from the naming
52
+ // convention and the test would pass by measuring nothing.
53
+ assert.ok(runs.length >= 13, `expected at least 13 run write-ups, found ${runs.length}`);
54
+
55
+ const missing = runs.filter((name) => !files.includes(name.replace(/\.md$/, '.json')));
56
+ assert.deepEqual(missing, [], `run write-ups with no .json machine record: ${missing.join(', ')}`);
57
+ });
58
+
59
+ suite('every machine record is a report of the schema it claims', async () => {
60
+ const files = await listReports();
61
+ const records = files.filter(
62
+ (name) => name.endsWith('.json') && RUN_FILE.test(name.replace(/\.json$/, '.md'))
63
+ );
64
+ assert.ok(records.length >= 13, `expected at least 13 machine records, found ${records.length}`);
65
+
66
+ for (const name of records) {
67
+ const record = JSON.parse(await readFile(`${reportsDir}/${name}`, 'utf8'));
68
+ assert.match(record.schema ?? '', /^webmcp-gauge\/report\/\d+$/, `${name} carries no report schema`);
69
+ assert.ok(record.stamps?.judge?.model, `${name} does not stamp its judge model`);
70
+ assert.ok(record.stamps?.utteranceSet?.version, `${name} does not stamp its utterance set version`);
71
+ // The coverage block is what stops an incomplete run reading as a rate; it
72
+ // arrived in schema 3, so older records are allowed to lack it.
73
+ if (record.schema === 'webmcp-gauge/report/3') {
74
+ assert.equal(typeof record.coverage?.expectedTrials, 'number', `${name} has no coverage block`);
75
+ }
76
+ }
77
+ });
@@ -0,0 +1,157 @@
1
+ /**
2
+ * A private scorecard for one captured project.
3
+ *
4
+ * Item 13 in the project log. The cohort snapshot already holds everything this
5
+ * needs — the page's manifest verbatim, the agent-visible view, and the liveness
6
+ * of the URL — and `core/lint.mjs` already knows what is wrong with a manifest.
7
+ * What did not exist was the rendering, so this is that and nothing more: it
8
+ * writes Markdown, it sends nothing, and the decision to hand one to anybody is
9
+ * still a gate in the log rather than a function call here.
10
+ *
11
+ * Publication rules do not apply to a scorecard, and the distinction matters:
12
+ * `toPublishable` withholds a builder's descriptions because publishing them
13
+ * would republish their work to strangers. A scorecard goes **to that builder**,
14
+ * about **their own page**, so quoting their own text back is the entire point —
15
+ * a finding a reader cannot locate is not actionable.
16
+ *
17
+ * The tone rule encoded below: every finding names what a client or a model does
18
+ * differently because of it. "Description too short" is a fact about a string;
19
+ * "a model choosing between this and `filter_rows` has 14 characters to go on" is
20
+ * a reason to act. Findings the harness cannot justify that way are not shipped.
21
+ */
22
+ import { lintManifest } from '../core/lint.mjs';
23
+
24
+ const severityLabel = { error: 'Error', warning: 'Warning' };
25
+
26
+ /** Rules whose fix is not obvious from the finding alone get a sentence here. */
27
+ const REMEDY = Object.freeze({
28
+ 'name/invalid-characters': 'Rename using letters, digits, underscores or hyphens only.',
29
+ 'name/too-long': 'Shorten it; a name is an identifier, not a sentence.',
30
+ 'name/duplicate': 'Two tools cannot share a name — the second registration is the one an agent will not see.',
31
+ 'description/missing': 'Write one sentence saying what the tool does and when to use it.',
32
+ 'description/too-short': 'Say what it does *and* when to prefer it over its neighbours.',
33
+ 'description/duplicate': 'Two tools described alike are two tools an agent cannot choose between; make the difference explicit in both.',
34
+ 'description/near-duplicate': 'Make the distinguishing condition the first thing each description says.',
35
+ 'schema/missing': 'Declare an `inputSchema`, even an empty object — a client cannot validate what is not described.',
36
+ 'schema/no-properties': 'If the tool takes arguments, name them; if it takes none, say so with an empty `properties`.',
37
+ 'schema/untyped-property': 'Give each property a `type`; an untyped property is a guess at call time.',
38
+ 'schema/undocumented-property': 'Describe each property — the description is what a model fills it from.',
39
+ 'schema/required-not-declared': 'List required properties in `required`, or a client will send a call that cannot succeed.',
40
+ 'budget/too-many-tools': 'Consider grouping; a long manifest costs context on every turn.',
41
+ });
42
+
43
+ /**
44
+ * Everything the scorecard says, as data. Rendered separately so the numbers can
45
+ * be checked without parsing prose.
46
+ */
47
+ export const buildScorecard = (record, { lintOptions = {} } = {}) => {
48
+ const manifest = {
49
+ present: record?.webmcp?.apiPresent ?? null,
50
+ settled: record?.webmcp?.settled ?? null,
51
+ tools: record?.webmcp?.tools ?? [],
52
+ };
53
+ const lint = lintManifest({ manifest, options: lintOptions });
54
+
55
+ const byTool = new Map();
56
+ for (const finding of lint.findings) {
57
+ if (!byTool.has(finding.tool)) byTool.set(finding.tool, []);
58
+ byTool.get(finding.tool).push(finding);
59
+ }
60
+
61
+ // Errors first, then the tools carrying the most findings: a builder with ten
62
+ // minutes should spend them where the manifest is worst.
63
+ const priorities = [...byTool.entries()]
64
+ .map(([tool, findings]) => ({
65
+ tool,
66
+ findings,
67
+ errors: findings.filter((f) => f.severity === 'error').length,
68
+ }))
69
+ .sort((a, b) => b.errors - a.errors || b.findings.length - a.findings.length || a.tool.localeCompare(b.tool));
70
+
71
+ const agentCount = record?.webmcp?.agentToolCount ?? null;
72
+ const pageCount = record?.webmcp?.toolCount ?? 0;
73
+
74
+ return {
75
+ schema: 'webmcp-gauge/scorecard/1',
76
+ generatedAt: new Date().toISOString(),
77
+ project: record?.project ?? null,
78
+ url: record?.url ?? null,
79
+ repo: record?.repo ?? null,
80
+ capturedAt: record?.capturedAt ?? null,
81
+ liveness: record?.liveness ?? null,
82
+ tools: {
83
+ registeredByThePage: pageCount,
84
+ visibleToAnAgent: agentCount,
85
+ // Stated only when the two disagree, because when they agree it is noise —
86
+ // and when they disagree it is the most surprising line on the page.
87
+ divergence: record?.webmcp?.divergence?.onlyInBrowser?.length
88
+ ? {
89
+ onlyInBrowser: record.webmcp.divergence.onlyInBrowser,
90
+ thirdParty: record.webmcp.thirdPartyToolCount ?? null,
91
+ }
92
+ : null,
93
+ },
94
+ counts: lint.counts,
95
+ priorities,
96
+ lint,
97
+ };
98
+ };
99
+
100
+ export const scorecardToMarkdown = (card) => {
101
+ const lines = [];
102
+ const { counts, tools } = card;
103
+
104
+ lines.push(`# WebMCP scorecard — ${card.project}`);
105
+ lines.push('');
106
+ lines.push(`**Page:** ${card.url}`);
107
+ if (card.repo) lines.push(`**Repo:** ${card.repo}`);
108
+ lines.push(`**Captured:** ${card.capturedAt}`);
109
+ lines.push(
110
+ `**Result:** ${counts.error} error${counts.error === 1 ? '' : 's'}, ${counts.warning} warning${counts.warning === 1 ? '' : 's'} across ${tools.registeredByThePage} registered tool${tools.registeredByThePage === 1 ? '' : 's'}`
111
+ );
112
+ lines.push('');
113
+ lines.push(
114
+ 'This is a static reading of your tool manifest, taken from your live page. It is **not** an invocation rate — nothing here says how often an agent chooses your tools, only what a client and a model have to work with when they try.'
115
+ );
116
+ lines.push('');
117
+
118
+ if (tools.divergence) {
119
+ lines.push('## An agent sees tools your page cannot list');
120
+ lines.push('');
121
+ lines.push(
122
+ `Your page's \`getTools()\` returns **${tools.registeredByThePage}**, but the browser offers an agent **${tools.visibleToAnAgent}**: ${tools.divergence.onlyInBrowser.map((n) => `\`${n}\``).join(', ')}. On Chrome 152 that happens when a cross-origin embed is granted \`allow="tools"\` — its registrations reach the agent while appearing in no page's manifest. Worth knowing if you did not intend it, because you cannot enumerate it from script.`
123
+ );
124
+ lines.push('');
125
+ }
126
+
127
+ if (card.priorities.length === 0) {
128
+ lines.push('## Nothing to fix');
129
+ lines.push('');
130
+ lines.push('Every rule passed. The manifest is as legible as this linter knows how to check for.');
131
+ lines.push('');
132
+ } else {
133
+ lines.push('## What to fix, worst first');
134
+ lines.push('');
135
+ for (const { tool, findings } of card.priorities) {
136
+ lines.push(`### \`${tool}\``);
137
+ lines.push('');
138
+ for (const finding of findings) {
139
+ const remedy = REMEDY[finding.rule];
140
+ lines.push(`- **${severityLabel[finding.severity] ?? finding.severity}** · \`${finding.rule}\` — ${finding.detail}`);
141
+ if (remedy) lines.push(` - ${remedy}`);
142
+ }
143
+ lines.push('');
144
+ }
145
+ }
146
+
147
+ lines.push('## How this was measured');
148
+ lines.push('');
149
+ lines.push(
150
+ `Your page was loaded once in Chrome with WebMCP enabled, its manifest read after settling, and ${card.lint.families.length > 0 ? `the ${card.lint.families.join(', ')} rule families applied` : 'every rule applied'}. Thresholds: ${Object.entries(card.lint.thresholds).map(([k, v]) => `${k}=${v}`).join(', ')}. Nothing was submitted to your page and no tool was invoked.`
151
+ );
152
+ lines.push('');
153
+ lines.push('Findings are advisory. If a rule is wrong about your page, it is the rule that needs fixing.');
154
+ lines.push('');
155
+
156
+ return `${lines.join('\n')}`;
157
+ };
@@ -0,0 +1,130 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import { buildScorecard, scorecardToMarkdown } from './scorecard.mjs';
5
+
6
+ /** The shape a cohort capture produces, trimmed to what a scorecard reads. */
7
+ const record = ({ tools, agentTools = null, divergence = null, thirdParty = null }) => ({
8
+ project: 'Somebody Else\u2019s Project',
9
+ url: 'https://project.example/',
10
+ repo: 'https://github.com/them/project',
11
+ capturedAt: '2026-09-04T09:41:00.000Z',
12
+ liveness: { status: 200, reachable: true, redirected: false, finalUrl: 'https://project.example/' },
13
+ webmcp: {
14
+ apiPresent: true,
15
+ settled: true,
16
+ registered: tools.length > 0,
17
+ toolCount: tools.length,
18
+ tools,
19
+ agentTools,
20
+ agentToolCount: agentTools?.length ?? null,
21
+ divergence,
22
+ thirdPartyToolCount: thirdParty,
23
+ },
24
+ });
25
+
26
+ const goodTool = (name) => ({
27
+ name,
28
+ description: `Return the ${name.replace(/_/g, ' ')} for the requested period, so a caller can answer a question about it without reading the table.`,
29
+ inputSchema: { type: 'object', properties: { period: { type: 'string', description: 'Month or year to report on.' } } },
30
+ annotations: { readOnlyHint: true },
31
+ });
32
+
33
+ test('a clean manifest scores zero and says so without inventing advice', () => {
34
+ const card = buildScorecard(record({ tools: [goodTool('monthly_total')] }));
35
+
36
+ assert.equal(card.counts.error, 0);
37
+ assert.equal(card.counts.warning, 0);
38
+ assert.deepEqual(card.priorities, []);
39
+
40
+ const markdown = scorecardToMarkdown(card);
41
+ assert.match(markdown, /Nothing to fix/);
42
+ assert.ok(!/What to fix/.test(markdown));
43
+ });
44
+
45
+ test('a finding carries the rule id, the tool, and what to do about it', () => {
46
+ const card = buildScorecard(
47
+ record({ tools: [{ name: 'bad name', description: 'x', inputSchema: null }] })
48
+ );
49
+
50
+ assert.ok(card.counts.error > 0, 'an invalid name is an error');
51
+ const markdown = scorecardToMarkdown(card);
52
+ assert.match(markdown, /`name\/invalid-characters`/);
53
+ assert.match(markdown, /Rename using letters, digits, underscores or hyphens only\./);
54
+ assert.match(markdown, /`bad name`/, 'the offending tool is named so the finding can be located');
55
+ });
56
+
57
+ /**
58
+ * A scorecard goes to the page's own author, so quoting their descriptions back is
59
+ * the point — the opposite of `toPublishable`, which withholds them from strangers.
60
+ * This test exists to stop somebody "fixing" one to match the other.
61
+ */
62
+ test('a scorecard may quote the builder\u2019s own text, unlike a published row', () => {
63
+ const description = 'Totals spending per category and highlights one on request.';
64
+ const card = buildScorecard(
65
+ record({ tools: [{ name: 'sum_by_category', description, inputSchema: { type: 'object', properties: {} } }] })
66
+ );
67
+
68
+ const markdown = scorecardToMarkdown(card);
69
+ assert.match(markdown, /sum_by_category/);
70
+ assert.ok(
71
+ card.lint.manifest.names.includes('sum_by_category'),
72
+ 'the lint result keeps the real manifest, not a redacted one'
73
+ );
74
+ assert.ok(description.length > 0);
75
+ });
76
+
77
+ test('errors are prioritised above warnings, and busier tools above quieter ones', () => {
78
+ const card = buildScorecard(
79
+ record({
80
+ tools: [
81
+ { name: 'ok_tool', description: 'x', inputSchema: null },
82
+ { name: 'worse tool', description: '', inputSchema: null },
83
+ ],
84
+ })
85
+ );
86
+
87
+ assert.equal(card.priorities[0].tool, 'worse tool', 'the tool with an error comes first');
88
+ assert.ok(card.priorities[0].errors >= 1);
89
+ });
90
+
91
+ test('the divergence section appears only when the two views disagree', () => {
92
+ const quiet = scorecardToMarkdown(
93
+ buildScorecard(record({ tools: [goodTool('a')], agentTools: ['a'], divergence: { onlyInBrowser: [], onlyInPage: [] } }))
94
+ );
95
+ assert.ok(!/An agent sees tools/.test(quiet), 'agreement is not worth a section');
96
+
97
+ const loud = scorecardToMarkdown(
98
+ buildScorecard(
99
+ record({
100
+ tools: [goodTool('a')],
101
+ agentTools: ['a', 'embedded_pay'],
102
+ divergence: { onlyInBrowser: ['embedded_pay'], onlyInPage: [] },
103
+ thirdParty: 1,
104
+ })
105
+ )
106
+ );
107
+ assert.match(loud, /An agent sees tools your page cannot list/);
108
+ assert.match(loud, /`embedded_pay`/);
109
+ assert.match(loud, /allow="tools"/);
110
+ });
111
+
112
+ test('a scorecard never claims to be an invocation rate', () => {
113
+ const markdown = scorecardToMarkdown(buildScorecard(record({ tools: [goodTool('a')] })));
114
+ assert.match(markdown, /not\*\* an invocation rate/);
115
+ assert.match(markdown, /no tool was invoked/);
116
+ assert.ok(!/%\s*invocation/.test(markdown));
117
+ });
118
+
119
+ test('the method section states the thresholds the findings depend on', () => {
120
+ const markdown = scorecardToMarkdown(buildScorecard(record({ tools: [goodTool('a')] })));
121
+ assert.match(markdown, /minDescriptionChars=/);
122
+ assert.match(markdown, /nearDuplicateThreshold=/);
123
+ });
124
+
125
+ test('a page that registered nothing renders without a findings section', () => {
126
+ const card = buildScorecard(record({ tools: [] }));
127
+ assert.equal(card.tools.registeredByThePage, 0);
128
+ const markdown = scorecardToMarkdown(card);
129
+ assert.match(markdown, /0 errors, 0 warnings across 0 registered tools/);
130
+ });