@cspeach/cli 1.1.31 → 1.1.32

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 (63) hide show
  1. package/dist/agent/loop.js +16 -6
  2. package/dist/cli.js +13 -4
  3. package/dist/commands/help.js +16 -1
  4. package/dist/commands/plan-chain.js +3 -0
  5. package/dist/commands/plan-gate.js +10 -1
  6. package/dist/commands/plan-resume.js +4 -0
  7. package/dist/commands/team-rules.js +187 -0
  8. package/dist/commands/team.js +632 -90
  9. package/dist/journey/chain.js +265 -15
  10. package/dist/journey/clean-core.js +25 -3
  11. package/dist/journey/flow.js +51 -1
  12. package/dist/journey/rebuild.js +464 -0
  13. package/dist/journey/run.js +213 -20
  14. package/dist/journey/session-deps.js +5 -1
  15. package/dist/lane/app-chain.js +12 -2
  16. package/dist/one-shot.js +24 -17
  17. package/dist/projects/clean-core-confirm.js +38 -5
  18. package/dist/projects/save-command.js +20 -3
  19. package/dist/register/baseline.js +39 -2
  20. package/dist/register/decision-rules.js +35 -0
  21. package/dist/register/discover.js +49 -19
  22. package/dist/register/export-files.js +58 -0
  23. package/dist/register/measure.js +191 -9
  24. package/dist/register/plan-import.js +6 -3
  25. package/dist/register/records.js +15 -3
  26. package/dist/register/rule-proposal.js +4 -0
  27. package/dist/register/rules-edit.js +257 -0
  28. package/dist/register/status-file/register-status-file.js +106 -108
  29. package/dist/register/status-file/report-model.js +569 -0
  30. package/dist/register/status-file/team-css.js +1 -1
  31. package/dist/register/status-file-sync.js +1 -0
  32. package/dist/register/status-html.js +25 -6
  33. package/dist/register/store.js +4 -3
  34. package/dist/register/variant.js +65 -0
  35. package/dist/register/workbooks.js +22 -56
  36. package/dist/register/write-whole.js +30 -2
  37. package/dist/renderer/tool-labels.js +2 -1
  38. package/dist/repl/builtin-commands.js +4 -0
  39. package/dist/repl/journey-entry.js +4 -2
  40. package/dist/repl/plan-hop.js +2 -9
  41. package/dist/repl/rules-menu.js +177 -0
  42. package/dist/repl/slash-completer.js +1 -0
  43. package/dist/repl/slash-picker.js +5 -4
  44. package/dist/repl.js +145 -86
  45. package/dist/router/classifier.js +8 -4
  46. package/dist/router/router-core.js +43 -7
  47. package/dist/router/routing-decision.js +1 -1
  48. package/dist/runs/bundled-runs.js +100 -0
  49. package/dist/runs/front-door.js +19 -0
  50. package/dist/runs/manifest-runs.js +50 -0
  51. package/dist/runs/run-command.js +24 -0
  52. package/dist/runs/runs.js +27 -0
  53. package/dist/runs/shadow.js +100 -0
  54. package/dist/runs/types.js +1 -0
  55. package/dist/session/queued-step.js +45 -0
  56. package/dist/skills/bundled-skills.js +53 -53
  57. package/dist/tools/dispatch-skill.js +8 -4
  58. package/dist/tools/lane/app-lane-resolve.js +1 -1
  59. package/dist/tools/project/rules_propose.js +59 -0
  60. package/dist/tools/project/team_findings.js +13 -2
  61. package/dist/upgrade-check.js +49 -4
  62. package/package.json +120 -120
  63. package/.cspeach/project.json +0 -4
