@cspeach/cli 1.1.29 → 1.1.31
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/.cspeach/project.json +4 -0
- package/README.md +11 -1
- package/dist/agent/loop.js +127 -11
- package/dist/auth/auth-file.js +2 -2
- package/dist/cli.js +39 -13
- package/dist/commands/config-set.js +1 -1
- package/dist/commands/login.js +1 -1
- package/dist/commands/logout.js +1 -1
- package/dist/commands/plan-continue.js +1 -1
- package/dist/commands/plan-gate.js +8 -1
- package/dist/commands/plan-resume.js +24 -18
- package/dist/commands/plan-startup.js +49 -6
- package/dist/commands/team-discover.js +195 -0
- package/dist/commands/team-plan.js +585 -0
- package/dist/commands/team.js +652 -46
- package/dist/commands/whoami.js +1 -1
- package/dist/config/paths.js +15 -1
- package/dist/journey/chain.js +728 -0
- package/dist/journey/clean-core.js +213 -0
- package/dist/journey/flow.js +166 -0
- package/dist/journey/run.js +435 -0
- package/dist/journey/session-deps.js +77 -0
- package/dist/journey/trigger.js +153 -0
- package/dist/lane/app-chain.js +9 -7
- package/dist/lane/chain-token.js +9 -5
- package/dist/lane/roots.js +97 -41
- package/dist/one-shot.js +21 -1
- package/dist/projects/clean-core-confirm.js +192 -0
- package/dist/projects/detail-path.js +9 -4
- package/dist/projects/extract-clean-core.js +82 -0
- package/dist/projects/migration.js +4 -105
- package/dist/projects/plan-lane.js +67 -17
- package/dist/projects/plan-sources.js +13 -7
- package/dist/projects/project-folder.js +131 -0
- package/dist/projects/promote-command.js +39 -0
- package/dist/projects/save-command.js +115 -15
- package/dist/projects/saved-lines.js +17 -3
- package/dist/projects/sort-command.js +499 -0
- package/dist/projects/sort-core.js +248 -0
- package/dist/projects/validate.js +2 -1
- package/dist/projects/workspace-retire.js +48 -0
- package/dist/projects/workspace.js +182 -120
- package/dist/register/atc-file.js +9 -1
- package/dist/register/discover.js +901 -0
- package/dist/register/findings.js +82 -0
- package/dist/register/measure.js +60 -1
- package/dist/register/offer.js +125 -0
- package/dist/register/picture.js +25 -0
- package/dist/register/plan-import.js +325 -0
- package/dist/register/records.js +57 -0
- package/dist/register/status-file/estate-parts.js +168 -0
- package/dist/register/status-file/plan-parts.js +68 -0
- package/dist/register/status-file/progress-chart.js +110 -0
- package/dist/register/status-file/register-map.js +221 -0
- package/dist/register/status-file/register-model.js +341 -0
- package/dist/register/status-file/register-parts.js +555 -0
- package/dist/register/status-file/register-status-file.js +189 -0
- package/dist/register/status-file/team-css.js +5 -0
- package/dist/register/status-file-sync.js +22 -0
- package/dist/register/status-html.js +19 -0
- package/dist/register/store.js +40 -2
- package/dist/register/workbooks.js +124 -0
- package/dist/register/write-whole.js +22 -0
- package/dist/register/xlsx.js +127 -0
- package/dist/register/xref.js +117 -0
- package/dist/renderer/startup-lines.js +11 -0
- package/dist/renderer/tool-labels.js +3 -1
- package/dist/renderer/tool-widget.js +15 -1
- package/dist/repl/at-completer.js +6 -2
- package/dist/repl/at-picker.js +1 -1
- package/dist/repl/journey-entry.js +25 -0
- package/dist/repl.js +158 -39
- package/dist/session/backfill.js +342 -0
- package/dist/session/project-match.js +63 -0
- package/dist/session/resume.js +72 -24
- package/dist/session/schema.js +3 -1
- package/dist/session/store.js +96 -51
- package/dist/skill-catalog.js +2 -2
- package/dist/skills/bundled-skills.js +45 -45
- package/dist/tools/_filesystem-shared.js +190 -21
- package/dist/tools/_team-work-path.js +133 -0
- package/dist/tools/approval.js +7 -1
- package/dist/tools/filesystem/extract-xlsx.js +27 -3
- package/dist/tools/filesystem/file-edit.js +12 -4
- package/dist/tools/filesystem/file-read.js +8 -4
- package/dist/tools/filesystem/file-write.js +12 -4
- package/dist/tools/filesystem/glob.js +33 -8
- package/dist/tools/filesystem/grep.js +9 -4
- package/dist/tools/filesystem/read-document.js +6 -3
- package/dist/tools/project/cca_merge.js +8 -2
- package/dist/tools/project/team_estate.js +91 -0
- package/dist/tools/project/team_findings.js +145 -0
- package/dist/tools/project/upgrade_merge_progress.js +8 -2
- package/dist/tools/sap-read.js +21 -4
- package/dist/tools/sap-write.js +203 -5
- package/dist/tools/subagent/read_agent.js +3 -0
- package/dist/tools/tool-loading.js +2 -2
- package/dist/tools/transport-fit-check.js +117 -0
- package/dist/tools/transport-fit.js +15 -7
- package/dist/tools/transport.js +57 -2
- package/dist/ui/app.js +2 -2
- package/dist/ui/approval-modal.js +74 -6
- package/dist/ui/widgets/confirm-card.js +68 -5
- package/dist/util/open-in-browser.js +143 -0
- package/package.json +120 -120
package/dist/commands/team.js
CHANGED
|
@@ -18,12 +18,20 @@
|
|
|
18
18
|
import fs from 'node:fs';
|
|
19
19
|
import os from 'node:os';
|
|
20
20
|
import path from 'node:path';
|
|
21
|
-
import {
|
|
21
|
+
import { laneActiveOf, laneProvenOf, levelOf, normalizeRuleId, objectKey, } from '@cspeach/register-core';
|
|
22
22
|
import { LOCAL_PACKAGE, listCustomPackages, pullBaseline, BaselineError } from '../register/baseline.js';
|
|
23
|
-
import { BASELINE_FILE, TEAM_FILE, RegisterError, activeProject,
|
|
24
|
-
import { measure, noVariantYetMessage, scopeLabel, scopeObjects, variantMismatchMessage, } from '../register/measure.js';
|
|
23
|
+
import { BASELINE_FILE, TEAM_FILE, RegisterError, activeProject, defaultOutboxDir, loadState, projectIdFor, readBaseline, readEstate, readLanes, readProjectFile, saveState, sha256, VARIANT_NAME, writeLanes, writeProjectVariant, writerIdFor, } from '../register/store.js';
|
|
24
|
+
import { MAX_SECOND_PASS, measure, noVariantYetMessage, scopeLabel, scopeObjects, variantMismatchMessage, } from '../register/measure.js';
|
|
25
25
|
import { measureFromFile, shown, upToFive } from '../register/atc-file.js';
|
|
26
|
-
import { writeRecord } from '../register/records.js';
|
|
26
|
+
import { writeLeadRecord, writeRecord } from '../register/records.js';
|
|
27
|
+
import { findingsByObject } from '../register/findings.js';
|
|
28
|
+
import { registerFor } from '../register/picture.js';
|
|
29
|
+
import { HELD, combinedSheets, regenerateWorkingList, reportFileNames } from '../register/workbooks.js';
|
|
30
|
+
import { writeXlsxFile } from '../register/xlsx.js';
|
|
31
|
+
import { writeWhole } from '../register/write-whole.js';
|
|
32
|
+
import { statusHtmlFor } from '../register/status-html.js';
|
|
33
|
+
import { teamDiscover, evidenceRows, EVIDENCE_LINE, OUTSIDE_LINE } from './team-discover.js';
|
|
34
|
+
import { focusCommand, laneChangeRecord, laneProposal, nowLine, planCommand, priorityCommand, reasonFrom } from './team-plan.js';
|
|
27
35
|
const CLASSIFICATIONS = ['keep', 'fix', 'retire', 'redesign'];
|
|
28
36
|
const TYPES = ['clean-core', 'upgrade', 'build'];
|
|
29
37
|
/**
|
|
@@ -37,7 +45,16 @@ const TYPES = ['clean-core', 'upgrade', 'build'];
|
|
|
37
45
|
* compares this with the lead's name, a ruling only counts when it comes from
|
|
38
46
|
* the lead, and every record says who wrote it.
|
|
39
47
|
*/
|
|
40
|
-
const whoAmI = (deps) => activeProject()?.name ?? deps.identity().name;
|
|
48
|
+
export const whoAmI = (deps) => activeProject()?.name ?? deps.identity().name;
|
|
49
|
+
/** After a record this laptop wrote: its working list, made again. One line, or none. */
|
|
50
|
+
function refreshSheet(deps) {
|
|
51
|
+
const joined = activeProject();
|
|
52
|
+
if (!joined)
|
|
53
|
+
return;
|
|
54
|
+
const res = regenerateWorkingList(joined, whoAmI(deps), deps.now?.());
|
|
55
|
+
if (res.line)
|
|
56
|
+
deps.print(res.line);
|
|
57
|
+
}
|
|
41
58
|
/**
|
|
42
59
|
* Names that are a machine's, not a person's. A team project whose lead is
|
|
43
60
|
* "Admin" says nothing about who leads it, and the writer id, the rulings and
|
|
@@ -59,7 +76,7 @@ function looksLikeAMachine(name, deps) {
|
|
|
59
76
|
* and a verb that passes it also gets `--name=Wave`, the spelling `refresh`
|
|
60
77
|
* has always taken. The verbs that pass nothing read exactly as before.
|
|
61
78
|
*/
|
|
62
|
-
function flags(args, flagOnly = []) {
|
|
79
|
+
export function flags(args, flagOnly = []) {
|
|
63
80
|
const positional = [];
|
|
64
81
|
const opts = {};
|
|
65
82
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -82,7 +99,7 @@ function flags(args, flagOnly = []) {
|
|
|
82
99
|
}
|
|
83
100
|
return { positional, opts };
|
|
84
101
|
}
|
|
85
|
-
const list = (v) => (v ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
102
|
+
export const list = (v) => (v ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
86
103
|
/** "1 object", "3 objects" — every count this command prints reads as a sentence. */
|
|
87
104
|
const plural = (count, one, many) => `${count} ${count === 1 ? one : many}`;
|
|
88
105
|
/** `ZFI_*` style match, used only to expand --packages patterns against the real package list. */
|
|
@@ -95,15 +112,24 @@ export async function runTeamCommand(args, deps) {
|
|
|
95
112
|
try {
|
|
96
113
|
switch (sub) {
|
|
97
114
|
case 'init': return await init(rest, deps);
|
|
115
|
+
case 'start': return await start(rest, deps);
|
|
98
116
|
case 'join': return join(rest, deps);
|
|
99
|
-
case 'lane': return lane(rest, deps);
|
|
117
|
+
case 'lane': return await lane(rest, deps);
|
|
100
118
|
case 'refresh': return await refresh(rest, deps);
|
|
101
119
|
case 'status': return status(rest, deps);
|
|
102
120
|
case 'outbox': return outbox(rest, deps);
|
|
103
121
|
case 'rule': return rule(rest, deps);
|
|
104
122
|
case 'leave': return leave(deps);
|
|
123
|
+
case 'decide': return decide(rest, deps);
|
|
124
|
+
case 'fixed': return await fixed(rest, deps);
|
|
125
|
+
case 'sheet': return sheet(deps);
|
|
126
|
+
case 'report': return report(rest, deps);
|
|
127
|
+
case 'discover': return await teamDiscover(rest, deps);
|
|
128
|
+
case 'focus': return focusCommand(rest, deps);
|
|
129
|
+
case 'priority': return priorityCommand(rest, deps);
|
|
130
|
+
case 'plan': return await planCommand(rest, deps);
|
|
105
131
|
default:
|
|
106
|
-
deps.print('Usage: cspeach team init|join|lane|refresh|status|outbox|rule|leave (cspeach --help for details)');
|
|
132
|
+
deps.print('Usage: cspeach team init|join|lane|refresh|status|outbox|rule|leave|decide|fixed|sheet|report|discover|focus|priority|plan|start (cspeach --help for details)');
|
|
107
133
|
return sub ? 1 : 0;
|
|
108
134
|
}
|
|
109
135
|
}
|
|
@@ -112,16 +138,22 @@ export async function runTeamCommand(args, deps) {
|
|
|
112
138
|
deps.print(`✗ ${e.message}`);
|
|
113
139
|
return 1;
|
|
114
140
|
}
|
|
115
|
-
|
|
141
|
+
// Anything else (no SAP system configured, the connection refused, …) is one
|
|
142
|
+
// plain line too, never a stack trace (rehearsal 2026-10-07). CSPEACH_DEBUG=1
|
|
143
|
+
// still rethrows, so the trace is there when someone needs it.
|
|
144
|
+
if (process.env['CSPEACH_DEBUG'] === '1')
|
|
145
|
+
throw e;
|
|
146
|
+
deps.print(`✗ ${(e instanceof Error ? e.message : String(e)).split('\n')[0]}`);
|
|
147
|
+
return 1;
|
|
116
148
|
}
|
|
117
149
|
}
|
|
118
|
-
function requireLead(project, deps) {
|
|
150
|
+
export function requireLead(project, deps) {
|
|
119
151
|
const me = whoAmI(deps);
|
|
120
152
|
if (me.trim().toLowerCase() !== project.lead.name.trim().toLowerCase()) {
|
|
121
153
|
throw new RegisterError(`Only the team project lead (${project.lead.name}) changes this. You are ${me}.`);
|
|
122
154
|
}
|
|
123
155
|
}
|
|
124
|
-
function joinedOrThrow() {
|
|
156
|
+
export function joinedOrThrow() {
|
|
125
157
|
const joined = activeProject();
|
|
126
158
|
if (!joined)
|
|
127
159
|
throw new RegisterError('This laptop has not joined a team project. Run: cspeach team join <folder>');
|
|
@@ -129,11 +161,11 @@ function joinedOrThrow() {
|
|
|
129
161
|
}
|
|
130
162
|
// ── init ────────────────────────────────────────────────────────────────────
|
|
131
163
|
const INIT_USAGE = 'Usage: cspeach team init <folder> --name "<name>" [--lead "<your name>"] [--type clean-core|upgrade|build] '
|
|
132
|
-
+ '[--packages ZFI_*,ZSD_*] [--include-local] [--atc-variant <NAME>] [--no-measure]';
|
|
164
|
+
+ '[--packages ZFI_*,ZSD_*] [--include-local] [--atc-variant <NAME>] [--no-measure] [--no-from-discovery]';
|
|
133
165
|
/** Everything `init` understands. Anything else is a typo, and a typo is refused, never ignored. */
|
|
134
|
-
const INIT_OPTIONS = ['name', 'lead', 'type', 'packages', 'include-local', 'sap-alias', 'atc-variant', 'no-measure'];
|
|
166
|
+
const INIT_OPTIONS = ['name', 'lead', 'type', 'packages', 'include-local', 'sap-alias', 'atc-variant', 'no-measure', 'from-discovery', 'no-from-discovery'];
|
|
135
167
|
/** The two that take no value, so neither can eat the folder name. */
|
|
136
|
-
const INIT_FLAGS = ['include-local', 'no-measure'];
|
|
168
|
+
const INIT_FLAGS = ['include-local', 'no-measure', 'from-discovery', 'no-from-discovery'];
|
|
137
169
|
/** Typed on `init` out of muscle memory: they choose what to MEASURE, which is another verb. */
|
|
138
170
|
const REFRESH_ONLY = ['all', 'lane', 'force-variant', 'chunk', 'max-minutes', 'no-one-by-one', 'from-file', 'absent-means-clean'];
|
|
139
171
|
/**
|
|
@@ -159,6 +191,60 @@ function unknownInitOption(name) {
|
|
|
159
191
|
return said;
|
|
160
192
|
return `${said} Did you mean --${guess}?`;
|
|
161
193
|
}
|
|
194
|
+
const START_USAGE = 'Usage: cspeach team start <packages> [--name "<name>"] [--atc-variant <NAME>] [--folder <folder>] [--no-measure] [--sap-alias <name>]';
|
|
195
|
+
const START_OPTIONS = ['name', 'atc-variant', 'folder', 'no-measure', 'sap-alias'];
|
|
196
|
+
/** The check a project measures with when nobody names one. */
|
|
197
|
+
export const DEFAULT_ATC_VARIANT = 'ABAP_CLOUD_READINESS';
|
|
198
|
+
/**
|
|
199
|
+
* 1.1.31 (owner, 2026-10-07: "why doesn't CSPeach remember all that?") — one
|
|
200
|
+
* command for what init + join + lane add said in three: the folder you are
|
|
201
|
+
* in, a name from the packages, you as the lead, the default check, your
|
|
202
|
+
* results straight into the folder, and every object yours. The long verbs
|
|
203
|
+
* stay for big team setups.
|
|
204
|
+
*/
|
|
205
|
+
async function start(args, deps) {
|
|
206
|
+
const { positional, opts } = flags(args, ['no-measure']);
|
|
207
|
+
for (const name of Object.keys(opts)) {
|
|
208
|
+
if (!START_OPTIONS.includes(name))
|
|
209
|
+
throw new RegisterError(`--${name} is not an option of team start.\n${START_USAGE}`);
|
|
210
|
+
}
|
|
211
|
+
const packages = positional.join(',').split(',').map((p) => p.trim().toUpperCase()).filter(Boolean);
|
|
212
|
+
if (!packages.length)
|
|
213
|
+
throw new RegisterError(START_USAGE);
|
|
214
|
+
const dir = path.resolve(opts.folder && opts.folder !== 'true' ? opts.folder : process.cwd());
|
|
215
|
+
if (fs.existsSync(path.join(dir, TEAM_FILE)))
|
|
216
|
+
throw new RegisterError('This folder already holds a team project. Start a new one in an empty folder, or carry on with this one.');
|
|
217
|
+
const lead = deps.identity().name;
|
|
218
|
+
const name = opts.name && opts.name !== 'true' ? opts.name : packages.join(', ');
|
|
219
|
+
const variant = opts['atc-variant'] && opts['atc-variant'] !== 'true' ? opts['atc-variant'] : DEFAULT_ATC_VARIANT;
|
|
220
|
+
// The three verbs run as they always do; their detail is kept back and the
|
|
221
|
+
// few lines that matter are said once, below.
|
|
222
|
+
const said = [];
|
|
223
|
+
const quiet = { ...deps, print: (l) => said.push(l) };
|
|
224
|
+
const initArgs = [dir, '--name', name, '--lead', lead, '--packages', packages.join(','), '--atc-variant', variant,
|
|
225
|
+
...(opts['no-measure'] ? ['--no-measure'] : []), ...(opts['sap-alias'] && opts['sap-alias'] !== 'true' ? ['--sap-alias', opts['sap-alias']] : [])];
|
|
226
|
+
const code = await init(initArgs, quiet);
|
|
227
|
+
if (code !== 0) {
|
|
228
|
+
for (const l of said)
|
|
229
|
+
deps.print(l);
|
|
230
|
+
return code;
|
|
231
|
+
}
|
|
232
|
+
join([dir, '--outbox', dir], quiet);
|
|
233
|
+
const laneId = (lead.trim().split(/\s+/)[0] ?? '').toLowerCase().replace(/[^a-z0-9]/g, '') || 'me';
|
|
234
|
+
await lane(['add', laneId, '--owner', lead, '--packages', packages.join(',')], quiet);
|
|
235
|
+
const pulled = said.map((l) => /: (\d+) objects? in \d+ packages?\. Every package matched/.exec(l)).find(Boolean);
|
|
236
|
+
const measured = said.map((l) => /^✓ (\d+) objects? measured, (\d+) with findings/.exec(l)).find(Boolean);
|
|
237
|
+
const objects = pulled ? Number(pulled[1]) : readBaseline(dir).length;
|
|
238
|
+
deps.print(`✓ Started "${name}" in this folder. Lead: ${lead}.`);
|
|
239
|
+
deps.print(` ${plural(objects, 'object', 'objects')}, matched against the system's own count.`);
|
|
240
|
+
if (measured)
|
|
241
|
+
deps.print(` First check (${variant.toUpperCase()}): ${measured[2]} of ${measured[1]} objects with findings.`);
|
|
242
|
+
else if (opts['no-measure'])
|
|
243
|
+
deps.print(' No check run yet: cspeach team refresh measures it.');
|
|
244
|
+
deps.print(` You own all ${plural(objects, 'object', 'objects')}. Your results go straight into this folder.`);
|
|
245
|
+
deps.print(` Next, in CSPeach: /abap-clean-core triage lane ${laneId}`);
|
|
246
|
+
return 0;
|
|
247
|
+
}
|
|
162
248
|
async function init(args, deps) {
|
|
163
249
|
const { positional, opts } = flags(args, INIT_FLAGS);
|
|
164
250
|
// `--no-measur` must not read as "measure anyway": a person who meant to skip
|
|
@@ -191,6 +277,11 @@ async function init(args, deps) {
|
|
|
191
277
|
throw new RegisterError(`"${opts['atc-variant'].trim()}" is not an ATC check variant name.`);
|
|
192
278
|
if (!deps.connect)
|
|
193
279
|
throw new RegisterError('init needs a SAP connection to pull the object list.');
|
|
280
|
+
if (opts['from-discovery'] && opts['no-from-discovery'])
|
|
281
|
+
throw new RegisterError(`Say --from-discovery or --no-from-discovery, not both.
|
|
282
|
+
${INIT_USAGE}`);
|
|
283
|
+
// The newest discovery in this folder, when there is one and it is not turned off (spec 2026-10-06 §7).
|
|
284
|
+
const discovery = opts['no-from-discovery'] ? null : readEstate(dir);
|
|
194
285
|
const { sql, system, atc } = await deps.connect(opts['sap-alias']);
|
|
195
286
|
deps.print('Reading the package list from SAP…');
|
|
196
287
|
const all = await listCustomPackages(sql);
|
|
@@ -206,6 +297,9 @@ async function init(args, deps) {
|
|
|
206
297
|
if (!chosen.length) {
|
|
207
298
|
throw new RegisterError(`No package chosen. ${all.length} customer packages exist; name them with --packages, e.g. --packages "ZFI_*,ZSD_*".`);
|
|
208
299
|
}
|
|
300
|
+
if (discovery)
|
|
301
|
+
for (const l of estatePackageLines(chosen, discovery.estate))
|
|
302
|
+
deps.print(l);
|
|
209
303
|
deps.print(`Pulling ${chosen.length} package${chosen.length === 1 ? '' : 's'}, checking each against the system's own count…`);
|
|
210
304
|
const pull = await pullBaseline(sql, { packages: chosen });
|
|
211
305
|
if (!pull.objects.length)
|
|
@@ -225,9 +319,14 @@ async function init(args, deps) {
|
|
|
225
319
|
fs.writeFileSync(path.join(dir, BASELINE_FILE), baselineText);
|
|
226
320
|
fs.writeFileSync(path.join(dir, TEAM_FILE), `${JSON.stringify(project, null, 2)}\n`);
|
|
227
321
|
writeLanes(dir, projectId, []);
|
|
322
|
+
const evidenceLines = discovery || opts['from-discovery']
|
|
323
|
+
? firstEvidence(dir, projectId, pull.objects, discovery, { name: lead, writer: writerIdFor(given || deps.identity().name, deps.machine?.()) }, now, deps)
|
|
324
|
+
: [];
|
|
228
325
|
deps.print(`✓ ${name}: ${pull.objects.length} objects in ${chosen.length} packages. Every package matched the system's count.`);
|
|
229
326
|
deps.print(` Set aside, not in scope: ${project.setAside.local} customer-named local ($TMP) entries of any type, ${pull.setAside.generated} generated, ${pull.setAside.otherTypes} non-code entries.`);
|
|
230
327
|
deps.print(` Written to ${dir}`);
|
|
328
|
+
for (const l of evidenceLines)
|
|
329
|
+
deps.print(l);
|
|
231
330
|
// F4: the live run of 2026-09-20 recorded the lead as "Admin" — the Windows
|
|
232
331
|
// user. That name goes into the team project file, into the writer id and
|
|
233
332
|
// into every record from then on. Said once, never asked.
|
|
@@ -253,6 +352,62 @@ async function init(args, deps) {
|
|
|
253
352
|
deps.print(l);
|
|
254
353
|
return 0;
|
|
255
354
|
}
|
|
355
|
+
/**
|
|
356
|
+
* Per chosen package, what the estate knows beside the live count: its
|
|
357
|
+
* description, application area and last change. Packages the estate does
|
|
358
|
+
* not hold get the count alone.
|
|
359
|
+
*/
|
|
360
|
+
function estatePackageLines(chosen, estate) {
|
|
361
|
+
const known = new Map();
|
|
362
|
+
const pkgs = estate?.packages;
|
|
363
|
+
if (Array.isArray(pkgs))
|
|
364
|
+
for (const p of pkgs)
|
|
365
|
+
if (p && typeof p.name === 'string')
|
|
366
|
+
known.set(p.name, p);
|
|
367
|
+
return chosen.map((c) => {
|
|
368
|
+
const p = known.get(c.package);
|
|
369
|
+
const text = (v) => (typeof v === 'string' ? v.trim().slice(0, 300) : '');
|
|
370
|
+
const parts = [plural(c.objects, 'object', 'objects')];
|
|
371
|
+
if (p) {
|
|
372
|
+
if (text(p.text))
|
|
373
|
+
parts.push(text(p.text));
|
|
374
|
+
if (text(p.area))
|
|
375
|
+
parts.push(`area ${text(p.area)}`);
|
|
376
|
+
const at = text(p.lastChanged?.at);
|
|
377
|
+
if (at)
|
|
378
|
+
parts.push(`last changed ${at}${text(p.lastChanged?.by) ? ` by ${text(p.lastChanged.by)}` : ''}`);
|
|
379
|
+
}
|
|
380
|
+
return ` ${c.package}: ${parts.join(' · ')}`;
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* baseline/evidence-0.json (spec 2026-10-06 §7): the inventory's evidence for
|
|
385
|
+
* the team project's own objects, from the newest object list. Rows for other
|
|
386
|
+
* objects are never written; they are counted. Never fails `init`: the team
|
|
387
|
+
* project is complete without it.
|
|
388
|
+
*/
|
|
389
|
+
function firstEvidence(dir, projectId, baseline, discovery, by, now, deps) {
|
|
390
|
+
if (!discovery)
|
|
391
|
+
return [' No discovery in this folder, so no evidence was written. Run: cspeach team discover --out <folder>'];
|
|
392
|
+
const ev = evidenceRows(discovery.objects, new Set(baseline.map((o) => objectKey(o.type, o.name))));
|
|
393
|
+
if (!ev)
|
|
394
|
+
return [' The object list of the inventory could not be read, so no evidence was written.'];
|
|
395
|
+
try {
|
|
396
|
+
const file = `objects-${discovery.stamp}.json`;
|
|
397
|
+
let sum = '';
|
|
398
|
+
try {
|
|
399
|
+
sum = sha256(fs.readFileSync(path.join(dir, 'discovery', file), 'utf8'));
|
|
400
|
+
}
|
|
401
|
+
catch { /* the checksum is a nicety */ }
|
|
402
|
+
writeLeadRecord(path.join(dir, 'baseline'), projectId, 'evidence', { source: { file, sha256: sum } }, ev.rows, by, { now, fileName: 'evidence-0.json', ...(deps.rand ? { rand: deps.rand } : {}) });
|
|
403
|
+
}
|
|
404
|
+
catch (e) {
|
|
405
|
+
// Node's own message carries the full path, user name and all; the code says enough.
|
|
406
|
+
const code = e?.code;
|
|
407
|
+
return [` ! The inventory's evidence could not be written${code ? ` (${code})` : ''}. The team project itself is complete.`];
|
|
408
|
+
}
|
|
409
|
+
return [` ${EVIDENCE_LINE(ev.rows.length)}`, ...(ev.outside ? [` ${OUTSIDE_LINE(ev.outside)}`] : [])];
|
|
410
|
+
}
|
|
256
411
|
/** Errors that are about this laptop's disk or the shared folder, never about SAP. */
|
|
257
412
|
const CANNOT_WRITE = new Set(['EPERM', 'EACCES', 'EROFS', 'ENOSPC', 'EISDIR', 'ENOTEMPTY', 'EBUSY', 'EMFILE', 'ENOTDIR']);
|
|
258
413
|
/**
|
|
@@ -298,6 +453,7 @@ async function firstMeasurement(dir, project, baseline, opts, atc, deps) {
|
|
|
298
453
|
const res = await measure({
|
|
299
454
|
joined: joinedOrThrow(), project, baseline, lanes: [], scope: { kind: 'all' }, variant, atc, by: { name: whoAmI(deps) },
|
|
300
455
|
dir: path.join(dir, 'baseline'), fileName: 'measurement-0.json',
|
|
456
|
+
confirmAbsent: true,
|
|
301
457
|
...(deps.now ? { now: deps.now() } : {}), ...(deps.rand ? { rand: deps.rand } : {}),
|
|
302
458
|
signal: stop.signal,
|
|
303
459
|
onProgress: progressLine(deps),
|
|
@@ -312,7 +468,7 @@ async function firstMeasurement(dir, project, baseline, opts, atc, deps) {
|
|
|
312
468
|
// line: this one record belongs to the team project, not to this laptop.
|
|
313
469
|
if (res.file)
|
|
314
470
|
deps.print(' This first measurement is part of the team project\'s baseline, in the shared folder.');
|
|
315
|
-
const stoppedBy = res.stoppedBy ?? (stop.signal.aborted ? 'aborted' : undefined);
|
|
471
|
+
const stoppedBy = res.stoppedBy ?? secondPassStop(res) ?? (stop.signal.aborted ? 'aborted' : undefined);
|
|
316
472
|
if (stoppedBy) {
|
|
317
473
|
const why = STOPPED_WORDS[stoppedBy];
|
|
318
474
|
deps.print(res.file
|
|
@@ -356,6 +512,11 @@ function join(args, deps, asName) {
|
|
|
356
512
|
state.active = project.projectId;
|
|
357
513
|
saveState(state);
|
|
358
514
|
deps.print(`✓ Joined "${project.name}" (${objects} objects, lead ${project.lead.name}) as ${writerId}.`);
|
|
515
|
+
// Outbox is the team project folder itself: records land where everyone reads them.
|
|
516
|
+
if (path.relative(projectDir, outboxDir) === '') {
|
|
517
|
+
deps.print(' Your results are written straight into the team project folder, under writers. Nothing to hand in.');
|
|
518
|
+
return 0;
|
|
519
|
+
}
|
|
359
520
|
deps.print(` Your own results are recorded on this laptop, in ${outboxDir}`);
|
|
360
521
|
deps.print(' CSPeach does not send them anywhere. Hand them in by copying that folder\'s "writers" folder into the team project folder,');
|
|
361
522
|
deps.print(' or keep the outbox inside your own synced folder: cspeach team join <folder> --outbox <folder>');
|
|
@@ -374,8 +535,10 @@ function leave(deps) {
|
|
|
374
535
|
return 0;
|
|
375
536
|
}
|
|
376
537
|
// ── lane ────────────────────────────────────────────────────────────────────
|
|
377
|
-
function lane(args, deps) {
|
|
538
|
+
async function lane(args, deps) {
|
|
378
539
|
const [verb, ...rest] = args;
|
|
540
|
+
if (verb === 'propose')
|
|
541
|
+
return laneProposal(rest, deps);
|
|
379
542
|
const { positional, opts } = flags(rest);
|
|
380
543
|
const joined = joinedOrThrow();
|
|
381
544
|
const project = readProjectFile(joined.projectDir);
|
|
@@ -390,14 +553,20 @@ function lane(args, deps) {
|
|
|
390
553
|
requireLead(project, deps);
|
|
391
554
|
const id = positional[0];
|
|
392
555
|
if (!id)
|
|
393
|
-
throw new RegisterError('Usage: cspeach team lane add <id> --owner "<name>" --packages "ZFI_*" [--names "ZCL_TAX_*"] | lane remove <id>');
|
|
556
|
+
throw new RegisterError('Usage: cspeach team lane add <id> --owner "<name>" --packages "ZFI_*" [--names "ZCL_TAX_*"] [--reason "<why>"] | lane remove <id> [--reason "<why>"] | lane propose --developers "…"');
|
|
557
|
+
const reason = reasonFrom(opts, false);
|
|
394
558
|
if (verb === 'remove') {
|
|
395
559
|
writeLanes(joined.projectDir, project.projectId, lanes.filter((l) => l.id !== id));
|
|
396
560
|
deps.print(`✓ Lane ${id} removed.`);
|
|
561
|
+
const was = lanes.find((l) => l.id === id);
|
|
562
|
+
// With a reason, the change is also kept in the plan history; without one,
|
|
563
|
+
// `lane remove` says and writes exactly what it always did.
|
|
564
|
+
if (was && reason)
|
|
565
|
+
deps.print(laneChangeRecord(deps, 'remove', was, undefined, id, reason));
|
|
397
566
|
return 0;
|
|
398
567
|
}
|
|
399
568
|
if (verb !== 'add')
|
|
400
|
-
throw new RegisterError('lane: use add, remove or
|
|
569
|
+
throw new RegisterError('lane: use add, remove, list or propose.');
|
|
401
570
|
const next = { id, owner: opts.owner ?? '', packages: list(opts.packages), namePatterns: list(opts.names) };
|
|
402
571
|
if (!next.owner || (!next.packages.length && !next.namePatterns.length)) {
|
|
403
572
|
throw new RegisterError('A lane needs --owner and at least one of --packages or --names.');
|
|
@@ -415,27 +584,11 @@ function lane(args, deps) {
|
|
|
415
584
|
deps.print(` ! ${plural(reg.register.totals.overlapping, 'object now sits', 'objects now sit')} in more than one lane.`);
|
|
416
585
|
if (reg.register.totals.unowned)
|
|
417
586
|
deps.print(` ${plural(reg.register.totals.unowned, 'object still has', 'objects still have')} no owner.`);
|
|
587
|
+
if (reason)
|
|
588
|
+
deps.print(laneChangeRecord(deps, 'add', lanes.find((l) => l.id === id), next, id, reason));
|
|
418
589
|
return 0;
|
|
419
590
|
}
|
|
420
591
|
// ── status ──────────────────────────────────────────────────────────────────
|
|
421
|
-
function registerFor(projectDir, outboxDir) {
|
|
422
|
-
const project = readProjectFile(projectDir);
|
|
423
|
-
const collected = collectRecords(projectDir);
|
|
424
|
-
// This laptop's own outbox counts too, so a developer sees their work before
|
|
425
|
-
// handing it in. It is also the one way this picture can legitimately differ
|
|
426
|
-
// from the team project page's, so `status` says out loud when it counted them.
|
|
427
|
-
const own = outboxDir && path.resolve(outboxDir) !== path.resolve(projectDir) ? collectRecords(outboxDir) : { records: [], unreadable: 0, files: 0 };
|
|
428
|
-
const baseline = readBaseline(projectDir);
|
|
429
|
-
const lanes = readLanes(projectDir);
|
|
430
|
-
const register = buildRegister({ project, baseline, lanes, records: [...collected.records, ...own.records] });
|
|
431
|
-
return {
|
|
432
|
-
register, files: collected.files + own.files,
|
|
433
|
-
unreadable: collected.unreadable + own.unreadable, ownFiles: own.files,
|
|
434
|
-
// F2: a lanes.json written before names began to narrow packages holds
|
|
435
|
-
// fewer objects now. The lead is told, not left to work it out from a count.
|
|
436
|
-
narrowed: narrowedLanes(lanes, baseline),
|
|
437
|
-
};
|
|
438
|
-
}
|
|
439
592
|
const STATE_WORDS = [
|
|
440
593
|
['verified', 'verified'], ['clean', 'clean'], ['claimed', 'claimed, not measured since'], ['working', 'in work'],
|
|
441
594
|
['decided', 'decided, not started'], ['untouched', 'untouched'], ['did-not-hold', 'fix did not hold'],
|
|
@@ -451,11 +604,21 @@ function status(args, deps) {
|
|
|
451
604
|
const { register: r, files, unreadable, ownFiles, narrowed } = registerFor(projectDir, positional[0] ? undefined : joined?.outboxDir);
|
|
452
605
|
const t = r.totals;
|
|
453
606
|
deps.print(`${project.name} (${project.type}, lead ${project.lead.name})`);
|
|
607
|
+
// The estate around the team project, when the folder holds a discovery (spec 2026-10-06 §9).
|
|
608
|
+
if (r.estate) {
|
|
609
|
+
const e = r.estate.totals;
|
|
610
|
+
const n = (v) => v.toLocaleString('en-US');
|
|
611
|
+
deps.print(`Estate: ${n(e.packages)} ${e.packages === 1 ? 'package' : 'packages'}, `
|
|
612
|
+
+ `${n(e.codeObjects)} programs and other code objects, ${n(e.nonCodeObjects)} non-code objects, `
|
|
613
|
+
+ `${n(e.unknowns)} ${e.unknowns === 1 ? 'unknown' : 'unknowns'}`);
|
|
614
|
+
}
|
|
454
615
|
// A clean sheet out of a file is somebody's export, not this system speaking.
|
|
455
616
|
// The words "verified" and "clean" do not change; the line says how many of
|
|
456
617
|
// them were measured one step removed, so nobody reads them as live proof.
|
|
457
618
|
const fromFile = t.fromFile.verified + t.fromFile.clean;
|
|
619
|
+
// Recording fix 4: a clean object nobody decided has nothing to decide — said on its own.
|
|
458
620
|
deps.print(`${t.objects} objects, ${t.active} active. ${t.decided} decided. `
|
|
621
|
+
+ `${t.cleanNothingToDecide ? `${t.cleanNothingToDecide} clean, nothing to decide. ` : ''}`
|
|
459
622
|
+ `${t.byState.verified} verified, ${t.byState.clean} clean`
|
|
460
623
|
+ `${fromFile ? ` — ${fromFile} of them measured from a file` : ''}.`);
|
|
461
624
|
// The newest run that counted — by its time, not by the order the files were
|
|
@@ -464,6 +627,7 @@ function status(args, deps) {
|
|
|
464
627
|
deps.print(r.lastMeasuredAt
|
|
465
628
|
? `Last measured ${r.lastMeasuredAt.slice(0, 16).replace('T', ' ')} UTC${lastRun?.basis === 'file' ? ', from a file' : ''}.`
|
|
466
629
|
: 'Not measured yet: "verified" and "clean" need an ATC run.');
|
|
630
|
+
deps.print(nowLine(r));
|
|
467
631
|
deps.print('');
|
|
468
632
|
for (const [state, word] of STATE_WORDS)
|
|
469
633
|
if (t.byState[state])
|
|
@@ -476,9 +640,15 @@ function status(args, deps) {
|
|
|
476
640
|
deps.print(`${'Lane'.padEnd(12)} ${'Owner'.padEnd(20)} Objects Active Proven Last record`);
|
|
477
641
|
for (const l of r.lanes) {
|
|
478
642
|
deps.print(`${l.id.padEnd(12)} ${l.owner.padEnd(20)} ${String(l.objects).padStart(7)} `
|
|
479
|
-
+ `${String(
|
|
643
|
+
+ `${String(laneActiveOf(l)).padStart(6)} ${String(laneProvenOf(l)).padStart(6)} `
|
|
480
644
|
+ `${l.lastRecordAt ? l.lastRecordAt.slice(0, 10) : 'nothing yet'}`);
|
|
481
645
|
}
|
|
646
|
+
// Done and what comes next, by the same priority every surface sorts by.
|
|
647
|
+
// Names only: a lane id stands only beside its owner's name.
|
|
648
|
+
for (const l of r.lanes) {
|
|
649
|
+
const next = l.next.slice(0, 3).map((k) => k.slice(k.indexOf('|') + 1));
|
|
650
|
+
deps.print(` ${l.owner} (${l.id}): Done ${l.donePct}%. Next: ${next.length ? next.join(', ') : 'nothing left to prove'}`);
|
|
651
|
+
}
|
|
482
652
|
// Said once per lane it is true of. A lane set up before this rule held
|
|
483
653
|
// both a package list and a name list, and took the whole package.
|
|
484
654
|
for (const id of narrowed)
|
|
@@ -489,6 +659,10 @@ function status(args, deps) {
|
|
|
489
659
|
deps.print(`! ${plural(t.unowned, 'object has', 'objects have')} no owner.`);
|
|
490
660
|
if (t.overlapping)
|
|
491
661
|
deps.print(`! ${plural(t.overlapping, 'object sits', 'objects sit')} in more than one lane.`);
|
|
662
|
+
if (t.retireCandidates)
|
|
663
|
+
deps.print(`${plural(t.retireCandidates, 'retire candidate waits', 'retire candidates wait')} for the lead.`);
|
|
664
|
+
if (t.outOfScope)
|
|
665
|
+
deps.print(`${plural(t.outOfScope, 'object', 'objects')} out of scope.`);
|
|
492
666
|
if (t.conflicts) {
|
|
493
667
|
deps.print(`! ${plural(t.conflicts, 'object has a conflicting decision', 'objects have conflicting decisions')}.`
|
|
494
668
|
+ ` The lead settles ${t.conflicts === 1 ? 'it' : 'each'} with: cspeach team rule <TYPE> <NAME> <decision>`);
|
|
@@ -544,6 +718,7 @@ const sameText = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
|
|
|
544
718
|
*/
|
|
545
719
|
export const REFRESH_USAGE = 'Usage: cspeach team refresh (--lane <id> | --packages "ZFI_*,ZSD_*" | --all) '
|
|
546
720
|
+ '[--variant <NAME>] [--force-variant] [--chunk <n>] [--max-minutes <n>] [--no-one-by-one] [--sap-alias <name>]\n'
|
|
721
|
+
+ ' or: cspeach team refresh (--all | --packages "ZFI_*,ZSD_*") --per-package [--again] [--variant <NAME>]\n'
|
|
547
722
|
+ ' or: cspeach team refresh --from-file <file.csv> [--lane <id> | --packages "ZFI_*,ZSD_*" | --all] '
|
|
548
723
|
+ '[--variant <NAME>] [--absent-means-clean]';
|
|
549
724
|
/** Options that take a value, with the example each refusal shows. */
|
|
@@ -556,7 +731,7 @@ const VALUE_OPTIONS = {
|
|
|
556
731
|
'sap-alias': '--sap-alias S4H',
|
|
557
732
|
'from-file': '--from-file atc-export.csv',
|
|
558
733
|
};
|
|
559
|
-
const FLAG_OPTIONS = ['all', 'force-variant', 'no-one-by-one', 'absent-means-clean'];
|
|
734
|
+
const FLAG_OPTIONS = ['all', 'force-variant', 'no-one-by-one', 'absent-means-clean', 'per-package', 'again'];
|
|
560
735
|
const KNOWN_OPTIONS = [...Object.keys(VALUE_OPTIONS), ...FLAG_OPTIONS];
|
|
561
736
|
/** Options that describe a check RUN. A file was made by a run that is already over. */
|
|
562
737
|
const RUN_ONLY_OPTIONS = ['chunk', 'max-minutes', 'no-one-by-one', 'sap-alias'];
|
|
@@ -646,6 +821,11 @@ export function parseRefreshArgs(args) {
|
|
|
646
821
|
}
|
|
647
822
|
const given = ['all', 'lane', 'packages'].filter((k) => opts[k] !== undefined);
|
|
648
823
|
const fromFile = opts['from-file'];
|
|
824
|
+
if (opts['per-package'] !== undefined && (fromFile !== undefined || opts.lane !== undefined)) {
|
|
825
|
+
return no('--per-package goes with --all or --packages: it measures one package at a time.');
|
|
826
|
+
}
|
|
827
|
+
if (opts.again !== undefined && opts['per-package'] === undefined)
|
|
828
|
+
return no('--again only goes with --per-package.');
|
|
649
829
|
if (fromFile === undefined) {
|
|
650
830
|
if (given.length !== 1)
|
|
651
831
|
return no('Say what to measure, one of: --lane <id> --packages "ZFI_*,ZSD_*" --all');
|
|
@@ -689,6 +869,8 @@ export function parseRefreshArgs(args) {
|
|
|
689
869
|
oneByOne: opts['no-one-by-one'] === undefined,
|
|
690
870
|
absentMeansClean: opts['absent-means-clean'] !== undefined,
|
|
691
871
|
...(fromFile !== undefined ? { fromFile: fromFile.trim() } : {}),
|
|
872
|
+
...(opts['per-package'] !== undefined ? { perPackage: true } : {}),
|
|
873
|
+
...(opts.again !== undefined ? { again: true } : {}),
|
|
692
874
|
};
|
|
693
875
|
if (opts.variant !== undefined) {
|
|
694
876
|
const variant = opts.variant.trim().toUpperCase();
|
|
@@ -805,9 +987,49 @@ export const absentLine = (count, names) => [
|
|
|
805
987
|
+ `${count === 1 ? 'It is' : 'They are'} left as not measured.`,
|
|
806
988
|
...(names.length ? [` ${someNames(names)}`] : []),
|
|
807
989
|
];
|
|
990
|
+
/**
|
|
991
|
+
* R-1, said plainly: how many silent objects were checked again on their own,
|
|
992
|
+
* and — for the ones that were not — the true reason. An object the second pass
|
|
993
|
+
* skipped or never finished gets THIS reason, never the variant's.
|
|
994
|
+
*/
|
|
995
|
+
function secondPassLines(res, deps) {
|
|
996
|
+
const pass = res.secondPass;
|
|
997
|
+
if (!pass)
|
|
998
|
+
return;
|
|
999
|
+
if (pass.asked) {
|
|
1000
|
+
deps.print(` ${plural(pass.asked, 'object the run did not list was', 'objects the run did not list were')} checked again on `
|
|
1001
|
+
+ `${pass.asked === 1 ? 'its' : 'their'} own: ${pass.listed} listed.`);
|
|
1002
|
+
}
|
|
1003
|
+
const n = pass.notChecked ?? 0;
|
|
1004
|
+
const own = n === 1 ? 'on its own and stays' : 'on their own and stay';
|
|
1005
|
+
if (pass.stopped) {
|
|
1006
|
+
const why = STOPPED_WORDS[pass.stopped];
|
|
1007
|
+
deps.print(n
|
|
1008
|
+
? ` ! Checking those objects one by one ${why} before it finished: ${plural(n, 'object was', 'objects were')} not checked again ${own} not measured.`
|
|
1009
|
+
: ` ! Checking those objects one by one ${why} before it finished.`);
|
|
1010
|
+
}
|
|
1011
|
+
else if (n) {
|
|
1012
|
+
deps.print(` ! ${plural(n, 'object was', 'objects were')} not checked again: ${n === 1 ? 'its own check' : 'their own checks'} did not finish. `
|
|
1013
|
+
+ `${n === 1 ? 'It stays' : 'They stay'} not measured.`);
|
|
1014
|
+
}
|
|
1015
|
+
if (pass.skipped && pass.skippedBy === 'time') {
|
|
1016
|
+
deps.print(` ! ${plural(pass.skipped, 'object the run did not list was', 'objects the run did not list were')} not checked again on `
|
|
1017
|
+
+ `${pass.skipped === 1 ? 'its' : 'their'} own: the time budget ran out first. ${pass.skipped === 1 ? 'It stays' : 'They stay'} not measured.`);
|
|
1018
|
+
}
|
|
1019
|
+
else if (pass.skipped) {
|
|
1020
|
+
deps.print(` ! ${plural(pass.skipped, 'more object was', 'more objects were')} not checked again `
|
|
1021
|
+
+ `(one measurement checks up to ${MAX_SECOND_PASS} on their own). Narrow the run with --lane or --packages to check the rest.`);
|
|
1022
|
+
}
|
|
1023
|
+
if ((n || pass.skipped) && res.notCheckedNames?.length)
|
|
1024
|
+
deps.print(` ${someNames(res.notCheckedNames)}`);
|
|
1025
|
+
}
|
|
808
1026
|
function notMeasuredLines(res, deps, words = {}) {
|
|
809
|
-
|
|
810
|
-
|
|
1027
|
+
// Objects the second pass skipped or never finished were said above, with
|
|
1028
|
+
// their own reason. Only the rest are the variant's silence.
|
|
1029
|
+
const unchecked = new Set(res.notCheckedNames ?? []);
|
|
1030
|
+
const silent = Math.max(0, res.absent - (res.secondPass?.skipped ?? 0) - (res.secondPass?.notChecked ?? 0));
|
|
1031
|
+
if (silent) {
|
|
1032
|
+
for (const line of words.absentLines?.(silent) ?? absentLine(silent, res.absentNames.filter((k) => !unchecked.has(k))))
|
|
811
1033
|
deps.print(line);
|
|
812
1034
|
}
|
|
813
1035
|
for (const c of res.refusedChunks) {
|
|
@@ -826,10 +1048,12 @@ export function reportMeasurement(res, variant, deps, foreignTo, words = {}) {
|
|
|
826
1048
|
deps.print(words.nothing ?? '✗ Nothing was measured, so nothing was written.');
|
|
827
1049
|
for (const note of words.notes ?? [])
|
|
828
1050
|
deps.print(note);
|
|
1051
|
+
secondPassLines(res, deps);
|
|
829
1052
|
notMeasuredLines(res, deps, words);
|
|
830
1053
|
return;
|
|
831
1054
|
}
|
|
832
1055
|
deps.print(words.headline ?? `✓ ${plural(res.measured, 'object measured', 'objects measured')}, ${res.withFindings} with findings.`);
|
|
1056
|
+
secondPassLines(res, deps);
|
|
833
1057
|
// F3: SAP reports some findings twice — same place, same message, two finding
|
|
834
1058
|
// ids. One problem in the code is one finding, so it is counted once; a
|
|
835
1059
|
// person comparing this with their own ATC screen has to be told why the two
|
|
@@ -850,6 +1074,11 @@ export function reportMeasurement(res, variant, deps, foreignTo, words = {}) {
|
|
|
850
1074
|
deps.print(` Written to ${res.file}`);
|
|
851
1075
|
deps.print(' Next: cspeach team status');
|
|
852
1076
|
}
|
|
1077
|
+
/**
|
|
1078
|
+
* A second pass that was cut short, or never had time to start, leaves objects
|
|
1079
|
+
* not checked again: the measurement is partial, and the exit code says so.
|
|
1080
|
+
*/
|
|
1081
|
+
const secondPassStop = (res) => res.secondPass?.stopped ?? (res.secondPass?.skippedBy === 'time' && res.secondPass.skipped ? 'deadline' : undefined);
|
|
853
1082
|
/** Why a run ended before it reached the end of the set, in plain words. */
|
|
854
1083
|
const STOPPED_WORDS = {
|
|
855
1084
|
deadline: 'ran out of time',
|
|
@@ -881,7 +1110,7 @@ function stillStanding(register, absentNames) {
|
|
|
881
1110
|
* `once`, on purpose: the SECOND press is Node's own, so a request that has
|
|
882
1111
|
* hung can always be quit.
|
|
883
1112
|
*/
|
|
884
|
-
function onInterrupt(deps, stop) {
|
|
1113
|
+
export function onInterrupt(deps, stop) {
|
|
885
1114
|
if (deps.onInterrupt)
|
|
886
1115
|
return deps.onInterrupt(stop);
|
|
887
1116
|
const handler = () => stop();
|
|
@@ -998,6 +1227,7 @@ function refreshFromFile(opts, deps) {
|
|
|
998
1227
|
});
|
|
999
1228
|
if (!res.file)
|
|
1000
1229
|
return 1;
|
|
1230
|
+
refreshSheet(deps);
|
|
1001
1231
|
// Live evidence outranks file evidence, per object. An import that changes no
|
|
1002
1232
|
// state is legal and is kept — but nobody should have to work that out from
|
|
1003
1233
|
// the picture afterwards, and no closing line may read as a fresh measurement.
|
|
@@ -1020,6 +1250,16 @@ async function refresh(args, deps) {
|
|
|
1020
1250
|
deps.print(CANNOT_MEASURE);
|
|
1021
1251
|
return 2;
|
|
1022
1252
|
}
|
|
1253
|
+
// 1.1.31: no scope said → your own lane, when you own exactly one.
|
|
1254
|
+
if (!args.some((a) => /^--(all|lane|packages|from-file)(=|$)/.test(a))) {
|
|
1255
|
+
const joinedNow = activeProject();
|
|
1256
|
+
if (joinedNow) {
|
|
1257
|
+
const me = whoAmI(deps).trim().toLowerCase();
|
|
1258
|
+
const mine = readLanes(joinedNow.projectDir).filter((l) => l.owner.trim().toLowerCase() === me);
|
|
1259
|
+
if (mine.length === 1)
|
|
1260
|
+
args = [...args, '--lane', mine[0].id];
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1023
1263
|
const parsed = parseRefreshArgs(args);
|
|
1024
1264
|
if (!parsed.ok)
|
|
1025
1265
|
throw new RegisterError(parsed.message);
|
|
@@ -1057,6 +1297,8 @@ async function refresh(args, deps) {
|
|
|
1057
1297
|
deps.print(CANNOT_MEASURE);
|
|
1058
1298
|
return 2;
|
|
1059
1299
|
}
|
|
1300
|
+
if (opts.perPackage)
|
|
1301
|
+
return refreshPerPackage({ joined, project, baseline, lanes, inScope, variant, foreign, persist, atc, opts }, deps);
|
|
1060
1302
|
deps.print(`Measuring ${scopeWords(scope)} with ${variant}: ${plural(inScope.length, 'object', 'objects')}.`);
|
|
1061
1303
|
const stop = new AbortController();
|
|
1062
1304
|
const off = onInterrupt(deps, () => {
|
|
@@ -1079,6 +1321,7 @@ async function refresh(args, deps) {
|
|
|
1079
1321
|
// the lead's first run, whose name is not on disk yet.
|
|
1080
1322
|
...(foreign || persist ? { forceVariant: true } : {}),
|
|
1081
1323
|
signal: stop.signal,
|
|
1324
|
+
confirmAbsent: true,
|
|
1082
1325
|
onProgress: progressLine(deps),
|
|
1083
1326
|
});
|
|
1084
1327
|
}
|
|
@@ -1086,6 +1329,8 @@ async function refresh(args, deps) {
|
|
|
1086
1329
|
off();
|
|
1087
1330
|
}
|
|
1088
1331
|
reportMeasurement(res, variant, deps, foreign ? (project.atcVariant ?? '').trim().toUpperCase() : undefined);
|
|
1332
|
+
if (res.file)
|
|
1333
|
+
refreshSheet(deps);
|
|
1089
1334
|
// The picture, once, for the two things said after the report. Built lazily:
|
|
1090
1335
|
// a refresh that measured nothing and reported nothing does not need it.
|
|
1091
1336
|
let merged = null;
|
|
@@ -1111,7 +1356,7 @@ async function refresh(args, deps) {
|
|
|
1111
1356
|
+ `Ask the lead to try again: ${e instanceof Error ? e.message : String(e)}`);
|
|
1112
1357
|
}
|
|
1113
1358
|
}
|
|
1114
|
-
const stoppedBy = res.stoppedBy ?? (stop.signal.aborted ? 'aborted' : undefined);
|
|
1359
|
+
const stoppedBy = res.stoppedBy ?? secondPassStop(res) ?? (stop.signal.aborted ? 'aborted' : undefined);
|
|
1115
1360
|
if (stoppedBy) {
|
|
1116
1361
|
const why = STOPPED_WORDS[stoppedBy];
|
|
1117
1362
|
deps.print(res.file
|
|
@@ -1129,6 +1374,117 @@ async function refresh(args, deps) {
|
|
|
1129
1374
|
}
|
|
1130
1375
|
return 0;
|
|
1131
1376
|
}
|
|
1377
|
+
/**
|
|
1378
|
+
* D6: the estate measured one package at a time, one measurement record each,
|
|
1379
|
+
* through the same `measure()` as every run. A package that already has a
|
|
1380
|
+
* counted record of this variant for exactly its own scope is skipped unless
|
|
1381
|
+
* `--again`, so a long estate run can be stopped and taken up again. Ctrl-C
|
|
1382
|
+
* stops between packages; every record already written stays.
|
|
1383
|
+
*/
|
|
1384
|
+
async function refreshPerPackage(input, deps) {
|
|
1385
|
+
const { joined, project, baseline, lanes, inScope, variant, foreign, persist, atc, opts } = input;
|
|
1386
|
+
const packages = [...new Set(inScope.map((o) => o.package))].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
1387
|
+
const labelOf = (pkg) => scopeLabel({ kind: 'packages', patterns: [pkg] });
|
|
1388
|
+
// A package is done only when a counted run from the system (never a result file),
|
|
1389
|
+
// with this variant and this package as its scope, says it reached every object:
|
|
1390
|
+
// nothing failed, nothing left not run. A run cut short by Ctrl-C, the deadline or
|
|
1391
|
+
// a lost connection wrote a partial record, and that package is measured again.
|
|
1392
|
+
const done = new Set();
|
|
1393
|
+
if (!opts.again) {
|
|
1394
|
+
for (const run of registerFor(joined.projectDir, joined.outboxDir).register.runs) {
|
|
1395
|
+
if ((run.atcVariant ?? '').trim().toUpperCase() !== variant || !run.scope || run.basis !== 'system')
|
|
1396
|
+
continue;
|
|
1397
|
+
const c = run.coverage;
|
|
1398
|
+
// notRun must be said and be 0: a record that does not say is not proof the run got everywhere.
|
|
1399
|
+
if (c && c.asked > 0 && c.failed === 0 && c.notRun === 0
|
|
1400
|
+
&& c.measured + (c.unsupported ?? 0) + (c.absent ?? 0) >= c.asked)
|
|
1401
|
+
done.add(run.scope);
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
const todo = packages.filter((p) => !done.has(labelOf(p)));
|
|
1405
|
+
const skipped = packages.length - todo.length;
|
|
1406
|
+
if (skipped) {
|
|
1407
|
+
deps.print(`Skipped ${plural(skipped, 'package', 'packages')} already measured (use --again to measure ${skipped === 1 ? 'it' : 'them'} again).`);
|
|
1408
|
+
}
|
|
1409
|
+
if (!todo.length)
|
|
1410
|
+
return 0;
|
|
1411
|
+
deps.print(`Measuring ${plural(todo.length, 'package', 'packages')} one at a time with ${variant}. Ctrl-C stops between packages and keeps what is written.`);
|
|
1412
|
+
// Ctrl-C is checked between packages, never handed into a package's run: a package
|
|
1413
|
+
// is measured whole or not at all. (A deadline or a lost connection can still cut a
|
|
1414
|
+
// package short; its record then shows it, and the next run measures it again.)
|
|
1415
|
+
const stop = new AbortController();
|
|
1416
|
+
const off = onInterrupt(deps, () => {
|
|
1417
|
+
if (stop.signal.aborted)
|
|
1418
|
+
return;
|
|
1419
|
+
deps.print(' Stopping after this package. What has already been measured will still be written.');
|
|
1420
|
+
deps.print(' Press Ctrl-C again to quit now.');
|
|
1421
|
+
stop.abort();
|
|
1422
|
+
});
|
|
1423
|
+
let measuredPackages = 0;
|
|
1424
|
+
let written = 0;
|
|
1425
|
+
let variantSaved = false;
|
|
1426
|
+
let lostConnection = false;
|
|
1427
|
+
try {
|
|
1428
|
+
for (const pkg of todo) {
|
|
1429
|
+
if (stop.signal.aborted)
|
|
1430
|
+
break;
|
|
1431
|
+
const objects = inScope.filter((o) => o.package === pkg).length;
|
|
1432
|
+
deps.print(`${pkg}: ${plural(objects, 'object', 'objects')}…`);
|
|
1433
|
+
const res = await measure({
|
|
1434
|
+
joined, project, baseline, lanes, scope: { kind: 'packages', patterns: [pkg] }, variant, atc, by: { name: whoAmI(deps) },
|
|
1435
|
+
...(deps.now ? { now: deps.now() } : {}), ...(deps.rand ? { rand: deps.rand } : {}),
|
|
1436
|
+
...(opts.chunkSize ? { chunkSize: opts.chunkSize } : {}),
|
|
1437
|
+
...(opts.maxTotalMs ? { maxTotalMs: opts.maxTotalMs } : {}),
|
|
1438
|
+
...(opts.oneByOne ? {} : { oneByOne: false }),
|
|
1439
|
+
...(foreign || persist ? { forceVariant: true } : {}),
|
|
1440
|
+
confirmAbsent: true,
|
|
1441
|
+
onProgress: progressLine(deps),
|
|
1442
|
+
});
|
|
1443
|
+
measuredPackages += 1;
|
|
1444
|
+
if (res.file) {
|
|
1445
|
+
written += 1;
|
|
1446
|
+
deps.print(` ✓ ${pkg}: ${plural(res.measured, 'object measured', 'objects measured')}, ${res.withFindings} with findings. Written: ${path.basename(res.file)}`);
|
|
1447
|
+
}
|
|
1448
|
+
else {
|
|
1449
|
+
deps.print(` ✗ ${pkg}: nothing was measured, so nothing was written.`);
|
|
1450
|
+
}
|
|
1451
|
+
secondPassLines(res, deps);
|
|
1452
|
+
notMeasuredLines(res, deps);
|
|
1453
|
+
if (res.file && persist && !variantSaved) {
|
|
1454
|
+
try {
|
|
1455
|
+
writeProjectVariant(joined.projectDir, variant);
|
|
1456
|
+
variantSaved = true;
|
|
1457
|
+
deps.print(`✓ This team project now measures with ${variant}.`);
|
|
1458
|
+
}
|
|
1459
|
+
catch (e) {
|
|
1460
|
+
deps.print(` ! The measurement is written, but ${TEAM_FILE} could not be updated, so this team project still has no check variant. `
|
|
1461
|
+
+ `Ask the lead to try again: ${e instanceof Error ? e.message : String(e)}`);
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
if (res.stoppedBy === 'connection') {
|
|
1465
|
+
lostConnection = true;
|
|
1466
|
+
break;
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
finally {
|
|
1471
|
+
off();
|
|
1472
|
+
}
|
|
1473
|
+
if (foreign) {
|
|
1474
|
+
deps.print(` ! Made with ${variant}, not the team project's ${(project.atcVariant ?? '').trim().toUpperCase()}. `
|
|
1475
|
+
+ 'The records are kept, but they are not counted in the picture.');
|
|
1476
|
+
}
|
|
1477
|
+
if (written)
|
|
1478
|
+
refreshSheet(deps);
|
|
1479
|
+
if (stop.signal.aborted || lostConnection) {
|
|
1480
|
+
deps.print(`✗ ${lostConnection ? 'Lost the connection to SAP' : 'Stopped'} after ${measuredPackages} of ${todo.length} packages. What was measured is written. `
|
|
1481
|
+
+ 'Run the same command again to measure the rest.');
|
|
1482
|
+
return 3;
|
|
1483
|
+
}
|
|
1484
|
+
deps.print(`✓ ${plural(written, 'package', 'packages')} measured, one record each.`);
|
|
1485
|
+
deps.print(' Next: cspeach team status');
|
|
1486
|
+
return written ? 0 : 1;
|
|
1487
|
+
}
|
|
1132
1488
|
// ── outbox / rule ───────────────────────────────────────────────────────────
|
|
1133
1489
|
function outbox(_args, deps) {
|
|
1134
1490
|
const joined = joinedOrThrow();
|
|
@@ -1147,15 +1503,25 @@ function outbox(_args, deps) {
|
|
|
1147
1503
|
deps.print('Each file is plain JSON: object names, decisions, statuses. No source code. Open one in Notepad to check.');
|
|
1148
1504
|
return 0;
|
|
1149
1505
|
}
|
|
1506
|
+
const RETIRE_RULING_NEEDS_REASON = 'A retire is confirmed with a reason: --reason "no callers (index read OK); usage unknown on this system; owner confirms"';
|
|
1150
1507
|
function rule(args, deps) {
|
|
1151
1508
|
const { positional, opts } = flags(args);
|
|
1152
1509
|
const [type, name, classification] = positional;
|
|
1153
1510
|
if (!type || !name || !CLASSIFICATIONS.includes(classification)) {
|
|
1154
|
-
throw new RegisterError(`Usage: cspeach team rule <TYPE> <NAME> ${CLASSIFICATIONS.join('|')} [--reason "<why>"]`);
|
|
1155
|
-
}
|
|
1511
|
+
throw new RegisterError(`Usage: cspeach team rule <TYPE> <NAME> ${CLASSIFICATIONS.join('|')} [--reason "<why>"] (a retire needs the reason)`);
|
|
1512
|
+
}
|
|
1513
|
+
// Read the way `decide` reads one: trimmed, a bare flag refused, at most 300.
|
|
1514
|
+
if (opts.reason === 'true')
|
|
1515
|
+
throw new RegisterError('--reason needs words, for example: --reason "reads BSID directly"');
|
|
1516
|
+
const reason = (opts.reason ?? '').trim();
|
|
1517
|
+
if (reason.length > MAX_REASON)
|
|
1518
|
+
throw new RegisterError(`Keep the reason under ${MAX_REASON} characters.`);
|
|
1519
|
+
// The lead's retire is what makes an object retired (D2), so it carries what was checked.
|
|
1520
|
+
if (classification === 'retire' && !reason)
|
|
1521
|
+
throw new RegisterError(RETIRE_RULING_NEEDS_REASON);
|
|
1156
1522
|
const joined = joinedOrThrow();
|
|
1157
1523
|
requireLead(readProjectFile(joined.projectDir), deps);
|
|
1158
|
-
const res = writeRecord(joined, 'ruling', [{ key: objectKey(type, name), classification, ...(
|
|
1524
|
+
const res = writeRecord(joined, 'ruling', [{ key: objectKey(type, name), classification, ...(reason ? { reason } : {}) }], { name: whoAmI(deps) }, {
|
|
1159
1525
|
dir: path.join(joined.projectDir, 'rulings'), now: deps.now?.(),
|
|
1160
1526
|
...(deps.rand ? { rand: deps.rand } : {}),
|
|
1161
1527
|
});
|
|
@@ -1164,3 +1530,243 @@ function rule(args, deps) {
|
|
|
1164
1530
|
deps.print(`✓ Ruled ${objectKey(type, name)} = ${classification}.`);
|
|
1165
1531
|
return 0;
|
|
1166
1532
|
}
|
|
1533
|
+
// ── decide ──────────────────────────────────────────────────────────────────
|
|
1534
|
+
const DECIDE_USAGE = 'Usage: cspeach team decide <TYPE> <NAME> keep|fix|retire|rebuild|exception [--level A-D] [--target A-D] [--reason "<why>"]';
|
|
1535
|
+
const DECIDE_OPTIONS = ['level', 'target', 'reason'];
|
|
1536
|
+
/** The five words a person types, onto the four classifications the files hold (spec §4.2). */
|
|
1537
|
+
const DECIDE_WORDS = {
|
|
1538
|
+
keep: { classification: 'keep' },
|
|
1539
|
+
exception: { classification: 'keep', exception: true },
|
|
1540
|
+
fix: { classification: 'fix' },
|
|
1541
|
+
rebuild: { classification: 'redesign' },
|
|
1542
|
+
redesign: { classification: 'redesign' },
|
|
1543
|
+
retire: { classification: 'retire' },
|
|
1544
|
+
};
|
|
1545
|
+
const MAX_REASON = 300;
|
|
1546
|
+
export function parseDecideArgs(args) {
|
|
1547
|
+
const { positional, opts } = flags(args);
|
|
1548
|
+
for (const name of Object.keys(opts)) {
|
|
1549
|
+
if (!DECIDE_OPTIONS.includes(name))
|
|
1550
|
+
return { ok: false, message: `--${name} is not an option of team decide.\n${DECIDE_USAGE}` };
|
|
1551
|
+
}
|
|
1552
|
+
const [type, name, word] = positional;
|
|
1553
|
+
const said = word ? DECIDE_WORDS[word.toLowerCase()] : undefined;
|
|
1554
|
+
if (!type || !name || !said || positional.length > 3)
|
|
1555
|
+
return { ok: false, message: DECIDE_USAGE };
|
|
1556
|
+
const letter = (flag) => {
|
|
1557
|
+
if (opts[flag] === undefined)
|
|
1558
|
+
return { ok: true };
|
|
1559
|
+
const value = levelOf(opts[flag].trim().toUpperCase());
|
|
1560
|
+
return value ? { ok: true, value } : { ok: false };
|
|
1561
|
+
};
|
|
1562
|
+
const level = letter('level');
|
|
1563
|
+
const target = letter('target');
|
|
1564
|
+
if (!level.ok || !target.ok)
|
|
1565
|
+
return { ok: false, message: 'A level is one of A, B, C or D. A uses only released APIs; D modifies SAP standard.' };
|
|
1566
|
+
const moves = said.classification === 'fix' || said.classification === 'redesign';
|
|
1567
|
+
if (target.value && !moves)
|
|
1568
|
+
return { ok: false, message: 'A target level only goes with fix or rebuild.' };
|
|
1569
|
+
if (opts.reason === 'true')
|
|
1570
|
+
return { ok: false, message: '--reason needs words, for example: --reason "reads BSID directly"' };
|
|
1571
|
+
const reason = (opts.reason ?? '').trim();
|
|
1572
|
+
if (reason.length > MAX_REASON)
|
|
1573
|
+
return { ok: false, message: `Keep the reason under ${MAX_REASON} characters.` };
|
|
1574
|
+
if (said.exception && !reason)
|
|
1575
|
+
return { ok: false, message: 'An accepted exception needs a reason: --reason "<why it stays>"' };
|
|
1576
|
+
if (said.classification === 'retire' && !reason) {
|
|
1577
|
+
return { ok: false, message: 'A retire needs a reason: say what was checked, for example: --reason "no callers (index read OK); usage unknown on this system"' };
|
|
1578
|
+
}
|
|
1579
|
+
return {
|
|
1580
|
+
ok: true,
|
|
1581
|
+
value: {
|
|
1582
|
+
type: type.toUpperCase(), name: name.toUpperCase(),
|
|
1583
|
+
row: {
|
|
1584
|
+
classification: said.classification,
|
|
1585
|
+
...(said.exception ? { exception: true } : {}),
|
|
1586
|
+
...(level.value ? { level: level.value } : {}),
|
|
1587
|
+
// Spec §4.2: a fix or a rebuild reaches A unless the person says otherwise.
|
|
1588
|
+
...(moves ? { targetLevel: target.value ?? 'A' } : {}),
|
|
1589
|
+
...(reason ? { reason } : {}),
|
|
1590
|
+
},
|
|
1591
|
+
},
|
|
1592
|
+
};
|
|
1593
|
+
}
|
|
1594
|
+
const DECISION_WORDS = { keep: 'keep', fix: 'fix', redesign: 'rebuild beside the core', retire: 'retire' };
|
|
1595
|
+
function decisionWords(row) {
|
|
1596
|
+
const parts = [row.exception ? 'accepted exception' : DECISION_WORDS[row.classification]];
|
|
1597
|
+
if (row.level)
|
|
1598
|
+
parts.push(`level ${row.level}`);
|
|
1599
|
+
if (row.targetLevel)
|
|
1600
|
+
parts.push(`target ${row.targetLevel}`);
|
|
1601
|
+
return `${parts.join(', ')}${row.reason ? `, because: ${row.reason}` : ''}`;
|
|
1602
|
+
}
|
|
1603
|
+
function decide(args, deps) {
|
|
1604
|
+
const parsed = parseDecideArgs(args);
|
|
1605
|
+
if (!parsed.ok)
|
|
1606
|
+
throw new RegisterError(parsed.message);
|
|
1607
|
+
const joined = joinedOrThrow();
|
|
1608
|
+
const { type, name, row } = parsed.value;
|
|
1609
|
+
const res = writeRecord(joined, 'decision', [{ key: objectKey(type, name), ...row }], { name: whoAmI(deps) }, {
|
|
1610
|
+
...(deps.now ? { now: deps.now() } : {}), ...(deps.rand ? { rand: deps.rand } : {}),
|
|
1611
|
+
});
|
|
1612
|
+
if (!res.file)
|
|
1613
|
+
throw new RegisterError(`${type} ${name} is not in this team project's object list.`);
|
|
1614
|
+
deps.print(`✓ Recorded: ${type} ${name} — ${decisionWords(row)}.`);
|
|
1615
|
+
if (row.classification === 'retire')
|
|
1616
|
+
deps.print('Recorded as a retire candidate. It becomes retired when the lead confirms it.');
|
|
1617
|
+
refreshSheet(deps);
|
|
1618
|
+
return 0;
|
|
1619
|
+
}
|
|
1620
|
+
// ── fixed ───────────────────────────────────────────────────────────────────
|
|
1621
|
+
const FIXED_USAGE = 'Usage: cspeach team fixed <TYPE> <NAME> [--rule <id>] [--transport <request>]';
|
|
1622
|
+
const utcLine = (iso) => `${iso.slice(0, 16).replace('T', ' ')} UTC`;
|
|
1623
|
+
/**
|
|
1624
|
+
* "I fixed this" — a claim, matched to a finding by (object, rule). It moves no
|
|
1625
|
+
* colour to proven: only the next measurement does (spec §4.3).
|
|
1626
|
+
*/
|
|
1627
|
+
async function fixed(args, deps) {
|
|
1628
|
+
const { positional, opts } = flags(args);
|
|
1629
|
+
for (const name of Object.keys(opts)) {
|
|
1630
|
+
if (name !== 'rule' && name !== 'transport')
|
|
1631
|
+
throw new RegisterError(`--${name} is not an option of team fixed.\n${FIXED_USAGE}`);
|
|
1632
|
+
}
|
|
1633
|
+
const [rawType, rawName] = positional;
|
|
1634
|
+
if (!rawType || !rawName || positional.length > 2)
|
|
1635
|
+
throw new RegisterError(FIXED_USAGE);
|
|
1636
|
+
const type = rawType.toUpperCase();
|
|
1637
|
+
const name = rawName.toUpperCase();
|
|
1638
|
+
const joined = joinedOrThrow();
|
|
1639
|
+
const key = objectKey(type, name);
|
|
1640
|
+
if (!readBaseline(joined.projectDir).some((o) => objectKey(o.type, o.name) === key)) {
|
|
1641
|
+
// `shown()`: the person's own arguments, still printed through CSPeach.
|
|
1642
|
+
throw new RegisterError(`${shown(type)} ${shown(name)} is not in this team project's object list.`);
|
|
1643
|
+
}
|
|
1644
|
+
let transport;
|
|
1645
|
+
if (opts.transport !== undefined) {
|
|
1646
|
+
transport = opts.transport === 'true' ? '' : opts.transport.trim().toUpperCase();
|
|
1647
|
+
if (!/^[A-Z0-9]{1,20}$/.test(transport))
|
|
1648
|
+
throw new RegisterError('--transport needs the request number, for example: --transport S4HK903500');
|
|
1649
|
+
}
|
|
1650
|
+
let rules;
|
|
1651
|
+
if (opts.rule !== undefined) {
|
|
1652
|
+
const rule = opts.rule === 'true' ? '' : normalizeRuleId(opts.rule);
|
|
1653
|
+
if (!rule)
|
|
1654
|
+
throw new RegisterError('--rule needs the rule id, for example: --rule "API:BSID"');
|
|
1655
|
+
rules = [rule];
|
|
1656
|
+
}
|
|
1657
|
+
else {
|
|
1658
|
+
const f = findingsByObject(joined.projectDir, joined.outboxDir).get(key);
|
|
1659
|
+
if (!f?.latestAt)
|
|
1660
|
+
throw new RegisterError('Nothing measured yet to claim against; name the rule with --rule.');
|
|
1661
|
+
if (!f.latest.length) {
|
|
1662
|
+
deps.print(`Nothing is open for ${type} ${name} at its last measurement (${utcLine(f.latestAt)}). Nothing was recorded.`);
|
|
1663
|
+
return 0;
|
|
1664
|
+
}
|
|
1665
|
+
deps.print(`Open for ${type} ${name} at its last measurement, ${utcLine(f.latestAt)}:`);
|
|
1666
|
+
for (const r of f.latest.slice(0, 10))
|
|
1667
|
+
deps.print(` ${r.count}× ${r.rule}${r.message ? ` — ${r.message}` : ''}`);
|
|
1668
|
+
if (f.latest.length > 10)
|
|
1669
|
+
deps.print(` and ${f.latest.length - 10} more`);
|
|
1670
|
+
if (!deps.ask)
|
|
1671
|
+
throw new RegisterError('Marking findings fixed needs a yes. In a script, name each rule with --rule.');
|
|
1672
|
+
const total = f.latest.reduce((n, r) => n + r.count, 0);
|
|
1673
|
+
const answer = (await deps.ask(`Mark these ${plural(total, 'finding', 'findings')} as fixed? [y/n]`)).trim().toLowerCase();
|
|
1674
|
+
if (answer !== 'y' && answer !== 'yes') {
|
|
1675
|
+
deps.print('Nothing was recorded.');
|
|
1676
|
+
return 1;
|
|
1677
|
+
}
|
|
1678
|
+
rules = f.latest.map((r) => r.rule);
|
|
1679
|
+
}
|
|
1680
|
+
writeRecord(joined, 'fix', rules.map((rule) => ({ key, rule, status: 'fixed', ...(transport ? { transport } : {}) })), { name: whoAmI(deps) }, { ...(deps.now ? { now: deps.now() } : {}), ...(deps.rand ? { rand: deps.rand } : {}) });
|
|
1681
|
+
deps.print(`✓ Claimed: ${type} ${name}, ${plural(rules.length, 'rule', 'rules')} marked fixed. A claim is not proof — the next cspeach team refresh checks it.`);
|
|
1682
|
+
refreshSheet(deps);
|
|
1683
|
+
return 0;
|
|
1684
|
+
}
|
|
1685
|
+
// ── sheet ───────────────────────────────────────────────────────────────────
|
|
1686
|
+
function sheet(deps) {
|
|
1687
|
+
const joined = joinedOrThrow();
|
|
1688
|
+
const res = regenerateWorkingList(joined, whoAmI(deps), deps.now?.());
|
|
1689
|
+
if (!res.ownsLane) {
|
|
1690
|
+
deps.print('You own no lane in this team project, so there is no working list to write. The lead adds lanes with: cspeach team lane add');
|
|
1691
|
+
return 0;
|
|
1692
|
+
}
|
|
1693
|
+
if (res.line)
|
|
1694
|
+
deps.print(res.line.trimStart());
|
|
1695
|
+
return res.file ? 0 : 1;
|
|
1696
|
+
}
|
|
1697
|
+
// ── report ──────────────────────────────────────────────────────────────────
|
|
1698
|
+
export const heldReportLine = (name) => ` ! "${name}" is open in another program, so it was not updated. Close it and run: cspeach team report`;
|
|
1699
|
+
/**
|
|
1700
|
+
* The combined workbook and the status file, made from the team project folder
|
|
1701
|
+
* (the same files the team page reads) and written whole, then renamed, into
|
|
1702
|
+
* `outDir`. Used by `report` and by `plan import`, which makes them again so the
|
|
1703
|
+
* lead sees the new export stamp.
|
|
1704
|
+
*
|
|
1705
|
+
* A report open in Excel is the likeliest failure here: Windows refuses the
|
|
1706
|
+
* rename. Each file is tried on its own, so one held file never costs the
|
|
1707
|
+
* other, and the caller is told by file name which one was not updated.
|
|
1708
|
+
* writeWhole has already removed its .tmp when it throws.
|
|
1709
|
+
*/
|
|
1710
|
+
export function writeReports(projectDir, project, outDir, deps) {
|
|
1711
|
+
const picture = registerFor(projectDir);
|
|
1712
|
+
const asOf = (deps.now?.() ?? new Date()).toISOString();
|
|
1713
|
+
const folderName = path.basename(projectDir);
|
|
1714
|
+
const names = reportFileNames(project.name);
|
|
1715
|
+
// The discovery file as the folder holds it: the status file says "could not be read"
|
|
1716
|
+
// for one register-core cannot read, the way the page does.
|
|
1717
|
+
const discovery = readEstate(projectDir);
|
|
1718
|
+
const held = [];
|
|
1719
|
+
const attempt = (name, write) => {
|
|
1720
|
+
try {
|
|
1721
|
+
write();
|
|
1722
|
+
return true;
|
|
1723
|
+
}
|
|
1724
|
+
catch (e) {
|
|
1725
|
+
if (!HELD.has(e?.code ?? ''))
|
|
1726
|
+
throw e;
|
|
1727
|
+
held.push(name);
|
|
1728
|
+
return false;
|
|
1729
|
+
}
|
|
1730
|
+
};
|
|
1731
|
+
const saved = [
|
|
1732
|
+
attempt(names.xlsx, () => writeXlsxFile(path.join(outDir, names.xlsx), combinedSheets(picture.register, asOf, folderName))) ? names.xlsx : null,
|
|
1733
|
+
attempt(names.html, () => writeWhole(path.join(outDir, names.html), statusHtmlFor({
|
|
1734
|
+
project, register: picture.register, files: picture.files, unreadable: picture.unreadable,
|
|
1735
|
+
checksum: project.baseline?.sha256 ? 'ok' : 'not-checked', asOf, folderName,
|
|
1736
|
+
...(discovery ? { estate: discovery.estate } : {}),
|
|
1737
|
+
}))) ? names.html : null,
|
|
1738
|
+
].filter((n) => n !== null);
|
|
1739
|
+
return { saved, held };
|
|
1740
|
+
}
|
|
1741
|
+
const REPORT_USAGE = 'Usage: cspeach team report [--out <folder>]';
|
|
1742
|
+
/**
|
|
1743
|
+
* Lead only (spec §4.5). Reads the team project folder — the same files the team
|
|
1744
|
+
* page reads, so its numbers are the page's — and writes two files whole, then
|
|
1745
|
+
* renamed, into `reports/` or the folder named with --out. Never `writers/`.
|
|
1746
|
+
*/
|
|
1747
|
+
function report(args, deps) {
|
|
1748
|
+
const { positional, opts } = flags(args);
|
|
1749
|
+
if (positional.length || Object.keys(opts).some((k) => k !== 'out') || opts.out === 'true')
|
|
1750
|
+
throw new RegisterError(REPORT_USAGE);
|
|
1751
|
+
const joined = joinedOrThrow();
|
|
1752
|
+
const project = readProjectFile(joined.projectDir);
|
|
1753
|
+
requireLead(project, deps);
|
|
1754
|
+
const outDir = opts.out ? path.resolve(opts.out) : path.join(joined.projectDir, 'reports');
|
|
1755
|
+
const { saved, held } = writeReports(joined.projectDir, project, outDir, deps);
|
|
1756
|
+
const where = opts.out ? 'the folder you named' : 'the reports folder of the team project';
|
|
1757
|
+
if (saved.length)
|
|
1758
|
+
deps.print(`✓ Saved ${saved.map((n) => `"${n}"`).join(' and ')} in ${where}.`);
|
|
1759
|
+
for (const name of held)
|
|
1760
|
+
deps.print(heldReportLine(name));
|
|
1761
|
+
// Records on this laptop that have not reached the folder are not in the report.
|
|
1762
|
+
const ownDir = path.join(joined.outboxDir, 'writers', joined.writerId, 'records');
|
|
1763
|
+
const inFolder = path.join(joined.projectDir, 'writers', joined.writerId, 'records');
|
|
1764
|
+
if (path.resolve(ownDir) !== path.resolve(inFolder) && fs.existsSync(ownDir)) {
|
|
1765
|
+
const missing = fs.readdirSync(ownDir).filter((f) => f.endsWith('.json') && !fs.existsSync(path.join(inFolder, f))).length;
|
|
1766
|
+
if (missing) {
|
|
1767
|
+
deps.print(` ${plural(missing, 'record file on this laptop is', 'record files on this laptop are')} not in the team project folder yet, `
|
|
1768
|
+
+ `so ${missing === 1 ? 'it is' : 'they are'} not in this report.`);
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
return held.length ? 1 : 0;
|
|
1772
|
+
}
|