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/trial.mjs ADDED
@@ -0,0 +1,248 @@
1
+ /**
2
+ * One trial: one utterance, one expected tool, one outcome.
3
+ *
4
+ * Fresh context per utterance is not a detail - conversational carry-over means
5
+ * trial N contaminates trial N+1 - so the judge is called with a single message
6
+ * pair and nothing else, and the tab is the caller's to discard afterwards.
7
+ */
8
+ import { captureManifest, executeTool, observe, watchBrowserTools } from '../browser/webmcp.mjs';
9
+ import { classifyAfterExecution, classifyBeforeExecution } from './taxonomy.mjs';
10
+
11
+ const SYSTEM_PROMPT = `You are the tool-using layer of a web browser. The page below exposes tools you may call.
12
+
13
+ Answer with a single JSON object and nothing else - no prose, no code fence:
14
+ {"tool": "<tool name or null>", "arguments": {<arguments object>}}
15
+
16
+ Rules:
17
+ - Choose the one tool that best serves the user's request, or null if none of them does.
18
+ - Pass only arguments that appear in the chosen tool's inputSchema, and only those the user's words justify.
19
+ - Never invent argument values the user did not imply.`;
20
+
21
+ const buildUserPrompt = ({ tools, utterance }) =>
22
+ [
23
+ 'Tools available on this page:',
24
+ JSON.stringify(
25
+ tools.map((tool) => ({
26
+ name: tool.name,
27
+ description: tool.description,
28
+ inputSchema: tool.inputSchema,
29
+ annotations: tool.annotations,
30
+ })),
31
+ null,
32
+ 2
33
+ ),
34
+ '',
35
+ 'The user says:',
36
+ utterance,
37
+ ].join('\n');
38
+
39
+ /**
40
+ * Parses the judge's reply without repairing it. A judge that cannot follow the
41
+ * contract is a measurement, so a parse failure is reported as an unparseable
42
+ * selection (which classifies as not_selected) rather than retried into shape.
43
+ */
44
+ export const parseSelection = (content) => {
45
+ const text = (content ?? '').trim();
46
+ const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
47
+ const candidate = (fenced ? fenced[1] : text).trim();
48
+
49
+ try {
50
+ const parsed = JSON.parse(candidate);
51
+ return {
52
+ tool: typeof parsed.tool === 'string' && parsed.tool.length > 0 ? parsed.tool : null,
53
+ arguments: parsed.arguments ?? {},
54
+ parsed: true,
55
+ usedFence: Boolean(fenced),
56
+ };
57
+ } catch (error) {
58
+ return { tool: null, arguments: {}, parsed: false, parseError: String(error.message ?? error) };
59
+ }
60
+ };
61
+
62
+ export const runTrial = async ({
63
+ session,
64
+ judge,
65
+ url,
66
+ toolName,
67
+ utterance,
68
+ expectation = {},
69
+ setup = null,
70
+ browserToolNames = null,
71
+ fixtureVersion = null,
72
+ /**
73
+ * Control mode inverts the question: no tool should be selected at all. The
74
+ * pipeline is identical up to selection, then stops - executing a tool for a
75
+ * control would mutate the page to measure nothing.
76
+ */
77
+ controlMode = false,
78
+ }) => {
79
+ const startedAt = new Date().toISOString();
80
+
81
+ // Started before navigation, because the browser announces tools through
82
+ // `WebMCP.toolsAdded` events rather than through any command: a watch attached
83
+ // afterwards sees nothing and would read as "the browser surfaced none of them".
84
+ const browserWatch = await watchBrowserTools(session);
85
+
86
+ await session.navigate(url);
87
+ const manifest = await captureManifest(session);
88
+
89
+ // Two independent views of the same page. The page's own getTools() says what it
90
+ // believes it registered; the browser's says what an agent would be offered. A
91
+ // disagreement is the whole point of the not_discovered outcome, and is invisible
92
+ // from inside the page.
93
+ const browserNames = browserToolNames ?? browserWatch.names();
94
+ const browserTools = browserWatch.tools();
95
+ browserWatch.stop();
96
+ const pageNames = (manifest.tools ?? []).map((tool) => tool.name);
97
+ const browserView = {
98
+ available: browserWatch.available,
99
+ reason: browserWatch.reason,
100
+ toolCount: browserNames?.length ?? null,
101
+ // More than one frame means tools arrived from an embed. Chrome 152 folds those
102
+ // into the top frame's getTools(), so a host page's agent surface can include a
103
+ // widget's tools - worth recording per trial rather than discovering later.
104
+ frames: browserTools ? new Set(browserTools.map((tool) => tool.frameId)).size : null,
105
+ registeredButNotSurfaced: browserNames
106
+ ? pageNames.filter((name) => !browserNames.includes(name))
107
+ : null,
108
+ surfacedButNotInPage: browserNames
109
+ ? browserNames.filter((name) => !pageNames.includes(name))
110
+ : null,
111
+ };
112
+
113
+ // Utterances like "clear that" have no referent on a clean page, so the fixture
114
+ // declares the state they presuppose. The seed call is setup, never scored.
115
+ let seed = null;
116
+ if (setup?.seedCall && manifest.present) {
117
+ const seedResult = await executeTool(session, setup.seedCall.tool, setup.seedCall.args ?? {});
118
+ seed = { call: setup.seedCall, ok: seedResult.ok, error: seedResult.error ?? null };
119
+ }
120
+
121
+ const before = manifest.present ? await observe(session) : null;
122
+
123
+ let selection = null;
124
+ let judgeAnswer = null;
125
+ let judgeError = null;
126
+
127
+ if (manifest.present && (manifest.tools?.length ?? 0) > 0) {
128
+ try {
129
+ judgeAnswer = await judge.complete({
130
+ system: SYSTEM_PROMPT,
131
+ user: buildUserPrompt({ tools: manifest.tools, utterance: utterance.text }),
132
+ });
133
+ selection = parseSelection(judgeAnswer.content);
134
+ } catch (error) {
135
+ judgeError = String(error.message ?? error);
136
+ }
137
+ }
138
+
139
+ const preVerdict = controlMode
140
+ ? null
141
+ : classifyBeforeExecution({
142
+ manifest,
143
+ expectedTool: toolName,
144
+ selection,
145
+ expectation,
146
+ browserToolNames: browserNames,
147
+ });
148
+
149
+ const record = {
150
+ trial: {
151
+ utteranceId: utterance.id,
152
+ utterance: utterance.text,
153
+ tag: utterance.tag ?? null,
154
+ expectedTool: toolName,
155
+ fixtureVersion,
156
+ startedAt,
157
+ url,
158
+ },
159
+ client: {
160
+ cdpPort: session.port,
161
+ modelContextPresent: Boolean(manifest.present),
162
+ surface: manifest.surface ?? null,
163
+ settled: manifest.settled ?? null,
164
+ settledAtMs: manifest.settledAtMs ?? null,
165
+ toolCount: manifest.tools?.length ?? 0,
166
+ browserView,
167
+ },
168
+ judge: {
169
+ model: judgeAnswer?.model ?? judge.id,
170
+ requested: judge.id,
171
+ baseUrl: judge.baseUrl,
172
+ keySource: judge.keySource,
173
+ elapsedMs: judgeAnswer?.elapsedMs ?? null,
174
+ usage: judgeAnswer?.usage ?? null,
175
+ finishReason: judgeAnswer?.finishReason ?? null,
176
+ // The raw response travels with every trial: a number nobody can audit
177
+ // back to what the model actually said is not evidence.
178
+ rawResponse: judgeAnswer?.raw ?? null,
179
+ error: judgeError,
180
+ },
181
+ selection,
182
+ seed,
183
+ };
184
+
185
+ if (judgeError) {
186
+ // A judge that could not be reached has told us nothing about the page. Calling
187
+ // that not_selected would put transport failures inside the metric and quietly
188
+ // depress every rate; the sweep routes a null outcome to harness failures.
189
+ return {
190
+ ...record,
191
+ outcome: null,
192
+ harnessFailure: { kind: 'judge_unavailable', detail: judgeError },
193
+ };
194
+ }
195
+
196
+ if (judgeAnswer?.truncated && !judgeAnswer.content) {
197
+ const reasoning = judgeAnswer.usage?.completion_tokens_details?.reasoning_tokens ?? null;
198
+ return {
199
+ ...record,
200
+ outcome: null,
201
+ harnessFailure: {
202
+ kind: 'judge_truncated',
203
+ detail: `finish_reason=length with empty content; ${judgeAnswer.usage?.completion_tokens ?? '?'} completion tokens${reasoning === null ? '' : ` of which ${reasoning} reasoning`}. Raise maxTokens.`,
204
+ },
205
+ };
206
+ }
207
+
208
+ if (controlMode) {
209
+ if (!manifest.present) {
210
+ return { ...record, outcome: 'not_supported', reason: 'no modelContext on this client' };
211
+ }
212
+ if ((manifest.tools?.length ?? 0) === 0) {
213
+ return { ...record, outcome: 'not_registered', reason: 'page registered no tools' };
214
+ }
215
+ if (!selection?.tool) {
216
+ return { ...record, outcome: 'not_selected', reason: 'no tool selected, which is the pass' };
217
+ }
218
+ return {
219
+ ...record,
220
+ outcome: 'wrong_tool',
221
+ reason: `control utterance selected ${selection.tool}`,
222
+ };
223
+ }
224
+
225
+ if (preVerdict) {
226
+ return { ...record, outcome: preVerdict.outcome, reason: preVerdict.reason, violations: preVerdict.violations ?? null };
227
+ }
228
+
229
+ const execution = await executeTool(session, selection.tool, selection.arguments);
230
+ const after = await observe(session);
231
+ const postVerdict = classifyAfterExecution({ execution, before, after });
232
+
233
+ return {
234
+ ...record,
235
+ execution: {
236
+ ok: execution.ok,
237
+ callShape: execution.callShape ?? null,
238
+ // The rejected signatures are the compatibility data: which shapes a build
239
+ // refuses, and with what message, is the row worth publishing.
240
+ attempts: execution.attempts ?? null,
241
+ error: execution.error ?? null,
242
+ result: execution.result ?? null,
243
+ },
244
+ observation: { before, after, changed: JSON.stringify(before) !== JSON.stringify(after) },
245
+ outcome: postVerdict.outcome,
246
+ reason: postVerdict.reason,
247
+ };
248
+ };
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Is this repository still invisible to a stranger? Classification rules only.
3
+ *
4
+ * The publishing policy is that the remote stays private until the report launch,
5
+ * so "still private?" is a precondition for every push. It was checked by hand on
6
+ * 2026-08-29 the right way — two independent signals, both of whose failure modes
7
+ * are informative — and then by hand again on 2026-09-03 the wrong way, in an
8
+ * inline one-liner whose catch block printed "404 = private" for **any** thrown
9
+ * error. DNS was down at that moment, so a transport failure that never received
10
+ * an HTTP status at all was reported as a private repo. The push was held only
11
+ * because the second signal named the real cause (`Could not resolve host`).
12
+ *
13
+ * That is the same defect as the `d.name`/`d.domain` bug corrected the same day: a
14
+ * check whose failure path cannot distinguish *no answer* from *the answer I
15
+ * expected* is not a check. So the rules live here, with tests, and the I/O lives
16
+ * in `probes/remote-visibility.mjs` — the split this project already uses for
17
+ * `cohort.mjs`, `gallery.mjs` and `scorecard.mjs`.
18
+ *
19
+ * Three verdicts, never two. `indeterminate` is a first-class answer: the only
20
+ * failure this gate must never produce is a confident "private" it did not measure.
21
+ *
22
+ * Exit codes follow the harness contract in `gate.mjs`, for the same reason:
23
+ *
24
+ * 0 every signal answered, and all of them say private
25
+ * 1 a signal definitively says public — a real answer that breaks the policy
26
+ * 2 cannot answer: a transport failure, an unexpected status, one lone signal,
27
+ * or signals that disagree
28
+ */
29
+
30
+ export const VISIBILITY_EXIT = { private: 0, public: 1, indeterminate: 2 };
31
+
32
+ /**
33
+ * The slug comes from the configured remote rather than a constant, because a
34
+ * hardcoded `owner/repo` is how a check quietly starts testing a different
35
+ * repository than the one being pushed. Returns null for anything that is not a
36
+ * GitHub remote, which the caller must treat as "this signal does not apply"
37
+ * rather than as a pass.
38
+ */
39
+ export const parseGitHubSlug = (remoteUrl) => {
40
+ if (typeof remoteUrl !== 'string' || remoteUrl.trim() === '') return null;
41
+ const url = remoteUrl.trim();
42
+
43
+ const patterns = [
44
+ /^https?:\/\/(?:[^@/]+@)?github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/i,
45
+ /^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?\/?$/i,
46
+ /^ssh:\/\/git@github\.com(?::\d+)?\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/i,
47
+ ];
48
+
49
+ for (const pattern of patterns) {
50
+ const match = url.match(pattern);
51
+ if (match) return { owner: match[1], repo: match[2] };
52
+ }
53
+ return null;
54
+ };
55
+
56
+ /**
57
+ * Signal 1 — an unauthenticated GitHub API read.
58
+ *
59
+ * `status: null` is the case that motivated this file: no response arrived, so
60
+ * there is nothing to read a verdict off. 403 and 429 are GitHub's rate limits,
61
+ * which are also not visibility answers. A 404 means "not visible anonymously",
62
+ * which covers both private and deleted — either way a stranger cannot read it,
63
+ * and either way a push is not made unsafe by it.
64
+ */
65
+ export const classifyApiSignal = ({ status = null, transportError = null } = {}) => {
66
+ if (transportError) {
67
+ return { verdict: 'indeterminate', detail: `no HTTP response (${transportError})` };
68
+ }
69
+ if (status === null || status === undefined) {
70
+ return { verdict: 'indeterminate', detail: 'no HTTP status was captured' };
71
+ }
72
+ if (status === 200) return { verdict: 'public', detail: 'HTTP 200 — readable without credentials' };
73
+ if (status === 404) return { verdict: 'private', detail: 'HTTP 404 — not visible anonymously' };
74
+ if (status === 401 || status === 403 || status === 429) {
75
+ return { verdict: 'indeterminate', detail: `HTTP ${status} — rate limit or auth, not a visibility answer` };
76
+ }
77
+ return { verdict: 'indeterminate', detail: `HTTP ${status} — unexpected, treated as no answer` };
78
+ };
79
+
80
+ const DEMANDS_CREDENTIALS =
81
+ /could not read Username|could not read Password|terminal prompts disabled|Authentication failed|Invalid username or (?:password|token)|Password authentication is not supported|Permission denied \(publickey\)|Repository not found/i;
82
+
83
+ const TRANSPORT_FAILURE =
84
+ /could not resolve host|couldn't resolve host|connection timed out|failed to connect|operation timed out|network is unreachable|connection reset|SSL certificate problem|proxy/i;
85
+
86
+ /**
87
+ * Signal 2 — an anonymous `git ls-remote`, credential helper disabled and prompts
88
+ * off, so a private repo has no way to succeed.
89
+ *
90
+ * Order matters. Git reports a DNS failure as `fatal: unable to access '…':
91
+ * Could not resolve host`, and an HTTP error as `unable to access '…': The
92
+ * requested URL returned error: 403` — the shared prefix is worthless, so the
93
+ * transport patterns are checked before anything is concluded, and only a message
94
+ * that actually demands credentials counts as private.
95
+ *
96
+ * Exit 0 with no refs is deliberately indeterminate: an empty *public* repository
97
+ * answers exactly that way, and this project's remote has a `main` to report.
98
+ */
99
+ export const classifyLsRemoteSignal = ({ code = null, stdout = '', stderr = '' } = {}) => {
100
+ const out = String(stdout);
101
+ const err = String(stderr);
102
+
103
+ if (/refs\/heads\//.test(out)) {
104
+ return { verdict: 'public', detail: 'refs listed anonymously' };
105
+ }
106
+ if (TRANSPORT_FAILURE.test(err)) {
107
+ return { verdict: 'indeterminate', detail: `transport failure (${firstLine(err)})` };
108
+ }
109
+ if (DEMANDS_CREDENTIALS.test(err)) {
110
+ return { verdict: 'private', detail: `credentials demanded (${firstLine(err)})` };
111
+ }
112
+ if (code === 0) {
113
+ return { verdict: 'indeterminate', detail: 'connected and listed no refs — an empty public repo looks like this' };
114
+ }
115
+ return { verdict: 'indeterminate', detail: err.trim() === '' ? `exit ${code}, no output` : `exit ${code}: ${firstLine(err)}` };
116
+ };
117
+
118
+ const firstLine = (text) => String(text).trim().split('\n')[0].slice(0, 200);
119
+
120
+ /**
121
+ * One signal cannot audit itself — that is the whole lesson of 2026-09-03, twice
122
+ * over — so a `private` pass requires at least two signals that all say private.
123
+ * A single `public` outranks everything: it is a real answer, and it is the one
124
+ * answer that must stop a push.
125
+ */
126
+ export const combineSignals = (signals) => {
127
+ const list = Array.isArray(signals) ? signals : [];
128
+ if (list.length === 0) {
129
+ return { verdict: 'indeterminate', exitCode: VISIBILITY_EXIT.indeterminate, reason: 'no signals were collected' };
130
+ }
131
+
132
+ const publics = list.filter((signal) => signal.verdict === 'public');
133
+ if (publics.length > 0) {
134
+ return {
135
+ verdict: 'public',
136
+ exitCode: VISIBILITY_EXIT.public,
137
+ reason: `readable without credentials — ${publics.map((signal) => signal.detail).join('; ')}`,
138
+ };
139
+ }
140
+
141
+ const unknown = list.filter((signal) => signal.verdict !== 'private');
142
+ if (unknown.length > 0) {
143
+ return {
144
+ verdict: 'indeterminate',
145
+ exitCode: VISIBILITY_EXIT.indeterminate,
146
+ reason: `cannot conclude — ${unknown.map((signal) => `${signal.name ?? 'signal'}: ${signal.detail}`).join('; ')}`,
147
+ };
148
+ }
149
+
150
+ if (list.length < 2) {
151
+ return {
152
+ verdict: 'indeterminate',
153
+ exitCode: VISIBILITY_EXIT.indeterminate,
154
+ reason: 'only one signal answered, and one signal cannot audit itself',
155
+ };
156
+ }
157
+
158
+ return {
159
+ verdict: 'private',
160
+ exitCode: VISIBILITY_EXIT.private,
161
+ reason: `${list.length} independent signals agree the remote is not readable without credentials`,
162
+ };
163
+ };
@@ -0,0 +1,164 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import {
5
+ VISIBILITY_EXIT,
6
+ classifyApiSignal,
7
+ classifyLsRemoteSignal,
8
+ combineSignals,
9
+ parseGitHubSlug,
10
+ } from './visibility.mjs';
11
+
12
+ /**
13
+ * The first test is the bug this module exists for: on 2026-09-03 an inline check
14
+ * printed "404 = private" when DNS was down and no HTTP status existed at all.
15
+ */
16
+ test('a transport failure is never a private verdict, because no answer arrived', () => {
17
+ const signal = classifyApiSignal({ transportError: 'getaddrinfo ENOTFOUND api.github.com' });
18
+ assert.equal(signal.verdict, 'indeterminate');
19
+ assert.match(signal.detail, /no HTTP response/);
20
+ });
21
+
22
+ test('a missing status is indeterminate even with no error to report', () => {
23
+ assert.equal(classifyApiSignal({}).verdict, 'indeterminate');
24
+ assert.equal(classifyApiSignal({ status: null }).verdict, 'indeterminate');
25
+ });
26
+
27
+ test('200 is public and 404 is private, and 404 says what it really means', () => {
28
+ assert.equal(classifyApiSignal({ status: 200 }).verdict, 'public');
29
+ const notFound = classifyApiSignal({ status: 404 });
30
+ assert.equal(notFound.verdict, 'private');
31
+ assert.match(notFound.detail, /not visible anonymously/);
32
+ });
33
+
34
+ test('a rate limit is not a visibility answer', () => {
35
+ for (const status of [401, 403, 429]) {
36
+ const signal = classifyApiSignal({ status });
37
+ assert.equal(signal.verdict, 'indeterminate', `HTTP ${status} must not conclude`);
38
+ }
39
+ });
40
+
41
+ test('an unexpected status is treated as no answer rather than guessed at', () => {
42
+ assert.equal(classifyApiSignal({ status: 500 }).verdict, 'indeterminate');
43
+ assert.equal(classifyApiSignal({ status: 301 }).verdict, 'indeterminate');
44
+ });
45
+
46
+ test('listed refs mean a stranger can read the repository', () => {
47
+ const signal = classifyLsRemoteSignal({
48
+ code: 0,
49
+ stdout: '3486bc838f9e1debabb49cd5d44fd25368067a11\trefs/heads/main\n',
50
+ });
51
+ assert.equal(signal.verdict, 'public');
52
+ });
53
+
54
+ test('a demand for credentials is the private signature', () => {
55
+ const signal = classifyLsRemoteSignal({
56
+ code: 128,
57
+ stderr: "fatal: could not read Username for 'https://github.com': terminal prompts disabled",
58
+ });
59
+ assert.equal(signal.verdict, 'private');
60
+ assert.match(signal.detail, /credentials demanded/);
61
+ });
62
+
63
+ /**
64
+ * Observed 2026-09-03 with a credential helper in the environment: the refusal
65
+ * arrives as GitHub's own wording rather than git's, so matching only git's
66
+ * phrasing would have downgraded a real private answer to indeterminate.
67
+ */
68
+ test('GitHub\u2019s own rejection wording counts as the private signature too', () => {
69
+ for (const stderr of [
70
+ 'remote: Invalid username or token. Password authentication is not supported for Git operations.',
71
+ "fatal: Authentication failed for 'https://github.com/owner/repo/'",
72
+ 'remote: Repository not found.',
73
+ ]) {
74
+ assert.equal(classifyLsRemoteSignal({ code: 128, stderr }).verdict, 'private', stderr);
75
+ }
76
+ });
77
+
78
+ /**
79
+ * Both of these begin `fatal: unable to access`, so the shared prefix decides
80
+ * nothing and the transport patterns have to be tested before any conclusion.
81
+ */
82
+ test('a DNS failure is indeterminate, not private, however fatal it looks', () => {
83
+ const signal = classifyLsRemoteSignal({
84
+ code: 128,
85
+ stderr: "fatal: unable to access 'https://github.com/owner/repo/': Could not resolve host: github.com",
86
+ });
87
+ assert.equal(signal.verdict, 'indeterminate');
88
+ assert.match(signal.detail, /transport failure/);
89
+ });
90
+
91
+ test('a timeout is indeterminate too', () => {
92
+ const signal = classifyLsRemoteSignal({ code: 128, stderr: 'fatal: unable to access: Connection timed out after 15000 ms' });
93
+ assert.equal(signal.verdict, 'indeterminate');
94
+ });
95
+
96
+ test('connecting and listing nothing is indeterminate, because an empty public repo looks the same', () => {
97
+ const signal = classifyLsRemoteSignal({ code: 0, stdout: '' });
98
+ assert.equal(signal.verdict, 'indeterminate');
99
+ });
100
+
101
+ test('an unclassifiable failure reports its exit code instead of picking a side', () => {
102
+ const signal = classifyLsRemoteSignal({ code: 129, stderr: 'error: unknown option `--nope`' });
103
+ assert.equal(signal.verdict, 'indeterminate');
104
+ assert.match(signal.detail, /exit 129/);
105
+ });
106
+
107
+ test('two private signals are a pass, and that is the only pass', () => {
108
+ const verdict = combineSignals([
109
+ { name: 'api', verdict: 'private', detail: 'HTTP 404' },
110
+ { name: 'ls-remote', verdict: 'private', detail: 'credentials demanded' },
111
+ ]);
112
+ assert.equal(verdict.verdict, 'private');
113
+ assert.equal(verdict.exitCode, VISIBILITY_EXIT.private);
114
+ });
115
+
116
+ test('one lone private signal does not pass, because one signal cannot audit itself', () => {
117
+ const verdict = combineSignals([{ name: 'api', verdict: 'private', detail: 'HTTP 404' }]);
118
+ assert.equal(verdict.verdict, 'indeterminate');
119
+ assert.equal(verdict.exitCode, VISIBILITY_EXIT.indeterminate);
120
+ assert.match(verdict.reason, /one signal cannot audit itself/);
121
+ });
122
+
123
+ test('a single public signal outranks everything else', () => {
124
+ const verdict = combineSignals([
125
+ { name: 'api', verdict: 'private', detail: 'HTTP 404' },
126
+ { name: 'ls-remote', verdict: 'public', detail: 'refs listed anonymously' },
127
+ ]);
128
+ assert.equal(verdict.verdict, 'public');
129
+ assert.equal(verdict.exitCode, VISIBILITY_EXIT.public);
130
+ });
131
+
132
+ test('one indeterminate signal blocks a pass and names the signal that failed', () => {
133
+ const verdict = combineSignals([
134
+ { name: 'api', verdict: 'indeterminate', detail: 'no HTTP response (ENOTFOUND)' },
135
+ { name: 'ls-remote', verdict: 'private', detail: 'credentials demanded' },
136
+ ]);
137
+ assert.equal(verdict.exitCode, VISIBILITY_EXIT.indeterminate);
138
+ assert.match(verdict.reason, /api: no HTTP response/);
139
+ });
140
+
141
+ test('no signals at all is indeterminate rather than vacuously private', () => {
142
+ assert.equal(combineSignals([]).exitCode, VISIBILITY_EXIT.indeterminate);
143
+ assert.equal(combineSignals(undefined).exitCode, VISIBILITY_EXIT.indeterminate);
144
+ });
145
+
146
+ test('the slug comes off any spelling of a GitHub remote', () => {
147
+ const expected = { owner: 'Svishwa2004', repo: 'webmcp-gauge' };
148
+ for (const url of [
149
+ 'https://github.com/Svishwa2004/webmcp-gauge',
150
+ 'https://github.com/Svishwa2004/webmcp-gauge.git',
151
+ 'https://github.com/Svishwa2004/webmcp-gauge/',
152
+ 'https://token@github.com/Svishwa2004/webmcp-gauge.git',
153
+ 'git@github.com:Svishwa2004/webmcp-gauge.git',
154
+ 'ssh://git@github.com/Svishwa2004/webmcp-gauge.git',
155
+ ]) {
156
+ assert.deepEqual(parseGitHubSlug(url), expected, url);
157
+ }
158
+ });
159
+
160
+ test('a non-GitHub or unusable remote yields null rather than a guessed slug', () => {
161
+ for (const url of ['https://gitlab.com/owner/repo.git', 'file:///tmp/repo', '', null, undefined, 'not a url']) {
162
+ assert.equal(parseGitHubSlug(url), null, String(url));
163
+ }
164
+ });