@@ -0,0 +1,257 @@
1
+ /**
2
+ * The decision-rule edits behind `/rules` and `cspeach team rules` (step 2,
3
+ * Task 10; spec 2026-10-08 §7 D7).
4
+ *
5
+ * Pure: every edit takes the rules as they are and gives back the new list,
6
+ * the one line said about it (History, the printed line) and the confirm card's
7
+ * question. Each result is read back through register-core's strict reader, so
8
+ * nothing this file makes could be refused by the next read. `null` rules mean
9
+ * "restore the defaults": the file then names no rules at all.
10
+ */
11
+ import { readDecisionRules, levelOf, } from '@cspeach/register-core';
12
+ import { RegisterError } from './store.js';
13
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
14
+ const DISPOSITIONS = ['keep', 'fix', 'rebuild', 'retire-candidate'];
15
+ /* ── plain words ─────────────────────────────────────────────────────────── */
16
+ const TYPE_WORDS = {
17
+ PROG: 'program', CLAS: 'class', INTF: 'interface', FUGR: 'function group', DDLS: 'CDS view', TABL: 'table', ENHO: 'enhancement',
18
+ };
19
+ const CLASS_WORDS = {
20
+ 'not-released': 'unreleased APIs', 'not-to-be-released': 'APIs SAP will not release', deprecated: 'deprecated APIs',
21
+ 'cloud-language': 'statements ABAP Cloud does not allow', quality: 'quality findings (for example ORDER BY or WRITE in a loop)', modification: 'modifications', unclassified: 'unclassified findings',
22
+ };
23
+ /** Finding-rule prefixes with plain words; any other is said by its id. */
24
+ const RULE_WORDS = { SEARCH_PROBLEMATIC_STATEMENTS_FOR_ALV_CALLS: 'ALV list output' };
25
+ const DISPOSITION_WORDS = { keep: 'keep', fix: 'fix', rebuild: 'rebuild', 'retire-candidate': 'retire candidate' };
26
+ const or = (words) => (words.length <= 1 ? words.join('') : `${words.slice(0, -1).join(', ')} or ${words.at(-1)}`);
27
+ const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s);
28
+ /** "more than 25 findings", "10 or fewer findings", "3 to 10 findings"; null when the rule counts nothing. */
29
+ export function countWords(w) {
30
+ const min = w.minFindings;
31
+ const max = w.maxFindings;
32
+ if (min === undefined && max === undefined)
33
+ return null;
34
+ if (min !== undefined && max !== undefined)
35
+ return `${min} to ${max} findings`;
36
+ if (min !== undefined)
37
+ return min === 0 ? '0 or more findings' : `more than ${min - 1} findings`;
38
+ return `${max} or fewer findings`;
39
+ }
40
+ /** One phrase per condition. `first` = it follows the subject directly ("Any object with …"). */
41
+ function phrases(w) {
42
+ const out = [];
43
+ const both = (s) => out.push({ first: s, next: s });
44
+ const known = new Set(['types', 'minFindings', 'maxFindings']);
45
+ // The order a lead reads them in; any key not named here follows by its own name.
46
+ const said = [
47
+ ['noOpenForTarget', () => both('that meets its target already')],
48
+ ['findingClasses', () => both(`with ${or(w.findingClasses.map((c) => CLASS_WORDS[c] ?? c))}`)],
49
+ ['onlyClasses', () => both(`with only ${or(w.onlyClasses.map((c) => CLASS_WORDS[c] ?? c))}`)],
50
+ ['findingRules', () => both(`with ${or(w.findingRules.map((r) => RULE_WORDS[r] ?? `findings of ${r}`))}`)],
51
+ ['levelNow', () => both(`now at level ${or(w.levelNow)}`)],
52
+ ['targetAtMost', () => out.push({ first: `with target ${w.targetAtMost} or stricter`, next: `target ${w.targetAtMost} or stricter` })],
53
+ ['callers', () => both('with no callers found')],
54
+ ['usage', () => both('with usage read as unused')],
55
+ ];
56
+ for (const [key, say] of said) {
57
+ known.add(key);
58
+ if (w[key] !== undefined)
59
+ say();
60
+ }
61
+ const counted = countWords(w);
62
+ if (counted)
63
+ out.push({ first: `with ${counted}`, next: counted });
64
+ for (const [key, v] of Object.entries(w)) {
65
+ if (known.has(key) || v === undefined)
66
+ continue;
67
+ // A condition this build has no words for is said by its name, never dropped.
68
+ const values = Array.isArray(v) ? v.map(String) : [String(v)];
69
+ both(`${key} ${or(values)}`);
70
+ }
71
+ return out;
72
+ }
73
+ /** The "when" half: "Program with ALV list output, target A or stricter". */
74
+ export function whenWords(w) {
75
+ const types = w.types?.length ? w.types.map((t) => TYPE_WORDS[t] ?? t) : null;
76
+ const subject = types ? cap(or(types)) : 'Any object';
77
+ const ps = phrases(w);
78
+ if (!ps.length)
79
+ return subject;
80
+ return `${subject} ${ps[0].first}${ps.slice(1).map((p) => `, ${p.next}`).join('')}`;
81
+ }
82
+ /** The "then" half: "rebuild", "rebuild, target B". */
83
+ export function thenWords(t) {
84
+ return `${DISPOSITION_WORDS[t.disposition] ?? t.disposition}${t.target ? `, target ${t.target}` : ''}`;
85
+ }
86
+ /** One rule in plain words: "Program with ALV list output, target A or stricter → rebuild". */
87
+ export const ruleWords = (r) => `${whenWords(r.when)} → ${thenWords(r.then)}`;
88
+ const dayMonth = (iso) => {
89
+ const d = new Date(iso);
90
+ return Number.isNaN(d.getTime()) ? '' : `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]}`;
91
+ };
92
+ const changedWords = (r) => (r.changedBy ? `${r.changedBy}${r.changedAt && dayMonth(r.changedAt) ? ` on ${dayMonth(r.changedAt)}` : ''}` : 'default');
93
+ /** The numbered table: id · when → then · on/off · last changed by. */
94
+ export function rulesTableLines(rules, from, warning) {
95
+ const head = from === 'default'
96
+ ? 'Decision rules — the defaults (cspeach-team.json names none).'
97
+ : 'Decision rules — from cspeach-team.json.';
98
+ return [
99
+ `${head} The first rule that is on and holds decides; a person confirms every row.`,
100
+ ...(warning ? [` ${warning}`] : []),
101
+ ...rules.map((r, i) => `${i + 1}. ${r.id} · ${ruleWords(r)} · ${r.on ? 'on' : 'off'} · ${changedWords(r)}`),
102
+ ];
103
+ }
104
+ /* ── the edits ───────────────────────────────────────────────────────────── */
105
+ const copy = (v) => JSON.parse(JSON.stringify(v));
106
+ function find(rules, id) {
107
+ const i = rules.findIndex((r) => r.id.toUpperCase() === id.trim().toUpperCase());
108
+ if (i < 0)
109
+ throw new RegisterError(`There is no rule ${id.trim() || '(none given)'}. Rules: ${rules.map((r) => r.id).join(', ') || 'none'}.`);
110
+ return i;
111
+ }
112
+ /** The list as the next read will see it; anything the strict reader refuses is refused here, in its words. */
113
+ function checked(rules) {
114
+ const read = readDecisionRules({ decisionRules: rules });
115
+ if (read.warning) {
116
+ const why = /\((.*)\)/.exec(read.warning)?.[1] ?? read.warning;
117
+ throw new RegisterError(`That change cannot be saved: ${why}.`);
118
+ }
119
+ return read.rules;
120
+ }
121
+ function replaced(rules, i, next, stamp) {
122
+ const out = copy(rules);
123
+ out[i] = { ...next, changedBy: stamp.by, changedAt: stamp.at };
124
+ return checked(out);
125
+ }
126
+ export function toggleRule(rules, id, on, stamp) {
127
+ const i = find(rules, id);
128
+ const before = rules[i];
129
+ if (before.on === on)
130
+ throw new RegisterError(`Rule ${before.id} is already ${on ? 'on' : 'off'}.`);
131
+ const next = replaced(rules, i, { ...copy(before), on }, stamp);
132
+ const words = ruleWords(before);
133
+ return {
134
+ rules: next, basis: copy(rules), change: on ? 'on' : 'off', id: before.id, before: copy(before), after: next[i],
135
+ summary: `Rule ${before.id} turned ${on ? 'on' : 'off'}: ${words}`,
136
+ card: `Turn ${on ? 'on' : 'off'} rule ${before.id}: ${words}.`,
137
+ };
138
+ }
139
+ /** A count from a flag or a typed answer: a whole number of 0 or more, else the one sentence. */
140
+ export function wholeCount(v, flag) {
141
+ const n = typeof v === 'number' ? v : typeof v === 'string' && /^\s*\d+\s*$/.test(v) ? Number(v) : Number.NaN;
142
+ if (!Number.isInteger(n) || n < 0)
143
+ throw new RegisterError(`${flag} needs a whole number of 0 or more, for example ${flag} 15.`);
144
+ return n;
145
+ }
146
+ export function setThreshold(rules, id, t, stamp) {
147
+ const i = find(rules, id);
148
+ const before = rules[i];
149
+ const when = { ...copy(before.when) };
150
+ if (t.minFindings !== undefined)
151
+ when.minFindings = wholeCount(t.minFindings, '--min-findings');
152
+ if (t.maxFindings !== undefined)
153
+ when.maxFindings = wholeCount(t.maxFindings, '--max-findings');
154
+ if (when.minFindings !== undefined && when.maxFindings !== undefined && when.minFindings > when.maxFindings) {
155
+ throw new RegisterError(`That would need at least ${when.minFindings} and at most ${when.maxFindings} findings: no object could match.`);
156
+ }
157
+ const next = replaced(rules, i, { ...copy(before), when }, stamp);
158
+ const was = countWords(before.when);
159
+ const now = countWords(when);
160
+ const change = was ? `${was.replace(/ findings$/, now.endsWith(' findings') ? '' : ' findings')} → ${now}` : `${now} (was no count)`;
161
+ const said = `rule ${before.id}: ${change} → ${thenWords(before.then)}`;
162
+ return { rules: next, basis: copy(rules), change: 'set', id: before.id, before: copy(before), after: next[i], summary: cap(said), card: `Change ${said}.` };
163
+ }
164
+ export function setOutcome(rules, id, disposition, stamp, target) {
165
+ if (!DISPOSITIONS.includes(disposition))
166
+ throw new RegisterError('--then is keep, fix, rebuild or retire-candidate.');
167
+ const i = find(rules, id);
168
+ const before = rules[i];
169
+ const then = { disposition };
170
+ const t = target === undefined ? before.then.target : target === null ? undefined : levelOrThrow(target);
171
+ if (t)
172
+ then.target = t;
173
+ const next = replaced(rules, i, { ...copy(before), then }, stamp);
174
+ const said = `rule ${before.id}: ${thenWords(before.then)} → ${thenWords(then)} (${whenWords(before.when)})`;
175
+ return { rules: next, basis: copy(rules), change: 'set', id: before.id, before: copy(before), after: next[i], summary: cap(said), card: `Change ${said}.` };
176
+ }
177
+ function levelOrThrow(v) {
178
+ const l = levelOf(v);
179
+ if (!l)
180
+ throw new RegisterError('--target is A, B, C or D.');
181
+ return l;
182
+ }
183
+ /** The level the rule proposes; null = the object's own target (the default). */
184
+ export function setTarget(rules, id, target, stamp) {
185
+ const t = target === null ? null : levelOrThrow(target);
186
+ const i = find(rules, id);
187
+ const before = rules[i];
188
+ const then = { disposition: before.then.disposition, ...(t ? { target: t } : {}) };
189
+ const next = replaced(rules, i, { ...copy(before), then }, stamp);
190
+ const own = "the object's own target";
191
+ const said = `rule ${before.id} target: ${before.then.target ?? own} → ${t ?? own}`;
192
+ return { rules: next, basis: copy(rules), change: 'set', id: before.id, before: copy(before), after: next[i], summary: cap(said), card: `Change ${said}.` };
193
+ }
194
+ /** The rules a lead can add by name. Each is checked by the strict reader like any other. */
195
+ export const RULE_TEMPLATES = {
196
+ 'many-findings': { when: { minFindings: 26 }, then: { disposition: 'rebuild' }, note: 'too many findings to fix in place' },
197
+ 'few-findings': { when: { maxFindings: 5 }, then: { disposition: 'fix' }, note: 'few findings: fix in place' },
198
+ 'meets-target': { when: { noOpenForTarget: true }, then: { disposition: 'keep' }, note: 'meets its target already' },
199
+ 'no-callers': { when: { callers: 'none-read-ok', usage: 'unused' }, then: { disposition: 'retire-candidate' }, note: 'no callers found; candidate until the lead confirms' },
200
+ 'unreleased-class': {
201
+ when: { types: ['CLAS', 'INTF'], onlyClasses: ['not-released', 'deprecated'], maxFindings: 10 },
202
+ then: { disposition: 'fix' }, note: 'a small class using unreleased or deprecated APIs: swap to released ones',
203
+ },
204
+ 'classic-program': {
205
+ when: { types: ['PROG'], findingRules: ['SEARCH_PROBLEMATIC_STATEMENTS_FOR_ALV_CALLS'], targetAtMost: 'A' }, then: { disposition: 'rebuild' }, note: 'a classic list program cannot reach A in place',
206
+ },
207
+ };
208
+ /** The next free "R<n>" after the highest one in use. */
209
+ function nextId(rules) {
210
+ const n = Math.max(0, ...rules.map((r) => Number(/^R(\d+)$/i.exec(r.id)?.[1] ?? 0)));
211
+ return `R${n + 1}`;
212
+ }
213
+ /** Added at the end: rules are tried in order, so a new one never jumps ahead of the lead's own. */
214
+ export function addFromTemplate(rules, template, stamp) {
215
+ const t = RULE_TEMPLATES[template.trim().toLowerCase()];
216
+ if (!t)
217
+ throw new RegisterError(`There is no template "${template.trim()}". Templates: ${Object.keys(RULE_TEMPLATES).join(', ')}.`);
218
+ const rule = { id: nextId(rules), on: true, ...copy(t), changedBy: stamp.by, changedAt: stamp.at };
219
+ const next = checked([...copy(rules), rule]);
220
+ const said = `rule ${rule.id} added from the template ${template.trim().toLowerCase()}: ${ruleWords(rule)}`;
221
+ return { rules: next, basis: copy(rules), change: 'add', id: rule.id, before: null, after: next.at(-1), summary: cap(said), card: `Add ${said.replace(' added', '')}.` };
222
+ }
223
+ export function removeRule(rules, id) {
224
+ const i = find(rules, id);
225
+ const before = rules[i];
226
+ const next = checked(copy(rules).filter((_, k) => k !== i));
227
+ return {
228
+ rules: next, basis: copy(rules), change: 'remove', id: before.id, before: copy(before), after: null,
229
+ summary: `Rule ${before.id} removed: ${ruleWords(before)}`, card: `Remove rule ${before.id}: ${ruleWords(before)}.`,
230
+ };
231
+ }
232
+ export function resetRules(basis) {
233
+ return {
234
+ rules: null, ...(basis ? { basis: copy(basis) } : {}), change: 'reset', before: null, after: null,
235
+ summary: 'Decision rules restored to the defaults',
236
+ card: 'Restore the default decision rules? Every change made to the rules is replaced.',
237
+ };
238
+ }
239
+ /**
240
+ * A rule from the person's words (the model's `rules_propose`): the proposal
241
+ * is data, never trusted. Its own id, on/off and stamp are ignored — the rule
242
+ * gets the next free id, starts on, and is stamped by the person who saves it —
243
+ * and the whole list must pass the strict reader. Added at the end.
244
+ */
245
+ export function addProposed(rules, proposal, stamp) {
246
+ let candidate = proposal;
247
+ if (proposal && typeof proposal === 'object' && !Array.isArray(proposal)) {
248
+ const { id: _id, on: _on, changedBy: _by, changedAt: _at, ...rest } = proposal;
249
+ candidate = { id: nextId(rules), on: true, ...rest, changedBy: stamp.by, changedAt: stamp.at };
250
+ }
251
+ const next = checked([...copy(rules), candidate]);
252
+ const rule = next.at(-1);
253
+ return {
254
+ rules: next, basis: copy(rules), change: 'add', id: rule.id, before: null, after: rule,
255
+ summary: `Rule ${rule.id} added from your words: ${ruleWords(rule)}`, card: `Add rule ${rule.id}: ${ruleWords(rule)}.`,
256
+ };
257
+ }
@@ -34,15 +34,9 @@
34
34
  */
