echelongraph-mcp 3.0.2 → 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.
@@ -79,6 +79,10 @@ const Match = z.looseObject({
79
79
  fixed_in: opt(z.string()).describe("Registry match: the lowest version above this one that no affected interval of the CVE's record for the package holds, always above it: the fixed bound of the interval that holds this version, moved past any overlapping interval that still holds that bound (fixed_in_detail then names it). null when no such version is on record (an interval on the way records no fix: it ends at last_affected, or has no end) or no interval holds the version, with fixed_in_reason saying which. Absent on a CPE match."),
80
80
  fixed_in_reason: opt(z.string()).describe("Why fixed_in is null; absent when fixed_in is a version."),
81
81
  fixed_in_detail: opt(z.string()).describe("Set when fixed_in is not the fixed bound of the interval that holds this version: the overlapping intervals of the same record it moved past, which still hold that bound."),
82
+ // #2834: CPE matches only (core-backend cve/cpematch.go ProductMatch): the matched NVD range's
83
+ // own end, as NVD states it, in its own field and never as fixed_in.
84
+ cpe_version_end_excluding: opt(z.string()).describe("CPE match: NVD's versionEndExcluding for the range that matched, the first version that range no longer holds. Not a fix, and never fixed_in: another range of the same CVE, or a vendor backport, can sit past it. Absent on a registry match, and where the range sets no such end."),
85
+ cpe_version_end_including: opt(z.string()).describe("CPE match: NVD's versionEndIncluding for the range that matched, the last version that range holds. Not a fix, and never fixed_in. Absent on a registry match, and where the range sets no such end."),
82
86
  });
