@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,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
|
+
}
|