@cspeach/cli 1.1.31 → 1.1.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/loop.js +16 -6
- package/dist/cli.js +13 -4
- package/dist/commands/help.js +16 -1
- package/dist/commands/plan-chain.js +3 -0
- package/dist/commands/plan-gate.js +10 -1
- package/dist/commands/plan-resume.js +4 -0
- package/dist/commands/team-rules.js +187 -0
- package/dist/commands/team.js +632 -90
- package/dist/journey/chain.js +265 -15
- package/dist/journey/clean-core.js +25 -3
- package/dist/journey/flow.js +51 -1
- package/dist/journey/rebuild.js +464 -0
- package/dist/journey/run.js +213 -20
- package/dist/journey/session-deps.js +5 -1
- package/dist/lane/app-chain.js +12 -2
- package/dist/one-shot.js +24 -17
- package/dist/projects/clean-core-confirm.js +38 -5
- package/dist/projects/save-command.js +20 -3
- package/dist/register/baseline.js +39 -2
- package/dist/register/decision-rules.js +35 -0
- package/dist/register/discover.js +49 -19
- package/dist/register/export-files.js +58 -0
- package/dist/register/measure.js +191 -9
- package/dist/register/plan-import.js +6 -3
- package/dist/register/records.js +15 -3
- package/dist/register/rule-proposal.js +4 -0
- package/dist/register/rules-edit.js +257 -0
- package/dist/register/status-file/register-status-file.js +106 -108
- package/dist/register/status-file/report-model.js +569 -0
- package/dist/register/status-file/team-css.js +1 -1
- package/dist/register/status-file-sync.js +1 -0
- package/dist/register/status-html.js +25 -6
- package/dist/register/store.js +4 -3
- package/dist/register/variant.js +65 -0
- package/dist/register/workbooks.js +22 -56
- package/dist/register/write-whole.js +30 -2
- package/dist/renderer/tool-labels.js +2 -1
- package/dist/repl/builtin-commands.js +4 -0
- package/dist/repl/journey-entry.js +4 -2
- package/dist/repl/plan-hop.js +2 -9
- package/dist/repl/rules-menu.js +177 -0
- package/dist/repl/slash-completer.js +1 -0
- package/dist/repl/slash-picker.js +5 -4
- package/dist/repl.js +145 -86
- package/dist/router/classifier.js +8 -4
- package/dist/router/router-core.js +43 -7
- package/dist/router/routing-decision.js +1 -1
- package/dist/runs/bundled-runs.js +100 -0
- package/dist/runs/front-door.js +19 -0
- package/dist/runs/manifest-runs.js +50 -0
- package/dist/runs/run-command.js +24 -0
- package/dist/runs/runs.js +27 -0
- package/dist/runs/shadow.js +100 -0
- package/dist/runs/types.js +1 -0
- package/dist/session/queued-step.js +45 -0
- package/dist/skills/bundled-skills.js +53 -53
- package/dist/tools/dispatch-skill.js +8 -4
- package/dist/tools/lane/app-lane-resolve.js +1 -1
- package/dist/tools/project/rules_propose.js +59 -0
- package/dist/tools/project/team_findings.js +13 -2
- package/dist/upgrade-check.js +49 -4
- package/package.json +120 -120
- package/.cspeach/project.json +0 -4
package/dist/commands/team.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* status [folder] anyone: the register as text
|
|
10
10
|
* outbox what this laptop has recorded, and where
|
|
11
11
|
* rule … lead: settle a disagreement
|
|
12
|
+
* rules … the decision rules: anyone views, the lead changes (team-rules.ts)
|
|
12
13
|
* leave stop recording
|
|
13
14
|
*
|
|
14
15
|
* Every path written here is one a person typed. Nothing is written to a
|
|
@@ -18,19 +19,23 @@
|
|
|
18
19
|
import fs from 'node:fs';
|
|
19
20
|
import os from 'node:os';
|
|
20
21
|
import path from 'node:path';
|
|
21
|
-
import {
|
|
22
|
+
import { isAppLabel, isPatternWord, laneIdFor, levelOf, normalizeRuleId, objectKey, proposeLanes, scopeFromWords, targetsOf, } from '@cspeach/register-core';
|
|
22
23
|
import { LOCAL_PACKAGE, listCustomPackages, pullBaseline, BaselineError } from '../register/baseline.js';
|
|
23
24
|
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
|
-
import { measureFromFile, shown, upToFive } from '../register/atc-file.js';
|
|
25
|
+
import { MAX_API_DOCUMENTS, MAX_SECOND_PASS, measure, noVariantYetMessage, scopeLabel, scopeObjects, variantMismatchMessage, } from '../register/measure.js';
|
|
26
|
+
import { measureFromFile, shown, shownUpTo, upToFive } from '../register/atc-file.js';
|
|
26
27
|
import { writeLeadRecord, writeRecord } from '../register/records.js';
|
|
27
28
|
import { findingsByObject } from '../register/findings.js';
|
|
28
29
|
import { registerFor } from '../register/picture.js';
|
|
29
30
|
import { HELD, combinedSheets, regenerateWorkingList, reportFileNames } from '../register/workbooks.js';
|
|
30
31
|
import { writeXlsxFile } from '../register/xlsx.js';
|
|
31
32
|
import { writeWhole } from '../register/write-whole.js';
|
|
32
|
-
import {
|
|
33
|
+
import { EXPORTS_DIR, exportsOn, setExports, writeExportFiles } from '../register/export-files.js';
|
|
34
|
+
import { previousAsOfIn, reportViewFor, statusHtmlFor } from '../register/status-html.js';
|
|
35
|
+
import { firstBlockLines, awaitingWord, lastCheckWord } from '../register/status-file/report-model.js';
|
|
36
|
+
import { lookupVariant, variantLookupLine } from '../register/variant.js';
|
|
33
37
|
import { teamDiscover, evidenceRows, EVIDENCE_LINE, OUTSIDE_LINE } from './team-discover.js';
|
|
38
|
+
import { rulesCommand } from './team-rules.js';
|
|
34
39
|
import { focusCommand, laneChangeRecord, laneProposal, nowLine, planCommand, priorityCommand, reasonFrom } from './team-plan.js';
|
|
35
40
|
const CLASSIFICATIONS = ['keep', 'fix', 'retire', 'redesign'];
|
|
36
41
|
const TYPES = ['clean-core', 'upgrade', 'build'];
|
|
@@ -119,17 +124,21 @@ export async function runTeamCommand(args, deps) {
|
|
|
119
124
|
case 'status': return status(rest, deps);
|
|
120
125
|
case 'outbox': return outbox(rest, deps);
|
|
121
126
|
case 'rule': return rule(rest, deps);
|
|
127
|
+
case 'rules': return rulesCommand(rest, deps);
|
|
122
128
|
case 'leave': return leave(deps);
|
|
123
129
|
case 'decide': return decide(rest, deps);
|
|
130
|
+
case 'target': return target(rest, deps);
|
|
124
131
|
case 'fixed': return await fixed(rest, deps);
|
|
125
132
|
case 'sheet': return sheet(deps);
|
|
126
133
|
case 'report': return report(rest, deps);
|
|
134
|
+
case 'export': return exportVerb(rest, deps);
|
|
127
135
|
case 'discover': return await teamDiscover(rest, deps);
|
|
128
136
|
case 'focus': return focusCommand(rest, deps);
|
|
129
137
|
case 'priority': return priorityCommand(rest, deps);
|
|
130
138
|
case 'plan': return await planCommand(rest, deps);
|
|
139
|
+
case 'rebuild': return await rebuildVerb(rest, deps);
|
|
131
140
|
default:
|
|
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)');
|
|
141
|
+
deps.print('Usage: cspeach team init|join|lane|refresh|status|outbox|rule|leave|decide|target|fixed|sheet|report|export|discover|focus|priority|plan|start|rebuild|rules (cspeach --help for details)');
|
|
133
142
|
return sub ? 1 : 0;
|
|
134
143
|
}
|
|
135
144
|
}
|
|
@@ -191,16 +200,67 @@ function unknownInitOption(name) {
|
|
|
191
200
|
return said;
|
|
192
201
|
return `${said} Did you mean --${guess}?`;
|
|
193
202
|
}
|
|
194
|
-
const START_USAGE = 'Usage: cspeach team start <packages>
|
|
195
|
-
|
|
203
|
+
const START_USAGE = 'Usage: cspeach team start <packages> | --for "<area words, e.g. FI and SD>" [--developers "<names>, me"] '
|
|
204
|
+
+ '[--name "<name>"] [--atc-variant <NAME>] [--folder <folder>] [--no-measure] [--sap-alias <name>]';
|
|
205
|
+
const START_OPTIONS = ['name', 'atc-variant', 'folder', 'no-measure', 'sap-alias', 'for', 'developers'];
|
|
196
206
|
/** The check a project measures with when nobody names one. */
|
|
197
207
|
export const DEFAULT_ATC_VARIANT = 'ABAP_CLOUD_READINESS';
|
|
208
|
+
/** I16: `--for` reads the packages from the folder's discovery; without one there is nothing to read them from. */
|
|
209
|
+
export const NO_DISCOVERY_LINE = 'Nothing is known about the system yet. Run: cspeach team discover, then try again.';
|
|
210
|
+
/** Step 2 (owner: "we need to give some regex. This is not good."): nobody types a pattern. */
|
|
211
|
+
const NOT_A_PATTERN = 'Name each package in full, for example ZFI_AR — not a pattern.';
|
|
212
|
+
const FOR_NOT_A_PATTERN = 'Say the area or the packages by name, for example --for "FI and SD" — not a pattern.';
|
|
213
|
+
const CONTROL = /[\u0000-\u001f\u007f-\u009f]/;
|
|
214
|
+
/** Longest name a person gives with --as or --developers (F13: like a lead name). */
|
|
215
|
+
export const MAX_PERSON_NAME = 60;
|
|
216
|
+
const sameName = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
|
|
217
|
+
const firstWordOf = (name) => (name.trim().split(/\s+/)[0] ?? '').toLowerCase();
|
|
218
|
+
/**
|
|
219
|
+
* A person's name typed on the command line (`team join --as`, `--developers`,
|
|
220
|
+
* `lane move --to`): plain text, no control characters, at most 60 characters.
|
|
221
|
+
* `what` starts the message, so the line says which option was wrong.
|
|
222
|
+
*/
|
|
223
|
+
export function personName(raw, what, example = 'James Miller') {
|
|
224
|
+
const name = raw === undefined || raw === 'true' ? '' : raw.trim();
|
|
225
|
+
if (!name)
|
|
226
|
+
throw new RegisterError(`${what} needs a name, for example: ${what} "${example}"`);
|
|
227
|
+
if (CONTROL.test(name) || name.length > MAX_PERSON_NAME) {
|
|
228
|
+
throw new RegisterError(`${what} must be a plain name of at most ${MAX_PERSON_NAME} characters, without control characters.`);
|
|
229
|
+
}
|
|
230
|
+
return name;
|
|
231
|
+
}
|
|
232
|
+
/** `--developers "James Miller, Mike Chen, me"`: the names as given, checked, each once. `me` stays `me` here. */
|
|
233
|
+
function developersOf(raw) {
|
|
234
|
+
if (raw === undefined)
|
|
235
|
+
return [];
|
|
236
|
+
const seen = new Set();
|
|
237
|
+
const out = [];
|
|
238
|
+
for (const d of list(raw === 'true' ? '' : raw)) {
|
|
239
|
+
if (isPatternWord(d))
|
|
240
|
+
throw new RegisterError('Name each developer in full, for example "James Miller" — not a pattern.');
|
|
241
|
+
const name = personName(d, '--developers');
|
|
242
|
+
if (seen.has(name.toLowerCase()))
|
|
243
|
+
continue;
|
|
244
|
+
seen.add(name.toLowerCase());
|
|
245
|
+
out.push(name);
|
|
246
|
+
}
|
|
247
|
+
if (!out.length)
|
|
248
|
+
throw new RegisterError('--developers needs at least one name, for example: --developers "James Miller, me"');
|
|
249
|
+
return out;
|
|
250
|
+
}
|
|
198
251
|
/**
|
|
199
252
|
* 1.1.31 (owner, 2026-10-07: "why doesn't CSPeach remember all that?") — one
|
|
200
253
|
* command for what init + join + lane add said in three: the folder you are
|
|
201
254
|
* in, a name from the packages, you as the lead, the default check, your
|
|
202
255
|
* results straight into the folder, and every object yours. The long verbs
|
|
203
256
|
* stay for big team setups.
|
|
257
|
+
*
|
|
258
|
+
* Step 2 (spec §3.6, I16) — set up by name: `--for "FI and SD"` reads the
|
|
259
|
+
* packages from the folder's discovery (`scopeFromWords`, names only), and
|
|
260
|
+
* `--developers "James Miller, Mike Chen, me"` proposes one lane each,
|
|
261
|
+
* balanced by work (`proposeLanes`), written as package NAMES. Nothing is
|
|
262
|
+
* measured on that path (`--no-measure`): the lanes are balanced by objects
|
|
263
|
+
* and the table says so. A typed pattern is refused in one line.
|
|
204
264
|
*/
|
|
205
265
|
async function start(args, deps) {
|
|
206
266
|
const { positional, opts } = flags(args, ['no-measure']);
|
|
@@ -208,21 +268,48 @@ async function start(args, deps) {
|
|
|
208
268
|
if (!START_OPTIONS.includes(name))
|
|
209
269
|
throw new RegisterError(`--${name} is not an option of team start.\n${START_USAGE}`);
|
|
210
270
|
}
|
|
211
|
-
const
|
|
212
|
-
if (
|
|
271
|
+
const typed = positional.join(',').split(',').map((p) => p.trim().toUpperCase()).filter(Boolean);
|
|
272
|
+
if (typed.some(isPatternWord))
|
|
273
|
+
throw new RegisterError(NOT_A_PATTERN);
|
|
274
|
+
if (opts.for === 'true')
|
|
275
|
+
throw new RegisterError(START_USAGE);
|
|
276
|
+
const forWords = (opts.for ?? '').trim();
|
|
277
|
+
if (forWords && forWords.split(/[\s,;&]+/).some(isPatternWord))
|
|
278
|
+
throw new RegisterError(FOR_NOT_A_PATTERN);
|
|
279
|
+
if (forWords && typed.length)
|
|
280
|
+
throw new RegisterError(`Name the packages or say --for, not both.\n${START_USAGE}`);
|
|
281
|
+
const developers = developersOf(opts.developers);
|
|
282
|
+
if (!forWords && !typed.length)
|
|
213
283
|
throw new RegisterError(START_USAGE);
|
|
214
284
|
const dir = path.resolve(opts.folder && opts.folder !== 'true' ? opts.folder : process.cwd());
|
|
215
285
|
if (fs.existsSync(path.join(dir, TEAM_FILE)))
|
|
216
286
|
throw new RegisterError('This folder already holds a team project. Start a new one in an empty folder, or carry on with this one.');
|
|
287
|
+
let packages = [...new Set(typed)];
|
|
288
|
+
let unmatched = [];
|
|
289
|
+
if (forWords) {
|
|
290
|
+
const found = readEstate(dir);
|
|
291
|
+
if (!found) {
|
|
292
|
+
deps.print(NO_DISCOVERY_LINE);
|
|
293
|
+
return 1;
|
|
294
|
+
}
|
|
295
|
+
const scope = scopeFromWords(found.estate, forWords);
|
|
296
|
+
unmatched = scope.unmatched.map((w) => shown(w));
|
|
297
|
+
if (!scope.packages.length) {
|
|
298
|
+
throw new RegisterError(`Nothing in the inventory matched: ${unmatched.join(', ') || shown(forWords)}. Name an area such as FI, or a package in full.`);
|
|
299
|
+
}
|
|
300
|
+
packages = scope.packages;
|
|
301
|
+
}
|
|
217
302
|
const lead = deps.identity().name;
|
|
218
|
-
const name = opts.name && opts.name !== 'true' ? opts.name : packages.join(', ');
|
|
303
|
+
const name = opts.name && opts.name !== 'true' ? opts.name : forWords ? shownUpTo(forWords, 80) : packages.join(', ');
|
|
219
304
|
const variant = opts['atc-variant'] && opts['atc-variant'] !== 'true' ? opts['atc-variant'] : DEFAULT_ATC_VARIANT;
|
|
305
|
+
// I16: a setup by name measures nothing yet; the first check is the lead's next step.
|
|
306
|
+
const noMeasure = !!opts['no-measure'] || !!forWords || developers.length > 0;
|
|
220
307
|
// The three verbs run as they always do; their detail is kept back and the
|
|
221
308
|
// few lines that matter are said once, below.
|
|
222
309
|
const said = [];
|
|
223
310
|
const quiet = { ...deps, print: (l) => said.push(l) };
|
|
224
311
|
const initArgs = [dir, '--name', name, '--lead', lead, '--packages', packages.join(','), '--atc-variant', variant,
|
|
225
|
-
...(
|
|
312
|
+
...(noMeasure ? ['--no-measure'] : []), ...(opts['sap-alias'] && opts['sap-alias'] !== 'true' ? ['--sap-alias', opts['sap-alias']] : [])];
|
|
226
313
|
const code = await init(initArgs, quiet);
|
|
227
314
|
if (code !== 0) {
|
|
228
315
|
for (const l of said)
|
|
@@ -230,19 +317,62 @@ async function start(args, deps) {
|
|
|
230
317
|
return code;
|
|
231
318
|
}
|
|
232
319
|
join([dir, '--outbox', dir], quiet);
|
|
233
|
-
|
|
234
|
-
|
|
320
|
+
let lanesWritten = null;
|
|
321
|
+
let note = '';
|
|
322
|
+
let laneId = (lead.trim().split(/\s+/)[0] ?? '').toLowerCase().replace(/[^a-z0-9]/g, '') || 'me';
|
|
323
|
+
if (developers.length) {
|
|
324
|
+
// `me` is the seat, named only now: after the join, it is the name this folder records under.
|
|
325
|
+
const me = whoAmI(deps);
|
|
326
|
+
const owners = [];
|
|
327
|
+
for (const d of developers.map((x) => (x.toLowerCase() === 'me' ? me : x)))
|
|
328
|
+
if (!owners.some((o) => sameName(o, d)))
|
|
329
|
+
owners.push(d);
|
|
330
|
+
const project = readProjectFile(dir);
|
|
331
|
+
const proposal = proposeLanes(registerFor(dir).register, owners);
|
|
332
|
+
note = proposal.note ?? 'balanced by the work: objects with findings count three times, then findings, then objects';
|
|
333
|
+
lanesWritten = proposal.lanes.map((l) => ({ id: l.id, owner: l.owner, packages: l.packages, objects: l.objects }));
|
|
334
|
+
writeLanes(dir, project.projectId, lanesWritten.filter((l) => l.packages.length).map((l) => ({ id: l.id, owner: l.owner, packages: l.packages, namePatterns: [] })));
|
|
335
|
+
laneId = lanesWritten.find((l) => sameName(l.owner, me) && l.packages.length)?.id ?? '';
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
await lane(['add', laneId, '--owner', lead, '--packages', packages.join(',')], quiet);
|
|
339
|
+
}
|
|
235
340
|
const pulled = said.map((l) => /: (\d+) objects? in \d+ packages?\. Every package matched/.exec(l)).find(Boolean);
|
|
236
341
|
const measured = said.map((l) => /^✓ (\d+) objects? measured, (\d+) with findings/.exec(l)).find(Boolean);
|
|
237
342
|
const objects = pulled ? Number(pulled[1]) : readBaseline(dir).length;
|
|
238
343
|
deps.print(`✓ Started "${name}" in this folder. Lead: ${lead}.`);
|
|
344
|
+
if (forWords)
|
|
345
|
+
deps.print(` Packages: ${packages.join(', ')}.`);
|
|
346
|
+
if (unmatched.length)
|
|
347
|
+
deps.print(` Nothing in the inventory matched: ${unmatched.join(', ')}.`);
|
|
348
|
+
// §3.1: what the variant lookup found, said on every path (init's own line, kept back above).
|
|
349
|
+
for (const l of said.filter((x) => /^ {2}(! )?CSPeach could not (find|confirm) the variant /.test(x)))
|
|
350
|
+
deps.print(l);
|
|
239
351
|
deps.print(` ${plural(objects, 'object', 'objects')}, matched against the system's own count.`);
|
|
240
352
|
if (measured)
|
|
241
353
|
deps.print(` First check (${variant.toUpperCase()}): ${measured[2]} of ${measured[1]} objects with findings.`);
|
|
242
|
-
|
|
354
|
+
// I15: above 500 objects the first check is per package, also when this start measured nothing on purpose.
|
|
355
|
+
else if (said.includes(FIRST_CHECK_PER_PACKAGE_LINE) || objects > FIRST_CHECK_WHOLE_MAX)
|
|
356
|
+
deps.print(FIRST_CHECK_PER_PACKAGE_LINE);
|
|
357
|
+
else if (lanesWritten)
|
|
358
|
+
deps.print(' No check run yet: cspeach team refresh --all measures it.');
|
|
359
|
+
else if (noMeasure)
|
|
243
360
|
deps.print(' No check run yet: cspeach team refresh measures it.');
|
|
244
|
-
|
|
245
|
-
|
|
361
|
+
if (lanesWritten) {
|
|
362
|
+
deps.print(` Lanes, ${note}.`);
|
|
363
|
+
for (const l of lanesWritten) {
|
|
364
|
+
deps.print(l.packages.length
|
|
365
|
+
? ` ${l.owner} (${l.id}): ${l.packages.join(', ')} · ${plural(l.objects, 'object', 'objects')}`
|
|
366
|
+
: ` ${l.owner}: no package yet.`);
|
|
367
|
+
}
|
|
368
|
+
deps.print(' Move a package: cspeach team lane move --packages <names> --to <name>');
|
|
369
|
+
deps.print(' Your results go straight into this folder.');
|
|
370
|
+
}
|
|
371
|
+
else {
|
|
372
|
+
deps.print(` You own all ${plural(objects, 'object', 'objects')}. Your results go straight into this folder.`);
|
|
373
|
+
}
|
|
374
|
+
if (laneId)
|
|
375
|
+
deps.print(` Next, in CSPeach: /abap-clean-core triage lane ${laneId}`);
|
|
246
376
|
return 0;
|
|
247
377
|
}
|
|
248
378
|
async function init(args, deps) {
|
|
@@ -283,6 +413,18 @@ ${INIT_USAGE}`);
|
|
|
283
413
|
// The newest discovery in this folder, when there is one and it is not turned off (spec 2026-10-06 §7).
|
|
284
414
|
const discovery = opts['no-from-discovery'] ? null : readEstate(dir);
|
|
285
415
|
const { sql, system, atc } = await deps.connect(opts['sap-alias']);
|
|
416
|
+
// D5 / I8: the named variant, looked up on the system before it is saved.
|
|
417
|
+
// A global variant found needs no words; anything else is said, and the
|
|
418
|
+
// confirmed name is still saved (the first check says if SAP refuses it).
|
|
419
|
+
if (atcVariant) {
|
|
420
|
+
const look = await lookupVariant(sql, atcVariant);
|
|
421
|
+
if (look === 'unreadable' || !look.found || look.privateTo) {
|
|
422
|
+
const sid = shown(system?.sid ?? 'the system');
|
|
423
|
+
deps.print(look !== 'unreadable' && !look.found
|
|
424
|
+
? ` ! CSPeach could not find the variant ${atcVariant} on ${sid}. It is saved as named; the first check shows whether SAP knows it.`
|
|
425
|
+
: ` ${look === 'unreadable' ? '' : '! '}${variantLookupLine(atcVariant, sid, look)}`);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
286
428
|
deps.print('Reading the package list from SAP…');
|
|
287
429
|
const all = await listCustomPackages(sql);
|
|
288
430
|
const patterns = list(opts.packages);
|
|
@@ -316,8 +458,8 @@ ${INIT_USAGE}`);
|
|
|
316
458
|
createdAt: now.toISOString(),
|
|
317
459
|
};
|
|
318
460
|
fs.mkdirSync(path.join(dir, 'baseline'), { recursive: true });
|
|
319
|
-
|
|
320
|
-
|
|
461
|
+
writeWhole(path.join(dir, BASELINE_FILE), baselineText);
|
|
462
|
+
writeWhole(path.join(dir, TEAM_FILE), `${JSON.stringify(project, null, 2)}\n`);
|
|
321
463
|
writeLanes(dir, projectId, []);
|
|
322
464
|
const evidenceLines = discovery || opts['from-discovery']
|
|
323
465
|
? firstEvidence(dir, projectId, pull.objects, discovery, { name: lead, writer: writerIdFor(given || deps.identity().name, deps.machine?.()) }, now, deps)
|
|
@@ -347,11 +489,19 @@ ${INIT_USAGE}`);
|
|
|
347
489
|
deps.print(l);
|
|
348
490
|
return joinedRc;
|
|
349
491
|
}
|
|
350
|
-
|
|
492
|
+
// Step 2 (I15): above 500 objects one run over the whole baseline would be cut
|
|
493
|
+
// by its 30-minute budget. No cut first check: the per-package one is named instead.
|
|
494
|
+
if (pull.objects.length > FIRST_CHECK_WHOLE_MAX && !opts['no-measure'])
|
|
495
|
+
deps.print(FIRST_CHECK_PER_PACKAGE_LINE);
|
|
496
|
+
else
|
|
497
|
+
await firstMeasurement(dir, project, pull.objects, opts, atc, deps, sql);
|
|
351
498
|
for (const l of joinLines)
|
|
352
499
|
deps.print(l);
|
|
353
500
|
return 0;
|
|
354
501
|
}
|
|
502
|
+
/** Above this many objects, `init` does not run the first check over the whole baseline (I15). */
|
|
503
|
+
export const FIRST_CHECK_WHOLE_MAX = 500;
|
|
504
|
+
export const FIRST_CHECK_PER_PACKAGE_LINE = ' First check per package: cspeach team refresh --all --per-package (it resumes if stopped).';
|
|
355
505
|
/**
|
|
356
506
|
* Per chosen package, what the estate knows beside the live count: its
|
|
357
507
|
* description, application area and last change. Packages the estate does
|
|
@@ -421,7 +571,7 @@ const CANNOT_WRITE = new Set(['EPERM', 'EACCES', 'EROFS', 'ENOSPC', 'EISDIR', 'E
|
|
|
421
571
|
* an estate the run reports nothing back for at all — no object listed — leaves
|
|
422
572
|
* nothing to write, and then that is said out loud and `init` still succeeds.
|
|
423
573
|
*/
|
|
424
|
-
async function firstMeasurement(dir, project, baseline, opts, atc, deps) {
|
|
574
|
+
async function firstMeasurement(dir, project, baseline, opts, atc, deps, sql) {
|
|
425
575
|
const LATER = 'Measure when you can: cspeach team refresh --all';
|
|
426
576
|
if (opts['no-measure']) {
|
|
427
577
|
deps.print(' Not measured (--no-measure). When you are ready: cspeach team refresh --all');
|
|
@@ -454,6 +604,7 @@ async function firstMeasurement(dir, project, baseline, opts, atc, deps) {
|
|
|
454
604
|
joined: joinedOrThrow(), project, baseline, lanes: [], scope: { kind: 'all' }, variant, atc, by: { name: whoAmI(deps) },
|
|
455
605
|
dir: path.join(dir, 'baseline'), fileName: 'measurement-0.json',
|
|
456
606
|
confirmAbsent: true,
|
|
607
|
+
...(sql ? { sql } : {}),
|
|
457
608
|
...(deps.now ? { now: deps.now() } : {}), ...(deps.rand ? { rand: deps.rand } : {}),
|
|
458
609
|
signal: stop.signal,
|
|
459
610
|
onProgress: progressLine(deps),
|
|
@@ -497,11 +648,15 @@ async function firstMeasurement(dir, project, baseline, opts, atc, deps) {
|
|
|
497
648
|
function join(args, deps, asName) {
|
|
498
649
|
const { positional, opts } = flags(args);
|
|
499
650
|
if (!positional[0])
|
|
500
|
-
throw new RegisterError('Usage: cspeach team join <team project folder> [--outbox <folder>]');
|
|
651
|
+
throw new RegisterError('Usage: cspeach team join <team project folder> [--outbox <folder>] [--as "<your name>"]');
|
|
652
|
+
// F13: `--as "James Miller"` joins under the lane owner's name, kept in
|
|
653
|
+
// JoinedProject.name, so the writer id and every record are that person.
|
|
501
654
|
const projectDir = path.resolve(positional[0]);
|
|
502
655
|
const project = readProjectFile(projectDir);
|
|
503
656
|
const objects = readBaseline(projectDir).length; // also verifies the checksum
|
|
504
657
|
const state = loadState();
|
|
658
|
+
if (opts.as !== undefined && asName === undefined)
|
|
659
|
+
asName = joinAsName(opts.as, projectDir, project, state.projects[project.projectId]?.name, deps);
|
|
505
660
|
const outboxDir = path.resolve(opts.outbox ?? state.projects[project.projectId]?.outboxDir ?? defaultOutboxDir(project.projectId));
|
|
506
661
|
// A name given once is kept: joining again must not turn the lead back into
|
|
507
662
|
// whatever the machine calls them.
|
|
@@ -515,13 +670,93 @@ function join(args, deps, asName) {
|
|
|
515
670
|
// Outbox is the team project folder itself: records land where everyone reads them.
|
|
516
671
|
if (path.relative(projectDir, outboxDir) === '') {
|
|
517
672
|
deps.print(' Your results are written straight into the team project folder, under writers. Nothing to hand in.');
|
|
518
|
-
return 0;
|
|
519
673
|
}
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
674
|
+
else {
|
|
675
|
+
deps.print(` Your own results are recorded on this laptop, in ${outboxDir}`);
|
|
676
|
+
deps.print(' CSPeach does not send them anywhere. Hand them in by copying that folder\'s "writers" folder into the team project folder,');
|
|
677
|
+
deps.print(' or keep the outbox inside your own synced folder: cspeach team join <folder> --outbox <folder>');
|
|
678
|
+
}
|
|
679
|
+
const owns = ownsNothingLine(projectDir, project, saved ?? deps.identity().name);
|
|
680
|
+
if (owns)
|
|
681
|
+
deps.print(` ${owns}`);
|
|
523
682
|
return 0;
|
|
524
683
|
}
|
|
684
|
+
/**
|
|
685
|
+
* A developer's first day (spec §3.6, F13, cut 5): someone who owns no lane in
|
|
686
|
+
* a folder that has lanes is told so — never left on "Done" with nothing to
|
|
687
|
+
* do. When one lane owner shares their first name, the line says how to join
|
|
688
|
+
* under that name; two such owners are never guessed between. The lead, and a
|
|
689
|
+
* folder with no lanes yet, get nothing. Null when there is nothing to say.
|
|
690
|
+
*/
|
|
691
|
+
export function ownsNothingLine(projectDir, project, me) {
|
|
692
|
+
const lanes = readLanes(projectDir);
|
|
693
|
+
if (!lanes.length || sameName(me, project.lead.name))
|
|
694
|
+
return null;
|
|
695
|
+
const m = ownerMatch(lanes, me);
|
|
696
|
+
if (m.kind === 'exact')
|
|
697
|
+
return null;
|
|
698
|
+
if (m.kind === 'one')
|
|
699
|
+
return `Are you ${m.owner}? Join with: cspeach team join . --as "${m.owner}"`;
|
|
700
|
+
if (m.kind === 'several')
|
|
701
|
+
return severalLine(m);
|
|
702
|
+
return `You own nothing here yet. Ask ${shown(project.lead.name)} to move packages to you.`;
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* The lane owner a seat may be (architecture §D, F13, cut 5) — the ONE place
|
|
706
|
+
* this is decided, for `team join` and the journey's JOIN card. Owners compare
|
|
707
|
+
* case-insensitively ("James Miller" and "james miller" are one person, the
|
|
708
|
+
* first spelling kept); the first word alone makes a candidate. Names come
|
|
709
|
+
* out of a synced file, so the owner returned is cleaned for the screen.
|
|
710
|
+
*/
|
|
711
|
+
export function ownerMatch(lanes, me) {
|
|
712
|
+
if (lanes.some((l) => sameName(l.owner, me)))
|
|
713
|
+
return { kind: 'exact' };
|
|
714
|
+
const first = firstWordOf(me);
|
|
715
|
+
const people = new Map();
|
|
716
|
+
for (const l of lanes) {
|
|
717
|
+
if (!first || firstWordOf(l.owner) !== first)
|
|
718
|
+
continue;
|
|
719
|
+
const key = l.owner.trim().toLowerCase();
|
|
720
|
+
const seen = people.get(key);
|
|
721
|
+
if (seen)
|
|
722
|
+
seen.laneIds.push(l.id);
|
|
723
|
+
else
|
|
724
|
+
people.set(key, { owner: shown(l.owner).trim(), laneIds: [l.id] });
|
|
725
|
+
}
|
|
726
|
+
const found = [...people.values()];
|
|
727
|
+
if (found.length > 1)
|
|
728
|
+
return { kind: 'several', called: found[0].owner.split(/\s+/)[0], count: found.length };
|
|
729
|
+
return found[0] ? { kind: 'one', ...found[0] } : { kind: 'none' };
|
|
730
|
+
}
|
|
731
|
+
/** Cut 5: never a guess between two people. */
|
|
732
|
+
export const severalLine = (m) => `${m.count === 2 ? 'Two' : m.count} people here are called ${m.called}. Join with: cspeach team join . --as "<full name>"`;
|
|
733
|
+
/**
|
|
734
|
+
* `--as` (F13; review finding 2): only the name of someone who owns a lane
|
|
735
|
+
* here, in that lane's spelling. The lead's name only from the lead's own
|
|
736
|
+
* seat — otherwise one option would hand any joiner the lead's powers.
|
|
737
|
+
*/
|
|
738
|
+
function joinAsName(raw, projectDir, project, savedName, deps) {
|
|
739
|
+
const name = personName(raw, '--as');
|
|
740
|
+
const lead = project.lead.name;
|
|
741
|
+
if (sameName(name, lead)) {
|
|
742
|
+
if (![savedName, deps.identity().name].some((n) => !!n && sameName(n, lead))) {
|
|
743
|
+
throw new RegisterError(`Only the lead's own seat joins as ${shown(lead)}.`);
|
|
744
|
+
}
|
|
745
|
+
return lead;
|
|
746
|
+
}
|
|
747
|
+
const lanes = readLanes(projectDir);
|
|
748
|
+
const owner = lanes.find((l) => sameName(l.owner, name));
|
|
749
|
+
if (!owner) {
|
|
750
|
+
const owners = [];
|
|
751
|
+
for (const l of lanes)
|
|
752
|
+
if (!owners.some((o) => sameName(o, l.owner)))
|
|
753
|
+
owners.push(shown(l.owner).trim());
|
|
754
|
+
throw new RegisterError(owners.length
|
|
755
|
+
? `--as takes the name of someone who owns a lane here: ${owners.join(', ')}.`
|
|
756
|
+
: '--as takes the name of someone who owns a lane here; nobody does yet.');
|
|
757
|
+
}
|
|
758
|
+
return owner.owner.trim();
|
|
759
|
+
}
|
|
525
760
|
function leave(deps) {
|
|
526
761
|
const state = loadState();
|
|
527
762
|
if (!state.active) {
|
|
@@ -551,6 +786,8 @@ async function lane(args, deps) {
|
|
|
551
786
|
return 0;
|
|
552
787
|
}
|
|
553
788
|
requireLead(project, deps);
|
|
789
|
+
if (verb === 'move')
|
|
790
|
+
return laneMove(positional, opts, joined, project, lanes, deps);
|
|
554
791
|
const id = positional[0];
|
|
555
792
|
if (!id)
|
|
556
793
|
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 "…"');
|
|
@@ -566,7 +803,7 @@ async function lane(args, deps) {
|
|
|
566
803
|
return 0;
|
|
567
804
|
}
|
|
568
805
|
if (verb !== 'add')
|
|
569
|
-
throw new RegisterError('lane: use add, remove, list or propose.');
|
|
806
|
+
throw new RegisterError('lane: use add, remove, move, list or propose.');
|
|
570
807
|
const next = { id, owner: opts.owner ?? '', packages: list(opts.packages), namePatterns: list(opts.names) };
|
|
571
808
|
if (!next.owner || (!next.packages.length && !next.namePatterns.length)) {
|
|
572
809
|
throw new RegisterError('A lane needs --owner and at least one of --packages or --names.');
|
|
@@ -588,12 +825,75 @@ async function lane(args, deps) {
|
|
|
588
825
|
deps.print(laneChangeRecord(deps, 'add', lanes.find((l) => l.id === id), next, id, reason));
|
|
589
826
|
return 0;
|
|
590
827
|
}
|
|
828
|
+
const MOVE_USAGE = 'Usage: cspeach team lane move --packages <names> --to <developer> [--reason "<why>"]';
|
|
829
|
+
/** The first pattern a lane holds (a package with `*`, or any name pattern); undefined for a lane of names. */
|
|
830
|
+
const patternOf = (l) => l.packages.find((p) => p.includes('*')) ?? l.namePatterns[0];
|
|
831
|
+
/**
|
|
832
|
+
* `team lane move --packages ZSD_ORDERS --to mike` (spec §3.6, F14): package
|
|
833
|
+
* NAMES move from whichever lane holds them to the developer's lane — a new
|
|
834
|
+
* lane of names when that developer has none yet. Lanes of names only: a lane
|
|
835
|
+
* that holds a pattern cannot give up one package, so the move is refused in
|
|
836
|
+
* one line and nothing is written. A lane left empty is removed.
|
|
837
|
+
*/
|
|
838
|
+
function laneMove(positional, opts, joined, project, lanes, deps) {
|
|
839
|
+
const names = [...new Set(list(opts.packages === 'true' ? '' : opts.packages).map((p) => p.toUpperCase()))];
|
|
840
|
+
if (positional.length || !names.length || !opts.to || opts.to === 'true')
|
|
841
|
+
throw new RegisterError(MOVE_USAGE);
|
|
842
|
+
if (names.some(isPatternWord))
|
|
843
|
+
throw new RegisterError('Name each package in full, for example ZSD_ORDERS — not a pattern.');
|
|
844
|
+
const known = new Set(readBaseline(joined.projectDir).map((o) => o.package.toUpperCase()));
|
|
845
|
+
for (const n of names)
|
|
846
|
+
if (!known.has(n))
|
|
847
|
+
throw new RegisterError(`${shown(n)} is not a package of this team project.`);
|
|
848
|
+
const to = personName(opts.to, '--to');
|
|
849
|
+
const target = lanes.find((l) => l.id.toLowerCase() === to.toLowerCase()) ?? lanes.find((l) => sameName(l.owner, to));
|
|
850
|
+
const holders = lanes.filter((l) => l !== target && l.packages.some((p) => names.some((n) => matches(p, n))));
|
|
851
|
+
for (const l of [...holders, ...(target ? [target] : [])]) {
|
|
852
|
+
const p = patternOf(l);
|
|
853
|
+
if (p) {
|
|
854
|
+
throw new RegisterError(`Lane ${l.id} uses the pattern ${p}; CSPeach cannot move one package out of a pattern. `
|
|
855
|
+
+ `Rewrite the lane with names: cspeach team lane add ${l.id} --owner … --packages <names>.`);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
// Review finding 7: a move that changes nothing says so and writes nothing.
|
|
859
|
+
if (target && !holders.length && names.every((n) => target.packages.some((p) => p.toUpperCase() === n))) {
|
|
860
|
+
deps.print(`Nothing to move: ${names.join(', ')} ${names.length === 1 ? 'is' : 'are'} already in ${shown(target.owner)}'s lane.`);
|
|
861
|
+
return 0;
|
|
862
|
+
}
|
|
863
|
+
const reason = reasonFrom(opts, false);
|
|
864
|
+
const into = target
|
|
865
|
+
? { ...target, packages: [...target.packages, ...names.filter((n) => !target.packages.some((p) => p.toUpperCase() === n))] }
|
|
866
|
+
: { id: laneIdFor(to, new Set(lanes.map((l) => l.id))), owner: to, packages: names, namePatterns: [] };
|
|
867
|
+
const removed = [];
|
|
868
|
+
const next = [];
|
|
869
|
+
for (const l of lanes) {
|
|
870
|
+
if (l === target) {
|
|
871
|
+
next.push(into);
|
|
872
|
+
continue;
|
|
873
|
+
}
|
|
874
|
+
if (!holders.includes(l)) {
|
|
875
|
+
next.push(l);
|
|
876
|
+
continue;
|
|
877
|
+
}
|
|
878
|
+
const left = l.packages.filter((p) => !names.includes(p.toUpperCase()));
|
|
879
|
+
if (left.length || l.namePatterns.length)
|
|
880
|
+
next.push({ ...l, packages: left });
|
|
881
|
+
else
|
|
882
|
+
removed.push(l.id);
|
|
883
|
+
}
|
|
884
|
+
if (!target)
|
|
885
|
+
next.push(into);
|
|
886
|
+
writeLanes(joined.projectDir, project.projectId, next);
|
|
887
|
+
const mine = registerFor(joined.projectDir, joined.outboxDir).register.lanes.find((l) => l.id === into.id);
|
|
888
|
+
deps.print(`✓ ${names.join(', ')} moved to ${shown(into.owner)} (${into.id}): `
|
|
889
|
+
+ `${plural(into.packages.length, 'package', 'packages')}, ${plural(mine?.objects ?? 0, 'object', 'objects')}.`);
|
|
890
|
+
for (const id of removed)
|
|
891
|
+
deps.print(` Lane ${id} held nothing else and was removed.`);
|
|
892
|
+
if (reason)
|
|
893
|
+
deps.print(laneChangeRecord(deps, 'add', target, into, into.id, reason));
|
|
894
|
+
return 0;
|
|
895
|
+
}
|
|
591
896
|
// ── status ──────────────────────────────────────────────────────────────────
|
|
592
|
-
const STATE_WORDS = [
|
|
593
|
-
['verified', 'verified'], ['clean', 'clean'], ['claimed', 'claimed, not measured since'], ['working', 'in work'],
|
|
594
|
-
['decided', 'decided, not started'], ['untouched', 'untouched'], ['did-not-hold', 'fix did not hold'],
|
|
595
|
-
['regressed', 'regressed'], ['retired', 'retired'],
|
|
596
|
-
];
|
|
597
897
|
function status(args, deps) {
|
|
598
898
|
const { positional } = flags(args);
|
|
599
899
|
const joined = activeProject();
|
|
@@ -604,53 +904,39 @@ function status(args, deps) {
|
|
|
604
904
|
const { register: r, files, unreadable, ownFiles, narrowed } = registerFor(projectDir, positional[0] ? undefined : joined?.outboxDir);
|
|
605
905
|
const t = r.totals;
|
|
606
906
|
deps.print(`${project.name} (${project.type}, lead ${project.lead.name})`);
|
|
607
|
-
//
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
// A clean sheet out of a file is somebody's export, not this system speaking.
|
|
616
|
-
// The words "verified" and "clean" do not change; the line says how many of
|
|
617
|
-
// them were measured one step removed, so nobody reads them as live proof.
|
|
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.
|
|
620
|
-
deps.print(`${t.objects} objects, ${t.active} active. ${t.decided} decided. `
|
|
621
|
-
+ `${t.cleanNothingToDecide ? `${t.cleanNothingToDecide} clean, nothing to decide. ` : ''}`
|
|
622
|
-
+ `${t.byState.verified} verified, ${t.byState.clean} clean`
|
|
623
|
-
+ `${fromFile ? ` — ${fromFile} of them measured from a file` : ''}.`);
|
|
624
|
-
// The newest run that counted — by its time, not by the order the files were
|
|
625
|
-
// read in — so "from a file" is said of the run the date belongs to.
|
|
626
|
-
const lastRun = [...r.runs].reverse().find((run) => run.at === r.lastMeasuredAt);
|
|
627
|
-
deps.print(r.lastMeasuredAt
|
|
628
|
-
? `Last measured ${r.lastMeasuredAt.slice(0, 16).replace('T', ' ')} UTC${lastRun?.basis === 'file' ? ', from a file' : ''}.`
|
|
629
|
-
: 'Not measured yet: "verified" and "clean" need an ATC run.');
|
|
630
|
-
deps.print(nowLine(r));
|
|
907
|
+
// Step 2 (the one report): the first screen is the status page's first
|
|
908
|
+
// block, from the same model — coverage first, then the numbers. The
|
|
909
|
+
// previous page in reports/ says where "changed since" starts.
|
|
910
|
+
const asOf = (deps.now?.() ?? new Date()).toISOString();
|
|
911
|
+
const previousAsOf = previousAsOfIn(path.join(projectDir, 'reports', reportFileNames(project.name).html));
|
|
912
|
+
const view = reportViewFor({ project, register: r, asOf, previousAsOf });
|
|
913
|
+
for (const l of firstBlockLines(view))
|
|
914
|
+
deps.print(l);
|
|
631
915
|
deps.print('');
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
916
|
+
deps.print(nowLine(r));
|
|
917
|
+
for (const l of targetLines(project))
|
|
918
|
+
deps.print(l);
|
|
919
|
+
// Spec §3.6: a joiner who owns nothing reads that first, never a finished picture.
|
|
920
|
+
if (!positional[0] && joined) {
|
|
921
|
+
const owns = ownsNothingLine(projectDir, project, whoAmI(deps));
|
|
922
|
+
if (owns)
|
|
923
|
+
deps.print(owns);
|
|
924
|
+
}
|
|
635
925
|
deps.print('');
|
|
636
|
-
if (
|
|
637
|
-
//
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
+ `${String(laneActiveOf(l)).padStart(6)} ${String(laneProvenOf(l)).padStart(6)} `
|
|
644
|
-
+ `${l.lastRecordAt ? l.lastRecordAt.slice(0, 10) : 'nothing yet'}`);
|
|
926
|
+
if (view.lanes.length) {
|
|
927
|
+
// Every lane together, in the order the lanes were set up — never ranked.
|
|
928
|
+
deps.print(`${'Developer'.padEnd(20)} Objects Decided Fixes proven Awaiting check Kept Rebuilds Fix failed Last check`);
|
|
929
|
+
for (const row of view.lanes) {
|
|
930
|
+
deps.print(`${row.developer.padEnd(20)} ${String(row.objects).padStart(7)} ${String(row.decided).padStart(7)} `
|
|
931
|
+
+ `${String(row.fixesProven).padStart(12)} ${awaitingWord(row).padEnd(18)} ${String(row.kept).padStart(4)} `
|
|
932
|
+
+ `${String(row.rebuilds).padStart(8)} ${String(row.fixFailed).padStart(10)} ${lastCheckWord(row)}`);
|
|
645
933
|
}
|
|
646
|
-
//
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
deps.print(` ${
|
|
934
|
+
// What each lane does next, in the priority order every surface sorts by. No %.
|
|
935
|
+
for (const row of view.lanes) {
|
|
936
|
+
if (row.laneId === null)
|
|
937
|
+
continue;
|
|
938
|
+
deps.print(` ${row.developer}: Next: ${row.next.length ? row.next.join(', ') : 'nothing open'}`);
|
|
651
939
|
}
|
|
652
|
-
// Said once per lane it is true of. A lane set up before this rule held
|
|
653
|
-
// both a package list and a name list, and took the whole package.
|
|
654
940
|
for (const id of narrowed)
|
|
655
941
|
deps.print(`Lane ${id} now means: in these packages AND matching these names.`);
|
|
656
942
|
deps.print('');
|
|
@@ -1043,6 +1329,38 @@ function notMeasuredLines(res, deps, words = {}) {
|
|
|
1043
1329
|
+ someNames(res.unsupportedNames));
|
|
1044
1330
|
}
|
|
1045
1331
|
}
|
|
1332
|
+
/**
|
|
1333
|
+
* What reading API names found and cost, said in the summary line: "Read n
|
|
1334
|
+
* API names", and — when finding documents were read — how many and how long
|
|
1335
|
+
* (each is one call to SAP). Findings left unnamed are counted with the reason:
|
|
1336
|
+
* they carry no `uses`, so they stay open for B.
|
|
1337
|
+
*/
|
|
1338
|
+
export function apiNamesWords(a) {
|
|
1339
|
+
if (!a || !a.findings)
|
|
1340
|
+
return { suffix: '', warning: null };
|
|
1341
|
+
const docs = a.documents
|
|
1342
|
+
? ` (${plural(a.documents, 'finding document', 'finding documents')} from SAP, ${(a.ms / 1000).toFixed(1)} s)`
|
|
1343
|
+
: '';
|
|
1344
|
+
const suffix = ` Read ${plural(a.named, 'API name', 'API names')}${docs}.`;
|
|
1345
|
+
if (!a.unnamed)
|
|
1346
|
+
return { suffix, warning: null };
|
|
1347
|
+
const one = a.unnamed === 1;
|
|
1348
|
+
// Review M3: every reason, in words. With one finding the count is left off.
|
|
1349
|
+
const reasons = [
|
|
1350
|
+
[a.namedNone, 'SAP named none'],
|
|
1351
|
+
[a.unreadable, a.unreadable === 1 && one ? 'its document could not be read' : 'whose document could not be read'],
|
|
1352
|
+
[a.overCap, `past the limit of ${MAX_API_DOCUMENTS} documents per run`],
|
|
1353
|
+
[a.stopped, 'not read because the run was stopped'],
|
|
1354
|
+
[a.notLookedUp, 'not looked up'],
|
|
1355
|
+
];
|
|
1356
|
+
const why = reasons.filter(([n]) => n > 0).map(([n, w]) => (one ? w : `${n} ${w}`)).join(', ');
|
|
1357
|
+
return {
|
|
1358
|
+
suffix,
|
|
1359
|
+
// "API-usage findings": the released-API check reports deprecated APIs too, not only unreleased ones.
|
|
1360
|
+
warning: ` ! ${plural(a.unnamed, 'API-usage finding', 'API-usage findings')} ${one ? 'has' : 'have'} no API name, `
|
|
1361
|
+
+ `so ${one ? 'it stays' : 'they stay'} open for B${why ? `: ${why}` : ''}.`,
|
|
1362
|
+
};
|
|
1363
|
+
}
|
|
1046
1364
|
export function reportMeasurement(res, variant, deps, foreignTo, words = {}) {
|
|
1047
1365
|
if (!res.file) {
|
|
1048
1366
|
deps.print(words.nothing ?? '✗ Nothing was measured, so nothing was written.');
|
|
@@ -1052,7 +1370,10 @@ export function reportMeasurement(res, variant, deps, foreignTo, words = {}) {
|
|
|
1052
1370
|
notMeasuredLines(res, deps, words);
|
|
1053
1371
|
return;
|
|
1054
1372
|
}
|
|
1055
|
-
|
|
1373
|
+
const api = apiNamesWords(res.apiNames);
|
|
1374
|
+
deps.print((words.headline ?? `✓ ${plural(res.measured, 'object measured', 'objects measured')}, ${res.withFindings} with findings.`) + api.suffix);
|
|
1375
|
+
if (api.warning)
|
|
1376
|
+
deps.print(api.warning);
|
|
1056
1377
|
secondPassLines(res, deps);
|
|
1057
1378
|
// F3: SAP reports some findings twice — same place, same message, two finding
|
|
1058
1379
|
// ids. One problem in the code is one finding, so it is counted once; a
|
|
@@ -1284,8 +1605,9 @@ async function refresh(args, deps) {
|
|
|
1284
1605
|
if (!inScope.length)
|
|
1285
1606
|
throw new RegisterError(`Nothing in this team project matches ${scopeLabel(scope)}.`);
|
|
1286
1607
|
let atc;
|
|
1608
|
+
let sql;
|
|
1287
1609
|
try {
|
|
1288
|
-
({ atc } = await deps.connect(opts.sapAlias));
|
|
1610
|
+
({ atc, sql } = await deps.connect(opts.sapAlias));
|
|
1289
1611
|
}
|
|
1290
1612
|
catch (e) {
|
|
1291
1613
|
// One line, the reason, no stack: "no system configured", a dead VPN and a
|
|
@@ -1298,7 +1620,7 @@ async function refresh(args, deps) {
|
|
|
1298
1620
|
return 2;
|
|
1299
1621
|
}
|
|
1300
1622
|
if (opts.perPackage)
|
|
1301
|
-
return refreshPerPackage({ joined, project, baseline, lanes, inScope, variant, foreign, persist, atc, opts }, deps);
|
|
1623
|
+
return refreshPerPackage({ joined, project, baseline, lanes, inScope, variant, foreign, persist, atc, opts, sql }, deps);
|
|
1302
1624
|
deps.print(`Measuring ${scopeWords(scope)} with ${variant}: ${plural(inScope.length, 'object', 'objects')}.`);
|
|
1303
1625
|
const stop = new AbortController();
|
|
1304
1626
|
const off = onInterrupt(deps, () => {
|
|
@@ -1322,6 +1644,7 @@ async function refresh(args, deps) {
|
|
|
1322
1644
|
...(foreign || persist ? { forceVariant: true } : {}),
|
|
1323
1645
|
signal: stop.signal,
|
|
1324
1646
|
confirmAbsent: true,
|
|
1647
|
+
sql,
|
|
1325
1648
|
onProgress: progressLine(deps),
|
|
1326
1649
|
});
|
|
1327
1650
|
}
|
|
@@ -1382,7 +1705,7 @@ async function refresh(args, deps) {
|
|
|
1382
1705
|
* stops between packages; every record already written stays.
|
|
1383
1706
|
*/
|
|
1384
1707
|
async function refreshPerPackage(input, deps) {
|
|
1385
|
-
const { joined, project, baseline, lanes, inScope, variant, foreign, persist, atc, opts } = input;
|
|
1708
|
+
const { joined, project, baseline, lanes, inScope, variant, foreign, persist, atc, opts, sql } = input;
|
|
1386
1709
|
const packages = [...new Set(inScope.map((o) => o.package))].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
1387
1710
|
const labelOf = (pkg) => scopeLabel({ kind: 'packages', patterns: [pkg] });
|
|
1388
1711
|
// A package is done only when a counted run from the system (never a result file),
|
|
@@ -1396,8 +1719,11 @@ async function refreshPerPackage(input, deps) {
|
|
|
1396
1719
|
continue;
|
|
1397
1720
|
const c = run.coverage;
|
|
1398
1721
|
// notRun must be said and be 0: a record that does not say is not proof the run got everywhere.
|
|
1722
|
+
// Step 2 (I15): nor is a second pass that left silent objects out or never reached some.
|
|
1723
|
+
const pass = run.secondPass;
|
|
1399
1724
|
if (c && c.asked > 0 && c.failed === 0 && c.notRun === 0
|
|
1400
|
-
&& c.measured + (c.unsupported ?? 0) + (c.absent ?? 0) >= c.asked
|
|
1725
|
+
&& c.measured + (c.unsupported ?? 0) + (c.absent ?? 0) >= c.asked
|
|
1726
|
+
&& !(pass && pass.skipped > 0) && (pass?.notChecked ?? 0) === 0)
|
|
1401
1727
|
done.add(run.scope);
|
|
1402
1728
|
}
|
|
1403
1729
|
}
|
|
@@ -1438,12 +1764,16 @@ async function refreshPerPackage(input, deps) {
|
|
|
1438
1764
|
...(opts.oneByOne ? {} : { oneByOne: false }),
|
|
1439
1765
|
...(foreign || persist ? { forceVariant: true } : {}),
|
|
1440
1766
|
confirmAbsent: true,
|
|
1767
|
+
...(sql ? { sql } : {}),
|
|
1441
1768
|
onProgress: progressLine(deps),
|
|
1442
1769
|
});
|
|
1443
1770
|
measuredPackages += 1;
|
|
1444
1771
|
if (res.file) {
|
|
1445
1772
|
written += 1;
|
|
1446
|
-
|
|
1773
|
+
const api = apiNamesWords(res.apiNames);
|
|
1774
|
+
deps.print(` ✓ ${pkg}: ${plural(res.measured, 'object measured', 'objects measured')}, ${res.withFindings} with findings.${api.suffix} Written: ${path.basename(res.file)}`);
|
|
1775
|
+
if (api.warning)
|
|
1776
|
+
deps.print(api.warning);
|
|
1447
1777
|
}
|
|
1448
1778
|
else {
|
|
1449
1779
|
deps.print(` ✗ ${pkg}: nothing was measured, so nothing was written.`);
|
|
@@ -1531,8 +1861,11 @@ function rule(args, deps) {
|
|
|
1531
1861
|
return 0;
|
|
1532
1862
|
}
|
|
1533
1863
|
// ── 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'];
|
|
1864
|
+
const DECIDE_USAGE = 'Usage: cspeach team decide <TYPE> <NAME> keep|fix|retire|rebuild|exception [--level A-D] [--target A-D] [--reason "<why>"] [--kind app|class|api] [--app <APP>]';
|
|
1865
|
+
const DECIDE_OPTIONS = ['level', 'target', 'reason', 'kind', 'app'];
|
|
1866
|
+
/** Step 2 (D3): what a rebuild becomes, said with `--kind` beside `rebuild` only. */
|
|
1867
|
+
const REBUILD_KINDS = ['app', 'class', 'api'];
|
|
1868
|
+
const REBUILD_KIND_WORDS = { app: 'as an app', class: 'as a clean class', api: 'as an API service' };
|
|
1536
1869
|
/** The five words a person types, onto the four classifications the files hold (spec §4.2). */
|
|
1537
1870
|
const DECIDE_WORDS = {
|
|
1538
1871
|
keep: { classification: 'keep' },
|
|
@@ -1576,10 +1909,23 @@ export function parseDecideArgs(args) {
|
|
|
1576
1909
|
if (said.classification === 'retire' && !reason) {
|
|
1577
1910
|
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
1911
|
}
|
|
1912
|
+
const rebuild = said.classification === 'redesign';
|
|
1913
|
+
if ((opts.kind !== undefined || opts.app !== undefined) && !rebuild)
|
|
1914
|
+
return { ok: false, message: '--kind and --app only go with rebuild.' };
|
|
1915
|
+
const kind = opts.kind === undefined ? undefined : opts.kind.trim().toLowerCase();
|
|
1916
|
+
if (kind !== undefined && !REBUILD_KINDS.includes(kind))
|
|
1917
|
+
return { ok: false, message: 'A rebuild kind is app, class or api.' };
|
|
1918
|
+
const app = opts.app === undefined ? undefined : opts.app.trim().toUpperCase();
|
|
1919
|
+
if (app !== undefined && !isAppLabel(app))
|
|
1920
|
+
return { ok: false, message: '--app is a name of letters, digits and underscores, for example AR_COCKPIT (not CON, NUL, AUX, PRN, COM1-9 or LPT1-9).' };
|
|
1579
1921
|
return {
|
|
1580
1922
|
ok: true,
|
|
1581
1923
|
value: {
|
|
1582
1924
|
type: type.toUpperCase(), name: name.toUpperCase(),
|
|
1925
|
+
// F3 / M19: a person NAMED the level this decision's target is read from —
|
|
1926
|
+
// `--target` on a fix or rebuild, `--level` on a keep or an exception.
|
|
1927
|
+
// Only then does `decide` stamp `targetBy`; the default A is no target.
|
|
1928
|
+
targetNamed: moves ? !!target.value : said.classification === 'keep' && !!level.value,
|
|
1583
1929
|
row: {
|
|
1584
1930
|
classification: said.classification,
|
|
1585
1931
|
...(said.exception ? { exception: true } : {}),
|
|
@@ -1587,17 +1933,24 @@ export function parseDecideArgs(args) {
|
|
|
1587
1933
|
// Spec §4.2: a fix or a rebuild reaches A unless the person says otherwise.
|
|
1588
1934
|
...(moves ? { targetLevel: target.value ?? 'A' } : {}),
|
|
1589
1935
|
...(reason ? { reason } : {}),
|
|
1936
|
+
...(kind ? { rebuildKind: kind } : {}),
|
|
1937
|
+
...(app ? { app } : {}),
|
|
1590
1938
|
},
|
|
1591
1939
|
},
|
|
1592
1940
|
};
|
|
1593
1941
|
}
|
|
1594
1942
|
const DECISION_WORDS = { keep: 'keep', fix: 'fix', redesign: 'rebuild beside the core', retire: 'retire' };
|
|
1595
|
-
function decisionWords(row) {
|
|
1943
|
+
function decisionWords(row, targetNamed) {
|
|
1596
1944
|
const parts = [row.exception ? 'accepted exception' : DECISION_WORDS[row.classification]];
|
|
1597
1945
|
if (row.level)
|
|
1598
1946
|
parts.push(`level ${row.level}`);
|
|
1947
|
+
// Step 2 (F3): the default A is recorded but is no target, so it is not said as one.
|
|
1599
1948
|
if (row.targetLevel)
|
|
1600
|
-
parts.push(`target ${row.targetLevel}`);
|
|
1949
|
+
parts.push(targetNamed ? `target ${row.targetLevel}` : 'no target set');
|
|
1950
|
+
if (row.rebuildKind)
|
|
1951
|
+
parts.push(REBUILD_KIND_WORDS[row.rebuildKind]);
|
|
1952
|
+
if (row.app)
|
|
1953
|
+
parts.push(`app ${row.app}`);
|
|
1601
1954
|
return `${parts.join(', ')}${row.reason ? `, because: ${row.reason}` : ''}`;
|
|
1602
1955
|
}
|
|
1603
1956
|
function decide(args, deps) {
|
|
@@ -1605,18 +1958,144 @@ function decide(args, deps) {
|
|
|
1605
1958
|
if (!parsed.ok)
|
|
1606
1959
|
throw new RegisterError(parsed.message);
|
|
1607
1960
|
const joined = joinedOrThrow();
|
|
1608
|
-
const { type, name, row } = parsed.value;
|
|
1609
|
-
const
|
|
1610
|
-
|
|
1961
|
+
const { type, name, row, targetNamed } = parsed.value;
|
|
1962
|
+
const now = (deps.now ?? (() => new Date()))();
|
|
1963
|
+
const by = whoAmI(deps);
|
|
1964
|
+
const res = writeRecord(joined, 'decision', [{
|
|
1965
|
+
key: objectKey(type, name), ...row,
|
|
1966
|
+
...(targetNamed ? { targetBy: { name: by, at: now.toISOString() } } : {}),
|
|
1967
|
+
}], { name: by }, {
|
|
1968
|
+
now, ...(deps.rand ? { rand: deps.rand } : {}),
|
|
1611
1969
|
});
|
|
1612
1970
|
if (!res.file)
|
|
1613
1971
|
throw new RegisterError(`${type} ${name} is not in this team project's object list.`);
|
|
1614
|
-
deps.print(`✓ Recorded: ${type} ${name} — ${decisionWords(row)}.`);
|
|
1972
|
+
deps.print(`✓ Recorded: ${type} ${name} — ${decisionWords(row, targetNamed)}.`);
|
|
1615
1973
|
if (row.classification === 'retire')
|
|
1616
1974
|
deps.print('Recorded as a retire candidate. It becomes retired when the lead confirms it.');
|
|
1617
1975
|
refreshSheet(deps);
|
|
1618
1976
|
return 0;
|
|
1619
1977
|
}
|
|
1978
|
+
// ── rebuild ─────────────────────────────────────────────────────────────────
|
|
1979
|
+
/**
|
|
1980
|
+
* `cspeach team rebuild built <TYPE> <NAME> --app <APP>` (F9): a rebuild run by
|
|
1981
|
+
* /abap-modernize reaches "built, not yet checked" by hand. `proven` is not
|
|
1982
|
+
* written tonight: it needs a code-run ATC over the new objects.
|
|
1983
|
+
*/
|
|
1984
|
+
async function rebuildVerb(args, deps) {
|
|
1985
|
+
const { REBUILD_USAGE, recordRebuildBuilt } = await import('../journey/rebuild.js');
|
|
1986
|
+
const { positional, opts } = flags(args);
|
|
1987
|
+
for (const name of Object.keys(opts))
|
|
1988
|
+
if (name !== 'app')
|
|
1989
|
+
throw new RegisterError(`--${name} is not an option of team rebuild.\n${REBUILD_USAGE}`);
|
|
1990
|
+
const [word, type, name] = positional;
|
|
1991
|
+
if (word?.toLowerCase() !== 'built' || !type || !name || positional.length !== 3 || !opts.app || opts.app === 'true')
|
|
1992
|
+
throw new RegisterError(REBUILD_USAGE);
|
|
1993
|
+
joinedOrThrow();
|
|
1994
|
+
let line;
|
|
1995
|
+
try {
|
|
1996
|
+
line = recordRebuildBuilt({ type, name, app: opts.app }, whoAmI(deps), (deps.now ?? (() => new Date()))());
|
|
1997
|
+
}
|
|
1998
|
+
catch (e) {
|
|
1999
|
+
throw new RegisterError(e instanceof Error ? e.message : String(e));
|
|
2000
|
+
}
|
|
2001
|
+
deps.print(line);
|
|
2002
|
+
refreshSheet(deps);
|
|
2003
|
+
return 0;
|
|
2004
|
+
}
|
|
2005
|
+
// ── target ──────────────────────────────────────────────────────────────────
|
|
2006
|
+
const TARGET_USAGE = 'Usage: cspeach team target <A|B|C|D> --reason "<why>" [--packages ZFI_AR,ZFI_GL] [--variant <NAME>] | cspeach team target clear [--packages …]';
|
|
2007
|
+
const TARGET_OPTIONS = ['reason', 'packages', 'variant'];
|
|
2008
|
+
const TARGET_PACKAGE = /^(\/[A-Z0-9_]{1,10}\/)?[A-Z0-9_]{1,30}$/;
|
|
2009
|
+
const samePackages = (a, b) => (!a?.length && !b?.length) || (!!a && !!b && a.length === b.length && [...a].sort().join(',') === [...b].sort().join(','));
|
|
2010
|
+
/** The packages an entry is for, read loosely from the file (upper case), or undefined for the whole project. */
|
|
2011
|
+
function entryPackages(t) {
|
|
2012
|
+
const p = t?.packages;
|
|
2013
|
+
return Array.isArray(p) ? p.map((x) => String(x).trim().toUpperCase()) : undefined;
|
|
2014
|
+
}
|
|
2015
|
+
const forWhom = (packages) => (packages?.length ? packages.join(', ') : 'the whole team project');
|
|
2016
|
+
/**
|
|
2017
|
+
* The project's target (spec §3.2, review I9): `target <L> --reason` sets the
|
|
2018
|
+
* entry without packages; `--packages` (exact names, never a pattern) sets the
|
|
2019
|
+
* entry for exactly that set; `clear` removes the matching entry. The whole
|
|
2020
|
+
* project file is written, then renamed. A decision whose target a person set
|
|
2021
|
+
* still comes first (register-core `targetOf`). `--variant` is stored only
|
|
2022
|
+
* (F15): checks go on with the project's variant.
|
|
2023
|
+
*/
|
|
2024
|
+
function target(args, deps) {
|
|
2025
|
+
const { positional, opts } = flags(args);
|
|
2026
|
+
for (const name of Object.keys(opts)) {
|
|
2027
|
+
if (!TARGET_OPTIONS.includes(name))
|
|
2028
|
+
throw new RegisterError(`--${name} is not an option of team target.\n${TARGET_USAGE}`);
|
|
2029
|
+
}
|
|
2030
|
+
const [word] = positional;
|
|
2031
|
+
const clearing = word?.toLowerCase() === 'clear';
|
|
2032
|
+
const level = clearing ? undefined : levelOf((word ?? '').trim().toUpperCase());
|
|
2033
|
+
if ((!clearing && !level) || positional.length !== 1)
|
|
2034
|
+
throw new RegisterError(TARGET_USAGE);
|
|
2035
|
+
const joined = joinedOrThrow();
|
|
2036
|
+
const project = readProjectFile(joined.projectDir);
|
|
2037
|
+
requireLead(project, deps);
|
|
2038
|
+
let packages;
|
|
2039
|
+
if (opts.packages !== undefined) {
|
|
2040
|
+
const named = list(opts.packages === 'true' ? '' : opts.packages).map((p) => p.toUpperCase());
|
|
2041
|
+
if (!named.length)
|
|
2042
|
+
throw new RegisterError(`--packages needs package names.\n${TARGET_USAGE}`);
|
|
2043
|
+
if (named.some((p) => /[*?%]/.test(p)))
|
|
2044
|
+
throw new RegisterError('Name each package in full, for example ZFI_AR — not a pattern.');
|
|
2045
|
+
const known = new Set(readBaseline(joined.projectDir).map((o) => o.package.toUpperCase()));
|
|
2046
|
+
const unknown = named.find((p) => !TARGET_PACKAGE.test(p) || !known.has(p));
|
|
2047
|
+
if (unknown)
|
|
2048
|
+
throw new RegisterError(`${shown(unknown)} is not a package of this team project.`);
|
|
2049
|
+
packages = [...new Set(named)].sort();
|
|
2050
|
+
}
|
|
2051
|
+
const existing = Array.isArray(project.target) ? [...project.target] : project.target ? [project.target] : [];
|
|
2052
|
+
const at = existing.findIndex((t) => samePackages(entryPackages(t), packages));
|
|
2053
|
+
let next = existing;
|
|
2054
|
+
const out = [];
|
|
2055
|
+
if (clearing) {
|
|
2056
|
+
if (at < 0) {
|
|
2057
|
+
deps.print(`Nothing to clear: no target is set for ${forWhom(packages)}.`);
|
|
2058
|
+
return 0;
|
|
2059
|
+
}
|
|
2060
|
+
next = existing.filter((_, i) => i !== at);
|
|
2061
|
+
out.push(`✓ Target cleared for ${forWhom(packages)}.${next.length ? '' : ' No target set — counted as the check reports.'}`);
|
|
2062
|
+
}
|
|
2063
|
+
else {
|
|
2064
|
+
if (opts.reason === undefined || opts.reason === 'true')
|
|
2065
|
+
throw new RegisterError('A target needs a reason: --reason "why this level is enough"');
|
|
2066
|
+
const reason = Array.from(shownUpTo(opts.reason, Number.POSITIVE_INFINITY)).slice(0, MAX_REASON).join('').trim();
|
|
2067
|
+
if (!reason)
|
|
2068
|
+
throw new RegisterError('A target needs a reason: --reason "why this level is enough"');
|
|
2069
|
+
let variant;
|
|
2070
|
+
if (opts.variant !== undefined) {
|
|
2071
|
+
variant = opts.variant === 'true' ? '' : opts.variant.trim().toUpperCase();
|
|
2072
|
+
if (!VARIANT_NAME.test(variant))
|
|
2073
|
+
throw new RegisterError(`"${shown(opts.variant === 'true' ? '' : opts.variant.trim())}" is not an ATC check variant name.`);
|
|
2074
|
+
}
|
|
2075
|
+
const entry = {
|
|
2076
|
+
level, by: whoAmI(deps), at: (deps.now ?? (() => new Date()))().toISOString(), reason,
|
|
2077
|
+
...(packages ? { packages } : {}), ...(variant ? { variant } : {}),
|
|
2078
|
+
};
|
|
2079
|
+
next = at < 0 ? [...existing, entry] : existing.map((t, i) => (i === at ? entry : t));
|
|
2080
|
+
out.push(`✓ Target: level ${level} for ${forWhom(packages)}. Reason recorded.`);
|
|
2081
|
+
if (variant) {
|
|
2082
|
+
out.push(` Target ${level} — variant ${variant} (stored, not yet used for measuring). `
|
|
2083
|
+
+ `Checks still use ${(project.atcVariant ?? DEFAULT_ATC_VARIANT).trim().toUpperCase()}.`);
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
// The whole file, then renamed (review I9): one entry is written as an object, more as a list.
|
|
2087
|
+
const { target: _old, ...rest } = readProjectFile(joined.projectDir);
|
|
2088
|
+
const file = { ...rest, ...(next.length ? { target: next.length === 1 ? next[0] : next } : {}) };
|
|
2089
|
+
writeWhole(path.join(joined.projectDir, TEAM_FILE), `${JSON.stringify(file, null, 2)}\n`);
|
|
2090
|
+
for (const l of out)
|
|
2091
|
+
deps.print(l);
|
|
2092
|
+
return 0;
|
|
2093
|
+
}
|
|
2094
|
+
/** `team status`: one line per target in the project file, only when there is one (D1). */
|
|
2095
|
+
function targetLines(project) {
|
|
2096
|
+
return targetsOf(project).map((t) => `Target ${t.level}${t.variant ? ` — variant ${t.variant} (stored, not yet used for measuring)` : ''}`
|
|
2097
|
+
+ `, for ${forWhom(t.packages)}. Set by ${shown(t.by)}.`);
|
|
2098
|
+
}
|
|
1620
2099
|
// ── fixed ───────────────────────────────────────────────────────────────────
|
|
1621
2100
|
const FIXED_USAGE = 'Usage: cspeach team fixed <TYPE> <NAME> [--rule <id>] [--transport <request>]';
|
|
1622
2101
|
const utcLine = (iso) => `${iso.slice(0, 16).replace('T', ' ')} UTC`;
|
|
@@ -1728,11 +2207,14 @@ export function writeReports(projectDir, project, outDir, deps) {
|
|
|
1728
2207
|
return false;
|
|
1729
2208
|
}
|
|
1730
2209
|
};
|
|
2210
|
+
// The page this one replaces says when the last report was made, so the
|
|
2211
|
+
// "changed since" block starts there. Read BEFORE it is overwritten.
|
|
2212
|
+
const previousAsOf = previousAsOfIn(path.join(outDir, names.html));
|
|
1731
2213
|
const saved = [
|
|
1732
|
-
attempt(names.xlsx, () => writeXlsxFile(path.join(outDir, names.xlsx), combinedSheets(picture.register, asOf, folderName))) ? names.xlsx : null,
|
|
2214
|
+
attempt(names.xlsx, () => writeXlsxFile(path.join(outDir, names.xlsx), combinedSheets(picture.register, asOf, folderName, project))) ? names.xlsx : null,
|
|
1733
2215
|
attempt(names.html, () => writeWhole(path.join(outDir, names.html), statusHtmlFor({
|
|
1734
2216
|
project, register: picture.register, files: picture.files, unreadable: picture.unreadable,
|
|
1735
|
-
checksum: project.baseline?.sha256 ? 'ok' : 'not-checked', asOf, folderName,
|
|
2217
|
+
checksum: project.baseline?.sha256 ? 'ok' : 'not-checked', asOf, folderName, previousAsOf,
|
|
1736
2218
|
...(discovery ? { estate: discovery.estate } : {}),
|
|
1737
2219
|
}))) ? names.html : null,
|
|
1738
2220
|
].filter((n) => n !== null);
|
|
@@ -1758,6 +2240,15 @@ function report(args, deps) {
|
|
|
1758
2240
|
deps.print(`✓ Saved ${saved.map((n) => `"${n}"`).join(' and ')} in ${where}.`);
|
|
1759
2241
|
for (const name of held)
|
|
1760
2242
|
deps.print(heldReportLine(name));
|
|
2243
|
+
// D2: the CSV export rides along only when the project file switched it on.
|
|
2244
|
+
if (exportsOn(project)) {
|
|
2245
|
+
const out = writeExports(joined.projectDir, project, path.join(outDir, EXPORTS_DIR), deps);
|
|
2246
|
+
if (out.saved.length)
|
|
2247
|
+
deps.print('✓ Saved the CSV export in the exports folder beside the report.');
|
|
2248
|
+
for (const name of out.held)
|
|
2249
|
+
deps.print(heldExportLine(name));
|
|
2250
|
+
held.push(...out.held);
|
|
2251
|
+
}
|
|
1761
2252
|
// Records on this laptop that have not reached the folder are not in the report.
|
|
1762
2253
|
const ownDir = path.join(joined.outboxDir, 'writers', joined.writerId, 'records');
|
|
1763
2254
|
const inFolder = path.join(joined.projectDir, 'writers', joined.writerId, 'records');
|
|
@@ -1770,3 +2261,54 @@ function report(args, deps) {
|
|
|
1770
2261
|
}
|
|
1771
2262
|
return held.length ? 1 : 0;
|
|
1772
2263
|
}
|
|
2264
|
+
// ── export ──────────────────────────────────────────────────────────────────
|
|
2265
|
+
const EXPORT_USAGE = 'Usage: cspeach team export [--out <folder>] [--always | --never]';
|
|
2266
|
+
const heldExportLine = (name) => ` ! "${name}" is open in another program, so it was not updated. Close it and run: cspeach team export`;
|
|
2267
|
+
/** The CSV export of the team project folder, as the team page reads it, into `dir`. */
|
|
2268
|
+
export function writeExports(projectDir, project, dir, deps) {
|
|
2269
|
+
const { register } = registerFor(projectDir);
|
|
2270
|
+
return writeExportFiles(register, project, dir, (deps.now?.() ?? new Date()).toISOString());
|
|
2271
|
+
}
|
|
2272
|
+
/**
|
|
2273
|
+
* `team export [--out <folder>] [--always]` (spec §3.4, D2): the CSV files for
|
|
2274
|
+
* the customer's own tools, on demand. Into `reports/exports` of the team
|
|
2275
|
+
* project — the lead's folder, like the report — or the folder `--out` names,
|
|
2276
|
+
* which anyone may. `--always` (lead only) switches on `"exports": { "csv": true }`
|
|
2277
|
+
* in cspeach-team.json, so every report writes them too; `--never` (lead only)
|
|
2278
|
+
* switches it off and writes no files.
|
|
2279
|
+
*/
|
|
2280
|
+
function exportVerb(args, deps) {
|
|
2281
|
+
const { positional, opts } = flags(args, ['always', 'never']);
|
|
2282
|
+
const always = opts.always === 'true';
|
|
2283
|
+
const never = opts.never === 'true';
|
|
2284
|
+
if (positional.length || Object.keys(opts).some((k) => k !== 'out' && k !== 'always' && k !== 'never') || opts.out === 'true'
|
|
2285
|
+
|| (always && never) || (never && opts.out !== undefined)) {
|
|
2286
|
+
throw new RegisterError(EXPORT_USAGE);
|
|
2287
|
+
}
|
|
2288
|
+
const joined = joinedOrThrow();
|
|
2289
|
+
const project = readProjectFile(joined.projectDir);
|
|
2290
|
+
if (always || never || !opts.out)
|
|
2291
|
+
requireLead(project, deps);
|
|
2292
|
+
if (never) {
|
|
2293
|
+
setExports(joined.projectDir, false);
|
|
2294
|
+
deps.print('✓ Reports no longer write the CSV export ("exports": { "csv": false } in cspeach-team.json). cspeach team export still writes them when you ask.');
|
|
2295
|
+
return 0;
|
|
2296
|
+
}
|
|
2297
|
+
if (always && !exportsOn(project))
|
|
2298
|
+
setExports(joined.projectDir, true);
|
|
2299
|
+
const dir = opts.out ? path.resolve(opts.out) : path.join(joined.projectDir, 'reports', EXPORTS_DIR);
|
|
2300
|
+
const { saved, held } = writeExports(joined.projectDir, project, dir, deps);
|
|
2301
|
+
const where = opts.out ? 'the folder you named' : 'the exports folder of the reports folder';
|
|
2302
|
+
if (saved.length === 6)
|
|
2303
|
+
deps.print(`✓ Saved the CSV export (4 CSV files, manifest.json and SCHEMA.md) in ${where}.`);
|
|
2304
|
+
else if (saved.length)
|
|
2305
|
+
deps.print(`✓ Saved ${saved.map((n) => `"${n}"`).join(', ')} in ${where}.`);
|
|
2306
|
+
for (const name of held)
|
|
2307
|
+
deps.print(heldExportLine(name));
|
|
2308
|
+
if (always) {
|
|
2309
|
+
deps.print(exportsOn(project)
|
|
2310
|
+
? ' The CSV export was already written with every report.'
|
|
2311
|
+
: '✓ From now on every report writes the CSV export too ("exports": { "csv": true } in cspeach-team.json).');
|
|
2312
|
+
}
|
|
2313
|
+
return held.length ? 1 : 0;
|
|
2314
|
+
}
|