echelongraph-mcp 2.6.7 → 2.7.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.
@@ -0,0 +1,432 @@
1
+ // The batch loop behind check_sbom and scan_manifest (#2835): a list of distinct purls sent to
2
+ // POST /api/v1/public/cves/match/batch, at most MAX_COMPONENTS per request, one request after
3
+ // another, within the call's TIME_BUDGET_MS, waiting out a 429's Retry-After while the wait fits,
4
+ // and stopping when the call is cancelled. Moved here from check_sbom.ts unchanged (#2734, #2756,
5
+ // #2775), so every tool that checks purls through the batch route uses one loop: the same batch
6
+ // size, budget, waits and cancellation, and the same sentences for what happened to the purls.
7
+ //
8
+ // What a tool keeps for itself: how it reads its input into purls, its own refusals, and how its
9
+ // note says what it read. Everything after "these are the distinct purls to check" is here.
10
+ import * as z from "zod";
11
+ export const BATCH_PATH = "/api/v1/public/cves/match/batch";
12
+ // The backend's cap per request (cveBatchMaxComponents): the batch size. A longer list is sent
13
+ // as consecutive batches of at most this many, one after another (#2734).
14
+ export const MAX_COMPONENTS = 200;
15
+ // The backend charges the batch route per component: 1,200 components a minute per caller
16
+ // (core-backend internal/waf/weighted.go, CVEMatchBatchComponentsPerWindow). Over it, it answers
17
+ // 429 with Retry-After and looks nothing up.
18
+ export const API_COMPONENTS_PER_MINUTE = 1200;
19
+ // The most distinct purls one call checks: two minutes of that budget. From a fresh budget, 2,000
20
+ // purls are 10 batches: six go at once, the API then asks for a wait of at most a minute, and the
21
+ // other four follow, and waiting out that minute would pass TIME_BUDGET_MS, so the call answers partial:
22
+ // the first 1,200 checked, the rest named for a second call. A list with
23
+ // more is refused before any request, never truncated: a dropped component would read as clean.
24
+ export const MAX_PURLS = 2000;
25
+ // How long one call may spend, waits for Retry-After included. When the next wait would pass it,
26
+ // the call stops and answers what it has, with the purls not sent counted in coverage.not_sent
27
+ // and listed in data.not_sent_purls, never dropped silently.
28
+ // 50 s, not more: MCP clients time a tools/call out (the TypeScript SDK's default request timeout is
29
+ // 60 s), and a call that outlives its client returns nothing at all — not even the partial answer
30
+ // below. From a fresh budget, 1,200 purls (6 batches) go at once; a larger list answers partial
31
+ // with the rest in data.not_sent_purls, for a call a minute later.
32
+ // The budget bounds the whole call, not only when a batch may start (#2756): each batch's request
33
+ // may take at most what is left of it (api()'s init.timeoutMs), so a batch started at 49.9 s is cut
34
+ // off at 50 s and its purls answered as not sent (time_budget), instead of running on for the
35
+ // request timeout (15 s) past the client's and the hosted endpoint's 60 s.
36
+ export const TIME_BUDGET_MS = 50_000;
37
+ // Retry-After 0 is waited this long; a 429 without Retry-After is not retried. At most MAX_WAITS
38
+ // waits per call.
39
+ const MIN_WAIT_MS = 1000;
40
+ const MAX_WAITS = 10;
41
+ // The backend's per-purl cap (cveBatchMaxPurlLen).
42
+ export const MAX_PURL_LEN = 512;
43
+ export const METHOD = "EchelonGraph's registry advisory matcher (POST /api/v1/public/cves/match/batch): each component's purl is mapped to its OSV ecosystem, package name and version, and matched against the affected version ranges EchelonGraph holds from OSV.dev advisory records, the same matcher GET /api/v1/public/cves/match uses when given an ecosystem. No CPE matching, no score, no ranking.";
44
+ // Always in the envelope's notes, whatever the answer: the refusals a model must not read as clean.
45
+ export const DISTRO_NOTE = "A deb, apk or rpm purl without a distro qualifier naming its release (for example ?distro=debian-12 or ?distro=alpine-3.20) is not assessed (distro_release_unknown): the advisory corpus is keyed per distro release, and EchelonGraph does not guess one.";
46
+ export const CLEAN_NOTE = "Only not_affected is a clean verdict, and only for the advisories decided at that version: an undetermined or not_assessed component is not a finding of no vulnerability, and is reported as unchecked, never as clean.";
47
+ export const MEASURED_AT_NOTE = "measured_at is null: the corpus rows are read through a cache whose maximum age the answer states (summary.corpus_cache_max_age_ms), so the answer gives no single time at which they were read.";
48
+ export const FRESHNESS_NOTE = "freshness is null: the answer carries no time at which the advisory corpus was last refreshed.";
49
+ // How many affected components the note names, and how many CVE ids per component.
50
+ const NOTE_AFFECTED_MAX = 25;
51
+ const NOTE_CVES_MAX = 5;
52
+ export const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
53
+ export const field = (o, k) => (isObj(o) ? o[k] : undefined);
54
+ export const count = (o, k) => {
55
+ const v = field(o, k);
56
+ return typeof v === "number" && Number.isFinite(v) ? v : undefined;
57
+ };
58
+ export const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
59
+ // The distinct purls of a list, each at its first place.
60
+ export function dedupe(all) {
61
+ const seen = new Set();
62
+ const purls = [];
63
+ for (const p of all) {
64
+ if (seen.has(p))
65
+ continue;
66
+ seen.add(p);
67
+ purls.push(p);
68
+ }
69
+ return { purls, removed: all.length - purls.length };
70
+ }
71
+ // Summary fields summed over the batches, and bounds that hold per batch (the largest held for all).
72
+ const SUMMED = ["components", "affected", "not_affected", "undetermined", "not_assessed", "lookups", "elapsed_ms"];
73
+ const MAXED = ["time_budget_ms", "corpus_cache_max_age_ms"];
74
+ // The batches' answers as one (#2734): results concatenated with index counted across batches
75
+ // (batch b's row i becomes index b*MAX_COMPONENTS+i, its position in the list sent), summaries
76
+ // summed, not_assessed_by_reason summed per reason, partial true when any batch's was, answered_at
77
+ // the last batch's. A count some batch did not send is left out, never summed as 0.
78
+ export function mergeAnswers(answers) {
79
+ const out = { ...answers[0] };
80
+ const results = [];
81
+ answers.forEach((a, b) => {
82
+ const rows = field(a, "results");
83
+ if (!Array.isArray(rows))
84
+ return;
85
+ for (const r of rows) {
86
+ const i = field(r, "index");
87
+ results.push(isObj(r) && typeof i === "number" ? { ...r, index: i + b * MAX_COMPONENTS } : r);
88
+ }
89
+ });
90
+ const total = (vs) => vs.every((v) => typeof v === "number" && Number.isFinite(v)) ? vs.reduce((t, v) => t + v, 0) : undefined;
91
+ const comps = total(answers.map((a) => field(a, "components")));
92
+ if (comps === undefined)
93
+ delete out.components;
94
+ else
95
+ out.components = comps;
96
+ const ss = answers.map((a) => field(a, "summary"));
97
+ if (ss.every(isObj)) {
98
+ const sums = ss;
99
+ const m = { ...sums[0] };
100
+ for (const k of SUMMED) {
101
+ const t = total(sums.map((x) => x[k]));
102
+ if (t === undefined)
103
+ delete m[k];
104
+ else
105
+ m[k] = t;
106
+ }
107
+ for (const k of MAXED) {
108
+ const vs = sums.map((x) => x[k]).filter((v) => typeof v === "number");
109
+ if (vs.length)
110
+ m[k] = Math.max(...vs);
111
+ }
112
+ if (sums.some((x) => typeof x.partial === "boolean"))
113
+ m.partial = sums.some((x) => x.partial === true);
114
+ if (sums.some((x) => isObj(x.not_assessed_by_reason))) {
115
+ const by = {};
116
+ for (const x of sums) {
117
+ if (!isObj(x.not_assessed_by_reason))
118
+ continue;
119
+ for (const [r, n] of Object.entries(x.not_assessed_by_reason))
120
+ if (typeof n === "number")
121
+ by[r] = (by[r] ?? 0) + n;
122
+ }
123
+ m.not_assessed_by_reason = by;
124
+ }
125
+ out.summary = m;
126
+ }
127
+ else {
128
+ delete out.summary;
129
+ }
130
+ const last = answers[answers.length - 1];
131
+ if (field(last, "answered_at") !== undefined)
132
+ out.answered_at = field(last, "answered_at");
133
+ out.results = results;
134
+ return out;
135
+ }
136
+ export const realSleep = (ms, signal) => new Promise((r) => {
137
+ if (signal?.aborted)
138
+ return r();
139
+ const done = () => {
140
+ clearTimeout(timer);
141
+ signal?.removeEventListener("abort", done);
142
+ r();
143
+ };
144
+ const timer = setTimeout(done, ms);
145
+ signal?.addEventListener("abort", done, { once: true });
146
+ });
147
+ // The purls a call may send, or why it may not: none, more than MAX_PURLS (refused before any
148
+ // request, never truncated), or one longer than the backend takes. `split` says how the caller
149
+ // can make the list smaller.
150
+ export function purlsRefusal(purls, split) {
151
+ if (purls.length > MAX_PURLS) {
152
+ return `${purls.length} distinct purls; at most ${MAX_PURLS} are checked per call (in batches of ${MAX_COMPONENTS}, within the API's budget of ${API_COMPONENTS_PER_MINUTE} components a minute), and none is dropped silently: ${split}`;
153
+ }
154
+ const long = purls.findIndex((p) => p.length > MAX_PURL_LEN);
155
+ if (long >= 0)
156
+ return `purl ${long + 1} is longer than ${MAX_PURL_LEN} characters`;
157
+ return null;
158
+ }
159
+ // The loop (#2734, #2756, #2775): one batch at a time, in order; a 429 with Retry-After is waited
160
+ // out and the same batch sent again, while the wait fits in the call's budget.
161
+ export async function sendBatches(deps, tool, purls) {
162
+ const sleep = deps.sleep ?? realSleep;
163
+ const now = deps.now ?? Date.now;
164
+ const batches = [];
165
+ for (let i = 0; i < purls.length; i += MAX_COMPONENTS)
166
+ batches.push(purls.slice(i, i + MAX_COMPONENTS));
167
+ const started = now();
168
+ const budgetS = TIME_BUDGET_MS / 1000;
169
+ const answers = [];
170
+ let status = 200;
171
+ let waits = 0;
172
+ let waitedMs = 0;
173
+ let stop = null;
174
+ // The 429 last waited out, for the one way the budget can be gone before anything answered.
175
+ let waitedFor;
176
+ while (answers.length < batches.length) {
177
+ const left = TIME_BUDGET_MS - (now() - started);
178
+ if (left <= 0 && answers.length === 0 && waitedFor) {
179
+ // A wait that ended at the budget's end: no time is left to send the first batch again, and
180
+ // nothing was measured, so that 429 is the answer, not a request given no time at all.
181
+ return { ok: false, result: deps.failed(tool, waitedFor) };
182
+ }
183
+ if (answers.length > 0 && left <= 0) {
184
+ stop = { reason: "time_budget", detail: `the call's ${budgetS} s budget ran out after ${answers.length} of ${batches.length} batches` };
185
+ break;
186
+ }
187
+ // At most what is left of the budget (#2756): a slow batch is cut off at its end.
188
+ const r = await deps.api(BATCH_PATH, {
189
+ method: "POST",
190
+ headers: { "Content-Type": "application/json" },
191
+ body: JSON.stringify({ components: batches[answers.length].map((purl) => ({ purl })) }),
192
+ timeoutMs: Math.max(0, left),
193
+ signal: deps.signal,
194
+ });
195
+ if (r.ok) {
196
+ answers.push(r.data);
197
+ status = r.status;
198
+ continue;
199
+ }
200
+ // Cancelled (#2775): this batch was cut off, or never sent after a wait the signal ended.
201
+ // Nothing more is sent, and the answer goes nowhere.
202
+ if (deps.signal?.aborted)
203
+ return { ok: false, result: deps.failed(tool, r) };
204
+ if (r.kind === "http" && r.status === 429) {
205
+ if (r.retryAfter === undefined) {
206
+ stop = { reason: "rate_limited", detail: "the API answered 429 without a Retry-After to wait for" };
207
+ }
208
+ else if (waits >= MAX_WAITS) {
209
+ stop = { reason: "rate_limited", detail: `the API answered 429 again after ${MAX_WAITS} waits` };
210
+ }
211
+ else {
212
+ const waitMs = Math.max(MIN_WAIT_MS, r.retryAfter * 1000);
213
+ if (now() - started + waitMs <= TIME_BUDGET_MS) {
214
+ await sleep(waitMs, deps.signal);
215
+ waits++;
216
+ waitedMs += waitMs;
217
+ waitedFor = r;
218
+ continue;
219
+ }
220
+ stop = { reason: "time_budget", detail: `the API answered 429 asking for a wait of ${r.retryAfter} s (Retry-After), which would pass the call's ${budgetS} s budget` };
221
+ }
222
+ }
223
+ else if (r.kind === "timeout" && now() - started >= TIME_BUDGET_MS) {
224
+ // Cut off by the budget, not by the API failing: the purls are not sent for want of time.
225
+ stop = { reason: "time_budget", detail: `the call's ${budgetS} s budget ran out while batch ${answers.length + 1} of ${batches.length} was unanswered, so it was cut off` };
226
+ }
227
+ else {
228
+ stop = { reason: "request_failed", detail: deps.describeFailure(r).replace(/\.$/, "") };
229
+ }
230
+ // Nothing answered yet: nothing was measured, so the failure is the answer.
231
+ if (answers.length === 0)
232
+ return { ok: false, result: deps.failed(tool, r) };
233
+ break;
234
+ }
235
+ const notSentPurls = purls.slice(answers.length * MAX_COMPONENTS);
236
+ const sending = {
237
+ batches: batches.length,
238
+ batches_sent: answers.length,
239
+ sent: purls.length - notSentPurls.length,
240
+ not_sent_purls: notSentPurls,
241
+ not_sent_reason: notSentPurls.length ? (stop?.reason ?? null) : null,
242
+ not_sent_detail: notSentPurls.length ? (stop?.detail ?? null) : null,
243
+ waits,
244
+ waited_ms: waitedMs,
245
+ };
246
+ // One batch: data is the API's JSON as it came. More: the merged answer.
247
+ const data = batches.length === 1 ? answers[0] : mergeAnswers(answers);
248
+ if (notSentPurls.length)
249
+ data.not_sent_purls = notSentPurls;
250
+ return { ok: true, data, status, sending };
251
+ }
252
+ // ── The note's sentences on sending and on the verdicts ──
253
+ // How the batches went: their count, the Retry-After waits, and the purls not sent. #2799: the
254
+ // position, not only the list. Past DATA_TEXT_BUDGET the first text block keeps the first 10 of
255
+ // data.not_sent_purls, so a model that reads the text alone cannot send "that list"; the purls not
256
+ // sent are always the input's distinct purls from position sent + 1 on
257
+ // (purls.slice(answers.length * MAX_COMPONENTS)), which it can rebuild from its own input, given
258
+ // the order they are counted in (`readOrder`), which the sentence says whenever it gives the
259
+ // position, not only when the text cuts the list. `retry` is the tool and argument to send them
260
+ // to ("check_sbom again with purls set to").
261
+ export function sendingSentences(g, distinct, readOrder, retry) {
262
+ const out = [];
263
+ if (g.batches > 1) {
264
+ out.push(`The ${distinct} distinct purls make ${g.batches} batches of at most ${MAX_COMPONENTS}, sent one after another; the API answered ${g.batches_sent} of them, and data.summary sums those answers.`);
265
+ }
266
+ if (g.waits > 0) {
267
+ out.push(`The API's component budget (${API_COMPONENTS_PER_MINUTE} components a minute) ran out ${g.waits === 1 ? "once" : `${g.waits} times`}: the tool waited ${Math.round(g.waited_ms / 1000)} s in all, as its Retry-After asked, and sent the batch again.`);
268
+ }
269
+ const notSent = g.not_sent_purls.length;
270
+ if (notSent > 0) {
271
+ const one = notSent === 1;
272
+ const where = one ? `it is the input's distinct purl at position ${g.sent + 1}` : `they are the input's distinct purls from position ${g.sent + 1} on`;
273
+ out.push(`${plural(notSent, "purl was", "purls were")} NOT sent (not_sent_reason ${g.not_sent_reason}: ${g.not_sent_detail}), so ${one ? "it is" : "they are"} not checked and not clean; ${where}, ${readOrder}, and data.not_sent_purls lists ${one ? "it" : "them"}: call ${retry} ${one ? "it" : "them"}${g.not_sent_reason === "request_failed" ? "" : " after a minute"}.`);
274
+ }
275
+ return out;
276
+ }
277
+ // The verdict counts, the not-assessed reasons, the affected components by name, and the
278
+ // upstream-matched rows, from the (merged) answer.
279
+ export function verdictSentences(d, g) {
280
+ const s = field(d, "summary");
281
+ const affected = count(s, "affected") ?? 0;
282
+ const notAffected = count(s, "not_affected") ?? 0;
283
+ const undetermined = count(s, "undetermined") ?? 0;
284
+ const notAssessed = count(s, "not_assessed") ?? 0;
285
+ const components = count(s, "components") ?? g.sent;
286
+ const out = [];
287
+ out.push(`Of the ${components} components checked: ${affected} affected, ${notAffected} not affected, ${undetermined} undetermined, ${notAssessed} not assessed.`);
288
+ const by = field(s, "not_assessed_by_reason");
289
+ if (isObj(by)) {
290
+ const parts = Object.entries(by)
291
+ .filter(([, n]) => typeof n === "number" && n > 0)
292
+ .sort(([a], [b]) => a.localeCompare(b))
293
+ .map(([r, n]) => `${r} ${n}`);
294
+ if (parts.length)
295
+ out.push(`Not assessed, by not_assessed_reason: ${parts.join(", ")}.`);
296
+ const tb = count(by, "time_budget") ?? 0;
297
+ if (tb > 0) {
298
+ out.push(`The batch's time budget ran out before ${plural(tb, "component was", "components were")} looked up: ${tb === 1 ? "it is" : "they are"} not assessed, not clean; check ${tb === 1 ? "it" : "them"} again.`);
299
+ }
300
+ }
301
+ const rows = field(d, "results");
302
+ if (Array.isArray(rows)) {
303
+ const hits = rows.filter((r) => field(r, "verdict") === "affected");
304
+ if (hits.length) {
305
+ const named = hits.slice(0, NOTE_AFFECTED_MAX).map((r) => {
306
+ const who = (typeof field(r, "purl") === "string" && field(r, "purl")) || `${field(r, "ecosystem") ?? ""} ${field(r, "package") ?? ""} ${field(r, "version") ?? ""}`.trim();
307
+ const ids = field(r, "cve_ids");
308
+ const list = Array.isArray(ids) ? ids.filter((i) => typeof i === "string") : [];
309
+ const shown = list.slice(0, NOTE_CVES_MAX).join(", ");
310
+ return `${who} (${shown}${list.length > NOTE_CVES_MAX ? `, and ${list.length - NOTE_CVES_MAX} more` : ""})`;
311
+ });
312
+ out.push(`Affected: ${named.join("; ")}${hits.length > NOTE_AFFECTED_MAX ? `; and ${hits.length - NOTE_AFFECTED_MAX} more affected components in data.results` : ""}.`);
313
+ // Said only when the answer carries fixed_in (an API before #2834 sends none).
314
+ if (hits.some((r) => (Array.isArray(field(r, "matches")) ? field(r, "matches") : []).some((m) => isObj(m) && "fixed_in" in m))) {
315
+ out.push("Each match's fixed_in is the fixed bound of the advisory interval that holds the component's version; null, with fixed_in_reason, where the advisory records no fix for that version.");
316
+ }
317
+ }
318
+ // #2836: rows matched by the source package their purl's upstream qualifier names.
319
+ const upstream = rows.filter((r) => field(r, "matched_via") === "upstream").length;
320
+ if (upstream > 0) {
321
+ out.push(`${plural(upstream, "component was", "components were")} matched by the source package ${upstream === 1 ? "its" : "their"} purl's upstream qualifier names (matched_via upstream, matched_package), not by the purl's own (binary) name, which is how Debian and Alpine file advisories.`);
322
+ }
323
+ }
324
+ return { sentences: out, assessed: affected + notAffected + undetermined };
325
+ }
326
+ // The coverage fields every batch-route tool reports on its sending.
327
+ export function sendingCoverage(g, distinct, summary) {
328
+ const summaryPartial = typeof field(summary, "partial") === "boolean" ? field(summary, "partial") : null;
329
+ return {
330
+ distinct_purls: distinct,
331
+ batch_size: MAX_COMPONENTS,
332
+ batches: g.batches,
333
+ batches_sent: g.batches_sent,
334
+ sent: g.sent,
335
+ not_sent: g.not_sent_purls.length,
336
+ not_sent_reason: g.not_sent_reason,
337
+ rate_limit_waits: g.waits,
338
+ waited_ms: g.waited_ms,
339
+ not_assessed: count(summary, "not_assessed") ?? null,
340
+ partial: g.not_sent_purls.length > 0 ? true : summaryPartial,
341
+ };
342
+ }
343
+ // ── Schemas ──
344
+ const opt = (t) => t.nullable().optional();
345
+ // One row of the batch route's answer, as core-backend internal/cve/matchbatch.go writes it.
346
+ export const Row = z.looseObject({
347
+ index: opt(z.number()),
348
+ input_kind: opt(z.string()),
349
+ purl: opt(z.string()),
350
+ verdict: opt(z.string()).describe("affected (count > 0); not_affected (assessed, no match, nothing undetermined: the only clean verdict); undetermined (advisories name the package, at least one could not be decided, none matched: not clean); not_assessed (no verdict: not clean)."),
351
+ ecosystem: opt(z.string()),
352
+ package: opt(z.string()),
353
+ version: opt(z.string()),
354
+ assessed: opt(z.boolean()).describe("Whether the matcher produced a verdict for this component. false is never clean."),
355
+ not_assessed_reason: opt(z.string()).describe("Why assessed is false: package_not_in_advisory_corpus, no_decidable_advisory, advisory_lookup_failed, candidate_window_truncated, time_budget, distro_release_unknown, purl_type_unsupported, version_missing or invalid_component."),
356
+ code: opt(z.string()),
357
+ error: opt(z.string()),
358
+ cve_ids: opt(z.array(z.string())),
359
+ matched_package: opt(z.string()).describe("The package name the advisories were looked up by (#2836): the purl's own name, or, for a deb or apk purl with an upstream qualifier, the source package it names, which is how Debian and Alpine file advisories."),
360
+ matched_via: opt(z.string()).describe("Which name matched_package is: purl_name (the purl's name), upstream (the purl's upstream qualifier) or coordinates (the component's package)."),
361
+ matched_version_from: opt(z.string()).describe("Upstream rows: upstream when the qualifier named a version (upstream=name@version) and it was used, purl when the purl's own version was."),
362
+ purl_name: opt(z.string()).describe("Upstream rows: the purl's own (binary) package name, not looked up."),
363
+ matches: opt(z.array(z.looseObject({
364
+ cve_id: opt(z.string()),
365
+ kev_listed: opt(z.boolean()).describe("Whether the CVE is in CISA's Known Exploited Vulnerabilities catalog, as EchelonGraph's record holds it."),
366
+ epss_score: opt(z.number()).describe("The CVE's EPSS score (FIRST), as EchelonGraph's record holds it; null when it has none."),
367
+ match_reason: opt(z.string()).describe("Why the advisory applies, with the advisory interval the version falls inside: \"[A, B)\" names the fixed version B, \"[A, B]\" the last affected version B. On an upstream row it ends by naming the source package and the purl name."),
368
+ interval: opt(z.looseObject({ introduced: opt(z.string()), fixed: opt(z.string()), last_affected: opt(z.string()) })).describe("The advisory interval that holds this component's version (fixed is exclusive, last_affected inclusive); absent when no interval holds it."),
369
+ fixed_in: opt(z.string()).describe("The fixed bound of the advisory interval that holds this component's version, always above it. null when that interval records no fix (it ends at last_affected, or has no end) or no interval holds the version, with fixed_in_reason saying which."),
370
+ fixed_in_reason: opt(z.string()).describe("Why fixed_in is null; absent when fixed_in is a version."),
371
+ }))),
372
+ count: opt(z.number()),
373
+ advisories_considered: opt(z.number()),
374
+ not_affected_count: opt(z.number()),
375
+ undetermined_count: opt(z.number()),
376
+ undetermined: opt(z.array(z.looseObject({ cve_id: opt(z.string()) }))),
377
+ capped: opt(z.boolean()),
378
+ candidates_capped: opt(z.boolean()),
379
+ });
380
+ // The batch route's answer, as data carries it: its fields, merged over the batches.
381
+ export const batchDataShape = {
382
+ match_layer: opt(z.string()),
383
+ components: opt(z.number()),
384
+ summary: z
385
+ .looseObject({
386
+ components: opt(z.number()),
387
+ affected: opt(z.number()),
388
+ not_affected: opt(z.number()).describe("Components with a decided, clean verdict."),
389
+ undetermined: opt(z.number()).describe("Components whose advisories could not all be decided and none matched: not clean."),
390
+ not_assessed: opt(z.number()).describe("Components with no verdict: not clean."),
391
+ not_assessed_by_reason: opt(z.looseObject({
392
+ package_not_in_advisory_corpus: z.number().optional(),
393
+ no_decidable_advisory: z.number().optional(),
394
+ advisory_lookup_failed: z.number().optional(),
395
+ candidate_window_truncated: z.number().optional(),
396
+ time_budget: z.number().optional().describe("Components the batch's time budget ran out before: not clean; check them again."),
397
+ distro_release_unknown: z.number().optional().describe("deb, apk or rpm purls without a distro qualifier naming the release, which EchelonGraph does not guess."),
398
+ purl_type_unsupported: z.number().optional(),
399
+ version_missing: z.number().optional(),
400
+ invalid_component: z.number().optional(),
401
+ })).describe("The not_assessed components, counted by not_assessed_reason."),
402
+ lookups: opt(z.number()),
403
+ partial: opt(z.boolean()).describe("true when the time budget ran out before every component was looked up."),
404
+ time_budget_ms: opt(z.number()),
405
+ elapsed_ms: opt(z.number()),
406
+ corpus_cache_max_age_ms: opt(z.number()),
407
+ })
408
+ .optional(),
409
+ answered_at: opt(z.string()),
410
+ results: z.array(Row).optional(),
411
+ not_sent_purls: opt(z.array(z.string())).describe("The distinct purls not sent (coverage.not_sent_reason says why): not checked, and not clean. Present only when some were not sent."),
412
+ };
413
+ // The coverage fields sendingCoverage fills, as the schema holds them.
414
+ export const sendingCoverageShape = {
415
+ distinct_purls: z.number().int().describe("Distinct purls to check: sent plus not_sent."),
416
+ batch_size: z.number().int().describe("The most purls one request carries (the API's cap per request)."),
417
+ batches: z.number().int().describe("Requests the distinct purls make, at batch_size each."),
418
+ batches_sent: z.number().int().describe("Of those, the ones the API answered."),
419
+ sent: z.number().int().describe("Distinct purls sent and answered."),
420
+ not_sent: z.number().int().describe("Distinct purls not sent, listed in data.not_sent_purls: not checked, and not clean."),
421
+ not_sent_reason: z
422
+ .enum(["time_budget", "rate_limited", "request_failed"])
423
+ .nullable()
424
+ .describe("Why not_sent is above 0: time_budget (the call's 50 s budget ran out, before a batch or while one was unanswered, which is then cut off; or waiting out the API's Retry-After would pass it), rate_limited (a 429 without Retry-After, or a 429 after 10 waits), request_failed (a batch after the first failed; the note quotes how). null when every purl was sent."),
425
+ rate_limit_waits: z.number().int().describe("How many times the API answered 429 and the tool waited its Retry-After before sending the batch again."),
426
+ waited_ms: z.number().int().describe("Milliseconds spent in those waits."),
427
+ not_assessed: z.number().int().nullable().describe("Of those sent, the components with no verdict, as the answer counts them."),
428
+ partial: z
429
+ .boolean()
430
+ .nullable()
431
+ .describe("true when not every purl was sent (not_sent above 0) or any batch's summary.partial was true (its time budget ran out first); otherwise the answer's summary.partial."),
432
+ };