mcp-context-cost 0.13.2 → 0.14.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.
package/README.md CHANGED
@@ -271,7 +271,7 @@ sample of that range; the full range is in
271
271
  | filesystem (reference) | 2,823 | 14 |
272
272
  | markitdown | 64 | 1 |
273
273
 
274
- *(87 of 106 popular servers measured, each row dated by its own most recent sweep — full table in
274
+ *(87 of 107 popular servers measured, each row dated by its own most recent sweep — full table in
275
275
  [results/leaderboard.md](results/leaderboard.md); every failure is listed with its reason.
276
276
  Each measured server also has a [detail page](https://athakur3.github.io/mcp-context-cost/servers/)
277
277
  showing which tools its tokens are in.)*
@@ -347,9 +347,9 @@ number is *not*, config policy, failure taxonomy, frozen color bands, known dive
347
347
  | `src/audit/` | client-config discovery (5 clients, JSONC-tolerant), the per-stack report, and the baseline diff |
348
348
  | `src/cli.ts` | `audit` (measure your own stack), `verify` (re-derive any published number), `measure` |
349
349
  | `spec/fixtures/` | golden vectors shared by the TypeScript and bash implementations |
350
- | `tools/` | the one script that calls a network API (Claude divergence); kept out of the package so the library stays offline |
350
+ | `tools/` | the scripts that call a network API — the Claude divergence run, the adoption reading, the registry scan — kept out of the package so the library stays offline |
351
351
  | `upstream/` | `badge.sh` + composite-action patch + bash tests — the self-serve badge recipe, carried here |
352
- | `servers.yaml` | 106 curated candidates with live install metrics and provenance |
352
+ | `servers.yaml` | 107 curated candidates with live install metrics and provenance |
353
353
  | `results/` · `badges/` | measurements, leaderboard, history series, shields endpoint JSONs |
354
354
  | `docs/` | methodology, dashboard, and a generated page per measured server |
355
355
 
@@ -357,11 +357,26 @@ number is *not*, config policy, failure taxonomy, frozen color bands, known dive
357
357
 
358
358
  ```bash
359
359
  npm ci
360
- npm run sweep -- --name my-server --command "npx -y my-mcp-server"
361
- cat badges/my-server.json # strict shields.io endpoint JSON
360
+ npm run sweep -- --no-persist --name my-server --command "npx -y my-mcp-server"
362
361
  ```
363
362
 
364
- Then in your README:
363
+ That prints the number and writes nothing. Published records
364
+ (`results/<name>/measurement.json`, `badges/<name>.json`, the `history.csv`
365
+ row) come from CI: a developer machine is a different architecture under
366
+ different load, and a measurement taken there describes it rather than the
367
+ server. `local-mcp`'s failing record was made on an arm64 laptop, and its
368
+ stderr named an architecture the record itself did not — which is why every
369
+ measurement now records `isolation.arch`. The entry turned out to be
370
+ unavailable on both architectures, and a record that says where it was made
371
+ is what lets that be told from a broken server. To get your server into the
372
+ leaderboard, add an entry to `servers.yaml` and open a pull request; the check
373
+ on that PR measures the entry read-only, and the rotation publishes it after
374
+ merge.
375
+
376
+ For a badge on your own README, run the published CLI in your server's own CI
377
+ (the [gate](#defend-the-number-dont-just-display-it) below writes
378
+ `results/my-server/measurement.json` and `badges/my-server.json` into that
379
+ repository), then in your README:
365
380
 
366
381
  ```markdown
367
382
  [![context cost](https://img.shields.io/endpoint?url=<raw URL of badges/my-server.json>)](<link target>)
@@ -455,6 +470,7 @@ public, one click each:
455
470
  [re-sweep runs](https://github.com/athakur3/mcp-context-cost/actions/workflows/resweep.yml)
456
471
  and [self-badge runs](https://github.com/athakur3/mcp-context-cost/actions/workflows/self-badge.yml).
457
472
  See [ROADMAP.md](ROADMAP.md) for what's next — contributions welcome, especially new
458
- `servers.yaml` entries.
473
+ `servers.yaml` entries; [CONTRIBUTING.md](CONTRIBUTING.md) says what an entry needs, the
474
+ order of steps that leaves a pull request green, and where its number comes from.
459
475
 
460
476
  MIT © 2026
@@ -28,7 +28,7 @@ import { homedir, tmpdir } from 'node:os';
28
28
  import { join, resolve } from 'node:path';
29
29
  import { fileURLToPath } from 'node:url';
30
30
  import { parse } from 'yaml';
31
- import { measureServer } from './run.js';
31
+ import { isSelfContainerised, measureServer } from './run.js';
32
32
  import { DockerHarnessFault, defaultImageFor, dockerize } from './docker.js';
33
33
  import { splitCommand } from './client.js';
34
34
  import { selectShard, shardIndexForDate } from './shard.js';
@@ -305,7 +305,7 @@ if (isMain) {
305
305
  console.log(` ${e.name}: our measurement ${m.status} — recorded, nothing to compare`);
306
306
  continue;
307
307
  }
308
- const selfDocker = e.command.trimStart().startsWith('docker ');
308
+ const selfDocker = isSelfContainerised(e.command);
309
309
  const out = await runCli(docker && selfDocker ? await hostBin() : binPath, e, {
310
310
  docker: docker && !selfDocker,
311
311
  timeoutMs,
@@ -0,0 +1,62 @@
1
+ import { type SchemaProblem } from './servers-schema.js';
2
+ import type { ServerEntry } from './report.js';
3
+ import type { Measurement } from '../core/types.js';
4
+ /**
5
+ * The fields that change what a sweep launches — the option object
6
+ * sweep-all.ts hands to `measureServer`, plus the timeout it is given. A change
7
+ * to `metric`, `metricSource`, `category`, `repo`, `package` or `deprecated`
8
+ * changes a row's text, not its process, and is not re-measured here.
9
+ */
10
+ export declare const LAUNCH_FIELDS: readonly ["command", "dockerImage", "aptPackages", "needsGit", "env", "envValues", "timeoutSeconds", "notApplicable"];
11
+ /**
12
+ * How many entries one pull request may launch, at the default budget. The
13
+ * worst case per entry is a timeout retried on `TIMEOUT_RETRY_FACTOR` times
14
+ * its budget (run.ts:351-352), so at the workflow's `--default-timeout 240`
15
+ * one entry can hold the runner for 240 × (1 + 2) = 720s, and this many of
16
+ * them measured one at a time is what pr-check.yml's `timeout-minutes` is
17
+ * sized to (`launchBudgetSeconds`).
18
+ *
19
+ * The count alone does not hold that bound. An entry's own `timeoutSeconds`
20
+ * replaces the default (the rotation's rule, kept here), and the schema bounds
21
+ * it only below — "a whole number of seconds greater than zero"
22
+ * (servers-schema.ts:166-169) — so one added entry with `timeoutSeconds: 4000`
23
+ * would cost 12000s under the same count and be killed by the job limit with
24
+ * no line printed for it. The bound is held because the script also refuses,
25
+ * before any launch, a selection whose `worstCaseSeconds` exceeds the budget
26
+ * the count was sized to. A PR that trips either is asked to split or shorten,
27
+ * rather than have the runner cut it off with the last entries unmeasured and
28
+ * no line saying so.
29
+ */
30
+ export declare const DEFAULT_MAX_ENTRIES = 4;
31
+ /** The launched entries among a selection: remote endpoints and self-containerised commands are listed, not spawned. */
32
+ export declare function launchedEntries(entries: ServerEntry[]): ServerEntry[];
33
+ /**
34
+ * The most seconds a selection can hold the runner: each launched entry's own
35
+ * budget (`timeoutSeconds`, or the default) once, then `TIMEOUT_RETRY_FACTOR`
36
+ * times more on the retry. Listed entries cost nothing and are not counted.
37
+ */
38
+ export declare function worstCaseSeconds(entries: ServerEntry[], defaultTimeout: number): number;
39
+ /** What the job's `timeout-minutes` was sized to: `maxEntries` launches, each at the default budget and its retry. */
40
+ export declare function launchBudgetSeconds(maxEntries: number, defaultTimeout: number): number;
41
+ export interface ServersDiff {
42
+ /** Head fails the shape check; nothing is diffed from a malformed document. */
43
+ problems: SchemaProblem[];
44
+ /** Names present in head and absent from base. */
45
+ added: ServerEntry[];
46
+ /** Names in both whose `LAUNCH_FIELDS` projection differs. */
47
+ relaunched: ServerEntry[];
48
+ }
49
+ /** The part of an entry that decides what gets spawned, as one comparable string. */
50
+ export declare function launchSignature(entry: ServerEntry): string;
51
+ /**
52
+ * What a pull request adds or relaunches, given the parsed base and head
53
+ * documents. Head is shape-checked first and returns only problems when it has
54
+ * any. Base is the committed branch, which `npm test` already holds valid; an
55
+ * unreadable base is treated as empty, which makes every head entry "added"
56
+ * and lets the entry cap refuse the run rather than measuring the whole file.
57
+ */
58
+ export declare function entriesToMeasure(base: unknown, head: unknown): ServersDiff;
59
+ /** The line printed for a launched entry — sweep-all's summary form, with the evidence behind a failure. */
60
+ export declare function summarise(name: string, m: Measurement, secs: number): string;
61
+ /** Whether an outcome fails the check. */
62
+ export declare function failsCheck(status: Measurement['status']): boolean;
@@ -0,0 +1,296 @@
1
+ /**
2
+ * The read-only measurement a pull request gets before any job holding
3
+ * `contents: write` ever runs its launch command:
4
+ * npx tsx src/sweep/pr-check.ts --base <base servers.yaml> [--head servers.yaml]
5
+ * [--docker] [--default-timeout 60] [--max-entries 4]
6
+ *
7
+ * Diffs the two documents BY NAME and measures only what changed: entries the
8
+ * pull request added, and entries whose launch-affecting fields it changed
9
+ * (`LAUNCH_FIELDS`). The second set is not optional. The roadmap first wrote
10
+ * "measures only the entries the PR added", and that misses the point of the
11
+ * check: a PR that rewrites an existing entry's `command` changes what the
12
+ * Wednesday rotation (resweep.yml, `contents: write`) will spawn next, and
13
+ * "added" never sees it. What is launched is what is measured.
14
+ *
15
+ * Nothing is written anywhere. `measureServer` runs with `persist: false`, the
16
+ * form session-start.ts and cross-check.ts already use, and this file imports
17
+ * none of history.js, report.js, regressions.js or regen.js — the number is
18
+ * printed in the check log and the rotation publishes its own later, under its
19
+ * own rules. The measured line here is not the published number and must not
20
+ * be quoted as one: a fresh runner resolves `@latest` on its own day.
21
+ *
22
+ * Four refusals, all before a launch, all exit 2 so a reader can tell "nothing
23
+ * ran" from "the entry failed". A head document that does not parse as YAML
24
+ * prints the parser's message and stops — never its stack trace, which the
25
+ * first draft of this script let through with exit 1, the code the policy
26
+ * below reserves for an entry that launched and failed. A head that parses but
27
+ * fails `validateServers` prints the problems (servers-schema.ts was written
28
+ * for exactly this second caller). More than `--max-entries` entries is refused
29
+ * because one entry can cost `budget × (1 + TIMEOUT_RETRY_FACTOR)` (run.ts
30
+ * retries a timeout on the doubled budget) and the workflow's `timeout-minutes`
31
+ * is derived from that product — and, because the schema bounds an entry's own
32
+ * `timeoutSeconds` only below (servers-schema.ts:166-169), the sum of what the
33
+ * selected entries would actually cost (`worstCaseSeconds`) is refused when it
34
+ * exceeds the same budget, so no entry's `timeoutSeconds: 4000` can push the
35
+ * runner past the job limit with its line unprinted. And an entry whose command
36
+ * is already its own `docker run` (`isSelfContainerised`) is listed, not
37
+ * launched: the harness would spawn it against the runner's own daemon with the
38
+ * pull request's argv, so it waits for a maintainer to measure it via
39
+ * resweep.yml's `servers` input after review. Listing is the `remote` treatment
40
+ * sweep-all.ts gives endpoints, and it is exit 0 — published entries already
41
+ * take this form (the premise record cites the `github`, `grafana` and
42
+ * `terraform` entries), and a check that went red on a shape the file already
43
+ * uses would be a check against the file.
44
+ *
45
+ * Exit policy, by the entry's outcome: `measured`, `auth-required` and a
46
+ * declared `not-applicable` are findings the leaderboard publishes today, so
47
+ * they pass. `startup-failure` and `timeout` mean the entry does not launch as
48
+ * written — the lesson `agent-device` taught, measured only once its `mcp`
49
+ * subcommand was named (CHANGELOG 0.12.0) — and `dynamic` means two captures
50
+ * disagreed, so there is no one number to show. Those fail, with the evidence
51
+ * tail printed so the reader sees what the server said. A `DockerHarnessFault`
52
+ * is the runner's problem, not the entry's (the 2026-08-26 `sequential-thinking`
53
+ * incident, docker.ts), and exits 1 saying so.
54
+ */
55
+ import { readFileSync } from 'node:fs';
56
+ import { resolve } from 'node:path';
57
+ import { fileURLToPath } from 'node:url';
58
+ import { parse } from 'yaml';
59
+ import { isSelfContainerised, measureServer, TIMEOUT_RETRY_FACTOR } from './run.js';
60
+ import { DockerHarnessFault } from './docker.js';
61
+ import { formatProblems, validateServers } from './servers-schema.js';
62
+ /**
63
+ * The fields that change what a sweep launches — the option object
64
+ * sweep-all.ts hands to `measureServer`, plus the timeout it is given. A change
65
+ * to `metric`, `metricSource`, `category`, `repo`, `package` or `deprecated`
66
+ * changes a row's text, not its process, and is not re-measured here.
67
+ */
68
+ export const LAUNCH_FIELDS = [
69
+ 'command',
70
+ 'dockerImage',
71
+ 'aptPackages',
72
+ 'needsGit',
73
+ 'env',
74
+ 'envValues',
75
+ 'timeoutSeconds',
76
+ 'notApplicable',
77
+ ];
78
+ /**
79
+ * How many entries one pull request may launch, at the default budget. The
80
+ * worst case per entry is a timeout retried on `TIMEOUT_RETRY_FACTOR` times
81
+ * its budget (run.ts:351-352), so at the workflow's `--default-timeout 240`
82
+ * one entry can hold the runner for 240 × (1 + 2) = 720s, and this many of
83
+ * them measured one at a time is what pr-check.yml's `timeout-minutes` is
84
+ * sized to (`launchBudgetSeconds`).
85
+ *
86
+ * The count alone does not hold that bound. An entry's own `timeoutSeconds`
87
+ * replaces the default (the rotation's rule, kept here), and the schema bounds
88
+ * it only below — "a whole number of seconds greater than zero"
89
+ * (servers-schema.ts:166-169) — so one added entry with `timeoutSeconds: 4000`
90
+ * would cost 12000s under the same count and be killed by the job limit with
91
+ * no line printed for it. The bound is held because the script also refuses,
92
+ * before any launch, a selection whose `worstCaseSeconds` exceeds the budget
93
+ * the count was sized to. A PR that trips either is asked to split or shorten,
94
+ * rather than have the runner cut it off with the last entries unmeasured and
95
+ * no line saying so.
96
+ */
97
+ export const DEFAULT_MAX_ENTRIES = 4;
98
+ /** The launched entries among a selection: remote endpoints and self-containerised commands are listed, not spawned. */
99
+ export function launchedEntries(entries) {
100
+ return entries.filter((e) => !e.remote && !isSelfContainerised(e.command));
101
+ }
102
+ /**
103
+ * The most seconds a selection can hold the runner: each launched entry's own
104
+ * budget (`timeoutSeconds`, or the default) once, then `TIMEOUT_RETRY_FACTOR`
105
+ * times more on the retry. Listed entries cost nothing and are not counted.
106
+ */
107
+ export function worstCaseSeconds(entries, defaultTimeout) {
108
+ return launchedEntries(entries).reduce((sum, e) => sum + (e.timeoutSeconds ?? defaultTimeout) * (1 + TIMEOUT_RETRY_FACTOR), 0);
109
+ }
110
+ /** What the job's `timeout-minutes` was sized to: `maxEntries` launches, each at the default budget and its retry. */
111
+ export function launchBudgetSeconds(maxEntries, defaultTimeout) {
112
+ return maxEntries * defaultTimeout * (1 + TIMEOUT_RETRY_FACTOR);
113
+ }
114
+ /** JSON with object keys sorted at every depth, so equal launches stringify equal. */
115
+ function stable(value) {
116
+ if (Array.isArray(value))
117
+ return `[${value.map(stable).join(',')}]`;
118
+ if (typeof value === 'object' && value !== null) {
119
+ const o = value;
120
+ return `{${Object.keys(o)
121
+ .sort()
122
+ .map((k) => `${JSON.stringify(k)}:${stable(o[k])}`)
123
+ .join(',')}}`;
124
+ }
125
+ return JSON.stringify(value);
126
+ }
127
+ /** The part of an entry that decides what gets spawned, as one comparable string. */
128
+ export function launchSignature(entry) {
129
+ const projection = {};
130
+ for (const field of LAUNCH_FIELDS) {
131
+ if (entry[field] !== undefined)
132
+ projection[field] = entry[field];
133
+ }
134
+ return stable(projection);
135
+ }
136
+ function entriesOf(doc) {
137
+ if (typeof doc !== 'object' || doc === null)
138
+ return [];
139
+ const servers = doc.servers;
140
+ if (!Array.isArray(servers))
141
+ return [];
142
+ return servers.filter((e) => typeof e === 'object' && e !== null && typeof e.name === 'string');
143
+ }
144
+ /**
145
+ * What a pull request adds or relaunches, given the parsed base and head
146
+ * documents. Head is shape-checked first and returns only problems when it has
147
+ * any. Base is the committed branch, which `npm test` already holds valid; an
148
+ * unreadable base is treated as empty, which makes every head entry "added"
149
+ * and lets the entry cap refuse the run rather than measuring the whole file.
150
+ */
151
+ export function entriesToMeasure(base, head) {
152
+ const problems = validateServers(head);
153
+ if (problems.length)
154
+ return { problems, added: [], relaunched: [] };
155
+ const before = new Map(entriesOf(base).map((e) => [e.name, e]));
156
+ const added = [];
157
+ const relaunched = [];
158
+ for (const entry of entriesOf(head)) {
159
+ const prior = before.get(entry.name);
160
+ if (!prior)
161
+ added.push(entry);
162
+ else if (launchSignature(prior) !== launchSignature(entry))
163
+ relaunched.push(entry);
164
+ }
165
+ return { problems: [], added, relaunched };
166
+ }
167
+ /** The line printed for a launched entry — sweep-all's summary form, with the evidence behind a failure. */
168
+ export function summarise(name, m, secs) {
169
+ const head = m.status === 'measured' || m.status === 'dynamic'
170
+ ? `${name}: ${m.totalTokens} tokens / ${m.toolCount} tools (${m.status}, ${secs}s)`
171
+ : `${name}: ${m.status} (${secs}s)`;
172
+ const tail = m.notes ? `\n ${m.notes.split('\n').join('\n ')}` : '';
173
+ return head + tail;
174
+ }
175
+ /** Whether an outcome fails the check. */
176
+ export function failsCheck(status) {
177
+ return status === 'startup-failure' || status === 'timeout' || status === 'dynamic';
178
+ }
179
+ function arg(name) {
180
+ const i = process.argv.indexOf(`--${name}`);
181
+ return i >= 0 ? process.argv[i + 1] : undefined;
182
+ }
183
+ // Exact path match, for the reason src/sweep/run.ts states: any other file whose
184
+ // name merely ends the same way would otherwise run this block.
185
+ const isMain = process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
186
+ if (isMain) {
187
+ const basePath = arg('base');
188
+ if (!basePath) {
189
+ console.error('usage: npx tsx src/sweep/pr-check.ts --base <base servers.yaml> [--head servers.yaml] [--docker] ' +
190
+ `[--default-timeout 60] [--max-entries ${DEFAULT_MAX_ENTRIES}]`);
191
+ process.exit(2);
192
+ }
193
+ const headPath = arg('head') ?? 'servers.yaml';
194
+ const docker = process.argv.includes('--docker');
195
+ const defaultTimeout = Number(arg('default-timeout') ?? 60);
196
+ const maxEntries = Number(arg('max-entries') ?? DEFAULT_MAX_ENTRIES);
197
+ // A parse error is a refusal, not a failed launch: exit 2 with the parser's
198
+ // message, never its stack trace. Base is the committed branch and always
199
+ // parses in practice, but the two are treated alike so no path exits 1
200
+ // without an entry having been launched.
201
+ const parseDoc = (path) => {
202
+ try {
203
+ return parse(readFileSync(path, 'utf8'));
204
+ }
205
+ catch (err) {
206
+ console.error(`${path} does not parse as YAML: ${err.message.trim()}\nnothing was launched`);
207
+ process.exit(2);
208
+ }
209
+ };
210
+ const base = parseDoc(basePath);
211
+ const head = parseDoc(headPath);
212
+ const diff = entriesToMeasure(base, head);
213
+ if (diff.problems.length) {
214
+ console.error(`${headPath} is not shaped like a servers list; nothing was launched:`);
215
+ console.error(formatProblems(diff.problems));
216
+ process.exit(2);
217
+ }
218
+ const selected = [...diff.added, ...diff.relaunched];
219
+ if (selected.length === 0) {
220
+ console.log('servers.yaml adds or relaunches no entry; nothing to measure');
221
+ process.exit(0);
222
+ }
223
+ if (selected.length > maxEntries) {
224
+ console.error(`this pull request adds or relaunches ${selected.length} entries ` +
225
+ `(${selected.map((e) => e.name).join(', ')}); the check launches at most ${maxEntries} — ` +
226
+ `one entry can cost --default-timeout × (1 + ${TIMEOUT_RETRY_FACTOR}) seconds, and the ` +
227
+ `job's timeout-minutes is sized to ${maxEntries} of those. Split the pull request. Nothing was launched.`);
228
+ process.exit(2);
229
+ }
230
+ const budget = launchBudgetSeconds(maxEntries, defaultTimeout);
231
+ const worst = worstCaseSeconds(selected, defaultTimeout);
232
+ if (worst > budget) {
233
+ const named = launchedEntries(selected)
234
+ .map((e) => `${e.name} (timeoutSeconds ${e.timeoutSeconds ?? `${defaultTimeout}, the default`})`)
235
+ .join(', ');
236
+ console.error(`the entries this pull request launches could hold the runner for ${worst}s — ${named}, each ` +
237
+ `retried on ${TIMEOUT_RETRY_FACTOR}× its budget after a timeout — and the job's timeout-minutes ` +
238
+ `is sized to ${budget}s (${maxEntries} entries at --default-timeout ${defaultTimeout}). ` +
239
+ `Shorten timeoutSeconds or split the pull request. Nothing was launched.`);
240
+ process.exit(2);
241
+ }
242
+ console.log(`measuring ${diff.added.length} added and ${diff.relaunched.length} relaunched ` +
243
+ `(docker=${docker}, default timeout ${defaultTimeout}s, one at a time)`);
244
+ // One at a time, deliberately: contention is what produced two false
245
+ // timeouts in an earlier sweep (run.ts, `retriesWithLongerTimeout`), and a
246
+ // check over a handful of entries has no throughput to buy with it.
247
+ let failed = 0;
248
+ for (const e of selected) {
249
+ const kind = diff.added.includes(e) ? 'added' : 'relaunched';
250
+ if (e.remote) {
251
+ console.log(` ${e.name} (${kind}): remote — listed, not measured; an endpoint never reaches initialize without credentials`);
252
+ continue;
253
+ }
254
+ if (isSelfContainerised(e.command)) {
255
+ console.log(` ${e.name} (${kind}): listed, not launched here — its command is its own \`docker run\`, which ` +
256
+ `the harness would spawn against this runner's daemon with the pull request's argv. ` +
257
+ `A maintainer measures it via resweep.yml servers=${e.name} after review.`);
258
+ continue;
259
+ }
260
+ const started = Date.now();
261
+ let m;
262
+ try {
263
+ m = await measureServer(e.name, e.command, {
264
+ timeoutMs: (e.timeoutSeconds ?? defaultTimeout) * 1000,
265
+ docker,
266
+ dockerImage: e.dockerImage,
267
+ dummyEnv: e.env ?? [],
268
+ dummyEnvValues: e.envValues,
269
+ needsGit: e.needsGit,
270
+ aptPackages: e.aptPackages,
271
+ notApplicable: e.notApplicable,
272
+ persist: false, // the check prints; the rotation publishes
273
+ });
274
+ }
275
+ catch (err) {
276
+ if (!(err instanceof DockerHarnessFault))
277
+ throw err;
278
+ // A statement about the runner, not the entry — but nothing was measured,
279
+ // and a green check with no number in it would be read as one.
280
+ console.error(`HARNESS FAULT: ${err.message}`);
281
+ process.exit(1);
282
+ }
283
+ const secs = Math.round((Date.now() - started) / 1000);
284
+ console.log(` ${summarise(`${e.name} (${kind})`, m, secs)}`);
285
+ if (failsCheck(m.status))
286
+ failed++;
287
+ }
288
+ console.log('Nothing was written: results/, badges/ and history.csv are published only by the rotation ' +
289
+ '(.github/workflows/resweep.yml). If CI is red on this pull request, run `npx tsx src/sweep/regen.ts` ' +
290
+ 'and commit what it rewrote, and add a bullet under `## Unreleased` in CHANGELOG.md or start the ' +
291
+ 'commit subject with `chore:` — tools/release-readiness.ts fails a servers.yaml commit that does neither.');
292
+ if (failed) {
293
+ console.error(`${failed} entr${failed === 1 ? 'y does' : 'ies do'} not launch as written; see the lines above`);
294
+ }
295
+ process.exit(failed ? 1 : 0);
296
+ }