mcp-context-cost 0.3.0 → 0.5.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 (44) hide show
  1. package/README.md +202 -43
  2. package/dist/audit/audit.d.ts +101 -0
  3. package/dist/audit/audit.js +492 -16
  4. package/dist/audit/config.d.ts +38 -0
  5. package/dist/audit/config.js +64 -0
  6. package/dist/audit/deferral.d.ts +346 -0
  7. package/dist/audit/deferral.js +376 -0
  8. package/dist/audit/diff.d.ts +124 -0
  9. package/dist/audit/diff.js +318 -0
  10. package/dist/audit/run.d.ts +34 -0
  11. package/dist/audit/run.js +45 -2
  12. package/dist/cli.d.ts +21 -0
  13. package/dist/cli.js +141 -7
  14. package/dist/core/adoption.d.ts +226 -0
  15. package/dist/core/adoption.js +432 -0
  16. package/dist/core/canonical.d.ts +6 -0
  17. package/dist/core/canonical.js +3 -0
  18. package/dist/core/index.d.ts +1 -0
  19. package/dist/core/index.js +1 -0
  20. package/dist/core/session-start.d.ts +102 -0
  21. package/dist/core/session-start.js +186 -0
  22. package/dist/core/types.d.ts +8 -0
  23. package/dist/sweep/client.d.ts +6 -1
  24. package/dist/sweep/client.js +1 -0
  25. package/dist/sweep/dashboard.d.ts +18 -0
  26. package/dist/sweep/dashboard.js +74 -10
  27. package/dist/sweep/docker.d.ts +31 -0
  28. package/dist/sweep/docker.js +20 -11
  29. package/dist/sweep/harness-guard.d.ts +57 -0
  30. package/dist/sweep/harness-guard.js +144 -0
  31. package/dist/sweep/history.d.ts +33 -1
  32. package/dist/sweep/history.js +60 -5
  33. package/dist/sweep/regen.js +6 -1
  34. package/dist/sweep/report.d.ts +18 -0
  35. package/dist/sweep/report.js +79 -5
  36. package/dist/sweep/run.d.ts +43 -0
  37. package/dist/sweep/run.js +135 -37
  38. package/dist/sweep/server-pages.js +31 -6
  39. package/dist/sweep/session-start.d.ts +3 -0
  40. package/dist/sweep/session-start.js +103 -0
  41. package/dist/sweep/shard.d.ts +41 -0
  42. package/dist/sweep/shard.js +58 -0
  43. package/dist/sweep/sweep-all.js +57 -2
  44. package/package.json +3 -1
package/dist/cli.js CHANGED
@@ -2,16 +2,26 @@
2
2
  /**
3
3
  * mcp-context-cost CLI — the dispute drill as a command.
4
4
  *
5
- * mcp-context-cost audit [--budget N] [--json] measure the servers in your own
6
- * MCP config; exit 1 if over budget
5
+ * mcp-context-cost audit [--budget N] [--claude] [--json] measure the servers in your
6
+ * own MCP config; exit 1 if over budget.
7
+ * mcp-context-cost audit --baseline <report.json> [--max-increase N] diff against a
8
+ * stored earlier report; exit 1 if this
9
+ * config change adds more than N tokens
10
+ * to every request (or if it can't tell).
11
+ * --claude adds each server's Anthropic-
12
+ * request cost where the published capture
13
+ * hash matches what's installed.
7
14
  * mcp-context-cost verify <measurement.json> [--json] re-derive the number from the
8
15
  * published capture; exit 1 on mismatch
9
16
  * mcp-context-cost verify --remote <url> [--json] same, fetched from a measurement URL
10
17
  * mcp-context-cost measure --name x --command "npx -y ..." one-off measurement
18
+ * mcp-context-cost measure --remote <url> [--name x] same, via the mcp-remote bridge
19
+ * (name defaults to the URL's hostname)
11
20
  *
12
21
  * Exit codes: 0 ok, 1 verification/measurement/budget failed, 2 usage error.
13
22
  */
