@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.
Files changed (93) hide show
  1. package/dist/agent/cold-prune.js +1 -1
  2. package/dist/agent/conversation-prompt.js +52 -0
  3. package/dist/agent/level.js +1 -1
  4. package/dist/agent/loop.js +90 -8
  5. package/dist/agent/oversized-results.js +204 -0
  6. package/dist/agent/sap-connection-adapter.js +2 -0
  7. package/dist/agent/system-prompt.js +2 -2
  8. package/dist/agent/tool-dispatch.js +13 -1
  9. package/dist/agent/turn-error-ux.js +14 -0
  10. package/dist/approvals/op-labels.js +49 -10
  11. package/dist/approvals/render.js +14 -23
  12. package/dist/approvals/risk-floor.js +196 -19
  13. package/dist/approvals/visible-changes.js +35 -0
  14. package/dist/commands/help.js +1 -1
  15. package/dist/commands/plan-continue-gate.js +11 -0
  16. package/dist/commands/team-plan.js +16 -6
  17. package/dist/commands/team.js +295 -68
  18. package/dist/cost/cost-log.js +4 -0
  19. package/dist/cost/system-block.js +25 -0
  20. package/dist/journey/chain.js +489 -61
  21. package/dist/journey/clean-core.js +61 -36
  22. package/dist/journey/flow.js +75 -2
  23. package/dist/journey/people.js +37 -0
  24. package/dist/journey/rebuild.js +7 -6
  25. package/dist/journey/run.js +129 -42
  26. package/dist/journey/trigger.js +16 -10
  27. package/dist/models/server-config.js +13 -0
  28. package/dist/one-shot.js +1 -1
  29. package/dist/project-context/render.js +7 -0
  30. package/dist/projects/clean-core-confirm.js +166 -44
  31. package/dist/projects/extract-cca.js +8 -7
  32. package/dist/projects/index.js +0 -1
  33. package/dist/projects/merge-upgrade.js +2 -2
  34. package/dist/projects/project-folder.js +2 -1
  35. package/dist/projects/save-command.js +51 -14
  36. package/dist/projects/workspace.js +6 -3
  37. package/dist/register/decision-rules.js +2 -1
  38. package/dist/register/export-files.js +18 -0
  39. package/dist/register/picture.js +3 -3
  40. package/dist/register/project-transport.js +125 -0
  41. package/dist/register/records.js +25 -13
  42. package/dist/register/rules-edit.js +5 -0
  43. package/dist/register/status-file/progress-chart.js +17 -6
  44. package/dist/register/status-file/register-map.js +8 -5
  45. package/dist/register/status-file/register-model.js +55 -36
  46. package/dist/register/status-file/register-parts.js +97 -82
  47. package/dist/register/status-file/register-status-file.js +16 -7
  48. package/dist/register/status-file/report-model.js +293 -205
  49. package/dist/register/status-file/team-css.js +1 -1
  50. package/dist/register/status-html.js +11 -4
  51. package/dist/register/store.js +2 -2
  52. package/dist/register/workbooks.js +24 -13
  53. package/dist/renderer/footer-line.js +10 -2
  54. package/dist/renderer/tool-labels.js +2 -2
  55. package/dist/repl/builtin-commands.js +1 -1
  56. package/dist/repl/continue-skill.js +5 -0
  57. package/dist/repl/level-command.js +6 -15
  58. package/dist/repl.js +37 -14
  59. package/dist/router/router-core.js +5 -3
  60. package/dist/session/fresh-start.js +15 -0
  61. package/dist/skill-catalog.js +4 -7
  62. package/dist/tools/_filesystem-shared.js +7 -0
  63. package/dist/tools/_team-work-path.js +53 -0
  64. package/dist/tools/approval.js +97 -19
  65. package/dist/tools/cut-markers.js +47 -0
  66. package/dist/tools/filesystem/extract-document.js +50 -2
  67. package/dist/tools/filesystem/file-edit.js +8 -1
  68. package/dist/tools/filesystem/file-read.js +2 -2
  69. package/dist/tools/filesystem/file-write.js +8 -1
  70. package/dist/tools/filesystem/read-document.js +20 -4
  71. package/dist/tools/project/_merge-shared.js +2 -2
  72. package/dist/tools/project/_team-tool-shared.js +5 -0
  73. package/dist/tools/project/rules_propose.js +2 -1
  74. package/dist/tools/project/team_findings.js +81 -28
  75. package/dist/tools/project/team_lane_move.js +127 -0
  76. package/dist/tools/project/upgrade_merge_progress.js +8 -4
  77. package/dist/tools/text-elements/tools.js +7 -1
  78. package/dist/tools/tool-loading.js +1 -1
  79. package/dist/tools/tool-output-read.js +5 -1
  80. package/dist/tools/update-method-intercept.js +18 -1
  81. package/dist/tools/write-pipeline.js +24 -5
  82. package/dist/ui/approval-modal.js +6 -3
  83. package/dist/ui/footer-line.js +19 -1
  84. package/package.json +3 -3
  85. package/dist/projects/merge-cca.js +0 -311
  86. package/dist/tools/project/cca_merge.js +0 -174
  87. package/test-harness/fe-draft/app.ts +0 -188
  88. package/test-harness/fe-draft/fe-draft.e2e.test.ts +0 -461
  89. package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +0 -51
  90. package/test-harness/fe-draft/routes.ts +0 -224
  91. package/test-harness/fe-draft/routes.unit.test.ts +0 -45
  92. package/test-harness/fe-draft/server.ts +0 -225
  93. 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: a Register in, plain data out. No DOM and no clock — `asOf` is handed
