@cspeach/cli 1.1.48 → 1.1.50
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/cold-prune.js +1 -1
- package/dist/agent/conversation-prompt.js +52 -0
- package/dist/agent/level.js +1 -1
- package/dist/agent/loop.js +90 -8
- package/dist/agent/oversized-results.js +204 -0
- package/dist/agent/sap-connection-adapter.js +2 -0
- package/dist/agent/system-prompt.js +2 -2
- package/dist/agent/tool-dispatch.js +13 -1
- package/dist/agent/turn-error-ux.js +14 -0
- package/dist/approvals/op-labels.js +49 -10
- package/dist/approvals/render.js +14 -23
- package/dist/approvals/risk-floor.js +196 -19
- package/dist/approvals/visible-changes.js +35 -0
- package/dist/commands/help.js +1 -1
- package/dist/commands/plan-continue-gate.js +11 -0
- package/dist/commands/team-plan.js +16 -6
- package/dist/commands/team.js +295 -68
- package/dist/cost/cost-log.js +4 -0
- package/dist/cost/system-block.js +25 -0
- package/dist/journey/chain.js +489 -61
- package/dist/journey/clean-core.js +61 -36
- package/dist/journey/flow.js +75 -2
- package/dist/journey/people.js +37 -0
- package/dist/journey/rebuild.js +7 -6
- package/dist/journey/run.js +129 -42
- package/dist/journey/trigger.js +16 -10
- package/dist/models/server-config.js +13 -0
- package/dist/one-shot.js +1 -1
- package/dist/project-context/render.js +7 -0
- package/dist/projects/clean-core-confirm.js +166 -44
- package/dist/projects/extract-cca.js +8 -7
- package/dist/projects/index.js +0 -1
- package/dist/projects/merge-upgrade.js +2 -2
- package/dist/projects/project-folder.js +2 -1
- package/dist/projects/save-command.js +51 -14
- package/dist/projects/workspace.js +6 -3
- package/dist/register/decision-rules.js +2 -1
- package/dist/register/export-files.js +18 -0
- package/dist/register/picture.js +3 -3
- package/dist/register/project-transport.js +125 -0
- package/dist/register/records.js +25 -13
- package/dist/register/rules-edit.js +5 -0
- package/dist/register/status-file/progress-chart.js +17 -6
- package/dist/register/status-file/register-map.js +8 -5
- package/dist/register/status-file/register-model.js +55 -36
- package/dist/register/status-file/register-parts.js +97 -82
- package/dist/register/status-file/register-status-file.js +16 -7
- package/dist/register/status-file/report-model.js +293 -205
- package/dist/register/status-file/team-css.js +1 -1
- package/dist/register/status-html.js +11 -4
- package/dist/register/store.js +2 -2
- package/dist/register/workbooks.js +24 -13
- package/dist/renderer/footer-line.js +10 -2
- package/dist/renderer/tool-labels.js +2 -2
- package/dist/repl/builtin-commands.js +1 -1
- package/dist/repl/continue-skill.js +5 -0
- package/dist/repl/level-command.js +6 -15
- package/dist/repl.js +37 -14
- package/dist/router/router-core.js +5 -3
- package/dist/session/fresh-start.js +15 -0
- package/dist/skill-catalog.js +4 -7
- package/dist/tools/_filesystem-shared.js +7 -0
- package/dist/tools/_team-work-path.js +53 -0
- package/dist/tools/approval.js +97 -19
- package/dist/tools/cut-markers.js +47 -0
- package/dist/tools/filesystem/extract-document.js +50 -2
- package/dist/tools/filesystem/file-edit.js +8 -1
- package/dist/tools/filesystem/file-read.js +2 -2
- package/dist/tools/filesystem/file-write.js +8 -1
- package/dist/tools/filesystem/read-document.js +20 -4
- package/dist/tools/project/_merge-shared.js +2 -2
- package/dist/tools/project/_team-tool-shared.js +5 -0
- package/dist/tools/project/rules_propose.js +2 -1
- package/dist/tools/project/team_findings.js +81 -28
- package/dist/tools/project/team_lane_move.js +127 -0
- package/dist/tools/project/upgrade_merge_progress.js +8 -4
- package/dist/tools/text-elements/tools.js +7 -1
- package/dist/tools/tool-loading.js +1 -1
- package/dist/tools/tool-output-read.js +5 -1
- package/dist/tools/update-method-intercept.js +18 -1
- package/dist/tools/write-pipeline.js +24 -5
- package/dist/ui/approval-modal.js +6 -3
- package/dist/ui/footer-line.js +19 -1
- package/package.json +3 -3
- package/dist/projects/merge-cca.js +0 -311
- package/dist/tools/project/cca_merge.js +0 -174
- package/test-harness/fe-draft/app.ts +0 -188
- package/test-harness/fe-draft/fe-draft.e2e.test.ts +0 -461
- package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +0 -51
- package/test-harness/fe-draft/routes.ts +0 -224
- package/test-harness/fe-draft/routes.unit.test.ts +0 -45
- package/test-harness/fe-draft/server.ts +0 -225
- package/test-harness/fe-draft/variants.ts +0 -196
|
@@ -11,17 +11,25 @@
|
|
|
11
11
|
* all print from `reportView`, so a number on paper, in Excel and in the
|
|
12
12
|
* terminal cannot differ.
|
|
13
13
|
*
|
|
14
|
-
* Pure:
|
|
15
|
-
* in.
|
|
14
|
+
* Pure: the project view and its Register in, plain data out. No DOM and no
|
|
15
|
+
* clock — `asOf` is handed in. Every count and word about an object comes from
|
|
16
|
+
* the project view (register-core `projectView`, design 2026-10-21), built
|
|
17
|
+
* once by the caller; the register is read only for what the view does not
|
|
18
|
+
* carry (the runs, the history of records). The honesty rules this file keeps
|
|
19
|
+
* (each pinned by a test):
|
|
16
20
|
* - the coverage line comes first: no percentage, no claim before it;
|
|
17
|
-
* - "
|
|
18
|
-
*
|
|
21
|
+
* - "active" is said wherever it is the denominator, and retired and out of
|
|
22
|
+
* scope objects are their own parts, said apart — never a bare "of 21"
|
|
23
|
+
* beside a bare "28 objects";
|
|
24
|
+
* - "fix proven" is the view's proven part — an object that was clean from its
|
|
25
|
+
* first check never had a fix to prove;
|
|
19
26
|
* - kept by decision is its own count: never "not started", never "below target";
|
|
20
27
|
* - no target anywhere → one line, "no target set — counted as the check reports",
|
|
21
28
|
* and no judgement words;
|
|
22
|
-
* - developers are listed in lane order (lanes.json), never by a count
|
|
29
|
+
* - developers are listed in lane order (lanes.json), never by a count; each
|
|
30
|
+
* object counts once, in the lane that owns it.
|
|
23
31
|
*/
|
|
24
|
-
import {
|
|
32
|
+
import { DECISION_WORDS, PLAN_COLUMNS, decisionWhatOf, fixFailedAt, isOutsideUsageWindowLine, overlapNote, planRows, usageCellOf, utcWords } from '@cspeach/register-core';
|
|
25
33
|
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
26
34
|
const two = (v) => String(v).padStart(2, '0');
|
|
27
35
|
const DAY_MS = 86_400_000;
|
|
@@ -68,38 +76,25 @@ export function asOfFromStatusHtml(text) {
|
|
|
68
76
|
return m && Number.isFinite(Date.parse(m[1])) ? m[1] : null;
|
|
69
77
|
}
|
|
70
78
|
const isActive = (o) => o.inScope !== false && o.state !== 'retired';
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
const isRebuild = (o) => classOf(o) === 'redesign';
|
|
74
|
-
const ownerOf = (register, o) => register.lanes.find((l) => l.id === o.lane)?.owner ?? null;
|
|
79
|
+
/** The parts that are not active: said apart wherever the active count divides. */
|
|
80
|
+
const ASIDE = ['retired', 'out-of-scope'];
|
|
75
81
|
const REBUILD_STATUSES = new Set(['handed-off', 'started', 'built', 'proven']);
|
|
76
82
|
/** The rebuild row on an old object, read tolerantly: a later build defines it (T5). */
|
|
77
83
|
const rebuildOf = (o) => {
|
|
78
84
|
const r = o.evidence?.rebuild;
|
|
79
85
|
return r && typeof r === 'object' && REBUILD_STATUSES.has(r.status) ? r : null;
|
|
80
86
|
};
|
|
81
|
-
/**
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
return run?.atcVariant ?? "the team project's check";
|
|
88
|
-
}
|
|
89
|
-
function coverageOf(project, register) {
|
|
90
|
-
const active = register.objects.filter(isActive);
|
|
91
|
-
const of = active.length;
|
|
92
|
-
const checked = active.filter((o) => (o.lastMeasuredAt ?? null) !== null).length;
|
|
93
|
-
const notChecked = of - checked;
|
|
94
|
-
const variant = variantOf(project, register);
|
|
95
|
-
const at = register.lastMeasuredAt ?? null;
|
|
96
|
-
const lastRun = [...(register.runs ?? [])].reverse().find((r) => r.at === at);
|
|
97
|
-
const fromFile = lastRun?.basis === 'file';
|
|
98
|
-
const notRun = lastRun?.coverage?.notRun ?? 0;
|
|
87
|
+
/** "7 retired, not checked · 1 taken out of scope, not checked": the parts the coverage line does not count, each said once. */
|
|
88
|
+
const asideWords = (view) => ASIDE.filter((p) => view.all.parts[p] > 0).map((p) => `${view.words.part(p, view.all.parts[p])}, not checked`);
|
|
89
|
+
function coverageOf(view, register) {
|
|
90
|
+
const c = view.all.coverage;
|
|
91
|
+
const { checked, of, notChecked, variant, fromFile, notRun } = c;
|
|
92
|
+
const at = c.lastCheckAt;
|
|
99
93
|
const cut = notChecked > 0 && notRun > 0 ? ` (the last check run stopped before reaching ${count(Math.min(notRun, notChecked))} of them)` : '';
|
|
94
|
+
const aside = asideWords(view).map((w) => ` · ${w}`).join('');
|
|
100
95
|
const line = at
|
|
101
|
-
? `Checked ${count(checked)} of ${plural(of, 'object', 'objects')} with ${variant}, latest check on ${whenWords(at)}${fromFile ? ', from a file' : ''} · ${count(notChecked)} not checked${cut}.`
|
|
102
|
-
: `Checked 0 of ${plural(of, 'object', 'objects')} with ${variant} · ${count(of)} not checked.`;
|
|
96
|
+
? `Checked ${count(checked)} of ${plural(of, 'active object', 'active objects')} with ${variant}, latest check on ${whenWords(at)}${fromFile ? ', from a file' : ''} · ${count(notChecked)} not checked${cut}${aside}.`
|
|
97
|
+
: `Checked 0 of ${plural(of, 'active object', 'active objects')} with ${variant} · ${count(of)} not checked${aside}.`;
|
|
103
98
|
// What a reader two forwards away cannot look up in the folder: where the
|
|
104
99
|
// results came from, and what was left out.
|
|
105
100
|
const notes = [];
|
|
@@ -138,81 +133,43 @@ export function findingsTrend(register) {
|
|
|
138
133
|
return { at: t, findings: sum };
|
|
139
134
|
});
|
|
140
135
|
}
|
|
141
|
-
/** Does any target exist that a person set? (controller ruling for T3/T4). */
|
|
142
|
-
export
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
}
|
|
147
|
-
function targetOf(project, register) {
|
|
148
|
-
if (!hasTarget(project, register))
|
|
136
|
+
/** Does any target exist that a person set? The view's answer (controller ruling for T3/T4). */
|
|
137
|
+
export const hasTarget = (view) => view.all.target.any;
|
|
138
|
+
function targetOf(view) {
|
|
139
|
+
const t = view.all.target;
|
|
140
|
+
if (!t.any)
|
|
149
141
|
return { set: false, line: NO_TARGET, provenLines: [] };
|
|
150
|
-
const withTarget = register.objects.filter((o) => isActive(o) && o.proof?.target?.level);
|
|
151
|
-
const judged = withTarget.filter((o) => !isKept(o));
|
|
152
|
-
const meets = judged.filter((o) => o.proof.proven).length;
|
|
153
|
-
const keptBelow = withTarget.filter((o) => isKept(o) && !o.proof.proven).length;
|
|
154
142
|
// Final review I1: a fix the check did not pass in full, with nothing open for its target, says so by name.
|
|
155
|
-
const provenLines =
|
|
156
|
-
.sort((a, b) => a.name.localeCompare(b.name, 'en')).map((
|
|
157
|
-
const whole =
|
|
143
|
+
const provenLines = view.all.objects.filter((v) => v.part === 'proven' && v.proof.provenForTarget)
|
|
144
|
+
.sort((a, b) => a.name.localeCompare(b.name, 'en')).map((v) => `${v.name}: ${v.proof.fixState}.`);
|
|
145
|
+
const whole = t.project.filter((x) => !x.packages);
|
|
158
146
|
const level = whole.length === 1 ? ` (${whole[0].level})` : '';
|
|
159
147
|
return {
|
|
160
|
-
set: true, meets, of:
|
|
161
|
-
line: `${count(meets)} of ${plural(
|
|
162
|
-
keptLine: keptBelow ? `${plural(keptBelow, 'object is', 'objects are')} kept below your target, by decision.` : '',
|
|
148
|
+
set: true, meets: t.meets, of: t.of, keptBelow: t.keptBelow,
|
|
149
|
+
line: `${count(t.meets)} of ${plural(t.of, 'object meets', 'objects meet')} your target${level}.`,
|
|
150
|
+
keptLine: t.keptBelow ? `${plural(t.keptBelow, 'object is', 'objects are')} kept below your target, by decision.` : '',
|
|
163
151
|
provenLines,
|
|
164
152
|
};
|
|
165
153
|
}
|
|
166
|
-
function levelsOf(
|
|
167
|
-
const now =
|
|
154
|
+
function levelsOf(view) {
|
|
155
|
+
const { now, nothingToDecide: nothing } = view.all.levels;
|
|
168
156
|
const rows = ['A', 'B', 'C', 'D'].map((l) => ({ level: l, word: l, n: now[l] ?? 0, key: LEVEL_KEY[l] }));
|
|
169
157
|
rows.push({ level: 'none', word: 'Not assessed', n: now.none ?? 0, key: LEVEL_KEY.none });
|
|
170
|
-
const nothing = register.totals.byLevel.nothingToDecide ?? 0;
|
|
171
158
|
if (nothing)
|
|
172
159
|
rows.push({ level: 'nothing', word: 'Clean, nothing to decide', n: nothing, key: LEVEL_KEY.nothing });
|
|
173
160
|
return rows;
|
|
174
161
|
}
|
|
175
|
-
/**
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
for (const o of register.objects) {
|
|
182
|
-
if (o.decision?.conflict) {
|
|
183
|
-
const at = (o.decisionHistory ?? []).reduce((a, d) => (d.at > a ? d.at : a), '');
|
|
184
|
-
add('conflict', o, `Settle the different decisions on ${o.name}.`, lead, at || null);
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
for (const o of register.objects)
|
|
188
|
-
if (o.retireCandidate === true)
|
|
189
|
-
add('retire', o, `Retire ${o.name}?`, lead, o.decision?.at);
|
|
190
|
-
for (const o of register.objects) {
|
|
191
|
-
if (isKept(o) && o.decision?.exception === true && !o.decision?.reason)
|
|
192
|
-
add('reason', o, `Give the reason ${o.name} is kept.`, lead, o.decision?.at);
|
|
193
|
-
}
|
|
194
|
-
for (const o of register.objects)
|
|
195
|
-
if (o.state === 'did-not-hold' && !provenForTarget(o))
|
|
196
|
-
add('failed', o, `${o.name}: the fix failed the check. Fix it again?`, who(o), o.lastMeasuredAt);
|
|
197
|
-
for (const o of register.objects)
|
|
198
|
-
if (o.state === 'regressed' && !provenForTarget(o))
|
|
199
|
-
add('regressed', o, `${o.name} has more findings than before. Look again?`, who(o), o.lastMeasuredAt);
|
|
200
|
-
// Not here (ruling on review finding 5): objects nobody has decided yet. That
|
|
201
|
-
// is the developer's own triage, counted on its own in the headline.
|
|
202
|
-
const order = ['conflict', 'retire', 'reason', 'failed', 'regressed'];
|
|
203
|
-
// One line per object: the first kind in this order wins.
|
|
204
|
-
const seen = new Set();
|
|
205
|
-
const once = items.filter((x) => order.indexOf(x.kind) >= 0).sort((a, b) => order.indexOf(a.kind) - order.indexOf(b.kind))
|
|
206
|
-
.filter((x) => (seen.has(x.key) ? false : (seen.add(x.key), true)));
|
|
207
|
-
return once.sort((a, b) => order.indexOf(a.kind) - order.indexOf(b.kind)
|
|
208
|
-
|| (ms(a.since) || 0) - (ms(b.since) || 0) || a.name.localeCompare(b.name, 'en'));
|
|
209
|
-
}
|
|
162
|
+
/**
|
|
163
|
+
* Everything that waits for a person, in a fixed order of kinds; oldest first
|
|
164
|
+
* inside a kind; one line per object. The view's list (design §2 "Needs a
|
|
165
|
+
* person"): not worked out here.
|
|
166
|
+
*/
|
|
167
|
+
export const decisionsNeeded = (view) => view.all.needs;
|
|
210
168
|
/** Counted measurements of one object, oldest first, with their run time. */
|
|
211
169
|
const countedOf = (register, o) => (o.measurements ?? [])
|
|
212
170
|
.filter((m) => m.counted && register.runs?.[m.run]?.at)
|
|
213
171
|
.map((m) => ({ at: register.runs[m.run].at, open: m.open }))
|
|
214
172
|
.sort((a, b) => ms(a.at) - ms(b.at));
|
|
215
|
-
const lastClaimAt = (o) => (o.fixes ?? []).filter((f) => f.status === 'fixed').map((f) => f.at).sort((a, b) => ms(a) - ms(b)).pop() ?? null;
|
|
216
173
|
/** When a verified object became verified: the latest check with nothing open that followed one with findings. */
|
|
217
174
|
export function provenAt(register, o) {
|
|
218
175
|
let at = null;
|
|
@@ -224,14 +181,12 @@ export function provenAt(register, o) {
|
|
|
224
181
|
}
|
|
225
182
|
return at;
|
|
226
183
|
}
|
|
227
|
-
/** When a fix did not hold: the first check after the newest claim that still had findings. */
|
|
184
|
+
/** When a fix did not hold: the first check after the newest claim that still had findings (register-core's, the view's rule). */
|
|
228
185
|
export function failedAt(register, o) {
|
|
229
|
-
|
|
230
|
-
const m = countedOf(register, o).find((x) => (!Number.isFinite(claim) || ms(x.at) > claim) && x.open > 0);
|
|
231
|
-
return m?.at ?? null;
|
|
186
|
+
return fixFailedAt(register, o);
|
|
232
187
|
}
|
|
233
|
-
/** Events recorded inside (from, to]: newest first. Each names one object or one plan change. */
|
|
234
|
-
export function eventsBetween(register, from, to) {
|
|
188
|
+
/** Events recorded inside (from, to]: newest first. Each names one object or one plan change. "Proven for target" is the view's. */
|
|
189
|
+
export function eventsBetween(register, view, from, to) {
|
|
235
190
|
const lo = ms(from);
|
|
236
191
|
const hi = ms(to);
|
|
237
192
|
const inside = (iso) => { const t = ms(iso); return Number.isFinite(t) && (!Number.isFinite(lo) || t > lo) && (!Number.isFinite(hi) || t <= hi); };
|
|
@@ -241,7 +196,7 @@ export function eventsBetween(register, from, to) {
|
|
|
241
196
|
// confirms the same state must not announce it again (review finding 2).
|
|
242
197
|
if (o.state === 'verified' && inside(provenAt(register, o)))
|
|
243
198
|
out.proven.push(o.name);
|
|
244
|
-
else if (
|
|
199
|
+
else if (view.object(o.key)?.proof.provenForTarget) {
|
|
245
200
|
if (inside(failedAt(register, o)))
|
|
246
201
|
out.provenForTarget.push(o.name);
|
|
247
202
|
}
|
|
@@ -258,10 +213,10 @@ export function eventsBetween(register, from, to) {
|
|
|
258
213
|
out.plan.push(c.what);
|
|
259
214
|
return out;
|
|
260
215
|
}
|
|
261
|
-
function changedOf(register, previousAsOf, asOf) {
|
|
216
|
+
function changedOf(register, view, previousAsOf, asOf) {
|
|
262
217
|
if (!previousAsOf)
|
|
263
218
|
return { first: true, since: null, lines: [], more: 0, moreObjects: 0, line: FIRST_REPORT };
|
|
264
|
-
const e = eventsBetween(register, previousAsOf, asOf);
|
|
219
|
+
const e = eventsBetween(register, view, previousAsOf, asOf);
|
|
265
220
|
// The third value says where the names a line leaves out can be found: the
|
|
266
221
|
// History sheet holds check runs, fixes, decisions and plan changes; which
|
|
267
222
|
// objects a check proved or failed is on the Objects sheet.
|
|
@@ -289,25 +244,35 @@ function changedOf(register, previousAsOf, asOf) {
|
|
|
289
244
|
line: lines.length ? '' : `Nothing was recorded since the last report on ${dayWords(previousAsOf)}.`,
|
|
290
245
|
};
|
|
291
246
|
}
|
|
292
|
-
/**
|
|
293
|
-
|
|
247
|
+
/**
|
|
248
|
+
* The rebuild group (review F17), over the project view's objects (Task 9 fix round 2): planned = the
|
|
249
|
+
* view's rebuild part with no rebuild started (I13); the others by the status of the rebuild row; an
|
|
250
|
+
* object the lead took out of scope is in no count. The register is read only for the rebuild row's
|
|
251
|
+
* status and time (when a hand-off went stale).
|
|
252
|
+
*/
|
|
253
|
+
export function rebuildGroup(register, asOf, view) {
|
|
294
254
|
const g = { planned: 0, handedOff: 0, inProgress: 0, built: 0, proven: 0, retired: 0, stale: [] };
|
|
295
255
|
const now = ms(asOf);
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
if (
|
|
299
|
-
|
|
256
|
+
const byKey = new Map(register.objects.map((o) => [o.key, o]));
|
|
257
|
+
for (const v of view.all.objects) {
|
|
258
|
+
if (v.part === 'out-of-scope')
|
|
259
|
+
continue;
|
|
260
|
+
const o = byKey.get(v.key);
|
|
261
|
+
const r = o ? rebuildOf(o) : null;
|
|
262
|
+
if (v.part === 'retired') {
|
|
263
|
+
if (r)
|
|
264
|
+
g.retired += 1;
|
|
300
265
|
continue;
|
|
301
266
|
}
|
|
302
267
|
if (!r) {
|
|
303
|
-
if (
|
|
268
|
+
if (v.part === 'rebuild')
|
|
304
269
|
g.planned += 1;
|
|
305
270
|
continue;
|
|
306
271
|
}
|
|
307
272
|
if (r.status === 'handed-off') {
|
|
308
273
|
g.handedOff += 1;
|
|
309
274
|
if (Number.isFinite(now) && Number.isFinite(ms(r.at)) && now - ms(r.at) > 14 * DAY_MS)
|
|
310
|
-
g.stale.push({ name:
|
|
275
|
+
g.stale.push({ name: v.name, at: r.at });
|
|
311
276
|
}
|
|
312
277
|
else if (r.status === 'started')
|
|
313
278
|
g.inProgress += 1;
|
|
@@ -324,103 +289,128 @@ export function rebuildGroup(register, asOf) {
|
|
|
324
289
|
g.staleLines = g.stale.map((s) => `${s.name}: handed off on ${dayWords(s.at)}, not started.`);
|
|
325
290
|
return g;
|
|
326
291
|
}
|
|
327
|
-
/**
|
|
328
|
-
|
|
292
|
+
/**
|
|
293
|
+
* One row per lane, in lanes.json order — never sorted by a count (review F11).
|
|
294
|
+
* The counts are the lane's parts in the view: each object counts once, in the
|
|
295
|
+
* lane that owns it, so the rows (with "No owner yet") add up to the objects.
|
|
296
|
+
* The register is read only for how long a claim has waited.
|
|
297
|
+
*/
|
|
298
|
+
export function laneRows(view, register, asOf) {
|
|
329
299
|
const now = ms(asOf);
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
const claimTimes =
|
|
300
|
+
const byKey = new Map(register.objects.map((o) => [o.key, o]));
|
|
301
|
+
const rowOf = (owner, laneId, scope) => {
|
|
302
|
+
const claimTimes = scope.objects.filter((v) => v.part === 'awaiting-check')
|
|
303
|
+
.map((v) => Math.max(...(byKey.get(v.key)?.fixes ?? []).filter((f) => f.status === 'fixed').map((f) => ms(f.at)).filter(Number.isFinite), -Infinity))
|
|
333
304
|
.filter(Number.isFinite);
|
|
334
305
|
const oldest = claimTimes.length && Number.isFinite(now) ? Math.max(0, Math.floor((now - Math.min(...claimTimes)) / DAY_MS)) : null;
|
|
335
|
-
const packages = [...new Set(objects.map((
|
|
336
|
-
const last = objects.map((
|
|
306
|
+
const packages = [...new Set(scope.objects.map((v) => v.package))].sort((a, b) => a.localeCompare(b, 'en'));
|
|
307
|
+
const last = scope.objects.map((v) => v.proof.lastCheckAt).filter(Boolean).sort().pop() ?? null;
|
|
308
|
+
const p = scope.parts;
|
|
337
309
|
return {
|
|
338
310
|
laneId, developer: owner, packages,
|
|
339
311
|
packagesWord: packages.length > 3 ? `${packages.slice(0, 3).join(', ')} and ${packages.length - 3} more` : packages.join(', '),
|
|
340
|
-
objects:
|
|
341
|
-
decided: objects.filter((
|
|
342
|
-
fixesProven:
|
|
343
|
-
awaiting:
|
|
344
|
-
kept:
|
|
345
|
-
rebuilds:
|
|
346
|
-
fixFailed:
|
|
312
|
+
objects: scope.total, active: scope.active, retired: p.retired,
|
|
313
|
+
decided: scope.objects.filter((v) => v.decision.what !== 'none').length,
|
|
314
|
+
fixesProven: p.proven,
|
|
315
|
+
awaiting: p['awaiting-check'], oldestDays: oldest,
|
|
316
|
+
kept: p.kept,
|
|
317
|
+
rebuilds: p.rebuild,
|
|
318
|
+
fixFailed: p['failed-check'],
|
|
347
319
|
lastCheck: last,
|
|
348
320
|
next: [],
|
|
349
321
|
};
|
|
350
322
|
};
|
|
351
323
|
// A person with two lanes is two rows; the lane id tells them apart.
|
|
352
|
-
const
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
324
|
+
const owners = view.lanes.map((s) => s.scope.owner);
|
|
325
|
+
const twice = new Set(owners.filter((o, i, all) => all.indexOf(o) !== i));
|
|
326
|
+
const rows = view.lanes.map((s) => ({
|
|
327
|
+
...rowOf(twice.has(s.scope.owner) ? `${s.scope.owner} (${s.scope.id})` : s.scope.owner, s.scope.id, s),
|
|
328
|
+
// The lane's own next objects, in the priority order every surface sorts by (the view's `next`).
|
|
329
|
+
next: s.next.slice(0, 3).map((v) => v.name),
|
|
357
330
|
}));
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
if (unowned.length)
|
|
361
|
-
rows.push(rowOf('No owner yet', null, unowned));
|
|
331
|
+
if (view.unowned.total)
|
|
332
|
+
rows.push(rowOf('No owner yet', null, view.unowned));
|
|
362
333
|
return rows;
|
|
363
334
|
}
|
|
335
|
+
/** "1 object also matches another lane. It counts once, in the lane that owns it." — or nothing. */
|
|
336
|
+
export const lanesNote = (register) => overlapNote(register);
|
|
364
337
|
export const awaitingWord = (r) => (r.awaiting ? `${count(r.awaiting)}${r.oldestDays !== null ? ` (oldest ${plural(r.oldestDays, 'day', 'days')})` : ''}` : '0');
|
|
365
338
|
export const lastCheckWord = (r) => (r.lastCheck ? dayWords(r.lastCheck) : 'not yet');
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
339
|
+
/** The kept part, by name. The reason says whose words it is (the view's words: "Why: …", "Reason (proposed, accepted by …): …"). */
|
|
340
|
+
function keptList(view) {
|
|
341
|
+
return view.all.objects.filter((v) => v.part === 'kept').map((v) => {
|
|
342
|
+
const d = v.decision;
|
|
343
|
+
return {
|
|
344
|
+
name: v.name, type: v.type,
|
|
345
|
+
line: `${v.name} kept${v.level.decided ? ` at ${v.level.decided}` : ''}${d.what === 'exception' ? ' as an accepted exception' : ''}, ${d.reason ? 'reason recorded' : 'no reason recorded'}.`,
|
|
346
|
+
why: d.reason ? view.words.reason(d.reason, d.by) : '',
|
|
347
|
+
by: d.by ? `Decided by ${d.by.name}${d.by.at ? ` on ${dayWords(d.by.at)}` : ''}.` : '',
|
|
348
|
+
};
|
|
349
|
+
}).sort((a, b) => a.name.localeCompare(b.name, 'en'));
|
|
373
350
|
}
|
|
374
|
-
/** Why "Objects" can be more than the coverage line's "of m":
|
|
375
|
-
function objectsSub(
|
|
376
|
-
const
|
|
377
|
-
|
|
378
|
-
return parts.length ? `${parts.join(', ')}; not checked` : 'in the team project';
|
|
351
|
+
/** Why "Objects" can be more than the coverage line's "of m": "21 active; 7 retired, not checked". */
|
|
352
|
+
function objectsSub(view) {
|
|
353
|
+
const aside = ASIDE.filter((p) => view.all.parts[p] > 0).map((p) => view.words.part(p, view.all.parts[p]));
|
|
354
|
+
return aside.length ? `${count(view.all.active)} active; ${aside.join(', ')}, not checked` : 'in the team project';
|
|
379
355
|
}
|
|
380
356
|
/** "with its reason" only when every kept object has one. */
|
|
381
|
-
function keptSub(
|
|
382
|
-
const
|
|
383
|
-
const missing = kept.filter((o) => !o.decision.reason).length;
|
|
357
|
+
function keptSub(view) {
|
|
358
|
+
const missing = view.all.objects.filter((v) => v.part === 'kept' && !v.decision.reason).length;
|
|
384
359
|
return missing ? `by decision; ${plural(missing, 'has', 'have')} no reason recorded` : 'by decision, with its reason';
|
|
385
360
|
}
|
|
386
361
|
/**
|
|
387
|
-
* The whole report, worked out once.
|
|
388
|
-
*
|
|
362
|
+
* The whole report, worked out once, from the project view. The view is
|
|
363
|
+
* required: the caller builds it once (`projectView(register, project)`) and
|
|
364
|
+
* hands it in; the register beside it is the one the view was built from.
|
|
365
|
+
* @param {{ view: object, register: object, asOf: string, previousAsOf?: string|null }} input
|
|
389
366
|
*/
|
|
390
|
-
export function reportView({
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
const
|
|
394
|
-
const
|
|
395
|
-
const
|
|
396
|
-
const
|
|
367
|
+
export function reportView({ view, register, asOf, previousAsOf = null }) {
|
|
368
|
+
if (!view?.all)
|
|
369
|
+
throw new Error('reportView needs the project view (projectView(register, project)), built once by the caller.');
|
|
370
|
+
const all = view.all;
|
|
371
|
+
const p = all.parts;
|
|
372
|
+
const coverage = coverageOf(view, register);
|
|
373
|
+
const needed = decisionsNeeded(view);
|
|
374
|
+
const packages = new Set(all.objects.map((v) => v.package)).size;
|
|
397
375
|
const estate = register.estate;
|
|
398
376
|
const sid = estate?.system?.sid;
|
|
399
|
-
|
|
377
|
+
// "28 objects in 5 packages (21 active, 7 retired)": the parts the coverage line leaves out, said where the total is.
|
|
378
|
+
const aside = ASIDE.filter((x) => p[x] > 0).map((x) => view.words.part(x, p[x]));
|
|
379
|
+
const scope = `${plural(all.total, 'object', 'objects')} in ${plural(packages, 'package', 'packages')}`
|
|
380
|
+
+ `${aside.length ? ` (${count(all.active)} active, ${aside.join(', ')})` : ''}`
|
|
400
381
|
+ `${estate ? `, of ${count(estate.totals.codeObjects)} code objects${sid ? ` on ${sid}` : ' on the system'}` : ''}.`;
|
|
401
|
-
const rebuilds = rebuildGroup(register, asOf);
|
|
402
|
-
|
|
382
|
+
const rebuilds = rebuildGroup(register, asOf, view);
|
|
383
|
+
// Of the fixes proven, how many a file measured: the view's proof basis.
|
|
384
|
+
const fromFile = all.objects.filter((v) => v.part === 'proven' && v.proof.basis === 'file').length;
|
|
385
|
+
const decided = all.objects.filter((v) => v.decision.what !== 'none').length;
|
|
386
|
+
// The tiles that are parts carry the part's id; the parts add up to Objects.
|
|
403
387
|
const numbers = [
|
|
404
|
-
{ id: 'objects', n:
|
|
405
|
-
{ id: 'decided', n:
|
|
406
|
-
{ id: 'fixesProven', n:
|
|
407
|
-
{ id: 'awaiting',
|
|
408
|
-
{ id: 'kept', n: kept, label: 'Kept as is', sub: keptSub(
|
|
409
|
-
...(
|
|
388
|
+
{ id: 'objects', n: all.total, label: 'Objects', sub: objectsSub(view) },
|
|
389
|
+
{ id: 'decided', n: decided, label: 'Decided', sub: 'a person recorded what to do' },
|
|
390
|
+
{ id: 'fixesProven', part: 'proven', n: p.proven, label: 'Fixes proven', sub: fromFile ? `${count(fromFile)} of them measured from a file` : 'a check passed after the fix' },
|
|
391
|
+
{ id: 'awaiting', part: 'awaiting-check', n: p['awaiting-check'], label: 'Fixed, awaiting check', sub: 'reported fixed, not checked since' },
|
|
392
|
+
{ id: 'kept', part: 'kept', n: p.kept, label: 'Kept as is', sub: keptSub(view) },
|
|
393
|
+
...(p.rebuild > 0 ? [{ id: 'rebuilds', part: 'rebuild', n: p.rebuild, label: 'Rebuilds', sub: 'decided to be built again' }] : []),
|
|
394
|
+
...(p.retired > 0 ? [{ id: 'retired', part: 'retired', n: p.retired, label: 'Retired', sub: 'ruled by the lead; not checked' }] : []),
|
|
395
|
+
...(p['out-of-scope'] > 0 ? [{ id: 'outOfScope', part: 'out-of-scope', n: p['out-of-scope'], label: 'Taken out of scope', sub: 'by the lead; not checked' }] : []),
|
|
410
396
|
];
|
|
411
397
|
// Objects with findings nobody has decided yet: the team's own triage, not a decision for someone else.
|
|
412
|
-
const notDecided =
|
|
413
|
-
const headline = `${plural(
|
|
398
|
+
const notDecided = all.objects.filter((v) => (v.part === 'undecided' || v.part === 'skipped') && (v.proof.open ?? 0) > 0).length;
|
|
399
|
+
const headline = `${plural(p.proven, 'fix', 'fixes')} proven by your check · ${plural(needed.length, 'decision', 'decisions')} waiting · ${count(p.kept)} kept by decision`
|
|
414
400
|
+ `${notDecided ? ` · ${plural(notDecided, 'object', 'objects')} not decided yet` : ''}.`;
|
|
415
401
|
return {
|
|
416
402
|
asOf, coverage, headline, scope, numbers, fromFile,
|
|
417
403
|
trend: findingsTrend(register),
|
|
418
|
-
levels: levelsOf(
|
|
419
|
-
target: targetOf(
|
|
404
|
+
levels: levelsOf(view),
|
|
405
|
+
target: targetOf(view),
|
|
420
406
|
decisions: { total: needed.length, shown: needed.slice(0, 5), more: Math.max(0, needed.length - 5), all: needed },
|
|
421
|
-
changed: changedOf(register, previousAsOf, asOf),
|
|
422
|
-
lanes: laneRows(register, asOf),
|
|
423
|
-
|
|
407
|
+
changed: changedOf(register, view, previousAsOf, asOf),
|
|
408
|
+
lanes: laneRows(view, register, asOf),
|
|
409
|
+
lanesNote: lanesNote(register),
|
|
410
|
+
kept: keptList(view),
|
|
411
|
+
// Active objects decided keep that the check counts in another part (clean, fix proven, awaiting or failed).
|
|
412
|
+
keptElsewhere: all.objects.filter((v) => (v.decision.what === 'keep' || v.decision.what === 'exception') && v.part !== 'kept'
|
|
413
|
+
&& !ASIDE.includes(v.part)).length,
|
|
424
414
|
rebuilds,
|
|
425
415
|
};
|
|
426
416
|
}
|
|
@@ -440,8 +430,9 @@ export function firstBlockLines(view) {
|
|
|
440
430
|
for (const note of view.coverage.notes)
|
|
441
431
|
lines.push(note);
|
|
442
432
|
lines.push(view.headline, view.scope, '');
|
|
433
|
+
// The sub text after the label, so the terminal says why 28 objects are not 21 checked.
|
|
443
434
|
for (const x of view.numbers)
|
|
444
|
-
lines.push(` ${String(count(x.n)).padStart(6)} ${x.label}`);
|
|
435
|
+
lines.push(` ${String(count(x.n)).padStart(6)} ${x.label}${x.sub ? ` — ${x.sub}` : ''}`);
|
|
445
436
|
if (view.fromFile)
|
|
446
437
|
lines.push(` ${count(view.fromFile)} of the fixes proven ${view.fromFile === 1 ? 'was' : 'were'} measured from a file.`);
|
|
447
438
|
lines.push('');
|
|
@@ -479,6 +470,8 @@ export function firstBlockLines(view) {
|
|
|
479
470
|
/* ── The workbook's four sheets: rows only, the CLI writes the file ──────── */
|
|
480
471
|
const usageWord = (u) => { const c = usageCellOf(u); return c === 'unknown' && !isOutsideUsageWindowLine(u.why) ? 'not recorded' : c; };
|
|
481
472
|
const callersWord = (o) => (o.evidence?.callers?.read ? o.evidence.callers.objects : 'not read');
|
|
473
|
+
/** The lane table's columns, on the Summary sheet, the page and `team status`: each object once, in the lane that owns it. */
|
|
474
|
+
export const LANE_COLUMNS = ['Developer', 'Lane packages', 'Objects', 'Active', 'Retired', 'Decided', 'Fixes proven', 'Awaiting check', 'Kept', 'Rebuilds', 'Fix failed', 'Last check'];
|
|
482
475
|
/** Summary: the page's numbers, the coverage row first. */
|
|
483
476
|
export function summarySheetRows(view, { madeFor = '' } = {}) {
|
|
484
477
|
const rows = [
|
|
@@ -499,8 +492,9 @@ export function summarySheetRows(view, { madeFor = '' } = {}) {
|
|
|
499
492
|
...(view.rebuilds.total ? [[`Rebuilds: ${view.rebuilds.words.join(' · ')}`, view.rebuilds.total]] : []),
|
|
500
493
|
[],
|
|
501
494
|
['By lane / developer (lanes in their set order)', ''],
|
|
502
|
-
[
|
|
503
|
-
...view.lanes.map((r) => [r.developer, r.packages.join(', '), r.objects, r.decided, r.fixesProven, awaitingWord(r), r.kept, r.rebuilds, r.fixFailed, lastCheckWord(r)]),
|
|
495
|
+
[...LANE_COLUMNS],
|
|
496
|
+
...view.lanes.map((r) => [r.developer, r.packages.join(', '), r.objects, r.active, r.retired, r.decided, r.fixesProven, awaitingWord(r), r.kept, r.rebuilds, r.fixFailed, lastCheckWord(r)]),
|
|
497
|
+
...(view.lanesNote ? [[view.lanesNote, '']] : []),
|
|
504
498
|
[],
|
|
505
499
|
[madeFor, ''],
|
|
506
500
|
[FIX_RULE, ''],
|
|
@@ -510,49 +504,116 @@ export function summarySheetRows(view, { madeFor = '' } = {}) {
|
|
|
510
504
|
];
|
|
511
505
|
return rows;
|
|
512
506
|
}
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
507
|
+
/**
|
|
508
|
+
* The read-only facts after the plan columns. "Target set by" only when a target
|
|
509
|
+
* exists (no target words without a target); "Reason from" says whose words the
|
|
510
|
+
* Reason is.
|
|
511
|
+
*/
|
|
512
|
+
export const OBJECTS_EXTRA = ['Level now', 'Reason', 'Last check', 'Checked with', 'Level decided by', 'Reason from'];
|
|
513
|
+
const PLAN_PART = PLAN_COLUMNS.map((c) => (c === 'Level' ? 'Level decided' : c));
|
|
514
|
+
/** The plan's "Level" is the level a person decided; beside "Level now" it says so. The columns with no target set. */
|
|
515
|
+
export const OBJECTS_COLUMNS = [...PLAN_PART, ...OBJECTS_EXTRA];
|
|
516
|
+
/** The Objects sheet's columns for this view: "Target set by" after "Level decided by" when a target exists. */
|
|
517
|
+
export function objectsSheetColumns(view) {
|
|
518
|
+
if (!view.all.target.any)
|
|
519
|
+
return [...OBJECTS_COLUMNS];
|
|
520
|
+
return [...PLAN_PART, 'Level now', 'Reason', 'Last check', 'Checked with', 'Level decided by', 'Target set by', 'Reason from'];
|
|
521
|
+
}
|
|
522
|
+
const REASON_FROM = { person: 'person', proposal: 'proposal, accepted', none: 'not recorded (older record)' };
|
|
523
|
+
const targetSetBy = (t) => (t.from === 'person' ? t.by?.name ?? '' : t.from === 'project' ? `${t.by?.name ?? 'the lead'} (project target)` : '');
|
|
524
|
+
/**
|
|
525
|
+
* Objects: the Plan sheet's columns (so plan import reads it), then the
|
|
526
|
+
* read-only facts, from the view: who decided the level (G8), who set the
|
|
527
|
+
* target, whose words the reason is.
|
|
528
|
+
*/
|
|
529
|
+
export function objectsSheetRows(register, view, variant) {
|
|
530
|
+
const withTarget = view.all.target.any;
|
|
531
|
+
return planRows(register, view).map((row) => {
|
|
532
|
+
const v = view.object(`${row[1]}|${row[0]}`.toUpperCase());
|
|
533
|
+
const checked = v?.proof.lastCheckAt ?? null;
|
|
534
|
+
const reason = v?.decision.reason ?? null;
|
|
535
|
+
return [...row, v?.level.now ?? '', reason?.text ?? '', checked ? utcWords(checked) : 'not checked', checked ? variant : '',
|
|
536
|
+
v?.level.by?.name ?? '',
|
|
537
|
+
...(withTarget ? [v ? targetSetBy(v.target) : ''] : []),
|
|
538
|
+
reason?.text ? REASON_FROM[reason.from] ?? '' : ''];
|
|
522
539
|
});
|
|
523
540
|
}
|
|
524
541
|
export const BUSINESS_COLUMNS = ['Object', 'Type', 'Package', 'Owner', 'Proposal', 'Reason', 'Decided by', 'Last changed', 'Callers', 'Usage', 'Confirm'];
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
if (c === 'keep')
|
|
536
|
-
return o.decision.exception ? 'kept, accepted exception' : 'kept as is';
|
|
537
|
-
return null;
|
|
538
|
-
};
|
|
539
|
-
return register.objects.filter((o) => proposal(o) && !(o.state === 'retired' && !o.retireCandidate))
|
|
540
|
-
.sort((a, b) => a.package.localeCompare(b.package, 'en') || a.name.localeCompare(b.name, 'en'))
|
|
541
|
-
.map((o) => [
|
|
542
|
-
o.name, o.type, o.package, ownerOf(register, o) ?? 'nobody yet', proposal(o), o.decision?.reason ?? '',
|
|
543
|
-
o.decision ? `${o.decision.by}${o.decision.at ? `, ${utcWords(o.decision.at)}` : ''}` : '',
|
|
542
|
+
const BUSINESS_WORD = { 'retire-candidate': 'retire (candidate)', rebuild: 'rebuild beside the core', keep: 'kept as is', exception: 'kept, accepted exception' };
|
|
543
|
+
/** For the business: retire candidates, rebuild and kept rows, with their evidence, and an empty Confirm column. The decision and owner are the view's. */
|
|
544
|
+
export function businessSheetRows(register, view) {
|
|
545
|
+
// A retire candidate is counted as the tiles and team status count it: active objects only.
|
|
546
|
+
const proposal = (v) => (v.decision.what === 'retire-candidate' && (v.part === 'retired' || v.part === 'out-of-scope') ? null : BUSINESS_WORD[v.decision.what] ?? null);
|
|
547
|
+
return register.objects.map((o) => ({ o, v: view.object(o.key) })).filter(({ v }) => v && proposal(v))
|
|
548
|
+
.sort((a, b) => a.o.package.localeCompare(b.o.package, 'en') || a.o.name.localeCompare(b.o.name, 'en'))
|
|
549
|
+
.map(({ o, v }) => [
|
|
550
|
+
o.name, o.type, o.package, v.owner?.name ?? 'nobody yet', proposal(v), reasonCell(v.decision.reason),
|
|
551
|
+
v.decision.by ? `${v.decision.by.name}${v.decision.by.at ? `, ${utcWords(v.decision.by.at)}` : ''}` : '',
|
|
544
552
|
o.evidence?.changed?.at ? o.evidence.changed.at.slice(0, 10) : 'not read', callersWord(o), usageWord(o.evidence?.usage ?? { state: 'unknown' }), '',
|
|
545
553
|
]);
|
|
546
554
|
}
|
|
555
|
+
/** A reason cell: a proposal's words say they were proposed (design rule 5), the same words History uses. */
|
|
556
|
+
const reasonCell = (r) => (!r?.text ? '' : r.from === 'proposal' ? `proposed: ${r.text}` : r.text);
|
|
547
557
|
export const HISTORY_COLUMNS = ['When', 'Who', 'What', 'Object', 'Why'];
|
|
548
|
-
|
|
558
|
+
/** The classifications the view has words for (Task 9 fix round 3); any other is said as the folder wrote it. */
|
|
559
|
+
const VIEW_CLASSIFICATIONS = new Set(['keep', 'fix', 'retire', 'redesign']);
|
|
560
|
+
/** A decision row in the view's words: a writer's retire is a candidate, the lead's is retired, an excepted keep an accepted exception. */
|
|
561
|
+
const decisionWordOf = (d) => (VIEW_CLASSIFICATIONS.has(d.classification)
|
|
562
|
+
? DECISION_WORDS[decisionWhatOf({ classification: d.classification, exception: d.exception, ruling: d.ruling === true })] : d.classification);
|
|
549
563
|
const FIX_WORD = { fixed: 'fix reported', skipped: 'fix skipped', failed: 'fix failed', pending: 'fix pending' };
|
|
550
564
|
const HISTORY_CAP = 5000;
|
|
551
|
-
/**
|
|
552
|
-
|
|
565
|
+
/** Short words a check id spells in capitals that read wrong in lower case. */
|
|
566
|
+
const RULE_ACRONYMS = {
|
|
567
|
+
ABAP: 'ABAP', ALV: 'ALV', API: 'API', APIS: 'APIs', ATC: 'ATC', BAPI: 'BAPI', BADI: 'BAdI', CDS: 'CDS', DB: 'DB', DDIC: 'DDIC',
|
|
568
|
+
HANA: 'HANA', ID: 'ID', IDS: 'IDs', OO: 'OO', RAP: 'RAP', RFC: 'RFC', SAP: 'SAP', SLIN: 'SLIN', SQL: 'SQL', UI: 'UI', 'S/4HANA': 'S/4HANA',
|
|
569
|
+
};
|
|
570
|
+
const ruleWord = (w, first) => {
|
|
571
|
+
const known = RULE_ACRONYMS[w.toUpperCase()];
|
|
572
|
+
if (known)
|
|
573
|
+
return known;
|
|
574
|
+
const low = w.toLowerCase();
|
|
575
|
+
return first ? low.charAt(0).toUpperCase() + low.slice(1) : low;
|
|
576
|
+
};
|
|
577
|
+
/** A check named only by a system id (a GUID): it has no words a person can read. */
|
|
578
|
+
const CHECK_WORDS = 'a check';
|
|
579
|
+
const isCheckId = (head) => { const bare = head.replace(/[-_{}]/g, ''); return !bare || /^[0-9A-F]{16,}$/i.test(bare); };
|
|
580
|
+
/**
|
|
581
|
+
* A check id in words: `USAGE_OF_RELEASED_APIS:4` reads "Usage of released APIs".
|
|
582
|
+
* The part after the last colon is the check's message id; it never shows.
|
|
583
|
+
* Underscores become spaces, sentence case. A check known only by its system
|
|
584
|
+
* id reads "a check". A person reads this on the History sheet; a check id they cannot.
|
|
585
|
+
*/
|
|
586
|
+
export function ruleWords(rule) {
|
|
587
|
+
const text = String(rule ?? '').trim();
|
|
588
|
+
const cut = text.lastIndexOf(':');
|
|
589
|
+
const head = cut > 0 ? text.slice(0, cut) : text;
|
|
590
|
+
if (isCheckId(head))
|
|
591
|
+
return CHECK_WORDS;
|
|
592
|
+
return head.split(/[_:\s]+/).filter(Boolean).map((w, i) => ruleWord(w, i === 0)).join(' ');
|
|
593
|
+
}
|
|
594
|
+
/** Who ran the counted check at this time on this object. */
|
|
595
|
+
const checkBy = (register, o, at) => {
|
|
596
|
+
for (const m of o.measurements ?? []) {
|
|
597
|
+
const r = m.counted ? register.runs?.[m.run] : null;
|
|
598
|
+
if (r?.at === at)
|
|
599
|
+
return r.by ?? '';
|
|
600
|
+
}
|
|
601
|
+
return '';
|
|
602
|
+
};
|
|
603
|
+
/**
|
|
604
|
+
* History: one event log from the rows the folder already holds (plan changes, decisions, fixes, checks that proved or failed a fix). Newest first.
|
|
605
|
+
*
|
|
606
|
+
* The check rows ("fix proven", "fix failed the check") say what the object's fix IS now, at the check
|
|
607
|
+
* that made it so: they are read from the project view (Task 9 fix round 1). A fix the check left with
|
|
608
|
+
* findings that only matter for a stricter level is proven for its target, in the view's words. Without
|
|
609
|
+
* a view (unit tests over a bare register) the register's own state stands in.
|
|
610
|
+
*/
|
|
611
|
+
export function historySheetRows(register, view) {
|
|
553
612
|
const rows = [];
|
|
554
613
|
for (const r of register.runs ?? []) {
|
|
555
|
-
|
|
614
|
+
// Objects with findings, never the finding rows: a finding in a shared include counts under every program (types.ts RegisterRun).
|
|
615
|
+
const found = Number.isFinite(r.withFindings) ? `: ${plural(r.withFindings, 'object with findings', 'objects with findings')},` : ',';
|
|
616
|
+
rows.push([r.at, r.by, `check run${found} ${r.atcVariant ?? 'variant not recorded'}${r.basis === 'file' ? ', from a file' : ''}`,
|
|
556
617
|
plural(r.objects ?? 0, 'object', 'objects'), r.scope ?? '']);
|
|
557
618
|
}
|
|
558
619
|
for (const c of register.plan?.history ?? []) {
|
|
@@ -560,10 +621,37 @@ export function historySheetRows(register) {
|
|
|
560
621
|
}
|
|
561
622
|
for (const o of register.objects) {
|
|
562
623
|
for (const d of o.decisionHistory ?? []) {
|
|
563
|
-
|
|
624
|
+
// A reason accepted from a proposal says so: the person said Yes to words they did not write (R4).
|
|
625
|
+
const why = d.reason ? (d.reasonFrom === 'proposal' && !d.ruling ? `proposed: ${d.reason}` : d.reason) : '';
|
|
626
|
+
rows.push([d.at, d.by, `${d.ruling ? 'ruling' : 'decision'}: ${decisionWordOf(d)}${d.superseded ? ' (replaced later)' : ''}`, o.name, why]);
|
|
627
|
+
}
|
|
628
|
+
// G7: one row per claim. Rules one person reported on this object at one time are one row, in words.
|
|
629
|
+
const claims = new Map();
|
|
630
|
+
for (const f of o.fixHistory ?? o.fixes ?? []) {
|
|
631
|
+
const id = JSON.stringify([f.by, f.at, f.status]);
|
|
632
|
+
if (!claims.has(id))
|
|
633
|
+
claims.set(id, { at: f.at, by: f.by, status: f.status, open: new Map() });
|
|
634
|
+
const c = claims.get(id);
|
|
635
|
+
if (!c.open.has(f.rule))
|
|
636
|
+
c.open.set(f.rule, Number.isFinite(f.openAtClaim) ? f.openAtClaim : null);
|
|
637
|
+
}
|
|
638
|
+
for (const c of claims.values()) {
|
|
639
|
+
// Two message ids of one check are one name. The count is the findings
|
|
640
|
+
// those rules had open at the last check before the claim; unknown, no count.
|
|
641
|
+
const names = [...new Set([...c.open.keys()].map(ruleWords))].join(', ');
|
|
642
|
+
const counts = [...c.open.values()];
|
|
643
|
+
const label = FIX_WORD[c.status] ?? `fix ${c.status}`;
|
|
644
|
+
const what = counts.every((n) => n !== null) ? `${label}: ${plural(counts.reduce((a, n) => a + n, 0), 'finding', 'findings')} — ${names}` : `${label} — ${names}`;
|
|
645
|
+
rows.push([c.at, c.by, what, o.name, '']);
|
|
564
646
|
}
|
|
565
|
-
|
|
566
|
-
|
|
647
|
+
// A fix counts when a check proves it: that check is its own row, by whoever ran it.
|
|
648
|
+
const proof = view?.object(o.key)?.proof ?? { state: o.state, provenForTarget: false, fixState: '' };
|
|
649
|
+
const proven = proof.state === 'verified' ? provenAt(register, o) : null;
|
|
650
|
+
if (proven && [...claims.values()].some((c) => c.status === 'fixed' && ms(c.at) <= ms(proven)))
|
|
651
|
+
rows.push([proven, checkBy(register, o, proven), 'fix proven', o.name, '']);
|
|
652
|
+
const failed = proof.state === 'did-not-hold' || proof.provenForTarget ? failedAt(register, o) : null;
|
|
653
|
+
if (failed)
|
|
654
|
+
rows.push([failed, checkBy(register, o, failed), proof.provenForTarget ? proof.fixState : 'fix failed the check', o.name, '']);
|
|
567
655
|
}
|
|
568
656
|
return rows.sort((a, b) => (ms(b[0]) || 0) - (ms(a[0]) || 0)).slice(0, HISTORY_CAP).map((r) => [utcWords(r[0]), ...r.slice(1)]);
|
|
569
657
|
}
|