35
35
  import { esc } from './register-map.js';
36
36
  import { formatWhen } from './register-model.js';
37
- // Every mark this file shares with the page. One copy, in a module that knows
38
- // nothing about the browser: two pictures of one folder that disagree would be
39
- // worse than no picture at all, and these two had already begun to.
40
- import { decisionWord, developersTableHtml, dispositionsHtml, ignoredRunsHtml, mapBodyHtml, measuredWord, n, needItemHtml, focusLineHtml, notMeasuredLine, ownerLabel, provenanceLine, readNote, runsTableHtml, stateWord, whereItStandsHtml, } from './register-parts.js';
41
- import { estateSectionHtml } from './estate-parts.js';
42
- import { progressSectionHtml } from './progress-chart.js';
43
- import { planSectionHtml } from './plan-parts.js';
37
+ import { n, provenanceLine, readNote } from './register-parts.js';
38
+ import { FIX_RULE, LEVEL_KEY, NO_TARGET_LINE, READING_LINE, awaitingWord, dayWords, lastCheckWord, reportView, trendLine, } from './report-model.js';
44
39
  const FILE_CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src data:";
45
- const objects = (count) => `${n(count)} object${count === 1 ? '' : 's'}`;
46
40
  const files = (count) => `${n(count)} file${count === 1 ? '' : 's'}`;
