@cspeach/cli 1.1.29 → 1.1.30
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/dist/agent/loop.js +6 -3
- package/dist/auth/auth-file.js +2 -2
- package/dist/cli.js +4 -0
- package/dist/commands/login.js +1 -1
- package/dist/commands/logout.js +1 -1
- package/dist/commands/team.js +298 -26
- package/dist/commands/whoami.js +1 -1
- package/dist/config/paths.js +15 -1
- package/dist/one-shot.js +1 -0
- package/dist/projects/clean-core-confirm.js +36 -0
- package/dist/projects/extract-clean-core.js +79 -0
- package/dist/projects/save-command.js +38 -9
- package/dist/projects/validate.js +2 -1
- package/dist/projects/workspace.js +2 -1
- package/dist/register/atc-file.js +9 -1
- package/dist/register/findings.js +79 -0
- package/dist/register/measure.js +60 -1
- package/dist/register/offer.js +33 -0
- package/dist/register/picture.js +21 -0
- package/dist/register/records.js +32 -0
- package/dist/register/status-file/register-map.js +213 -0
- package/dist/register/status-file/register-model.js +307 -0
- package/dist/register/status-file/register-parts.js +509 -0
- package/dist/register/status-file/register-status-file.js +171 -0
- package/dist/register/status-file/team-css.js +5 -0
- package/dist/register/status-file-sync.js +19 -0
- package/dist/register/status-html.js +17 -0
- package/dist/register/workbooks.js +104 -0
- package/dist/register/write-whole.js +22 -0
- package/dist/register/xlsx.js +117 -0
- package/dist/renderer/tool-labels.js +2 -1
- package/dist/repl.js +1 -0
- package/dist/skills/bundled-skills.js +45 -45
- package/dist/tools/_filesystem-shared.js +16 -9
- package/dist/tools/project/team_findings.js +72 -0
- package/dist/tools/sap-write.js +8 -0
- package/dist/tools/tool-loading.js +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
// GENERATED COPY — do not edit here. Edit viewer/src/lib/register-model.js,
|
|
3
|
+
// then run `npx tsx scripts/sync-status-file.ts` (prebuild runs it too).
|
|
4
|
+
// src/register/__tests__/status-file-pin.test.ts fails when this copy drifts.
|
|
5
|
+
/**
|
|
6
|
+
* What a manager reads, worked out from the register.
|
|
7
|
+
*
|
|
8
|
+
* Pure: a Register in, plain data out. No DOM and no clock — `now` is handed
|
|
9
|
+
* in — so the page, the status file and the tests all get the same answer.
|
|
10
|
+
*
|
|
11
|
+
* "Proven" means the system said so: verified (had findings, a later
|
|
12
|
+
* measurement has none) or clean (measured, never had any). A developer's
|
|
13
|
+
* "fixed" is a claim and is never counted here.
|
|
14
|
+
*/
|
|
15
|
+
import { activeOf, provenOf, settledOf } from '@cspeach/register-core';
|
|
16
|
+
/**
|
|
17
|
+
* The three counts every surface divides by are defined once, in register-core,
|
|
18
|
+
* where the terminal reads them too. They are passed on from here so that
|
|
19
|
+
* register-parts.js — which draws the lane table for both the page and the
|
|
20
|
+
* status file — takes its numbers from the model, like everything else it draws.
|
|
21
|
+
*/
|
|
22
|
+
export { activeOf, provenOf, settledOf };
|
|
23
|
+
/** Worst first: the order cells are banded in a block and listed in a legend. */
|
|
24
|
+
export const STATE_ORDER = ['regressed', 'did-not-hold', 'untouched', 'decided', 'working', 'claimed', 'verified', 'clean', 'retired'];
|
|
25
|
+
export const STATE_LABEL = {
|
|
26
|
+
regressed: 'Regressed',
|
|
27
|
+
'did-not-hold': 'Did not hold',
|
|
28
|
+
untouched: 'Untouched',
|
|
29
|
+
decided: 'Decided',
|
|
30
|
+
working: 'Working',
|
|
31
|
+
claimed: 'Claimed fixed',
|
|
32
|
+
verified: 'Verified',
|
|
33
|
+
clean: 'Clean',
|
|
34
|
+
retired: 'Retired',
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Objects carrying a state this build has no word for. Data written by a newer
|
|
38
|
+
* CSPeach, or hand-edited: the map already draws them in the tenth colour, and
|
|
39
|
+
* the legend has to name that colour or the reader is left with a mark nobody
|
|
40
|
+
* accounts for.
|
|
41
|
+
*/
|
|
42
|
+
export const unknownStateCount = (register) => register.objects.filter((o) => !Object.hasOwn(STATE_LABEL, o.state)).length;
|
|
43
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
44
|
+
const two = (n) => String(n).padStart(2, '0');
|
|
45
|
+
export function formatWhen(iso) {
|
|
46
|
+
const t = iso ? new Date(iso) : null;
|
|
47
|
+
if (!t || Number.isNaN(t.getTime()))
|
|
48
|
+
return 'never';
|
|
49
|
+
return `${t.getUTCDate()} ${MONTHS[t.getUTCMonth()]} ${t.getUTCFullYear()} ${two(t.getUTCHours())}:${two(t.getUTCMinutes())} UTC`;
|
|
50
|
+
}
|
|
51
|
+
const plural = (n, one, many) => `${n.toLocaleString('en-US')} ${n === 1 ? one : many}`;
|
|
52
|
+
/**
|
|
53
|
+
* Proven, but one step removed from the system.
|
|
54
|
+
*
|
|
55
|
+
* A file export is somebody's choice of what to put in a file, with which
|
|
56
|
+
* scope, at which moment. It can prove an object — a team with nobody connected
|
|
57
|
+
* to SAP works that way for weeks — but it is not the same thing as a run of
|
|
58
|
+
* the system, and every place that shows such a proof says which it was. One
|
|
59
|
+
* phrase, defined once, because it is printed in five of them.
|
|
60
|
+
*/
|
|
61
|
+
export const FROM_FILE = 'measured from a file';
|
|
62
|
+
/**
|
|
63
|
+
* Weaker still: the file never mentioned the object, and the person who
|
|
64
|
+
* imported it told CSPeach to read that silence as clean. Nothing looked at the
|
|
65
|
+
* object at all — so where a colour rests on that, it says this instead of
|
|
66
|
+
* `FROM_FILE`, which would claim a measurement that never happened.
|
|
67
|
+
*/
|
|
68
|
+
export const NOT_MENTIONED = 'not mentioned in a file';
|
|
69
|
+
/**
|
|
70
|
+
* The same silence, from a live check run: the run finished and never reported
|
|
71
|
+
* the object. There is no file, so `NOT_MENTIONED` would be the page describing
|
|
72
|
+
* something that did not happen — and a manager who went looking for the export
|
|
73
|
+
* would find none. Which of the two is said follows the BASIS that drove the
|
|
74
|
+
* state, never the shape of the row.
|
|
75
|
+
*/
|
|
76
|
+
export const NOT_REPORTED = 'not reported by the check run';
|
|
77
|
+
/** "A", "A and B", "A, B and C" — how a person would say a list out loud. */
|
|
78
|
+
const andList = (names) => (names.length < 3 ? names.join(' and ') : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`);
|
|
79
|
+
/** As many names as a person reads before they stop reading names. */
|
|
80
|
+
const LIST_CAP = 12;
|
|
81
|
+
const count = (value) => value.toLocaleString('en-US');
|
|
82
|
+
/**
|
|
83
|
+
* How a cut list ends.
|
|
84
|
+
*
|
|
85
|
+
* Three endings, because a reader sent to the wrong place looks for something
|
|
86
|
+
* that is not drawn. Objects are cells on the map, so on the PAGE the rest of
|
|
87
|
+
* them are one click away. In the status FILE that map is a picture with no
|
|
88
|
+
* script behind it, so the same list points at the object list the file
|
|
89
|
+
* carries further down. And a list of something that is on neither — a pair of
|
|
90
|
+
* lanes, an object out of scope — says only how many it left out.
|
|
91
|
+
*/
|
|
92
|
+
const seeMap = (more) => `and ${count(more)} more — see the map`;
|
|
93
|
+
const seeObjectList = (more) => `and ${count(more)} more — see the object list below`;
|
|
94
|
+
const justHowMany = (more) => `and ${count(more)} more`;
|
|
95
|
+
/**
|
|
96
|
+
* A list, and an honest count of what is not on it.
|
|
97
|
+
*
|
|
98
|
+
* A new ATC variant can regress three thousand objects in one run. Joining
|
|
99
|
+
* every name would put a 60 KB paragraph on the page on the day a manager most
|
|
100
|
+
* needs to read it — and it would still not be a list anybody could use. Twelve
|
|
101
|
+
* names, then the number, then where the rest of them are.
|
|
102
|
+
*
|
|
103
|
+
* `tail` writes that last part, and there is no default: a new cut list has to
|
|
104
|
+
* say where its own reader should look. `more` overrides what is counted, for
|
|
105
|
+
* a list whose items and whose problem are not the same thing.
|
|
106
|
+
*
|
|
107
|
+
* Only the DETAIL is cut. The `keys` beside it stay complete: they are data a
|
|
108
|
+
* map or an export reads, not something a person is looking at.
|
|
109
|
+
*/
|
|
110
|
+
function upTo(parts, { tail, cap = LIST_CAP, sep = ', ', more } = {}) {
|
|
111
|
+
if (parts.length <= cap)
|
|
112
|
+
return parts.join(sep);
|
|
113
|
+
return [...parts.slice(0, cap), tail(more ?? parts.length - cap)].join(sep);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* One object can be wrong in more than one way — regressed AND disputed is a
|
|
117
|
+
* normal pairing. Counting the ways would tell the manager four things need
|
|
118
|
+
* attention when three objects do, so this counts objects.
|
|
119
|
+
*/
|
|
120
|
+
function attentionKeys(register) {
|
|
121
|
+
const keys = new Set();
|
|
122
|
+
for (const o of register.objects) {
|
|
123
|
+
if (o.state === 'regressed' || o.state === 'did-not-hold' || o.decision?.conflict)
|
|
124
|
+
keys.add(o.key);
|
|
125
|
+
}
|
|
126
|
+
return keys;
|
|
127
|
+
}
|
|
128
|
+
export function summarize(register, { unreadable = 0 } = {}) {
|
|
129
|
+
const { totals } = register;
|
|
130
|
+
const proven = provenOf(totals.byState);
|
|
131
|
+
// Floor, never round: 7,997 of 8,000 is not 100% proven, and a manager who
|
|
132
|
+
// reads 100% stops looking. Only every last object earns the round number.
|
|
133
|
+
const provenPct = totals.active ? Math.floor((proven / totals.active) * 100) : 0;
|
|
134
|
+
const needAttention = attentionKeys(register).size;
|
|
135
|
+
const lastMeasured = register.lastMeasuredAt ? `last measured ${formatWhen(register.lastMeasuredAt)}` : 'never measured';
|
|
136
|
+
// A project where every object was retired has nothing left to prove, and
|
|
137
|
+
// "0% proven" would read as a team that proved nothing. An empty project
|
|
138
|
+
// keeps the percentage: its map already says there is nothing in scope yet.
|
|
139
|
+
const provenPhrase = totals.objects > 0 && totals.active === 0 ? 'nothing left to prove' : `${provenPct}% proven`;
|
|
140
|
+
// A proof read out of a file is still a proof, and it is not the same thing
|
|
141
|
+
// as a run of the system. The one line a manager reads says how much of what
|
|
142
|
+
// it just called proven came that way, right beside the word.
|
|
143
|
+
const fromFile = (totals.fromFile?.verified ?? 0) + (totals.fromFile?.clean ?? 0);
|
|
144
|
+
const fromFilePhrase = fromFile > 0 ? ` · ${count(fromFile)} of them ${FROM_FILE}` : '';
|
|
145
|
+
// A folder from an older build carries no `undecided`; work it out the same
|
|
146
|
+
// way register-core does, so the line never prints a blank.
|
|
147
|
+
const decided = totals.decided ?? 0;
|
|
148
|
+
const undecided = Number.isFinite(totals.undecided) ? totals.undecided : Math.max(0, totals.objects - decided);
|
|
149
|
+
return {
|
|
150
|
+
objects: totals.objects, active: totals.active, proven, provenPct, decided: totals.decided, undecided, needAttention,
|
|
151
|
+
lastMeasured, unreadable, fromFile,
|
|
152
|
+
line: `${plural(totals.objects, 'object', 'objects')} · ${provenPhrase}${fromFilePhrase} · ${count(decided)} decided, ${count(undecided)} undecided · ${plural(needAttention, 'needs attention', 'need attention')} · ${lastMeasured}`,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
const DAY_MS = 86_400_000;
|
|
156
|
+
/**
|
|
157
|
+
* `surface` is which of the two things these words will be read on. It changes
|
|
158
|
+
* one mark: where a cut list of OBJECTS sends the reader. On the page that is
|
|
159
|
+
* the map, where a cell opens. In the status file the map is a picture, so it
|
|
160
|
+
* is the object list the file carries instead.
|
|
161
|
+
*/
|
|
162
|
+
export function needsYou(register, { now, unreadable = 0, quietDays = 6, surface = 'page' } = {}) {
|
|
163
|
+
const items = [];
|
|
164
|
+
const inState = (state) => register.objects.filter((o) => o.state === state);
|
|
165
|
+
// The four lists below name objects, and an object is a cell. Everything
|
|
166
|
+
// else here names something the map does not draw.
|
|
167
|
+
const onMap = surface === 'file' ? seeObjectList : seeMap;
|
|
168
|
+
const regressed = inState('regressed');
|
|
169
|
+
if (regressed.length) {
|
|
170
|
+
items.push({ kind: 'regressed', title: `${plural(regressed.length, 'object has', 'objects have')} more findings than before`,
|
|
171
|
+
detail: upTo(regressed.map((o) => o.name), { tail: onMap }), keys: regressed.map((o) => o.key) });
|
|
172
|
+
}
|
|
173
|
+
const didNotHold = inState('did-not-hold');
|
|
174
|
+
if (didNotHold.length) {
|
|
175
|
+
// Objects, not fixes. One object can carry three failed fixes, and "3 fixes
|
|
176
|
+
// did not hold" sends a lead looking for three things to put right.
|
|
177
|
+
//
|
|
178
|
+
// Every writer who claimed a fix here, not just the first. The register
|
|
179
|
+
// carries no per-rule open counts, so the model cannot tell whose fix
|
|
180
|
+
// failed — naming one person would put the blame on a guess.
|
|
181
|
+
items.push({ kind: 'did-not-hold', title: `${plural(didNotHold.length, 'object has', 'objects have')} a fix that did not hold`,
|
|
182
|
+
detail: upTo(didNotHold.map((o) => {
|
|
183
|
+
const by = [...new Set(o.fixes.filter((f) => f.status === 'fixed').map((f) => f.by))];
|
|
184
|
+
return `${o.name} (fixed by ${by.length ? andList(by) : 'unknown'})`;
|
|
185
|
+
}), { tail: onMap }),
|
|
186
|
+
keys: didNotHold.map((o) => o.key) });
|
|
187
|
+
}
|
|
188
|
+
const conflicts = register.objects.filter((o) => o.decision?.conflict);
|
|
189
|
+
if (conflicts.length) {
|
|
190
|
+
items.push({ kind: 'conflict', title: `${plural(conflicts.length, 'decision needs', 'decisions need')} the lead to rule`,
|
|
191
|
+
detail: upTo(conflicts.map((o) => `${o.name} — ${Object.entries(o.decision.conflict).map(([w, c]) => `${w}: ${c}`).join(', ')}`), { tail: onMap, sep: '; ' }),
|
|
192
|
+
keys: conflicts.map((o) => o.key) });
|
|
193
|
+
}
|
|
194
|
+
// An accepted exception is work nobody has to do — on a reason somebody gave.
|
|
195
|
+
// One with no reason (an older record, a hand-written file) is a decision the
|
|
196
|
+
// lead cannot defend yet.
|
|
197
|
+
const noReason = register.objects.filter((o) => o.decision?.classification === 'keep' && o.decision?.exception === true && !o.decision?.reason);
|
|
198
|
+
if (noReason.length) {
|
|
199
|
+
items.push({ kind: 'exception-no-reason', title: `${plural(noReason.length, 'accepted exception has', 'accepted exceptions have')} no reason`,
|
|
200
|
+
detail: upTo(noReason.map((o) => o.name), { tail: onMap }), keys: noReason.map((o) => o.key) });
|
|
201
|
+
}
|
|
202
|
+
const unowned = register.objects.filter((o) => o.lane === null);
|
|
203
|
+
if (unowned.length) {
|
|
204
|
+
const packages = [...new Set(unowned.map((o) => o.package))].sort();
|
|
205
|
+
items.push({ kind: 'unowned', title: `${plural(unowned.length, 'object has', 'objects have')} no owner`,
|
|
206
|
+
detail: `Packages: ${upTo(packages, { tail: onMap })}`, keys: unowned.map((o) => o.key) });
|
|
207
|
+
}
|
|
208
|
+
const overlap = register.objects.filter((o) => o.laneOverlap.length > 0);
|
|
209
|
+
if (overlap.length) {
|
|
210
|
+
// Three lanes can claim one object, and "two lanes" would then be wrong.
|
|
211
|
+
// A pair of lanes is not on the map either — no cell says "fi + sd" — so a
|
|
212
|
+
// cut list of pairs says only how many it left out.
|
|
213
|
+
items.push({ kind: 'overlap', title: `${plural(overlap.length, 'object is', 'objects are')} claimed by more than one lane`,
|
|
214
|
+
detail: upTo([...new Set(overlap.map((o) => o.laneOverlap.join(' + ')))].sort(), { tail: justHowMany, sep: '; ' }), keys: overlap.map((o) => o.key) });
|
|
215
|
+
}
|
|
216
|
+
// A lane that has finished its work stops writing records, and that is the
|
|
217
|
+
// point — it is not quiet, it is done. Only a lane with work still open can
|
|
218
|
+
// be waiting for someone.
|
|
219
|
+
const nowMs = Date.parse(now);
|
|
220
|
+
for (const lane of register.lanes) {
|
|
221
|
+
if (!lane.objects || !lane.lastRecordAt || !Number.isFinite(nowMs))
|
|
222
|
+
continue;
|
|
223
|
+
if (lane.objects - settledOf(lane.byState) <= 0)
|
|
224
|
+
continue;
|
|
225
|
+
const days = Math.floor((nowMs - Date.parse(lane.lastRecordAt)) / DAY_MS);
|
|
226
|
+
if (days >= quietDays) {
|
|
227
|
+
items.push({ kind: 'quiet-lane', title: `${lane.owner} (${lane.id}): no records for ${plural(days, 'day', 'days')}`,
|
|
228
|
+
detail: `Last record ${formatWhen(lane.lastRecordAt)}`, keys: [] });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (register.unknownKeys.length) {
|
|
232
|
+
// These objects are on no map: being out of scope is the whole of what is
|
|
233
|
+
// wrong with them. So the tail says what the list leaves out rather than
|
|
234
|
+
// where to look, and it counts ROWS — what somebody actually recorded.
|
|
235
|
+
// Neither cap is the size of the problem: register-core stops collecting
|
|
236
|
+
// keys at 50, and twelve of those are printed.
|
|
237
|
+
const shown = Math.min(register.unknownKeys.length, LIST_CAP);
|
|
238
|
+
items.push({ kind: 'unknown-objects', title: `${plural(register.skipped.rowsNotInBaseline, 'row names', 'rows name')} an object that is not in scope`,
|
|
239
|
+
detail: upTo(register.unknownKeys, {
|
|
240
|
+
more: register.skipped.rowsNotInBaseline - shown,
|
|
241
|
+
tail: (more) => (more === 1
|
|
242
|
+
? 'and 1 more row names an object that is not in scope'
|
|
243
|
+
: `${justHowMany(more)} rows name objects that are not in scope`),
|
|
244
|
+
}),
|
|
245
|
+
keys: [] });
|
|
246
|
+
}
|
|
247
|
+
if (unreadable) {
|
|
248
|
+
items.push({ kind: 'unreadable', title: `${plural(unreadable, 'file', 'files')} could not be read yet`,
|
|
249
|
+
detail: 'Usually a file that is still syncing. Reload in a minute.', keys: [] });
|
|
250
|
+
}
|
|
251
|
+
return items;
|
|
252
|
+
}
|
|
253
|
+
export function packageBlocks(register) {
|
|
254
|
+
const rank = Object.fromEntries(STATE_ORDER.map((s, i) => [s, i]));
|
|
255
|
+
const byPackage = new Map();
|
|
256
|
+
for (const o of register.objects) {
|
|
257
|
+
if (!byPackage.has(o.package))
|
|
258
|
+
byPackage.set(o.package, []);
|
|
259
|
+
byPackage.get(o.package).push(o);
|
|
260
|
+
}
|
|
261
|
+
return [...byPackage.entries()]
|
|
262
|
+
.map(([pkg, objects]) => {
|
|
263
|
+
const byState = Object.fromEntries(STATE_ORDER.map((s) => [s, 0]));
|
|
264
|
+
for (const o of objects)
|
|
265
|
+
byState[o.state] += 1;
|
|
266
|
+
return {
|
|
267
|
+
package: pkg,
|
|
268
|
+
// 'en' on every comparison: the order of a map must not depend on the
|
|
269
|
+
// laptop's locale, or two people looking at one folder see the cells in
|
|
270
|
+
// two different places.
|
|
271
|
+
objects: objects.slice().sort((a, b) => rank[a.state] - rank[b.state] || a.name.localeCompare(b.name, 'en')),
|
|
272
|
+
byState,
|
|
273
|
+
};
|
|
274
|
+
})
|
|
275
|
+
.sort((a, b) => b.objects.length - a.objects.length || a.package.localeCompare(b.package, 'en'));
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Active objects nobody has measured. Their colour rests on what people said,
|
|
279
|
+
* and the page says so out loud: "not measured" is a state of knowledge, not a
|
|
280
|
+
* blank. A retired object is not counted — there is nothing left to prove.
|
|
281
|
+
*/
|
|
282
|
+
export const notMeasuredCount = (register) => register.objects.filter((o) => o.state !== 'retired' && (o.lastMeasuredAt ?? null) === null).length;
|
|
283
|
+
/**
|
|
284
|
+
* One object's measurements, newest first: when, by whom, how many open, and
|
|
285
|
+
* whether the numbers came from a system check or from a file somebody read in.
|
|
286
|
+
* `more` is how many older ones the cap left out.
|
|
287
|
+
*
|
|
288
|
+
* BOTH bases are here, the counted and the uncounted. A file export of an
|
|
289
|
+
* object the system has already measured moves nothing — live evidence outranks
|
|
290
|
+
* file evidence, per object — but hiding it would leave a manager wondering
|
|
291
|
+
* where the run they were told about went. It is shown, and it is labelled.
|
|
292
|
+
*/
|
|
293
|
+
export function measurementHistory(o, runs, { cap = 8 } = {}) {
|
|
294
|
+
const all = (o.measurements ?? []).map((m) => ({ m, info: runs?.[m.run] })).filter((x) => x.info).reverse();
|
|
295
|
+
return {
|
|
296
|
+
rows: all.slice(0, cap).map(({ m, info }) => ({
|
|
297
|
+
at: info.at, by: info.by, open: m.open, fromFile: info.basis === 'file',
|
|
298
|
+
variant: info.atcVariant ?? '', scope: info.scope ?? '',
|
|
299
|
+
// Straight off the register, never worked out again here: whether this
|
|
300
|
+
// run moved the object's colour is register-core's answer, and a second
|
|
301
|
+
// copy of that rule in the view is a second place for it to go wrong.
|
|
302
|
+
counted: m.counted === true,
|
|
303
|
+
...(m.evidence ? { evidence: m.evidence } : {}),
|
|
304
|
+
})),
|
|
305
|
+
more: Math.max(0, all.length - cap),
|
|
306
|
+
};
|
|
307
|
+
}
|