@cspeach/cli 1.1.30 → 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/README.md +11 -1
- package/dist/agent/loop.js +132 -9
- package/dist/cli.js +49 -18
- package/dist/commands/config-set.js +1 -1
- package/dist/commands/help.js +16 -1
- package/dist/commands/plan-chain.js +3 -0
- package/dist/commands/plan-continue.js +1 -1
- package/dist/commands/plan-gate.js +17 -1
- package/dist/commands/plan-resume.js +28 -18
- package/dist/commands/plan-startup.js +49 -6
- package/dist/commands/team-discover.js +195 -0
- package/dist/commands/team-plan.js +585 -0
- package/dist/commands/team-rules.js +187 -0
- package/dist/commands/team.js +971 -95
- package/dist/journey/chain.js +978 -0
- package/dist/journey/clean-core.js +235 -0
- package/dist/journey/flow.js +216 -0
- package/dist/journey/rebuild.js +464 -0
- package/dist/journey/run.js +628 -0
- package/dist/journey/session-deps.js +81 -0
- package/dist/journey/trigger.js +153 -0
- package/dist/lane/app-chain.js +21 -9
- package/dist/lane/chain-token.js +9 -5
- package/dist/lane/roots.js +97 -41
- package/dist/one-shot.js +28 -2
- package/dist/projects/clean-core-confirm.js +192 -3
- package/dist/projects/detail-path.js +9 -4
- package/dist/projects/extract-clean-core.js +3 -0
- package/dist/projects/migration.js +4 -105
- package/dist/projects/plan-lane.js +67 -17
- package/dist/projects/plan-sources.js +13 -7
- package/dist/projects/project-folder.js +131 -0
- package/dist/projects/promote-command.js +39 -0
- package/dist/projects/save-command.js +100 -12
- package/dist/projects/saved-lines.js +17 -3
- package/dist/projects/sort-command.js +499 -0
- package/dist/projects/sort-core.js +248 -0
- package/dist/projects/workspace-retire.js +48 -0
- package/dist/projects/workspace.js +182 -121
- package/dist/register/baseline.js +39 -2
- package/dist/register/decision-rules.js +35 -0
- package/dist/register/discover.js +931 -0
- package/dist/register/export-files.js +58 -0
- package/dist/register/findings.js +5 -2
- package/dist/register/measure.js +191 -9
- package/dist/register/offer.js +100 -8
- package/dist/register/picture.js +6 -2
- package/dist/register/plan-import.js +328 -0
- package/dist/register/records.js +40 -3
- package/dist/register/rule-proposal.js +4 -0
- package/dist/register/rules-edit.js +257 -0
- package/dist/register/status-file/estate-parts.js +168 -0
- package/dist/register/status-file/plan-parts.js +68 -0
- package/dist/register/status-file/progress-chart.js +110 -0
- package/dist/register/status-file/register-map.js +11 -3
- package/dist/register/status-file/register-model.js +40 -6
- package/dist/register/status-file/register-parts.js +57 -11
- package/dist/register/status-file/register-status-file.js +106 -90
- 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 +4 -0
- package/dist/register/status-html.js +27 -6
- package/dist/register/store.js +44 -5
- package/dist/register/variant.js +65 -0
- package/dist/register/workbooks.js +28 -42
- package/dist/register/write-whole.js +30 -2
- package/dist/register/xlsx.js +13 -3
- package/dist/register/xref.js +117 -0
- package/dist/renderer/startup-lines.js +11 -0
- package/dist/renderer/tool-labels.js +3 -1
- package/dist/renderer/tool-widget.js +15 -1
- package/dist/repl/at-completer.js +6 -2
- package/dist/repl/at-picker.js +1 -1
- package/dist/repl/builtin-commands.js +4 -0
- package/dist/repl/journey-entry.js +27 -0
- 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 +236 -59
- 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/backfill.js +342 -0
- package/dist/session/project-match.js +63 -0
- package/dist/session/queued-step.js +45 -0
- package/dist/session/resume.js +72 -24
- package/dist/session/schema.js +3 -1
- package/dist/session/store.js +96 -51
- package/dist/skill-catalog.js +2 -2
- package/dist/skills/bundled-skills.js +53 -53
- package/dist/tools/_filesystem-shared.js +174 -12
- package/dist/tools/_team-work-path.js +133 -0
- package/dist/tools/approval.js +7 -1
- package/dist/tools/dispatch-skill.js +8 -4
- package/dist/tools/filesystem/extract-xlsx.js +27 -3
- package/dist/tools/filesystem/file-edit.js +12 -4
- package/dist/tools/filesystem/file-read.js +8 -4
- package/dist/tools/filesystem/file-write.js +12 -4
- package/dist/tools/filesystem/glob.js +33 -8
- package/dist/tools/filesystem/grep.js +9 -4
- package/dist/tools/filesystem/read-document.js +6 -3
- package/dist/tools/lane/app-lane-resolve.js +1 -1
- package/dist/tools/project/cca_merge.js +8 -2
- package/dist/tools/project/rules_propose.js +59 -0
- package/dist/tools/project/team_estate.js +91 -0
- package/dist/tools/project/team_findings.js +89 -5
- package/dist/tools/project/upgrade_merge_progress.js +8 -2
- package/dist/tools/sap-read.js +21 -4
- package/dist/tools/sap-write.js +200 -10
- package/dist/tools/subagent/read_agent.js +3 -0
- package/dist/tools/tool-loading.js +1 -1
- package/dist/tools/transport-fit-check.js +117 -0
- package/dist/tools/transport-fit.js +15 -7
- package/dist/tools/transport.js +57 -2
- package/dist/ui/app.js +2 -2
- package/dist/ui/approval-modal.js +74 -6
- package/dist/ui/widgets/confirm-card.js +68 -5
- package/dist/upgrade-check.js +49 -4
- package/dist/util/open-in-browser.js +143 -0
- package/package.json +3 -3
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,18 +19,24 @@
|
|
|
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
|
-
import { BASELINE_FILE, TEAM_FILE, RegisterError, activeProject, defaultOutboxDir, loadState, projectIdFor, readBaseline, 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';
|
|
26
|
-
import { writeRecord } from '../register/records.js';
|
|
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';
|
|
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';
|
|
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';
|
|
37
|
+
import { teamDiscover, evidenceRows, EVIDENCE_LINE, OUTSIDE_LINE } from './team-discover.js';
|
|
38
|
+
import { rulesCommand } from './team-rules.js';
|
|
39
|
+
import { focusCommand, laneChangeRecord, laneProposal, nowLine, planCommand, priorityCommand, reasonFrom } from './team-plan.js';
|
|
33
40
|
const CLASSIFICATIONS = ['keep', 'fix', 'retire', 'redesign'];
|
|
34
41
|
const TYPES = ['clean-core', 'upgrade', 'build'];
|
|
35
42
|
/**
|
|
@@ -43,7 +50,7 @@ const TYPES = ['clean-core', 'upgrade', 'build'];
|
|
|
43
50
|
* compares this with the lead's name, a ruling only counts when it comes from
|
|
44
51
|
* the lead, and every record says who wrote it.
|
|
45
52
|
*/
|
|
46
|
-
const whoAmI = (deps) => activeProject()?.name ?? deps.identity().name;
|
|
53
|
+
export const whoAmI = (deps) => activeProject()?.name ?? deps.identity().name;
|
|
47
54
|
/** After a record this laptop wrote: its working list, made again. One line, or none. */
|
|
48
55
|
function refreshSheet(deps) {
|
|
49
56
|
const joined = activeProject();
|
|
@@ -74,7 +81,7 @@ function looksLikeAMachine(name, deps) {
|
|
|
74
81
|
* and a verb that passes it also gets `--name=Wave`, the spelling `refresh`
|
|
75
82
|
* has always taken. The verbs that pass nothing read exactly as before.
|
|
76
83
|
*/
|
|
77
|
-
function flags(args, flagOnly = []) {
|
|
84
|
+
export function flags(args, flagOnly = []) {
|
|
78
85
|
const positional = [];
|
|
79
86
|
const opts = {};
|
|
80
87
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -97,7 +104,7 @@ function flags(args, flagOnly = []) {
|
|
|
97
104
|
}
|
|
98
105
|
return { positional, opts };
|
|
99
106
|
}
|
|
100
|
-
const list = (v) => (v ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
107
|
+
export const list = (v) => (v ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
101
108
|
/** "1 object", "3 objects" — every count this command prints reads as a sentence. */
|
|
102
109
|
const plural = (count, one, many) => `${count} ${count === 1 ? one : many}`;
|
|
103
110
|
/** `ZFI_*` style match, used only to expand --packages patterns against the real package list. */
|
|
@@ -110,19 +117,28 @@ export async function runTeamCommand(args, deps) {
|
|
|
110
117
|
try {
|
|
111
118
|
switch (sub) {
|
|
112
119
|
case 'init': return await init(rest, deps);
|
|
120
|
+
case 'start': return await start(rest, deps);
|
|
113
121
|
case 'join': return join(rest, deps);
|
|
114
|
-
case 'lane': return lane(rest, deps);
|
|
122
|
+
case 'lane': return await lane(rest, deps);
|
|
115
123
|
case 'refresh': return await refresh(rest, deps);
|
|
116
124
|
case 'status': return status(rest, deps);
|
|
117
125
|
case 'outbox': return outbox(rest, deps);
|
|
118
126
|
case 'rule': return rule(rest, deps);
|
|
127
|
+
case 'rules': return rulesCommand(rest, deps);
|
|
119
128
|
case 'leave': return leave(deps);
|
|
120
129
|
case 'decide': return decide(rest, deps);
|
|
130
|
+
case 'target': return target(rest, deps);
|
|
121
131
|
case 'fixed': return await fixed(rest, deps);
|
|
122
132
|
case 'sheet': return sheet(deps);
|
|
123
133
|
case 'report': return report(rest, deps);
|
|
134
|
+
case 'export': return exportVerb(rest, deps);
|
|
135
|
+
case 'discover': return await teamDiscover(rest, deps);
|
|
136
|
+
case 'focus': return focusCommand(rest, deps);
|
|
137
|
+
case 'priority': return priorityCommand(rest, deps);
|
|
138
|
+
case 'plan': return await planCommand(rest, deps);
|
|
139
|
+
case 'rebuild': return await rebuildVerb(rest, deps);
|
|
124
140
|
default:
|
|
125
|
-
deps.print('Usage: cspeach team init|join|lane|refresh|status|outbox|rule|leave|decide|fixed|sheet|report (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)');
|
|
126
142
|
return sub ? 1 : 0;
|
|
127
143
|
}
|
|
128
144
|
}
|
|
@@ -131,16 +147,22 @@ export async function runTeamCommand(args, deps) {
|
|
|
131
147
|
deps.print(`✗ ${e.message}`);
|
|
132
148
|
return 1;
|
|
133
149
|
}
|
|
134
|
-
|
|
150
|
+
// Anything else (no SAP system configured, the connection refused, …) is one
|
|
151
|
+
// plain line too, never a stack trace (rehearsal 2026-10-07). CSPEACH_DEBUG=1
|
|
152
|
+
// still rethrows, so the trace is there when someone needs it.
|
|
153
|
+
if (process.env['CSPEACH_DEBUG'] === '1')
|
|
154
|
+
throw e;
|
|
155
|
+
deps.print(`✗ ${(e instanceof Error ? e.message : String(e)).split('\n')[0]}`);
|
|
156
|
+
return 1;
|
|
135
157
|
}
|
|
136
158
|
}
|
|
137
|
-
function requireLead(project, deps) {
|
|
159
|
+
export function requireLead(project, deps) {
|
|
138
160
|
const me = whoAmI(deps);
|
|
139
161
|
if (me.trim().toLowerCase() !== project.lead.name.trim().toLowerCase()) {
|
|
140
162
|
throw new RegisterError(`Only the team project lead (${project.lead.name}) changes this. You are ${me}.`);
|
|
141
163
|
}
|
|
142
164
|
}
|
|
143
|
-
function joinedOrThrow() {
|
|
165
|
+
export function joinedOrThrow() {
|
|
144
166
|
const joined = activeProject();
|
|
145
167
|
if (!joined)
|
|
146
168
|
throw new RegisterError('This laptop has not joined a team project. Run: cspeach team join <folder>');
|
|
@@ -148,11 +170,11 @@ function joinedOrThrow() {
|
|
|
148
170
|
}
|
|
149
171
|
// ── init ────────────────────────────────────────────────────────────────────
|
|
150
172
|
const INIT_USAGE = 'Usage: cspeach team init <folder> --name "<name>" [--lead "<your name>"] [--type clean-core|upgrade|build] '
|
|
151
|
-
+ '[--packages ZFI_*,ZSD_*] [--include-local] [--atc-variant <NAME>] [--no-measure]';
|
|
173
|
+
+ '[--packages ZFI_*,ZSD_*] [--include-local] [--atc-variant <NAME>] [--no-measure] [--no-from-discovery]';
|
|
152
174
|
/** Everything `init` understands. Anything else is a typo, and a typo is refused, never ignored. */
|
|
153
|
-
const INIT_OPTIONS = ['name', 'lead', 'type', 'packages', 'include-local', 'sap-alias', 'atc-variant', 'no-measure'];
|
|
175
|
+
const INIT_OPTIONS = ['name', 'lead', 'type', 'packages', 'include-local', 'sap-alias', 'atc-variant', 'no-measure', 'from-discovery', 'no-from-discovery'];
|
|
154
176
|
/** The two that take no value, so neither can eat the folder name. */
|
|
155
|
-
const INIT_FLAGS = ['include-local', 'no-measure'];
|
|
177
|
+
const INIT_FLAGS = ['include-local', 'no-measure', 'from-discovery', 'no-from-discovery'];
|
|
156
178
|
/** Typed on `init` out of muscle memory: they choose what to MEASURE, which is another verb. */
|
|
157
179
|
const REFRESH_ONLY = ['all', 'lane', 'force-variant', 'chunk', 'max-minutes', 'no-one-by-one', 'from-file', 'absent-means-clean'];
|
|
158
180
|
/**
|
|
@@ -178,6 +200,181 @@ function unknownInitOption(name) {
|
|
|
178
200
|
return said;
|
|
179
201
|
return `${said} Did you mean --${guess}?`;
|
|
180
202
|
}
|
|
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'];
|
|
206
|
+
/** The check a project measures with when nobody names one. */
|
|
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
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* 1.1.31 (owner, 2026-10-07: "why doesn't CSPeach remember all that?") — one
|
|
253
|
+
* command for what init + join + lane add said in three: the folder you are
|
|
254
|
+
* in, a name from the packages, you as the lead, the default check, your
|
|
255
|
+
* results straight into the folder, and every object yours. The long verbs
|
|
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.
|
|
264
|
+
*/
|
|
265
|
+
async function start(args, deps) {
|
|
266
|
+
const { positional, opts } = flags(args, ['no-measure']);
|
|
267
|
+
for (const name of Object.keys(opts)) {
|
|
268
|
+
if (!START_OPTIONS.includes(name))
|
|
269
|
+
throw new RegisterError(`--${name} is not an option of team start.\n${START_USAGE}`);
|
|
270
|
+
}
|
|
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)
|
|
283
|
+
throw new RegisterError(START_USAGE);
|
|
284
|
+
const dir = path.resolve(opts.folder && opts.folder !== 'true' ? opts.folder : process.cwd());
|
|
285
|
+
if (fs.existsSync(path.join(dir, TEAM_FILE)))
|
|
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
|
+
}
|
|
302
|
+
const lead = deps.identity().name;
|
|
303
|
+
const name = opts.name && opts.name !== 'true' ? opts.name : forWords ? shownUpTo(forWords, 80) : packages.join(', ');
|
|
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;
|
|
307
|
+
// The three verbs run as they always do; their detail is kept back and the
|
|
308
|
+
// few lines that matter are said once, below.
|
|
309
|
+
const said = [];
|
|
310
|
+
const quiet = { ...deps, print: (l) => said.push(l) };
|
|
311
|
+
const initArgs = [dir, '--name', name, '--lead', lead, '--packages', packages.join(','), '--atc-variant', variant,
|
|
312
|
+
...(noMeasure ? ['--no-measure'] : []), ...(opts['sap-alias'] && opts['sap-alias'] !== 'true' ? ['--sap-alias', opts['sap-alias']] : [])];
|
|
313
|
+
const code = await init(initArgs, quiet);
|
|
314
|
+
if (code !== 0) {
|
|
315
|
+
for (const l of said)
|
|
316
|
+
deps.print(l);
|
|
317
|
+
return code;
|
|
318
|
+
}
|
|
319
|
+
join([dir, '--outbox', dir], quiet);
|
|
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
|
+
}
|
|
340
|
+
const pulled = said.map((l) => /: (\d+) objects? in \d+ packages?\. Every package matched/.exec(l)).find(Boolean);
|
|
341
|
+
const measured = said.map((l) => /^✓ (\d+) objects? measured, (\d+) with findings/.exec(l)).find(Boolean);
|
|
342
|
+
const objects = pulled ? Number(pulled[1]) : readBaseline(dir).length;
|
|
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);
|
|
351
|
+
deps.print(` ${plural(objects, 'object', 'objects')}, matched against the system's own count.`);
|
|
352
|
+
if (measured)
|
|
353
|
+
deps.print(` First check (${variant.toUpperCase()}): ${measured[2]} of ${measured[1]} objects with findings.`);
|
|
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)
|
|
360
|
+
deps.print(' No check run yet: cspeach team refresh measures it.');
|
|
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}`);
|
|
376
|
+
return 0;
|
|
377
|
+
}
|
|
181
378
|
async function init(args, deps) {
|
|
182
379
|
const { positional, opts } = flags(args, INIT_FLAGS);
|
|
183
380
|
// `--no-measur` must not read as "measure anyway": a person who meant to skip
|
|
@@ -210,7 +407,24 @@ async function init(args, deps) {
|
|
|
210
407
|
throw new RegisterError(`"${opts['atc-variant'].trim()}" is not an ATC check variant name.`);
|
|
211
408
|
if (!deps.connect)
|
|
212
409
|
throw new RegisterError('init needs a SAP connection to pull the object list.');
|
|
410
|
+
if (opts['from-discovery'] && opts['no-from-discovery'])
|
|
411
|
+
throw new RegisterError(`Say --from-discovery or --no-from-discovery, not both.
|
|
412
|
+
${INIT_USAGE}`);
|
|
413
|
+
// The newest discovery in this folder, when there is one and it is not turned off (spec 2026-10-06 §7).
|
|
414
|
+
const discovery = opts['no-from-discovery'] ? null : readEstate(dir);
|
|
213
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
|
+
}
|
|
214
428
|
deps.print('Reading the package list from SAP…');
|
|
215
429
|
const all = await listCustomPackages(sql);
|
|
216
430
|
const patterns = list(opts.packages);
|
|
@@ -225,6 +439,9 @@ async function init(args, deps) {
|
|
|
225
439
|
if (!chosen.length) {
|
|
226
440
|
throw new RegisterError(`No package chosen. ${all.length} customer packages exist; name them with --packages, e.g. --packages "ZFI_*,ZSD_*".`);
|
|
227
441
|
}
|
|
442
|
+
if (discovery)
|
|
443
|
+
for (const l of estatePackageLines(chosen, discovery.estate))
|
|
444
|
+
deps.print(l);
|
|
228
445
|
deps.print(`Pulling ${chosen.length} package${chosen.length === 1 ? '' : 's'}, checking each against the system's own count…`);
|
|
229
446
|
const pull = await pullBaseline(sql, { packages: chosen });
|
|
230
447
|
if (!pull.objects.length)
|
|
@@ -241,12 +458,17 @@ async function init(args, deps) {
|
|
|
241
458
|
createdAt: now.toISOString(),
|
|
242
459
|
};
|
|
243
460
|
fs.mkdirSync(path.join(dir, 'baseline'), { recursive: true });
|
|
244
|
-
|
|
245
|
-
|
|
461
|
+
writeWhole(path.join(dir, BASELINE_FILE), baselineText);
|
|
462
|
+
writeWhole(path.join(dir, TEAM_FILE), `${JSON.stringify(project, null, 2)}\n`);
|
|
246
463
|
writeLanes(dir, projectId, []);
|
|
464
|
+
const evidenceLines = discovery || opts['from-discovery']
|
|
465
|
+
? firstEvidence(dir, projectId, pull.objects, discovery, { name: lead, writer: writerIdFor(given || deps.identity().name, deps.machine?.()) }, now, deps)
|
|
466
|
+
: [];
|
|
247
467
|
deps.print(`✓ ${name}: ${pull.objects.length} objects in ${chosen.length} packages. Every package matched the system's count.`);
|
|
248
468
|
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.`);
|
|
249
469
|
deps.print(` Written to ${dir}`);
|
|
470
|
+
for (const l of evidenceLines)
|
|
471
|
+
deps.print(l);
|
|
250
472
|
// F4: the live run of 2026-09-20 recorded the lead as "Admin" — the Windows
|
|
251
473
|
// user. That name goes into the team project file, into the writer id and
|
|
252
474
|
// into every record from then on. Said once, never asked.
|
|
@@ -267,11 +489,75 @@ async function init(args, deps) {
|
|
|
267
489
|
deps.print(l);
|
|
268
490
|
return joinedRc;
|
|
269
491
|
}
|
|
270
|
-
|
|
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);
|
|
271
498
|
for (const l of joinLines)
|
|
272
499
|
deps.print(l);
|
|
273
500
|
return 0;
|
|
274
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).';
|
|
505
|
+
/**
|
|
506
|
+
* Per chosen package, what the estate knows beside the live count: its
|
|
507
|
+
* description, application area and last change. Packages the estate does
|
|
508
|
+
* not hold get the count alone.
|
|
509
|
+
*/
|
|
510
|
+
function estatePackageLines(chosen, estate) {
|
|
511
|
+
const known = new Map();
|
|
512
|
+
const pkgs = estate?.packages;
|
|
513
|
+
if (Array.isArray(pkgs))
|
|
514
|
+
for (const p of pkgs)
|
|
515
|
+
if (p && typeof p.name === 'string')
|
|
516
|
+
known.set(p.name, p);
|
|
517
|
+
return chosen.map((c) => {
|
|
518
|
+
const p = known.get(c.package);
|
|
519
|
+
const text = (v) => (typeof v === 'string' ? v.trim().slice(0, 300) : '');
|
|
520
|
+
const parts = [plural(c.objects, 'object', 'objects')];
|
|
521
|
+
if (p) {
|
|
522
|
+
if (text(p.text))
|
|
523
|
+
parts.push(text(p.text));
|
|
524
|
+
if (text(p.area))
|
|
525
|
+
parts.push(`area ${text(p.area)}`);
|
|
526
|
+
const at = text(p.lastChanged?.at);
|
|
527
|
+
if (at)
|
|
528
|
+
parts.push(`last changed ${at}${text(p.lastChanged?.by) ? ` by ${text(p.lastChanged.by)}` : ''}`);
|
|
529
|
+
}
|
|
530
|
+
return ` ${c.package}: ${parts.join(' · ')}`;
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* baseline/evidence-0.json (spec 2026-10-06 §7): the inventory's evidence for
|
|
535
|
+
* the team project's own objects, from the newest object list. Rows for other
|
|
536
|
+
* objects are never written; they are counted. Never fails `init`: the team
|
|
537
|
+
* project is complete without it.
|
|
538
|
+
*/
|
|
539
|
+
function firstEvidence(dir, projectId, baseline, discovery, by, now, deps) {
|
|
540
|
+
if (!discovery)
|
|
541
|
+
return [' No discovery in this folder, so no evidence was written. Run: cspeach team discover --out <folder>'];
|
|
542
|
+
const ev = evidenceRows(discovery.objects, new Set(baseline.map((o) => objectKey(o.type, o.name))));
|
|
543
|
+
if (!ev)
|
|
544
|
+
return [' The object list of the inventory could not be read, so no evidence was written.'];
|
|
545
|
+
try {
|
|
546
|
+
const file = `objects-${discovery.stamp}.json`;
|
|
547
|
+
let sum = '';
|
|
548
|
+
try {
|
|
549
|
+
sum = sha256(fs.readFileSync(path.join(dir, 'discovery', file), 'utf8'));
|
|
550
|
+
}
|
|
551
|
+
catch { /* the checksum is a nicety */ }
|
|
552
|
+
writeLeadRecord(path.join(dir, 'baseline'), projectId, 'evidence', { source: { file, sha256: sum } }, ev.rows, by, { now, fileName: 'evidence-0.json', ...(deps.rand ? { rand: deps.rand } : {}) });
|
|
553
|
+
}
|
|
554
|
+
catch (e) {
|
|
555
|
+
// Node's own message carries the full path, user name and all; the code says enough.
|
|
556
|
+
const code = e?.code;
|
|
557
|
+
return [` ! The inventory's evidence could not be written${code ? ` (${code})` : ''}. The team project itself is complete.`];
|
|
558
|
+
}
|
|
559
|
+
return [` ${EVIDENCE_LINE(ev.rows.length)}`, ...(ev.outside ? [` ${OUTSIDE_LINE(ev.outside)}`] : [])];
|
|
560
|
+
}
|
|
275
561
|
/** Errors that are about this laptop's disk or the shared folder, never about SAP. */
|
|
276
562
|
const CANNOT_WRITE = new Set(['EPERM', 'EACCES', 'EROFS', 'ENOSPC', 'EISDIR', 'ENOTEMPTY', 'EBUSY', 'EMFILE', 'ENOTDIR']);
|
|
277
563
|
/**
|
|
@@ -285,7 +571,7 @@ const CANNOT_WRITE = new Set(['EPERM', 'EACCES', 'EROFS', 'ENOSPC', 'EISDIR', 'E
|
|
|
285
571
|
* an estate the run reports nothing back for at all — no object listed — leaves
|
|
286
572
|
* nothing to write, and then that is said out loud and `init` still succeeds.
|
|
287
573
|
*/
|
|
288
|
-
async function firstMeasurement(dir, project, baseline, opts, atc, deps) {
|
|
574
|
+
async function firstMeasurement(dir, project, baseline, opts, atc, deps, sql) {
|
|
289
575
|
const LATER = 'Measure when you can: cspeach team refresh --all';
|
|
290
576
|
if (opts['no-measure']) {
|
|
291
577
|
deps.print(' Not measured (--no-measure). When you are ready: cspeach team refresh --all');
|
|
@@ -318,6 +604,7 @@ async function firstMeasurement(dir, project, baseline, opts, atc, deps) {
|
|
|
318
604
|
joined: joinedOrThrow(), project, baseline, lanes: [], scope: { kind: 'all' }, variant, atc, by: { name: whoAmI(deps) },
|
|
319
605
|
dir: path.join(dir, 'baseline'), fileName: 'measurement-0.json',
|
|
320
606
|
confirmAbsent: true,
|
|
607
|
+
...(sql ? { sql } : {}),
|
|
321
608
|
...(deps.now ? { now: deps.now() } : {}), ...(deps.rand ? { rand: deps.rand } : {}),
|
|
322
609
|
signal: stop.signal,
|
|
323
610
|
onProgress: progressLine(deps),
|
|
@@ -361,11 +648,15 @@ async function firstMeasurement(dir, project, baseline, opts, atc, deps) {
|
|
|
361
648
|
function join(args, deps, asName) {
|
|
362
649
|
const { positional, opts } = flags(args);
|
|
363
650
|
if (!positional[0])
|
|
364
|
-
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.
|
|
365
654
|
const projectDir = path.resolve(positional[0]);
|
|
366
655
|
const project = readProjectFile(projectDir);
|
|
367
656
|
const objects = readBaseline(projectDir).length; // also verifies the checksum
|
|
368
657
|
const state = loadState();
|
|
658
|
+
if (opts.as !== undefined && asName === undefined)
|
|
659
|
+
asName = joinAsName(opts.as, projectDir, project, state.projects[project.projectId]?.name, deps);
|
|
369
660
|
const outboxDir = path.resolve(opts.outbox ?? state.projects[project.projectId]?.outboxDir ?? defaultOutboxDir(project.projectId));
|
|
370
661
|
// A name given once is kept: joining again must not turn the lead back into
|
|
371
662
|
// whatever the machine calls them.
|
|
@@ -376,11 +667,96 @@ function join(args, deps, asName) {
|
|
|
376
667
|
state.active = project.projectId;
|
|
377
668
|
saveState(state);
|
|
378
669
|
deps.print(`✓ Joined "${project.name}" (${objects} objects, lead ${project.lead.name}) as ${writerId}.`);
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
670
|
+
// Outbox is the team project folder itself: records land where everyone reads them.
|
|
671
|
+
if (path.relative(projectDir, outboxDir) === '') {
|
|
672
|
+
deps.print(' Your results are written straight into the team project folder, under writers. Nothing to hand in.');
|
|
673
|
+
}
|
|
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}`);
|
|
382
682
|
return 0;
|
|
383
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
|
+
}
|
|
384
760
|
function leave(deps) {
|
|
385
761
|
const state = loadState();
|
|
386
762
|
if (!state.active) {
|
|
@@ -394,8 +770,10 @@ function leave(deps) {
|
|
|
394
770
|
return 0;
|
|
395
771
|
}
|
|
396
772
|
// ── lane ────────────────────────────────────────────────────────────────────
|
|
397
|
-
function lane(args, deps) {
|
|
773
|
+
async function lane(args, deps) {
|
|
398
774
|
const [verb, ...rest] = args;
|
|
775
|
+
if (verb === 'propose')
|
|
776
|
+
return laneProposal(rest, deps);
|
|
399
777
|
const { positional, opts } = flags(rest);
|
|
400
778
|
const joined = joinedOrThrow();
|
|
401
779
|
const project = readProjectFile(joined.projectDir);
|
|
@@ -408,16 +786,24 @@ function lane(args, deps) {
|
|
|
408
786
|
return 0;
|
|
409
787
|
}
|
|
410
788
|
requireLead(project, deps);
|
|
789
|
+
if (verb === 'move')
|
|
790
|
+
return laneMove(positional, opts, joined, project, lanes, deps);
|
|
411
791
|
const id = positional[0];
|
|
412
792
|
if (!id)
|
|
413
|
-
throw new RegisterError('Usage: cspeach team lane add <id> --owner "<name>" --packages "ZFI_*" [--names "ZCL_TAX_*"] | lane remove <id>');
|
|
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 "…"');
|
|
794
|
+
const reason = reasonFrom(opts, false);
|
|
414
795
|
if (verb === 'remove') {
|
|
415
796
|
writeLanes(joined.projectDir, project.projectId, lanes.filter((l) => l.id !== id));
|
|
416
797
|
deps.print(`✓ Lane ${id} removed.`);
|
|
798
|
+
const was = lanes.find((l) => l.id === id);
|
|
799
|
+
// With a reason, the change is also kept in the plan history; without one,
|
|
800
|
+
// `lane remove` says and writes exactly what it always did.
|
|
801
|
+
if (was && reason)
|
|
802
|
+
deps.print(laneChangeRecord(deps, 'remove', was, undefined, id, reason));
|
|
417
803
|
return 0;
|
|
418
804
|
}
|
|
419
805
|
if (verb !== 'add')
|
|
420
|
-
throw new RegisterError('lane: use add, remove or
|
|
806
|
+
throw new RegisterError('lane: use add, remove, move, list or propose.');
|
|
421
807
|
const next = { id, owner: opts.owner ?? '', packages: list(opts.packages), namePatterns: list(opts.names) };
|
|
422
808
|
if (!next.owner || (!next.packages.length && !next.namePatterns.length)) {
|
|
423
809
|
throw new RegisterError('A lane needs --owner and at least one of --packages or --names.');
|
|
@@ -435,14 +821,79 @@ function lane(args, deps) {
|
|
|
435
821
|
deps.print(` ! ${plural(reg.register.totals.overlapping, 'object now sits', 'objects now sit')} in more than one lane.`);
|
|
436
822
|
if (reg.register.totals.unowned)
|
|
437
823
|
deps.print(` ${plural(reg.register.totals.unowned, 'object still has', 'objects still have')} no owner.`);
|
|
824
|
+
if (reason)
|
|
825
|
+
deps.print(laneChangeRecord(deps, 'add', lanes.find((l) => l.id === id), next, id, reason));
|
|
826
|
+
return 0;
|
|
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));
|
|
438
894
|
return 0;
|
|
439
895
|
}
|
|
440
896
|
// ── status ──────────────────────────────────────────────────────────────────
|
|
441
|
-
const STATE_WORDS = [
|
|
442
|
-
['verified', 'verified'], ['clean', 'clean'], ['claimed', 'claimed, not measured since'], ['working', 'in work'],
|
|
443
|
-
['decided', 'decided, not started'], ['untouched', 'untouched'], ['did-not-hold', 'fix did not hold'],
|
|
444
|
-
['regressed', 'regressed'], ['retired', 'retired'],
|
|
445
|
-
];
|
|
446
897
|
function status(args, deps) {
|
|
447
898
|
const { positional } = flags(args);
|
|
448
899
|
const joined = activeProject();
|
|
@@ -453,36 +904,39 @@ function status(args, deps) {
|
|
|
453
904
|
const { register: r, files, unreadable, ownFiles, narrowed } = registerFor(projectDir, positional[0] ? undefined : joined?.outboxDir);
|
|
454
905
|
const t = r.totals;
|
|
455
906
|
deps.print(`${project.name} (${project.type}, lead ${project.lead.name})`);
|
|
456
|
-
//
|
|
457
|
-
//
|
|
458
|
-
//
|
|
459
|
-
const
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
// read in — so "from a file" is said of the run the date belongs to.
|
|
465
|
-
const lastRun = [...r.runs].reverse().find((run) => run.at === r.lastMeasuredAt);
|
|
466
|
-
deps.print(r.lastMeasuredAt
|
|
467
|
-
? `Last measured ${r.lastMeasuredAt.slice(0, 16).replace('T', ' ')} UTC${lastRun?.basis === 'file' ? ', from a file' : ''}.`
|
|
468
|
-
: 'Not measured yet: "verified" and "clean" need an ATC run.');
|
|
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);
|
|
469
915
|
deps.print('');
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
+
}
|
|
473
925
|
deps.print('');
|
|
474
|
-
if (
|
|
475
|
-
//
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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)}`);
|
|
933
|
+
}
|
|
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'}`);
|
|
483
939
|
}
|
|
484
|
-
// Said once per lane it is true of. A lane set up before this rule held
|
|
485
|
-
// both a package list and a name list, and took the whole package.
|
|
486
940
|
for (const id of narrowed)
|
|
487
941
|
deps.print(`Lane ${id} now means: in these packages AND matching these names.`);
|
|
488
942
|
deps.print('');
|
|
@@ -491,6 +945,10 @@ function status(args, deps) {
|
|
|
491
945
|
deps.print(`! ${plural(t.unowned, 'object has', 'objects have')} no owner.`);
|
|
492
946
|
if (t.overlapping)
|
|
493
947
|
deps.print(`! ${plural(t.overlapping, 'object sits', 'objects sit')} in more than one lane.`);
|
|
948
|
+
if (t.retireCandidates)
|
|
949
|
+
deps.print(`${plural(t.retireCandidates, 'retire candidate waits', 'retire candidates wait')} for the lead.`);
|
|
950
|
+
if (t.outOfScope)
|
|
951
|
+
deps.print(`${plural(t.outOfScope, 'object', 'objects')} out of scope.`);
|
|
494
952
|
if (t.conflicts) {
|
|
495
953
|
deps.print(`! ${plural(t.conflicts, 'object has a conflicting decision', 'objects have conflicting decisions')}.`
|
|
496
954
|
+ ` The lead settles ${t.conflicts === 1 ? 'it' : 'each'} with: cspeach team rule <TYPE> <NAME> <decision>`);
|
|
@@ -546,6 +1004,7 @@ const sameText = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
|
|
|
546
1004
|
*/
|
|
547
1005
|
export const REFRESH_USAGE = 'Usage: cspeach team refresh (--lane <id> | --packages "ZFI_*,ZSD_*" | --all) '
|
|
548
1006
|
+ '[--variant <NAME>] [--force-variant] [--chunk <n>] [--max-minutes <n>] [--no-one-by-one] [--sap-alias <name>]\n'
|
|
1007
|
+
+ ' or: cspeach team refresh (--all | --packages "ZFI_*,ZSD_*") --per-package [--again] [--variant <NAME>]\n'
|
|
549
1008
|
+ ' or: cspeach team refresh --from-file <file.csv> [--lane <id> | --packages "ZFI_*,ZSD_*" | --all] '
|
|
550
1009
|
+ '[--variant <NAME>] [--absent-means-clean]';
|
|
551
1010
|
/** Options that take a value, with the example each refusal shows. */
|
|
@@ -558,7 +1017,7 @@ const VALUE_OPTIONS = {
|
|
|
558
1017
|
'sap-alias': '--sap-alias S4H',
|
|
559
1018
|
'from-file': '--from-file atc-export.csv',
|
|
560
1019
|
};
|
|
561
|
-
const FLAG_OPTIONS = ['all', 'force-variant', 'no-one-by-one', 'absent-means-clean'];
|
|
1020
|
+
const FLAG_OPTIONS = ['all', 'force-variant', 'no-one-by-one', 'absent-means-clean', 'per-package', 'again'];
|
|
562
1021
|
const KNOWN_OPTIONS = [...Object.keys(VALUE_OPTIONS), ...FLAG_OPTIONS];
|
|
563
1022
|
/** Options that describe a check RUN. A file was made by a run that is already over. */
|
|
564
1023
|
const RUN_ONLY_OPTIONS = ['chunk', 'max-minutes', 'no-one-by-one', 'sap-alias'];
|
|
@@ -648,6 +1107,11 @@ export function parseRefreshArgs(args) {
|
|
|
648
1107
|
}
|
|
649
1108
|
const given = ['all', 'lane', 'packages'].filter((k) => opts[k] !== undefined);
|
|
650
1109
|
const fromFile = opts['from-file'];
|
|
1110
|
+
if (opts['per-package'] !== undefined && (fromFile !== undefined || opts.lane !== undefined)) {
|
|
1111
|
+
return no('--per-package goes with --all or --packages: it measures one package at a time.');
|
|
1112
|
+
}
|
|
1113
|
+
if (opts.again !== undefined && opts['per-package'] === undefined)
|
|
1114
|
+
return no('--again only goes with --per-package.');
|
|
651
1115
|
if (fromFile === undefined) {
|
|
652
1116
|
if (given.length !== 1)
|
|
653
1117
|
return no('Say what to measure, one of: --lane <id> --packages "ZFI_*,ZSD_*" --all');
|
|
@@ -691,6 +1155,8 @@ export function parseRefreshArgs(args) {
|
|
|
691
1155
|
oneByOne: opts['no-one-by-one'] === undefined,
|
|
692
1156
|
absentMeansClean: opts['absent-means-clean'] !== undefined,
|
|
693
1157
|
...(fromFile !== undefined ? { fromFile: fromFile.trim() } : {}),
|
|
1158
|
+
...(opts['per-package'] !== undefined ? { perPackage: true } : {}),
|
|
1159
|
+
...(opts.again !== undefined ? { again: true } : {}),
|
|
694
1160
|
};
|
|
695
1161
|
if (opts.variant !== undefined) {
|
|
696
1162
|
const variant = opts.variant.trim().toUpperCase();
|
|
@@ -863,6 +1329,38 @@ function notMeasuredLines(res, deps, words = {}) {
|
|
|
863
1329
|
+ someNames(res.unsupportedNames));
|
|
864
1330
|
}
|
|
865
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
|
+
}
|
|
866
1364
|
export function reportMeasurement(res, variant, deps, foreignTo, words = {}) {
|
|
867
1365
|
if (!res.file) {
|
|
868
1366
|
deps.print(words.nothing ?? '✗ Nothing was measured, so nothing was written.');
|
|
@@ -872,7 +1370,10 @@ export function reportMeasurement(res, variant, deps, foreignTo, words = {}) {
|
|
|
872
1370
|
notMeasuredLines(res, deps, words);
|
|
873
1371
|
return;
|
|
874
1372
|
}
|
|
875
|
-
|
|
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);
|
|
876
1377
|
secondPassLines(res, deps);
|
|
877
1378
|
// F3: SAP reports some findings twice — same place, same message, two finding
|
|
878
1379
|
// ids. One problem in the code is one finding, so it is counted once; a
|
|
@@ -930,7 +1431,7 @@ function stillStanding(register, absentNames) {
|
|
|
930
1431
|
* `once`, on purpose: the SECOND press is Node's own, so a request that has
|
|
931
1432
|
* hung can always be quit.
|
|
932
1433
|
*/
|
|
933
|
-
function onInterrupt(deps, stop) {
|
|
1434
|
+
export function onInterrupt(deps, stop) {
|
|
934
1435
|
if (deps.onInterrupt)
|
|
935
1436
|
return deps.onInterrupt(stop);
|
|
936
1437
|
const handler = () => stop();
|
|
@@ -1070,6 +1571,16 @@ async function refresh(args, deps) {
|
|
|
1070
1571
|
deps.print(CANNOT_MEASURE);
|
|
1071
1572
|
return 2;
|
|
1072
1573
|
}
|
|
1574
|
+
// 1.1.31: no scope said → your own lane, when you own exactly one.
|
|
1575
|
+
if (!args.some((a) => /^--(all|lane|packages|from-file)(=|$)/.test(a))) {
|
|
1576
|
+
const joinedNow = activeProject();
|
|
1577
|
+
if (joinedNow) {
|
|
1578
|
+
const me = whoAmI(deps).trim().toLowerCase();
|
|
1579
|
+
const mine = readLanes(joinedNow.projectDir).filter((l) => l.owner.trim().toLowerCase() === me);
|
|
1580
|
+
if (mine.length === 1)
|
|
1581
|
+
args = [...args, '--lane', mine[0].id];
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1073
1584
|
const parsed = parseRefreshArgs(args);
|
|
1074
1585
|
if (!parsed.ok)
|
|
1075
1586
|
throw new RegisterError(parsed.message);
|
|
@@ -1094,8 +1605,9 @@ async function refresh(args, deps) {
|
|
|
1094
1605
|
if (!inScope.length)
|
|
1095
1606
|
throw new RegisterError(`Nothing in this team project matches ${scopeLabel(scope)}.`);
|
|
1096
1607
|
let atc;
|
|
1608
|
+
let sql;
|
|
1097
1609
|
try {
|
|
1098
|
-
({ atc } = await deps.connect(opts.sapAlias));
|
|
1610
|
+
({ atc, sql } = await deps.connect(opts.sapAlias));
|
|
1099
1611
|
}
|
|
1100
1612
|
catch (e) {
|
|
1101
1613
|
// One line, the reason, no stack: "no system configured", a dead VPN and a
|
|
@@ -1107,6 +1619,8 @@ async function refresh(args, deps) {
|
|
|
1107
1619
|
deps.print(CANNOT_MEASURE);
|
|
1108
1620
|
return 2;
|
|
1109
1621
|
}
|
|
1622
|
+
if (opts.perPackage)
|
|
1623
|
+
return refreshPerPackage({ joined, project, baseline, lanes, inScope, variant, foreign, persist, atc, opts, sql }, deps);
|
|
1110
1624
|
deps.print(`Measuring ${scopeWords(scope)} with ${variant}: ${plural(inScope.length, 'object', 'objects')}.`);
|
|
1111
1625
|
const stop = new AbortController();
|
|
1112
1626
|
const off = onInterrupt(deps, () => {
|
|
@@ -1130,6 +1644,7 @@ async function refresh(args, deps) {
|
|
|
1130
1644
|
...(foreign || persist ? { forceVariant: true } : {}),
|
|
1131
1645
|
signal: stop.signal,
|
|
1132
1646
|
confirmAbsent: true,
|
|
1647
|
+
sql,
|
|
1133
1648
|
onProgress: progressLine(deps),
|
|
1134
1649
|
});
|
|
1135
1650
|
}
|
|
@@ -1182,6 +1697,124 @@ async function refresh(args, deps) {
|
|
|
1182
1697
|
}
|
|
1183
1698
|
return 0;
|
|
1184
1699
|
}
|
|
1700
|
+
/**
|
|
1701
|
+
* D6: the estate measured one package at a time, one measurement record each,
|
|
1702
|
+
* through the same `measure()` as every run. A package that already has a
|
|
1703
|
+
* counted record of this variant for exactly its own scope is skipped unless
|
|
1704
|
+
* `--again`, so a long estate run can be stopped and taken up again. Ctrl-C
|
|
1705
|
+
* stops between packages; every record already written stays.
|
|
1706
|
+
*/
|
|
1707
|
+
async function refreshPerPackage(input, deps) {
|
|
1708
|
+
const { joined, project, baseline, lanes, inScope, variant, foreign, persist, atc, opts, sql } = input;
|
|
1709
|
+
const packages = [...new Set(inScope.map((o) => o.package))].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
1710
|
+
const labelOf = (pkg) => scopeLabel({ kind: 'packages', patterns: [pkg] });
|
|
1711
|
+
// A package is done only when a counted run from the system (never a result file),
|
|
1712
|
+
// with this variant and this package as its scope, says it reached every object:
|
|
1713
|
+
// nothing failed, nothing left not run. A run cut short by Ctrl-C, the deadline or
|
|
1714
|
+
// a lost connection wrote a partial record, and that package is measured again.
|
|
1715
|
+
const done = new Set();
|
|
1716
|
+
if (!opts.again) {
|
|
1717
|
+
for (const run of registerFor(joined.projectDir, joined.outboxDir).register.runs) {
|
|
1718
|
+
if ((run.atcVariant ?? '').trim().toUpperCase() !== variant || !run.scope || run.basis !== 'system')
|
|
1719
|
+
continue;
|
|
1720
|
+
const c = run.coverage;
|
|
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;
|
|
1724
|
+
if (c && c.asked > 0 && c.failed === 0 && c.notRun === 0
|
|
1725
|
+
&& c.measured + (c.unsupported ?? 0) + (c.absent ?? 0) >= c.asked
|
|
1726
|
+
&& !(pass && pass.skipped > 0) && (pass?.notChecked ?? 0) === 0)
|
|
1727
|
+
done.add(run.scope);
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
const todo = packages.filter((p) => !done.has(labelOf(p)));
|
|
1731
|
+
const skipped = packages.length - todo.length;
|
|
1732
|
+
if (skipped) {
|
|
1733
|
+
deps.print(`Skipped ${plural(skipped, 'package', 'packages')} already measured (use --again to measure ${skipped === 1 ? 'it' : 'them'} again).`);
|
|
1734
|
+
}
|
|
1735
|
+
if (!todo.length)
|
|
1736
|
+
return 0;
|
|
1737
|
+
deps.print(`Measuring ${plural(todo.length, 'package', 'packages')} one at a time with ${variant}. Ctrl-C stops between packages and keeps what is written.`);
|
|
1738
|
+
// Ctrl-C is checked between packages, never handed into a package's run: a package
|
|
1739
|
+
// is measured whole or not at all. (A deadline or a lost connection can still cut a
|
|
1740
|
+
// package short; its record then shows it, and the next run measures it again.)
|
|
1741
|
+
const stop = new AbortController();
|
|
1742
|
+
const off = onInterrupt(deps, () => {
|
|
1743
|
+
if (stop.signal.aborted)
|
|
1744
|
+
return;
|
|
1745
|
+
deps.print(' Stopping after this package. What has already been measured will still be written.');
|
|
1746
|
+
deps.print(' Press Ctrl-C again to quit now.');
|
|
1747
|
+
stop.abort();
|
|
1748
|
+
});
|
|
1749
|
+
let measuredPackages = 0;
|
|
1750
|
+
let written = 0;
|
|
1751
|
+
let variantSaved = false;
|
|
1752
|
+
let lostConnection = false;
|
|
1753
|
+
try {
|
|
1754
|
+
for (const pkg of todo) {
|
|
1755
|
+
if (stop.signal.aborted)
|
|
1756
|
+
break;
|
|
1757
|
+
const objects = inScope.filter((o) => o.package === pkg).length;
|
|
1758
|
+
deps.print(`${pkg}: ${plural(objects, 'object', 'objects')}…`);
|
|
1759
|
+
const res = await measure({
|
|
1760
|
+
joined, project, baseline, lanes, scope: { kind: 'packages', patterns: [pkg] }, variant, atc, by: { name: whoAmI(deps) },
|
|
1761
|
+
...(deps.now ? { now: deps.now() } : {}), ...(deps.rand ? { rand: deps.rand } : {}),
|
|
1762
|
+
...(opts.chunkSize ? { chunkSize: opts.chunkSize } : {}),
|
|
1763
|
+
...(opts.maxTotalMs ? { maxTotalMs: opts.maxTotalMs } : {}),
|
|
1764
|
+
...(opts.oneByOne ? {} : { oneByOne: false }),
|
|
1765
|
+
...(foreign || persist ? { forceVariant: true } : {}),
|
|
1766
|
+
confirmAbsent: true,
|
|
1767
|
+
...(sql ? { sql } : {}),
|
|
1768
|
+
onProgress: progressLine(deps),
|
|
1769
|
+
});
|
|
1770
|
+
measuredPackages += 1;
|
|
1771
|
+
if (res.file) {
|
|
1772
|
+
written += 1;
|
|
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);
|
|
1777
|
+
}
|
|
1778
|
+
else {
|
|
1779
|
+
deps.print(` ✗ ${pkg}: nothing was measured, so nothing was written.`);
|
|
1780
|
+
}
|
|
1781
|
+
secondPassLines(res, deps);
|
|
1782
|
+
notMeasuredLines(res, deps);
|
|
1783
|
+
if (res.file && persist && !variantSaved) {
|
|
1784
|
+
try {
|
|
1785
|
+
writeProjectVariant(joined.projectDir, variant);
|
|
1786
|
+
variantSaved = true;
|
|
1787
|
+
deps.print(`✓ This team project now measures with ${variant}.`);
|
|
1788
|
+
}
|
|
1789
|
+
catch (e) {
|
|
1790
|
+
deps.print(` ! The measurement is written, but ${TEAM_FILE} could not be updated, so this team project still has no check variant. `
|
|
1791
|
+
+ `Ask the lead to try again: ${e instanceof Error ? e.message : String(e)}`);
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
if (res.stoppedBy === 'connection') {
|
|
1795
|
+
lostConnection = true;
|
|
1796
|
+
break;
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
finally {
|
|
1801
|
+
off();
|
|
1802
|
+
}
|
|
1803
|
+
if (foreign) {
|
|
1804
|
+
deps.print(` ! Made with ${variant}, not the team project's ${(project.atcVariant ?? '').trim().toUpperCase()}. `
|
|
1805
|
+
+ 'The records are kept, but they are not counted in the picture.');
|
|
1806
|
+
}
|
|
1807
|
+
if (written)
|
|
1808
|
+
refreshSheet(deps);
|
|
1809
|
+
if (stop.signal.aborted || lostConnection) {
|
|
1810
|
+
deps.print(`✗ ${lostConnection ? 'Lost the connection to SAP' : 'Stopped'} after ${measuredPackages} of ${todo.length} packages. What was measured is written. `
|
|
1811
|
+
+ 'Run the same command again to measure the rest.');
|
|
1812
|
+
return 3;
|
|
1813
|
+
}
|
|
1814
|
+
deps.print(`✓ ${plural(written, 'package', 'packages')} measured, one record each.`);
|
|
1815
|
+
deps.print(' Next: cspeach team status');
|
|
1816
|
+
return written ? 0 : 1;
|
|
1817
|
+
}
|
|
1185
1818
|
// ── outbox / rule ───────────────────────────────────────────────────────────
|
|
1186
1819
|
function outbox(_args, deps) {
|
|
1187
1820
|
const joined = joinedOrThrow();
|
|
@@ -1200,15 +1833,25 @@ function outbox(_args, deps) {
|
|
|
1200
1833
|
deps.print('Each file is plain JSON: object names, decisions, statuses. No source code. Open one in Notepad to check.');
|
|
1201
1834
|
return 0;
|
|
1202
1835
|
}
|
|
1836
|
+
const RETIRE_RULING_NEEDS_REASON = 'A retire is confirmed with a reason: --reason "no callers (index read OK); usage unknown on this system; owner confirms"';
|
|
1203
1837
|
function rule(args, deps) {
|
|
1204
1838
|
const { positional, opts } = flags(args);
|
|
1205
1839
|
const [type, name, classification] = positional;
|
|
1206
1840
|
if (!type || !name || !CLASSIFICATIONS.includes(classification)) {
|
|
1207
|
-
throw new RegisterError(`Usage: cspeach team rule <TYPE> <NAME> ${CLASSIFICATIONS.join('|')} [--reason "<why>"]`);
|
|
1841
|
+
throw new RegisterError(`Usage: cspeach team rule <TYPE> <NAME> ${CLASSIFICATIONS.join('|')} [--reason "<why>"] (a retire needs the reason)`);
|
|
1208
1842
|
}
|
|
1843
|
+
// Read the way `decide` reads one: trimmed, a bare flag refused, at most 300.
|
|
1844
|
+
if (opts.reason === 'true')
|
|
1845
|
+
throw new RegisterError('--reason needs words, for example: --reason "reads BSID directly"');
|
|
1846
|
+
const reason = (opts.reason ?? '').trim();
|
|
1847
|
+
if (reason.length > MAX_REASON)
|
|
1848
|
+
throw new RegisterError(`Keep the reason under ${MAX_REASON} characters.`);
|
|
1849
|
+
// The lead's retire is what makes an object retired (D2), so it carries what was checked.
|
|
1850
|
+
if (classification === 'retire' && !reason)
|
|
1851
|
+
throw new RegisterError(RETIRE_RULING_NEEDS_REASON);
|
|
1209
1852
|
const joined = joinedOrThrow();
|
|
1210
1853
|
requireLead(readProjectFile(joined.projectDir), deps);
|
|
1211
|
-
const res = writeRecord(joined, 'ruling', [{ key: objectKey(type, name), classification, ...(
|
|
1854
|
+
const res = writeRecord(joined, 'ruling', [{ key: objectKey(type, name), classification, ...(reason ? { reason } : {}) }], { name: whoAmI(deps) }, {
|
|
1212
1855
|
dir: path.join(joined.projectDir, 'rulings'), now: deps.now?.(),
|
|
1213
1856
|
...(deps.rand ? { rand: deps.rand } : {}),
|
|
1214
1857
|
});
|
|
@@ -1218,8 +1861,11 @@ function rule(args, deps) {
|
|
|
1218
1861
|
return 0;
|
|
1219
1862
|
}
|
|
1220
1863
|
// ── decide ──────────────────────────────────────────────────────────────────
|
|
1221
|
-
const DECIDE_USAGE = 'Usage: cspeach team decide <TYPE> <NAME> keep|fix|retire|rebuild|exception [--level A-D] [--target A-D] [--reason "<why>"]';
|
|
1222
|
-
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' };
|
|
1223
1869
|
/** The five words a person types, onto the four classifications the files hold (spec §4.2). */
|
|
1224
1870
|
const DECIDE_WORDS = {
|
|
1225
1871
|
keep: { classification: 'keep' },
|
|
@@ -1260,10 +1906,26 @@ export function parseDecideArgs(args) {
|
|
|
1260
1906
|
return { ok: false, message: `Keep the reason under ${MAX_REASON} characters.` };
|
|
1261
1907
|
if (said.exception && !reason)
|
|
1262
1908
|
return { ok: false, message: 'An accepted exception needs a reason: --reason "<why it stays>"' };
|
|
1909
|
+
if (said.classification === 'retire' && !reason) {
|
|
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"' };
|
|
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).' };
|
|
1263
1921
|
return {
|
|
1264
1922
|
ok: true,
|
|
1265
1923
|
value: {
|
|
1266
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,
|
|
1267
1929
|
row: {
|
|
1268
1930
|
classification: said.classification,
|
|
1269
1931
|
...(said.exception ? { exception: true } : {}),
|
|
@@ -1271,17 +1933,24 @@ export function parseDecideArgs(args) {
|
|
|
1271
1933
|
// Spec §4.2: a fix or a rebuild reaches A unless the person says otherwise.
|
|
1272
1934
|
...(moves ? { targetLevel: target.value ?? 'A' } : {}),
|
|
1273
1935
|
...(reason ? { reason } : {}),
|
|
1936
|
+
...(kind ? { rebuildKind: kind } : {}),
|
|
1937
|
+
...(app ? { app } : {}),
|
|
1274
1938
|
},
|
|
1275
1939
|
},
|
|
1276
1940
|
};
|
|
1277
1941
|
}
|
|
1278
1942
|
const DECISION_WORDS = { keep: 'keep', fix: 'fix', redesign: 'rebuild beside the core', retire: 'retire' };
|
|
1279
|
-
function decisionWords(row) {
|
|
1943
|
+
function decisionWords(row, targetNamed) {
|
|
1280
1944
|
const parts = [row.exception ? 'accepted exception' : DECISION_WORDS[row.classification]];
|
|
1281
1945
|
if (row.level)
|
|
1282
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.
|
|
1283
1948
|
if (row.targetLevel)
|
|
1284
|
-
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}`);
|
|
1285
1954
|
return `${parts.join(', ')}${row.reason ? `, because: ${row.reason}` : ''}`;
|
|
1286
1955
|
}
|
|
1287
1956
|
function decide(args, deps) {
|
|
@@ -1289,16 +1958,144 @@ function decide(args, deps) {
|
|
|
1289
1958
|
if (!parsed.ok)
|
|
1290
1959
|
throw new RegisterError(parsed.message);
|
|
1291
1960
|
const joined = joinedOrThrow();
|
|
1292
|
-
const { type, name, row } = parsed.value;
|
|
1293
|
-
const
|
|
1294
|
-
|
|
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 } : {}),
|
|
1295
1969
|
});
|
|
1296
1970
|
if (!res.file)
|
|
1297
1971
|
throw new RegisterError(`${type} ${name} is not in this team project's object list.`);
|
|
1298
|
-
deps.print(`✓ Recorded: ${type} ${name} — ${decisionWords(row)}.`);
|
|
1972
|
+
deps.print(`✓ Recorded: ${type} ${name} — ${decisionWords(row, targetNamed)}.`);
|
|
1973
|
+
if (row.classification === 'retire')
|
|
1974
|
+
deps.print('Recorded as a retire candidate. It becomes retired when the lead confirms it.');
|
|
1975
|
+
refreshSheet(deps);
|
|
1976
|
+
return 0;
|
|
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);
|
|
1299
2002
|
refreshSheet(deps);
|
|
1300
2003
|
return 0;
|
|
1301
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
|
+
}
|
|
1302
2099
|
// ── fixed ───────────────────────────────────────────────────────────────────
|
|
1303
2100
|
const FIXED_USAGE = 'Usage: cspeach team fixed <TYPE> <NAME> [--rule <id>] [--transport <request>]';
|
|
1304
2101
|
const utcLine = (iso) => `${iso.slice(0, 16).replace('T', ' ')} UTC`;
|
|
@@ -1377,28 +2174,26 @@ function sheet(deps) {
|
|
|
1377
2174
|
return res.file ? 0 : 1;
|
|
1378
2175
|
}
|
|
1379
2176
|
// ── report ──────────────────────────────────────────────────────────────────
|
|
1380
|
-
const
|
|
2177
|
+
export const heldReportLine = (name) => ` ! "${name}" is open in another program, so it was not updated. Close it and run: cspeach team report`;
|
|
1381
2178
|
/**
|
|
1382
|
-
*
|
|
1383
|
-
*
|
|
1384
|
-
*
|
|
2179
|
+
* The combined workbook and the status file, made from the team project folder
|
|
2180
|
+
* (the same files the team page reads) and written whole, then renamed, into
|
|
2181
|
+
* `outDir`. Used by `report` and by `plan import`, which makes them again so the
|
|
2182
|
+
* lead sees the new export stamp.
|
|
2183
|
+
*
|
|
2184
|
+
* A report open in Excel is the likeliest failure here: Windows refuses the
|
|
2185
|
+
* rename. Each file is tried on its own, so one held file never costs the
|
|
2186
|
+
* other, and the caller is told by file name which one was not updated.
|
|
2187
|
+
* writeWhole has already removed its .tmp when it throws.
|
|
1385
2188
|
*/
|
|
1386
|
-
function
|
|
1387
|
-
const
|
|
1388
|
-
if (positional.length || Object.keys(opts).some((k) => k !== 'out') || opts.out === 'true')
|
|
1389
|
-
throw new RegisterError(REPORT_USAGE);
|
|
1390
|
-
const joined = joinedOrThrow();
|
|
1391
|
-
const project = readProjectFile(joined.projectDir);
|
|
1392
|
-
requireLead(project, deps);
|
|
1393
|
-
const picture = registerFor(joined.projectDir);
|
|
2189
|
+
export function writeReports(projectDir, project, outDir, deps) {
|
|
2190
|
+
const picture = registerFor(projectDir);
|
|
1394
2191
|
const asOf = (deps.now?.() ?? new Date()).toISOString();
|
|
1395
|
-
const folderName = path.basename(
|
|
1396
|
-
const outDir = opts.out ? path.resolve(opts.out) : path.join(joined.projectDir, 'reports');
|
|
2192
|
+
const folderName = path.basename(projectDir);
|
|
1397
2193
|
const names = reportFileNames(project.name);
|
|
1398
|
-
//
|
|
1399
|
-
//
|
|
1400
|
-
|
|
1401
|
-
// writeWhole has already removed its .tmp when it throws.
|
|
2194
|
+
// The discovery file as the folder holds it: the status file says "could not be read"
|
|
2195
|
+
// for one register-core cannot read, the way the page does.
|
|
2196
|
+
const discovery = readEstate(projectDir);
|
|
1402
2197
|
const held = [];
|
|
1403
2198
|
const attempt = (name, write) => {
|
|
1404
2199
|
try {
|
|
@@ -1412,18 +2207,48 @@ function report(args, deps) {
|
|
|
1412
2207
|
return false;
|
|
1413
2208
|
}
|
|
1414
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));
|
|
1415
2213
|
const saved = [
|
|
1416
|
-
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,
|
|
1417
2215
|
attempt(names.html, () => writeWhole(path.join(outDir, names.html), statusHtmlFor({
|
|
1418
2216
|
project, register: picture.register, files: picture.files, unreadable: picture.unreadable,
|
|
1419
|
-
checksum: project.baseline?.sha256 ? 'ok' : 'not-checked', asOf, folderName,
|
|
2217
|
+
checksum: project.baseline?.sha256 ? 'ok' : 'not-checked', asOf, folderName, previousAsOf,
|
|
2218
|
+
...(discovery ? { estate: discovery.estate } : {}),
|
|
1420
2219
|
}))) ? names.html : null,
|
|
1421
2220
|
].filter((n) => n !== null);
|
|
2221
|
+
return { saved, held };
|
|
2222
|
+
}
|
|
2223
|
+
const REPORT_USAGE = 'Usage: cspeach team report [--out <folder>]';
|
|
2224
|
+
/**
|
|
2225
|
+
* Lead only (spec §4.5). Reads the team project folder — the same files the team
|
|
2226
|
+
* page reads, so its numbers are the page's — and writes two files whole, then
|
|
2227
|
+
* renamed, into `reports/` or the folder named with --out. Never `writers/`.
|
|
2228
|
+
*/
|
|
2229
|
+
function report(args, deps) {
|
|
2230
|
+
const { positional, opts } = flags(args);
|
|
2231
|
+
if (positional.length || Object.keys(opts).some((k) => k !== 'out') || opts.out === 'true')
|
|
2232
|
+
throw new RegisterError(REPORT_USAGE);
|
|
2233
|
+
const joined = joinedOrThrow();
|
|
2234
|
+
const project = readProjectFile(joined.projectDir);
|
|
2235
|
+
requireLead(project, deps);
|
|
2236
|
+
const outDir = opts.out ? path.resolve(opts.out) : path.join(joined.projectDir, 'reports');
|
|
2237
|
+
const { saved, held } = writeReports(joined.projectDir, project, outDir, deps);
|
|
1422
2238
|
const where = opts.out ? 'the folder you named' : 'the reports folder of the team project';
|
|
1423
2239
|
if (saved.length)
|
|
1424
2240
|
deps.print(`✓ Saved ${saved.map((n) => `"${n}"`).join(' and ')} in ${where}.`);
|
|
1425
2241
|
for (const name of held)
|
|
1426
|
-
deps.print(
|
|
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
|
+
}
|
|
1427
2252
|
// Records on this laptop that have not reached the folder are not in the report.
|
|
1428
2253
|
const ownDir = path.join(joined.outboxDir, 'writers', joined.writerId, 'records');
|
|
1429
2254
|
const inFolder = path.join(joined.projectDir, 'writers', joined.writerId, 'records');
|
|
@@ -1436,3 +2261,54 @@ function report(args, deps) {
|
|
|
1436
2261
|
}
|
|
1437
2262
|
return held.length ? 1 : 0;
|
|
1438
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
|
+
}
|