47
41
  /**
48
42
  * What the file is called once it lands in somebody's downloads. A manager with
@@ -57,133 +51,137 @@ export function statusFileName(project, asOf) {
57
51
  const slug = String(project?.name ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'team-project';
58
52
  return `${slug}-status-${String(asOf).slice(0, 10)}.html`;
59
53
  }
60
- /**
61
- * One package, folded shut. The count is in the fold's own line so the size of
62
- * a package is readable without opening it — a real project puts thousands of
63
- * rows behind one of these.
64
- */
65
- function packageFold(block, lanes, runs, projectType) {
66
- // An empty cell reads as a value somebody lost rather than as one nobody has
67
- // set yet, so both absences say themselves in words — in the same lower case
68
- // as the values they stand in for ("fix", "retire", a person's name).
69
- //
70
- // The last column is the whole of step 2 in one cell: what the system found,
71
- // or that nobody has looked. A reader working down this list must not have to
72
- // take a colour on trust, and "not measured" is an answer, not a blank.
73
- const row = (o) => `<tr><td class="ident">${esc(o.name)}</td><td>${esc(o.type)}</td><td>${esc(stateWord(o))}</td>`
74
- + `<td>${esc(decisionWord(o.decision, projectType, o.state))}</td>`
75
- + `<td>${esc(ownerLabel(lanes.find((l) => l.id === o.lane)))}</td>`
76
- + `<td>${esc(measuredWord(o, runs))}</td></tr>`;
77
- // Every fold holds a table with the same six headings, so each one says
78
- // which package it belongs to — otherwise a screen reader announces the
79
- // twelfth "Object, Type, State, Decision, Owner, Measured" with nothing to
80
- // tell it from the eleventh.
81
- return `<details data-role="package"><summary>${esc(block.package)} — ${objects(block.objects.length)}</summary>`
82
- + `<div class="scroller"><table aria-label="${esc(block.package)}"><thead><tr><th scope="col">Object</th><th scope="col">Type</th>`
83
- + '<th scope="col">State</th><th scope="col">Decision</th><th scope="col">Owner</th><th scope="col">Measured</th></tr></thead>'
84
- + `<tbody>${block.objects.map(row).join('')}</tbody></table></div></details>`;
85
- }
86
54
  /** Nothing in a saved file can reload, so the page's advice is not repeated here. */