83
87
  const Undetermined = z.looseObject({
84
88
  cve_id: opt(z.string()),
@@ -108,7 +112,7 @@ const DATA = z.looseObject({
108
112
  excluded: z.unknown().optional(),
109
113
  undetermined_count: opt(z.number()),
110
114
  undetermined: opt(z.array(Undetermined)),
111
- not_affected_count: opt(z.number()).describe("Advisories decided as not affecting this version."),
115
+ not_affected_count: opt(z.number()).describe("CVEs decided as not affecting this version: on the registry path one per CVE, however many of its advisory records are stored (advisories_considered counts those)."),
112
116
  degraded: opt(z.boolean()).describe("true: the lookup ran out of time."),
113
117
  undecidable_excluded_count: opt(z.number()),
114
118
  undecided_candidate_count: opt(z.number()),
@@ -178,14 +182,16 @@ function matchesNote(matches) {
178
182
  // that no affected interval of the match's record holds (core-backend cve/pkgmatch_fixedin.go):
179
183
  // the fixed bound of the interval that holds this version, moved past any overlapping interval
180
184
  // that still holds it (#3074), so it is per match: an advisory with several branches (Log4Shell:
181
- // 2.3.1, 2.12.2, 2.15.0) names the one for this version.
185
+ // 2.3.1, 2.12.2, 2.15.0) names the one for this version. The record is every advisory row stored for
186
+ // the CVE and the package (#3137), so a null can come from another row of the CVE: no fix recorded,
187
+ // or one that row may still hold affected, or versions that could not be ordered.
182
188
  function fixedInNote(matches) {
183
189
  const registry = matches.filter((m) => m !== null && typeof m === "object" && "fixed_in" in m);
184
190
  if (registry.length === 0)
185
191
  return "";
186
192
  const fixed = registry.filter((m) => typeof field(m, "fixed_in") === "string").length;
187
193
  const none = registry.length - fixed;
188
- return ` fixed_in, the first version above this one that no affected interval of the advisory holds: ${fixed === registry.length ? `set on every match` : `set on ${fixed} of ${registry.length} matches`}${none > 0 ? `; null on ${none}, where the advisory records no fix for this version (fixed_in_reason says why)` : ""}.`;
194
+ return ` fixed_in, the first version above this one that no affected interval of the advisory holds: ${fixed === registry.length ? `set on every match` : `set on ${fixed} of ${registry.length} matches`}${none > 0 ? `; null on ${none}, where the CVE's advisory records show no version above this one to be unaffected (fixed_in_reason says why; it is not a finding that no fix exists)` : ""}.`;
189
195
  }
190
196
  // #2783: a production match is about 2,100 characters as pretty JSON, most of it the CVE's
191
197
  // description and match_reason, so openssl 3.0.0 (71 matches) was 156,058 characters of text and
@@ -225,7 +231,7 @@ const CHECK_AFFECTED_TEXT = {
225
231
  keep: [
226
232
  "cve_id", "severity", "cvss_v3_score", "description", "kev_listed", "ransomware", "epss_score", "echelongraph_score", "echelongraph_severity", "effective_score", "effective_severity",
227
233
  "score_assessed", "score_unassessed_reason", "matched_criteria", "cpe_vendor", "vendor_unknown", "match_confidence", "match_reason", "version_evidence", "fixed_in",
228
- "fixed_in_reason", "fixed_in_detail",
234
+ "fixed_in_reason", "fixed_in_detail", "cpe_version_end_excluding", "cpe_version_end_including",
229
235
  ],
230
236
  clip: 200,
231
237
  },
@@ -252,6 +258,12 @@ const CHECK_AFFECTED_TEXT = {
252
258
  { list: "excluded", keep: ["cve_id", "reason"], clip: 100, cap: 10 },
253
259
  { list: "undetermined", keep: ["cve_id", "reason"], clip: 100, cap: 10 },
254
260
  ],
261
+ // #2899: the matches that leave the text near the cap are the last in rank order (effective_score),
262
+ // so through 3.0.2 a KEV-listed or ransomware match ranked low was in structuredContent.data only,
263
+ // and the note said how many left, not that one of them is exploited. Now every match whose
264
+ // kev_listed or ransomware is true stays while any other match is in the text, and should one
265
+ // still have to leave (every other already out), the note names each by its cve_id.
266
+ leaveOutLast: { fields: ["kev_listed", "ransomware"], name: "cve_id", why: "known exploited, per CISA-KEV" },
255
267
  whole: `${GET_CVE_WHOLE}, and cve_intel a CVE's affected packages and fixed versions.`,
256
268
  };
257
269
  // #3013: the structured result's cut. A production match is about 1,650 characters of compact JSON
@@ -273,6 +285,7 @@ const CHECK_AFFECTED_DATA = {
273
285
  [
274
286
  "cve_id", "severity", "cvss_v3_score", "kev_listed", "ransomware", "epss_score", "echelongraph_score", "echelongraph_severity", "effective_score", "effective_severity", "score_assessed",
275
287
  "score_unassessed_reason", "matched_criteria", "cpe_vendor", "vendor_unknown", "match_confidence", "version_evidence", "fixed_in", "fixed_in_reason",
288
+ "cpe_version_end_excluding", "cpe_version_end_including",
276
289
  ],
277
290
  ["cve_id", "kev_listed", "ransomware", "epss_score", "effective_score", "score_assessed", "fixed_in", "fixed_in_reason"],
278
291
  ],
@@ -337,10 +350,10 @@ async function checkAffected(kit, a) {
337
350
  else if (count === 0 && assessed === true) {
338
351
  note += ` 0 CVEs match this version, a measured result (we looked and found nothing that matches), not a lookup failure.`;
339
352
  if (lookup === "registry" && notAffected !== null)
340
- note += ` ${plural(notAffected, "advisory was", "advisories were")} decided not to affect this version (not_affected_count), and only those are cleared.`;
353
+ note += ` ${plural(notAffected, "CVE was", "CVEs were")} decided not to affect this version (not_affected_count, one per CVE however many of its advisory records are stored), and only those are cleared.`;
341
354
  }
342
355
  if (undetermined !== null && undetermined > 0) {
343
- note += ` UNDETERMINED: ${plural(undetermined, "advisory names", "advisories name")} this package with a version range that could not be decided at this version (undetermined_count, listed in undetermined): report ${undetermined === 1 ? "it" : "them"} as undetermined, never as safe.`;
356
+ note += ` UNDETERMINED: ${plural(undetermined, "CVE", "CVEs")} with advisories naming this package could not be decided at this version (undetermined_count, one per CVE, listed in undetermined): report ${undetermined === 1 ? "it" : "them"} as undetermined, never as safe.`;
344
357
  }
345
358
  if (candidatesCapped === true && reason !== "candidate_window_truncated")
346
359
  note += " candidates_capped is true: not every candidate CVE was loaded, so more CVEs may match than the answer lists.";
@@ -1,6 +1,6 @@
1
1
  import * as z from "zod";
2
2
  import { answering, FULL_ARG, TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
3
- import { CLEAN_NOTE, DISTRO_NOTE, FRESHNESS_NOTE, MAX_PURLS, MEASURED_AT_NOTE, METHOD, batchDataShape, dedupe, field, isObj, plural, purlsRefusal, sendBatches, sendingCoverage, sendingCoverageShape, sendingSentences, verdictSentences, } from "./match_batch.js";
3
+ import { CLEAN_NOTE, DISTRO_NOTE, FRESHNESS_NOTE, MAX_PURLS, MEASURED_AT_NOTE, METHOD, affectedSaidOver, batchDataShape, dedupe, field, isObj, plural, purlsRefusal, sendBatches, sendingCoverage, sendingCoverageShape, sendingSentences, verdictSentences, } from "./match_batch.js";
4
4
  // #2835: the batch loop, its constants and the row schema moved to match_batch.ts, which
5
5
  // scan_manifest shares; re-exported here, where the tests and prompts.ts import them.
6
6
  export { API_COMPONENTS_PER_MINUTE, BATCH_PATH, MAX_COMPONENTS, MAX_PURLS, TIME_BUDGET_MS, mergeAnswers } from "./match_batch.js";
@@ -48,6 +48,12 @@ export const CHECK_SBOM_DESCRIPTION = "Check a dependency list against EchelonGr
48
48
  // them (x.purls.slice(answers.length * MAX_COMPONENTS)), so a second call can send them without
49
49
  // reading the list. noteFor gives that order (READ_ORDER) beside the position, so this sentence
50
50
  // points to it.
51
+ // #3269 (the structured cut's rule, kept in the text too): the clean and not_assessed rows left
52
+ // the text only once every row was cut to the last level, so an image SBOM's text kept every clean
53
+ // row and no affected row's matches: replayed on the production synthetic's rows, 150 purls with 40
54
+ // affected kept 150 rows and the matches of none of the 40, on 3.0.0 and 3.0.2 alike. Now, before
55
+ // any row is cut to the last level, those rows are cut to it and leave the text, as many as must;
56
+ // where even that does not fit, the cut goes on as before.
51
57
  export function sbomText(sent) {
52
58
  const matches = ["cve_id", "severity", "effective_severity", "effective_score", "kev_listed", "ransomware", "epss_score", "score_assessed", "fixed_in", "fixed_in_reason"];
53
59
  const row = [
@@ -68,10 +74,22 @@ export function sbomText(sent) {
68
74
  },
69
75
  ],
70
76
  leaveOutFirst: { field: "verdict", values: ["not_affected", "not_assessed"], why: "data.summary counts every verdict" },
77
+ outFirstBefore: 2,
78
+ // #3284: data keeps the rows with a KEV-listed or ransomware match first (SBOM_LEAVE_LAST),
79
+ // while the text left rows from the end in input order, so a KEV-bearing row data kept could
80
+ // leave the text, named in no text block and in no note (the DATA CUT sentence names only rows
81
+ // left out of data): on the reviewer's 36 realistic Debian-12 images a client reading the text
82
+ // alone missed 95 of 413 KEV-bearing rows (linux-libc-dev at index 282, 5 KEV CVEs, among
83
+ // them). Now such rows leave the text last, as #2899's check_affected matches do, and the note
84
+ // names each one that still leaves by its index.
85
+ leaveOutLast: { fields: [], name: "index", why: "", has: hasKev, said: "a KEV-listed or ransomware match", runs: true },
71
86
  // check_affected cuts its text too past the budget (#2802): only its structuredContent.data is
72
87
  // a component's matches whole, with full: true (#3013).
73
88
  whole: "check_affected, given a row's ecosystem, package and version, returns that component's matches, whole in its structuredContent.data with full: true, and cve_intel a CVE's affected packages and fixed versions.",
74
- page: (shown) => `To read every row in the text, check ${shown} or fewer purls per call.`,
89
+ // #3269's second review: no page sentence. "To read every row in the text, check N or fewer purls
90
+ // per call", N the rows the text kept, was false for a call of the first N purls in 34 of 89
91
+ // mixes (on 3.0.2 too): the clean rows that leave first make N no page size. The sentence on
92
+ // the rows left out says how many left and that structuredContent.data holds them.
75
93
  };
76
94
  }
77
95
  // #3013: the structured result's cut. A production row is about 1,900 characters of compact JSON
@@ -110,16 +128,18 @@ export const SBOM_ROW_THIN = [
110
128
  // 3.0.0 (2026-10-11), five Debian 12 purls, all affected, kept 1 row (libssl3; linux-libc-dev
111
129
  // 6.1.55-1's 200 matches are 42,417 characters thinned); Juice Shop 11.1.2 with
112
130
  // magento/community-edition 2.3.0 inserted at index 2 kept 2 of 51 rows (19 affected); that purl
113
- // alone kept none, and the note said "check 0 or fewer purls per call". Now, in order:
131
+ // alone kept none, and the note said "check 0 or fewer purls per call". Now, in order (since
132
+ // #3269, the clean and not-assessed rows leave between 1 and 2: see SBOM_ROW_COMPACT's comment):
114
133
  // 1. SBOM_ROW_THIN, every row, every match (as before);
115
134
  // 2. the same, with every row's matches capped at one number m, the most that fit: a row with
116
- // more than m keeps its first m (the API's order: highest effective_score first) and carries
135
+ // more than m keeps m (its first in the API's order through 3.0.2; pickMatches's since #3270) and carries
117
136
  // matches_truncated, its own total and returned, and the CVE IDs among all its matches that
118
137
  // are KEV-listed (kev_listed_cve_ids) or used in ransomware (ransomware_cve_ids), and the
119
138
  // distinct fixed_in versions they name (fixed_in_versions), so what a triage ranks by first
120
139
  // is still said of the matches cut; rows with m or fewer matches are untouched;
121
140
  // 3. SBOM_ROW_COMPACT, each match cut to cve_id, kev_listed, ransomware, fixed_in and
122
- // effective_score, the row keeping matched_package, matched_via and an upstream row's
141
+ // effective_score (and, since #3270, epss_score and score_assessed), the row keeping
142
+ // matched_package, matched_via and an upstream row's
123
143
  // matched_version_from and purl_name, matches capped at one number m as in 2 (any m, 0
124
144
  // included), with the same matches_truncated;
125
145
  // 4. SBOM_ROW_MIN, the text's leanest level (index, purl, verdict, not_assessed_reason and
@@ -144,6 +164,49 @@ export const SBOM_ROW_THIN = [
144
164
  // upstream row with matched_via, at 29,959 characters.
145
165
  // cve_ids is what the API caps at 200 a row, about 3,400 characters, so every row survives level 5
146
166
  // unless the rows are many (the probe's 201, Juice Shop's full 840 purls).
167
+ // #3269's second review (DO-NOT-SHIP on its first): levels 2 and 3 went on below a row's floor,
168
+ // to a cap of 1 and then 0, and then to levels 4 and 5, every row kept with one match or none:
169
+ // five Debian 12 purls beside an image's 150 (40 affected) kept 45 affected rows, 1 with any match
170
+ // (3.0.2: 26, 22 with every match). Now levels 2 and 3 hold each row's floor (matchFloor) whatever
171
+ // their cap, taken from a cap of SBOM_LEAVING_CAP; levels 4 and 5 are not taken while a row other
172
+ // than a not_affected or not_assessed one has matches; and rows leave whole at level 3 at their
173
+ // floor (SBOM_LEAVE_AT), the rows kept chosen those with a KEV-listed or ransomware match first,
174
+ // then the others, each in input order, each one that fits (SBOM_LEAVE_LAST, textBudget.ts
175
+ // keptByFit), the rest named by index in the note. Where not even one row fits at its floor, the
176
+ // row chosen first stays alone below it, its matches the most that fit (belowSaid). Measured on
177
+ // the 40 Debian-image mixes of that review: 819 affected rows keep every match or their floor
178
+ // (round 1 of it: 462, 744 more under the floor; 3.0.2: 929, 116 more under it, its compact
179
+ // matches without epss_score and score_assessed, which #3270 keeps: with those two left out, this
180
+ // ladder keeps 1,025). #3269's third review: 820 (1,030 with those two left out), the note naming
181
+ // the rows left out with a KEV-listed match apart and how to check them again.
182
+ //
183
+ // #3269 and #3270, the post-close review of #3132 (3.0.2):
184
+ // - Every row went down these levels together, the clean ones with the affected, and rows left
185
+ // only at the last, so an ordinary image SBOM kept its clean rows by cutting the affected rows'
186
+ // matches: 150 purls, 40 of them affected (test/check_sbom-large-rows.test.mjs's image-shaped
187
+ // mix), kept all 150 rows and the matches of 2 of the 40 affected, where 3.0.0 kept 56 rows and
188
+ // every match of all 40. An affected row's matches are what an SBOM check is for, and the clean
189
+ // rows are counted in data.summary. So now (sbomData's outFirst) the not_affected and
190
+ // not_assessed rows are cut to SBOM_ROW_MIN (a not_assessed row keeps not_assessed_reason),
191
+ // then leave, not_affected first, before any other row is cut below SBOM_ROW_THIN; once the
192
+ // cut is settled, those that left come back, cut so, as many as the room left holds. #3269's
193
+ // review: they leave so only where that keeps every other row's matches whole; where the
194
+ // others must be capped anyway, they stay while the others fit at matchFloor (below).
195
+ // - A capped row kept its first m matches, the API's order (highest effective_score first), so a
196
+ // KEV-listed CVE ranked lower was cut and only matches_truncated named it: magento/community-
197
+ // edition 2.3.0's CVE-2025-54236, at position 21 of its 200, with Juice Shop beside it. A capped
198
+ // row now keeps its KEV-listed and ransomware matches first, then the highest effective_score
199
+ // (pickMatches), in the API's order.
200
+ // - SBOM_ROW_COMPACT dropped epss_score and score_assessed, which workload_triage ranks by: the
201
+ // 201-purl probe kept 54 rows and not one match with either. Every level that keeps matches now
202
+ // keeps every field a prompt reads from a match (test/prompt-fields.test.mjs holds each level of
203
+ // SBOM_ROW_LEVELS to them); compact is SBOM_ROW_THIN without fixed_in_reason. 3.1.0's
204
+ // integration (#2834 beside #3269): sbom_review relays a null fixed_in's fixed_in_reason, and
205
+ // keeping it here was measured: it cost the 201-purl probe a row (43 to 42; the DATA CUT
206
+ // sentence naming it is 17 characters longer) and the second review's image mixes affected rows
207
+ // at their floor (16 to 12, 32 to 31, 31 to 30, 23 to 22). So it stays out, and sbom_review
208
+ // reads a null fixed_in without it as a reason the cut left out, never as no fix
209
+ // (test/check_sbom-large-rows.test.mjs pins the probe; prompt-fields the prompt).
147
210
  export const SBOM_ROW_COMPACT = [
148
211
  "index",
149
212
  "purl",
@@ -154,12 +217,28 @@ export const SBOM_ROW_COMPACT = [
154
217
  "matched_via",
155
218
  "matched_version_from",
156
219
  "purl_name",
157
- ["matches", ["cve_id", "kev_listed", "ransomware", "fixed_in", "effective_score"]],
220
+ ["matches", ["cve_id", "kev_listed", "ransomware", "epss_score", "effective_score", "score_assessed", "fixed_in"]],
158
221
  ];
159
222
  export const SBOM_ROW_MIN = ["index", "purl", "verdict", "not_assessed_reason", "cve_ids"];
160
- // The most matches a row keeps, compact, when rows leave (the API's order, highest effective_score
161
- // first); the rest are in its cve_ids and matches_truncated.
223
+ // The most matches a row keeps, compact, when rows leave (pickMatches's); the rest are in its
224
+ // cve_ids and matches_truncated.
162
225
  export const SBOM_LEAVING_CAP = 10;
226
+ // #3270: which `cap` of a row's matches a capped level keeps, by their index: those used in
227
+ // ransomware, then the other KEV-listed ones, then the highest effective_score (a match without one
228
+ // last), the API's order breaking ties; returned in the API's order, which data keeps.
229
+ export function pickMatches(ms, cap) {
230
+ const tier = (m) => (field(m, "ransomware") === true ? 0 : field(m, "kev_listed") === true ? 1 : 2);
231
+ const score = (m) => {
232
+ const s = field(m, "effective_score");
233
+ return typeof s === "number" && Number.isFinite(s) ? s : Number.NEGATIVE_INFINITY;
234
+ };
235
+ const by = (a, b) => tier(ms[a]) - tier(ms[b]) || (score(ms[b]) === score(ms[a]) ? 0 : score(ms[b]) > score(ms[a]) ? 1 : -1) || a - b;
236
+ return ms
237
+ .map((_, i) => i)
238
+ .sort(by)
239
+ .slice(0, Math.max(0, cap))
240
+ .sort((a, b) => a - b);
241
+ }
163
242
  // matches_truncated for a row whose matches data keeps `kept` of (none at levels 3 and 4); with
164
243
  // `summary`, the three lists drawn from all of the row's matches. Absent on a row with no match
165
244
  // cut, and on one with no matches.
@@ -178,26 +257,107 @@ function matchesTruncated(row, kept, summary) {
178
257
  };
179
258
  }
180
259
  const MATCHES_TRUNCATED_SAID = "carrying matches_truncated, which gives its own total and returned, and, of all its matches, the CVE IDs KEV-listed (kev_listed_cve_ids) or used in ransomware (ransomware_cve_ids) and the distinct fixed_in versions (fixed_in_versions)";
181
- const cappedSaid = (cap) => `each row's matches keeping at most its first ${cap} (the API's order, highest effective_score first), a row with more than that ${MATCHES_TRUNCATED_SAID}`;
260
+ const PICK_SAID = "chosen used in ransomware first, then the other KEV-listed ones, then those of highest effective_score, and listed in the API's order";
261
+ // #3269's review: the matches kept are chosen in one order and listed in another; round 1's "at
262
+ // most m, in the API's order: those KEV-listed or used in ransomware first, then those of highest
263
+ // effective_score" read as the order data lists them in. #3269's second review: and a row keeps its
264
+ // floor (matchFloor) whatever the cap, which the clause says where it is above the cap for a row.
265
+ const cappedSaid = (cap, raised) => `each row's matches keeping at most ${cap}${raised ? `, or its matches used in ransomware or KEV-listed and ${SBOM_LEAVING_CAP} more where those come to more` : ""}, ${PICK_SAID}; a row with more than that ${MATCHES_TRUNCATED_SAID}`;
266
+ // #3269's second review: the one row kept below its floor, where not even it fits at the floor.
267
+ const belowSaid = (cap) => `the one row kept keeping ${cap} of its matches, fewer than its matches used in ransomware or KEV-listed and ${SBOM_LEAVING_CAP} more, since no more fit beside it, ${PICK_SAID}, and ${MATCHES_TRUNCATED_SAID}`;
268
+ // #3269's second review: the two capped levels hold each row's floor (matchFloor) whatever the cap,
269
+ // and are taken from a cap of SBOM_LEAVING_CAP (below it every row keeps the same: its floor).
182
270
  export const SBOM_ROW_LEVELS = [
183
271
  SBOM_ROW_THIN,
184
- { keep: SBOM_ROW_THIN, capped: "matches", least: 1, mark: (row, cap) => matchesTruncated(row, cap, true), said: cappedSaid },
185
- { keep: SBOM_ROW_COMPACT, capped: "matches", mark: (row, cap) => matchesTruncated(row, cap, true), said: cappedSaid },
272
+ { keep: SBOM_ROW_THIN, capped: "matches", least: SBOM_LEAVING_CAP, floor: matchFloor, pick: pickMatches, mark: (row, cap) => matchesTruncated(row, cap, true), said: cappedSaid },
273
+ { keep: SBOM_ROW_COMPACT, capped: "matches", least: SBOM_LEAVING_CAP, floor: matchFloor, pick: pickMatches, mark: (row, cap) => matchesTruncated(row, cap, true), said: cappedSaid, below: belowSaid },
186
274
  { keep: SBOM_ROW_MIN, mark: (row) => matchesTruncated(row, 0, true), said: () => `a row that had matches ${MATCHES_TRUNCATED_SAID}` },
187
275
  { keep: SBOM_ROW_MIN, mark: (row) => matchesTruncated(row, 0, false), said: () => "a row that had matches carrying matches_truncated with its total and returned 0" },
188
276
  ];
189
- const SBOM_LEAVE_AT = { level: 2, cap: SBOM_LEAVING_CAP };
190
- // The page sentence (#3132): never "0 or fewer". Rows leave only once no level keeps them all, so a
191
- // call of as many purls as data kept rows keeps each of its rows; when not one row fits, no page
192
- // size does, and the note says where a row is read instead.
277
+ // #3269's second review: rows leave at the compact level at their floor (`floor`: levels 4 and 5
278
+ // are not taken while a row other than a not_affected or not_assessed one has matches), and of
279
+ // those rows, the ones with a KEV-listed or ransomware match last (SBOM_LEAVE_LAST).
280
+ const SBOM_LEAVE_AT = { level: 2, cap: SBOM_LEAVING_CAP, floor: true };
281
+ const hasKev = (row) => {
282
+ const ms = field(row, "matches");
283
+ return Array.isArray(ms) && ms.some((m) => field(m, "ransomware") === true || field(m, "kev_listed") === true);
284
+ };
285
+ export const SBOM_LEAVE_LAST = {
286
+ has: hasKev,
287
+ said: "a KEV-listed or ransomware match",
288
+ id: "index",
289
+ rest: ["affected or undetermined row", "affected or undetermined rows"],
290
+ chosen: "kept: rows with one first, then the others, in input order, each that fits",
291
+ };
292
+ // #3269's third review: the most check_sbom calls the prompts make to check again the rows a DATA
293
+ // CUT left out (prompts.ts workload_triage's step 6 and sbom_review), and so the calls whose sizes
294
+ // the note gives one by one.
295
+ export const RECHECK_CALLS = 5;
296
+ // check_sbom's sentence on checking again the affected and undetermined rows that left data, in
297
+ // the order the note names them (those with a KEV-listed or ransomware match first): the sizes of
298
+ // the first RECHECK_CALLS calls, each the most of the rest that fits, then at most `m` a call
299
+ // (textBudget.ts PageAt.againFit, counted for those rows, where the page sentence's N holds for
300
+ // the first N purls of the call only: re-check calls of N dropped 504 of 2,540 rows on realistic
301
+ // Debian images). One size for every call, any m in a row (the review's "calls of at most M"),
302
+ // put 201 of 255 KEV-bearing rows of six old unpatched images in view after 5 calls, and the best
303
+ // single size, found by real calls, 224, against 228 for round 3's step 6 on the same images with
304
+ // their KEV-bearing purls sent first; the first-fit sizes put 238. A row whose floor alone is past
305
+ // the budget goes in a call of its own, which keeps it under its floor.
306
+ export const sbomAgainSaid = ({ k, sizes, m, alone, aloneCount }) => {
307
+ const own = aloneCount === 0
308
+ ? ""
309
+ : `${aloneCount === 1 ? `The purl at index ${alone} goes` : `Each of the purls at index ${alone} goes`} in a call of its own: its row's matches used in ransomware or KEV-listed and ${SBOM_LEAVING_CAP} more are past the budget even alone, so that call keeps the row with the most of them that fit, and its note says how many.`;
310
+ if (aloneCount === k)
311
+ return own;
312
+ const these = aloneCount === 0 ? `these ${k} purls` : `the other ${k - aloneCount} of these purls`;
313
+ const calls = sizes.length === 1 && m === 0
314
+ ? `Re-check ${these} in one call: it keeps every row in data.`
315
+ : `Re-check ${these} in this order, in calls of ${listedAnd(sizes.map(String))}${m > 0 ? `, then of at most ${m}` : ""}: each such call keeps every row in data.`;
316
+ return own ? `${calls} ${own}` : calls;
317
+ };
318
+ const listedAnd = (xs) => (xs.length < 2 ? xs.join("") : `${xs.slice(0, -1).join(", ")} and ${xs[xs.length - 1]}`);
319
+ // #3269's review: the floor of matches an affected row keeps, once the rows must be capped anyway,
320
+ // before a clean or not-assessed row leaves for it: every match used in ransomware or KEV-listed,
321
+ // and SBOM_LEAVING_CAP more, the highest effective_score (pickMatches keeps them, at a cap of this
322
+ // many). SBOM_LEAVING_CAP because it is what the ladder already gives a row when rows leave
323
+ // (SBOM_LEAVE_AT): a clean row stays at SBOM_ROW_MIN while every affected row can keep at least
324
+ // that, and its KEV-listed matches beside it. Round 1 had no floor: once a row had to be capped, the
325
+ // clean rows left so that the cap could rise, and came back only into the room the cap left.
326
+ // linux-libc-dev 6.1.55-1 (200 matches) with 100 clean Debian purls kept 1 row of 101, and the page
327
+ // sentence said "check 1 or fewer purls per call": 101 calls to read 100 not_affected verdicts.
328
+ export function matchFloor(row) {
329
+ const ms = field(row, "matches");
330
+ if (!Array.isArray(ms))
331
+ return 0;
332
+ const kev = ms.filter((m) => field(m, "ransomware") === true || field(m, "kev_listed") === true).length;
333
+ return Math.min(ms.length, kev + SBOM_LEAVING_CAP);
334
+ }
335
+ // #3269: the clean and not-assessed rows are cut to SBOM_ROW_MIN, then leave, before any other row
336
+ // is cut to SBOM_ROW_LEVELS[1] (its matches capped); see SBOM_ROW_COMPACT's comment. #3269's
337
+ // review: where the other rows must be capped even with every one of them gone, they stay while the
338
+ // others fit at matchFloor, at SBOM_ROW_LEVELS[1] or [2] (textBudget.ts OutFirst.floor).
339
+ const SBOM_OUT_FIRST = { before: 1, level: SBOM_ROW_MIN, floor: { at: 2 } };
340
+ // The page sentence (#3132): never "0 or fewer". #3269's review: the number it names is what a
341
+ // call of the first that many purls keeps whole (PageAt.firstFit), not the number of rows this
342
+ // call's data kept. Round 1 named the rows kept, and the clean rows that leave first made that
343
+ // false: Juice Shop 11.1.2 with magento/community-edition 2.3.0 said "check 19 or fewer purls per
344
+ // call", and a call of its first 19 purls kept 14 of them (and one of 14 kept 12). firstFit counts
345
+ // every first n' up to the number it names, so "or fewer" holds too; a later page is not this
346
+ // call's, so its own note says what its data leaves out. When not one row fits, no page size does,
347
+ // and the note says where a row is read instead.
193
348
  // #3132's review: the lists beside the rows go first, scan_manifest's not_checked before
194
349
  // not_sent_purls, which keeps every purl or none (whole): those purls are the list a client
195
350
  // retries, and round 1 kept 3 of the rate-limited case's 800. With none kept, the note gives them
196
351
  // exactly as positions in the input (sendingSentences: coverage.sent + 1 to
197
352
  // coverage.distinct_purls), and the room the rows leave goes back to not_sent_purls before
198
353
  // not_checked.
199
- export function sbomData(sides, page) {
354
+ // #3269's third review: `again`, check_sbom's sentence on checking the left-out rows again
355
+ // (sbomAgainSaid), with the note's own growth for such a call (match_batch.ts affectedSaidOver);
356
+ // scan_manifest's rows are checked again through check_sbom, by purls its note does not give, so it
357
+ // names none.
358
+ export function sbomData(sides, page, again) {
200
359
  const others = sides.filter((x) => x !== "not_sent_purls");
360
+ const leaveLast = again ? { ...SBOM_LEAVE_LAST, again, againCalls: RECHECK_CALLS, noteOver: affectedSaidOver } : SBOM_LEAVE_LAST;
201
361
  return {
202
362
  steps: [
203
363
  ...(others.length ? [{ lists: others }] : []),
@@ -206,18 +366,27 @@ export function sbomData(sides, page) {
206
366
  lists: ["results"],
207
367
  thin: SBOM_ROW_LEVELS,
208
368
  leaveOutFirst: { field: "verdict", values: ["not_affected", "not_assessed"], why: "data.summary counts every verdict" },
369
+ leaveLast,
370
+ outFirst: SBOM_OUT_FIRST,
209
371
  leaveAt: SBOM_LEAVE_AT,
210
372
  giveBack: true,
211
373
  },
212
374
  ],
213
- page: (t) => t.results && t.results.returned < t.results.total
214
- ? t.results.returned > 0
215
- ? page(t.results.returned)
216
- : `Not one row fits data even cut to ${SBOM_ROW_COMPACT.map((x) => (typeof x === "string" ? x : x[0])).join(", ")}, so no number of purls per call does: the same call with full: true returns every row whole.`
217
- : "",
375
+ page: (t, _data, at) => {
376
+ if (!t.results || t.results.returned >= t.results.total)
377
+ return "";
378
+ if (t.results.returned === 0)
379
+ return `Not one row fits data even cut to ${SBOM_ROW_MIN.join(", ")}, so no number of purls per call does: the same call with full: true returns every row whole.`;
380
+ const k = at.firstFit("results");
381
+ return k > 0 ? page(k) : "Not even a call of the first of these purls is sure to keep its row in data: the same call with full: true returns every row whole.";
382
+ },
218
383
  };
219
384
  }
220
- const SBOM_DATA = sbomData(["not_sent_purls"], (k) => `To read every row in data, check ${k} or fewer purls per call.`);
385
+ // #3269's second review: the sentence promises only what holds. Its round-2 opening, "To read every
386
+ // row in data, check N or fewer purls per call", read as "page the SBOM in calls of N", and a call
387
+ // of a later N purls need not keep every row (it lost rows in 55% of the paged mixes).
388
+ export const sbomPageSaid = (k) => `A call of the first ${k} of these purls, or of fewer, keeps every row in data; a call of other purls says in its own note what it leaves out.`;
389
+ const SBOM_DATA = sbomData(["not_sent_purls"], sbomPageSaid, sbomAgainSaid);
221
390
  // The purls of a CycloneDX JSON document: every components[] entry, nested ones included.
222
391
  // metadata.component (the subject of the SBOM, not a dependency of it) is not checked.
223
392
  function fromCycloneDX(doc) {