@cspeach/cli 1.1.31 → 1.1.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/loop.js +16 -6
- package/dist/cli.js +13 -4
- package/dist/commands/help.js +16 -1
- package/dist/commands/plan-chain.js +3 -0
- package/dist/commands/plan-gate.js +10 -1
- package/dist/commands/plan-resume.js +4 -0
- package/dist/commands/team-rules.js +187 -0
- package/dist/commands/team.js +632 -90
- package/dist/journey/chain.js +265 -15
- package/dist/journey/clean-core.js +25 -3
- package/dist/journey/flow.js +51 -1
- package/dist/journey/rebuild.js +464 -0
- package/dist/journey/run.js +213 -20
- package/dist/journey/session-deps.js +5 -1
- package/dist/lane/app-chain.js +12 -2
- package/dist/one-shot.js +24 -17
- package/dist/projects/clean-core-confirm.js +38 -5
- package/dist/projects/save-command.js +20 -3
- package/dist/register/baseline.js +39 -2
- package/dist/register/decision-rules.js +35 -0
- package/dist/register/discover.js +49 -19
- package/dist/register/export-files.js +58 -0
- package/dist/register/measure.js +191 -9
- package/dist/register/plan-import.js +6 -3
- package/dist/register/records.js +15 -3
- package/dist/register/rule-proposal.js +4 -0
- package/dist/register/rules-edit.js +257 -0
- package/dist/register/status-file/register-status-file.js +106 -108
- package/dist/register/status-file/report-model.js +569 -0
- package/dist/register/status-file/team-css.js +1 -1
- package/dist/register/status-file-sync.js +1 -0
- package/dist/register/status-html.js +25 -6
- package/dist/register/store.js +4 -3
- package/dist/register/variant.js +65 -0
- package/dist/register/workbooks.js +22 -56
- package/dist/register/write-whole.js +30 -2
- package/dist/renderer/tool-labels.js +2 -1
- package/dist/repl/builtin-commands.js +4 -0
- package/dist/repl/journey-entry.js +4 -2
- package/dist/repl/plan-hop.js +2 -9
- package/dist/repl/rules-menu.js +177 -0
- package/dist/repl/slash-completer.js +1 -0
- package/dist/repl/slash-picker.js +5 -4
- package/dist/repl.js +145 -86
- package/dist/router/classifier.js +8 -4
- package/dist/router/router-core.js +43 -7
- package/dist/router/routing-decision.js +1 -1
- package/dist/runs/bundled-runs.js +100 -0
- package/dist/runs/front-door.js +19 -0
- package/dist/runs/manifest-runs.js +50 -0
- package/dist/runs/run-command.js +24 -0
- package/dist/runs/runs.js +27 -0
- package/dist/runs/shadow.js +100 -0
- package/dist/runs/types.js +1 -0
- package/dist/session/queued-step.js +45 -0
- package/dist/skills/bundled-skills.js +53 -53
- package/dist/tools/dispatch-skill.js +8 -4
- package/dist/tools/lane/app-lane-resolve.js +1 -1
- package/dist/tools/project/rules_propose.js +59 -0
- package/dist/tools/project/team_findings.js +13 -2
- package/dist/upgrade-check.js +49 -4
- package/package.json +120 -120
- package/.cspeach/project.json +0 -4
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decision rules for the joined team project (step 2, D6): which rule decides
|
|
3
|
+
* each object, read from the same register and findings team_findings shows,
|
|
4
|
+
* with the project's own rules or the defaults. The row cards use it to say
|
|
5
|
+
* "Rule <id>: …" or "Differs from rule <id>: …". Read only; never writes the
|
|
6
|
+
* rules to the file.
|
|
7
|
+
*/
|
|
8
|
+
import { evaluateRules, readDecisionRules } from '@cspeach/register-core';
|
|
9
|
+
import { findingsByObject } from './findings.js';
|
|
10
|
+
import { registerFor } from './picture.js';
|
|
11
|
+
import { readProjectFile } from './store.js';
|
|
12
|
+
/** The rule that decides one register object, from its latest counted run's findings. */
|
|
13
|
+
export function ruleProposalOf(o, f, rules) {
|
|
14
|
+
return evaluateRules(o, rules, f ? { findings: f.latest } : {});
|
|
15
|
+
}
|
|
16
|
+
/** "TYPE|NAME" → the deciding rule (or null), for every object of the team project; `warning` when the file's rules could not be read. */
|
|
17
|
+
export function ruleProposalsFor(projectDir, outboxDir) {
|
|
18
|
+
const rules = readDecisionRules(readProjectFile(projectDir));
|
|
19
|
+
const { register } = registerFor(projectDir, outboxDir);
|
|
20
|
+
const findings = findingsByObject(projectDir, outboxDir);
|
|
21
|
+
const byKey = new Map(register.objects.map((o) => [o.key.toUpperCase(), o]));
|
|
22
|
+
const cache = new Map();
|
|
23
|
+
return {
|
|
24
|
+
lookup: (key) => {
|
|
25
|
+
const k = key.trim().toUpperCase();
|
|
26
|
+
if (cache.has(k))
|
|
27
|
+
return cache.get(k);
|
|
28
|
+
const o = byKey.get(k);
|
|
29
|
+
const p = o ? ruleProposalOf(o, findings.get(o.key), rules.rules) : null;
|
|
30
|
+
cache.set(k, p);
|
|
31
|
+
return p;
|
|
32
|
+
},
|
|
33
|
+
...(rules.warning ? { warning: rules.warning } : {}),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -20,8 +20,12 @@ import { CODE_OBJECT_TYPES, LOCAL_PACKAGE } from './baseline.js';
|
|
|
20
20
|
import { callersFromIndex, reposrcNameFor } from './xref.js';
|
|
21
21
|
import { outsideUsageWindowLine, shortUsageWindow, usageUnknownLineOf } from '@cspeach/register-core';
|
|
22
22
|
// ── Fixed values ──
|
|
23
|
-
/** Rows per keyset page (steps 5, 6, 8, 9, 10). */
|
|
23
|
+
/** Rows per keyset page (steps 5, 6, 7, 8, 9, 10, 11) — the data preview's own cap. */
|
|
24
24
|
const PAGE = 5000;
|
|
25
|
+
/** A COUNT or GROUP BY statement: its totalRows may count the rows before aggregation (final review I4). */
|
|
26
|
+
const AGGREGATE = /\bCOUNT\s*\(|\bGROUP\s+BY\b/i;
|
|
27
|
+
/** An answer that may be cut is never counted as the whole truth (review I14). */
|
|
28
|
+
export const POSSIBLY_CUT = 'possibly cut at 5,000 rows — counted as unknown';
|
|
25
29
|
/** Namespaces that are SAP's or a product's, never treated as customer even with role P. */
|
|
26
30
|
const NOT_CUSTOMER_NAMESPACES = [
|
|
27
31
|
'/BI0/', '/BIC/', '/IWBEP/', '/IWFND/', '/UI2/', '/UI5/', '/SDF/', '/BOBF/', '/OSP/', '/SCWM/', '/SCMB/', '/S4PPM/',
|
|
@@ -74,7 +78,7 @@ export const SQL = {
|
|
|
74
78
|
localCount: (p) => `SELECT COUNT(*) AS CNT FROM TADIR WHERE PGMID = 'R3TR' AND DELFLAG = ' ' AND DEVCLASS = '${LOCAL_PACKAGE}' AND OBJ_NAME LIKE '${p}%'`,
|
|
75
79
|
localObjects: (p, a) => `SELECT OBJECT, OBJ_NAME, DEVCLASS, AUTHOR, GENFLAG, CREATED_ON FROM TADIR WHERE PGMID = 'R3TR' AND DELFLAG = ' ' AND DEVCLASS = '${LOCAL_PACKAGE}' AND OBJ_NAME LIKE '${p}%'${after('OBJ_NAME', a)} ORDER BY OBJ_NAME, OBJECT`,
|
|
76
80
|
changes: (p, a) => `SELECT PROGNAME, R3STATE, UNAM, UDAT, CNAM, CDAT FROM REPOSRC WHERE R3STATE = 'A' AND PROGNAME LIKE '${p}%'${after('PROGNAME', a)} ORDER BY PROGNAME`,
|
|
77
|
-
batch: (p) => `SELECT p~PROGNAME, COUNT(*) AS RUNS, MAX( o~STRTDATE ) AS LAST_RUN FROM TBTCP AS p INNER JOIN TBTCO AS o ON p~JOBNAME = o~JOBNAME AND p~JOBCOUNT = o~JOBCOUNT WHERE o~STATUS = 'F' AND p~PROGNAME LIKE '${p}%' GROUP BY p~PROGNAME`,
|
|
81
|
+
batch: (p, a) => `SELECT p~PROGNAME, COUNT(*) AS RUNS, MAX( o~STRTDATE ) AS LAST_RUN FROM TBTCP AS p INNER JOIN TBTCO AS o ON p~JOBNAME = o~JOBNAME AND p~JOBCOUNT = o~JOBCOUNT WHERE o~STATUS = 'F' AND p~PROGNAME LIKE '${p}%'${after('p~PROGNAME', a)} GROUP BY p~PROGNAME ORDER BY p~PROGNAME`,
|
|
78
82
|
batchWindow: () => `SELECT MIN( STRTDATE ) AS FIRST_RUN, MAX( STRTDATE ) AS LAST_RUN, COUNT(*) AS CNT FROM TBTCO WHERE STATUS = 'F'`,
|
|
79
83
|
scmonConfig: () => 'SELECT CONFIG_KEY, CONFIG_VALUE FROM SCMON_CONFIG',
|
|
80
84
|
scmonData: () => 'SELECT COUNT(*) AS CNT FROM SCMON_DATA',
|
|
@@ -91,7 +95,8 @@ export const SQL = {
|
|
|
91
95
|
wbcrossgt: (p, a, strict) => `SELECT OTYPE, NAME, INCLUDE FROM WBCROSSGT WHERE NAME LIKE '${p}%'${after('NAME', a, strict)} ORDER BY NAME, INCLUDE`,
|
|
92
96
|
cross: (p, a, strict) => `SELECT TYPE, NAME, INCLUDE FROM CROSS WHERE NAME LIKE '${p}%'${after('NAME', a, strict)} ORDER BY NAME, INCLUDE`,
|
|
93
97
|
masters: (p, a) => `SELECT MASTER, INCLUDE FROM D010INC WHERE INCLUDE LIKE '${p}%'${after('INCLUDE', a)} ORDER BY INCLUDE, MASTER`,
|
|
94
|
-
|
|
98
|
+
// INT_TYPE/INT_NAME (live DD03L 2026-10-06): two rows that differ only there are two rows, kept apart while paging.
|
|
99
|
+
modifications: (op, a) => `SELECT OBJ_TYPE, OBJ_NAME, SUB_TYPE, SUB_NAME, INT_TYPE, INT_NAME, OPERATION, MOD_USER, MOD_DATE FROM SMODILOG WHERE OPERATION = '${op}'${after('OBJ_NAME', a)} ORDER BY OBJ_NAME, OBJ_TYPE, SUB_TYPE, SUB_NAME, INT_TYPE, INT_NAME`,
|
|
95
100
|
modificationCount: (op) => `SELECT COUNT(*) AS CNT FROM SMODILOG WHERE OPERATION = '${op}'`,
|
|
96
101
|
successors: () => 'SELECT COUNT(*) AS CNT FROM ARS_W_API_STATE',
|
|
97
102
|
};
|
|
@@ -111,7 +116,7 @@ export const DISCOVER_STATEMENTS = (o) => {
|
|
|
111
116
|
{ step: 5, sql: SQL.localObjects(p, a) },
|
|
112
117
|
{ step: 6, sql: SQL.changes(p, a) },
|
|
113
118
|
...fugrPrefixes(p, 'SAPL').map((fp) => ({ step: 6, sql: SQL.changes(fp, a) })),
|
|
114
|
-
{ step: 7, sql: SQL.batch(p) },
|
|
119
|
+
{ step: 7, sql: SQL.batch(p, a) },
|
|
115
120
|
{ step: 7, sql: SQL.batchWindow() },
|
|
116
121
|
{ step: 8, sql: SQL.scmonConfig() },
|
|
117
122
|
{ step: 8, sql: SQL.scmonData() },
|
|
@@ -131,9 +136,9 @@ export const DISCOVER_STATEMENTS = (o) => {
|
|
|
131
136
|
...(a === undefined ? [] : [{ step: 9, sql: SQL.cross(p, a, true) }, { step: 9, sql: SQL.wbcrossgt(p, a, true) }]),
|
|
132
137
|
{ step: 10, sql: SQL.masters(p, a) },
|
|
133
138
|
...fugrPrefixes(p, 'L').map((fp) => ({ step: 10, sql: SQL.masters(fp, a) })),
|
|
134
|
-
{ step: 11, sql: SQL.modifications('MOD') },
|
|
135
|
-
{ step: 11, sql: SQL.modifications('NEW') },
|
|
136
|
-
{ step: 11, sql: SQL.modifications('IMP') },
|
|
139
|
+
{ step: 11, sql: SQL.modifications('MOD', a) },
|
|
140
|
+
{ step: 11, sql: SQL.modifications('NEW', a) },
|
|
141
|
+
{ step: 11, sql: SQL.modifications('IMP', a) },
|
|
137
142
|
{ step: 11, sql: SQL.modificationCount('NOTE') },
|
|
138
143
|
{ step: 11, sql: SQL.modificationCount('ALL') },
|
|
139
144
|
{ step: 12, sql: SQL.successors() },
|
|
@@ -147,13 +152,27 @@ export async function discover(sql, opts) {
|
|
|
147
152
|
const unknowns = [];
|
|
148
153
|
const aborted = () => opts.signal?.aborted === true;
|
|
149
154
|
const codeTypes = new Set(CODE_OBJECT_TYPES);
|
|
150
|
-
/**
|
|
151
|
-
|
|
155
|
+
/**
|
|
156
|
+
* The one wrapper every statement runs through. Never throws. An answer that
|
|
157
|
+
* may be cut (sap-client's `possiblyCut`, or a full 5,000-row page) is marked
|
|
158
|
+
* on `queries[]` and named under unknowns — unless the caller pages past it
|
|
159
|
+
* (`paging`), where a full page is expected and the next page follows.
|
|
160
|
+
*/
|
|
161
|
+
const run = async (step, statement, maxRows, what, paging = false) => {
|
|
152
162
|
const t0 = Date.now();
|
|
153
163
|
try {
|
|
154
164
|
const res = await sql(statement, maxRows);
|
|
155
165
|
const rows = res.rows ?? [];
|
|
156
|
-
|
|
166
|
+
// Final review I4: SAP may count totalRows before aggregation, so a COUNT /
|
|
167
|
+
// GROUP BY answer is "possibly cut" only when it fills the page it asked for.
|
|
168
|
+
const possiblyCut = AGGREGATE.test(statement)
|
|
169
|
+
? rows.length >= Math.min(maxRows, PAGE)
|
|
170
|
+
: res.possiblyCut === true || rows.length >= PAGE;
|
|
171
|
+
queries.push({ step, statement, rows: rows.length, ms: Date.now() - t0, ok: true, ...(possiblyCut && !paging ? { possiblyCut: true } : {}) });
|
|
172
|
+
if (possiblyCut && !paging) {
|
|
173
|
+
unknowns.push({ what: what ?? `step ${step}: ${STEP_LABELS[step]}`, why: POSSIBLY_CUT });
|
|
174
|
+
return { rows, possiblyCut: true };
|
|
175
|
+
}
|
|
157
176
|
return { rows };
|
|
158
177
|
}
|
|
159
178
|
catch (err) {
|
|
@@ -175,7 +194,7 @@ export async function discover(sql, opts) {
|
|
|
175
194
|
const why = stop();
|
|
176
195
|
if (why)
|
|
177
196
|
return { rows, complete: false, boundary: a, stop: why };
|
|
178
|
-
const res = await run(step, build(a, strict), PAGE, what);
|
|
197
|
+
const res = await run(step, build(a, strict), PAGE, what, true);
|
|
179
198
|
if ('error' in res)
|
|
180
199
|
return { rows, complete: false, boundary: a, stop: 'error', error: res.error };
|
|
181
200
|
let fresh = 0;
|
|
@@ -295,7 +314,9 @@ export async function discover(sql, opts) {
|
|
|
295
314
|
const res = await run(3, SQL.counts(p), 50000);
|
|
296
315
|
if (!('rows' in res))
|
|
297
316
|
continue;
|
|
298
|
-
|
|
317
|
+
// A cut count is no control total: step 4 then pulls those packages without one.
|
|
318
|
+
if (!res.possiblyCut)
|
|
319
|
+
countedPrefixes.add(p);
|
|
299
320
|
for (const r of res.rows) {
|
|
300
321
|
const pkg = clean(r.DEVCLASS).toUpperCase();
|
|
301
322
|
const t = clean(r.OBJECT).toUpperCase();
|
|
@@ -438,11 +459,14 @@ export async function discover(sql, opts) {
|
|
|
438
459
|
cut.add(7);
|
|
439
460
|
break;
|
|
440
461
|
}
|
|
441
|
-
const res = await
|
|
442
|
-
if (
|
|
443
|
-
|
|
444
|
-
continue;
|
|
462
|
+
const res = await paged(7, (a) => SQL.batch(p, a), 'PROGNAME', (r) => clean(r.PROGNAME), abortOnly);
|
|
463
|
+
if (res.stop === 'abort') {
|
|
464
|
+
cut.add(7);
|
|
445
465
|
}
|
|
466
|
+
else if (res.stop === 'error')
|
|
467
|
+
failed = failed ?? res.error;
|
|
468
|
+
else if (!res.complete)
|
|
469
|
+
unknowns.push({ what: `step 7: ${STEP_LABELS[7]}`, why: POSSIBLY_CUT });
|
|
446
470
|
for (const r of res.rows) {
|
|
447
471
|
const o = byKey.get(`PROG|${clean(r.PROGNAME).toUpperCase()}`);
|
|
448
472
|
if (!o)
|
|
@@ -819,9 +843,15 @@ export async function discover(sql, opts) {
|
|
|
819
843
|
let otherRows = null;
|
|
820
844
|
if (step(11)) {
|
|
821
845
|
for (const op of ['MOD', 'NEW', 'IMP']) {
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
846
|
+
if (aborted()) {
|
|
847
|
+
cut.add(11);
|
|
848
|
+
break;
|
|
849
|
+
}
|
|
850
|
+
const res = await paged(11, (a) => SQL.modifications(op, a), 'OBJ_NAME', (r) => [r.OBJ_TYPE, r.OBJ_NAME, r.SUB_TYPE, r.SUB_NAME, r.INT_TYPE, r.INT_NAME, r.OPERATION].map(clean).join('|'), abortOnly, `step 11: modifications (${op})`);
|
|
851
|
+
if (res.stop === 'abort')
|
|
852
|
+
cut.add(11);
|
|
853
|
+
else if (!res.complete && res.stop !== 'error')
|
|
854
|
+
unknowns.push({ what: `step 11: modifications (${op})`, why: POSSIBLY_CUT });
|
|
825
855
|
for (const r of res.rows) {
|
|
826
856
|
const objectName = clean(r.OBJ_NAME).toUpperCase();
|
|
827
857
|
if (customerNamed(objectName))
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CSV export on disk (step 2, Task 4; spec 2026-10-08 §3.4, D2). The
|
|
3
|
+
* tables come from register-core (`exportSet`, pure); this file writes them:
|
|
4
|
+
* each file whole, then renamed, so a sync tool never picks up half of one;
|
|
5
|
+
* CSV as UTF-8 with a BOM (Excel then reads umlauts right), CRLF line ends,
|
|
6
|
+
* RFC 4180 quoting (`csvText`).
|
|
7
|
+
*
|
|
8
|
+
* The switch is visible JSON in the team project file — `"exports": { "csv": true }`
|
|
9
|
+
* — set by `cspeach team export --always` or the journey's report card. No
|
|
10
|
+
* hidden state. Default off.
|
|
11
|
+
*/
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { csvText, exportSet } from '@cspeach/register-core';
|
|
14
|
+
import { HELD } from './workbooks.js';
|
|
15
|
+
import { readProjectFile, TEAM_FILE } from './store.js';
|
|
16
|
+
import { writeWhole } from './write-whole.js';
|
|
17
|
+
/** The folder the export goes into, under the reports folder (or the folder `--out` named for a report). */
|
|
18
|
+
export const EXPORTS_DIR = 'exports';
|
|
19
|
+
/** True only when the team project file says exactly `"exports": { "csv": true }`. */
|
|
20
|
+
export function exportsOn(project) {
|
|
21
|
+
const e = project.exports;
|
|
22
|
+
return !!e && typeof e === 'object' && e.csv === true;
|
|
23
|
+
}
|
|
24
|
+
/** Set the switch on or off: the whole project file, then renamed. Every other field stays as it is. */
|
|
25
|
+
export function setExports(projectDir, on) {
|
|
26
|
+
const project = readProjectFile(projectDir);
|
|
27
|
+
const had = project.exports && typeof project.exports === 'object' ? project.exports : {};
|
|
28
|
+
const next = { ...project, exports: { ...had, csv: on } };
|
|
29
|
+
writeWhole(path.join(projectDir, TEAM_FILE), `${JSON.stringify(next, null, 2)}\n`);
|
|
30
|
+
}
|
|
31
|
+
/** Turn the switch on (`team export --always`, the card's "Every time"). */
|
|
32
|
+
export const setExportsAlways = (projectDir) => setExports(projectDir, true);
|
|
33
|
+
/**
|
|
34
|
+
* Every file of the export into `dir`. Each file is tried on its own: one held
|
|
35
|
+
* open in Excel never costs the others, and the caller names it.
|
|
36
|
+
*/
|
|
37
|
+
export function writeExportFiles(register, project, dir, generatedAt) {
|
|
38
|
+
const set = exportSet(register, project, { generatedAt });
|
|
39
|
+
const files = [
|
|
40
|
+
...set.tables.map((t) => [t.file, csvText(t)]),
|
|
41
|
+
['manifest.json', `${JSON.stringify(set.manifest, null, 2)}\n`],
|
|
42
|
+
['SCHEMA.md', set.schema],
|
|
43
|
+
];
|
|
44
|
+
const saved = [];
|
|
45
|
+
const held = [];
|
|
46
|
+
for (const [name, text] of files) {
|
|
47
|
+
try {
|
|
48
|
+
writeWhole(path.join(dir, name), Buffer.from(text, 'utf8'));
|
|
49
|
+
saved.push(name);
|
|
50
|
+
}
|
|
51
|
+
catch (e) {
|
|
52
|
+
if (!HELD.has(e?.code ?? ''))
|
|
53
|
+
throw e;
|
|
54
|
+
held.push(name);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { saved, held };
|
|
58
|
+
}
|
package/dist/register/measure.js
CHANGED
|
@@ -98,18 +98,169 @@ function apiNameIn(text) {
|
|
|
98
98
|
*
|
|
99
99
|
* The TITLE is read first: it is where SAP names the API, while the long text
|
|
100
100
|
* can quote source code and would otherwise hand a token like `TABLE` to the
|
|
101
|
-
* record.
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
101
|
+
* record.
|
|
102
|
+
*
|
|
103
|
+
* READINESS WORDING: VERIFIED-NO-API-NAME (live, S4H, 2026-10-08). On a real
|
|
104
|
+
* ABAP_CLOUD_READINESS worklist the message TEXT is empty on every finding and
|
|
105
|
+
* the title says only "Usage of not released ABAP Platform APIs." — SAP's
|
|
106
|
+
* wording names no API (`__tests__/fixtures/atc-readiness-worklist.xml`). So
|
|
107
|
+
* this rule names nothing on a real system, and it is KEPT ONLY AS A FALLBACK
|
|
108
|
+
* for a text that ever does name one. The API of a USAGE_OF_RELEASED_APIS
|
|
109
|
+
* finding comes from the finding itself instead: its REF_OBJ_NAME /
|
|
110
|
+
* REF_OBJ_TYPE tags, or its document (`apiNamesFor`), and it only feeds
|
|
111
|
+
* `uses` — the finding keeps its check's own rule id.
|
|
105
112
|
*/
|
|
106
113
|
export function unreleasedApiRule(f) {
|
|
114
|
+
const name = unreleasedApiName(f);
|
|
115
|
+
return name ? `UNRELEASED_API:${name}` : null;
|
|
116
|
+
}
|
|
117
|
+
/** The API an unreleased-API finding names, by the same (unverified) reading as `unreleasedApiRule`. */
|
|
118
|
+
function unreleasedApiName(f) {
|
|
107
119
|
const title = f.messageTitle ?? '';
|
|
108
120
|
const body = f.messageText ?? '';
|
|
109
121
|
if (!NOT_RELEASED.test(`${title} ${body}`))
|
|
110
122
|
return null;
|
|
111
|
-
|
|
112
|
-
|
|
123
|
+
return apiNameIn(title) ?? apiNameIn(body);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* A finding of SAP's "Usage of Released APIs" check (USAGE_OF_RELEASED_APIS),
|
|
127
|
+
* read by its rule id exactly the way register-core's `classifyFinding` reads
|
|
128
|
+
* it. Only such a finding's referenced object is "the API it uses": the syntax
|
|
129
|
+
* check also tags a REF_OBJ_NAME, and that names a statement.
|
|
130
|
+
*/
|
|
131
|
+
export function isReleasedApiFinding(f) {
|
|
132
|
+
return [ruleIdFromAtc(f), ...ruleAliasesFromAtc(f)]
|
|
133
|
+
.some((id) => normalizeRuleId(id).split(':')[0] === 'USAGE_OF_RELEASED_APIS');
|
|
134
|
+
}
|
|
135
|
+
const apiRef = (o) => {
|
|
136
|
+
const name = typeof o?.name === 'string' ? o.name.trim().toUpperCase() : '';
|
|
137
|
+
const type = typeof o?.type === 'string' ? o.type.trim().toUpperCase() : '';
|
|
138
|
+
return name ? { name, ...(type ? { type } : {}) } : undefined;
|
|
139
|
+
};
|
|
140
|
+
/** Finding documents one run reads, at most. The rest are left without `uses`, counted and said. */
|
|
141
|
+
export const MAX_API_DOCUMENTS = 200;
|
|
142
|
+
/** Finding documents read at the same time. */
|
|
143
|
+
export const API_DOCUMENT_CONCURRENCY = 4;
|
|
144
|
+
/**
|
|
145
|
+
* The API each USAGE_OF_RELEASED_APIS finding uses, by finding.
|
|
146
|
+
*
|
|
147
|
+
* The worklist's own REF_OBJ_NAME / REF_OBJ_TYPE tags first — they cost
|
|
148
|
+
* nothing. A finding without them has its document read (distinct links, at
|
|
149
|
+
* most `API_DOCUMENT_CONCURRENCY` at a time, at most `cap` per run). A
|
|
150
|
+
* document that fails, names nothing, or was past the cap leaves its finding
|
|
151
|
+
* without a name: no `uses`, so it stays open for B. Never a guess.
|
|
152
|
+
*/
|
|
153
|
+
async function apiNamesFor(findings, read, cap, signal) {
|
|
154
|
+
const named = new Map();
|
|
155
|
+
const needDoc = new Map();
|
|
156
|
+
let namedNone = 0;
|
|
157
|
+
let notLookedUp = 0;
|
|
158
|
+
for (const f of findings) {
|
|
159
|
+
const tag = apiRef(f.referencedObject);
|
|
160
|
+
if (tag) {
|
|
161
|
+
named.set(f, tag);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (!f.documentation)
|
|
165
|
+
namedNone += 1;
|
|
166
|
+
else if (!read)
|
|
167
|
+
notLookedUp += 1;
|
|
168
|
+
else
|
|
169
|
+
needDoc.set(f.documentation, [...(needDoc.get(f.documentation) ?? []), f]);
|
|
170
|
+
}
|
|
171
|
+
const hrefs = [...needDoc.keys()];
|
|
172
|
+
const toRead = hrefs.slice(0, Math.max(0, cap));
|
|
173
|
+
const overCap = hrefs.slice(toRead.length).reduce((n, h) => n + needDoc.get(h).length, 0);
|
|
174
|
+
let unreadable = 0;
|
|
175
|
+
let documents = 0;
|
|
176
|
+
const started = Date.now();
|
|
177
|
+
let next = 0;
|
|
178
|
+
const worker = async () => {
|
|
179
|
+
while (next < toRead.length && !signal?.aborted) {
|
|
180
|
+
const href = toRead[next++];
|
|
181
|
+
documents += 1;
|
|
182
|
+
try {
|
|
183
|
+
const ref = apiRef((await read(href))?.referencedObject);
|
|
184
|
+
if (ref)
|
|
185
|
+
for (const f of needDoc.get(href))
|
|
186
|
+
named.set(f, ref);
|
|
187
|
+
else
|
|
188
|
+
namedNone += needDoc.get(href).length;
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
unreadable += needDoc.get(href).length;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
await Promise.all(Array.from({ length: Math.min(API_DOCUMENT_CONCURRENCY, toRead.length) }, worker));
|
|
196
|
+
// Within the cap but never asked for: the run was stopped.
|
|
197
|
+
const stopped = toRead.slice(next).reduce((n, h) => n + needDoc.get(h).length, 0);
|
|
198
|
+
return {
|
|
199
|
+
named,
|
|
200
|
+
report: {
|
|
201
|
+
findings: findings.length, named: named.size, documents, ms: documents ? Date.now() - started : 0,
|
|
202
|
+
unnamed: findings.length - named.size, overCap, unreadable, namedNone, stopped, notLookedUp,
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/* ── `uses`: what SAP says about the API a finding names (step 2, F8) ────── */
|
|
207
|
+
/** API names per `ARS_W_API_STATE` read. */
|
|
208
|
+
export const ARS_CHUNK = 50;
|
|
209
|
+
/** At most this many type/state pairs are kept for one API name. */
|
|
210
|
+
export const MAX_USES = 10;
|
|
211
|
+
/**
|
|
212
|
+
* Rows asked for per ARS read. ARS holds a row per sub-object too, so a read
|
|
213
|
+
* that comes back FULL may have been cut, and the row cut off may be the one
|
|
214
|
+
* that is not stable: a full page is read as unreadable (review 3).
|
|
215
|
+
*/
|
|
216
|
+
export const ARS_MAX_ROWS = ARS_CHUNK * 20;
|
|
217
|
+
/**
|
|
218
|
+
* One batched read of `ARS_W_API_STATE` per run, by `OBJECT_NAME`, in chunks
|
|
219
|
+
* of 50 distinct names (F8). Every distinct type/state SAP returns for a name
|
|
220
|
+
* is kept, as SAP spells it, so one entry that is not stable keeps the finding
|
|
221
|
+
* open for B (register-core `stateOfUses`). A name SAP returns nothing for
|
|
222
|
+
* gets no entry: the finding then has no `uses` and stays open — honest, not
|
|
223
|
+
* guessed. A read that fails reads nothing, and the measurement is written as
|
|
224
|
+
* it always was.
|
|
225
|
+
*
|
|
226
|
+
* The names come from the USAGE_OF_RELEASED_APIS findings themselves
|
|
227
|
+
* (`apiNamesFor`) and, as a fallback, from a message text that names an API
|
|
228
|
+
* (`unreleasedApiRule`): `uses` is written only on a finding one of them named.
|
|
229
|
+
*/
|
|
230
|
+
async function apiStatesOf(sql, names) {
|
|
231
|
+
const out = new Map();
|
|
232
|
+
// A name goes into the statement only when it can hold no quote or bracket.
|
|
233
|
+
const safe = [...new Set(names)].filter((n) => SAP_NAME.test(n));
|
|
234
|
+
for (let i = 0; i < safe.length; i += ARS_CHUNK) {
|
|
235
|
+
const chunk = safe.slice(i, i + ARS_CHUNK);
|
|
236
|
+
let rows;
|
|
237
|
+
try {
|
|
238
|
+
({ rows } = await sql(`SELECT DISTINCT OBJECT_TYPE, OBJECT_NAME, RELEASE_STATE FROM ARS_W_API_STATE WHERE OBJECT_NAME IN (${chunk.map((n) => `'${n}'`).join(',')})`, ARS_MAX_ROWS));
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
return new Map(); // unreadable: no `uses` at all, never half of them
|
|
242
|
+
}
|
|
243
|
+
const list = Array.isArray(rows) ? rows : [];
|
|
244
|
+
if (list.length >= ARS_MAX_ROWS)
|
|
245
|
+
return new Map(); // possibly cut: no `uses` at all, never a wrong state
|
|
246
|
+
for (const r of list) {
|
|
247
|
+
const name = String(r?.OBJECT_NAME ?? '').trim().toUpperCase();
|
|
248
|
+
const type = String(r?.OBJECT_TYPE ?? '').trim().toUpperCase();
|
|
249
|
+
const state = String(r?.RELEASE_STATE ?? '').trim().toUpperCase();
|
|
250
|
+
if (!name || !type || !state || !chunk.includes(name))
|
|
251
|
+
continue;
|
|
252
|
+
const seen = out.get(name) ?? [];
|
|
253
|
+
if (!seen.some((u) => u.type === type && u.state === state))
|
|
254
|
+
seen.push({ type, name, state });
|
|
255
|
+
out.set(name, seen);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
// More pairs than a finding carries: the name gets none, never a cut list whose
|
|
259
|
+
// missing entry could be the one that is not stable (review 3).
|
|
260
|
+
for (const [name, list] of out)
|
|
261
|
+
if (list.length > MAX_USES)
|
|
262
|
+
out.delete(name);
|
|
263
|
+
return out;
|
|
113
264
|
}
|
|
114
265
|
/**
|
|
115
266
|
* SAP writes some check names as `ID / text` — `SLIN_DB / SELECT without ORDER
|
|
@@ -144,7 +295,11 @@ function halvesOf(src) {
|
|
|
144
295
|
* `coverage.absent` goes to 0, an absent-clean row renders a plain "Clean", and
|
|
145
296
|
* nothing anywhere says that nothing actually looked at the object.
|
|
146
297
|
*/
|
|
147
|
-
export function findingsToRows(measured, findings, projectType
|
|
298
|
+
export function findingsToRows(measured, findings, projectType,
|
|
299
|
+
/** What `ARS_W_API_STATE` said about each API name (`apiStatesOf`). Absent: no `uses` are written. */
|
|
300
|
+
usesByName,
|
|
301
|
+
/** The API each USAGE_OF_RELEASED_APIS finding uses (`apiNamesFor`), by finding. */
|
|
302
|
+
apiNameOf) {
|
|
148
303
|
const byKey = new Map();
|
|
149
304
|
const evidenceOf = new Map();
|
|
150
305
|
for (const o of measured) {
|
|
@@ -160,7 +315,13 @@ export function findingsToRows(measured, findings, projectType) {
|
|
|
160
315
|
if (!list)
|
|
161
316
|
continue; // a finding for an object that was not measured is never written
|
|
162
317
|
const atcRule = ruleIdFromAtc(f);
|
|
163
|
-
const
|
|
318
|
+
const apiName = projectType === 'clean-core' ? unreleasedApiName(f) : null;
|
|
319
|
+
const api = apiName ? `UNRELEASED_API:${apiName}` : null;
|
|
320
|
+
// The finding's own referenced object first; the text's name only as a fallback.
|
|
321
|
+
// When SAP said the TYPE, only ARS rows of that type are its state: ARS is read by
|
|
322
|
+
// name, and a function group SCAL is not the table SCAL (live, 2026-10-08).
|
|
323
|
+
const ref = (projectType === 'clean-core' ? apiNameOf?.get(f) : undefined) ?? (apiName ? { name: apiName } : undefined);
|
|
324
|
+
const uses = ref ? usesByName?.get(ref.name)?.filter((u) => !ref.type || u.type === ref.type) : undefined;
|
|
164
325
|
// A finding nobody can name still counts: dropping it would make the object look cleaner than it is.
|
|
165
326
|
const rule = api ?? (atcRule || UNNAMED_FINDING);
|
|
166
327
|
const aliases = [...new Set([...(api && atcRule ? [atcRule] : []), ...ruleAliasesFromAtc(f), ...halvesOf(f)])]
|
|
@@ -182,6 +343,8 @@ export function findingsToRows(measured, findings, projectType) {
|
|
|
182
343
|
...(f.line ? { line: f.line } : {}),
|
|
183
344
|
// The title only. The long text can quote source, and the record promises: no source code.
|
|
184
345
|
...(title ? { message: title.slice(0, 200) } : {}),
|
|
346
|
+
// F8: only on a finding whose API was named, and only what SAP returned for that name.
|
|
347
|
+
...(uses?.length ? { uses: uses.map((u) => ({ ...u })) } : {}),
|
|
185
348
|
});
|
|
186
349
|
}
|
|
187
350
|
return [...byKey.entries()].map(([key, list]) => {
|
|
@@ -408,7 +571,25 @@ export async function measure(input) {
|
|
|
408
571
|
};
|
|
409
572
|
if (!rowRefs.length)
|
|
410
573
|
return { ...counts, file: null, withFindings: 0, refusedRows };
|
|
411
|
-
const
|
|
574
|
+
const allFindings = [...outcome.findings, ...ownFindings];
|
|
575
|
+
// F8: the APIs the findings of measured objects use, read once for the whole run.
|
|
576
|
+
let usesByName;
|
|
577
|
+
let apiNameOf;
|
|
578
|
+
let apiNames;
|
|
579
|
+
if (input.sql && input.project.type === 'clean-core') {
|
|
580
|
+
const rowKeys = new Set(rowRefs.map((o) => objectKey(o.type, o.name)));
|
|
581
|
+
const inRows = allFindings.filter((f) => typeof f?.key === 'string' && rowKeys.has(normalizeKey(f.key)));
|
|
582
|
+
const releasedApi = inRows.filter(isReleasedApiFinding);
|
|
583
|
+
if (releasedApi.length) {
|
|
584
|
+
const got = await apiNamesFor(releasedApi, input.atc.findingDocument?.bind(input.atc), input.apiDocumentCap ?? MAX_API_DOCUMENTS, input.signal);
|
|
585
|
+
apiNameOf = got.named;
|
|
586
|
+
apiNames = got.report;
|
|
587
|
+
}
|
|
588
|
+
const names = [...[...(apiNameOf?.values() ?? [])].map((r) => r.name), ...inRows.map((f) => unreleasedApiName(f)).filter((n) => !!n)];
|
|
589
|
+
if (names.length)
|
|
590
|
+
usesByName = await apiStatesOf(input.sql, names);
|
|
591
|
+
}
|
|
592
|
+
const rows = findingsToRows(rowRefs, allFindings, input.project.type, usesByName, apiNameOf);
|
|
412
593
|
const res = writeRecord(input.joined, 'measurement', rows, input.by, {
|
|
413
594
|
...(input.now ? { now: input.now } : {}), ...(input.dir ? { dir: input.dir } : {}),
|
|
414
595
|
...(input.fileName ? { fileName: input.fileName } : {}), ...(input.rand ? { rand: input.rand } : {}),
|
|
@@ -435,5 +616,6 @@ export async function measure(input) {
|
|
|
435
616
|
return {
|
|
436
617
|
...counts, ...coverageNow(), file: res.file, refusedRows,
|
|
437
618
|
withFindings: rows.filter((r) => r.findings.length > 0).length,
|
|
619
|
+
...(apiNames ? { apiNames } : {}),
|
|
438
620
|
};
|
|
439
621
|
}
|
|
@@ -31,6 +31,8 @@ const DASH = PLAN_COLUMNS.indexOf('—');
|
|
|
31
31
|
const FACT_COLUMNS = PLAN_COLUMNS.slice(DASH + 1);
|
|
32
32
|
const lower = (s) => s.trim().toLowerCase();
|
|
33
33
|
const CANONICAL = new Map(PLAN_COLUMNS.map((c) => [lower(c), c]));
|
|
34
|
+
// The Objects sheet (1.1.32) heads the decided level "Level decided", beside its "Level now".
|
|
35
|
+
CANONICAL.set('level decided', 'Level');
|
|
34
36
|
/**
|
|
35
37
|
* The Plan sheet and the export stamp of a workbook `team report` wrote.
|
|
36
38
|
* Throws the one sentence for anything else: not an .xlsx, no Plan sheet, no
|
|
@@ -44,9 +46,10 @@ export function readPlanSheet(buffer) {
|
|
|
44
46
|
catch {
|
|
45
47
|
throw new RegisterError(NOT_A_PLAN_SHEET);
|
|
46
48
|
}
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
const stampCell =
|
|
49
|
+
// 1.1.32 writes "Objects" with the stamp on "Summary"; a 1.1.31 workbook has "Plan" and "Read me".
|
|
50
|
+
const plan = sheets.find((s) => lower(s.name) === 'objects') ?? sheets.find((s) => lower(s.name) === 'plan');
|
|
51
|
+
const stampCell = sheets.filter((s) => lower(s.name) === 'summary' || lower(s.name) === 'read me')
|
|
52
|
+
.flatMap((s) => s.rows.flat()).find((c) => lower(c).startsWith(lower(EXPORT_STAMP)));
|
|
50
53
|
const exportStamp = stampCell ? stampCell.trim().slice(EXPORT_STAMP.length).trim() : '';
|
|
51
54
|
if (!plan || !STAMP.test(exportStamp) || Number.isNaN(Date.parse(exportStamp)))
|
|
52
55
|
throw new RegisterError(NOT_A_PLAN_SHEET);
|
package/dist/register/records.js
CHANGED
|
@@ -167,7 +167,15 @@ export function fixRowsFromUpgradeProgress(content, baseline) {
|
|
|
167
167
|
* per row, clean-core-confirm.ts). The five words map onto the four
|
|
168
168
|
* classifications (spec §4.2); a fix or a rebuild with no target reaches A.
|
|
169
169
|
*/
|
|
170
|
-
export function decisionRowsFromCleanCore(content
|
|
170
|
+
export function decisionRowsFromCleanCore(content,
|
|
171
|
+
/**
|
|
172
|
+
* Who said Yes, and when. With it, a confirmed row whose proposal NAMED the
|
|
173
|
+
* level its target is read from — a fix/rebuild target, a keep/exception
|
|
174
|
+
* level — carries `targetBy` (F3, M19): the person's Yes set that target.
|
|
175
|
+
* A fix with no target in the proposal still records the legacy A, and that
|
|
176
|
+
* is no target. Absent: rows exactly as before.
|
|
177
|
+
*/
|
|
178
|
+
by) {
|
|
171
179
|
const rows = [];
|
|
172
180
|
for (const r of content.adjudications ?? []) {
|
|
173
181
|
if (r?.confirmed !== true)
|
|
@@ -181,11 +189,13 @@ export function decisionRowsFromCleanCore(content) {
|
|
|
181
189
|
if (!base)
|
|
182
190
|
continue;
|
|
183
191
|
const reason = typeof r.reason === 'string' ? r.reason.trim().slice(0, 300) : '';
|
|
192
|
+
const named = moves ? !!r.targetLevel : base.classification === 'keep' && !!r.level;
|
|
184
193
|
rows.push({
|
|
185
194
|
key: objectKey(r.objectType, r.objectName), ...base,
|
|
186
195
|
...(r.level ? { level: r.level } : {}),
|
|
187
196
|
...(moves ? { targetLevel: r.targetLevel ?? 'A' } : {}),
|
|
188
197
|
...(reason ? { reason } : {}),
|
|
198
|
+
...(by && named ? { targetBy: { name: by.name, at: by.at } } : {}),
|
|
189
199
|
});
|
|
190
200
|
}
|
|
191
201
|
return rows;
|
|
@@ -214,8 +224,10 @@ opts = {}) {
|
|
|
214
224
|
return { ...res, refused: [...res.refused, ...ambiguous.map((n) => `${n} (name matches more than one object type)`)] };
|
|
215
225
|
}
|
|
216
226
|
if (env.artefactType === 'clean-core-decision') {
|
|
217
|
-
|
|
218
|
-
|
|
227
|
+
// Every row here had a person's Yes (confirmCleanCoreRows / the journey's row card).
|
|
228
|
+
const at = (opts.now ?? new Date()).toISOString();
|
|
229
|
+
const rows = decisionRowsFromCleanCore(env.content, { name: by.name, at });
|
|
230
|
+
return writeRecord(joined, 'decision', rows, by, { source, ...opts, now: opts.now ?? new Date(at) });
|
|
219
231
|
}
|
|
220
232
|
return null;
|
|
221
233
|
}
|