@cspeach/cli 1.1.28 → 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/agent/turn-error-ux.js +40 -0
- 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 +83 -83
- 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/dist/ui/app.js +4 -0
- package/dist/ui/widgets/hold-raw-mode.js +26 -0
- package/package.json +2 -2
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
// GENERATED COPY — do not edit here. Edit viewer/src/lib/register-map.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
|
+
* The map: one block per package, one cell per object, colour = state.
|
|
7
|
+
*
|
|
8
|
+
* Strings, not DOM, for two reasons: the status file is a document with no
|
|
9
|
+
* script in it and needs the same picture as text; and 20,000 cells arrive in
|
|
10
|
+
* one innerHTML assignment rather than 20,000 createElement calls.
|
|
11
|
+
*
|
|
12
|
+
* Colour is a CSS class (`s-verified`), never a style attribute: the page's
|
|
13
|
+
* Content-Security-Policy forbids inline styles, and the status file and the
|
|
14
|
+
* page then share one stylesheet.
|
|
15
|
+
*/
|
|
16
|
+
import { STATE_ORDER, STATE_LABEL, FROM_FILE, NOT_MENTIONED, NOT_REPORTED } from './register-model.js';
|
|
17
|
+
const ENTITIES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
18
|
+
/**
|
|
19
|
+
* The one escaping function for the whole page. Object, package, lane and
|
|
20
|
+
* person names all come from a shared folder and are untrusted text.
|
|
21
|
+
* The two quote characters are written as `\x22` and `\x27`. They are the same
|
|
22
|
+
* characters, and a quote inside a regex literal blinds the word scanner that
|
|
23
|
+
* reads these files (`team-words.test.js`): it knows strings and comments but
|
|
24
|
+
* not regexes, so one `"` here flips text and code around for the rest of the
|
|
25
|
+
* file and the guard goes quiet. Leave them escaped.
|
|
26
|
+
*
|
|
27
|
+
* @param {unknown} value
|
|
28
|
+
* @returns {string}
|
|
29
|
+
*/
|
|
30
|
+
export function esc(value) {
|
|
31
|
+
return value == null ? '' : String(value).replace(/[&<>\x22\x27]/g, (c) => ENTITIES[c]);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The class for a state. The states are a closed set: an unknown or hostile
|
|
35
|
+
* state becomes `s-unknown`, never a raw value, because `class` is
|
|
36
|
+
* space-separated and escaping does not neutralise a space — a state of
|
|
37
|
+
* "clean hidden" would otherwise add the class `hidden` and hide the cell.
|
|
38
|
+
* @param {unknown} state
|
|
39
|
+
* @returns {string}
|
|
40
|
+
*/
|
|
41
|
+
export function stateClass(state) {
|
|
42
|
+
return `s-${Object.hasOwn(STATE_LABEL, state) ? state : 'unknown'}`;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The words for a state, from the same closed set: an unknown state — or an
|
|
46
|
+
* inherited property name like "constructor" — reads "Unknown".
|
|
47
|
+
* @param {unknown} state
|
|
48
|
+
* @returns {string}
|
|
49
|
+
*/
|
|
50
|
+
export function stateLabel(state) {
|
|
51
|
+
return Object.hasOwn(STATE_LABEL, state) ? STATE_LABEL[state] : 'Unknown';
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The newest measurement this object's colour was actually read from.
|
|
55
|
+
*
|
|
56
|
+
* `counted` is register-core's own answer to "did this run move the state", so
|
|
57
|
+
* the view never works that rule out a second time. Walking backwards stops at
|
|
58
|
+
* the first counted one, which on a real object is the last entry.
|
|
59
|
+
*/
|
|
60
|
+
function drivingNewest(o) {
|
|
61
|
+
const all = o?.measurements;
|
|
62
|
+
if (!Array.isArray(all))
|
|
63
|
+
return null;
|
|
64
|
+
for (let i = all.length - 1; i >= 0; i -= 1)
|
|
65
|
+
if (all[i]?.counted)
|
|
66
|
+
return all[i];
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The words for an object's colour, with what that colour rests on.
|
|
71
|
+
*
|
|
72
|
+
* Three strengths, and only the two PROVEN states can carry the weaker two —
|
|
73
|
+
* "Did not hold, measured from a file" would be labelling a problem with how it
|
|
74
|
+
* was found. A colour read from a system run says nothing extra; one read from
|
|
75
|
+
* a file says so; and one that rests on SILENCE — nothing mentioned the object
|
|
76
|
+
* and it was counted clean anyway — says that instead, because nothing measured
|
|
77
|
+
* it at all.
|
|
78
|
+
*
|
|
79
|
+
* Whose silence it was follows the BASIS the state was read from: a file that
|
|
80
|
+
* did not mention it, or a check run that did not report it. Saying "in a file"
|
|
81
|
+
* of a live run would send a manager looking for an export that does not exist.
|
|
82
|
+
*
|
|
83
|
+
* It lives here, beside `stateLabel`, because the map's cells need it too: the
|
|
84
|
+
* `<title>` is the only thing a hover, a screen reader on the roving cell, and
|
|
85
|
+
* the status file's picture ever say about an object, and a file-backed "Clean"
|
|
86
|
+
* there would be the one place the label went missing.
|
|
87
|
+
*
|
|
88
|
+
* @param {{ state: string, measuredBasis?: string|null, measurements?: object[] }} o
|
|
89
|
+
* @returns {string}
|
|
90
|
+
*/
|
|
91
|
+
export function stateWord(o) {
|
|
92
|
+
const label = stateLabel(o?.state);
|
|
93
|
+
if (o?.state !== 'verified' && o?.state !== 'clean')
|
|
94
|
+
return label;
|
|
95
|
+
const fromFile = o.measuredBasis === 'file';
|
|
96
|
+
if (drivingNewest(o)?.evidence === 'absent')
|
|
97
|
+
return `${label} — ${fromFile ? NOT_MENTIONED : NOT_REPORTED}`;
|
|
98
|
+
return fromFile ? `${label} — ${FROM_FILE}` : label;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Wider than tall (reads like a block of text), and bounded so one huge
|
|
102
|
+
* package cannot run off the page. Nonsense in (NaN, negative) gives the
|
|
103
|
+
* smallest map rather than a NaN-sized picture.
|
|
104
|
+
* @param {number} count
|
|
105
|
+
* @returns {number}
|
|
106
|
+
*/
|
|
107
|
+
export function mapColumns(count) {
|
|
108
|
+
return Math.max(8, Math.min(60, Math.ceil(Math.sqrt(Math.max(0, count || 0)) * 1.6)));
|
|
109
|
+
}
|
|
110
|
+
/** The four clean-core levels. Anything else (absent, or a word this build does not know) is "not decided". */
|
|
111
|
+
const LEVEL_SET = new Set(['A', 'B', 'C', 'D']);
|
|
112
|
+
/** The five level buckets, in order, and their words: one table for the map, the legend and the bars. */
|
|
113
|
+
export const LEVEL_BUCKETS = ['A', 'B', 'C', 'D', 'none'];
|
|
114
|
+
export const LEVEL_WORD = { A: 'A', B: 'B', C: 'C', D: 'D', none: 'not decided' };
|
|
115
|
+
/**
|
|
116
|
+
* The class for a level, from a closed set like the states: a hostile
|
|
117
|
+
* `levelNow` can never write a second class into the cell.
|
|
118
|
+
* @param {unknown} l
|
|
119
|
+
* @returns {string}
|
|
120
|
+
*/
|
|
121
|
+
export const levelClass = (l) => `l-${LEVEL_SET.has(l) ? l : 'none'}`;
|
|
122
|
+
/** The only two roles a block may carry, so `role` can never be written into. */
|
|
123
|
+
const BLOCK_ROLES = new Set(['group', 'img']);
|
|
124
|
+
/**
|
|
125
|
+
* One package block as a single `<svg>` string.
|
|
126
|
+
* @param {{ package: string, objects: Array<{ key: string, type: string, name: string, state: string }> }} block
|
|
127
|
+
* `colourBy: 'level'` colours each cell by the level it stands at now
|
|
128
|
+
* (`levelNow`) instead of its state, and the title names that level. The state
|
|
129
|
+
* stays in the title either way.
|
|
130
|
+
* @param {{ cell?: number, gap?: number, role?: 'group'|'img', colourBy?: 'state'|'level' }} [opts]
|
|
131
|
+
* @returns {string}
|
|
132
|
+
*/
|
|
133
|
+
export function blockSvg(block, { cell = 10, gap = 2, role = 'group', colourBy = 'state' } = {}) {
|
|
134
|
+
// `group` by default: an image's children are presentational, and the page
|
|
135
|
+
// makes each cell something a reader can land on and open. `img` would hide
|
|
136
|
+
// every one of them from a screen reader.
|
|
137
|
+
//
|
|
138
|
+
// The status file asks for `img`, and is right to: it carries no script, so
|
|
139
|
+
// nothing there can open a cell, and a screen reader would otherwise be
|
|
140
|
+
// handed tens of thousands of shapes that do nothing. The set is closed, like
|
|
141
|
+
// the states — `role` is an attribute, and a caller must not be able to write
|
|
142
|
+
// a second one after it.
|
|
143
|
+
const safeRole = BLOCK_ROLES.has(role) ? role : 'group';
|
|
144
|
+
const n = block.objects.length;
|
|
145
|
+
const cols = mapColumns(n);
|
|
146
|
+
const rows = Math.max(1, Math.ceil(n / cols));
|
|
147
|
+
const step = cell + gap;
|
|
148
|
+
const width = cols * step - gap;
|
|
149
|
+
const height = rows * step - gap;
|
|
150
|
+
// `data-key` is how the page finds the object behind a cell that was clicked.
|
|
151
|
+
// A picture has no clicks, so in `img` mode it is 30 dead bytes per cell — on
|
|
152
|
+
// a 20,000-object project, most of a megabyte in a file somebody has to mail.
|
|
153
|
+
// The per-cell `<title>` stays either way: hovering a cell to read the name is
|
|
154
|
+
// worth its bytes in a document nobody can search any other way.
|
|
155
|
+
const keyed = safeRole !== 'img';
|
|
156
|
+
const byLevel = colourBy === 'level';
|
|
157
|
+
const rects = block.objects.map((o, i) => {
|
|
158
|
+
const x = (i % cols) * step;
|
|
159
|
+
const y = Math.floor(i / cols) * step;
|
|
160
|
+
// A retired object keeps its state colour in level mode: the level counts
|
|
161
|
+
// leave retired objects out, and a cell painted C that no count includes
|
|
162
|
+
// would show a manager two numbers for one folder.
|
|
163
|
+
const asLevel = byLevel && o.state !== 'retired';
|
|
164
|
+
return `<rect class="cell ${asLevel ? levelClass(o.levelNow) : stateClass(o.state)}"${keyed ? ` data-key="${esc(o.key)}"` : ''} x="${x}" y="${y}" width="${cell}" height="${cell}" rx="2">`
|
|
165
|
+
+ `<title>${esc(o.name)} · ${esc(o.type)}${asLevel ? ` · level ${esc(LEVEL_WORD[LEVEL_SET.has(o.levelNow) ? o.levelNow : 'none'])}` : ''} · ${esc(stateWord(o))}</title></rect>`;
|
|
166
|
+
}).join('');
|
|
167
|
+
const label = `${block.package}: ${n.toLocaleString('en-US')} ${n === 1 ? 'object' : 'objects'}`;
|
|
168
|
+
return `<svg class="block-map" role="${safeRole}" aria-label="${esc(label)}" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${rects}</svg>`;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* The key to the colours: only the states that occur, worst first, with counts.
|
|
172
|
+
*
|
|
173
|
+
* `unknown` is the tenth colour, and it is not a state. A cell drawn in it
|
|
174
|
+
* holds something this build has no word for, and it is meant to look wrong —
|
|
175
|
+
* so the legend names it and counts it rather than leaving a mark on the map
|
|
176
|
+
* that nothing in the key accounts for.
|
|
177
|
+
*
|
|
178
|
+
* @param {Record<string, number>} byState
|
|
179
|
+
* @param {{ unknown?: number }} [opts]
|
|
180
|
+
* @returns {string}
|
|
181
|
+
*/
|
|
182
|
+
export function legendHtml(byState, { unknown = 0 } = {}) {
|
|
183
|
+
const entry = (cls, label, count) => `<li><span class="swatch ${cls}"></span>${esc(label)} <b>${esc(count.toLocaleString('en-US'))}</b></li>`;
|
|
184
|
+
const items = STATE_ORDER.filter((s) => (byState[s] ?? 0) > 0)
|
|
185
|
+
.map((s) => entry(stateClass(s), stateLabel(s), byState[s]));
|
|
186
|
+
if (unknown > 0)
|
|
187
|
+
items.push(entry(stateClass('unknown'), stateLabel('unknown'), unknown));
|
|
188
|
+
return `<ul class="legend">${items.join('')}</ul>`;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* The key to the level colours: always all five, D included, with counts —
|
|
192
|
+
* or, for a folder from an older build with no level counts, one line saying so.
|
|
193
|
+
*
|
|
194
|
+
* The counts are register-core's own (`totals.byLevel.now`), never recounted
|
|
195
|
+
* here, so the legend and the "Levels now" bar are one number. Retired objects
|
|
196
|
+
* are outside those counts and drawn in their state colour, so they get their
|
|
197
|
+
* own entry when there are any.
|
|
198
|
+
*
|
|
199
|
+
* @param {Record<string, number>} counts `totals.byLevel.now`
|
|
200
|
+
* @param {{ retired?: number }} [opts]
|
|
201
|
+
* @returns {string}
|
|
202
|
+
*/
|
|
203
|
+
export function levelLegendHtml(counts, { retired = 0 } = {}) {
|
|
204
|
+
const entry = (cls, label, count) => `<li><span class="swatch ${cls}"></span>${esc(label)} <b>${esc((count ?? 0).toLocaleString('en-US'))}</b></li>`;
|
|
205
|
+
// A folder from an older build has no level counts: say so, as the level bars
|
|
206
|
+
// do, rather than print five zeros that read as "measured, nothing there".
|
|
207
|
+
const items = counts
|
|
208
|
+
? LEVEL_BUCKETS.map((l) => entry(`l-${l}`, LEVEL_WORD[l], counts[l]))
|
|
209
|
+
: ['<li>No levels recorded yet.</li>'];
|
|
210
|
+
if (retired > 0)
|
|
211
|
+
items.push(entry(stateClass('retired'), 'retired', retired));
|
|
212
|
+
return `<ul class="legend">${items.join('')}</ul>`;
|
|
213
|
+
}
|
|
@@ -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
|
+
}
|