14
23
  import { readFileSync } from 'node:fs';
24
+ import { createRequire } from 'node:module';
15
25
  import { canonicalString, countTokens, sha256Hex } from './core/canonical.js';
16
26
  import { toBadge } from './core/badge.js';
17
27
  export function verifyMeasurement(m) {
@@ -30,8 +40,68 @@ export function verifyMeasurement(m) {
30
40
  problems.push(`toolCount mismatch: capture has ${m.rawToolsCapture.length}, stored ${m.toolCount}`);
31
41
  return { ok: problems.length === 0, rederivedTokens: tokens, rederivedSha: sha, problems };
32
42
  }
43
+ /** Derives a servers.yaml-style slug from a remote URL's hostname, e.g. mcp.deepwiki.com -> deepwiki. */
44
+ export function slugFromUrl(url) {
45
+ const host = new URL(url).hostname.replace(/^(www|mcp)\./, '');
46
+ return host.replace(/[^a-z0-9]+/gi, '-').replace(/^-+|-+$/g, '').toLowerCase() || 'remote';
47
+ }
48
+ /** Installed version, for error messages that need to say which one you are running. */
49
+ export function cliVersion() {
50
+ try {
51
+ return createRequire(import.meta.url)('../package.json').version;
52
+ }
53
+ catch {
54
+ return 'unknown';
55
+ }
56
+ }
57
+ /**
58
+ * Reject flags this build does not know.
59
+ *
60
+ * An older CLI used to ignore an unrecognised flag and carry on. That is the exact failure
61
+ * this project exists to catch, in our own tool: `audit --baseline base.json
62
+ * --max-increase 2000` on a build without those flags ran a plain audit and **exited 0** —
63
+ * a green CI check on a gate that never ran. The README documents flags before they are
64
+ * published, so the version skew is not hypothetical; it is the normal case for anyone
65
+ * running `npx -y mcp-context-cost`.
66
+ *
67
+ * So an unknown flag is a usage error, and the message names the running version, because
68
+ * the likeliest cause is that the reader's command is newer than their install.
69
+ */
70
+ export function unknownFlags(argv, spec) {
71
+ const known = new Set([...spec.value, ...spec.boolean]);
72
+ const unknown = [];
73
+ for (let i = 0; i < argv.length; i++) {
74
+ const tok = argv[i];
75
+ if (!tok.startsWith('--'))
76
+ continue;
77
+ const name = tok.slice(2).split('=')[0];
78
+ if (!known.has(name)) {
79
+ unknown.push(tok.split('=')[0]);
80
+ continue;
81
+ }
82
+ // Skip a value-taking flag's value, so `--command "--weird"` is not read as a flag.
83
+ if (spec.value.includes(name) && !tok.includes('='))
84
+ i++;
85
+ }
86
+ return unknown;
87
+ }
88
+ function rejectUnknownFlags(cmd, argv, spec) {
89
+ const bad = unknownFlags(argv, spec);
90
+ if (!bad.length)
91
+ return;
92
+ const all = [...spec.value, ...spec.boolean].sort().map((f) => `--${f}`).join(' ');
93
+ console.error(`unknown flag for \`${cmd}\`: ${bad.join(', ')}`);
94
+ console.error(`this is mcp-context-cost ${cliVersion()} — if you copied the command from the README,`);
95
+ console.error(`your install may be older than the docs. Try: npx -y mcp-context-cost@latest ${cmd} ...`);
96
+ console.error(`known flags for ${cmd}: ${all}`);
97
+ process.exit(2);
98
+ }
33
99
  const [, , cmd, ...rest] = process.argv;
34
100
  if (cmd === 'audit') {
101
+ rejectUnknownFlags('audit', rest, {
102
+ value: ['config', 'budget', 'baseline', 'max-increase', 'context', 'timeout', 'concurrency', 'divergence-url'],
103
+ boolean: ['json', 'docker', 'claude'],
104
+ });
35
105
  const argOf = (name) => {
36
106
  const i = rest.indexOf(`--${name}`);
37
107
  return i >= 0 ? rest[i + 1] : undefined;
@@ -49,7 +119,44 @@ if (cmd === 'audit') {
49
119
  }
50
120
  return v;
51
121
  };
122
+ const nonNegative = (name) => {
123
+ const raw = argOf(name);
124
+ if (raw === undefined)
125
+ return undefined;
126
+ const v = Number(raw);
127
+ if (!Number.isFinite(v) || v < 0) {
128
+ console.error(`--${name} must be zero or a positive number, got '${raw}'`);
129
+ process.exit(2);
130
+ }
131
+ return v;
132
+ };
52
133
  const budget = numeric('budget');
134
+ const baselinePath = argOf('baseline');
135
+ const maxIncrease = nonNegative('max-increase');
136
+ if (maxIncrease !== undefined && !baselinePath) {
137
+ console.error('--max-increase needs a --baseline to measure the increase against');
138
+ process.exit(2);
139
+ }
140
+ const { buildDiff, evaluateIncreaseGate, parseBaselineReport } = await import('./audit/diff.js');
141
+ // Read and shape-check the baseline BEFORE measuring anything: a typo in the path
142
+ // should cost a second, not a full server sweep that is then thrown away.
143
+ let baseline;
144
+ if (baselinePath) {
145
+ let raw;
146
+ try {
147
+ raw = readFileSync(baselinePath, 'utf8');
148
+ }
149
+ catch (e) {
150
+ console.error(`cannot read baseline ${baselinePath}: ${e.message}`);
151
+ process.exit(2);
152
+ }
153
+ const parsed = parseBaselineReport(raw);
154
+ if (!parsed.report) {
155
+ console.error(`${baselinePath}: ${parsed.problem}`);
156
+ process.exit(2);
157
+ }
158
+ baseline = parsed.report;
159
+ }
53
160
  const { runAudit } = await import('./audit/run.js');
54
161
  const { formatReport } = await import('./audit/audit.js');
55
162
  const report = await runAudit({
@@ -59,6 +166,8 @@ if (cmd === 'audit') {
59
166
  timeoutMs: numeric('timeout'),
60
167
  concurrency: numeric('concurrency'),
61
168
  docker: rest.includes('--docker'),
169
+ claude: rest.includes('--claude'),
170
+ divergenceUrl: argOf('divergence-url'),
62
171
  // Progress goes to stderr so `--json` stdout stays a single parseable object.
63
172
  onProgress: json ? undefined : (name, done, total) => process.stderr.write(` [${done}/${total}] ${name}\n`),
64
173
  });
@@ -71,10 +180,16 @@ if (cmd === 'audit') {
71
180
  `Point at one explicitly: mcp-context-cost audit --config <path/to/mcp.json>`);
72
181
  process.exit(1);
73
182
  }
183
+ if (baseline) {
184
+ report.diff = buildDiff(baseline, report);
185
+ if (maxIncrease !== undefined)
186
+ report.increaseGate = evaluateIncreaseGate(report.diff, maxIncrease);
187
+ }
74
188
  console.log(json ? JSON.stringify(report) : formatReport(report));
75
- process.exit(report.budget?.over ? 1 : 0);
189
+ process.exit(report.budget?.over || report.increaseGate?.pass === false ? 1 : 0);
76
190
  }
77
191
  else if (cmd === 'verify') {
192
+ rejectUnknownFlags('verify', rest, { value: ['remote'], boolean: ['json'] });
78
193
  const json = rest.includes('--json');
79
194
  const remoteIdx = rest.indexOf('--remote');
80
195
  const remoteUrl = remoteIdx >= 0 ? rest[remoteIdx + 1] : undefined;
@@ -121,21 +236,36 @@ else if (cmd === 'verify') {
121
236
  process.exit(1);
122
237
  }
123
238
  else if (cmd === 'measure') {
239
+ rejectUnknownFlags('measure', rest, {
240
+ value: ['name', 'command', 'remote', 'timeout', 'docker-image'],
241
+ boolean: ['docker'],
242
+ });
124
243
  const argOf = (name) => {
125
244
  const i = rest.indexOf(`--${name}`);
126
245
  return i >= 0 ? rest[i + 1] : undefined;
127
246
  };
128
- const name = argOf('name');
129
247
  const command = argOf('command');
130
- if (!name || !command) {
248
+ const remoteUrl = argOf('remote');
249
+ if (remoteUrl && !/^https?:\/\//i.test(remoteUrl)) {
250
+ console.error(`--remote must be an http(s) URL, got '${remoteUrl}'`);
251
+ process.exit(2);
252
+ }
253
+ if (!command && !remoteUrl) {
254
+ console.error('usage: mcp-context-cost measure --name <slug> --command "npx -y <server>" [--timeout ms] [--docker]');
255
+ console.error(' mcp-context-cost measure --remote <url> [--name <slug>] [--timeout ms] [--docker]');
256
+ process.exit(2);
257
+ }
258
+ const name = argOf('name') ?? (remoteUrl ? slugFromUrl(remoteUrl) : undefined);
259
+ if (!name) {
131
260
  console.error('usage: mcp-context-cost measure --name <slug> --command "npx -y <server>" [--timeout ms] [--docker]');
132
261
  process.exit(2);
133
262
  }
134
263
  const { measureServer } = await import('./sweep/run.js');
135
- const m = await measureServer(name, command, {
264
+ const m = await measureServer(name, remoteUrl ? `npx -y mcp-remote ${remoteUrl}` : command, {
136
265
  timeoutMs: Number(argOf('timeout') ?? 60_000),
137
266
  docker: rest.includes('--docker'),
138
267
  dockerImage: argOf('docker-image'),
268
+ argv: remoteUrl ? ['npx', '-y', 'mcp-remote', remoteUrl] : undefined,
139
269
  });
140
270
  const ok = m.status === 'measured' || m.status === 'dynamic';
141
271
  console.log(ok
@@ -149,10 +279,14 @@ else if (cmd !== undefined && cmd !== '--help' && cmd !== '-h') {
149
279
  }
150
280
  else {
151
281
  console.log('mcp-context-cost — reproducible context-cost measurement for MCP servers');
152
- console.log(' audit [--config <path>] [--budget N] measure the servers in your own MCP config');
282
+ console.log(' audit [--config <path>] [--budget N] [--claude] measure the servers in your own MCP config');
153
283
  console.log(' [--json] [--context N] [--timeout ms] [--concurrency N] [--docker]');
284
+ console.log(' [--baseline <report.json>] [--max-increase N] diff against an earlier');
285
+ console.log(' audit --json report; --max-increase');
286
+ console.log(' fails when a change adds too much');
154
287
  console.log(' verify <measurement.json> [--json] re-derive tokens+sha from the published capture');
155
288
  console.log(' verify --remote <url> [--json] same, fetched from a measurement URL');
156
289
  console.log(' measure --name x --command "npx -y <server>" run a one-off measurement');
290
+ console.log(' measure --remote <url> [--name x] measure a remote server via mcp-remote');
157
291
  console.log('exit codes: 0 ok, 1 verification/measurement/budget failed, 2 usage error');
158
292
  }
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Badge adoption — how many projects outside this one actually display the
3
+ * badge, and on what day someone last looked.
4
+ *
5
+ * Every other number this project publishes is about MCP servers. This one is
6
+ * about the project itself, and it exists because the alternative is a launch
7
+ * that produces a number nobody can attribute. "Nobody is using the badge" and
8
+ * "nobody has checked whether anybody is using the badge" are the same sentence
9
+ * to a reader, and only one of them is a measurement. So the reading published
10
+ * here is a dated observation with its own working shown: the exact queries
11
+ * that were run, every file they turned up, and what each of those files was
12
+ * judged to be. A zero from this instrument means *these queries ran on this
13
+ * date and found none*, which is a fact. A missing reading says so in those
14
+ * words and publishes no number at all.
15
+ *
16
+ * ## What counts as displaying the badge
17
+ *
18
+ * A file, in a repository owned by someone else, carrying a shields.io endpoint
19
+ * badge that is auditable — and the project publishes two ways to make one, so
20
+ * the rule counts both. Either the badge's JSON is served from this
21
+ * repository's `badges/` directory, or the author hosts their own
22
+ * `badges/<name>.json` — which is what `npm run sweep` produces, and what every
23
+ * badge snippet this project ships tells a reader to point shields at — and
24
+ * links the badge back at the measurement here, which the same snippet
25
+ * requires.
26
+ *
27
+ * Counting only the first form would have published a zero about a spelling
28
+ * nobody was ever told to write: no snippet in README, in the dashboard or in
29
+ * the staged upstream patch produces a URL inside this repository's `badges/`,
30
+ * because each of them is addressed to an author measuring their own server.
31
+ *
32
+ * What is still outside the rule is a self-hosted badge that links back to
33
+ * nothing. Nothing in such a file names this project, so no query can nominate
34
+ * it — and this project's own README says a badge nobody can audit is
35
+ * decoration. That limit is published on the page rather than papered over; see
36
+ * `renderAdoptionPage`.
37
+ *
38
+ * The link is paired with the image it wraps rather than looked for anywhere in
39
+ * the file, because a README carrying an unrelated shields badge and, elsewhere,
40
+ * a sentence naming this project is not a project displaying this badge.
41
+ *
42
+ * ## Why the search is a net and not the judgement
43
+ *
44
+ * Code search matches text, and the same badge is written two ways: shields
45
+ * percent-encodes the `url` parameter, so the published snippet carries
46
+ * `raw.githubusercontent.com%2Fathakur3%2F…`, while a hand-written badge may
47
+ * carry the plain path. Measured against GitHub code search on 2026-08-20, the
48
+ * two forms do not find each other: a repository whose README carries only the
49
+ * encoded form of a raw URL returns 0 for the plain form of that same path,
50
+ * while the encoded literal returns matches. Neither query alone is the
51
+ * question being asked.
52
+ *
53
+ * So the queries only nominate candidates. What a candidate *is* gets decided
54
+ * by reading the file — `classifyFile` below, applied to content that has had
55
+ * its percent-encoding undone, so one rule covers both spellings. Files that
56
+ * name the project without displaying the badge are kept in the reading as
57
+ * rejections, because a zero is worth much more next to the list of things that
58
+ * were examined and turned down.
59
+ */
60
+ /** Method identifier, versioned independently of the o200k methodology. */
61
+ export declare const ADOPTION_METHOD = "badge-sightings/v1";
62
+ /** Where this project's badge JSON is published — the thing a badge points at. */
63
+ export interface BadgeSource {
64
+ owner: string;
65
+ repo: string;
66
+ branch: string;
67
+ }
68
+ export declare const BADGE_SOURCE: BadgeSource;
69
+ /** A query as published: what was asked, and why it is part of the question. */
70
+ export interface QueryDef {
71
+ name: string;
72
+ q: string;
73
+ why: string;
74
+ }
75
+ /** A query as answered. `hits` is null whenever `state` is not `ok`. */
76
+ export interface QueryResult extends QueryDef {
77
+ state: 'ok' | 'failed';
78
+ hits: number | null;
79
+ /** Set when the query did not answer — a reading with one of these is refused. */
80
+ error?: string;
81
+ /** Set when more results existed than were collected. Also refuses the reading. */
82
+ truncated?: boolean;
83
+ }
84
+ /** `badge`: displays it. `mention`: names the project without displaying it. */
85
+ export type SightingKind = 'badge' | 'mention';
86
+ export interface Sighting {
87
+ /** `owner/repo` of the third-party repository. */
88
+ repo: string;
89
+ path: string;
90
+ url: string;
91
+ kind: SightingKind;
92
+ /** Which query nominated it — so a reader can re-run the one that found it. */
93
+ foundBy: string;
94
+ firstSeenAt: string;
95
+ lastSeenAt: string;
96
+ }
97
+ export interface AdoptionRun {
98
+ method: string;
99
+ /** UTC day the queries were run (YYYY-MM-DD). */
100
+ checkedAt: string;
101
+ source: BadgeSource;
102
+ queries: QueryResult[];
103
+ /** Distinct third-party files examined this run, whatever they turned out to be. */
104
+ candidates: number;
105
+ /** Every sighting ever recorded, including ones not seen this run. */
106
+ sightings: Sighting[];
107
+ /**
108
+ * Third-party repositories displaying the badge on `checkedAt`, or null when
109
+ * the queries did not establish it. Never 0 for want of looking.
110
+ */
111
+ thirdPartyRepos: number | null;
112
+ /** Why no number is published. Null exactly when `thirdPartyRepos` is a number. */
113
+ unresolved: string | null;
114
+ /**
115
+ * The most recent reading that did establish a number, carried across runs.
116
+ * A search that falls over must not publish a count, but it also must not
117
+ * erase the last one that stood: "unknown today, 3 on the 20th" is a reading,
118
+ * and "unknown" alone throws away one that was already paid for.
119
+ */
120
+ lastResolved: {
121
+ checkedAt: string;
122
+ thirdPartyRepos: number;
123
+ } | null;
124
+ }
125
+ /**
126
+ * The published query set. Both spellings of the badge URL are asked for
127
+ * separately (see the header), plus the click-through the badge is supposed to
128
+ * carry, plus the project's own name as the widest net — anything that names
129
+ * the project becomes a candidate and is then judged by its contents.
130
+ */
131
+ export declare function adoptionQueries(src?: BadgeSource): QueryDef[];
132
+ /**
133
+ * Undo percent-encoding without throwing on the malformed sequences that turn
134
+ * up in real files. Decoded per-escape rather than over the whole string, so
135
+ * one bad `%zz` costs that escape and nothing around it.
136
+ */
137
+ export declare function decodeLoose(text: string): string;
138
+ /**
139
+ * A shields endpoint badge as it stands in a file: the JSON it renders from,
140
+ * and the link a reader clicking it would follow. `linkTarget` is null when the
141
+ * image is not wrapped in a link at all.
142
+ */
143
+ export interface EndpointBadge {
144
+ url: string;
145
+ linkTarget: string | null;
146
+ }
147
+ /**
148
+ * Every shields endpoint badge in a file, decoded, each paired with its own
149
+ * link target. Both spellings a README uses are read: markdown
150
+ * `[![alt](img)](target)`, which is the snippet this project publishes, and an
151
+ * HTML anchor wrapping an `<img>`.
152
+ */
153
+ export declare function endpointBadges(text: string): EndpointBadge[];
154
+ /**
155
+ * Whether a badge's JSON is served from this repository — auditable by
156
+ * construction, whatever the badge links to. Matched without regard to case:
157
+ * GitHub owner and repository names are case-insensitive, a badge written
158
+ * `MCP-Context-Cost` renders exactly the same one, and code search found the
159
+ * file that way too.
160
+ */
161
+ export declare function hostedHere(url: string, src?: BadgeSource): boolean;
162
+ /**
163
+ * Whether a badge's link target leads back to this project — the repository,
164
+ * the published pages, or a raw file in either. This is exactly what the
165
+ * published snippet asks an author to do with a badge whose JSON they host
166
+ * themselves, and it is what makes that badge auditable rather than decoration.
167
+ */
168
+ export declare function linksBackToProject(target: string, src?: BadgeSource): boolean;
169
+ /**
170
+ * Whether a file displays this project's badge, in either published form: the
171
+ * JSON served from here, or self-hosted JSON with the badge linked back at the
172
+ * measurement here. See the header for why both count and why the link is
173
+ * paired with the image rather than looked for anywhere in the file.
174
+ */
175
+ export declare function displaysBadge(text: string, src?: BadgeSource): boolean;
176
+ /**
177
+ * Every shields endpoint `url` in a file whose JSON is served from this
178
+ * repository's `badges/` directory, decoded. The first of the two forms above,
179
+ * on its own.
180
+ */
181
+ export declare function endpointUrls(text: string, src?: BadgeSource): string[];
182
+ /**
183
+ * What a candidate file is. `null` when it turns out to be neither — a search
184
+ * index can be older than the file it points at.
185
+ *
186
+ * Case is ignored here for the same reason it is ignored above, and the first
187
+ * real run is why it is stated rather than assumed: a file discussing
188
+ * "MCP-context-cost" was found by the search and would have been thrown out by
189
+ * an exact-case test, which is a rejection that looks identical to a file that
190
+ * genuinely stopped mentioning the project.
191
+ */
192
+ export declare function classifyFile(text: string, src?: BadgeSource): SightingKind | null;
193
+ /** `owner/repo` → is that owner someone other than this project's? */
194
+ export declare function isThirdParty(repoFullName: string, src?: BadgeSource): boolean;
195
+ /** A sighting as observed this run, before it is dated against the previous one. */
196
+ export type FreshSighting = Omit<Sighting, 'firstSeenAt' | 'lastSeenAt'>;
197
+ /**
198
+ * Date this run's sightings against the last one. A file seen before keeps its
199
+ * `firstSeenAt`; a file no longer found is kept with the date it was last seen
200
+ * rather than deleted, so a badge that disappears is visible as a badge that
201
+ * disappeared instead of as one that never existed.
202
+ */
203
+ export declare function mergeSightings(previous: Sighting[], fresh: FreshSighting[], checkedAt: string): Sighting[];
204
+ /** Repositories displaying the badge as of `checkedAt` — sorted, deduplicated. */
205
+ export declare function badgeRepos(sightings: Sighting[], checkedAt: string): string[];
206
+ /**
207
+ * Whether this run may publish a number, and if not, why not. A query that did
208
+ * not answer, or one whose results were cut short, means the set of files that
209
+ * carry the badge was never established — and a count taken from an incomplete
210
+ * search is a zero that means "we did not finish", which is the exact confusion
211
+ * this instrument exists to remove.
212
+ */
213
+ export declare function resolveCount(queries: QueryResult[], sightings: Sighting[], checkedAt: string, unreadableCandidates?: number): Pick<AdoptionRun, 'thirdPartyRepos' | 'unresolved'>;
214
+ /**
215
+ * The last completed reading to carry into this run's record: this one if it
216
+ * completed, otherwise whatever the previous run was carrying.
217
+ */
218
+ export declare function carryResolved(previous: AdoptionRun | null, current: Pick<AdoptionRun, 'checkedAt' | 'thirdPartyRepos'>): AdoptionRun['lastResolved'];
219
+ /** Parse results/badge-adoption.json; anything malformed yields null, never throws. */
220
+ export declare function parseAdoption(text: string): AdoptionRun | null;
221
+ /**
222
+ * The page a reader opens. `null` is the state that matters most: no run on
223
+ * record renders as "nobody has looked", in those words, with no number — which
224
+ * is the whole distinction this instrument exists to make readable.
225
+ */
226
+ export declare function renderAdoptionPage(run: AdoptionRun | null, src?: BadgeSource): string;