@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.
Files changed (41) hide show
  1. package/dist/agent/loop.js +6 -3
  2. package/dist/agent/turn-error-ux.js +40 -0
  3. package/dist/auth/auth-file.js +2 -2
  4. package/dist/cli.js +4 -0
  5. package/dist/commands/login.js +1 -1
  6. package/dist/commands/logout.js +1 -1
  7. package/dist/commands/team.js +298 -26
  8. package/dist/commands/whoami.js +1 -1
  9. package/dist/config/paths.js +15 -1
  10. package/dist/one-shot.js +1 -0
  11. package/dist/projects/clean-core-confirm.js +36 -0
  12. package/dist/projects/extract-clean-core.js +79 -0
  13. package/dist/projects/save-command.js +38 -9
  14. package/dist/projects/validate.js +2 -1
  15. package/dist/projects/workspace.js +2 -1
  16. package/dist/register/atc-file.js +9 -1
  17. package/dist/register/findings.js +79 -0
  18. package/dist/register/measure.js +60 -1
  19. package/dist/register/offer.js +33 -0
  20. package/dist/register/picture.js +21 -0
  21. package/dist/register/records.js +32 -0
  22. package/dist/register/status-file/register-map.js +213 -0
  23. package/dist/register/status-file/register-model.js +307 -0
  24. package/dist/register/status-file/register-parts.js +509 -0
  25. package/dist/register/status-file/register-status-file.js +171 -0
  26. package/dist/register/status-file/team-css.js +5 -0
  27. package/dist/register/status-file-sync.js +19 -0
  28. package/dist/register/status-html.js +17 -0
  29. package/dist/register/workbooks.js +104 -0
  30. package/dist/register/write-whole.js +22 -0
  31. package/dist/register/xlsx.js +117 -0
  32. package/dist/renderer/tool-labels.js +2 -1
  33. package/dist/repl.js +1 -0
  34. package/dist/skills/bundled-skills.js +83 -83
  35. package/dist/tools/_filesystem-shared.js +16 -9
  36. package/dist/tools/project/team_findings.js +72 -0
  37. package/dist/tools/sap-write.js +8 -0
  38. package/dist/tools/tool-loading.js +1 -1
  39. package/dist/ui/app.js +4 -0
  40. package/dist/ui/widgets/hold-raw-mode.js +26 -0
  41. 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 = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
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
+ }