@cspeach/cli 1.1.14 → 1.1.15

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.
@@ -0,0 +1,1166 @@
1
+ /**
2
+ * `cspeach team …` — a team project from the terminal
3
+ * (spec: docs/superpowers/specs/2026-09-18-project-register-folder-mode.md §6).
4
+ *
5
+ * init <folder> lead: pull the baseline from SAP, write the team project folder
6
+ * join <folder> everyone: start recording against a team project
7
+ * lane … lead: who owns which packages
8
+ * refresh … anyone connected: measure a lane, packages or everything with ATC
9
+ * status [folder] anyone: the register as text
10
+ * outbox what this laptop has recorded, and where
11
+ * rule … lead: settle a disagreement
12
+ * leave stop recording
13
+ *
14
+ * Every path written here is one a person typed. Nothing is written to a
15
+ * network location on CSPeach's own initiative, and none of this is reachable
16
+ * from a model tool.
17
+ */
18
+ import fs from 'node:fs';
19
+ import os from 'node:os';
20
+ import path from 'node:path';
21
+ import { activeOf, buildRegister, narrowedLanes, objectKey, provenOf, } from '@cspeach/register-core';
22
+ import { LOCAL_PACKAGE, listCustomPackages, pullBaseline, BaselineError } from '../register/baseline.js';
23
+ import { BASELINE_FILE, TEAM_FILE, RegisterError, activeProject, collectRecords, defaultOutboxDir, loadState, projectIdFor, readBaseline, readLanes, readProjectFile, saveState, sha256, VARIANT_NAME, writeLanes, writeProjectVariant, writerIdFor, } from '../register/store.js';
24
+ import { measure, noVariantYetMessage, scopeLabel, scopeObjects, variantMismatchMessage, } from '../register/measure.js';
25
+ import { measureFromFile, shown, upToFive } from '../register/atc-file.js';
26
+ import { writeRecord } from '../register/records.js';
27
+ const CLASSIFICATIONS = ['keep', 'fix', 'retire', 'redesign'];
28
+ const TYPES = ['clean-core', 'upgrade', 'build'];
29
+ /**
30
+ * Who this laptop is, for the team project it has joined.
31
+ *
32
+ * The name a person gave with `team init --lead` wins over the one the machine
33
+ * offers, and only for that team project. Without one, nothing changes: the
34
+ * name comes from `deps.identity()` exactly as it always has.
35
+ *
36
+ * It matters everywhere, not only in the team project file: `requireLead`
37
+ * compares this with the lead's name, a ruling only counts when it comes from
38
+ * the lead, and every record says who wrote it.
39
+ */
40
+ const whoAmI = (deps) => activeProject()?.name ?? deps.identity().name;
41
+ /**
42
+ * Names that are a machine's, not a person's. A team project whose lead is
43
+ * "Admin" says nothing about who leads it, and the writer id, the rulings and
44
+ * every record carry that name from then on.
45
+ */
46
+ const MACHINE_NAMES = new Set(['admin', 'administrator', 'user', 'root']);
47
+ function looksLikeAMachine(name, deps) {
48
+ const said = name.trim().toLowerCase();
49
+ if (!said)
50
+ return true;
51
+ if (MACHINE_NAMES.has(said))
52
+ return true;
53
+ const machine = (deps.machine?.() ?? os.hostname()).trim().toLowerCase();
54
+ return said === machine;
55
+ }
56
+ /**
57
+ * The loose parser the older verbs share. `flagOnly` names the options that
58
+ * take no value, so `--no-measure <folder>` cannot swallow the folder name —
59
+ * and a verb that passes it also gets `--name=Wave`, the spelling `refresh`
60
+ * has always taken. The verbs that pass nothing read exactly as before.
61
+ */
62
+ function flags(args, flagOnly = []) {
63
+ const positional = [];
64
+ const opts = {};
65
+ for (let i = 0; i < args.length; i++) {
66
+ const a = args[i];
67
+ if (!a.startsWith('--')) {
68
+ positional.push(a);
69
+ continue;
70
+ }
71
+ const equals = flagOnly.length ? a.indexOf('=') : -1;
72
+ if (equals > 2) {
73
+ opts[a.slice(2, equals)] = a.slice(equals + 1) || 'true';
74
+ continue;
75
+ }
76
+ const name = a.slice(2);
77
+ if (flagOnly.includes(name)) {
78
+ opts[name] = 'true';
79
+ continue;
80
+ }
81
+ opts[name] = args[i + 1] && !args[i + 1].startsWith('--') ? args[++i] : 'true';
82
+ }
83
+ return { positional, opts };
84
+ }
85
+ const list = (v) => (v ?? '').split(',').map((s) => s.trim()).filter(Boolean);
86
+ /** "1 object", "3 objects" — every count this command prints reads as a sentence. */
87
+ const plural = (count, one, many) => `${count} ${count === 1 ? one : many}`;
88
+ /** `ZFI_*` style match, used only to expand --packages patterns against the real package list. */
89
+ function matches(pattern, value) {
90
+ const re = new RegExp(`^${pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`, 'i');
91
+ return re.test(value);
92
+ }
93
+ export async function runTeamCommand(args, deps) {
94
+ const [sub, ...rest] = args;
95
+ try {
96
+ switch (sub) {
97
+ case 'init': return await init(rest, deps);
98
+ case 'join': return join(rest, deps);
99
+ case 'lane': return lane(rest, deps);
100
+ case 'refresh': return await refresh(rest, deps);
101
+ case 'status': return status(rest, deps);
102
+ case 'outbox': return outbox(rest, deps);
103
+ case 'rule': return rule(rest, deps);
104
+ case 'leave': return leave(deps);
105
+ default:
106
+ deps.print('Usage: cspeach team init|join|lane|refresh|status|outbox|rule|leave (cspeach --help for details)');
107
+ return sub ? 1 : 0;
108
+ }
109
+ }
110
+ catch (e) {
111
+ if (e instanceof RegisterError || e instanceof BaselineError) {
112
+ deps.print(`✗ ${e.message}`);
113
+ return 1;
114
+ }
115
+ throw e;
116
+ }
117
+ }
118
+ function requireLead(project, deps) {
119
+ const me = whoAmI(deps);
120
+ if (me.trim().toLowerCase() !== project.lead.name.trim().toLowerCase()) {
121
+ throw new RegisterError(`Only the team project lead (${project.lead.name}) changes this. You are ${me}.`);
122
+ }
123
+ }
124
+ function joinedOrThrow() {
125
+ const joined = activeProject();
126
+ if (!joined)
127
+ throw new RegisterError('This laptop has not joined a team project. Run: cspeach team join <folder>');
128
+ return joined;
129
+ }
130
+ // ── init ────────────────────────────────────────────────────────────────────
131
+ 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]';
133
+ /** 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'];
135
+ /** The two that take no value, so neither can eat the folder name. */
136
+ const INIT_FLAGS = ['include-local', 'no-measure'];
137
+ /** Typed on `init` out of muscle memory: they choose what to MEASURE, which is another verb. */
138
+ const REFRESH_ONLY = ['all', 'lane', 'force-variant', 'chunk', 'max-minutes', 'no-one-by-one', 'from-file', 'absent-means-clean'];
139
+ /**
140
+ * What `init` says about an option it does not know.
141
+ *
142
+ * A guess must never mean the opposite of what was typed: `--measure` answered
143
+ * with "did you mean --no-measure?" sends a person the other way. So the edit
144
+ * distance allowed grows with the length of the word, `no-measure` is offered
145
+ * only to a word that already starts with "no", and the three options people
146
+ * really reach for get an answer of their own.
147
+ */
148
+ function unknownInitOption(name) {
149
+ const said = `Unknown option --${name}.`;
150
+ if (name === 'measure')
151
+ return `${said} init measures by default when it can. Use --no-measure to skip it.`;
152
+ if (name === 'variant')
153
+ return `${said} Did you mean --atc-variant?`;
154
+ if (REFRESH_ONLY.includes(name)) {
155
+ return `${said} That option belongs to cspeach team refresh, which measures. init only creates the team project.`;
156
+ }
157
+ const guess = near(name, INIT_OPTIONS, Math.min(3, Math.floor(name.length / 4) + 1));
158
+ if (!guess || (guess === 'no-measure' && !name.toLowerCase().startsWith('no')))
159
+ return said;
160
+ return `${said} Did you mean --${guess}?`;
161
+ }
162
+ async function init(args, deps) {
163
+ const { positional, opts } = flags(args, INIT_FLAGS);
164
+ // `--no-measur` must not read as "measure anyway": a person who meant to skip
165
+ // the first measurement would be told nothing and would wait for a check run.
166
+ for (const name of Object.keys(opts)) {
167
+ if (INIT_OPTIONS.includes(name))
168
+ continue;
169
+ throw new RegisterError(`${unknownInitOption(name)}\n${INIT_USAGE}`);
170
+ }
171
+ const folder = positional[0];
172
+ if (!folder)
173
+ throw new RegisterError(INIT_USAGE);
174
+ const dir = path.resolve(folder);
175
+ if (fs.existsSync(path.join(dir, TEAM_FILE)))
176
+ throw new RegisterError(`${dir} already holds a team project. Pick an empty folder.`);
177
+ const name = opts.name ?? path.basename(dir);
178
+ // F4: the lead's own name, said once, or the one the machine offers. `true`
179
+ // is what the parser gives `--lead` with nothing after it.
180
+ const given = opts.lead && opts.lead !== 'true' ? opts.lead.trim().slice(0, 80) : '';
181
+ if (opts.lead !== undefined && !given)
182
+ throw new RegisterError(`--lead needs a name, for example: --lead "David Carter"\n${INIT_USAGE}`);
183
+ const lead = given || deps.identity().name;
184
+ const type = (opts.type ?? 'clean-core');
185
+ if (!TYPES.includes(type))
186
+ throw new RegisterError(`--type must be one of: ${TYPES.join(', ')}`);
187
+ // One variant per team project (D6), named here or never. A name SAP could
188
+ // not accept is refused before a folder exists, not after it is written in.
189
+ const atcVariant = opts['atc-variant'] && opts['atc-variant'] !== 'true' ? opts['atc-variant'].trim().toUpperCase() : '';
190
+ if (atcVariant && !VARIANT_NAME.test(atcVariant))
191
+ throw new RegisterError(`"${opts['atc-variant'].trim()}" is not an ATC check variant name.`);
192
+ if (!deps.connect)
193
+ throw new RegisterError('init needs a SAP connection to pull the object list.');
194
+ const { sql, system, atc } = await deps.connect(opts['sap-alias']);
195
+ deps.print('Reading the package list from SAP…');
196
+ const all = await listCustomPackages(sql);
197
+ const patterns = list(opts.packages);
198
+ let chosen = all.filter((p) => patterns.some((pat) => matches(pat, p.package)));
199
+ if (!patterns.length && deps.choosePackages) {
200
+ const picked = new Set(await deps.choosePackages(all));
201
+ chosen = all.filter((p) => picked.has(p.package));
202
+ }
203
+ const local = all.find((p) => p.package === LOCAL_PACKAGE)?.objects ?? 0;
204
+ if (!opts['include-local'])
205
+ chosen = chosen.filter((p) => p.package !== LOCAL_PACKAGE);
206
+ if (!chosen.length) {
207
+ throw new RegisterError(`No package chosen. ${all.length} customer packages exist; name them with --packages, e.g. --packages "ZFI_*,ZSD_*".`);
208
+ }
209
+ deps.print(`Pulling ${chosen.length} package${chosen.length === 1 ? '' : 's'}, checking each against the system's own count…`);
210
+ const pull = await pullBaseline(sql, { packages: chosen });
211
+ if (!pull.objects.length)
212
+ throw new RegisterError('Those packages hold no code objects. Nothing was written.');
213
+ const now = (deps.now ?? (() => new Date()))();
214
+ const projectId = projectIdFor(name, deps.rand);
215
+ const baselineText = `${JSON.stringify({ kind: 'baseline', formatVersion: 1, projectId, objects: pull.objects }, null, 1)}\n`;
216
+ const project = {
217
+ kind: 'team-project', formatVersion: 1, projectId, name, type, lead: { name: lead },
218
+ ...(system ? { system } : {}),
219
+ ...(atcVariant ? { atcVariant } : {}),
220
+ baseline: { file: BASELINE_FILE.replace(/\\/g, '/'), sha256: sha256(baselineText), takenAt: now.toISOString(), objectCount: pull.objects.length },
221
+ setAside: { local: opts['include-local'] ? 0 : local, generated: pull.setAside.generated, otherTypes: pull.setAside.otherTypes },
222
+ createdAt: now.toISOString(),
223
+ };
224
+ fs.mkdirSync(path.join(dir, 'baseline'), { recursive: true });
225
+ fs.writeFileSync(path.join(dir, BASELINE_FILE), baselineText);
226
+ fs.writeFileSync(path.join(dir, TEAM_FILE), `${JSON.stringify(project, null, 2)}\n`);
227
+ writeLanes(dir, projectId, []);
228
+ deps.print(`✓ ${name}: ${pull.objects.length} objects in ${chosen.length} packages. Every package matched the system's count.`);
229
+ 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
+ deps.print(` Written to ${dir}`);
231
+ // F4: the live run of 2026-09-20 recorded the lead as "Admin" — the Windows
232
+ // user. That name goes into the team project file, into the writer id and
233
+ // into every record from then on. Said once, never asked.
234
+ if (!given && looksLikeAMachine(lead, deps)) {
235
+ deps.print(` The lead is recorded as "${lead}". If that is not your name, run: cspeach team init … --lead "Your Name" next time, `
236
+ + `or edit "lead" in ${TEAM_FILE} now.`);
237
+ }
238
+ deps.print(' Next: cspeach team lane add <id> --owner "<name>" --packages "ZFI_*" then each person: cspeach team join <folder>');
239
+ // The lead reads this top to bottom: what was pulled, what was measured, then
240
+ // what to do next. `join` has to RUN first — a record needs a writer id — but
241
+ // its advice belongs after the measurement, so its lines are held back.
242
+ const joinLines = [];
243
+ // The name the lead gave rides into `join` too, so the writer id, the records
244
+ // and every later `lane add` or `rule` on this laptop are all one person.
245
+ const joinedRc = join([dir], { ...deps, print: (l) => joinLines.push(l) }, given || undefined);
246
+ if (joinedRc !== 0) {
247
+ for (const l of joinLines)
248
+ deps.print(l);
249
+ return joinedRc;
250
+ }
251
+ await firstMeasurement(dir, project, pull.objects, opts, atc, deps);
252
+ for (const l of joinLines)
253
+ deps.print(l);
254
+ return 0;
255
+ }
256
+ /** Errors that are about this laptop's disk or the shared folder, never about SAP. */
257
+ const CANNOT_WRITE = new Set(['EPERM', 'EACCES', 'EROFS', 'ENOSPC', 'EISDIR', 'ENOTEMPTY', 'EBUSY', 'EMFILE', 'ENOTDIR']);
258
+ /**
259
+ * Measurement zero (D10): what the system finds on day one, taken by the lead
260
+ * into baseline/measurement-0.json through the same code as `refresh --all`.
261
+ * It is what "verified" is later measured AGAINST. It never fails `init`: the
262
+ * team project is already written, and a measurement can be taken any day.
263
+ *
264
+ * A clean estate DOES get a first measurement: a check run lists every object
265
+ * it covered, so an object it found nothing in has a row with no findings. Only
266
+ * an estate the run reports nothing back for at all — no object listed — leaves
267
+ * nothing to write, and then that is said out loud and `init` still succeeds.
268
+ */
269
+ async function firstMeasurement(dir, project, baseline, opts, atc, deps) {
270
+ const LATER = 'Measure when you can: cspeach team refresh --all';
271
+ if (opts['no-measure']) {
272
+ deps.print(' Not measured (--no-measure). When you are ready: cspeach team refresh --all');
273
+ return;
274
+ }
275
+ if (!atc) {
276
+ deps.print(' Not measured yet: this connection cannot run ATC. When one can: cspeach team refresh --all');
277
+ return;
278
+ }
279
+ const variant = project.atcVariant;
280
+ if (!variant) {
281
+ deps.print(' Not measured yet: no ATC check variant was named. Name it and measure: cspeach team refresh --all --variant <NAME>');
282
+ return;
283
+ }
284
+ // The same Ctrl-C the run itself offers: what was measured before the press is still written.
285
+ const stop = new AbortController();
286
+ const off = onInterrupt(deps, () => {
287
+ if (stop.signal.aborted)
288
+ return;
289
+ deps.print(' Stopping. What has already been measured will still be written.');
290
+ deps.print(' Press Ctrl-C again to quit now.');
291
+ stop.abort();
292
+ });
293
+ try {
294
+ // A check run over a whole estate can take half an hour, and `init` has no
295
+ // time budget of its own. Say the size and the two ways out, before it starts.
296
+ deps.print(`Taking the first measurement with ${variant}: ${plural(baseline.length, 'object', 'objects')}.`);
297
+ deps.print(' This can take a while. Ctrl-C keeps what is measured; --no-measure skips it.');
298
+ const res = await measure({
299
+ joined: joinedOrThrow(), project, baseline, lanes: [], scope: { kind: 'all' }, variant, atc, by: { name: whoAmI(deps) },
300
+ dir: path.join(dir, 'baseline'), fileName: 'measurement-0.json',
301
+ ...(deps.now ? { now: deps.now() } : {}), ...(deps.rand ? { rand: deps.rand } : {}),
302
+ signal: stop.signal,
303
+ onProgress: progressLine(deps),
304
+ });
305
+ // The same block `refresh` prints, with one sentence said differently: a
306
+ // first measurement that proved nothing is not a failed command.
307
+ reportMeasurement(res, variant, deps, undefined, {
308
+ nothing: ' No first measurement was recorded: the check run reported no objects. Your team project is ready. '
309
+ + 'Run cspeach team refresh --all when you want to measure again.',
310
+ });
311
+ // Where it went, said so it cannot read as contradicting `join`'s outbox
312
+ // line: this one record belongs to the team project, not to this laptop.
313
+ if (res.file)
314
+ 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);
316
+ if (stoppedBy) {
317
+ const why = STOPPED_WORDS[stoppedBy];
318
+ deps.print(res.file
319
+ ? ` The first measurement ${why} before it finished, so it is partial. Measure the rest when you can: cspeach team refresh --all`
320
+ : ` The first measurement ${why} before anything was measured. ${LATER}`);
321
+ }
322
+ }
323
+ catch (e) {
324
+ // A folder CSPeach cannot write into is not a broken check run. Blaming the
325
+ // run would send the lead to look at SAP for a problem on their own disk.
326
+ const why = e instanceof Error ? e.message : String(e);
327
+ deps.print(CANNOT_WRITE.has(e?.code ?? '')
328
+ ? ` ! CSPeach could not write the first measurement into ${shown(dir)}: ${why}. The team project itself is complete.`
329
+ : ` ! The first measurement did not finish: ${why}. The team project is written. ${LATER}`);
330
+ }
331
+ finally {
332
+ off();
333
+ }
334
+ }
335
+ // ── join / leave ────────────────────────────────────────────────────────────
336
+ /**
337
+ * `asName` is set only by `init --lead`: the lead's own name, so their writer
338
+ * id and their records carry it. `join` itself takes no such option — a
339
+ * developer's name comes from `CSPEACH_AUTHOR_NAME`, else `USER`/`USERNAME`.
340
+ */
341
+ function join(args, deps, asName) {
342
+ const { positional, opts } = flags(args);
343
+ if (!positional[0])
344
+ throw new RegisterError('Usage: cspeach team join <team project folder> [--outbox <folder>]');
345
+ const projectDir = path.resolve(positional[0]);
346
+ const project = readProjectFile(projectDir);
347
+ const objects = readBaseline(projectDir).length; // also verifies the checksum
348
+ const state = loadState();
349
+ const outboxDir = path.resolve(opts.outbox ?? state.projects[project.projectId]?.outboxDir ?? defaultOutboxDir(project.projectId));
350
+ // A name given once is kept: joining again must not turn the lead back into
351
+ // whatever the machine calls them.
352
+ const saved = asName ?? state.projects[project.projectId]?.name;
353
+ const writerId = writerIdFor(saved ?? deps.identity().name, deps.machine?.());
354
+ fs.mkdirSync(path.join(outboxDir, 'writers', writerId, 'records'), { recursive: true });
355
+ state.projects[project.projectId] = { projectDir, outboxDir, writerId, ...(saved ? { name: saved } : {}) };
356
+ state.active = project.projectId;
357
+ saveState(state);
358
+ deps.print(`✓ Joined "${project.name}" (${objects} objects, lead ${project.lead.name}) as ${writerId}.`);
359
+ deps.print(` Your own results are recorded on this laptop, in ${outboxDir}`);
360
+ deps.print(' CSPeach does not send them anywhere. Hand them in by copying that folder\'s "writers" folder into the team project folder,');
361
+ deps.print(' or keep the outbox inside your own synced folder: cspeach team join <folder> --outbox <folder>');
362
+ return 0;
363
+ }
364
+ function leave(deps) {
365
+ const state = loadState();
366
+ if (!state.active) {
367
+ deps.print('This laptop is not recording against a team project.');
368
+ return 0;
369
+ }
370
+ const was = state.active;
371
+ state.active = null;
372
+ saveState(state);
373
+ deps.print(`✓ Stopped recording against ${was}. Files already written stay where they are.`);
374
+ return 0;
375
+ }
376
+ // ── lane ────────────────────────────────────────────────────────────────────
377
+ function lane(args, deps) {
378
+ const [verb, ...rest] = args;
379
+ const { positional, opts } = flags(rest);
380
+ const joined = joinedOrThrow();
381
+ const project = readProjectFile(joined.projectDir);
382
+ const lanes = readLanes(joined.projectDir);
383
+ if (verb === 'list' || !verb) {
384
+ if (!lanes.length)
385
+ deps.print('No lanes yet. cspeach team lane add <id> --owner "<name>" --packages "ZFI_*"');
386
+ for (const l of lanes)
387
+ deps.print(`${l.id.padEnd(12)} ${l.owner.padEnd(20)} ${[...l.packages, ...l.namePatterns.map((n) => `name:${n}`)].join(', ')}`);
388
+ return 0;
389
+ }
390
+ requireLead(project, deps);
391
+ const id = positional[0];
392
+ if (!id)
393
+ throw new RegisterError('Usage: cspeach team lane add <id> --owner "<name>" --packages "ZFI_*" [--names "ZCL_TAX_*"] | lane remove <id>');
394
+ if (verb === 'remove') {
395
+ writeLanes(joined.projectDir, project.projectId, lanes.filter((l) => l.id !== id));
396
+ deps.print(`✓ Lane ${id} removed.`);
397
+ return 0;
398
+ }
399
+ if (verb !== 'add')
400
+ throw new RegisterError('lane: use add, remove or list.');
401
+ const next = { id, owner: opts.owner ?? '', packages: list(opts.packages), namePatterns: list(opts.names) };
402
+ if (!next.owner || (!next.packages.length && !next.namePatterns.length)) {
403
+ throw new RegisterError('A lane needs --owner and at least one of --packages or --names.');
404
+ }
405
+ writeLanes(joined.projectDir, project.projectId, [...lanes.filter((l) => l.id !== id), next]);
406
+ const reg = registerFor(joined.projectDir, joined.outboxDir);
407
+ const mine = reg.register.lanes.find((l) => l.id === id);
408
+ // The count is every object this lane MATCHES, so it can never disagree with
409
+ // the overlap line under it (F2).
410
+ deps.print(`✓ Lane ${id}: ${next.owner}, ${plural(mine.objects, 'object', 'objects')}.`);
411
+ if (next.packages.length && next.namePatterns.length) {
412
+ deps.print(` Lane ${id} means: in these packages AND matching these names.`);
413
+ }
414
+ if (reg.register.totals.overlapping)
415
+ deps.print(` ! ${plural(reg.register.totals.overlapping, 'object now sits', 'objects now sit')} in more than one lane.`);
416
+ if (reg.register.totals.unowned)
417
+ deps.print(` ${plural(reg.register.totals.unowned, 'object still has', 'objects still have')} no owner.`);
418
+ return 0;
419
+ }
420
+ // ── 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
+ const STATE_WORDS = [
440
+ ['verified', 'verified'], ['clean', 'clean'], ['claimed', 'claimed, not measured since'], ['working', 'in work'],
441
+ ['decided', 'decided, not started'], ['untouched', 'untouched'], ['did-not-hold', 'fix did not hold'],
442
+ ['regressed', 'regressed'], ['retired', 'retired'],
443
+ ];
444
+ function status(args, deps) {
445
+ const { positional } = flags(args);
446
+ const joined = activeProject();
447
+ const projectDir = positional[0] ? path.resolve(positional[0]) : joined?.projectDir;
448
+ if (!projectDir)
449
+ throw new RegisterError('Usage: cspeach team status [<team project folder>]');
450
+ const project = readProjectFile(projectDir);
451
+ const { register: r, files, unreadable, ownFiles, narrowed } = registerFor(projectDir, positional[0] ? undefined : joined?.outboxDir);
452
+ const t = r.totals;
453
+ deps.print(`${project.name} (${project.type}, lead ${project.lead.name})`);
454
+ // A clean sheet out of a file is somebody's export, not this system speaking.
455
+ // The words "verified" and "clean" do not change; the line says how many of
456
+ // them were measured one step removed, so nobody reads them as live proof.
457
+ const fromFile = t.fromFile.verified + t.fromFile.clean;
458
+ deps.print(`${t.objects} objects, ${t.active} active. ${t.decided} decided. `
459
+ + `${t.byState.verified} verified, ${t.byState.clean} clean`
460
+ + `${fromFile ? ` — ${fromFile} of them measured from a file` : ''}.`);
461
+ // The newest run that counted — by its time, not by the order the files were
462
+ // read in — so "from a file" is said of the run the date belongs to.
463
+ const lastRun = [...r.runs].reverse().find((run) => run.at === r.lastMeasuredAt);
464
+ deps.print(r.lastMeasuredAt
465
+ ? `Last measured ${r.lastMeasuredAt.slice(0, 16).replace('T', ' ')} UTC${lastRun?.basis === 'file' ? ', from a file' : ''}.`
466
+ : 'Not measured yet: "verified" and "clean" need an ATC run.');
467
+ deps.print('');
468
+ for (const [state, word] of STATE_WORDS)
469
+ if (t.byState[state])
470
+ deps.print(` ${String(t.byState[state]).padStart(6)} ${word}`);
471
+ deps.print('');
472
+ if (r.lanes.length) {
473
+ // The same columns, in the same order, as the team project page's lane table,
474
+ // counted with the same three functions. One lane must not read "50% done"
475
+ // in a browser and "3 done" here.
476
+ deps.print(`${'Lane'.padEnd(12)} ${'Owner'.padEnd(20)} Objects Active Proven Last record`);
477
+ for (const l of r.lanes) {
478
+ deps.print(`${l.id.padEnd(12)} ${l.owner.padEnd(20)} ${String(l.objects).padStart(7)} `
479
+ + `${String(activeOf(l.objects, l.byState)).padStart(6)} ${String(provenOf(l.byState)).padStart(6)} `
480
+ + `${l.lastRecordAt ? l.lastRecordAt.slice(0, 10) : 'nothing yet'}`);
481
+ }
482
+ // Said once per lane it is true of. A lane set up before this rule held
483
+ // both a package list and a name list, and took the whole package.
484
+ for (const id of narrowed)
485
+ deps.print(`Lane ${id} now means: in these packages AND matching these names.`);
486
+ deps.print('');
487
+ }
488
+ if (t.unowned)
489
+ deps.print(`! ${plural(t.unowned, 'object has', 'objects have')} no owner.`);
490
+ if (t.overlapping)
491
+ deps.print(`! ${plural(t.overlapping, 'object sits', 'objects sit')} in more than one lane.`);
492
+ if (t.conflicts) {
493
+ deps.print(`! ${plural(t.conflicts, 'object has a conflicting decision', 'objects have conflicting decisions')}.`
494
+ + ` The lead settles ${t.conflicts === 1 ? 'it' : 'each'} with: cspeach team rule <TYPE> <NAME> <decision>`);
495
+ for (const o of r.objects.filter((x) => x.decision?.conflict).slice(0, 10)) {
496
+ deps.print(` ${o.key}: ${Object.entries(o.decision.conflict).map(([w, c]) => `${w} says ${c}`).join(', ')}`);
497
+ }
498
+ }
499
+ const s = r.skipped;
500
+ // Why this picture may be ahead of the one the team sees on the team project page.
501
+ if (ownFiles)
502
+ deps.print(` Includes ${ownFiles} record file${ownFiles === 1 ? '' : 's'} from this laptop that ${ownFiles === 1 ? 'has' : 'have'} not been handed in yet.`);
503
+ if (unreadable)
504
+ deps.print(` ${unreadable} of ${files} files could not be read yet (still syncing, or damaged).`);
505
+ // Read as JSON, but not a record: an export somebody dropped in, or a file a
506
+ // tool rewrote. It was counted and left out, and that is worth one line.
507
+ if (s.invalidRecords) {
508
+ deps.print(` ${plural(s.invalidRecords, 'file', 'files')} in the folder could not be read`
509
+ + ` and ${s.invalidRecords === 1 ? 'was' : 'were'} left out.`);
510
+ }
511
+ if (s.rowsNotInBaseline) {
512
+ deps.print(` ${plural(s.rowsNotInBaseline, 'row named an object', 'rows named objects')}`
513
+ + ` that ${s.rowsNotInBaseline === 1 ? 'is' : 'are'} not in this team project: ${r.unknownKeys.slice(0, 5).join(', ')}`);
514
+ }
515
+ if (s.otherProject)
516
+ deps.print(` ${plural(s.otherProject, 'file belongs', 'files belong')} to another team project and ${s.otherProject === 1 ? 'was' : 'were'} ignored.`);
517
+ if (s.conflictingDuplicates)
518
+ deps.print(` ${plural(s.conflictingDuplicates, 'file carries', 'files carry')} the id of another file but says something else. One of each pair was used.`);
519
+ if (s.rulingsNotFromLead)
520
+ deps.print(` ${plural(s.rulingsNotFromLead, 'ruling was', 'rulings were')} not from the lead and ${s.rulingsNotFromLead === 1 ? 'was' : 'were'} ignored.`);
521
+ if (s.measurementsOtherVariant) {
522
+ // Naming our own variant is the difference between a lead who knows which
523
+ // run to redo and one who has to go and look in the folder.
524
+ const ours = project.atcVariant?.trim();
525
+ deps.print(` ${plural(s.measurementsOtherVariant, 'measurement was', 'measurements were')} taken with another check variant`
526
+ + `${ours ? ` than this team project's (${ours})` : ''}`
527
+ + ` and ${s.measurementsOtherVariant === 1 ? 'was' : 'were'} ignored.`);
528
+ for (const g of r.ignoredRuns.slice(0, 5))
529
+ deps.print(` ${g.at.slice(0, 16).replace('T', ' ')} UTC, ${g.by}, ${g.atcVariant}`);
530
+ }
531
+ return 0;
532
+ }
533
+ // ── refresh ─────────────────────────────────────────────────────────────────
534
+ export const CANNOT_MEASURE = 'This laptop runs without a SAP system, so it cannot measure. Someone who is connected can run: cspeach team refresh. '
535
+ + 'Or read in an ATC result export: cspeach team refresh --from-file <file.csv>';
536
+ const sameText = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
537
+ /* ── The command line ───────────────────────────────────────────────────────
538
+ *
539
+ * `refresh` reads its own arguments, strictly. The loose parser the other verbs
540
+ * share turns `--all --lanes FI` into "measure everything" and
541
+ * `--packages "ZSD_*, ZFI_*"` into "measure ZSD_* only" — a half-scope that is
542
+ * then written down as a measurement of the whole. A command that cannot be
543
+ * sure what it was asked to measure must not measure anything.
544
+ */
545
+ export const REFRESH_USAGE = 'Usage: cspeach team refresh (--lane <id> | --packages "ZFI_*,ZSD_*" | --all) '
546
+ + '[--variant <NAME>] [--force-variant] [--chunk <n>] [--max-minutes <n>] [--no-one-by-one] [--sap-alias <name>]\n'
547
+ + ' or: cspeach team refresh --from-file <file.csv> [--lane <id> | --packages "ZFI_*,ZSD_*" | --all] '
548
+ + '[--variant <NAME>] [--absent-means-clean]';
549
+ /** Options that take a value, with the example each refusal shows. */
550
+ const VALUE_OPTIONS = {
551
+ lane: '--lane sd',
552
+ packages: '--packages "ZFI_*,ZSD_*"',
553
+ variant: '--variant S4HANA_READINESS',
554
+ 'max-minutes': '--max-minutes 20',
555
+ chunk: '--chunk 50',
556
+ 'sap-alias': '--sap-alias S4H',
557
+ 'from-file': '--from-file atc-export.csv',
558
+ };
559
+ const FLAG_OPTIONS = ['all', 'force-variant', 'no-one-by-one', 'absent-means-clean'];
560
+ const KNOWN_OPTIONS = [...Object.keys(VALUE_OPTIONS), ...FLAG_OPTIONS];
561
+ /** Options that describe a check RUN. A file was made by a run that is already over. */
562
+ const RUN_ONLY_OPTIONS = ['chunk', 'max-minutes', 'no-one-by-one', 'sap-alias'];
563
+ /** Edit distance, capped: only used to offer one guess at a mistyped option. */
564
+ function near(typed, known = KNOWN_OPTIONS, max = 3) {
565
+ const distance = (a, b) => {
566
+ let prev = [...Array(b.length + 1).keys()];
567
+ for (let i = 1; i <= a.length; i += 1) {
568
+ const row = [i];
569
+ for (let j = 1; j <= b.length; j += 1) {
570
+ row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
571
+ }
572
+ prev = row;
573
+ }
574
+ return prev[b.length];
575
+ };
576
+ let best = null;
577
+ for (const name of known) {
578
+ const d = distance(typed, name);
579
+ if (d <= max && (!best || d < best.d))
580
+ best = { name, d };
581
+ }
582
+ return best ? best.name : null;
583
+ }
584
+ /**
585
+ * The same option twice. Which one a person meant cannot be guessed, and taking
586
+ * the last one silently is how half a scope gets measured and written down as
587
+ * the whole.
588
+ */
589
+ const twice = (name) => `--${name} was given twice. Say it once.`;
590
+ export function parseRefreshArgs(args) {
591
+ const no = (message) => ({ ok: false, message: `${message}\n${REFRESH_USAGE}` });
592
+ const opts = {};
593
+ const loose = [];
594
+ for (let i = 0; i < args.length; i += 1) {
595
+ const a = args[i];
596
+ if (!a.startsWith('--')) {
597
+ loose.push(a);
598
+ continue;
599
+ }
600
+ // `--lane=sd` is the same command as `--lane sd`. Read here, once, so the
601
+ // two spellings cannot be understood differently anywhere downstream.
602
+ const equals = a.indexOf('=');
603
+ if (equals > 2) {
604
+ const name = a.slice(2, equals);
605
+ const value = a.slice(equals + 1);
606
+ if (FLAG_OPTIONS.includes(name))
607
+ return no(`--${name} takes no value.`);
608
+ if (!(name in VALUE_OPTIONS)) {
609
+ const guess = near(name);
610
+ return no(`Unknown option --${name}.${guess ? ` Did you mean --${guess}?` : ''}`);
611
+ }
612
+ if (!value)
613
+ return no(`--${name} needs a value, for example: ${VALUE_OPTIONS[name]}`);
614
+ if (opts[name] !== undefined)
615
+ return no(twice(name));
616
+ opts[name] = value;
617
+ continue;
618
+ }
619
+ const name = a.slice(2);
620
+ if (FLAG_OPTIONS.includes(name)) {
621
+ if (opts[name] !== undefined)
622
+ return no(twice(name));
623
+ opts[name] = 'yes'; // a flag never eats the next word
624
+ continue;
625
+ }
626
+ if (!(name in VALUE_OPTIONS)) {
627
+ const guess = near(name);
628
+ return no(`Unknown option --${name}.${guess ? ` Did you mean --${guess}?` : ''}`);
629
+ }
630
+ const value = args[i + 1];
631
+ if (value === undefined || value.startsWith('--'))
632
+ return no(`--${name} needs a value, for example: ${VALUE_OPTIONS[name]}`);
633
+ if (opts[name] !== undefined)
634
+ return no(twice(name));
635
+ opts[name] = value;
636
+ i += 1;
637
+ }
638
+ if (loose.length) {
639
+ // The one that bites in real life: a space after the comma splits the list
640
+ // and the tail arrives on its own. Dropping it would measure half a scope.
641
+ if (opts.packages !== undefined) {
642
+ return no(`A package list is one word: --packages "ZFI_*,ZSD_*". "${loose[0]}" was left on its own`
643
+ + ' — put the list in quotes, or take the space out after the comma.');
644
+ }
645
+ return no(`cspeach team refresh does not take "${loose[0]}" on its own.`);
646
+ }
647
+ const given = ['all', 'lane', 'packages'].filter((k) => opts[k] !== undefined);
648
+ const fromFile = opts['from-file'];
649
+ if (fromFile === undefined) {
650
+ if (given.length !== 1)
651
+ return no('Say what to measure, one of: --lane <id> --packages "ZFI_*,ZSD_*" --all');
652
+ if (opts['absent-means-clean'] !== undefined) {
653
+ return no('--absent-means-clean is only for --from-file. A check run reports what it checked.');
654
+ }
655
+ }
656
+ else {
657
+ // A file is the result of a run that is already over: nothing about how a
658
+ // run would have been made can be honoured, so it is refused rather than
659
+ // quietly ignored.
660
+ if (!fromFile.trim())
661
+ return no(`--from-file needs a value, for example: ${VALUE_OPTIONS['from-file']}`);
662
+ if (given.length > 1)
663
+ return no('Name at most one of: --lane <id> --packages "ZFI_*,ZSD_*" --all');
664
+ for (const name of RUN_ONLY_OPTIONS) {
665
+ if (opts[name] !== undefined)
666
+ return no(`--${name} is about a live check run, so it means nothing with --from-file.`);
667
+ }
668
+ // One filtered export must never turn a whole lane clean by accident.
669
+ if (opts['absent-means-clean'] !== undefined && !given.length) {
670
+ return no('--absent-means-clean needs a scope: say which objects the file covers, '
671
+ + 'with --lane <id>, --packages "ZFI_*,ZSD_*" or --all.');
672
+ }
673
+ }
674
+ let scope = { kind: 'all' };
675
+ if (given[0] === 'lane') {
676
+ if (!opts.lane.trim())
677
+ return no(`--lane needs a value, for example: ${VALUE_OPTIONS.lane}`);
678
+ scope = { kind: 'lane', id: opts.lane.trim() };
679
+ }
680
+ else if (given[0] === 'packages') {
681
+ const patterns = list(opts.packages);
682
+ if (!patterns.length)
683
+ return no(`--packages needs a value, for example: ${VALUE_OPTIONS.packages}`);
684
+ scope = { kind: 'packages', patterns };
685
+ }
686
+ const value = {
687
+ scope, scopeGiven: given.length === 1,
688
+ forceVariant: opts['force-variant'] !== undefined,
689
+ oneByOne: opts['no-one-by-one'] === undefined,
690
+ absentMeansClean: opts['absent-means-clean'] !== undefined,
691
+ ...(fromFile !== undefined ? { fromFile: fromFile.trim() } : {}),
692
+ };
693
+ if (opts.variant !== undefined) {
694
+ const variant = opts.variant.trim().toUpperCase();
695
+ if (!variant)
696
+ return no(`--variant needs a value, for example: ${VALUE_OPTIONS.variant}`);
697
+ if (!VARIANT_NAME.test(variant))
698
+ return no(`"${opts.variant.trim()}" is not an ATC check variant name.`);
699
+ value.variant = variant;
700
+ }
701
+ if (opts['max-minutes'] !== undefined) {
702
+ const minutes = Number(opts['max-minutes']);
703
+ if (!Number.isFinite(minutes) || minutes <= 0)
704
+ return no(`--max-minutes must be a number of minutes greater than 0, for example: ${VALUE_OPTIONS['max-minutes']}`);
705
+ value.maxTotalMs = Math.round(minutes * 60000);
706
+ }
707
+ if (opts.chunk !== undefined) {
708
+ const chunk = Number(opts.chunk);
709
+ if (!Number.isInteger(chunk) || chunk <= 0)
710
+ return no(`--chunk must be a whole number of objects greater than 0, for example: ${VALUE_OPTIONS.chunk}`);
711
+ value.chunkSize = chunk;
712
+ }
713
+ if (opts['sap-alias'] !== undefined)
714
+ value.sapAlias = opts['sap-alias'];
715
+ return { ok: true, value };
716
+ }
717
+ /** The scope in words, for the line printed before a run that can take half an hour. */
718
+ function scopeWords(scope) {
719
+ // `shown()`: the value is the person's own argument, and it still goes to a
720
+ // terminal through CSPeach.
721
+ if (scope.kind === 'lane')
722
+ return `lane ${shown(scope.id)}`;
723
+ if (scope.kind === 'packages')
724
+ return `packages ${shown(scope.patterns.join(', '))}`;
725
+ return 'the whole team project';
726
+ }
727
+ /**
728
+ * D6: one variant per team project.
729
+ *
730
+ * `persist` says the team project has no variant yet and this lead just chose
731
+ * one — but the name is NOT written here. A run that measures nothing (a typo,
732
+ * a dead VPN, a lane that does not exist) must leave the team project file
733
+ * exactly as it was; nothing in the family takes a variant name back.
734
+ */
735
+ /**
736
+ * The variant a run may carry, given what the team project measures with.
737
+ * Throws the D6 refusal — the same sentence `measure()` refuses with, said in
738
+ * one place so a live run and a file import can never answer differently.
739
+ */
740
+ function allowedVariant(ours, asked, force, asWritten = ours) {
741
+ // The team project file is a file like any other: it can be edited by hand,
742
+ // half-synced or filled in by something that was not CSPeach. A name SAP
743
+ // cannot accept never reaches SAP, and never reaches a record either.
744
+ if (!VARIANT_NAME.test(ours)) {
745
+ throw new RegisterError(`${TEAM_FILE} says this team project measures with "${shown(asWritten)}", `
746
+ + 'which is not an ATC check variant name. The lead must correct the file.');
747
+ }
748
+ if (!asked || asked === ours)
749
+ return { variant: ours, foreign: false };
750
+ if (!force)
751
+ throw new RegisterError(variantMismatchMessage(ours, asked));
752
+ return { variant: asked, foreign: true };
753
+ }
754
+ async function variantFor(project, opts, deps) {
755
+ const asked = opts.variant ?? '';
756
+ const ours = (project.atcVariant ?? '').trim().toUpperCase();
757
+ if (ours)
758
+ return { ...allowedVariant(ours, asked, opts.forceVariant, project.atcVariant ?? ours), persist: false };
759
+ const isLead = sameText(whoAmI(deps), project.lead.name);
760
+ let chosen = asked;
761
+ if (!chosen && isLead && deps.ask) {
762
+ chosen = (await deps.ask('Which ATC check variant does this team project measure with? (for example S4HANA_READINESS)')).trim().toUpperCase();
763
+ }
764
+ if (!chosen || !isLead)
765
+ throw new RegisterError(noVariantYetMessage(project.lead.name));
766
+ if (!VARIANT_NAME.test(chosen))
767
+ throw new RegisterError(`"${chosen}" is not an ATC check variant name.`);
768
+ return { variant: chosen, foreign: false, persist: true };
769
+ }
770
+ /** Why a run was refused, in words. `not-run` is said on its own: it never started. */
771
+ const REFUSED_WORDS = {
772
+ incomplete: 'did not finish',
773
+ unmapped: 'held findings CSPeach could not tie to an object',
774
+ 'no-object-list': 'gave an answer CSPeach could not read',
775
+ 'run-not-started': 'did not start',
776
+ error: 'failed',
777
+ 'not-run': 'was not started',
778
+ };
779
+ function progressLine(deps) {
780
+ return (p) => {
781
+ const note = p.note ? ` (${p.note})` : '';
782
+ if (p.phase === 'started')
783
+ deps.print(` Run ${p.chunk} of ${p.chunks}: ${plural(p.objects, 'object', 'objects')}…${note}`);
784
+ else if (p.phase === 'refused')
785
+ deps.print(` Run ${p.chunk} of ${p.chunks} ${REFUSED_WORDS[p.reason ?? 'error'] ?? 'failed'}.${note}`);
786
+ };
787
+ }
788
+ /**
789
+ * Every object that was asked for and did NOT get a row, with the reason in
790
+ * plain words. Only the buckets that hold something are printed: a line saying
791
+ * "0 not measured" teaches a person to skim past the ones that matter.
792
+ */
793
+ /** Up to five of them, and an honest "…" for the rest. */
794
+ const someNames = (names) => `${names.slice(0, 5).join(', ')}${names.length > 5 ? ', …' : ''}`;
795
+ /**
796
+ * What an object the check run never reported means, in one sentence.
797
+ *
798
+ * A real run LISTS every object it covered, with no findings when it found
799
+ * none — that is what clean is. An object it does not list was not checked:
800
+ * this variant has no check for its type. So the sentence says the reason,
801
+ * rather than promising CSPeach is still working something out.
802
+ */
803
+ export const absentLine = (count, names) => [
804
+ ` ! ${plural(count, 'object was', 'objects were')} not reported by the check run: this check variant has no check for their type. `
805
+ + `${count === 1 ? 'It is' : 'They are'} left as not measured.`,
806
+ ...(names.length ? [` ${someNames(names)}`] : []),
807
+ ];
808
+ function notMeasuredLines(res, deps, words = {}) {
809
+ if (res.absent) {
810
+ for (const line of words.absentLines?.(res.absent) ?? absentLine(res.absent, res.absentNames))
811
+ deps.print(line);
812
+ }
813
+ for (const c of res.refusedChunks) {
814
+ const words = REFUSED_WORDS[c.reason] ?? 'failed';
815
+ deps.print(` ! Run ${c.index} ${words}. ${plural(c.objects, 'object', 'objects')} left as not measured.`);
816
+ if (c.detail)
817
+ deps.print(` ${c.detail}`);
818
+ }
819
+ if (res.unsupported) {
820
+ deps.print(` ! ${plural(res.unsupported, 'object', 'objects')} of a type this check cannot reach (left as not measured): `
821
+ + someNames(res.unsupportedNames));
822
+ }
823
+ }
824
+ export function reportMeasurement(res, variant, deps, foreignTo, words = {}) {
825
+ if (!res.file) {
826
+ deps.print(words.nothing ?? '✗ Nothing was measured, so nothing was written.');
827
+ for (const note of words.notes ?? [])
828
+ deps.print(note);
829
+ notMeasuredLines(res, deps, words);
830
+ return;
831
+ }
832
+ deps.print(words.headline ?? `✓ ${plural(res.measured, 'object measured', 'objects measured')}, ${res.withFindings} with findings.`);
833
+ // F3: SAP reports some findings twice — same place, same message, two finding
834
+ // ids. One problem in the code is one finding, so it is counted once; a
835
+ // person comparing this with their own ATC screen has to be told why the two
836
+ // numbers differ.
837
+ if (res.duplicateFindingsMerged) {
838
+ deps.print(` SAP reported ${plural(res.duplicateFindingsMerged, 'finding', 'findings')} twice (same place, same message). CSPeach counts each once.`);
839
+ }
840
+ if (foreignTo) {
841
+ deps.print(` ! Made with ${variant}, not the team project's ${foreignTo}. The record is kept, but it is not counted in the picture.`);
842
+ }
843
+ for (const note of words.notes ?? [])
844
+ deps.print(note);
845
+ notMeasuredLines(res, deps, words);
846
+ if (res.refusedRows.length) {
847
+ deps.print(` ! ${plural(res.refusedRows.length, 'row named an object', 'rows named objects')}`
848
+ + ` that ${res.refusedRows.length === 1 ? 'is' : 'are'} not in this team project: ${res.refusedRows.slice(0, 5).join(', ')}`);
849
+ }
850
+ deps.print(` Written to ${res.file}`);
851
+ deps.print(' Next: cspeach team status');
852
+ }
853
+ /** Why a run ended before it reached the end of the set, in plain words. */
854
+ const STOPPED_WORDS = {
855
+ deadline: 'ran out of time',
856
+ aborted: 'was stopped',
857
+ connection: 'lost its connection to SAP',
858
+ };
859
+ /**
860
+ * I1, the product's main loop.
861
+ *
862
+ * An object the worklist never named gets no row, so its last measurement goes
863
+ * on driving its state. That is the safe direction and it stays. But when the
864
+ * object had findings, or somebody has claimed a fix on it and is waiting for a
865
+ * run to confirm it, "nothing happened" is exactly the wrong thing for a person
866
+ * to read: a fix that held would sit there as "did not hold" for ever. So the
867
+ * ones that matter are named.
868
+ *
869
+ * Objects only, never findings summed across them.
870
+ */
871
+ function stillStanding(register, absentNames) {
872
+ if (!absentNames.length)
873
+ return [];
874
+ const wanted = new Set(absentNames);
875
+ return register.objects
876
+ .filter((o) => wanted.has(o.key) && ((o.openFindings ?? 0) > 0 || o.fixes.some((f) => f.status === 'fixed')))
877
+ .map((o) => o.key);
878
+ }
879
+ /**
880
+ * Ctrl-C stops the run between checks; nothing already measured is thrown away.
881
+ * `once`, on purpose: the SECOND press is Node's own, so a request that has
882
+ * hung can always be quit.
883
+ */
884
+ function onInterrupt(deps, stop) {
885
+ if (deps.onInterrupt)
886
+ return deps.onInterrupt(stop);
887
+ const handler = () => stop();
888
+ process.once('SIGINT', handler);
889
+ return () => { process.off('SIGINT', handler); };
890
+ }
891
+ /**
892
+ * D8: proof for a team CSPeach cannot reach SAP from. Somebody exports the ATC
893
+ * result list from the system; this reads it in as a measurement, marked
894
+ * "measured from a file" everywhere it is shown. No connection is opened.
895
+ */
896
+ function refreshFromFile(opts, deps) {
897
+ const joined = joinedOrThrow();
898
+ const project = readProjectFile(joined.projectDir);
899
+ const ours = (project.atcVariant ?? '').trim().toUpperCase();
900
+ const asked = opts.variant ?? '';
901
+ // The team project's own name is checked even when this run carries none: a
902
+ // variant the file on disk could not have got from SAP is somebody's edit.
903
+ const checked = ours ? allowedVariant(ours, asked, opts.forceVariant, project.atcVariant ?? ours) : { variant: asked, foreign: false };
904
+ // A file never SETS the team project's variant: that is the lead's decision,
905
+ // made on a live run. It may only carry a name the person vouched for.
906
+ const carried = asked ? checked : { variant: '', foreign: false };
907
+ if (opts.absentMeansClean) {
908
+ deps.print(' ! You told CSPeach that every object in this scope that the file does not mention is clean. CSPeach cannot check that.');
909
+ }
910
+ const res = measureFromFile({
911
+ joined, project, baseline: readBaseline(joined.projectDir), lanes: readLanes(joined.projectDir),
912
+ filePath: path.resolve(opts.fromFile), scope: opts.scopeGiven ? opts.scope : null,
913
+ ...(carried.variant ? { variant: carried.variant } : {}),
914
+ ...(opts.absentMeansClean ? { absentMeansClean: true } : {}),
915
+ by: { name: whoAmI(deps) }, ...(deps.now ? { now: deps.now() } : {}), ...(deps.rand ? { rand: deps.rand } : {}),
916
+ });
917
+ // Everything the file said that CSPeach did not use, and why. Each of these
918
+ // is a row that could have been proof and was not counted as any.
919
+ const notes = [];
920
+ // The gate: one finding CSPeach could not place is one finding that may
921
+ // belong to an object this file calls clean.
922
+ if (res.unplaced.length) {
923
+ notes.push(` ! ${plural(res.unplaced.length, 'finding', 'findings')} in this file could not be matched to an object of this team project `
924
+ + `(${upToFive(res.unplaced)}), so CSPeach cannot take this file's word that anything is clean.`);
925
+ }
926
+ if (opts.absentMeansClean && !res.vouches) {
927
+ notes.push(' ! --absent-means-clean was not used: this file could not be read whole, '
928
+ + 'so CSPeach cannot take its word for the objects it does not mention.');
929
+ }
930
+ if (res.unknown.length) {
931
+ notes.push(` ! ${plural(res.unknown.length, 'object in the file is', 'objects in the file are')} not in this team project `
932
+ + `and ${res.unknown.length === 1 ? 'was' : 'were'} left out: ${upToFive(res.unknown)}`);
933
+ }
934
+ const otherType = [...res.disagreed, ...res.ambiguous];
935
+ if (otherType.length) {
936
+ notes.push(` ! ${plural(otherType.length, 'row names an object', 'rows name objects')} this team project does not have under that type, `
937
+ + `so ${otherType.length === 1 ? 'it was' : 'they were'} left out: ${upToFive(otherType)}`);
938
+ }
939
+ if (res.shortRows.length) {
940
+ notes.push(` ! ${plural(res.shortRows.length, 'row was', 'rows were')} cut short and could not be read: `
941
+ + `${upToFive(res.shortRows.map((n) => `line ${n}`))}.`);
942
+ }
943
+ if (res.longRows.length) {
944
+ notes.push(` ! ${plural(res.longRows.length, 'row has', 'rows have')} more cells than the header and could not be read: `
945
+ + `${upToFive(res.longRows.map((n) => `line ${n}`))}.`);
946
+ }
947
+ if (res.orphanFindings) {
948
+ notes.push(` ! ${plural(res.orphanFindings, 'row names a finding', 'rows name findings')} with no object name, `
949
+ + 'so no object in this file could be read as clean.');
950
+ }
951
+ const blank = res.noObjectRows - res.orphanFindings;
952
+ if (blank > 0)
953
+ notes.push(` ! ${plural(blank, 'row named', 'rows named')} no object and ${blank === 1 ? 'was' : 'were'} left out.`);
954
+ if (res.unknownColumns.length) {
955
+ const quoted = res.unknownColumns.map((c) => `"${c}"`);
956
+ const named = quoted.length === 1 ? `column ${quoted[0]}` : `columns ${upToFive(quoted)}`;
957
+ notes.push(` ! CSPeach does not know the ${named}, so it cannot tell a clean object from a finding in this file.`);
958
+ }
959
+ if (res.outsideScope) {
960
+ notes.push(` ! ${plural(res.outsideScope, 'row was', 'rows were')} outside the scope you named `
961
+ + `and ${res.outsideScope === 1 ? 'was' : 'were'} left out.`);
962
+ }
963
+ if (!carried.variant)
964
+ notes.push(' CSPeach cannot see which check variant made this file. Compare it only with files made the same way.');
965
+ reportMeasurement(res, carried.variant, deps, carried.foreign ? ours : undefined, {
966
+ headline: `✓ Read ${plural(res.fileRows, 'result row', 'result rows')} from ${shown(path.basename(opts.fromFile))}: `
967
+ + `${plural(res.measured, 'object', 'objects')} measured from a file, ${res.measured - res.withFindings} clean, `
968
+ + `${res.withFindings} with findings (${plural(res.findings, 'finding', 'findings')}).`,
969
+ nothing: res.unplaced.length || !res.vouches
970
+ ? '✗ Nothing in this file could be read as proof, so nothing was written.'
971
+ : opts.scopeGiven
972
+ ? '✗ Nothing in the file is in the scope you named, so nothing was written.'
973
+ : '✗ Nothing in the file matched an object in this team project, so nothing was written.',
974
+ notes,
975
+ absentLines: (count) => {
976
+ const lines = [];
977
+ const quiet = count - res.doubted.length;
978
+ if (quiet > 0) {
979
+ lines.push(` ! ${plural(quiet, 'object in the scope you named is', 'objects in the scope you named are')} not mentioned in the file — left as not measured.`);
980
+ // Only when it is still on the table: telling a person to pass a flag
981
+ // that this file has just had taken off it is worse than saying nothing.
982
+ if (!opts.absentMeansClean) {
983
+ lines.push(' A file that does not mention an object is not proof that it is clean. '
984
+ + 'If the export covers every object in this scope, say so with --absent-means-clean.');
985
+ }
986
+ }
987
+ // Doubt, said by name: one row nobody could read is one object nobody may
988
+ // call clean, and a person can only act on a name.
989
+ if (res.doubted.length === 1) {
990
+ lines.push(` ! A finding in this file may belong to ${res.doubted[0]}, so it was left as not measured.`);
991
+ }
992
+ else if (res.doubted.length) {
993
+ lines.push(` ! A finding in this file may belong to any of ${res.doubted.length} objects, `
994
+ + `so they were left as not measured: ${upToFive(res.doubted)}`);
995
+ }
996
+ return lines;
997
+ },
998
+ });
999
+ if (!res.file)
1000
+ return 1;
1001
+ // Live evidence outranks file evidence, per object. An import that changes no
1002
+ // state is legal and is kept — but nobody should have to work that out from
1003
+ // the picture afterwards, and no closing line may read as a fresh measurement.
1004
+ const { register } = registerFor(joined.projectDir, joined.outboxDir);
1005
+ const mine = new Set(res.keys);
1006
+ const live = register.objects.filter((o) => mine.has(o.key) && o.measuredBasis === 'system').length;
1007
+ if (live === res.measured)
1008
+ deps.print(' The system has already measured every object in this file, so nothing in the picture changed. The record is kept.');
1009
+ else if (live === 1)
1010
+ deps.print(' 1 of these objects has been measured from the system, so this file does not change its state.');
1011
+ else if (live)
1012
+ deps.print(` ${live} of these objects have been measured from the system, so this file does not change their state.`);
1013
+ return 0;
1014
+ }
1015
+ async function refresh(args, deps) {
1016
+ // First, before anything that reads this laptop's state: a standalone laptop
1017
+ // gets one honest sentence — unless it brought a file, which needs no system.
1018
+ const broughtAFile = args.some((a) => a === '--from-file' || a.startsWith('--from-file='));
1019
+ if (!broughtAFile && (deps.noSap || !deps.connect)) {
1020
+ deps.print(CANNOT_MEASURE);
1021
+ return 2;
1022
+ }
1023
+ const parsed = parseRefreshArgs(args);
1024
+ if (!parsed.ok)
1025
+ throw new RegisterError(parsed.message);
1026
+ const opts = parsed.value;
1027
+ if (opts.fromFile)
1028
+ return refreshFromFile(opts, deps);
1029
+ // Said twice on purpose: the check above lets a file through before the
1030
+ // command line has been read, and this one is the one that guards the run.
1031
+ if (deps.noSap || !deps.connect) {
1032
+ deps.print(CANNOT_MEASURE);
1033
+ return 2;
1034
+ }
1035
+ const { scope } = opts;
1036
+ const joined = joinedOrThrow();
1037
+ const project = readProjectFile(joined.projectDir);
1038
+ const baseline = readBaseline(joined.projectDir);
1039
+ const lanes = readLanes(joined.projectDir);
1040
+ // Everything that can be settled on this laptop is settled before SAP is
1041
+ // touched: the variant, the lane, and whether the scope holds anything.
1042
+ const { variant, foreign, persist } = await variantFor(project, opts, deps);
1043
+ const inScope = scopeObjects(baseline, lanes, scope);
1044
+ if (!inScope.length)
1045
+ throw new RegisterError(`Nothing in this team project matches ${scopeLabel(scope)}.`);
1046
+ let atc;
1047
+ try {
1048
+ ({ atc } = await deps.connect(opts.sapAlias));
1049
+ }
1050
+ catch (e) {
1051
+ // One line, the reason, no stack: "no system configured", a dead VPN and a
1052
+ // wrong password all arrive here and all mean the same thing to the person.
1053
+ deps.print(`✗ Could not connect to SAP: ${e instanceof Error ? e.message : String(e)}`);
1054
+ return 2;
1055
+ }
1056
+ if (!atc) {
1057
+ deps.print(CANNOT_MEASURE);
1058
+ return 2;
1059
+ }
1060
+ deps.print(`Measuring ${scopeWords(scope)} with ${variant}: ${plural(inScope.length, 'object', 'objects')}.`);
1061
+ const stop = new AbortController();
1062
+ const off = onInterrupt(deps, () => {
1063
+ if (stop.signal.aborted)
1064
+ return;
1065
+ deps.print(' Stopping. What has already been measured will still be written.');
1066
+ deps.print(' Press Ctrl-C again to quit now.');
1067
+ stop.abort();
1068
+ });
1069
+ let res;
1070
+ try {
1071
+ res = await measure({
1072
+ joined, project, baseline, lanes, scope, variant, atc, by: { name: whoAmI(deps) },
1073
+ ...(deps.now ? { now: deps.now() } : {}), ...(deps.rand ? { rand: deps.rand } : {}),
1074
+ ...(opts.chunkSize ? { chunkSize: opts.chunkSize } : {}),
1075
+ ...(opts.maxTotalMs ? { maxTotalMs: opts.maxTotalMs } : {}),
1076
+ ...(opts.oneByOne ? {} : { oneByOne: false }),
1077
+ // The variant was settled above; `measure()` checks it again and would
1078
+ // otherwise refuse the run this command has already allowed — including
1079
+ // the lead's first run, whose name is not on disk yet.
1080
+ ...(foreign || persist ? { forceVariant: true } : {}),
1081
+ signal: stop.signal,
1082
+ onProgress: progressLine(deps),
1083
+ });
1084
+ }
1085
+ finally {
1086
+ off();
1087
+ }
1088
+ reportMeasurement(res, variant, deps, foreign ? (project.atcVariant ?? '').trim().toUpperCase() : undefined);
1089
+ // The picture, once, for the two things said after the report. Built lazily:
1090
+ // a refresh that measured nothing and reported nothing does not need it.
1091
+ let merged = null;
1092
+ const picture = () => (merged ??= registerFor(joined.projectDir, joined.outboxDir).register);
1093
+ // I1: which of the objects the run did not report still have something
1094
+ // standing against them. Their state has not moved, and that is worth saying.
1095
+ const standing = res.absentNames.length ? stillStanding(picture(), res.absentNames) : [];
1096
+ if (standing.length) {
1097
+ deps.print(` ! ${standing.length} of the objects the check run did not report had findings before `
1098
+ + `or a fix waiting to be checked: ${upToFive(standing)}. Their state has not changed.`);
1099
+ }
1100
+ // The name is written only now: a run that measured something is the proof
1101
+ // that SAP knows this variant.
1102
+ if (res.file && persist) {
1103
+ try {
1104
+ writeProjectVariant(joined.projectDir, variant);
1105
+ deps.print(`✓ This team project now measures with ${variant}.`);
1106
+ }
1107
+ catch (e) {
1108
+ // The measurement is written and counts; only the team project file could
1109
+ // not be updated, and that is a problem with a folder, not with the run.
1110
+ deps.print(` ! The measurement is written, but ${TEAM_FILE} could not be updated, so this team project still has no check variant. `
1111
+ + `Ask the lead to try again: ${e instanceof Error ? e.message : String(e)}`);
1112
+ }
1113
+ }
1114
+ const stoppedBy = res.stoppedBy ?? (stop.signal.aborted ? 'aborted' : undefined);
1115
+ if (stoppedBy) {
1116
+ const why = STOPPED_WORDS[stoppedBy];
1117
+ deps.print(res.file
1118
+ ? `✗ The run ${why} before it finished. This measurement is partial.`
1119
+ : ` The run ${why} before anything was measured.`);
1120
+ return 3;
1121
+ }
1122
+ if (!res.file)
1123
+ return 1;
1124
+ // A claim whose rule no measurement has ever shown can never be confirmed or refuted. Say so now, by name.
1125
+ const lost = picture().objects.flatMap((o) => o.fixes.filter((f) => f.status === 'fixed' && f.unmatchedRule).map((f) => `${o.key} (${f.rule})`));
1126
+ if (lost.length) {
1127
+ deps.print(` ! ${plural(lost.length, 'claimed fix names', 'claimed fixes name')} a rule no measurement of that object has ever shown, `
1128
+ + `so no measurement can confirm ${lost.length === 1 ? 'it' : 'them'}: ${lost.slice(0, 5).join(', ')}`);
1129
+ }
1130
+ return 0;
1131
+ }
1132
+ // ── outbox / rule ───────────────────────────────────────────────────────────
1133
+ function outbox(_args, deps) {
1134
+ const joined = joinedOrThrow();
1135
+ const dir = path.join(joined.outboxDir, 'writers', joined.writerId, 'records');
1136
+ const files = fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => f.endsWith('.json')).sort() : [];
1137
+ deps.print(`Records this laptop has written for ${joined.projectId}: ${files.length}`);
1138
+ deps.print(` ${dir}`);
1139
+ for (const f of files.slice(-15)) {
1140
+ let rows = '?';
1141
+ try {
1142
+ rows = String(JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')).rows.length);
1143
+ }
1144
+ catch { /* listed anyway */ }
1145
+ deps.print(` ${f} (${rows} rows)`);
1146
+ }
1147
+ deps.print('Each file is plain JSON: object names, decisions, statuses. No source code. Open one in Notepad to check.');
1148
+ return 0;
1149
+ }
1150
+ function rule(args, deps) {
1151
+ const { positional, opts } = flags(args);
1152
+ const [type, name, classification] = positional;
1153
+ if (!type || !name || !CLASSIFICATIONS.includes(classification)) {
1154
+ throw new RegisterError(`Usage: cspeach team rule <TYPE> <NAME> ${CLASSIFICATIONS.join('|')} [--reason "<why>"]`);
1155
+ }
1156
+ const joined = joinedOrThrow();
1157
+ requireLead(readProjectFile(joined.projectDir), deps);
1158
+ const res = writeRecord(joined, 'ruling', [{ key: objectKey(type, name), classification, ...(opts.reason ? { reason: opts.reason } : {}) }], { name: whoAmI(deps) }, {
1159
+ dir: path.join(joined.projectDir, 'rulings'), now: deps.now?.(),
1160
+ ...(deps.rand ? { rand: deps.rand } : {}),
1161
+ });
1162
+ if (!res.file)
1163
+ throw new RegisterError(`${objectKey(type, name)} is not in this team project's baseline.`);
1164
+ deps.print(`✓ Ruled ${objectKey(type, name)} = ${classification}.`);
1165
+ return 0;
1166
+ }