@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,509 @@
1
+ // @ts-nocheck
2
+ // GENERATED COPY — do not edit here. Edit viewer/src/lib/register-parts.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 marks the page and the status file both draw.
7
+ *
8
+ * Two surfaces show one folder: the page a manager opens, and the file a lead
9
+ * mails to people who will never open a folder in a browser. When the same row
10
+ * is written out twice it drifts, and these two had already drifted — one lane
11
+ * table carried a `scope` the other did not, and neither showed the number it
12
+ * was dividing by. So every mark that belongs to both lives here, once.
13
+ *
14
+ * Pure strings in, one string out. No DOM, no clock, no listeners: the status
15
+ * file is a document on somebody's desktop with no script in it, and it wears
16
+ * these same strings. That is also why this file knows the model and the map
17
+ * and nothing else — the page component belongs to the browser.
18
+ *
19
+ * Two rules hold everywhere below, as they do on the page: every value that
20
+ * came out of the folder goes through `esc()`, and colour is a class, never a
21
+ * style attribute.
22
+ */
23
+ import { esc, blockSvg, legendHtml, levelLegendHtml, stateWord, LEVEL_BUCKETS as LEVELS, LEVEL_WORD as LEVEL_NAME } from './register-map.js';
24
+ import { activeOf, formatWhen, measurementHistory, notMeasuredCount, provenOf, unknownStateCount, FROM_FILE, } from './register-model.js';
25
+ import { DISPOSITION_ORDER, DISPOSITION_WORD, developerTable, dispositionOf, overlapNote, } from '@cspeach/register-core';
26
+ // The map owns it, because the map's cells need it too; it is offered here as
27
+ // well, where every other shared mark lives.
28
+ export { stateWord };
29
+ /** Digits grouped the way a reader expects: 1,500, not 1500. */
30
+ export const n = (value) => Number(value ?? 0).toLocaleString('en-US');
31
+ /**
32
+ * As much of a piece of folder text as a table cell can hold.
33
+ *
34
+ * Everything below prints text a whole team writes into a shared folder — a
35
+ * check variant, a scope, a person's name, the name of a file somebody
36
+ * imported. register-core already keeps 300 characters of a variant and a
37
+ * scope; 300 characters in a column would still push every other column off a
38
+ * phone. Cut the RAW text, then escape it: cutting after escaping can slice an
39
+ * entity in half and leave `&am` on the page.
40
+ */
41
+ const CELL_TEXT = 120;
42
+ /**
43
+ * Characters that are not text: zero-width joiners and spaces, the bidirectional
44
+ * embedding and OVERRIDE controls, the invisible-maths set, and a byte-order
45
+ * mark. `esc()` cannot help here — they are not markup, they are instructions to
46
+ * the text engine. One U+202E in a person's name reverses every character after
47
+ * it, so a line that reads "Clean — measured from a file" on one row can be made
48
+ * to read backwards on the next, and nothing on screen says why.
49
+ *
50
+ * Written as `\u` escapes, never as the characters themselves: a guard against
51
+ * invisible text cannot be made of invisible text. A reviewer must be able to
52
+ * see what this class covers, and a merge tool must not be able to lose one of
53
+ * them silently. Same rule as `cspeach-cli/src/register/atc-file.ts`.
54
+ */
55
+ const INVISIBLE = /[\u200B-\u200F\u202A-\u202E\u2060-\u2064\uFEFF]/g;
56
+ const short = (value) => {
57
+ const text = String(value ?? '').replace(INVISIBLE, '');
58
+ return text.length > CELL_TEXT ? `${text.slice(0, CELL_TEXT)}…` : text;
59
+ };
60
+ /** One value out of the folder, stripped of what is not text, cut to size, and escaped. */
61
+ export const cell = (value) => esc(short(value));
62
+ /** A count of objects a person can read: a whole number, none of it negative. */
63
+ const whole = (v) => typeof v === 'number' && Number.isInteger(v) && v >= 0;
64
+ /** The three types, in the words a person says them in. */
65
+ const TYPE_WORDS = { 'clean-core': 'clean core', upgrade: 'upgrade', build: 'build' };
66
+ /**
67
+ * Whose team project this is, in one line.
68
+ *
69
+ * No article. "A build team project" reads as a *build team*, and "An upgrade
70
+ * team project" is no better — so the type goes in brackets instead, in plain
71
+ * words. A type this build has never heard of is printed exactly as the folder
72
+ * wrote it, escaped, rather than bent into a phrase we would be guessing at.
73
+ *
74
+ * One sentence for both surfaces, so the page and the file cannot introduce
75
+ * the same folder two different ways.
76
+ *
77
+ * @param {{ type?: string, lead: { name: string } }} project
78
+ * @param {string} [folderName] omitted → the sentence stops after the lead.
79
+ * The status file omits it: its stamp names the folder one line below, and a
80
+ * document that says where it came from twice reads like two documents.
81
+ * @returns {string}
82
+ */
83
+ export function provenanceLine(project, folderName) {
84
+ const type = project.type ? (TYPE_WORDS[project.type] ?? esc(project.type)) : '';
85
+ return `Team project${type ? ` (${type})` : ''}, led by ${esc(project.lead.name)}`
86
+ + `${folderName ? `, read from the folder ${esc(folderName)}` : ''}.`;
87
+ }
88
+ /**
89
+ * Who owns something. Raw text — every caller puts it through `esc()`, because
90
+ * the owner and the lane id both came out of a shared folder.
91
+ *
92
+ * An absent owner says itself in words: an empty cell reads as a value somebody
93
+ * lost rather than as one nobody has set yet.
94
+ */
95
+ export const ownerLabel = (lane) => (lane ? `${lane.owner} (${lane.id})` : 'nobody yet');
96
+ /**
97
+ * Who owns what.
98
+ *
99
+ * Active sits beside Objects because Proven is counted against active objects,
100
+ * never against all of them: a retired object is not an unproven one. Without
101
+ * that column "Proven 2, Objects 4" reads as half done when it is two of three,
102
+ * and the lead and the manager argue about a number neither of them chose. The
103
+ * three counts come from register-core, which is also what the terminal uses.
104
+ *
105
+ * A lane summary counts states and nothing else, so how much of a lane's proven
106
+ * work came out of a file has to be counted here, off the objects. `objects` is
107
+ * optional: a caller that has none gets exactly the table it got before.
108
+ *
109
+ * @param {object[]} lanes `register.lanes`
110
+ * @param {object[]} [objects] `register.objects`
111
+ * @returns {string}
112
+ */
113
+ export function lanesTableHtml(lanes, objects) {
114
+ if (!lanes?.length)
115
+ return '<p>The lead has not set up lanes yet.</p>';
116
+ // One pass over the objects, not one per lane: a real estate has 20,000 of
117
+ // them and ten lanes, and this table is drawn on every render.
118
+ const fromFile = new Map();
119
+ for (const o of objects ?? []) {
120
+ if (o.measuredBasis !== 'file' || (o.state !== 'verified' && o.state !== 'clean'))
121
+ continue;
122
+ // Every lane that claims the object, exactly as the lane's own counts are
123
+ // taken: an object two lanes both claim is proven for both of them, so
124
+ // "(n from a file)" can never be larger than the Proven it sits beside.
125
+ for (const id of o.laneOverlap?.length ? o.laneOverlap : [o.lane])
126
+ fromFile.set(id, (fromFile.get(id) ?? 0) + 1);
127
+ }
128
+ const heads = ['Owner', 'Objects', 'Active', 'Proven', 'Open problems', 'Last record']
129
+ .map((h) => `<th scope="col">${h}</th>`).join('');
130
+ const provenCell = (l) => {
131
+ const said = fromFile.get(l.id) ?? 0;
132
+ return `${n(provenOf(l.byState))}${said ? ` (${n(said)} ${FROM_FILE})` : ''}`;
133
+ };
134
+ const row = (l) => `<tr><td>${esc(ownerLabel(l))}</td><td>${n(l.objects)}</td>`
135
+ + `<td>${n(activeOf(l.objects, l.byState))}</td><td>${provenCell(l)}</td>`
136
+ + `<td>${n((l.byState?.regressed ?? 0) + (l.byState?.['did-not-hold'] ?? 0))}</td>`
137
+ + `<td>${esc(formatWhen(l.lastRecordAt))}</td></tr>`;
138
+ return `<div class="scroller"><table><thead><tr>${heads}</tr></thead>`
139
+ + `<tbody>${lanes.map(row).join('')}</tbody></table></div>`;
140
+ }
141
+ /**
142
+ * One thing waiting for a person. The kind becomes a class so the bar beside it
143
+ * can carry that state's own colour, and nothing else about the kind is used.
144
+ */
145
+ export const needItemHtml = (item) => `<li class="need k-${esc(item.kind)}"><b>${esc(item.title)}</b><span>${esc(item.detail)}</span></li>`;
146
+ /**
147
+ * One package as a block of cells.
148
+ *
149
+ * `role: 'img'` is for the status file: it carries no script, so nothing there
150
+ * can open a cell, and a screen reader handed tens of thousands of shapes that
151
+ * do nothing is worse off than one handed a labelled picture.
152
+ */
153
+ export const blockFigureHtml = (block, { role, colourBy } = {}) => `<figure data-role="block"><h3>${esc(block.package)}</h3>`
154
+ + `${blockSvg(block, { ...(role ? { role } : {}), ...(colourBy ? { colourBy } : {}) })}`
155
+ + `<figcaption>${n(block.objects.length)} object${block.objects.length === 1 ? '' : 's'}</figcaption></figure>`;
156
+ /**
157
+ * The map itself: the key to the colours, then one block per package.
158
+ *
159
+ * A project with nothing in it yet gets a sentence instead. An empty legend
160
+ * above an empty row of blocks looks like a page that failed to load, and a
161
+ * lead who has just run `project init` would reasonably think it had.
162
+ */
163
+ export function mapBodyHtml(register, blocks, { role, colourBy } = {}) {
164
+ if (!register.totals.objects)
165
+ return '<p>No objects in scope yet.</p>';
166
+ // The status file never passes `colourBy`: it prints the state map only.
167
+ const legend = colourBy === 'level'
168
+ ? levelLegendHtml(register.totals.byLevel?.now, { retired: register.totals.byState?.retired ?? 0 })
169
+ : legendHtml(register.totals.byState, { unknown: unknownStateCount(register) });
170
+ return `${legend}
171
+ <div class="blocks">${blocks.map((b) => blockFigureHtml(b, { role, colourBy })).join('')}</div>`;
172
+ }
173
+ /**
174
+ * What was read and what was left out.
175
+ *
176
+ * A number a manager cannot see is a number they cannot trust, so nothing here
177
+ * is rounded away or hidden. The file says it too, and it matters most there:
178
+ * whoever it was mailed to cannot look in the folder, and an ignored ruling is
179
+ * a decision somebody made that this picture does not show.
180
+ *
181
+ * `tense: 'past'` is for that file. It states a moment that has gone, so it
182
+ * says what WAS read rather than what is.
183
+ *
184
+ * @param {{ files?: number, unreadable?: number, checksum?: string, register?: object }} data
185
+ * @param {{ tense?: 'present'|'past' }} [opts]
186
+ * @returns {string}
187
+ */
188
+ export function readNote({ files = 0, unreadable = 0, checksum, register }, { tense = 'present' } = {}) {
189
+ const past = tense === 'past';
190
+ const s = register?.skipped ?? {};
191
+ const parts = [`${n(files)} files ${past ? 'were read' : 'read'}`];
192
+ if (unreadable)
193
+ parts.push(`${n(unreadable)} could not be read${past ? '' : ' yet'}`);
194
+ if (s.duplicateRecords)
195
+ parts.push(`${n(s.duplicateRecords)} duplicate${s.duplicateRecords === 1 ? '' : 's'} ignored`);
196
+ if (s.otherProject)
197
+ parts.push(`${n(s.otherProject)} from another team project ignored`);
198
+ if (s.invalidRecords) {
199
+ parts.push(s.invalidRecords === 1
200
+ ? `1 file ${past ? 'was' : 'is'} not a status file`
201
+ : `${n(s.invalidRecords)} files ${past ? 'were' : 'are'} not status files`);
202
+ }
203
+ if (s.rulingsNotFromLead)
204
+ parts.push(`${n(s.rulingsNotFromLead)} ruling${s.rulingsNotFromLead === 1 ? '' : 's'} not from the lead ignored`);
205
+ if (s.measurementsOtherVariant) {
206
+ parts.push(`${n(s.measurementsOtherVariant)} measurement${s.measurementsOtherVariant === 1 ? '' : 's'} made with another check variant ignored`);
207
+ }
208
+ if (checksum === 'not-checked')
209
+ parts.push('object list checksum not checked');
210
+ return parts.join(' · ');
211
+ }
212
+ /* ── What was measured, and what was not ─────────────────────────────────── */
213
+ /**
214
+ * The one sentence that keeps a file export from reading as a live result.
215
+ *
216
+ * Live evidence outranks file evidence, per object: once the system has
217
+ * measured an object, that object's colour is read from system runs alone. The
218
+ * file run is still in its history — hiding it would leave a manager looking
219
+ * for a run they were told about — and this is what it says.
220
+ */
221
+ const NOT_USED = 'Measured from a file — not used for the state.';
222
+ /**
223
+ * Nothing looked at this object. The file stayed silent about it and the person
224
+ * who imported the file said to read silence as clean — so the row says clean
225
+ * on somebody's judgement, and the page says whose judgement it was.
226
+ */
227
+ const SILENCE = 'Counted as clean because the file did not mention it.';
228
+ /**
229
+ * The same silence from a live run: the check finished and never reported the
230
+ * object. There is no file to have mentioned anything, and saying there was
231
+ * would send a reader looking for an export nobody made.
232
+ */
233
+ const SILENCE_RUN = 'Counted as clean because the check run did not report it.';
234
+ const IMPORTER_SAID = 'The person who imported this file told CSPeach that objects the file does not mention are clean.';
235
+ /**
236
+ * How a run was made, in words. A file is never called plainly "measured".
237
+ *
238
+ * A file import carries a check variant only when the person who imported it
239
+ * vouched for one, and an empty half of this phrase reads as a value somebody
240
+ * lost rather than as one the file never had. A live run always has a variant.
241
+ */
242
+ const howMade = (fromFile, variant) => `${fromFile ? FROM_FILE : 'system check'}`
243
+ + `${variant ? `, ${variant}` : (fromFile ? ', check variant not stated' : '')}`;
244
+ /**
245
+ * The same, as markup: a file wears a tag, so the eye finds it down a column.
246
+ * The whole phrase goes inside the tag, variant and all — a pill followed by a
247
+ * loose comma reads as a comma nobody meant to type.
248
+ */
249
+ const howMadeHtml = (fromFile, variant) => (fromFile
250
+ ? `<span class="tag from-file">${esc(howMade(true, short(variant)))}</span>`
251
+ : esc(howMade(false, short(variant))));
252
+ /**
253
+ * The id register-core invents when a finding arrives with no readable name of
254
+ * its own. It is a placeholder, and on screen a placeholder in a Rule column
255
+ * reads as a rule somebody could go and look up. Say what it is instead.
256
+ *
257
+ * The id is written out rather than imported: this module draws marks and knows
258
+ * the model, and one string is a smaller dependency than a package.
259
+ */
260
+ const UNNAMED = 'UNNAMED_FINDING';
261
+ export const ruleWord = (rule) => (rule === UNNAMED ? 'a finding with no readable name' : short(rule));
262
+ /**
263
+ * One cell of the status file's object list.
264
+ *
265
+ * The label follows `measuredBasis` — the basis the FIGURE came from — and
266
+ * never the newest run of any basis. A system run followed by a file export
267
+ * leaves `openFindings` a live number, and "1 open, from a file" under it would
268
+ * be the page telling a manager to trust the system less than it should.
269
+ * `runs` is kept in the signature for callers; nothing here needs it.
270
+ */
271
+ export function measuredWord(o, runs) {
272
+ if ((o.lastMeasuredAt ?? null) === null)
273
+ return 'not measured';
274
+ return `${n(o.openFindings)} open${o.measuredBasis === 'file' ? ', from a file' : ''}`;
275
+ }
276
+ /** The line under the summary. Empty when every active object has been measured. */
277
+ export function notMeasuredLine(register) {
278
+ const count = notMeasuredCount(register);
279
+ if (!count)
280
+ return '';
281
+ return `${n(count)} of ${n(register.totals.active)} active object${register.totals.active === 1 ? '' : 's'} `
282
+ + `${count === 1 ? 'has' : 'have'} never been measured. ${count === 1 ? 'Its' : 'Their'} colour rests on what people said.`;
283
+ }
284
+ /** The evidence panel's measurement block: the two numbers, then every run that covered this object. */
285
+ export function measurementHistoryHtml(o, runs) {
286
+ const { rows, more } = measurementHistory(o, runs);
287
+ if (!rows.length) {
288
+ return '<p class="unmeasured" data-role="not-measured"><b>Not measured.</b> Nobody has run a check on this object yet, so its colour rests on what people said.</p>';
289
+ }
290
+ // `counted` comes from the register, which is where that rule lives. Working
291
+ // it out again here would be a second copy of it, free to drift.
292
+ const note = (r) => [
293
+ // Whose silence it was follows the run this row came from, never the shape
294
+ // of the row: a live run that reported nothing is not a file that said
295
+ // nothing, and only one of the two has an export to go and look at.
296
+ r.evidence === 'absent' ? (r.fromFile ? SILENCE : SILENCE_RUN) : '',
297
+ r.counted ? '' : NOT_USED,
298
+ ].filter(Boolean).map((text) => ` <span class="quiet-note">${text}</span>`).join('');
299
+ const row = (r) => `<tr><td>${esc(formatWhen(r.at))}</td><td>${cell(r.by)}</td><td>${n(r.open)}</td>`
300
+ + `<td>${howMadeHtml(r.fromFile, r.variant)}${note(r)}</td></tr>`;
301
+ return `<p><b>Measured:</b> ${n(o.openFindings)} open now, ${n(o.baselineFindings)} at the start. Last measured ${esc(formatWhen(o.lastMeasuredAt))}.</p>`
302
+ + `<div class="scroller" data-role="history"><table aria-label="Every measurement of ${cell(o.name)}">`
303
+ + '<thead><tr><th scope="col">When</th><th scope="col">By</th><th scope="col">Open</th><th scope="col">How</th></tr></thead>'
304
+ + `<tbody>${rows.map(row).join('')}</tbody></table></div>`
305
+ + (more ? `<p class="quiet-note">and ${n(more)} earlier ${more === 1 ? 'measurement' : 'measurements'}</p>` : '');
306
+ }
307
+ /**
308
+ * What a run was asked for and did not measure, and — only when it adds up —
309
+ * what became of those objects.
310
+ *
311
+ * "Not measured" is one number with four meanings, and a lead chasing the gap
312
+ * needs to know whether to look at the export, at the run, at an error or at
313
+ * the object's own type. But a breakdown that does not account for the whole
314
+ * gap is worse than none: a reader adds the parts, gets a different number, and
315
+ * stops believing the table. So the parts are printed only when they sum to
316
+ * exactly `asked − measured` and every one of them is a whole number — a folder
317
+ * can say `absent: 999`, and then the gap stands on its own.
318
+ *
319
+ * The gap itself is printed only when both numbers are whole: "3.5 not
320
+ * measured" is not a count of objects.
321
+ */
322
+ function gapOf(c) {
323
+ if (!c || !whole(c.asked) || !whole(c.measured) || c.asked <= c.measured)
324
+ return { missed: '', why: '' };
325
+ const gap = c.asked - c.measured;
326
+ const named = [
327
+ ['absent', 'the run never mentioned'],
328
+ ['notRun', 'the run never reached'],
329
+ ['failed', 'failed'],
330
+ ['unsupported', 'of a type this check cannot reach'],
331
+ ].filter(([key]) => c[key] !== undefined && c[key] !== null);
332
+ const adds = named.every(([key]) => whole(c[key])) && named.reduce((t, [key]) => t + c[key], 0) === gap;
333
+ return {
334
+ missed: `, ${n(gap)} not measured`,
335
+ why: adds ? named.filter(([key]) => c[key] > 0).map(([key, word]) => `${n(c[key])} ${word}`).join(' · ') : '',
336
+ };
337
+ }
338
+ /**
339
+ * Every measurement that counted, newest first. The page and the status file
340
+ * both print it.
341
+ *
342
+ * The findings column counts OBJECTS WITH FINDINGS, never findings added up
343
+ * across objects. A run that checks one object at a time gives every finding it
344
+ * produced to that object, so a finding in a shared include is counted once
345
+ * under each program that uses it — per object the numbers are right, added up
346
+ * they are a total that grows and shrinks with the chunking and that nobody on
347
+ * the team could defend in a meeting.
348
+ *
349
+ * An estate with nothing recorded yet is a normal first day, not a team that has
350
+ * done nothing: the sentence says what to run and what an object that no check
351
+ * run reports will look like.
352
+ */
353
+ export function runsTableHtml(runs) {
354
+ if (!runs?.length) {
355
+ return '<p>No measurement is recorded yet. A lead or developer connected to SAP can run cspeach team refresh. '
356
+ + 'Objects stay "not measured" until a check run reports them.</p>';
357
+ }
358
+ const row = (r) => {
359
+ const objects = `${n(r.objects)} object${r.objects === 1 ? '' : 's'}`;
360
+ const { missed, why } = gapOf(r.coverage);
361
+ // The NAME of the file, which register-core keeps; never a path, which it
362
+ // does not. One writer's Downloads folder is not the team's business.
363
+ const from = r.sourceFile ? ` — ${cell(r.sourceFile)}` : '';
364
+ // The sentence belongs to the RUN that carries the choice, not to the
365
+ // table: "this file", printed once under a list of three runs, names none
366
+ // of them. A run with a note keeps no rule under it, so the note and the
367
+ // row it belongs to read as one thing.
368
+ const said = r.absentMeansClean ? `<tr><td colspan="6" class="run-note"><span class="quiet-note">${IMPORTER_SAID}</span></td></tr>` : '';
369
+ return `<tr${said ? ' class="has-note"' : ''}><td>${esc(formatWhen(r.at))}</td><td>${cell(r.by)}</td><td>${cell(r.scope ?? '')}</td>`
370
+ + `<td>${objects}${missed}${why ? `<br><span class="quiet-note">${why}</span>` : ''}</td><td>${n(r.withFindings)}</td>`
371
+ + `<td>${howMadeHtml(r.basis === 'file', r.atcVariant)}${from}</td></tr>${said}`;
372
+ };
373
+ return '<div class="scroller"><table aria-label="Measurements"><thead><tr><th scope="col">When</th><th scope="col">By</th><th scope="col">Scope</th>'
374
+ + '<th scope="col">Covered</th><th scope="col">Objects with findings</th><th scope="col">How</th></tr></thead>'
375
+ + `<tbody>${[...runs].reverse().map(row).join('')}</tbody></table></div>`;
376
+ }
377
+ /**
378
+ * The runs nobody folded in, because they were taken with another check
379
+ * variant. The footer already counts them; a count alone cannot be looked into,
380
+ * so this says which run, when and by whom — as a list, not a table, because it
381
+ * is an aside beside the table above and should not read as more measurement.
382
+ */
383
+ export function ignoredRunsHtml(ignoredRuns) {
384
+ if (!ignoredRuns?.length)
385
+ return '';
386
+ const many = ignoredRuns.length !== 1;
387
+ const item = (r) => `<li>${esc(formatWhen(r.at))} — ${cell(r.by)}, ${cell(r.atcVariant)}`
388
+ + `${r.basis === 'file' ? ` <span class="tag from-file">${FROM_FILE}</span>` : ''}`
389
+ + `${r.sourceFile ? ` — ${cell(r.sourceFile)}` : ''}</li>`;
390
+ return `<div class="aside" data-role="ignored-runs"><p>Set aside: ${n(ignoredRuns.length)} run${many ? 's were' : ' was'} `
391
+ + `taken with another check variant, so ${many ? 'they cannot' : 'it cannot'} be compared with this team project.</p>`
392
+ + `<ul>${ignoredRuns.map(item).join('')}</ul></div>`;
393
+ }
394
+ /* ── Clean core: where it stands, levels, dispositions, per developer ───── */
395
+ const CLASSIFICATION_WORD = { keep: 'keep', fix: 'fix', redesign: 'redesign', retire: 'retire' };
396
+ /**
397
+ * The word for a decision. A clean-core team project speaks in the five
398
+ * dispositions (spec §4.2); any other type keeps the classification.
399
+ *
400
+ * A classification this build has no word for is printed as the folder wrote
401
+ * it (the caller escapes it), never as "not decided": somebody DID decide, and
402
+ * calling it undecided would put the object in a bucket nobody chose.
403
+ */
404
+ export function decisionWord(decision, projectType) {
405
+ if (!decision)
406
+ return 'not decided';
407
+ if (projectType === 'clean-core') {
408
+ const d = dispositionOf(decision);
409
+ if (d)
410
+ return DISPOSITION_WORD[d];
411
+ }
412
+ return CLASSIFICATION_WORD[decision.classification] ?? short(decision.classification);
413
+ }
414
+ const tile = (label, value, note, cls = '') => `<li class="tile${cls ? ` ${cls}` : ''}"><span class="tile-n">${n(value)}</span> <span class="tile-label">${esc(label)}</span> <span class="tile-note">${esc(note)}</span></li>`;
415
+ /**
416
+ * Five tiles, a number and two lines each, then the two level bars. Every
417
+ * number comes from register-core's totals — the same ones the terminal and
418
+ * the workbook print — so nothing here is counted twice.
419
+ */
420
+ export function whereItStandsHtml(register) {
421
+ const t = register.totals ?? {};
422
+ const s = t.byState ?? {};
423
+ const fromFile = (t.fromFile?.verified ?? 0) + (t.fromFile?.clean ?? 0);
424
+ const didNotHold = s['did-not-hold'] ?? 0;
425
+ return '<ul class="tiles" data-role="tiles">'
426
+ + tile('Decided', t.decided ?? 0, `of ${n(t.objects)} objects`)
427
+ + tile('Claimed', s.claimed ?? 0, 'a developer says fixed')
428
+ + tile('Proven', provenOf(s), fromFile ? `${n(fromFile)} of them ${FROM_FILE}` : 'a check run says so')
429
+ + tile('Did not hold', didNotHold, 'claimed, and the check still finds it', didNotHold ? 'bad' : '')
430
+ + tile('Retired', s.retired ?? 0, 'nothing left to do')
431
+ + '</ul>'
432
+ + (!(t.decided > 0) ? '<p class="empty-note" data-role="nothing-decided">Nothing decided yet. Developers record decisions with /abap-clean-core or cspeach team decide.</p>' : '')
433
+ + levelBarsHtml(register);
434
+ }
435
+ /**
436
+ * One bar: the five buckets side by side, then the same thing in words — colour
437
+ * never stands alone. Segments are paths, not rects: a `rect` on these surfaces
438
+ * is a map cell, and the budget and the keyboard both count cells by that tag.
439
+ */
440
+ function levelBar(title, counts) {
441
+ const total = LEVELS.reduce((sum, l) => sum + (counts?.[l] ?? 0), 0);
442
+ let x = 0;
443
+ const rects = total
444
+ ? LEVELS.map((l) => {
445
+ const c = counts?.[l] ?? 0;
446
+ if (!c)
447
+ return '';
448
+ const w = (c / total) * 100;
449
+ const r = `<path class="lvl l-${l}" d="M${x.toFixed(3)} 0h${w.toFixed(3)}v10h-${w.toFixed(3)}z"><title>${esc(`${LEVEL_NAME[l]}: ${c}`)}</title></path>`;
450
+ x += w;
451
+ return r;
452
+ }).join('')
453
+ : '<path class="lvl l-none" d="M0 0h100v10h-100z"></path>';
454
+ const words = LEVELS.map((l) => `${LEVEL_NAME[l]} ${n(counts?.[l] ?? 0)}`).join(' · ');
455
+ return `<figure class="levelbar"><figcaption>${esc(title)}</figcaption>`
456
+ + `<svg viewBox="0 0 100 10" preserveAspectRatio="none" role="img" aria-label="${esc(`${title}: ${words}`)}">${rects}</svg>`
457
+ + `<p class="levelwords">${esc(words)}</p></figure>`;
458
+ }
459
+ /** Levels at decision and now, in words as well as colour. A folder with no levels says so. */
460
+ export function levelBarsHtml(register) {
461
+ const b = register.totals?.byLevel;
462
+ const none = !b || ['A', 'B', 'C', 'D'].every((l) => !(b.decided?.[l]) && !(b.now?.[l]));
463
+ return `<div class="levels" data-role="levels">${levelBar('Levels at decision', b?.decided)}${levelBar('Levels now', b?.now)}`
464
+ + `<p class="legend-line">${none ? 'No levels recorded yet. ' : ''}A level moves only when a check proves the fix.</p></div>`;
465
+ }
466
+ /**
467
+ * One line of chips; the accepted exceptions open a list of object and reason.
468
+ * An exception is work nobody has to do, so the reason is the whole of it — and
469
+ * where there is none, the list says so rather than leaving a blank.
470
+ */
471
+ export function dispositionsHtml(register) {
472
+ const d = register.totals?.byDisposition ?? {};
473
+ const chips = DISPOSITION_ORDER.map((k) => `<li class="chip d-${k}">${esc(DISPOSITION_WORD[k])} <b>${n(d[k] ?? 0)}</b></li>`).join('');
474
+ const ex = register.objects.filter((o) => dispositionOf(o.decision) === 'exception');
475
+ const list = !ex.length ? '' : `<details class="exceptions" data-role="exceptions"><summary>${ex.length === 1 ? 'The accepted exception and why' : `The ${n(ex.length)} accepted exceptions and why`}</summary><ul>`
476
+ + ex.map((o) => `<li><span class="ident">${cell(o.name)}</span> — ${o.decision.reason ? cell(o.decision.reason) : '<span class="quiet-note">no reason recorded</span>'}</li>`).join('')
477
+ + '</ul></details>';
478
+ return `<ul class="chips" data-role="dispositions">${chips}</ul>${list}`;
479
+ }
480
+ /**
481
+ * Who owns what, per developer — register-core's sheet model for the numbers
482
+ * (the workbook prints the same row), the page's own words for the date.
483
+ */
484
+ export function developersTableHtml(register) {
485
+ if (!register.lanes?.length)
486
+ return '<p>The lead has not set up lanes yet.</p>';
487
+ const t = developerTable(register);
488
+ // How much of a lane's Proven came out of a file, said beside the number —
489
+ // as the lane table always has. One pass over the objects, counted for every
490
+ // lane that claims one, exactly as register-core counts the lane's Proven.
491
+ const fromFile = new Map();
492
+ for (const o of register.objects) {
493
+ if (o.measuredBasis !== 'file' || (o.state !== 'verified' && o.state !== 'clean'))
494
+ continue;
495
+ for (const id of o.laneOverlap?.length ? o.laneOverlap : [o.lane])
496
+ fromFile.set(id, (fromFile.get(id) ?? 0) + 1);
497
+ }
498
+ const provenAt = t.header.indexOf('Proven');
499
+ const num = (v, col, lane) => {
500
+ const said = col === provenAt ? fromFile.get(lane.id) ?? 0 : 0;
501
+ return `<td>${n(v)}${said ? ` (${n(said)} ${FROM_FILE})` : ''}</td>`;
502
+ };
503
+ const head = t.header.map((h) => `<th scope="col">${esc(h)}</th>`).join('');
504
+ const body = t.rows.map((r, i) => `<tr><td>${cell(r[0])}</td>${r.slice(1, -1).map((v, j) => num(v, j + 1, register.lanes[i])).join('')}`
505
+ + `<td>${esc(formatWhen(register.lanes[i].lastRecordAt))}</td></tr>`).join('');
506
+ const note = overlapNote(register);
507
+ return `<div class="scroller"><table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>`
508
+ + (note ? `<p class="quiet-note">${esc(note)}</p>` : '');
509
+ }