87
- const UNREADABLE_DETAIL = 'Some results may be missing. The next status file will include them once the files have finished syncing.';
55
+ const LANE_CAP = 8;
56
+ const KEPT_CAP = 10;
57
+ const numbersHtml = (view) => `<ul class="rp-numbers" data-role="numbers">${view.numbers.map((x) => `<li data-num="${esc(x.id)}">`
58
+ + `<span class="rp-n">${n(x.n)}</span><span class="rp-label">${esc(x.label)}</span><span class="rp-sub">${esc(x.sub)}</span></li>`).join('')}</ul>`;
59
+ function levelsHtml(view) {
60
+ const rows = view.levels.map((l) => `<li><span class="rp-lv">${esc(l.word)}</span><span class="rp-lv-n">${n(l.n)}</span>`
61
+ + `<span class="rp-lv-key">${esc(l.key)}</span></li>`).join('');
62
+ const t = view.target;
63
+ const target = t.set
64
+ ? `<p class="rp-target" data-role="target">${esc(t.line)}${t.keptLine ? ` ${esc(t.keptLine)}` : ''}</p>`
65
+ + `${(t.provenLines ?? []).map((l) => `<p class="rp-target" data-role="proven-for-target">${esc(l)}</p>`).join('')}<p class="rp-note">${esc(READING_LINE)}</p>`
66
+ : `<p class="rp-target" data-role="target">${esc(NO_TARGET_LINE)}</p>`;
67
+ return `<section class="rp-levels" data-role="levels"><h2>Objects by clean-core level, now</h2><ul class="rp-lvs">${rows}</ul>${target}</section>`;
68
+ }
69
+ function decisionsHtml(view) {
70
+ const d = view.decisions;
71
+ const list = d.total
72
+ ? `<ol>${d.shown.map((x) => `<li><p>${esc(x.text)}</p><p class="rp-who">Decides: ${esc(x.decides)}.${x.since ? ` Since ${esc(dayWords(x.since))}.` : ''}</p></li>`).join('')}</ol>`
73
+ + (d.more ? `<p class="rp-tail">And ${n(d.more)} more in the workbook.</p>` : '')
74
+ : '<p>Nothing is waiting for a decision.</p>';
75
+ return `<section class="rp-decide" data-role="decisions"><h2>Decisions needed: ${d.total ? n(d.total) : 'none'}</h2>${list}</section>`;
76
+ }
77
+ function changedHtml(view) {
78
+ const c = view.changed;
79
+ if (c.first)
80
+ return `<section class="rp-changed" data-role="changed"><h2>Changed since the last report</h2><p>${esc(c.line)}</p></section>`;
81
+ return `<section class="rp-changed" data-role="changed"><h2>Changed since the last report, ${esc(dayWords(c.since))}</h2>`
82
+ + (c.line ? `<p>${esc(c.line)}</p>` : '')
83
+ + (c.lines.length ? `<ul>${c.lines.map((l) => `<li>${esc(l)}</li>`).join('')}</ul>` : '')
84
+ + (c.more ? '<p class="rp-tail">The ones left out are in History.</p>' : '')
85
+ + (c.moreObjects ? `<p class="rp-tail">The rest of the objects a check proved or failed are on the Objects sheet.</p>` : '')
86
+ + '</section>';
87
+ }
88
+ const LANE_HEAD = ['Lane packages', 'Objects', 'Decided', 'Fixes proven', 'Awaiting check', 'Kept', 'Rebuilds', 'Fix failed', 'Last check'];
89
+ function lanesHtml(view) {
90
+ const rows = view.lanes.slice(0, LANE_CAP).map((r) => `<tr><th scope="row">${esc(r.developer)}</th>`
91
+ + `<td data-l="Lane packages" class="rp-pk">${esc(r.packagesWord)}</td><td data-l="Objects">${n(r.objects)}</td><td data-l="Decided">${n(r.decided)}</td>`
92
+ + `<td data-l="Fixes proven">${n(r.fixesProven)}</td><td data-l="Awaiting check">${esc(awaitingWord(r))}</td><td data-l="Kept">${n(r.kept)}</td>`
93
+ + `<td data-l="Rebuilds">${n(r.rebuilds)}</td><td data-l="Fix failed">${n(r.fixFailed)}</td><td data-l="Last check">${esc(lastCheckWord(r))}</td></tr>`).join('');
94
+ const more = view.lanes.length > LANE_CAP ? `<p class="rp-tail">And ${n(view.lanes.length - LANE_CAP)} more on the workbook's Summary sheet.</p>` : '';
95
+ return '<section class="rp-detail" data-role="lanes"><h2>By lane / developer</h2>'
96
+ + `<div class="scroller"><table class="rp-devs" aria-label="By lane / developer"><thead><tr><th scope="col">Developer</th>${LANE_HEAD.map((h) => `<th scope="col">${h}</th>`).join('')}</tr></thead>`
97
+ + `<tbody>${rows}</tbody></table></div>${more}`
98
+ + "<p class=\"rp-tail\">Lanes are listed in the order they were set up, never ranked. On the workbook's Objects sheet, filter Lane to see one developer's list.</p></section>";
99
+ }
100
+ function keptHtml(view) {
101
+ const k = view.kept;
102
+ const body = k.length
103
+ ? `<ul class="rp-kept-list">${k.slice(0, KEPT_CAP).map((x) => `<li><p>${esc(x.line)}</p>${x.why ? `<p class="rp-why">${esc(x.why)}</p>` : ''}<p class="rp-why">${esc(x.by)}</p></li>`).join('')}</ul>`
104
+ + (k.length > KEPT_CAP ? `<p class="rp-tail">And ${n(k.length - KEPT_CAP)} more on the workbook's For the business sheet.</p>` : '')
105
+ : '<p>No object is kept by decision.</p>';
106
+ return `<section class="rp-kept" data-role="kept"><h2>Kept as is, by decision (${n(k.length)})</h2>${body}</section>`;
107
+ }
108
+ function rebuildsHtml(view) {
109
+ const g = view.rebuilds;
110
+ if (!g.total)
111
+ return '';
112
+ return `<section class="rp-rebuilds" data-role="rebuilds"><h2>Rebuilds (${n(g.total)})</h2><p>${esc(g.words.join(' · '))}.</p>`
113
+ + (g.staleLines.length ? `<ul>${g.staleLines.map((l) => `<li>${esc(l)}</li>`).join('')}</ul>` : '')
114
+ + '</section>';
115
+ }
88
116
  /**
89
- * The whole document.
117
+ * The whole document: the one report.
90
118
  *
91
- * Read in the order a manager reads it: whose team project, when this was true, the
92
- * one line and the focus, the estate (or the sentence saying there is none, or
93
- * that it could not be read — `estate` is the discovery file as the folder read
94
- * gave it, exactly as the page gets it), where it stands,
95
- * progress over time, what is waiting for a person, who owns what, the plan —
96
- * and only then, folded away, every object one by one. The same sections, in
97
- * the same order, as the page.
119
+ * Page 1, in order: the coverage line (with the check and its date) · the
120
+ * headline and scope · five numbers (six with rebuilds) · levels now ·
121
+ * decisions needed · changed since the last report. Page 2: by lane /
122
+ * developer, the kept-by-decision list, the rebuild group.
98
123
  *
99
- * `css` is the only value written in unescaped, and the only one that never came
100
- * out of the folder: it is our own stylesheet, imported at build time. Every
101
- * name, package, decision and person below goes through `esc()`.
124
+ * `previousAsOf` is the as-of stamp of the status page this one replaces (read
125
+ * from its `cspeach-as-of` meta tag before it is overwritten); null for the
126
+ * first report. `summary`, `needs` and `blocks` are still accepted from the
127
+ * page's `collectView` and not drawn: the one report works its numbers out
128
+ * from the same folder, once, in report-model.js.
129
+ *
130
+ * `estate` is the discovery file as the folder read it: given but not read into
131
+ * the picture, the scope sentence says it could not be read, never "none".
102
132
  *
103
- * `files`, `checksum` and `register.skipped` are what the read note is worked
104
- * out from. The file prints it for the same reason the page does, and with more
105
- * reason: whoever this was mailed to cannot look in the folder to find out that
106
- * a ruling was ignored, and an ignored ruling is a decision somebody made that
107
- * this picture does not show.
133
+ * `css` is the only value written in unescaped, and the only one that never came
134
+ * out of the folder. Every name, package, decision and person goes through `esc()`.
108
135
  *
109
- * @param {{ project: object, register: object, summary: object, needs: object[],
110
- * blocks: object[], css: string, asOf: string, folderName: string,
111
- * files?: number, checksum?: string, unreadable?: number, estate?: unknown }} view
136
+ * @param {{ project: object, register: object, css: string, asOf: string, folderName: string,
137
+ * files?: number, checksum?: string, unreadable?: number, needs?: object[], previousAsOf?: string|null,
138
+ * estate?: unknown }} view
112
139
  * @returns {string}
113
140
  */