15
- * in. The honesty rules this file keeps (each pinned by a test):
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
- * - "fix proven" counts only objects the check moved to verified — an object
18
- * that was clean from its first check never had a fix to prove;
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 { PLAN_COLUMNS, inLane, isOutsideUsageWindowLine, planRows, provenForTarget, provenForTargetWords, targetsOf, usageCellOf, utcWords } from '@cspeach/register-core';
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
- const classOf = (o) => o.decision?.classification;
72
- const isKept = (o) => classOf(o) === 'keep';
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
- /** The variant the numbers come from: the project's, else the newest counted run's. */
82
- function variantOf(project, register) {
83
- const own = typeof project?.atcVariant === 'string' ? project.atcVariant.trim() : '';
84
- if (own)
85
- return own;
86
- const run = [...(register.runs ?? [])].reverse().find((r) => r.atcVariant);
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 function hasTarget(project, register) {
143
- if (targetsOf(project).length)
144
- return true;
145
- return register.objects.some((o) => (classOf(o) === 'fix' || classOf(o) === 'redesign') && o.decision?.targetBy && o.decision?.targetLevel);
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 = register.objects.filter((o) => isActive(o) && provenForTarget(o))
156
- .sort((a, b) => a.name.localeCompare(b.name, 'en')).map((o) => `${o.name}: ${provenForTargetWords(o)}.`);
157
- const whole = targetsOf(project).filter((t) => !t.packages);
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: judged.length, keptBelow,
161
- line: `${count(meets)} of ${plural(judged.length, 'object meets', 'objects meet')} your target${level}.`,
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(register) {
167
- const now = register.totals.byLevel.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
- /** Everything that waits for a person, in a fixed order of kinds; oldest first inside a kind. */
176
- export function decisionsNeeded(project, register) {
177
- const lead = project?.lead?.name ?? 'the lead';
178
- const who = (o) => ownerOf(register, o) ?? lead;
179
- const items = [];
180
- const add = (kind, o, text, decides, since) => items.push({ kind, key: o.key, name: o.name, text, decides, since: since ?? null });
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
- const claim = ms(lastClaimAt(o));
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 (provenForTarget(o)) {
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
- /** The rebuild group (review F17). Absent `evidence.rebuild` → planned from the decision only (I13). */
293
- export function rebuildGroup(register, asOf) {
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
- for (const o of register.objects) {
297
- const r = rebuildOf(o);
298
- if (o.state === 'retired' && r) {
299
- g.retired += 1;
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 (isRebuild(o) && o.state !== 'retired')
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: o.name, at: r.at });
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
- /** One row per lane, in lanes.json order — never sorted by a count (review F11). */
328
- export function laneRows(register, asOf) {
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 rowOf = (owner, laneId, objects) => {
331
- const claimed = objects.filter((o) => o.state === 'claimed');
332
- const claimTimes = claimed.map((o) => Math.max(...(o.fixes ?? []).filter((f) => f.status === 'fixed').map((f) => ms(f.at)).filter(Number.isFinite), -Infinity))
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((o) => o.package))].sort((a, b) => a.localeCompare(b, 'en'));
336
- const last = objects.map((o) => o.lastMeasuredAt).filter(Boolean).sort().pop() ?? null;
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: objects.length,
341
- decided: objects.filter((o) => o.decision).length,
342
- fixesProven: objects.filter((o) => o.state === 'verified').length,
343
- awaiting: claimed.length, oldestDays: oldest,
344
- kept: objects.filter(isKept).length,
345
- rebuilds: objects.filter((o) => isRebuild(o) || rebuildOf(o)).length,
346
- fixFailed: objects.filter((o) => o.state === 'did-not-hold' && !provenForTarget(o)).length,
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 twice = new Set(register.lanes.map((l) => l.owner).filter((o, i, all) => all.indexOf(o) !== i));
353
- const rows = register.lanes.map((l) => ({
354
- ...rowOf(twice.has(l.owner) ? `${l.owner} (${l.id})` : l.owner, l.id, register.objects.filter((o) => inLane(o, l.id))),
355
- // The lane's next objects, in the priority order every surface sorts by (register-core's `next`).
356
- next: (l.next ?? []).slice(0, 3).map((k) => k.slice(k.indexOf('|') + 1)),
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
- // Active objects only: a retired or out-of-scope object needs no owner.
359
- const unowned = register.objects.filter((o) => o.lane === null && isActive(o));
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
- function keptList(register) {
367
- return register.objects.filter((o) => isKept(o) && o.state !== 'retired').map((o) => ({
368
- name: o.name, type: o.type,
369
- line: `${o.name} kept${o.decision.level ? ` at ${o.decision.level}` : ''}${o.decision.exception ? ' as an accepted exception' : ''}, ${o.decision.reason ? 'reason recorded' : 'no reason recorded'}.`,
370
- why: o.decision.reason ?? '',
371
- by: `Decided by ${o.decision.by}${o.decision.at ? ` on ${dayWords(o.decision.at)}` : ''}.`,
372
- })).sort((a, b) => a.name.localeCompare(b.name, 'en'));
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": retired and out-of-scope objects are not checked. */
375
- function objectsSub(t) {
376
- const retired = t.byState?.retired ?? 0;
377
- const parts = [retired ? `${count(retired)} retired` : '', t.outOfScope ? `${count(t.outOfScope)} taken out of scope` : ''].filter(Boolean);
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(register) {
382
- const kept = register.objects.filter((o) => isKept(o) && o.state !== 'retired');
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
- * @param {{ project: object, register: object, asOf: string, previousAsOf?: string|null }} input
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({ project, register, asOf, previousAsOf = null }) {
391
- const t = register.totals;
392
- const coverage = coverageOf(project, register);
393
- const needed = decisionsNeeded(project, register);
394
- const kept = (t.byDisposition?.keep ?? 0) + (t.byDisposition?.exception ?? 0);
395
- const fixesProven = t.byState.verified ?? 0;
396
- const packages = new Set(register.objects.map((o) => o.package)).size;
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
- const scope = `${plural(t.objects, 'object', 'objects')} in ${plural(packages, 'package', 'packages')}`
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
- const fromFile = t.fromFile?.verified ?? 0;
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: t.objects, label: 'Objects', sub: objectsSub(t) },
405
- { id: 'decided', n: t.decided, label: 'Decided', sub: 'a person recorded what to do' },
406
- { id: 'fixesProven', n: fixesProven, label: 'Fixes proven', sub: fromFile ? `${count(fromFile)} of them measured from a file` : 'a check passed after the fix' },
407
- { id: 'awaiting', n: t.byState.claimed ?? 0, label: 'Fixed, awaiting check', sub: 'reported fixed, not checked since' },
408
- { id: 'kept', n: kept, label: 'Kept as is', sub: keptSub(register) },
409
- ...(rebuilds.total > 0 ? [{ id: 'rebuilds', n: rebuilds.total, label: 'Rebuilds', sub: 'decided to be built again' }] : []),
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 = register.objects.filter((o) => isActive(o) && !o.decision && (o.openFindings ?? 0) > 0).length;
413
- const headline = `${plural(fixesProven, 'fix', 'fixes')} proven by your check · ${plural(needed.length, 'decision', 'decisions')} waiting · ${count(kept)} kept by decision`
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(register),
419
- target: targetOf(project, register),
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
- kept: keptList(register),
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
- ['Developer', 'Lane packages', 'Objects', 'Decided', 'Fixes proven', 'Awaiting check', 'Kept', 'Rebuilds', 'Fix failed', 'Last check'],
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
- export const OBJECTS_EXTRA = ['Level now', 'Reason', 'Last check', 'Checked with'];
514
- /** The plan's "Level" is the level a person decided; beside "Level now" it says so. */
515
- export const OBJECTS_COLUMNS = [...PLAN_COLUMNS.map((c) => (c === 'Level' ? 'Level decided' : c)), ...OBJECTS_EXTRA];
516
- /** Objects: the Plan sheet's columns (so plan import reads it), then four read-only facts. */
517
- export function objectsSheetRows(register, variant) {
518
- const byKey = new Map(register.objects.map((o) => [`${o.type}|${o.name}`.toUpperCase(), o]));
519
- return planRows(register).map((row) => {
520
- const o = byKey.get(`${row[1]}|${row[0]}`.toUpperCase());
521
- return [...row, o?.levelNow ?? '', o?.decision?.reason ?? '', o?.lastMeasuredAt ? utcWords(o.lastMeasuredAt) : 'not checked', o?.lastMeasuredAt ? variant : ''];
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
- /** For the business: retire, rebuild and kept rows, with their evidence, and an empty Confirm column. */
526
- export function businessSheetRows(register) {
527
- const proposal = (o) => {
528
- if (o.retireCandidate)
529
- return 'retire (candidate)';
530
- const c = classOf(o);
531
- if (c === 'retire')
532
- return 'retire';
533
- if (c === 'redesign')
534
- return 'rebuild beside the core';
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
- const DECISION_WORD = { keep: 'keep', fix: 'fix', retire: 'retire', redesign: 'rebuild beside the core' };
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
- /** History: one event log from the rows the folder already holds (plan changes, decisions, fixes). Newest first. */
552
- export function historySheetRows(register) {
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
- rows.push([r.at, r.by, `check run: ${plural(r.findings ?? 0, 'finding', 'findings')}, ${r.atcVariant ?? 'variant not recorded'}${r.basis === 'file' ? ', from a file' : ''}`,
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
- rows.push([d.at, d.by, `${d.ruling ? 'ruling' : 'decision'}: ${DECISION_WORD[d.classification] ?? d.classification}${d.superseded ? ' (replaced later)' : ''}`, o.name, d.reason ?? '']);
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
- for (const f of o.fixes ?? [])
566
- rows.push([f.at, f.by, `${FIX_WORD[f.status] ?? `fix ${f.status}`}: ${f.rule}`, o.name, '']);
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
  }