@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.
- package/dist/agent/loop.js +16 -6
- package/dist/cli.js +13 -4
- package/dist/commands/help.js +16 -1
- package/dist/commands/plan-chain.js +3 -0
- package/dist/commands/plan-gate.js +10 -1
- package/dist/commands/plan-resume.js +4 -0
- package/dist/commands/team-rules.js +187 -0
- package/dist/commands/team.js +632 -90
- package/dist/journey/chain.js +265 -15
- package/dist/journey/clean-core.js +25 -3
- package/dist/journey/flow.js +51 -1
- package/dist/journey/rebuild.js +464 -0
- package/dist/journey/run.js +213 -20
- package/dist/journey/session-deps.js +5 -1
- package/dist/lane/app-chain.js +12 -2
- package/dist/one-shot.js +24 -17
- package/dist/projects/clean-core-confirm.js +38 -5
- package/dist/projects/save-command.js +20 -3
- package/dist/register/baseline.js +39 -2
- package/dist/register/decision-rules.js +35 -0
- package/dist/register/discover.js +49 -19
- package/dist/register/export-files.js +58 -0
- package/dist/register/measure.js +191 -9
- package/dist/register/plan-import.js +6 -3
- package/dist/register/records.js +15 -3
- package/dist/register/rule-proposal.js +4 -0
- package/dist/register/rules-edit.js +257 -0
- package/dist/register/status-file/register-status-file.js +106 -108
- package/dist/register/status-file/report-model.js +569 -0
- package/dist/register/status-file/team-css.js +1 -1
- package/dist/register/status-file-sync.js +1 -0
- package/dist/register/status-html.js +25 -6
- package/dist/register/store.js +4 -3
- package/dist/register/variant.js +65 -0
- package/dist/register/workbooks.js +22 -56
- package/dist/register/write-whole.js +30 -2
- package/dist/renderer/tool-labels.js +2 -1
- package/dist/repl/builtin-commands.js +4 -0
- package/dist/repl/journey-entry.js +4 -2
- package/dist/repl/plan-hop.js +2 -9
- package/dist/repl/rules-menu.js +177 -0
- package/dist/repl/slash-completer.js +1 -0
- package/dist/repl/slash-picker.js +5 -4
- package/dist/repl.js +145 -86
- package/dist/router/classifier.js +8 -4
- package/dist/router/router-core.js +43 -7
- package/dist/router/routing-decision.js +1 -1
- package/dist/runs/bundled-runs.js +100 -0
- package/dist/runs/front-door.js +19 -0
- package/dist/runs/manifest-runs.js +50 -0
- package/dist/runs/run-command.js +24 -0
- package/dist/runs/runs.js +27 -0
- package/dist/runs/shadow.js +100 -0
- package/dist/runs/types.js +1 -0
- package/dist/session/queued-step.js +45 -0
- package/dist/skills/bundled-skills.js +53 -53
- package/dist/tools/dispatch-skill.js +8 -4
- package/dist/tools/lane/app-lane-resolve.js +1 -1
- package/dist/tools/project/rules_propose.js +59 -0
- package/dist/tools/project/team_findings.js +13 -2
- package/dist/upgrade-check.js +49 -4
- package/package.json +120 -120
- 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
|
-
|
|
38
|
-
|
|
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
|
|
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
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
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
|
-
* `
|
|
100
|
-
*
|
|
101
|
-
*
|
|
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
|
-
* `
|
|
104
|
-
* out
|
|
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,
|
|
110
|
-
*
|
|
111
|
-
*
|
|
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,
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
<
|
|
156
|
-
|
|
157
|
-
</
|
|
158
|
-
<
|
|
159
|
-
${
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
${
|
|
163
|
-
</
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
${
|
|
169
|
-
</
|
|
170
|
-
<
|
|
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
|
}
|