echelongraph-mcp 2.7.2 → 3.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +219 -0
- package/README.md +163 -433
- package/dist/fixStatus.js +30 -0
- package/dist/httpPolicy.js +16 -1
- package/dist/index.js +287 -34
- package/dist/prompts.js +63 -33
- package/dist/requiredText.js +16 -1
- package/dist/resources.js +32 -6
- package/dist/textBudget.js +588 -22
- package/dist/tools/check_affected.js +45 -12
- package/dist/tools/check_sbom.js +158 -9
- package/dist/tools/cve_intel.js +63 -9
- package/dist/tools/cve_remediation.js +230 -16
- package/dist/tools/epss_history.js +10 -4
- package/dist/tools/get_cwe.js +7 -2
- package/dist/tools/kev_recent.js +22 -3
- package/dist/tools/match_batch.js +27 -7
- package/dist/tools/scan_manifest.js +35 -10
- package/dist/tools/vendor_advisories.js +47 -11
- package/docs/exposure-radar.md +181 -0
- package/docs/rate-limits.md +50 -0
- package/docs/structured-results.md +43 -0
- package/docs/tools.md +167 -0
- package/package.json +2 -1
- package/server.json +2 -2
package/dist/textBudget.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
// The text budget (#2783)
|
|
2
|
-
// than that is cut. index.ts's
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
1
|
+
// The text budget (#2783), and the structured result's (#3013): what a success may cost in each
|
|
2
|
+
// channel a client hands a model, and how an answer larger than that is cut. index.ts's
|
|
3
|
+
// `succeeded` builds every success's first text block with dataText (a tool that returns a list of
|
|
4
|
+
// rows names it with a TextCut), and its structured result with cutData (each tool names its lists
|
|
5
|
+
// in the order they are cut with a DataCut, and answering() hands the call's `full` and DataCut to
|
|
6
|
+
// `succeeded`). A module of its own, so a tool module can import its types,
|
|
7
|
+
// TEXT_BUDGET_DESCRIPTION and FULL_ARG without importing index.ts, which starts the stdio server
|
|
8
|
+
// when loaded.
|
|
9
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
10
|
+
import * as z from "zod";
|
|
7
11
|
// What a success costs in the channel a model reads is mostly its first text block, and through
|
|
8
12
|
// 2.6.2 that block was the API's JSON pretty-printed, whatever its size. Measured on production
|
|
9
13
|
// through https://mcp.echelongraph.io/mcp (2.6.2, 2026-10-04): search_cves {search: "openssl"} at
|
|
@@ -18,8 +22,8 @@
|
|
|
18
22
|
// serialized in a text block too, as a SHOULD, for backwards compatibility, and SEP-1624 proposes
|
|
19
23
|
// content as the model-facing, token-efficient form and structuredContent as the machine-facing one.
|
|
20
24
|
//
|
|
21
|
-
// So
|
|
22
|
-
// when it
|
|
25
|
+
// So the first text block is the API's JSON when it fits DATA_TEXT_BUDGET, half the ceiling, and
|
|
26
|
+
// cut when it does not (structuredContent.data, below, is cut on its own terms, never from this):
|
|
23
27
|
// 1. pretty-printed, as since 1.x, when that fits;
|
|
24
28
|
// 2. else the same JSON with each element of a list on a line of its own, which parses to the
|
|
25
29
|
// same value, when that fits;
|
|
@@ -33,19 +37,73 @@
|
|
|
33
37
|
// level keeps, and before any row leaves the text;
|
|
34
38
|
// 4. else every list keeps its first n entries, n halving, and then strings are cut too.
|
|
35
39
|
// Whenever the text is not the JSON whole, the note ends with one sentence, starting TEXT CUT,
|
|
36
|
-
// that says what the first text block leaves out,
|
|
40
|
+
// that says what the first text block leaves out, where structuredContent.data carries all of it,
|
|
37
41
|
// and how to read the rest in the text: the tool that returns one row, and what of it, and the page
|
|
38
42
|
// size or offset at which a page fits. The cut is deterministic: the same answer is cut the same way.
|
|
43
|
+
//
|
|
44
|
+
// The structured result, per client (#3013). Through 2.7.x structuredContent.data was the API's
|
|
45
|
+
// JSON whole, on the reading above that structuredContent is the machine-facing form. It is not, in
|
|
46
|
+
// the client the README gives first-class install steps: Claude Code hands the model
|
|
47
|
+
// structuredContent, not the text blocks (reproduced 2026-10-11 over raw JSON-RPC: get_cve
|
|
48
|
+
// CVE-2021-44228 was 65,540 characters of structuredContent beside a first text block of 23,759,
|
|
49
|
+
// and Claude Code refused it, "exceeds maximum allowed tokens", against its default
|
|
50
|
+
// MAX_MCP_OUTPUT_TOKENS of 25,000, and saved it to a file, so the model got a path, not an answer).
|
|
51
|
+
// The spec (MCP 2025-06-18, Tools, Structured Content) does not say which of the two a client gives
|
|
52
|
+
// the model. So the text cut above reached every client but that one. Since 3.0.0 the structured
|
|
53
|
+
// result is held to the same budget, STRUCTURED_BUDGET: the whole of it, envelope and notes with
|
|
54
|
+
// data, as compact JSON, since that is what such a client sizes. Past it, data is cut (cutData):
|
|
55
|
+
// 1. the lists the tool's DataCut names, step by step in its order. Where a step names thinning
|
|
56
|
+
// levels (a list whose single entries are large: check_sbom's and scan_manifest's results,
|
|
57
|
+
// check_affected's matches, cve_remediation's vendor_remediations, search_cves' and the
|
|
58
|
+
// vendor-advisory tools' rows), every entry is first cut to the fields each level keeps, level
|
|
59
|
+
// by level, all entries kept, so that no row leaves while thinning its detail would do; then
|
|
60
|
+
// each step keeps the most entries that fit, every list in the step at most the same number,
|
|
61
|
+
// its first ones, or for a list named with leaveOutFirst (check_sbom's results) those entries
|
|
62
|
+
// the tool names leave first (the clean rows, counted in data.summary), then the last of the
|
|
63
|
+
// rest, or for a step of series named keepNewest (exposure_radar's daily and weekly counts,
|
|
64
|
+
// which the API sends oldest first) its last ones, the newest, the oldest leaving first; a
|
|
65
|
+
// step that cannot fit even empty is emptied, and the next one is cut. A level can cut inside
|
|
66
|
+
// each entry on its own terms (an EntryLevel, #3132: check_sbom's rows keep at most the same
|
|
67
|
+
// number of matches, the most that fit, so only the rows with more lose any), and a step named
|
|
68
|
+
// keepAll (#3139: cve_remediation's vendor_remediations) keeps every entry at its leanest level
|
|
69
|
+
// while the later steps are cut, its entries leaving only once they all are;
|
|
70
|
+
// 2. then every other list at the top level of data, the same way. A list inside an object of
|
|
71
|
+
// data (get_cve's remediation summary, with fix_status, #2841) is cut only where a DataCut
|
|
72
|
+
// names it, and none names get_cve's. Once a step fits, the room it leaves goes back to the
|
|
73
|
+
// steps emptied before it, the last first, each the same way (scan_manifest's not_checked,
|
|
74
|
+
// emptied while its rows were whole, keeps its entries once the thinned rows fit);
|
|
75
|
+
// 3. no string is shortened and no value changes: a thinned entry keeps some of its fields as
|
|
76
|
+
// they are (every field a thinning level drops is optional in the outputSchema, so data still
|
|
77
|
+
// validates against it), and no field outside the lists cut changes (the one field a cut
|
|
78
|
+
// adds is an EntryLevel's mark, check_sbom's matches_truncated, which says what the row's own
|
|
79
|
+
// cut left out, and which its outputSchema names); the envelope says
|
|
80
|
+
// data_truncated true, truncated gives each list cut by its path in data with its total, the
|
|
81
|
+
// number returned and, where its entries are thinned, thinned true, and the note ends with
|
|
82
|
+
// sentences starting DATA CUT that say what was cut and to which fields, how to read the rest
|
|
83
|
+
// (the offset or page size, where the tool pages: a page size the note says fits data whole
|
|
84
|
+
// is counted on the entries whole, never on the thinned ones, #3013's review) and that the
|
|
85
|
+
// same call with full: true returns data whole.
|
|
86
|
+
// The first text block is cut from the whole answer as before, not from the cut data: what it
|
|
87
|
+
// holds is unchanged for the clients that read it. full: true is for programs, which read data
|
|
88
|
+
// whole whatever its size; a client that sizes structured results, Claude Code among them, may
|
|
89
|
+
// still refuse such an answer past its own cap (MAX_MCP_OUTPUT_TOKENS raises Claude Code's).
|
|
90
|
+
// Rejected (#3013's decision of 2026-10-11): saying only that Claude Code gets files; cutting only
|
|
91
|
+
// for a client whose clientInfo.name is Claude Code (the same call would answer differently by
|
|
92
|
+
// client, and the next client to pass structuredContent on would get the 65,540 again); and dropping
|
|
93
|
+
// structuredContent (it loses the outputSchema check the spec recommends and the suite relies on).
|
|
39
94
|
export const DATA_TEXT_BUDGET = 30_000;
|
|
40
95
|
// What a note may say of reading one CVE's record through get_cve (#2802): get_cve's own first text
|
|
41
|
-
// block is cut too past the budget (each list to its first entries, every other field kept),
|
|
42
|
-
//
|
|
43
|
-
|
|
96
|
+
// block is cut too past the budget (each list to its first entries, every other field kept), and
|
|
97
|
+
// since #3013 so is its structured result's data, unless the call says full: true. A row tool's
|
|
98
|
+
// `whole` sentence starts with it.
|
|
99
|
+
export const GET_CVE_WHOLE = "get_cve returns any one of these CVEs' records, whole in its structuredContent.data with full: true, and in its first text block with every field (past 30,000 characters, each list cut to its first entries)";
|
|
44
100
|
// What every tool description says about it, one constant string (marketing-site
|
|
45
101
|
// lib/mcpToolClaims.test.ts folds only constant strings); the suite holds its number to
|
|
46
102
|
// DATA_TEXT_BUDGET's. It uses none of the words that check reviews as the page's own prose (how,
|
|
47
|
-
// when, does, have, on and the rest of its PROSE list), so no reviewed entry goes stale.
|
|
48
|
-
|
|
103
|
+
// when, does, have, on and the rest of its PROSE list), so no reviewed entry goes stale. #3013: the
|
|
104
|
+
// same length as 2.7.x's (203 characters), since several descriptions sit within ten characters of
|
|
105
|
+
// the floor the suite keeps under the client cut (#2924); FULL_ARG says the rest.
|
|
106
|
+
export const TEXT_BUDGET_DESCRIPTION = "Past 30,000 characters of JSON, the first text block holds data cut to fit, and the note says what the cut leaves out and where to read it (TEXT CUT); so is structured data (DATA CUT), unless full: true.";
|
|
49
107
|
const isPlainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
50
108
|
const field = (o, k) => (isPlainObject(o) ? o[k] : undefined);
|
|
51
109
|
const listed = (xs) => (xs.length < 2 ? xs.join("") : `${xs.slice(0, -1).join(", ")} and ${xs[xs.length - 1]}`);
|
|
@@ -66,11 +124,12 @@ function cutValue(v, clip, cap, s) {
|
|
|
66
124
|
if (typeof v === "string") {
|
|
67
125
|
if (v.length <= clip)
|
|
68
126
|
return v;
|
|
69
|
-
s
|
|
127
|
+
if (s)
|
|
128
|
+
s.clipped++;
|
|
70
129
|
return clipString(v, clip);
|
|
71
130
|
}
|
|
72
131
|
if (Array.isArray(v)) {
|
|
73
|
-
if (v.length > cap)
|
|
132
|
+
if (s && v.length > cap)
|
|
74
133
|
s.capped++;
|
|
75
134
|
return v.slice(0, cap).map((x) => cutValue(x, clip, cap, s));
|
|
76
135
|
}
|
|
@@ -78,18 +137,31 @@ function cutValue(v, clip, cap, s) {
|
|
|
78
137
|
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, cutValue(x, clip, cap, s)]));
|
|
79
138
|
return v;
|
|
80
139
|
}
|
|
140
|
+
// A RowKeep as the fields it names, each with its subfields (null: kept whole), built once per
|
|
141
|
+
// RowKeep (3.0.2: through 3.0.1, once per row cut; a 2,000-row answer's structured cut built it
|
|
142
|
+
// some 12,000 times). A RowKeep is never changed once built.
|
|
143
|
+
const wantedOf = (() => {
|
|
144
|
+
const built = new WeakMap();
|
|
145
|
+
return (keep) => {
|
|
146
|
+
let wanted = built.get(keep);
|
|
147
|
+
if (!wanted)
|
|
148
|
+
built.set(keep, (wanted = new Map(keep.map((k) => (typeof k === "string" ? [k, null] : [k[0], k[1]])))));
|
|
149
|
+
return wanted;
|
|
150
|
+
};
|
|
151
|
+
})();
|
|
81
152
|
function cutRow(row, keep, level, s, prefix = "") {
|
|
82
|
-
const wanted =
|
|
153
|
+
const wanted = wantedOf(keep);
|
|
83
154
|
const cap = level.cap ?? Number.POSITIVE_INFINITY;
|
|
84
155
|
const out = {};
|
|
85
|
-
for (const
|
|
156
|
+
for (const k of Object.keys(row)) {
|
|
157
|
+
const v = row[k];
|
|
86
158
|
if (!wanted.has(k)) {
|
|
87
|
-
s
|
|
159
|
+
s?.left.add(`${prefix}${k}`);
|
|
88
160
|
continue;
|
|
89
161
|
}
|
|
90
162
|
const sub = wanted.get(k);
|
|
91
163
|
if (sub && Array.isArray(v)) {
|
|
92
|
-
if (v.length > cap)
|
|
164
|
+
if (s && v.length > cap)
|
|
93
165
|
s.capped++;
|
|
94
166
|
out[k] = v.slice(0, cap).map((e) => (isPlainObject(e) ? cutRow(e, sub, level, s, `${prefix}${k}[].`) : cutValue(e, level.clip, cap, s)));
|
|
95
167
|
}
|
|
@@ -165,6 +237,12 @@ function cutSides(data, sides) {
|
|
|
165
237
|
export const FIRST_TEXT_BLOCK = {
|
|
166
238
|
opens: (chars) => `TEXT CUT: the API's answer is ${chars} characters of JSON, more than the ${DATA_TEXT_BUDGET} the first text block holds, so that block is cut, and structuredContent.data carries the answer whole.`,
|
|
167
239
|
};
|
|
240
|
+
// #3013: the first text block beside a structuredContent.data that is cut too (DATA CUT): the same
|
|
241
|
+
// text, cut the same way, and sentences that say data is whole only with full: true.
|
|
242
|
+
export const FIRST_TEXT_BLOCK_BESIDE_A_CUT = {
|
|
243
|
+
opens: (chars) => `TEXT CUT: the API's answer is ${chars} characters of JSON, more than the ${DATA_TEXT_BUDGET} the first text block holds, so that block is cut, and structuredContent.data carries the answer whole with full: true (DATA CUT, below).`,
|
|
244
|
+
leftOut: (n, which) => `the other ${n} are left out of it${which}, and structuredContent.data holds them with full: true`,
|
|
245
|
+
};
|
|
168
246
|
/** The first text block for `data` within DATA_TEXT_BUDGET, and the TEXT CUT sentence when it is cut. */
|
|
169
247
|
export function dataText(data, cut, place = FIRST_TEXT_BLOCK) {
|
|
170
248
|
const pretty = JSON.stringify(data, null, 2);
|
|
@@ -232,7 +310,8 @@ export function dataText(data, cut, place = FIRST_TEXT_BLOCK) {
|
|
|
232
310
|
const which = outFirst > 0
|
|
233
311
|
? `: the ${outFirst} whose ${firstOut?.field} is ${(firstOut?.values ?? []).join(" or ")} (${firstOut?.why})${out.size > outFirst ? `, and the last ${out.size - outFirst} of the rest` : ""}`
|
|
234
312
|
: `, the last ${out.size} in order`;
|
|
235
|
-
const
|
|
313
|
+
const elsewhere = place.leftOut ? place.leftOut(out.size, which) : `the other ${out.size} are in structuredContent.data only${which}`;
|
|
314
|
+
const left = `Only ${kept.length} of the ${n} rows of ${cut.rows} are in it; ${elsewhere}.`;
|
|
236
315
|
return { text: shown, said: say(rowsSaid, ...sidesSaid, left, cut.whole, cut.page?.(kept.length, n, data)) };
|
|
237
316
|
}
|
|
238
317
|
base = { ...beside, [cut.rows]: cutRows };
|
|
@@ -250,5 +329,492 @@ export function dataText(data, cut, place = FIRST_TEXT_BLOCK) {
|
|
|
250
329
|
const more = last.caps.size > 8 ? `, and ${last.caps.size - 8} more` : "";
|
|
251
330
|
const lists = last.caps.size ? `Every list in it keeps at most its first ${last.n} ${last.n === 1 ? "entry" : "entries"}: ${listed(named)}${more}, each named with its full length.` : "";
|
|
252
331
|
const strings = last.s.clipped ? `Every string in it longer than ${last.clip} characters keeps its first ${last.clip}, ending in "…".` : "";
|
|
253
|
-
|
|
332
|
+
const said = say(rowsSaid, ...sidesSaid, lists, strings, cut?.whole);
|
|
333
|
+
if (last.caps.size === 0)
|
|
334
|
+
return { text: last.text, said };
|
|
335
|
+
return { text: last.text, said, capped: Object.fromEntries([...last.caps].map(([p, len]) => [p || "the answer", { total: len, returned: Math.min(last.n, len) }])) };
|
|
336
|
+
}
|
|
337
|
+
// ── The structured result's budget (#3013) ──
|
|
338
|
+
//
|
|
339
|
+
// What a success's structured result may cost, all of it (the envelope, its notes and data), as
|
|
340
|
+
// compact JSON, which is how a client that hands structuredContent to a model reads it: the same
|
|
341
|
+
// number as the first text block's. See "The structured result, per client" above.
|
|
342
|
+
export const STRUCTURED_BUDGET = DATA_TEXT_BUDGET;
|
|
343
|
+
// The clause of a paging tool's DATA CUT sentence that names the page size whose rows fit data
|
|
344
|
+
// whole, given PageAt.wholeFit for its rows (search_cves, search_vendor_advisories).
|
|
345
|
+
export const wholePage = (n) => n > 0
|
|
346
|
+
? `a page of ${n} ${n === 1 ? "row" : "rows"} or fewer like these fits data whole.`
|
|
347
|
+
: `even a page of one row like these is past ${STRUCTURED_BUDGET} characters with that row whole, so its data is cut too.`;
|
|
348
|
+
// A tool that names no list: only step 2 (every list at the top level of data) cuts its data.
|
|
349
|
+
export const NO_DATA_CUT = { steps: [] };
|
|
350
|
+
const calls = new AsyncLocalStorage();
|
|
351
|
+
export function answering(full, cut, fn) {
|
|
352
|
+
return calls.run({ full: full === true, cut }, fn);
|
|
353
|
+
}
|
|
354
|
+
export const answeringNow = () => calls.getStore();
|
|
355
|
+
// The argument every tool takes (#3013), and what it says, one constant string. It names no other
|
|
356
|
+
// tool and directs no model (test/description-policy.test.mjs reads inputSchema descriptions too).
|
|
357
|
+
export const FULL_ARG_DESCRIPTION = "true: data in the structured result is the answer whole, however long. Without it, a structured result past 30,000 characters of JSON has its data cut (DATA CUT): in an order the note states, the entries of its longest lists keep only some of their fields, and then the lists only some of their entries; data_truncated is true, and truncated gives each list cut with its total, the number returned and whether its entries are thinned. Whole data is for a program; a client that sizes structured results, as Claude Code does against MAX_MCP_OUTPUT_TOKENS, can refuse an answer past its cap.";
|
|
358
|
+
export const FULL_ARG = z.boolean().optional().describe(FULL_ARG_DESCRIPTION);
|
|
359
|
+
// A level as its fields, and what it does inside each entry (#3132).
|
|
360
|
+
const levelOf = (l) => (Array.isArray(l) ? { keep: l } : l);
|
|
361
|
+
const thinned = (e, level, cap = Number.POSITIVE_INFINITY) => {
|
|
362
|
+
if (!isPlainObject(e))
|
|
363
|
+
return e;
|
|
364
|
+
const { keep, capped, mark } = levelOf(level);
|
|
365
|
+
const t = cutRow(e, keep, { keep, clip: Number.POSITIVE_INFINITY });
|
|
366
|
+
const inner = capped ? t[capped] : undefined;
|
|
367
|
+
if (capped && Array.isArray(inner) && inner.length > cap)
|
|
368
|
+
t[capped] = inner.slice(0, cap);
|
|
369
|
+
return mark ? { ...t, ...mark(e, cap) } : t;
|
|
370
|
+
};
|
|
371
|
+
// The longest list named `capped` inside any entry of a list (#3132): the cap a level starts below.
|
|
372
|
+
const longestInside = (v, capped) => Array.isArray(v) ? v.reduce((m, e) => Math.max(m, Array.isArray(field(e, capped)) ? field(e, capped).length : 0), 0) : 0;
|
|
373
|
+
// An entry JSON cannot write (undefined) is written null in a list.
|
|
374
|
+
const entryChars = (e) => JSON.stringify(e)?.length ?? 4;
|
|
375
|
+
const listOf = (data, at) => at.reduce((o, k) => field(o, k), data);
|
|
376
|
+
// 3.0.2: a list's entries are measured, thinned, only when the cut first asks how many characters
|
|
377
|
+
// some of them take (chars), each thinned, measured and let go; and thinned to be kept only when
|
|
378
|
+
// the cut asks for them (keep), only those it keeps, each once. Through 3.0.1 every list the cut
|
|
379
|
+
// tried held every entry thinned for as long as the cut ran, and a level that caps a list inside
|
|
380
|
+
// each entry thinned every entry at no cap before it tried any: on a 2,000-row check_sbom answer
|
|
381
|
+
// (test/http-memory.test.mjs's) the structured cut allocated 108 MB, against 18 MB on 3.0.0 and 24
|
|
382
|
+
// MB now (V8's sampling heap profiler, collected objects included). What is measured and kept is
|
|
383
|
+
// the same: each entry thinned as before, its characters summed as before.
|
|
384
|
+
function listAt(data, path, firstOut, level, newest, cap = Number.POSITIVE_INFINITY, levelAt) {
|
|
385
|
+
const at = path.split(".");
|
|
386
|
+
const v = listOf(data, at);
|
|
387
|
+
if (!Array.isArray(v) || v.length === 0)
|
|
388
|
+
return undefined;
|
|
389
|
+
const n = v.length;
|
|
390
|
+
const made = [];
|
|
391
|
+
const entry = (i) => {
|
|
392
|
+
if (!level)
|
|
393
|
+
return v[i];
|
|
394
|
+
if (!(i in made))
|
|
395
|
+
made[i] = thinned(v[i], level, cap);
|
|
396
|
+
return made[i];
|
|
397
|
+
};
|
|
398
|
+
const thin = level ? levelOf(level).keep : undefined;
|
|
399
|
+
// A capped level's clause only where its cap cuts an entry's list (#3139's review: a package
|
|
400
|
+
// with one range is not "at most its first 1").
|
|
401
|
+
const lv = level ? levelOf(level) : undefined;
|
|
402
|
+
const bites = lv?.capped === undefined || v.some((e) => (Array.isArray(field(e, lv.capped)) ? field(e, lv.capped).length : 0) > cap);
|
|
403
|
+
const thinSaid = lv && bites ? lv.said?.(cap) : undefined;
|
|
404
|
+
// The indices in the order they leave: from the end, or, for a series kept newest, from the start.
|
|
405
|
+
const leavingOrder = (i) => (newest ? i : n - 1 - i);
|
|
406
|
+
const order = [];
|
|
407
|
+
for (const value of firstOut?.values ?? [])
|
|
408
|
+
for (let j = 0; j < n; j++)
|
|
409
|
+
if (field(v[leavingOrder(j)], firstOut?.field ?? "") === value)
|
|
410
|
+
order.push(leavingOrder(j));
|
|
411
|
+
const named = order.length;
|
|
412
|
+
const queued = new Set(order);
|
|
413
|
+
for (let j = 0; j < n; j++)
|
|
414
|
+
if (!queued.has(leavingOrder(j)))
|
|
415
|
+
order.push(leavingOrder(j));
|
|
416
|
+
// The characters of the entries in the order they leave, summed: leaving[j] is the first j's.
|
|
417
|
+
let leaving;
|
|
418
|
+
const sums = () => {
|
|
419
|
+
if (leaving)
|
|
420
|
+
return leaving;
|
|
421
|
+
const lens = [];
|
|
422
|
+
for (let i = 0; i < n; i++)
|
|
423
|
+
lens.push(entryChars(!level || i in made ? entry(i) : thinned(v[i], level, cap)));
|
|
424
|
+
leaving = [0];
|
|
425
|
+
for (const i of order)
|
|
426
|
+
leaving.push(leaving[leaving.length - 1] + lens[i]);
|
|
427
|
+
return leaving;
|
|
428
|
+
};
|
|
429
|
+
const kept = (k) => Math.max(0, Math.min(n, k));
|
|
430
|
+
const over = new Map();
|
|
431
|
+
const keptAt = (k) => {
|
|
432
|
+
const out = new Set(order.slice(0, n - kept(k)));
|
|
433
|
+
return Array.from({ length: n }, (_, i) => i).filter((i) => !out.has(i));
|
|
434
|
+
};
|
|
435
|
+
return {
|
|
436
|
+
path,
|
|
437
|
+
at,
|
|
438
|
+
n,
|
|
439
|
+
firstOut,
|
|
440
|
+
thin,
|
|
441
|
+
...(thinSaid ? { thinSaid } : {}),
|
|
442
|
+
...(newest ? { newest } : {}),
|
|
443
|
+
named,
|
|
444
|
+
...(levelAt !== undefined ? { level: levelAt } : {}),
|
|
445
|
+
cap,
|
|
446
|
+
over,
|
|
447
|
+
chars: (k) => {
|
|
448
|
+
const l = sums();
|
|
449
|
+
return l[n] - l[n - kept(k)] + Math.max(0, kept(k) - 1);
|
|
450
|
+
},
|
|
451
|
+
kept: keptAt,
|
|
452
|
+
keep: (k) => keptAt(k).map((i) => (over.has(i) ? over.get(i)?.entry : entry(i))),
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
// `o` with the list at `at` replaced by `v`, each object on the way copied, its keys in their order.
|
|
456
|
+
function withList(o, at, v) {
|
|
457
|
+
const [k, ...rest] = at;
|
|
458
|
+
return { ...o, [k]: rest.length ? withList(o[k], rest, v) : v };
|
|
459
|
+
}
|
|
460
|
+
const orListed = (xs) => (xs.length < 2 ? xs.join("") : `${xs.slice(0, -1).join(", ")} or ${xs[xs.length - 1]}`);
|
|
461
|
+
// The fields a thinning level keeps, as a sentence says them.
|
|
462
|
+
const keepSaid = (keep) => listed(keep.map((x) => (typeof x === "string" ? x : `${x[0]} (each with ${listed([...x[1]])})`)));
|
|
463
|
+
// The clause on the entries that took back detail (#3132's review): their positions among those
|
|
464
|
+
// kept, in their order in data, grouped where one level and cap holds a run of them (#3132's second
|
|
465
|
+
// review: not always the first kept, since give-back passes an entry that cannot take any back).
|
|
466
|
+
function givenBackSaid(list, k) {
|
|
467
|
+
if (list.over.size === 0)
|
|
468
|
+
return "";
|
|
469
|
+
const kept = list.kept(k);
|
|
470
|
+
const runs = [];
|
|
471
|
+
kept.forEach((i, pos) => {
|
|
472
|
+
const g = list.over.get(i);
|
|
473
|
+
if (!g)
|
|
474
|
+
return;
|
|
475
|
+
const last = runs[runs.length - 1];
|
|
476
|
+
if (last && last.to === pos && last.g.level === g.level && last.g.cap === g.cap)
|
|
477
|
+
last.to = pos + 1;
|
|
478
|
+
else
|
|
479
|
+
runs.push({ from: pos + 1, to: pos + 1, g });
|
|
480
|
+
});
|
|
481
|
+
if (runs.length === 0)
|
|
482
|
+
return "";
|
|
483
|
+
const n = runs.reduce((t, r) => t + r.to - r.from + 1, 0);
|
|
484
|
+
const one = (r) => `${r.from === r.to ? `entry ${r.from}` : `entries ${r.from} to ${r.to}`} cut to ${keepSaid(r.g.keep)}${r.g.said ? `, ${r.g.said}` : ""}`;
|
|
485
|
+
return `; with the room left, ${n} of the entries kept take back detail (by position among those kept, in their order in data): ${runs.map(one).join("; ")}`;
|
|
486
|
+
}
|
|
487
|
+
// What a cut keeps of each list it has cut so far, as truncated and as the DATA CUT sentences.
|
|
488
|
+
function cutSaid(cut, kept, wholeChars, data, over, wholeFit) {
|
|
489
|
+
const isThinned = ({ list, k }) => list.thin !== undefined && k > 0;
|
|
490
|
+
const cutOnes = kept.filter((x) => x.k < x.list.n || isThinned(x));
|
|
491
|
+
if (cutOnes.length === 0)
|
|
492
|
+
return { said: "" };
|
|
493
|
+
const truncated = Object.fromEntries(cutOnes.map((x) => [x.list.path, { total: x.list.n, returned: x.k, ...(isThinned(x) ? { thinned: true } : {}) }]));
|
|
494
|
+
const parts = [];
|
|
495
|
+
const byKept = new Map();
|
|
496
|
+
for (const x of cutOnes) {
|
|
497
|
+
const { list, k } = x;
|
|
498
|
+
const out = list.n - k;
|
|
499
|
+
const first = Math.min(out, list.named);
|
|
500
|
+
const end = list.newest ? "oldest" : "last";
|
|
501
|
+
const which = list.firstOut ? `whose ${list.firstOut.field} is ${orListed(list.firstOut.values)} (${list.firstOut.why})` : "";
|
|
502
|
+
const leftOut = out === 0 ? "" : first === 0 ? `, the ${end} ${out} left out` : first === out ? `, the ${out} left out being entries ${which}` : `, the ${out} left out being the ${first} ${which} and the ${end} ${out - first} of the rest`;
|
|
503
|
+
if (isThinned(x)) {
|
|
504
|
+
parts.push(`${list.path} keeps ${k === list.n ? `all ${k}` : `${k} of its ${list.n}`} entries, each cut to ${keepSaid(list.thin)}${list.thinSaid ? `, ${list.thinSaid}` : ""}${leftOut}${givenBackSaid(list, k)}`);
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
if (!list.firstOut || first === 0 || k === 0) {
|
|
508
|
+
const newest = list.newest === true && k > 0;
|
|
509
|
+
const key = `${k}:${newest}`;
|
|
510
|
+
const group = byKept.get(key);
|
|
511
|
+
if (group)
|
|
512
|
+
group.lists.push(list);
|
|
513
|
+
else {
|
|
514
|
+
const g = { k, newest, lists: [list] };
|
|
515
|
+
byKept.set(key, g);
|
|
516
|
+
parts.push(g);
|
|
517
|
+
}
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
parts.push(`${list.path} keeps ${k} of its ${list.n} entries${leftOut}`);
|
|
521
|
+
}
|
|
522
|
+
const cuts = parts.map((p) => {
|
|
523
|
+
if (typeof p === "string")
|
|
524
|
+
return p;
|
|
525
|
+
const paths = listed(p.lists.map((l) => l.path));
|
|
526
|
+
const which = p.newest ? "newest" : "first";
|
|
527
|
+
if (p.lists.length === 1)
|
|
528
|
+
return p.k === 0 ? `${paths} keeps none of its ${p.lists[0].n} entries` : `${paths} keeps its ${which} ${p.k} of ${p.lists[0].n} entries`;
|
|
529
|
+
return p.k === 0 ? `${paths} keep none of their entries` : `${paths} keep their ${which} ${p.k} entries each`;
|
|
530
|
+
});
|
|
531
|
+
const keeps = Object.fromEntries(cutOnes.filter(isThinned).map((x) => [x.list.path, x.list.thin]));
|
|
532
|
+
const page = cut.page?.(truncated, data, { keeps, wholeFit }) ?? "";
|
|
533
|
+
const said = [
|
|
534
|
+
`DATA CUT: with data whole, this structured result is ${wholeChars} characters of JSON, more than the ${STRUCTURED_BUDGET} it holds unless the call says full: true, so its data is cut: ${cuts.join("; ")}.`,
|
|
535
|
+
"truncated gives the total and the number returned of each list cut, and thinned where its entries are cut to some of their fields.",
|
|
536
|
+
page,
|
|
537
|
+
over ? `Cut so, the structured result is still past ${STRUCTURED_BUDGET} characters: data holds no other list to cut.` : "",
|
|
538
|
+
"The same call with full: true returns data whole.",
|
|
539
|
+
];
|
|
540
|
+
return { truncated, said: said.filter(Boolean).join(" ") };
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* The structured result for `data` cut to STRUCTURED_BUDGET, as `cut` says (see "The structured
|
|
544
|
+
* result, per client" above): `structured` builds the result for a data, its truncated and the DATA
|
|
545
|
+
* CUT sentences, and is called with data null to size the rest of it. `wholeChars` is the result's
|
|
546
|
+
* size with data whole. undefined when data holds no list to cut.
|
|
547
|
+
*/
|
|
548
|
+
export function cutData(data, cut, wholeChars, structured) {
|
|
549
|
+
if (!isPlainObject(data))
|
|
550
|
+
return undefined;
|
|
551
|
+
const named = new Set(cut.steps.flatMap((s) => s.lists));
|
|
552
|
+
const steps = [...cut.steps, { lists: Object.keys(data).filter((k) => !named.has(k)) }];
|
|
553
|
+
// Each list cut so far, in the order cut, at the level it was thinned to, how many entries it
|
|
554
|
+
// keeps, and the step that cut it.
|
|
555
|
+
const kept = new Map();
|
|
556
|
+
const apply = (also = []) => [...kept.values(), ...also].reduce((d, { list, k }) => withList(d, list.at, list.keep(k)), data);
|
|
557
|
+
// PageAt.wholeFit: of the list at `path`, the most first entries, each whole, that the result
|
|
558
|
+
// holds within the budget with the rest of data whole and no DATA CUT sentence (structured with
|
|
559
|
+
// truncated undefined): the shape of the same call at that page size. Counted once per path.
|
|
560
|
+
const wholeFits = new Map();
|
|
561
|
+
const wholeFit = (path) => {
|
|
562
|
+
const known = wholeFits.get(path);
|
|
563
|
+
if (known !== undefined)
|
|
564
|
+
return known;
|
|
565
|
+
const at = path.split(".");
|
|
566
|
+
const v = listOf(data, at);
|
|
567
|
+
let k = 0;
|
|
568
|
+
if (Array.isArray(v)) {
|
|
569
|
+
let chars = JSON.stringify(structured(withList(data, at, []), undefined, "")).length;
|
|
570
|
+
for (const e of v) {
|
|
571
|
+
chars += entryChars(e) + (k > 0 ? 1 : 0);
|
|
572
|
+
if (chars > STRUCTURED_BUDGET)
|
|
573
|
+
break;
|
|
574
|
+
k++;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
wholeFits.set(path, k);
|
|
578
|
+
return k;
|
|
579
|
+
};
|
|
580
|
+
// The result's size with this step's lists at k entries each: the rest of the result (data
|
|
581
|
+
// null), data with this step's lists empty (`base`), and their entries.
|
|
582
|
+
const size = (lists, k, base) => {
|
|
583
|
+
const all = [...kept.values(), ...lists.map((list) => ({ list, k: Math.min(k, list.n) }))];
|
|
584
|
+
const { truncated, said } = cutSaid(cut, all, wholeChars, data, false, wholeFit);
|
|
585
|
+
return JSON.stringify(structured(null, truncated, said)).length - "null".length + base + lists.reduce((t, l) => t + l.chars(k), 0);
|
|
586
|
+
};
|
|
587
|
+
let fits = false;
|
|
588
|
+
for (const [s, step] of steps.entries()) {
|
|
589
|
+
// Every entry whole, then each thinning level in turn, every entry kept; at the leanest level
|
|
590
|
+
// the entries leave until the result fits (with keepAll, #3139, not in this step: see below).
|
|
591
|
+
const levels = [undefined, ...(step.thin ?? [])];
|
|
592
|
+
let lists = [];
|
|
593
|
+
let k = 0;
|
|
594
|
+
const at = (level, cap, levelAt) => step.lists.map((p) => listAt(data, p, step.leaveOutFirst, level, step.keepNewest, cap, levelAt)).filter((l) => l !== undefined && !kept.has(l.path));
|
|
595
|
+
for (const [i, thin] of levels.entries()) {
|
|
596
|
+
lists = at(thin, undefined, i - 1);
|
|
597
|
+
if (lists.length === 0)
|
|
598
|
+
break;
|
|
599
|
+
const base = JSON.stringify(apply(lists.map((list) => ({ list, k: 0 })))).length;
|
|
600
|
+
const top = Math.max(...lists.map((l) => l.n));
|
|
601
|
+
// #3132: a level that caps a list inside each entry, every entry kept: the most entries of
|
|
602
|
+
// that inner list, the same number in each, that fit; a level where even none fits is passed.
|
|
603
|
+
// #3132's review: a level with `least` is taken at that cap or more, or passed.
|
|
604
|
+
const capped = thin === undefined ? undefined : levelOf(thin).capped;
|
|
605
|
+
if (capped !== undefined) {
|
|
606
|
+
const longest = Math.max(0, ...step.lists.map((p) => longestInside(listOf(data, p.split(".")), capped)));
|
|
607
|
+
const least = (thin === undefined ? undefined : levelOf(thin).least) ?? 0;
|
|
608
|
+
if (longest > least && size(at(thin, least, i - 1), top, base) <= STRUCTURED_BUDGET) {
|
|
609
|
+
let m = least;
|
|
610
|
+
let hi = longest - 1;
|
|
611
|
+
while (m < hi) {
|
|
612
|
+
const mid = Math.ceil((m + hi) / 2);
|
|
613
|
+
if (size(at(thin, mid, i - 1), top, base) <= STRUCTURED_BUDGET)
|
|
614
|
+
m = mid;
|
|
615
|
+
else
|
|
616
|
+
hi = mid - 1;
|
|
617
|
+
}
|
|
618
|
+
lists = at(thin, m, i - 1);
|
|
619
|
+
k = top;
|
|
620
|
+
fits = true;
|
|
621
|
+
break;
|
|
622
|
+
}
|
|
623
|
+
if (i < levels.length - 1)
|
|
624
|
+
continue;
|
|
625
|
+
// The last level, and even its least cap does not fit: the entries go on at that cap.
|
|
626
|
+
lists = at(thin, least, i - 1);
|
|
627
|
+
}
|
|
628
|
+
if (i < levels.length - 1) {
|
|
629
|
+
if (size(lists, top, base) <= STRUCTURED_BUDGET) {
|
|
630
|
+
k = top;
|
|
631
|
+
fits = true;
|
|
632
|
+
break;
|
|
633
|
+
}
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
if (step.keepAll) {
|
|
637
|
+
// #3139: every entry stays, at this level; the later steps are cut first.
|
|
638
|
+
k = top;
|
|
639
|
+
fits = size(lists, top, base) <= STRUCTURED_BUDGET;
|
|
640
|
+
break;
|
|
641
|
+
}
|
|
642
|
+
// #3132's review: entries leave at the step's leaveAt level, where it names one.
|
|
643
|
+
if (step.leaveAt && step.thin) {
|
|
644
|
+
const { level, cap } = step.leaveAt;
|
|
645
|
+
lists = at(step.thin[level], cap, level);
|
|
646
|
+
}
|
|
647
|
+
if (step.whole) {
|
|
648
|
+
// Every entry or none (#3132's review: data.not_sent_purls).
|
|
649
|
+
if (size(lists, 0, base) <= STRUCTURED_BUDGET) {
|
|
650
|
+
k = size(lists, top, base) <= STRUCTURED_BUDGET ? top : 0;
|
|
651
|
+
fits = true;
|
|
652
|
+
}
|
|
653
|
+
break;
|
|
654
|
+
}
|
|
655
|
+
if (size(lists, 0, base) <= STRUCTURED_BUDGET) {
|
|
656
|
+
// The most entries each list of the step keeps with the result inside the budget.
|
|
657
|
+
let hi = top;
|
|
658
|
+
while (k < hi) {
|
|
659
|
+
const mid = Math.ceil((k + hi) / 2);
|
|
660
|
+
if (size(lists, mid, base) <= STRUCTURED_BUDGET)
|
|
661
|
+
k = mid;
|
|
662
|
+
else
|
|
663
|
+
hi = mid - 1;
|
|
664
|
+
}
|
|
665
|
+
fits = true;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
for (const list of lists)
|
|
669
|
+
kept.set(list.path, { list, k: Math.min(k, list.n), step: s });
|
|
670
|
+
if (fits)
|
|
671
|
+
break;
|
|
672
|
+
}
|
|
673
|
+
const exact = () => {
|
|
674
|
+
const { truncated, said } = cutSaid(cut, [...kept.values()], wholeChars, data, false, wholeFit);
|
|
675
|
+
return JSON.stringify(structured(apply(), truncated, said)).length;
|
|
676
|
+
};
|
|
677
|
+
// #3139: every step cut and the result still past the budget: a keepAll step's entries leave now,
|
|
678
|
+
// the step's first, as many as must, the rest of data as the steps after it left it. The steps
|
|
679
|
+
// after it then take back what room is left (below), as though it were the step that fits.
|
|
680
|
+
// #3139's review: with more than one keepAll step, the last one's entries leave first
|
|
681
|
+
// (cve_remediation's fixed_branches before any vendor advisory).
|
|
682
|
+
if (!fits) {
|
|
683
|
+
for (const [s, step] of [...steps.entries()].reverse()) {
|
|
684
|
+
if (!step.keepAll || fits)
|
|
685
|
+
continue;
|
|
686
|
+
const entries = [...kept.values()].filter((e) => e.step === s);
|
|
687
|
+
if (entries.length === 0)
|
|
688
|
+
continue;
|
|
689
|
+
const set = (k) => {
|
|
690
|
+
for (const e of entries)
|
|
691
|
+
e.k = Math.min(k, e.list.n);
|
|
692
|
+
};
|
|
693
|
+
set(0);
|
|
694
|
+
if (exact() > STRUCTURED_BUDGET)
|
|
695
|
+
continue;
|
|
696
|
+
let k = 0;
|
|
697
|
+
let hi = Math.max(...entries.map((e) => e.list.n));
|
|
698
|
+
while (k < hi) {
|
|
699
|
+
const mid = Math.ceil((k + hi) / 2);
|
|
700
|
+
set(mid);
|
|
701
|
+
if (exact() <= STRUCTURED_BUDGET)
|
|
702
|
+
k = mid;
|
|
703
|
+
else
|
|
704
|
+
hi = mid - 1;
|
|
705
|
+
}
|
|
706
|
+
set(k);
|
|
707
|
+
for (const e of entries)
|
|
708
|
+
e.step = steps.length;
|
|
709
|
+
fits = true;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
// The room the step that fits leaves goes back to the steps emptied before it, the last first,
|
|
713
|
+
// each step's lists keeping at most the same number of their entries, as many as fit, at the
|
|
714
|
+
// level they were thinned to (scan_manifest's not_checked, emptied while its rows were whole,
|
|
715
|
+
// keeps its entries once the thinned rows fit).
|
|
716
|
+
if (fits) {
|
|
717
|
+
const last = Math.max(...[...kept.values()].map((e) => e.step));
|
|
718
|
+
for (let s = last - 1; s >= 0; s--) {
|
|
719
|
+
const entries = [...kept.values()].filter((e) => e.step === s);
|
|
720
|
+
if (entries.length === 0)
|
|
721
|
+
continue;
|
|
722
|
+
const at = (k) => {
|
|
723
|
+
for (const e of entries)
|
|
724
|
+
e.k = Math.min(k, e.list.n);
|
|
725
|
+
};
|
|
726
|
+
let k = Math.min(...entries.map((e) => e.k));
|
|
727
|
+
let hi = Math.max(...entries.map((e) => e.list.n));
|
|
728
|
+
if (steps[s].whole) {
|
|
729
|
+
// Every entry or none (#3132's review).
|
|
730
|
+
at(hi);
|
|
731
|
+
if (exact() > STRUCTURED_BUDGET)
|
|
732
|
+
at(k);
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
while (k < hi) {
|
|
736
|
+
const mid = Math.ceil((k + hi) / 2);
|
|
737
|
+
at(mid);
|
|
738
|
+
if (exact() <= STRUCTURED_BUDGET)
|
|
739
|
+
k = mid;
|
|
740
|
+
else
|
|
741
|
+
hi = mid - 1;
|
|
742
|
+
}
|
|
743
|
+
at(k);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
// #3132's review: then a giveBack step's entries, in their order in data, take back detail (see
|
|
747
|
+
// DataCutStep): each the richest level above its own that fits. A RowKeep level is tried whole;
|
|
748
|
+
// of the capped levels, only the leanest at or above the entry's own (check_sbom's compact one,
|
|
749
|
+
// or the entry's own), uncapped and then at the most inner entries that fit, so a row takes back
|
|
750
|
+
// more of its matches compact before fewer of them thinned. #3132's second review: an entry that
|
|
751
|
+
// takes back nothing is passed, not an end (through round 2 it ended give-back, so a row at the
|
|
752
|
+
// compact level holding every match, too large to move up, kept every later capped row at its
|
|
753
|
+
// cap while room was left); the entries that took some are named by position (givenBackSaid).
|
|
754
|
+
if (fits) {
|
|
755
|
+
for (const [s, step] of steps.entries()) {
|
|
756
|
+
if (!step.giveBack || !step.thin)
|
|
757
|
+
continue;
|
|
758
|
+
const thin = step.thin;
|
|
759
|
+
for (const e of [...kept.values()].filter((x) => x.step === s)) {
|
|
760
|
+
const { list } = e;
|
|
761
|
+
const own = list.level;
|
|
762
|
+
if (own === undefined || list.thin === undefined)
|
|
763
|
+
continue;
|
|
764
|
+
const whole = listOf(data, list.at);
|
|
765
|
+
if (!Array.isArray(whole))
|
|
766
|
+
continue;
|
|
767
|
+
let leanestCapped = -1;
|
|
768
|
+
for (let j = 0; j <= own; j++)
|
|
769
|
+
if (levelOf(thin[j]).capped !== undefined)
|
|
770
|
+
leanestCapped = j;
|
|
771
|
+
const tryAt = (i, j, c) => {
|
|
772
|
+
const l = levelOf(thin[j]);
|
|
773
|
+
list.over.set(i, { entry: thinned(whole[i], thin[j], c), level: j, cap: c, keep: l.keep, ...(l.said && l.capped && c < Number.POSITIVE_INFINITY ? { said: l.said(c) } : {}) });
|
|
774
|
+
if (exact() <= STRUCTURED_BUDGET)
|
|
775
|
+
return true;
|
|
776
|
+
list.over.delete(i);
|
|
777
|
+
return false;
|
|
778
|
+
};
|
|
779
|
+
for (const i of list.kept(e.k)) {
|
|
780
|
+
// An entry its own level already keeps as the richest would is passed, not an end.
|
|
781
|
+
if (JSON.stringify(thinned(whole[i], thin[0])) === JSON.stringify(thinned(whole[i], thin[own], list.cap)))
|
|
782
|
+
continue;
|
|
783
|
+
let took = false;
|
|
784
|
+
for (let j = 0; j <= own && !took; j++) {
|
|
785
|
+
const l = levelOf(thin[j]);
|
|
786
|
+
const inner = l.capped !== undefined && Array.isArray(field(whole[i], l.capped)) ? field(whole[i], l.capped).length : 0;
|
|
787
|
+
if (l.capped === undefined) {
|
|
788
|
+
if (j < own)
|
|
789
|
+
took = tryAt(i, j, Number.POSITIVE_INFINITY);
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
792
|
+
if (j !== leanestCapped)
|
|
793
|
+
continue;
|
|
794
|
+
if (j < own || list.cap < inner)
|
|
795
|
+
took = tryAt(i, j, Number.POSITIVE_INFINITY);
|
|
796
|
+
if (took)
|
|
797
|
+
break;
|
|
798
|
+
let c = j === own ? list.cap + 1 : (l.least ?? 0);
|
|
799
|
+
let hi = inner - 1;
|
|
800
|
+
if (c > hi || !tryAt(i, j, c))
|
|
801
|
+
continue;
|
|
802
|
+
while (c < hi) {
|
|
803
|
+
const mid = Math.ceil((c + hi) / 2);
|
|
804
|
+
if (tryAt(i, j, mid))
|
|
805
|
+
c = mid;
|
|
806
|
+
else
|
|
807
|
+
hi = mid - 1;
|
|
808
|
+
}
|
|
809
|
+
took = tryAt(i, j, c);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
const { truncated, said } = cutSaid(cut, [...kept.values()], wholeChars, data, !fits, wholeFit);
|
|
816
|
+
if (truncated === undefined)
|
|
817
|
+
return undefined;
|
|
818
|
+
const d = apply();
|
|
819
|
+
return { data: d, truncated, said, structured: structured(d, truncated, said) };
|
|
254
820
|
}
|