114
- export function buildStatusHtml({ project, register, summary, needs, blocks, css, asOf, folderName, files: filesRead = 0, checksum, unreadable = 0, estate = undefined, // the default keeps it optional for the TypeScript copy
115
- }) {
116
- // A file with no time on it, or with the wrong one, is worse than no file:
117
- // somebody will act on it. Refuse rather than print "never" or quietly stamp
118
- // the moment Save was pressed.
141
+ export function buildStatusHtml({ project, register, needs = [], css, asOf, folderName, files: filesRead = 0, checksum, unreadable = 0, previousAsOf = null, estate = undefined, }) {
142
+ // A file with no time on it is worse than no file: somebody will act on it.
119
143
  if (!asOf)
120
144
  throw new Error('buildStatusHtml needs asOf — the time the folder was read. The file states it as the truth of the picture.');
121
- // The file says how many files were missing. A caller that reports the need
122
- // but not the count would have it say "0 files could not be read", which is
123
- // the opposite of what the need means.
145
+ // "0 files could not be read" would be the opposite of what the need means.
124
146
  if (needs?.some((i) => i.kind === 'unreadable') && !(Number.isFinite(unreadable) && unreadable > 0)) {
125
147
  throw new Error('buildStatusHtml needs unreadable — a need says files could not be read, and the file states how many.');
126
148
  }
127
- // `css` is ours, but this function is exported and pure, so a caller could one
128
- // day hand it something with a closing style tag in it, which would end the
129
- // stylesheet early and put the rest of it into the document as text — or as
130
- // markup. Refuse; do not rewrite it behind the caller's back.
149
+ // A closing style tag would end the stylesheet early. Refuse; never rewrite it.
131
150
  if (/<\/style/i.test(css))
132
151
  throw new Error('buildStatusHtml refuses css that closes the style element.');
133
- // "Reload in a minute" is true on the page and false in a file that cannot
134
- // reload. The page keeps the model's wording; the file says what a reader
135
- // holding a snapshot can actually expect. This is the ONE mark where the two
136
- // surfaces are allowed to differ.
137
- const forFile = (i) => (i.kind !== 'unreadable' ? i
138
- : { ...i, title: `${files(unreadable)} could not be read when this was made`, detail: UNREADABLE_DETAIL });
152
+ const view = reportView({ project, register, asOf, previousAsOf });
153
+ const trend = trendLine(view.trend);
154
+ const levelKey = `Levels: ${['A', 'B', 'C', 'D'].map((l) => `${l} ${LEVEL_KEY[l]}`).join('; ')}. ${FIX_RULE}`;
139
155
  return `<!doctype html>
140
156
  <html lang="en"><head><meta charset="utf-8">
141
157
  <meta http-equiv="Content-Security-Policy" content="${FILE_CSP}">
142
158
  <meta name="viewport" content="width=device-width, initial-scale=1">
159
+ <meta name="cspeach-as-of" content="${esc(asOf)}">
143
160
  <title>${esc(project.name)} — status as of ${esc(String(asOf).slice(0, 10))}</title>
144
161
  <style>${css}</style></head>
145
- <body><main id="app">
162
+ <body><main id="app" class="rp">
146
163
  <header class="masthead">
147
164
  <p class="provenance">${provenanceLine(project)}</p>
148
165
  <h1>${esc(project.name)}</h1>
149
166
  <p class="stamp" data-role="stamp">Status as of ${esc(formatWhen(asOf))}. This file does not update. Made by CSPeach from the folder ${esc(folderName)}.</p>
150
167
  ${unreadable > 0 ? `<p class="notice" data-role="incomplete">${files(unreadable)} could not be read, so this picture may be incomplete.</p>` : ''}
151
- <p class="summary" data-role="summary">${esc(summary.line)}</p>
152
- <p class="focus-line" data-role="focus">${focusLineHtml(register)}</p>
153
- ${notMeasuredLine(register) ? `<p class="unmeasured" data-role="not-measured">${esc(notMeasuredLine(register))}</p>` : ''}
154
168
  </header>
155
- <section class="estate" data-role="estate"><h2>Estate</h2>
156
- ${estateSectionHtml(register, { fileGiven: estate !== undefined, now: asOf })}
157
- </section>
158
- <section class="stands" data-role="stands"><h2>Where it stands</h2>
159
- ${whereItStandsHtml(register)}
160
- </section>
161
- <section class="dispositions"><h2>Decisions</h2>
162
- ${dispositionsHtml(register)}
163
- </section>
164
- <section class="progress" data-role="progress"><h2>Progress over time</h2>
165
- ${progressSectionHtml(register)}
166
- </section>
167
- <section class="needs" data-role="needs"><h2>Needs you</h2>
168
- ${needs.length ? `<ul>${needs.map(forFile).map(needItemHtml).join('')}</ul>` : '<p>Nothing is waiting for anyone.</p>'}
169
- </section>
170
- <section class="lanes" data-role="lanes"><h2>Who owns what</h2>
171
- ${developersTableHtml(register)}
172
- </section>
173
- <section class="plan" data-role="plan"><h2>Plan and changes</h2>
174
- ${planSectionHtml(register)}
175
- </section>
176
- <section class="map"><h2>Every object</h2>
177
- ${mapBodyHtml(register, blocks, { role: 'img' })}
178
- </section>
179
- <section class="runs" data-role="runs"><h2>Measurements</h2>
180
- ${runsTableHtml(register.runs)}
181
- ${ignoredRunsHtml(register.ignoredRuns)}
182
- </section>
183
- <section class="objects"><h2>The object list</h2>
184
- ${blocks.map((b) => packageFold(b, register.lanes, register.runs, project.type)).join('')}
185
- </section>
186
- <footer><p data-role="read-note">${esc(readNote({ files: filesRead, unreadable, checksum, register }, { tense: 'past' }))}</p>
169
+ <div class="rp-first">
170
+ <div class="rp-coverage"><p class="rp-coverage-line" data-role="coverage">${esc(view.coverage.line)}</p>${trend ? `<p class="rp-trend" data-role="trend">${esc(trend)}</p>` : ''}${view.coverage.notes.map((note) => `<p class="rp-trend" data-role="coverage-note">${esc(note)}</p>`).join('')}</div>
171
+ <p class="rp-headline" data-role="headline">${esc(view.headline)}</p>
172
+ <p class="rp-scope" data-role="scope">${esc(view.scope)}${estate !== undefined && !register.estate ? ' The estate inventory in this folder could not be read.' : ''}</p>
173
+ ${numbersHtml(view)}
174
+ <div class="rp-band">
175
+ ${levelsHtml(view)}
176
+ ${decisionsHtml(view)}
177
+ </div>
178
+ ${changedHtml(view)}
179
+ </div>
180
+ ${lanesHtml(view)}
181
+ ${keptHtml(view)}
182
+ ${rebuildsHtml(view)}
183
+ <footer><p class="rp-key">${esc(levelKey)}</p>
184
+ <p data-role="read-note">${esc(`${readNote({ files: filesRead, unreadable, checksum, register }, { tense: 'past' })}`.replace(/([^.])$/, '$1.'))}</p>
187
185
  <p data-role="trust">This file holds object names, packages, team member names, decisions, fix status and check results. No source code. No passwords. It contains no script and no links, and it cannot load anything from the internet.</p></footer>
188
186
  </main></body></html>`;
189
187
  }