@cspeach/cli 1.1.31 → 1.1.33

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.
Files changed (66) hide show
  1. package/dist/agent/loop.js +16 -6
  2. package/dist/cli.js +13 -4
  3. package/dist/commands/cost.js +6 -2
  4. package/dist/commands/help.js +18 -2
  5. package/dist/commands/plan-chain.js +3 -0
  6. package/dist/commands/plan-gate.js +10 -1
  7. package/dist/commands/plan-resume.js +4 -0
  8. package/dist/commands/team-rules.js +187 -0
  9. package/dist/commands/team.js +632 -90
  10. package/dist/commands/usage.js +106 -0
  11. package/dist/cost/month-usage.js +45 -0
  12. package/dist/journey/chain.js +265 -15
  13. package/dist/journey/clean-core.js +25 -3
  14. package/dist/journey/flow.js +51 -1
  15. package/dist/journey/rebuild.js +464 -0
  16. package/dist/journey/run.js +213 -20
  17. package/dist/journey/session-deps.js +5 -1
  18. package/dist/lane/app-chain.js +12 -2
  19. package/dist/one-shot.js +24 -17
  20. package/dist/projects/clean-core-confirm.js +38 -5
  21. package/dist/projects/save-command.js +20 -3
  22. package/dist/register/baseline.js +39 -2
  23. package/dist/register/decision-rules.js +35 -0
  24. package/dist/register/discover.js +49 -19
  25. package/dist/register/export-files.js +58 -0
  26. package/dist/register/measure.js +191 -9
  27. package/dist/register/plan-import.js +6 -3
  28. package/dist/register/records.js +15 -3
  29. package/dist/register/rule-proposal.js +4 -0
  30. package/dist/register/rules-edit.js +257 -0
  31. package/dist/register/status-file/register-status-file.js +106 -108
  32. package/dist/register/status-file/report-model.js +569 -0
  33. package/dist/register/status-file/team-css.js +1 -1
  34. package/dist/register/status-file-sync.js +1 -0
  35. package/dist/register/status-html.js +25 -6
  36. package/dist/register/store.js +4 -3
  37. package/dist/register/variant.js +65 -0
  38. package/dist/register/workbooks.js +22 -56
  39. package/dist/register/write-whole.js +30 -2
  40. package/dist/renderer/tool-labels.js +2 -1
  41. package/dist/repl/builtin-commands.js +7 -0
  42. package/dist/repl/journey-entry.js +4 -2
  43. package/dist/repl/plan-hop.js +2 -9
  44. package/dist/repl/rules-menu.js +177 -0
  45. package/dist/repl/slash-completer.js +2 -0
  46. package/dist/repl/slash-picker.js +5 -4
  47. package/dist/repl.js +157 -86
  48. package/dist/router/classifier.js +8 -4
  49. package/dist/router/router-core.js +43 -7
  50. package/dist/router/routing-decision.js +1 -1
  51. package/dist/runs/bundled-runs.js +100 -0
  52. package/dist/runs/front-door.js +19 -0
  53. package/dist/runs/manifest-runs.js +50 -0
  54. package/dist/runs/run-command.js +24 -0
  55. package/dist/runs/runs.js +27 -0
  56. package/dist/runs/shadow.js +100 -0
  57. package/dist/runs/types.js +1 -0
  58. package/dist/session/queued-step.js +45 -0
  59. package/dist/skills/bundled-skills.js +53 -53
  60. package/dist/tools/dispatch-skill.js +8 -4
  61. package/dist/tools/lane/app-lane-resolve.js +1 -1
  62. package/dist/tools/project/rules_propose.js +59 -0
  63. package/dist/tools/project/team_findings.js +13 -2
  64. package/dist/upgrade-check.js +49 -4
  65. package/package.json +120 -120
  66. 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
- modifications: (op) => `SELECT OBJ_TYPE, OBJ_NAME, SUB_TYPE, SUB_NAME, OPERATION, MOD_USER, MOD_DATE FROM SMODILOG WHERE OPERATION = '${op}'`,
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
- /** The one wrapper every statement runs through. Never throws. */
151
- const run = async (step, statement, maxRows, what) => {
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
- queries.push({ step, statement, rows: rows.length, ms: Date.now() - t0, ok: true });
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
- countedPrefixes.add(p);
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 run(7, SQL.batch(p), 50000);
442
- if (!('rows' in res)) {
443
- failed = failed ?? res.error;
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
- const res = await run(11, SQL.modifications(op), 50000);
823
- if (!('rows' in res))
824
- continue;
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
+ }
@@ -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. The wording is STILL ASSUMED: the 2026-09-20 capture ran DEFAULT and
102
- * S4HANA_READINESS_2022 only, and neither produced an unreleased-API finding
103
- * (README answer 9, still open). This function is the only place that wording
104
- * is read, and it must be re-checked against a clean-core variant run.
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
- const name = apiNameIn(title) ?? apiNameIn(body);
112
- return name ? `UNRELEASED_API:${name}` : null;
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 api = projectType === 'clean-core' ? unreleasedApiRule(f) : null;
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 rows = findingsToRows(rowRefs, [...outcome.findings, ...ownFindings], input.project.type);
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
- const plan = sheets.find((s) => lower(s.name) === 'plan');
48
- const readMe = sheets.find((s) => lower(s.name) === 'read me');
49
- const stampCell = readMe?.rows.flat().find((c) => lower(c).startsWith(lower(EXPORT_STAMP)));
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);
@@ -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
- const rows = decisionRowsFromCleanCore(env.content);
218
- return writeRecord(joined, 'decision', rows, by, { source, ...opts });
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
  }
@@ -0,0 +1,4 @@
1
+ let slot = null;
2
+ export const setRuleProposal = (p) => { slot = JSON.parse(JSON.stringify(p)); };
3
+ export const ruleProposalFor = (projectId) => (slot && slot.projectId === projectId ? slot : null);
4
+ export const clearRuleProposal = () => { slot = null; };