echelongraph-mcp 3.0.0 → 3.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.
@@ -42,6 +42,7 @@
42
42
  import * as z from "zod";
43
43
  import { ANNOTATIONS, CVE_ID, NOT_A_FINDING, SHOWN_BASE, api, badInput, boolAt, checked, crashed, envelopeSchema, failed, failure, field, numAt, okHead, opt, strAt, succeeded, } from "../index.js";
44
44
  import { answering, FULL_ARG, TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
45
+ import { ONE_PACKAGE_RULE, packagesListedTwice } from "./packageRecords.js";
45
46
  const TOOL = "cve_intel";
46
47
  // The sections relayed, in the answer's order, and the keys each failed_sections name covers.
47
48
  const LISTS = ["cwes", "exploits", "affected_packages", "fixed_versions", "timeline"];
@@ -73,9 +74,57 @@ const LEFT_OUT = ["vendor_advisories", "patches", "ai", "trending", "historical_
73
74
  // Per-CVE row limits of the API (core-backend cve/enrichment_store.go): a list this long may be cut.
74
75
  const API_ROW_LIMIT = { exploits: 10, affected_packages: 50, fixed_versions: 200, timeline: 100 };
75
76
  const TIMELINE_RELAYED = 20;
76
- const CVE_INTEL_METHOD = "EchelonGraph's per-CVE enrichment (GET /api/v1/public/cves/:id/enrichment). cwes: CWE classifications stored from NVD, GitHub (GHSA) and CVE.org records, each with the source that gave it, named from the MITRE CWE catalog EchelonGraph embeds. exploits: public exploit references from EchelonGraph's cve_exploits records, whose data model holds Metasploit modules (metasploit), Exploit-DB entries (exploit_db), nuclei templates (nuclei_template), GitHub proof-of-concept repositories (github_poc) and vendor proofs of concept (vendor_poc), filled by a curated seed and by one poller per automated source, each run only where it is enabled, so which sources are current depends on the deployment. affected_packages and fixed_versions: package-ecosystem advisory data, each row naming its source. timeline: EchelonGraph's enrichment history for the CVE, newest first.";
77
- const VERIFIED_STATUS_DESCRIPTION = "verified_status is the label stored with each reference: verified for a Metasploit module, for an Exploit-DB entry Exploit-DB marks verified, and for curated seed rows marked so; reported for a public artefact nothing has confirmed works (nuclei templates, GitHub proofs of concept, unverified Exploit-DB entries); unconfirmed where a curated row says so. It is a label from the source, not a guarantee that the exploit works against a given system.";
77
+ const VERIFIED_STATUS_DESCRIPTION = "verified_status is the label stored with each reference: verified for a Metasploit module, for an Exploit-DB entry Exploit-DB marks verified, and for curated seed rows marked so; reported for a public artefact nothing has confirmed works (nuclei templates, GitHub proofs of concept and checker repositories, unverified Exploit-DB entries); unconfirmed where a curated row says so. It is a label from the source, not a guarantee that the exploit works against a given system.";
78
+ // #3267: the exploit kinds this server classifies, both lists by name, as core-backend's
79
+ // egkev.ExploitCodeKinds lists exploit code ("the one definition of exploit code", #1213, #3038)
80
+ // and every other kind core-backend holds (exploits.Kind* constants) is a detection kind there.
81
+ // Until #3267 this server named only the detection kinds and counted every other kind as exploit
82
+ // code, the inverse of the backend's list: the two failed in opposite directions. core-backend fails
83
+ // closed (egkev/backfill_exploit_poc_available.sql raises on a kind ExploitCodeKinds does not
84
+ // classify); this server failed open, so a kind core-backend added after a release (another scanner
85
+ // or template source) would have reached every client of that release as "N public exploit
86
+ // references", the claim #3039 removed, with nothing erroring. github_checker did exactly that
87
+ // from #3040 (2026-10-12) to #3221. A kind on neither list is now counted as neither: the note
88
+ // names it as a kind this server does not classify (exploitSentences). test/cve_intel.test.mjs holds both
89
+ // lists to core-backend's source in the monorepo, and the kind field's text to the note everywhere.
90
+ const EXPLOIT_CODE_KINDS = ["metasploit", "exploit_db", "github_poc", "vendor_poc"];
91
+ const DETECTION_KINDS = ["nuclei_template", "github_checker"];
92
+ const DETECTION_GLOSS = {
93
+ nuclei_template: "a Nuclei detection template",
94
+ github_checker: "a GitHub repository named or described as a checker or scanner",
95
+ };
96
+ const isExploitCodeKind = (k) => EXPLOIT_CODE_KINDS.includes(k);
97
+ const isDetectionKind = (k) => DETECTION_KINDS.includes(k);
98
+ const orList = (xs) => (xs.length <= 1 ? (xs[0] ?? "") : `${xs.slice(0, -1).join(", ")} or ${xs.at(-1)}`);
99
+ const andList = (xs) => (xs.length <= 1 ? (xs[0] ?? "") : `${xs.slice(0, -1).join(", ")} and ${xs.at(-1)}`);
100
+ // #3267's review: the method and exploits_by_kind name the kinds from the same two lists, so neither
101
+ // can name a kind the note counts otherwise; each kind's words are a Record over its list, so a kind
102
+ // added to a list without them does not compile.
103
+ const CODE_NOUN = {
104
+ metasploit: "Metasploit modules",
105
+ exploit_db: "Exploit-DB entries",
106
+ github_poc: "GitHub proof-of-concept repositories",
107
+ vendor_poc: "vendor proofs of concept",
108
+ };
109
+ const DETECTION_NOUN_METHOD = {
110
+ nuclei_template: "Nuclei detection templates (nuclei_template)",
111
+ github_checker: "GitHub checker repositories (github_checker: GitHub repositories named or described as a checker or scanner)",
112
+ };
113
+ const UNCLASSIFIED_KIND = "A kind on neither list is one this server does not classify: the note counts it as neither.";
114
+ const CVE_INTEL_METHOD = `EchelonGraph's per-CVE enrichment (GET /api/v1/public/cves/:id/enrichment). cwes: CWE classifications stored from NVD, GitHub (GHSA) and CVE.org records, each with the source that gave it, named from the MITRE CWE catalog EchelonGraph embeds. exploits: EchelonGraph's cve_exploits records, whose data model holds public exploit code, namely ${andList(EXPLOIT_CODE_KINDS.map((k) => `${CODE_NOUN[k]} (${k})`))}, and detection checks, namely ${andList(DETECTION_KINDS.map((k) => DETECTION_NOUN_METHOD[k]))}, which check whether a system is affected and are not necessarily exploit code, filled by a curated seed and by one poller per automated source, each run only where it is enabled, so which sources are current depends on the deployment. ${UNCLASSIFIED_KIND} affected_packages and fixed_versions: package-ecosystem advisory data, each row naming its source. timeline: EchelonGraph's enrichment history for the CVE, newest first.`;
115
+ // #3221: the kinds, split as egkev.ExploitCodeKinds splits them (#3038, #3040) and /pulse shows
116
+ // them: exploit code, and the two detection kinds. Until #3221 the field listed nuclei_template
117
+ // beside the exploit-code kinds, and not github_checker, a kind core-backend's data model has held
118
+ // since #3040 (exploits.KindGitHubChecker). The text says what each kind is, not which rows carry
119
+ // it. #3267: built from the two lists the note counts by, so the field cannot name a kind the note
120
+ // counts otherwise, and it says what the note does with any other kind.
121
+ const EXPLOIT_KIND_DESCRIPTION = `Exploit code: ${orList(EXPLOIT_CODE_KINDS)}. A detection check, which checks whether a system is affected and is not necessarily exploit code: ${orList(DETECTION_KINDS.map((k) => `${k} (${DETECTION_GLOSS[k]})`))}. A kind not named here is one this server does not classify: the note counts it as neither.`;
122
+ const EXPLOITS_BY_KIND_DESCRIPTION = `Every reference on record counted by kind (the values of each exploit's kind), past the 10 listed; the counts under ${andList(DETECTION_KINDS)} are detection checks, not exploit code, those under ${andList(EXPLOIT_CODE_KINDS)} are exploit code, and those under any other kind are of a kind this server does not classify, counted as neither.`;
78
123
  const FIXED_BRANCHES_DESCRIPTION = 'fixed_branches lists each affected range of the package on record: introduced, and fixed (the range\'s fix) or last_affected (its last affected version, where no fix is on record), each null when absent, with advisory_id (the OSV record that published the range) and source (the loader\'s label for that record), both null on a row loaded before they were stored. Ranges with an advisory_id are in the order their record lists them (records in id order); ranges without one are oldest first. To pick the range for an installed version, compare in the ecosystem\'s version order (release numbers part by part, as numbers: 2.4 is below 2.13.0): the version is in a range when it is at or above introduced ("0" is the first version) and below fixed, or at or below last_affected; its fix is that range\'s fixed, or, while another range still holds that, the other range\'s fixed (none past a range without one); a version in no range is outside every range on record. fixed_branches is null where the package\'s ranges were never loaded, and [] where no version range is on record (the advisory gives none, only commit ranges, or more than are stored), which is not a finding that no fix exists: fixed_version can still name one. fixed_version is one range\'s fix, kept for compatibility (one per package: the range the loader kept, the advisory\'s last as a rule), not the fix for every affected range: for CVE-2021-44228, its advisory gives log4j-core three ranges, and fixed_version 2.12.2 is the fix for the one from 2.4, while the range holding 2.14.1 (from 2.13.0) is fixed in 2.15.0.';
124
+ // #3039: a Nuclei template is a detection check, not necessarily exploit code (core-backend
125
+ // egkev.ExploitCodeKinds, #3038; /pulse words it so since #3022). The head says "exploit code,
126
+ // detection templates" where it said "public exploit code"; the method and the note say what a
127
+ // template is, and count templates apart. The room: "the newest" and "per-CVE" (14 characters).
79
128
  // #2876: the exploit dates are named for what they are. Within the 100 characters of headroom
80
129
  // under the 2048-character client cut (prompts-resources.test.mjs), the room came from tighter
81
130
  // glosses and from the input hint, which inputSchema's cve_id description carries.
@@ -84,7 +133,10 @@ const FIXED_BRANCHES_DESCRIPTION = 'fixed_branches lists each affected range of
84
133
  // still-affected 4.17.21 (ranges [0, 4.17.21) and [4.0.0, 4.18.0); the fix is 4.18.0). It now
85
134
  // states the walk FIXED_BRANCHES_DESCRIPTION and branchSentences state, in fewer characters than
86
135
  // the old rule (a range's bounds folded into the list of its fields), so the headroom holds.
87
- export const CVE_INTEL_DESCRIPTION = `Weakness, public exploit code, affected packages and fixed versions for one CVE, from EchelonGraph's per-CVE enrichment. Returns cwes (each cwe_id with name and source), exploits (each with kind, source_name, source_url, source_published_at (the source's date, null if none), eg_first_seen_at (when EchelonGraph recorded it) and verified_status; at most 10, verified first) with exploits_total, exploits_capped, exploits_by_kind and exploits_by_status, affected_packages (ecosystem, package_name, version_range, fixed_version, fixed_branches), fixed_versions (each fixed version with the vulnerable_range it fixes) and timeline (the newest enrichment-history rows, with timeline_total). fixed_branches lists each affected range on record (from introduced ("0" is the first version) to below fixed, or through last_affected, with advisory_id (its OSV record) and source); in the ecosystem's version order, an affected version's fix is the first fixed above it that no range of its row holds (none past a range with no fixed). fixed_version is one range's fix, kept for compatibility, not the fix for every affected range. fixed_branches [] is not a finding that no fix exists. verified_status is the source's label, not a guarantee the exploit works on a given system. An empty exploits list is not evidence that no public exploit exists: it covers only the sources EchelonGraph ingests, and which are polled depends on the deployment. A section the API could not read is named in coverage.sections_failed and left out of data, never relayed as an empty list. Its structured result carries notes, with data, which the first text block holds whole up to 30,000 characters; its coverage names the sections relayed, failed and left out. ${TEXT_BUDGET_DESCRIPTION}`;
136
+ // #3221: "detection checks", /pulse's word for Nuclei templates and GitHub checker repositories
137
+ // together (ExploitWatch.tsx DETECTION_CHECKS_HEADING), where the head said "detection templates",
138
+ // which a checker repository is not; 3 characters shorter. The kinds are the outputSchema's.
139
+ export const CVE_INTEL_DESCRIPTION = `Weakness, exploit code, detection checks, affected packages and fixed versions for one CVE, from EchelonGraph's per-CVE enrichment. Returns cwes (each cwe_id with name and source), exploits (each with kind, source_name, source_url, source_published_at (the source's date, null if none), eg_first_seen_at (when EchelonGraph recorded it) and verified_status; at most 10, verified first) with exploits_total, exploits_capped, exploits_by_kind and exploits_by_status, affected_packages (ecosystem, package_name, version_range, fixed_version, fixed_branches), fixed_versions (each fixed version with the vulnerable_range it fixes) and timeline (newest enrichment-history rows, with timeline_total). fixed_branches lists each affected range on record (from introduced ("0" is the first version) to below fixed, or through last_affected, with advisory_id (its OSV record) and source); in the ecosystem's version order, an affected version's fix is the first fixed above it that no range of its row holds (none past a range with no fixed). fixed_version is one range's fix, kept for compatibility, not the fix for every affected range. fixed_branches [] is not a finding that no fix exists. verified_status is the source's label, not a guarantee the exploit works on a given system. An empty exploits list is not evidence that no public exploit exists: it covers only the sources EchelonGraph ingests, and which are polled depends on the deployment. A section the API could not read is named in coverage.sections_failed and left out of data, never relayed as an empty list. Its structured result carries notes, with data, which the first text block holds whole up to 30,000 characters; its coverage names the sections relayed, failed and left out. ${TEXT_BUDGET_DESCRIPTION}`;
88
140
  // ── outputSchema ──
89
141
  const Rows = (shape) => z.array(z.looseObject(shape));
90
142
  const CountMap = z.record(z.string(), z.number());
@@ -97,7 +149,7 @@ function schemas() {
97
149
  cve_id: z.string(),
98
150
  cwes: Rows({ cwe_id: opt(z.string()), name: opt(z.string()), source: opt(z.string()) }).optional(),
99
151
  exploits: Rows({
100
- kind: opt(z.string()).describe("metasploit, exploit_db, nuclei_template, github_poc or vendor_poc."),
152
+ kind: opt(z.string()).describe(EXPLOIT_KIND_DESCRIPTION),
101
153
  source_name: opt(z.string()),
102
154
  source_url: opt(z.string()),
103
155
  description: opt(z.string()),
@@ -110,7 +162,7 @@ function schemas() {
110
162
  }).optional(),
111
163
  exploits_total: z.number().int().nullable().optional().describe("Every exploit reference on record for the CVE, not only those listed in exploits."),
112
164
  exploits_capped: z.boolean().optional().describe("true: exploits lists fewer references than exploits_total."),
113
- exploits_by_kind: CountMap.optional(),
165
+ exploits_by_kind: CountMap.optional().describe(EXPLOITS_BY_KIND_DESCRIPTION),
114
166
  exploits_by_status: CountMap.optional(),
115
167
  affected_packages: Rows({
116
168
  ecosystem: opt(z.string()),
@@ -178,6 +230,70 @@ function cweSentence(cve, cwes) {
178
230
  });
179
231
  return `${cve} is classified under ${listed(named)}.`;
180
232
  }
233
+ const DETECTION_NOUN = {
234
+ nuclei_template: ["Nuclei detection template", "Nuclei detection templates"],
235
+ github_checker: ["GitHub checker repository", "GitHub checker repositories"],
236
+ };
237
+ // /pulse's DETECTION_TEMPLATE_NOTE and CHECKER_REPOSITORY_NOTE, each lower-cased and without its
238
+ // period, to follow a colon, a semicolon or an opening parenthesis.
239
+ const DETECTION_NOTE = "a detection template checks whether a system is affected; it is not necessarily exploit code";
240
+ const CHECKER_NOTE = "a GitHub repository named or described as a checker or scanner checks whether a system is affected; it is not necessarily exploit code";
241
+ // Both kinds: /pulse's two notes in sequence, as the page prints them, never one merged sentence.
242
+ const BOTH_DETECTION_NOTES = `${DETECTION_NOTE}. ${CHECKER_NOTE[0].toUpperCase()}${CHECKER_NOTE.slice(1)}`;
243
+ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
244
+ const detectedTotal = (c) => DETECTION_KINDS.reduce((s, k) => s + c[k], 0);
245
+ // "1 Nuclei detection template", "3 GitHub checker repositories": each detection kind present.
246
+ const detectionParts = (c) => DETECTION_KINDS.filter((k) => c[k] > 0).map((k) => plural(c[k], ...DETECTION_NOUN[k]));
247
+ // What the detection kinds present are: the template's note, the checker's, or both in sequence.
248
+ const detectionNote = (c) => c.nuclei_template > 0 && c.github_checker > 0 ? BOTH_DETECTION_NOTES : c.github_checker > 0 ? CHECKER_NOTE : DETECTION_NOTE;
249
+ // #3267: a kind on neither list. The note names it only when it reads as a kind (a lower-case
250
+ // identifier, as every exploits.Kind* value is), so the note never carries other text the API sent.
251
+ const UNCLASSIFIED_NOUN = ["reference of a kind this server does not classify", "references of a kind this server does not classify"];
252
+ const KIND_TOKEN = /^[a-z][a-z0-9_]{0,63}$/;
253
+ const unknownKindsOf = (kinds) => [...new Set(kinds.filter((k) => k !== undefined && KIND_TOKEN.test(k) && !isExploitCodeKind(k) && !isDetectionKind(k)))].sort();
254
+ const unclassifiedNote = (kinds) => kinds.length === 0
255
+ ? "a reference of no kind this server classifies is counted as neither exploit code nor a detection check"
256
+ : kinds.length === 1
257
+ ? `the kind ${kinds[0]} is one this server does not classify: it is counted as neither exploit code nor a detection check, and a newer release of this server may classify it`
258
+ : `the kinds ${listed(kinds)} are ones this server does not classify: they are counted as neither exploit code nor a detection check, and a newer release of this server may classify them`;
259
+ const capitalized = (s) => `${s[0].toUpperCase()}${s.slice(1)}`;
260
+ // The listed rows by class.
261
+ function countsIn(rows) {
262
+ const kinds = rows.map((r) => strAt(r, "kind"));
263
+ const detections = Object.fromEntries(DETECTION_KINDS.map((k) => [k, kinds.filter((x) => x === k).length]));
264
+ const code = kinds.filter(isExploitCodeKind).length;
265
+ return { code, detections, unclassified: rows.length - code - detectedTotal(detections), unknownKinds: unknownKindsOf(kinds) };
266
+ }
267
+ // Every reference on record by class: exploits_by_kind's counts where the answer carries the map,
268
+ // the listed rows' where they are all of them, undefined otherwise. With the map, what its
269
+ // exploit-code and detection kinds do not account for of exploits_total is unclassified.
270
+ function kindCounts(d, rows, total) {
271
+ const byKind = field(d, "exploits_by_kind");
272
+ if (byKind !== null && typeof byKind === "object" && !Array.isArray(byKind)) {
273
+ const at = (k) => numAt(d, "exploits_by_kind", k) ?? 0;
274
+ const detections = Object.fromEntries(DETECTION_KINDS.map((k) => [k, at(k)]));
275
+ const code = EXPLOIT_CODE_KINDS.reduce((s, k) => s + at(k), 0);
276
+ return {
277
+ code,
278
+ detections,
279
+ unclassified: Math.max(0, total - code - detectedTotal(detections)),
280
+ unknownKinds: unknownKindsOf(Object.keys(byKind).filter((k) => at(k) > 0)),
281
+ };
282
+ }
283
+ return rows.length === total ? countsIn(rows) : undefined;
284
+ }
285
+ // "1 public exploit reference, 1 Nuclei detection template and 1 reference of a kind this server
286
+ // does not classify": each class present, exploit code first.
287
+ const classParts = (c) => [
288
+ ...(c.code > 0 ? [plural(c.code, "public exploit reference", "public exploit references")] : []),
289
+ ...detectionParts(c.detections),
290
+ ...(c.unclassified > 0 ? [plural(c.unclassified, ...UNCLASSIFIED_NOUN)] : []),
291
+ ];
292
+ // What the classes present beside exploit code are: the detection note(s), then the unclassified
293
+ // kind's, each its own sentence.
294
+ const classNotes = (c) => [...(detectedTotal(c.detections) > 0 ? [detectionNote(c.detections)] : []), ...(c.unclassified > 0 ? [unclassifiedNote(c.unknownKinds)] : [])]
295
+ .map((n, i) => (i === 0 ? n : capitalized(n)))
296
+ .join(". ");
181
297
  function exploitSentences(cve, d, rows) {
182
298
  const total = numAt(d, "exploits_total");
183
299
  const verified = numAt(d, "exploits_by_status", "verified");
@@ -191,7 +307,13 @@ function exploitSentences(cve, d, rows) {
191
307
  `exploits is empty and the answer carries no exploits_total, so it does not count the references on record for ${cve}; an empty list is not evidence that no public exploit exists.`,
192
308
  ];
193
309
  }
194
- return [`exploits lists ${rows.length} public exploit reference${rows.length === 1 ? "" : "s"} for ${cve}, ${verifiedRows} with verified_status verified; the answer carries no exploits_total, so it does not say how many are on record.${cut}`];
310
+ const shown = countsIn(rows);
311
+ const what = shown.code === rows.length
312
+ ? `${plural(rows.length, "public exploit reference", "public exploit references")}`
313
+ : shown.code === 0 && shown.unclassified === 0
314
+ ? `${listed([...detectionParts(shown.detections), "nothing else"])} (${classNotes(shown)})`
315
+ : `${plural(rows.length, "reference", "references")}: ${listed(classParts(shown))} (${classNotes(shown)})`;
316
+ return [`exploits lists ${what} for ${cve}, ${verifiedRows} with verified_status verified; the answer carries no exploits_total, so it does not say how many are on record.${cut}`];
195
317
  }
196
318
  if (total === 0) {
197
319
  return [
@@ -200,7 +322,23 @@ function exploitSentences(cve, d, rows) {
200
322
  }
201
323
  const v = verified === undefined ? "" : `, ${verified} of them with verified_status verified`;
202
324
  const cut = capped === true ? ` exploits_capped is true: exploits lists ${rows.length} of the ${total}, verified first.` : "";
203
- return [`EchelonGraph holds ${total} public exploit reference${total === 1 ? "" : "s"} for ${cve} (exploits_total)${v}.${cut}`];
325
+ const counts = kindCounts(d, rows, total);
326
+ if (counts === undefined) {
327
+ return [
328
+ `EchelonGraph holds ${plural(total, "reference", "references")} for ${cve} (exploits_total)${v}; the answer carries no exploits_by_kind, so it does not say how many are exploit code and how many detection checks, Nuclei detection templates or GitHub checker repositories (${BOTH_DETECTION_NOTES}).${cut}`,
329
+ ];
330
+ }
331
+ const detected = detectedTotal(counts.detections);
332
+ if (detected === 0 && counts.unclassified === 0) {
333
+ return [`EchelonGraph holds ${plural(total, "public exploit reference", "public exploit references")} for ${cve} (exploits_total)${v}.${cut}`];
334
+ }
335
+ // Only detection checks: no exploit code, and no kind this server cannot vouch for.
336
+ if (counts.code === 0 && counts.unclassified === 0) {
337
+ return [
338
+ `EchelonGraph holds ${listed(detectionParts(counts.detections))} for ${cve} and nothing else in exploits (exploits_total ${total}): ${detectionNote(counts.detections)}. It holds no Metasploit module, Exploit-DB entry or proof of concept for ${cve}, which is not evidence that none exists.${cut}`,
339
+ ];
340
+ }
341
+ return [`EchelonGraph holds ${plural(total, "reference", "references")} for ${cve} (exploits_total)${v}: ${listed(classParts(counts))}; ${classNotes(counts)}.${cut}`];
204
342
  }
205
343
  function listSentence(name, rows) {
206
344
  const limit = API_ROW_LIMIT[name] ?? 0;
@@ -209,6 +347,15 @@ function listSentence(name, rows) {
209
347
  const cut = rows.length >= limit ? `, the most the API returns per CVE, so there may be more` : "";
210
348
  return `${name} lists ${rows.length} row${rows.length === 1 ? "" : "s"}${cut}.`;
211
349
  }
350
+ // #3285 (3.1.0's lane P): affected_packages lists a package once per stored ecosystem string, as
351
+ // cve_remediation's fixed_branches does, so CVE-2025-48013 has two rows for
352
+ // drupal/quick_node_block, under Packagist:https://packages.drupal.org/8 ([0, 2.0.1)) and Packagist
353
+ // ([0, 2.0.0)), each of one range, and "no range of the row holds", read row by row, gave a
354
+ // Packagist 1.0.0 the fix 2.0.0, which the Drupal row still holds affected. Rows the matcher reads
355
+ // as one package (packageRecords.ts, core-backend registryRecordKey: Packagist or Maven, bare or
356
+ // with a repository URL; a release suffix never) are now said of together, with the walk over all
357
+ // their ranges, which gives 2.0.1, the matcher's fixed_in, and the sentence on rows of several
358
+ // ranges names the others.
212
359
  // #2817: where fixed_version is not the whole answer. A row with more than one range in
213
360
  // fixed_branches has a fix per range; a row with fixed_branches null has only fixed_version on
214
361
  // record, and so does a row with fixed_branches [] and a fixed_version (no version range on
@@ -232,9 +379,12 @@ function branchSentences(rows) {
232
379
  const multi = [];
233
380
  const unknown = [];
234
381
  const noRange = [];
382
+ // #3285: rows the matcher reads as one package (packageRecords.ts) are said of together, below.
383
+ const { twice } = packagesListedTwice(rows);
384
+ const together = new Set(twice.flatMap((g) => g.rows));
235
385
  rows.forEach((r, i) => {
236
386
  const fb = field(r, "fixed_branches");
237
- if (Array.isArray(fb) && fb.length > 1)
387
+ if (Array.isArray(fb) && fb.length > 1 && !together.has(i))
238
388
  multi.push(named(r, i));
239
389
  if (fb === null)
240
390
  unknown.push(named(r, i));
@@ -245,6 +395,11 @@ function branchSentences(rows) {
245
395
  if (multi.length) {
246
396
  out.push(`${multi.length} affected_packages row${multi.length === 1 ? " lists" : "s list"} more than one affected range in fixed_branches (${few(multi)}): there, fixed_version is one range's fix, and an installed version's fix is the first fixed above it that no range of the row holds: the fixed of the range that holds it, or, while another range still holds that version, that range's fixed (none where a range on the way has no fixed).`);
247
397
  }
398
+ if (twice.length) {
399
+ const rowsOf = twice.reduce((t, g) => t + g.rows.length, 0);
400
+ const pkgs = twice.map((g) => `${g.name} (${listed(g.ecosystems)})`);
401
+ out.push(`${rowsOf} affected_packages rows are ${twice.length} package${twice.length === 1 ? "" : "s"} (${few(pkgs)}): ${ONE_PACKAGE_RULE("Rows")}, so there an installed version's fix is the first fixed above it that no range of those rows holds: the fixed of the range that holds it, or, while another range of them still holds that version, that range's fixed (none where a range on the way has no fixed); each row's fixed_version is one range's fix.`);
402
+ }
248
403
  if (unknown.length) {
249
404
  out.push(`${unknown.length} affected_packages row${unknown.length === 1 ? " carries" : "s carry"} fixed_branches null (${few(unknown)}): ${unknown.length === 1 ? "its" : "their"} ranges are not on record, so fixed_version, one range's fix, is all the answer holds, and it need not be the fix for a given installed version.`);
250
405
  }
@@ -40,6 +40,7 @@ import { ANNOTATIONS, CVE_ID, SHOWN_BASE, api, badInput, checked, crashed, envel
40
40
  import { answering, FULL_ARG, TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
41
41
  import { fixStatusSchema } from "../fixStatus.js";
42
42
  import { MATCH_HEADERS } from "./check_affected.js";
43
+ import { ONE_PACKAGE_RULE, packagesListedTwice } from "./packageRecords.js";
43
44
  const TOOL = "cve_remediation";
44
45
  const SECTIONS = ["cisa", "fixed_branches", "vendor_remediations", "fix_references", "patches"];
45
46
  const KINDS = ["vendor_fix", "workaround", "mitigation", "no_fix_planned", "none_available", "other"];
@@ -124,6 +125,10 @@ function schemas() {
124
125
  advisory_id: opt(z.string()),
125
126
  source: opt(z.string()),
126
127
  }))),
128
+ fixed_branches_truncated: z
129
+ .object({ total: z.number().int(), returned: z.number().int() })
130
+ .optional()
131
+ .describe("Present only where the structured result's cut (DATA CUT) kept this package's first ranges, not all: total, its ranges in the answer; returned, the ones data keeps. The same call with full: true returns every range."),
127
132
  }))),
128
133
  vendor_remediations: opt(z.array(VendorAdvisory)),
129
134
  fix_references: opt(z.array(z.looseObject({ url: opt(z.string()), tags: opt(z.array(z.string())), source: opt(z.string()) }))),
@@ -148,24 +153,50 @@ function schemas() {
148
153
  }
149
154
  let built;
150
155
  const schemasOnce = () => (built ??= schemas());
151
- // The text cut: vendor texts go first, the kinds, states and URLs stay.
152
- const CUT = {
153
- rows: "vendor_remediations",
154
- levels: [
155
- { keep: ["vendor", "vendor_advisory_id", "withdrawn", "remediation_state", "remediation_kinds", "items_total", ["items", ["kind", "source_category", "text", "url", "fixed_build"]]], clip: 400, cap: 16 },
156
- { keep: ["vendor", "vendor_advisory_id", "remediation_state", "remediation_kinds", ["items", ["kind", "url"]]], clip: 2048, cap: 8 },
157
- ],
158
- whole: "get_vendor_advisory returns any one of these advisories by its vendor and vendor_advisory_id, whole in its structuredContent.data with full: true, with every remediation item it lists.",
159
- };
160
156
  // #3013: the structured result's cut. CVE-2021-44228's answer is 75,963 characters of structured
161
157
  // result, 61,248 of them its 20 vendor_remediations, whose items carry the vendor's text (about 550
162
158
  // characters an item, the payload) and its product_ids (about 550, the vendor's own identifiers).
163
159
  // Before any advisory leaves, every advisory's items are thinned: product_ids first, keeping each
164
160
  // text; then the texts, keeping each item's kind, source_category, url and fixed_build, so every
165
161
  // advisory stays named with its state, kinds and links (get_vendor_advisory returns one whole).
166
- // Only then do advisories leave from the end, then the patches, then any other list at the top of
167
- // data. fix_status and fixed_in (#2841) are not lists, and installed is an object, so none of them
168
- // is ever cut.
162
+ // fix_status and fixed_in (#2841) are not lists, and installed is an object, so none of them is
163
+ // ever cut.
164
+ //
165
+ // #3139: through 3.0.0 advisories then left from the end, before patches, and fixed_branches had no
166
+ // step, so CVE-2023-44487 (KEV-listed) kept 2 of its 20 advisories at 29,704 characters while
167
+ // fixed_branches (13,581 characters, 50 rows) and patches (7,471, 30) stayed whole, and the note
168
+ // named no tool that lists the other 18 (measured on hosted 3.0.0, 2026-10-11; also CVE-2018-12126
169
+ // 6/20, CVE-2019-11135 2/3, CVE-2018-3639 16/20). Now vendor_remediations is a keepAll step: every
170
+ // advisory stays, thinned, while patches (each to its vendor, patch_url and published_at: the
171
+ // erratum, which vendor_remediations names again for Red Hat) and then fixed_branches (each to its
172
+ // package, range, fixed_version and its ranges' introduced, fixed and last_affected, what the
173
+ // sbom_review prompt walks; the per-range source, advisory_id and dependents_count go) are thinned
174
+ // and then lose rows from the end, and then any other list at the top of data (fix_references).
175
+ // Only when all of that is cut and the result is still past the budget do advisories leave, from
176
+ // the end; whenever one does, the note names vendor_advisories_for_cve, which lists every vendor
177
+ // advisory naming the CVE, as where the rest are.
178
+ // #3139's review: that order dropped 2 of CVE-2023-44487's 30 patches while its 50 fixed_branches
179
+ // rows stayed whole (13,581 characters). Decided (2026-10-11): fixed_branches is thinned before any
180
+ // patch row leaves. The order is now:
181
+ // 1. vendor_remediations thinned, as above, every advisory kept (keepAll);
182
+ // 2. fixed_branches thinned (each package to its ecosystem, package_name, version_range,
183
+ // fixed_version and its ranges' introduced, fixed and last_affected: each range's source and
184
+ // advisory_id go, and the package's dependents_count and source), then each package's ranges
185
+ // capped at one number, the most that fit (at least 1), a package with more carrying
186
+ // fixed_branches_truncated with its total and returned; every package kept (keepAll);
187
+ // 3. patches thinned (vendor, patch_url and published_at), every patch kept (keepAll);
188
+ // 4. any other list at the top of data (fix_references), cut from the end;
189
+ // 5. then patch rows leave from the end, then fixed_branches rows, and only then advisories
190
+ // (textBudget.ts: the last keepAll step's entries leave first);
191
+ // 6. the room left goes back to each package's ranges, in the order of fixed_branches (giveBack):
192
+ // step 2 caps them before the patches are cut, and a cap that held while every patch was
193
+ // whole would otherwise stay after the patches are thinned.
194
+ // Round 2 replayed CVE-2023-44487 at 28 of 30 patches: fix_references (2,191 characters) were cut
195
+ // after patch rows left, and the 30 thinned were about 240 characters past the budget beside them.
196
+ // Round 3 (#3139's second review, 2026-10-11): patches is a keepAll step, so fix_references are cut
197
+ // first and CVE-2023-44487 keeps all 20 advisories, all 50 fixed_branches and all 30 patches, each
198
+ // thinned (test/cve_remediation.test.mjs); round 1 (patches cut before fixed_branches was thinned)
199
+ // kept 11 patches without fix_references.
169
200
  //
170
201
  // #3013's review: the second level leaves every item's text out of data, and the text is the
171
202
  // vendor's step itself (a workaround's or mitigation's wording), which the triage_cve and
@@ -175,6 +206,22 @@ const CUT = {
175
206
  // with full: true returns this data whole; the prompts send the model there when it is cut.
176
207
  const ADVISORY_FIELDS = ["vendor", "vendor_display_name", "vendor_advisory_id", "vendor_published_at", "withdrawn", "remediation_state", "remediation_kinds", "items_total"];
177
208
  const ITEM_TEXT_ELSEWHERE = "No item in data keeps its text, the vendor's own wording of the step (a workaround or mitigation among them): get_vendor_advisory, given an advisory's vendor and its vendor_advisory_id as advisory_id, returns it among that advisory's remediations, whole in its structuredContent.data with full: true.";
209
+ const FIXED_BRANCHES_THIN = ["ecosystem", "package_name", "version_range", "fixed_version", ["fixed_branches", ["introduced", "fixed", "last_affected"]]];
210
+ // A package whose ranges a cut keeps the first `cap` of: fixed_branches_truncated with its total
211
+ // and returned. The structured cut's step 2 marks them so, and (3.1.0's phase A review) the text's.
212
+ const rangesTruncated = (row, cap) => {
213
+ const n = Array.isArray(row.fixed_branches) ? row.fixed_branches.length : 0;
214
+ return n > cap ? { fixed_branches_truncated: { total: n, returned: cap } } : {};
215
+ };
216
+ // 3.1.0's phase C review: the note's fix walk ("the first fixed above it that no range of its
217
+ // package holds", branchSentence below), over a capped package's kept ranges alone, can name a
218
+ // version a range left out still holds (test/cve_remediation.test.mjs: a package whose every fix
219
+ // the next range holds walks to c.0.0 over its first c ranges, to N.0.0 over all N). Both notes say
220
+ // so of a capped package, and where its fix is read: data with full: true (the text beside a cut
221
+ // data, and data itself), or data as it is (the text beside a data the call asked whole).
222
+ const CAPPED_FIX_DATA = "its fix, walked over its kept ranges alone, can be wrong (read that package's fix with full: true)";
223
+ const cappedFixText = (full) => `A capped package's fix, walked over its kept ranges alone, can be wrong: read that package's fix ${full ? "with full: true" : "from structuredContent.data"}.`;
224
+ const PATCHES_THIN = ["vendor", "patch_url", "published_at"];
178
225
  const CVE_REMEDIATION_DATA = {
179
226
  steps: [
180
227
  {
@@ -183,15 +230,96 @@ const CVE_REMEDIATION_DATA = {
183
230
  [...ADVISORY_FIELDS, ["items", ["kind", "source_category", "source_subcategory", "text", "text_truncated", "text_chars", "url", "fixed_build", "product_count"]]],
184
231
  [...ADVISORY_FIELDS, ["items", ["kind", "source_category", "url", "fixed_build"]]],
185
232
  ],
233
+ keepAll: true,
234
+ },
235
+ {
236
+ lists: ["fixed_branches"],
237
+ thin: [
238
+ FIXED_BRANCHES_THIN,
239
+ {
240
+ keep: FIXED_BRANCHES_THIN,
241
+ capped: "fixed_branches",
242
+ least: 1,
243
+ mark: rangesTruncated,
244
+ said: (cap) => `each package's fixed_branches keeping at most its first ${cap} range${cap === 1 ? "" : "s"}, a package with more carrying fixed_branches_truncated with its total and returned, and ${CAPPED_FIX_DATA}`,
245
+ },
246
+ ],
247
+ keepAll: true,
248
+ giveBack: true,
186
249
  },
187
- { lists: ["patches"] },
250
+ // #3139 round 3: every patch row stays, thinned (keepAll), while the step after it (any other
251
+ // list at the top of data: fix_references) is cut; patch rows leave only after that, and
252
+ // before any fixed_branches row or advisory (the last keepAll step's entries leave first).
253
+ { lists: ["patches"], thin: [PATCHES_THIN], keepAll: true },
188
254
  ],
189
- // Said where the advisories kept are thinned to a level whose items keep no text.
190
- page: (_t, _d, at) => {
255
+ // Said where the advisories kept are thinned to a level whose items keep no text, and (#3139)
256
+ // wherever an advisory is left out.
257
+ page: (t, _d, at) => {
191
258
  const items = at.keeps.vendor_remediations?.find((x) => typeof x !== "string" && x[0] === "items");
192
- return items && !items[1].includes("text") ? ITEM_TEXT_ELSEWHERE : "";
259
+ const v = t.vendor_remediations;
260
+ return [items && !items[1].includes("text") ? ITEM_TEXT_ELSEWHERE : "", v && v.returned < v.total ? advisoriesElsewhere(v.total - v.returned, "data") : ""].filter(Boolean).join(" ");
193
261
  },
194
262
  };
263
+ // #3139: where the advisories a cut leaves out are read. vendor_advisories_for_cve lists every one
264
+ // naming the CVE, with its vendor and vendor_advisory_id (CVE-2023-44487's 20 at 15,505
265
+ // characters on hosted 3.0.0), and get_vendor_advisory then returns any one whole. #3272: the
266
+ // first text block's cut says it too, of the advisories it leaves out.
267
+ const advisoriesElsewhere = (n, of) => `vendor_advisories_for_cve, given this CVE, lists every vendor advisory naming it, the ${n} left out of ${of} among them, each with its vendor and vendor_advisory_id.`;
268
+ // The text cut: vendor texts go first, the kinds, states and URLs stay.
269
+ // #3272: through 3.0.2 the first text block was still cut the 3.0.0 way, which #3139 fixed only in
270
+ // structuredContent.data: CUT named no list beside the advisories, so once its leanest level did not
271
+ // fit, advisories left the text from the end while fixed_branches, patches and fix_references stayed
272
+ // whole beside them. On the recorded CVE-2023-44487 answer (test/fixtures/cve_remediation-
273
+ // CVE-2023-44487.json) the text kept 19 of 20 advisories beside 50/50 fixed_branches and 30/30
274
+ // patches, and the same answer with 1, 5, 10 and 16 more fixed_branches packages 18, 13, 8 and 2
275
+ // (from 18 more, the text fell through to step 4's every-list cap and kept 20 again); the TEXT CUT
276
+ // note named get_vendor_advisory, which needs the advisory's ID, and never vendor_advisories_for_cve.
277
+ // Now the text follows the structured cut's order (beforeRows, textBudget.ts step 3): the advisories
278
+ // are cut level by level with every list beside them whole; once the leanest level does not fit,
279
+ // fixed_branches is cut to FIXED_BRANCHES_THIN's fields, then patches to PATCHES_THIN's, every row
280
+ // kept; then fix_references, then patches, then fixed_branches lose rows from the end, each emptied
281
+ // before the next loses any; only then do advisories leave, from the end, and the note names
282
+ // vendor_advisories_for_cve as where the rest are. The recorded answer now keeps all 20 advisories
283
+ // beside every fixed_branches row (thinned), patch and fix_reference.
284
+ // 3.1.0's phase A review: the text kept every range of each fixed_branches row it kept, capping no
285
+ // package's ranges as the structured cut's step 2 does, so a package too long to fit alone left the
286
+ // text with every package after it, and the lists emptied before it stayed empty: CVE-2023-44487's
287
+ // answer with one package of N ranges put first among 51 kept 11 of 20 references at 150 ranges, 51
288
+ // packages, 14 patches and no reference at 200, 43 packages and no patch at 250, and, from 400
289
+ // ranges, no package, patch or reference in 6,347 characters, where 3.0.2's every-list cap kept 32
290
+ // packages, 30 patches and 20 references at every size. Now, once fixed_branches and patches are
291
+ // thinned and the text still does not fit, each package's ranges are cut to its first c, the most
292
+ // that fit with patches and references as they stand (at least 1), every package kept and marked
293
+ // fixed_branches_truncated as data marks it; only if 1 does not fit do references, then patches,
294
+ // then packages leave from the end, and the room they leave goes back to c. The note names the
295
+ // packages so cut and says data holds every range.
296
+ const CUT = {
297
+ rows: "vendor_remediations",
298
+ levels: [
299
+ { keep: ["vendor", "vendor_advisory_id", "withdrawn", "remediation_state", "remediation_kinds", "items_total", ["items", ["kind", "source_category", "text", "url", "fixed_build"]]], clip: 400, cap: 16 },
300
+ { keep: ["vendor", "vendor_advisory_id", "remediation_state", "remediation_kinds", ["items", ["kind", "url"]]], clip: 2048, cap: 8 },
301
+ ],
302
+ sides: [
303
+ {
304
+ list: "fixed_branches",
305
+ keep: FIXED_BRANCHES_THIN,
306
+ beforeRows: true,
307
+ capped: {
308
+ list: "fixed_branches",
309
+ least: 1,
310
+ mark: rangesTruncated,
311
+ name: (e) => `${strAt(e, "package_name") ?? "?"} (${strAt(e, "ecosystem") ?? "?"})`,
312
+ said: (cap, names, full) => `In it, each package's fixed_branches keeps at most its first ${cap} range${cap === 1 ? "" : "s"}: ${names.length <= 3 ? listed(names) : `${names.slice(0, 3).join(", ")} and ${names.length - 3} more`} ${names.length === 1 ? "has more, carrying" : "have more, each carrying"} fixed_branches_truncated with its total and returned, and structuredContent.data holds every range${full ? " with full: true" : ""}. ${cappedFixText(full)}`,
313
+ },
314
+ },
315
+ { list: "patches", keep: PATCHES_THIN, beforeRows: true },
316
+ { list: "fix_references", beforeRows: true },
317
+ ],
318
+ whole: "get_vendor_advisory returns any one of these advisories by its vendor and vendor_advisory_id, whole in its structuredContent.data with full: true, with every remediation item it lists.",
319
+ page: (inText, rows) => advisoriesElsewhere(rows - inText, "the first text block"),
320
+ };
321
+ // The text cut, for the tests that replay it (test/cve_remediation.test.mjs).
322
+ export { CUT as CVE_REMEDIATION_TEXT_CUT };
195
323
  const listed = (xs) => (xs.length <= 1 ? (xs[0] ?? "") : `${xs.slice(0, -1).join(", ")} and ${xs.at(-1)}`);
196
324
  const arr = (v) => (Array.isArray(v) ? v : null);
197
325
  const strs = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === "string") : null);
@@ -225,6 +353,21 @@ function vendorSentences(cve, rows) {
225
353
  out.push(`${few(states.withdrawn)} ${states.withdrawn.length === 1 ? "was" : "were"} withdrawn by ${states.withdrawn.length === 1 ? "its" : "their"} vendor: ${states.withdrawn.length === 1 ? "its" : "their"} remediation is not relayed.`);
226
354
  return out;
227
355
  }
356
+ // #3285: the ecosystems whose ":" suffix is a repository, not a release (core-backend
357
+ // cve/pkgecosystem.go baseTokenMatchableEcosystems): an entry of one package_name under
358
+ // "Packagist:https://packages.drupal.org/8" and one under "Packagist" are one package to the
359
+ // matcher (cve/pkgmatch_fixedin.go registryRecordKey), which walks their ranges together; a
360
+ // release suffix (Debian:12, Alpine:v3.20) is never folded. CVE-2025-48013's fixed_branches lists
361
+ // drupal/quick_node_block under both (fixed 2.0.1 and fixed 2.0.0, each from 0): read entry by
362
+ // entry, the rule gave a Packagist 1.0.0 the fix 2.0.0, which the Drupal advisory still holds
363
+ // affected; as one package it gives 2.0.1, the matcher's fixed_in. packageRecords.ts holds the
364
+ // reading, which cve_intel's note states of affected_packages too.
365
+ // The packages fixed_branches lists under more than one such ecosystem, each as its package_name
366
+ // with the ecosystems it is listed under, and how many entries fold into another so.
367
+ function onePackageListedTwice(rows) {
368
+ const { twice, folded } = packagesListedTwice(rows);
369
+ return { named: twice.map((g) => `${g.name} (${listed(g.ecosystems)})`), folded };
370
+ }
228
371
  function branchSentence(rows) {
229
372
  if (rows.length === 0)
230
373
  return "fixed_branches is empty: no affected package range is on record, which is not a finding that no fix exists.";
@@ -237,7 +380,14 @@ function branchSentence(rows) {
237
380
  fixed++;
238
381
  }
239
382
  }
240
- return `fixed_branches lists ${rows.length} affected package${rows.length === 1 ? "" : "s"} with ${ranges} affected range${ranges === 1 ? "" : "s"}, ${fixed} with a fix on record; an installed version's fix is the first fixed above it that no range of its package holds (the fixed of the range that holds it, or, while another range still holds that version, that range's fixed), compared in the ecosystem's version order.`;
383
+ const twice = onePackageListedTwice(rows);
384
+ const packages = rows.length - twice.folded;
385
+ const listedAs = twice.folded === 0 ? `${rows.length} affected package${rows.length === 1 ? "" : "s"}` : `${rows.length} entries, ${packages} affected package${packages === 1 ? "" : "s"},`;
386
+ const rule = `fixed_branches lists ${listedAs} with ${ranges} affected range${ranges === 1 ? "" : "s"}, ${fixed} with a fix on record; an installed version's fix is the first fixed above it that no range of its package holds (the fixed of the range that holds it, or, while another range still holds that version, that range's fixed), compared in the ecosystem's version order.`;
387
+ if (twice.named.length === 0)
388
+ return rule;
389
+ const few = twice.named.length <= 3 ? listed(twice.named) : `${twice.named.slice(0, 3).join(", ")} and ${twice.named.length - 3} more`;
390
+ return `${rule} ${ONE_PACKAGE_RULE("Entries")}: here ${few}.`;
241
391
  }
242
392
  // fix_status's sentence: what the API's rule says, relayed. Nothing about the fix is inferred here.
243
393
  function fixStatusSentence(cve, fs) {
@@ -250,7 +400,13 @@ function fixStatusSentence(cve, fs) {
250
400
  const unread = Array.isArray(field(fs, "unread_inputs")) ? field(fs, "unread_inputs").filter((x) => typeof x === "string") : [];
251
401
  const why = state === "none_confirmed" ? ", which is not a finding that no fix exists" : "";
252
402
  const notRead = unread.length ? `; it rests without ${listed(unread)}, which the API could not read (unread_inputs)` : "";
253
- return `fix_status for ${cve} is ${state}${basis ? ` (basis ${basis})` : ""}, the API's fix-status rule${why}${notRead}.`;
403
+ // #3076, third pass: what vendor_fix_released evidence is, so "available" is not read as more
404
+ // than it says. Relayed from the field the API set, nothing inferred.
405
+ const released = field(fs, "vendor_released");
406
+ const qualifier = released !== null && released !== undefined && typeof released === "object"
407
+ ? `; the vendor states a fix is released, but the CVE record names no fixed version (vendor_released), so the fixed release is the vendor advisory's to name`
408
+ : "";
409
+ return `fix_status for ${cve} is ${state}${basis ? ` (basis ${basis})` : ""}, the API's fix-status rule${why}${qualifier}${notRead}.`;
254
410
  }
255
411
  // The sentence on the installed version asked: what the API answered for it, relayed.
256
412
  function installedSentence(asked, d) {