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,432 @@
1
+ /**
2
+ * The cohort snapshot: what gets captured, what gets published, and what is
3
+ * refused. Deliberately browser-free so it can be tested without one — the
4
+ * runner in probes/ owns the CDP work, this owns the rules.
5
+ *
6
+ * This module exists because the capture is a one-day, non-repeatable event
7
+ * (docs/concept.md milestone 4). A bug found on the day is a bug that costs the
8
+ * whole dataset, so the parts that can be decided in advance are decided here,
9
+ * under test, before the gallery opens.
10
+ *
11
+ * Publishing policy is enforced in code rather than remembered, per concept §12:
12
+ * derived metrics and links only, never another project's source or assets.
13
+ */
14
+
15
+ /** The UA suffix every request in a cohort capture must carry, per §12. */
16
+ export const HARNESS_UA_SUFFIX = 'webmcp-gauge/0.1 (+https://github.com/Svishwa2004/webmcp-gauge; measurement, contact via repo issues)';
17
+
18
+ /**
19
+ * The date a capture is filed under, in the operator's own timezone.
20
+ *
21
+ * `toISOString().slice(0, 10)` is UTC, and this machine runs at UTC+5:30: a
22
+ * capture started at 00:30 local on gallery-publish day would be filed under the
23
+ * *previous* date. For the one artifact whose entire claim is "taken on the day
24
+ * those URLs were simultaneously live", a date off by one is not cosmetic.
25
+ */
26
+ export const localDateStamp = (date = new Date()) => {
27
+ const pad = (n) => String(n).padStart(2, '0');
28
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
29
+ };
30
+
31
+ /**
32
+ * Accepts the messy shapes a URL list arrives in and returns one canonical row
33
+ * per project, or throws with the offending entry. A list assembled by hand on
34
+ * the day will contain duplicates and bare hostnames; both are cheaper to handle
35
+ * here than at 2 a.m.
36
+ */
37
+ export const normalizeTargets = (raw) => {
38
+ if (!Array.isArray(raw)) throw new TypeError('cohort targets must be an array');
39
+
40
+ const seen = new Map();
41
+ const rows = [];
42
+
43
+ for (const [index, entry] of raw.entries()) {
44
+ const source = typeof entry === 'string' ? { url: entry } : entry ?? {};
45
+ const rawUrl = String(source.url ?? '').trim();
46
+ if (!rawUrl) throw new TypeError(`target ${index} has no url`);
47
+
48
+ // A scheme we do not speak must be refused, not repaired. Prepending https
49
+ // to "ftp://host/x" yields a URL that parses, with host "ftp" — a target
50
+ // that would be captured as dead instead of reported as unusable.
51
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(rawUrl)?.[1]?.toLowerCase() ?? null;
52
+ if (scheme && scheme !== 'http' && scheme !== 'https') {
53
+ throw new TypeError(`target ${index} is not http(s): ${rawUrl}`);
54
+ }
55
+
56
+ const withScheme = scheme ? rawUrl : `https://${rawUrl}`;
57
+ let parsed;
58
+ try {
59
+ parsed = new URL(withScheme);
60
+ } catch {
61
+ throw new TypeError(`target ${index} is not a URL: ${rawUrl}`);
62
+ }
63
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
64
+ throw new TypeError(`target ${index} is not http(s): ${rawUrl}`);
65
+ }
66
+
67
+ // Same page reached by two spellings is one capture, not two visits to
68
+ // someone else's free hosting. Fragment and trailing slash are not identity.
69
+ parsed.hash = '';
70
+ const key = `${parsed.host}${parsed.pathname.replace(/\/$/, '')}${parsed.search}`;
71
+ if (seen.has(key)) {
72
+ rows[seen.get(key)].aliases.push(parsed.href);
73
+ continue;
74
+ }
75
+
76
+ seen.set(key, rows.length);
77
+ rows.push({
78
+ project: source.project ? String(source.project) : parsed.host,
79
+ url: parsed.href,
80
+ repo: source.repo ? String(source.repo) : null,
81
+ aliases: [],
82
+ });
83
+ }
84
+
85
+ return rows;
86
+ };
87
+
88
+ /**
89
+ * The URLs already captured in a snapshot file — the resume half of a runner
90
+ * whose capture cannot be repeated. Built the night of 2026-09-25/26, when a
91
+ * browser death and a machine sleep each stopped a run mid-corpus and the
92
+ * URL subtraction ran twice by hand. A torn or unparseable line is skipped
93
+ * rather than fatal: the file is append-only, so the worst a torn final line
94
+ * can hide is the one record it belongs to. The runner never writes a record
95
+ * for the target a browser death stopped on, so everything in the file is a
96
+ * completed capture and resuming skips exactly what is done.
97
+ */
98
+ export const capturedUrlsFrom = (snapshotText) => {
99
+ const urls = new Set();
100
+ if (typeof snapshotText !== 'string') return urls;
101
+ for (const line of snapshotText.split(/\r?\n/)) {
102
+ if (!line.trim()) continue;
103
+ try {
104
+ const record = JSON.parse(line);
105
+ if (typeof record?.url === 'string' && record.url !== '') urls.add(record.url);
106
+ } catch {
107
+ // Skipped, not fatal — see above.
108
+ }
109
+ }
110
+ return urls;
111
+ };
112
+
113
+ /**
114
+ * The targets a resumed run still owes, given the set already captured.
115
+ * Matching is by the canonical URL `toRecord` stored, which is exactly what
116
+ * `normalizeTargets` produced — no re-normalization, so a fragment alias
117
+ * cannot come back as a second visit.
118
+ */
119
+ export const remainingTargets = (targets, captured) => {
120
+ if (!Array.isArray(targets)) throw new TypeError('targets must be an array');
121
+ if (!(captured instanceof Set)) throw new TypeError('captured must be a Set of urls');
122
+ return targets.filter((target) => !captured.has(target.url));
123
+ };
124
+
125
+ /**
126
+ * Minimal robots.txt evaluation for one path and our own user-agent token.
127
+ *
128
+ * Honouring robots is a §12 commitment, and a snapshot that quietly ignored it
129
+ * would poison the dataset's provenance rather than just its manners. Scope is
130
+ * stated so nobody mistakes it for a full implementation: `User-agent`,
131
+ * `Disallow`, `Allow`, longest-match wins, `*` wildcards and `$` anchors. No
132
+ * crawl-delay (the runner rate-limits unconditionally instead), no sitemaps.
133
+ * An unfetchable or unparseable robots.txt is treated as permission, which is
134
+ * what the standard says and is worth saying out loud.
135
+ */
136
+ export const robotsAllows = (robotsTxt, path, agent = 'webmcp-gauge') => {
137
+ if (typeof robotsTxt !== 'string' || robotsTxt.trim() === '') return true;
138
+
139
+ const groups = [];
140
+ let current = null;
141
+ for (const line of robotsTxt.split(/\r?\n/)) {
142
+ const text = line.replace(/#.*$/, '').trim();
143
+ if (!text) continue;
144
+ const [rawField, ...rest] = text.split(':');
145
+ const field = rawField.trim().toLowerCase();
146
+ const value = rest.join(':').trim();
147
+
148
+ if (field === 'user-agent') {
149
+ // Consecutive User-agent lines share one rule block.
150
+ if (!current || current.rules.length > 0) {
151
+ current = { agents: [], rules: [] };
152
+ groups.push(current);
153
+ }
154
+ current.agents.push(value.toLowerCase());
155
+ } else if ((field === 'disallow' || field === 'allow') && current) {
156
+ current.rules.push({ allow: field === 'allow', pattern: value });
157
+ }
158
+ }
159
+
160
+ const lower = agent.toLowerCase();
161
+ const specific = groups.filter((g) => g.agents.some((a) => a !== '*' && lower.includes(a)));
162
+ const wildcard = groups.filter((g) => g.agents.includes('*'));
163
+ const applicable = specific.length > 0 ? specific : wildcard;
164
+ if (applicable.length === 0) return true;
165
+
166
+ const toRegExp = (pattern) => {
167
+ const anchored = pattern.endsWith('$');
168
+ const body = anchored ? pattern.slice(0, -1) : pattern;
169
+ const escaped = body.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
170
+ return new RegExp(`^${escaped}${anchored ? '$' : ''}`);
171
+ };
172
+
173
+ let verdict = true;
174
+ let strongest = -1;
175
+ for (const group of applicable) {
176
+ for (const rule of group.rules) {
177
+ // An empty Disallow means "allow everything" and matches nothing.
178
+ if (rule.pattern === '' && !rule.allow) continue;
179
+ if (!toRegExp(rule.pattern).test(path)) continue;
180
+ // Longest match wins; Allow beats Disallow at equal length.
181
+ if (rule.pattern.length > strongest || (rule.pattern.length === strongest && rule.allow)) {
182
+ strongest = rule.pattern.length;
183
+ verdict = rule.allow;
184
+ }
185
+ }
186
+ }
187
+ return verdict;
188
+ };
189
+
190
+ /**
191
+ * Attribute each browser-visible tool to the document that registered it.
192
+ *
193
+ * Decided 2026-09-02, before the capture, because the alternative is unrecoverable.
194
+ * A cross-origin embed with `allow="tools"` registers tools that reach the
195
+ * **browser** — and therefore an agent — while appearing in **nobody's**
196
+ * `getTools()`: not the host's, not the union of any script-visible surface. Two
197
+ * different claims then need two different denominators:
198
+ *
199
+ * - "this project shipped tools" is an **attribution** claim, and counting an
200
+ * embedded third party's tools would credit a builder with someone else's
201
+ * work. It uses the page's own view, restricted to the page's origin.
202
+ * - "an agent can call these tools here" is a **reality** claim, and the
203
+ * browser's view is the only one that answers it.
204
+ *
205
+ * So both are captured and each published number says which it used. Capturing
206
+ * one view is a permanent loss on a one-day capture; capturing both is not.
207
+ *
208
+ * `frameId` on each browser-view tool maps to a frame's origin; the top call frame
209
+ * of its `stackTrace` names the registering script, which is the fallback when a
210
+ * frame has gone by the time the tree is read.
211
+ */
212
+ export const attributeTools = (browserTools, frames = [], pageOrigin = null) => {
213
+ if (!Array.isArray(browserTools)) return null;
214
+
215
+ const originByFrame = new Map((frames ?? []).map((f) => [f.id, f.origin]));
216
+ const originOf = (url) => {
217
+ try {
218
+ return new URL(url).origin;
219
+ } catch {
220
+ return null;
221
+ }
222
+ };
223
+
224
+ return browserTools.map((tool) => {
225
+ const scriptUrl = tool?.stackTrace?.callFrames?.[0]?.url ?? null;
226
+ const origin = originByFrame.get(tool?.frameId) ?? originOf(scriptUrl) ?? null;
227
+ return {
228
+ name: tool?.name ?? null,
229
+ frameId: tool?.frameId ?? null,
230
+ origin,
231
+ scriptUrl,
232
+ // Unknown provenance is not "same origin". A tool whose origin could not be
233
+ // established must not be silently credited to the page.
234
+ sameOrigin: origin && pageOrigin ? origin === pageOrigin : null,
235
+ };
236
+ });
237
+ };
238
+
239
+ /**
240
+ * One captured project as it is stored locally: the full manifest, because the
241
+ * linter and every later analysis need the real text.
242
+ *
243
+ * The distinction between `apiPresent` and `registered` is load-bearing, and the
244
+ * dry run on 2026-09-01 is why it exists. `document.modelContext` is present on
245
+ * **every** page in a WebMCP-enabled browser — it is a browser API, not a page
246
+ * opt-in — so `example.com` and a 404 page both reported "WebMCP present". Had
247
+ * the census counted that, the dataset's headline claim would have been that
248
+ * most of the cohort uses WebMCP, when what was measured was that Chrome does.
249
+ * A project *uses* WebMCP when it registers at least one tool, and nothing else
250
+ * counts.
251
+ */
252
+ export const toRecord = ({
253
+ target,
254
+ capturedAt,
255
+ status,
256
+ finalUrl,
257
+ title,
258
+ manifest,
259
+ browserTools = null,
260
+ frames = [],
261
+ browserView = null,
262
+ error = null,
263
+ }) => {
264
+ const reachable = typeof status === 'number' && status < 400;
265
+ const tools =
266
+ manifest?.tools?.map((tool) => ({
267
+ name: tool.name ?? null,
268
+ description: tool.description ?? null,
269
+ inputSchema: tool.inputSchema ?? null,
270
+ inputSchemaWire: tool.inputSchemaWire ?? null,
271
+ annotations: tool.annotations ?? null,
272
+ })) ?? [];
273
+
274
+ const pageOrigin = (() => {
275
+ try {
276
+ return new URL(finalUrl ?? target.url).origin;
277
+ } catch {
278
+ return null;
279
+ }
280
+ })();
281
+
282
+ const attributed = reachable ? attributeTools(browserTools, frames, pageOrigin) : null;
283
+ const pageNames = tools.map((t) => t.name);
284
+ const agentNames = attributed ? attributed.map((t) => t.name) : null;
285
+
286
+ return {
287
+ project: target.project,
288
+ url: target.url,
289
+ repo: target.repo ?? null,
290
+ aliases: target.aliases ?? [],
291
+ capturedAt,
292
+ liveness: {
293
+ status: status ?? null,
294
+ finalUrl: finalUrl ?? null,
295
+ redirected: Boolean(finalUrl && finalUrl !== target.url),
296
+ reachable,
297
+ },
298
+ title: title ?? null,
299
+ webmcp: {
300
+ // A property of the browser this capture ran in, kept for the compatibility
301
+ // record and never counted as adoption.
302
+ apiPresent: Boolean(manifest?.present),
303
+ // A property of the page, and the only adoption signal. A page that could
304
+ // not be reached registered nothing, whatever its error document did.
305
+ registered: reachable && tools.length > 0,
306
+ settled: manifest?.settled ?? null,
307
+ inNavigator: manifest?.inNavigator ?? null,
308
+ toolCount: reachable ? tools.length : 0,
309
+ tools: reachable ? tools : [],
310
+ // The agent's own view. `null`, never `[]`, when the browser domain was
311
+ // unavailable — a view you do not have is not evidence of absence.
312
+ agentTools: agentNames,
313
+ agentToolCount: agentNames?.length ?? null,
314
+ attribution: attributed,
315
+ thirdPartyToolCount: attributed ? attributed.filter((t) => t.sameOrigin === false).length : null,
316
+ unattributedToolCount: attributed ? attributed.filter((t) => t.sameOrigin === null).length : null,
317
+ // Divergence in both directions, which is what makes the two views worth
318
+ // keeping separately. `onlyInBrowser` is the delegated-embed case measured
319
+ // on 2026-09-02; `onlyInPage` would mean the browser dropped a tool.
320
+ divergence: agentNames
321
+ ? {
322
+ onlyInBrowser: agentNames.filter((n) => !pageNames.includes(n)),
323
+ onlyInPage: pageNames.filter((n) => !agentNames.includes(n)),
324
+ }
325
+ : null,
326
+ // How the agent view was taken and what it reached, or null when the
327
+ // runner did not say. Item 23 (2026-09-05): a host-attached watch
328
+ // undercounts a cross-site delegating page, so the capture reads the
329
+ // union at the browser endpoint — and `oopiFrames` travels with the
330
+ // number, because a watch no out-of-process iframe ever attached to has
331
+ // measured auto-attach rather than the browser's view. The two findings
332
+ // look identical from `agentToolCount` alone and are not the same claim.
333
+ browserView,
334
+ },
335
+ error,
336
+ };
337
+ };
338
+
339
+ /**
340
+ * What may leave this machine. The local record keeps descriptions verbatim
341
+ * because they are the measured object; the published row keeps only shape.
342
+ *
343
+ * The decision this encodes, recorded 2026-09-01: a tool *name* is published
344
+ * (it is an interface, like a function name in an API doc), a tool
345
+ * *description* is not (it is someone's prose, and §12 forbids republishing
346
+ * another project's source). Descriptions survive as lengths and as whatever
347
+ * the linter derives from them, which is what every aggregate claim needs.
348
+ */
349
+ export const toPublishable = (record) => ({
350
+ project: record.project,
351
+ url: record.url,
352
+ repo: record.repo,
353
+ capturedAt: record.capturedAt,
354
+ reachable: record.liveness.reachable,
355
+ status: record.liveness.status,
356
+ redirected: record.liveness.redirected,
357
+ // "Uses WebMCP" means "registered at least one tool". The browser-API flag is
358
+ // published beside it so the two can never be conflated by a later reader.
359
+ usesWebmcp: record.webmcp.registered,
360
+ browserApiPresent: record.webmcp.apiPresent,
361
+ toolCount: record.webmcp.toolCount,
362
+ // The agent-visible count travels with the page-visible one, because they can
363
+ // differ and the difference is the interesting part. Third-party *origins* stay
364
+ // local: publishing "this project embeds tools from x.example" would put a
365
+ // fourth party's identity into somebody else's row, so only the count and a
366
+ // per-tool boolean go out.
367
+ agentVisibleToolCount: record.webmcp.agentToolCount,
368
+ thirdPartyToolCount: record.webmcp.thirdPartyToolCount,
369
+ viewsDiverge: record.webmcp.divergence
370
+ ? record.webmcp.divergence.onlyInBrowser.length > 0 || record.webmcp.divergence.onlyInPage.length > 0
371
+ : null,
372
+ tools: record.webmcp.tools.map((tool) => ({
373
+ name: tool.name,
374
+ descriptionLength: typeof tool.description === 'string' ? tool.description.length : null,
375
+ propertyCount:
376
+ tool.inputSchema && typeof tool.inputSchema === 'object' && tool.inputSchema.properties
377
+ ? Object.keys(tool.inputSchema.properties).length
378
+ : null,
379
+ requiredCount: Array.isArray(tool.inputSchema?.required) ? tool.inputSchema.required.length : null,
380
+ hasAnnotations: Boolean(tool.annotations && Object.keys(tool.annotations).length > 0),
381
+ inputSchemaWire: tool.inputSchemaWire,
382
+ })),
383
+ });
384
+
385
+ /**
386
+ * The one-line-per-project census a snapshot is judged by.
387
+ *
388
+ * `usingWebmcp` counts pages that registered a tool. There is deliberately no
389
+ * count of pages where the API merely existed, because that number describes the
390
+ * browser and would be misread as adoption the moment it appeared in a table.
391
+ *
392
+ * The agent-side totals are reported separately rather than folded in: they
393
+ * answer "what could an agent call across this cohort", which is a different
394
+ * question from "how many builders shipped tools", and 2026-09-02's measurement
395
+ * showed the two can disagree on a single page.
396
+ */
397
+ export const summarize = (records) => {
398
+ const reachable = records.filter((r) => r.liveness.reachable);
399
+ const using = reachable.filter((r) => r.webmcp.registered);
400
+ const toolCounts = using.map((r) => r.webmcp.toolCount).sort((a, b) => a - b);
401
+ const withAgentView = reachable.filter((r) => Array.isArray(r.webmcp.agentTools));
402
+
403
+ return {
404
+ projects: records.length,
405
+ reachable: reachable.length,
406
+ dead: records.length - reachable.length,
407
+ usingWebmcp: using.length,
408
+ reachableWithoutTools: reachable.length - using.length,
409
+ totalTools: toolCounts.reduce((sum, n) => sum + n, 0),
410
+ medianToolCount: toolCounts.length
411
+ ? toolCounts[Math.floor((toolCounts.length - 1) / 2)]
412
+ : null,
413
+ maxToolCount: toolCounts.length ? toolCounts.at(-1) : null,
414
+ // Agent-side, and null-safe: a cohort captured without the browser domain
415
+ // reports nulls rather than zeroes.
416
+ pagesWithAgentView: withAgentView.length,
417
+ totalAgentVisibleTools: withAgentView.length
418
+ ? withAgentView.reduce((sum, r) => sum + (r.webmcp.agentToolCount ?? 0), 0)
419
+ : null,
420
+ pagesWithThirdPartyTools: withAgentView.length
421
+ ? withAgentView.filter((r) => (r.webmcp.thirdPartyToolCount ?? 0) > 0).length
422
+ : null,
423
+ pagesWhereViewsDiverge: withAgentView.length
424
+ ? withAgentView.filter(
425
+ (r) =>
426
+ (r.webmcp.divergence?.onlyInBrowser.length ?? 0) > 0 ||
427
+ (r.webmcp.divergence?.onlyInPage.length ?? 0) > 0
428
+ ).length
429
+ : null,
430
+ errors: records.filter((r) => r.error).length,
431
+ };
432
+ };