@cspeach/cli 1.1.14 → 1.1.16
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 +63 -0
- package/dist/agent/loop.js +32 -7
- package/dist/agent/tool-dispatch.js +13 -9
- package/dist/cli.js +81 -27
- package/dist/commands/compact.js +6 -2
- package/dist/commands/project-context-impact.js +2 -2
- package/dist/commands/project.js +301 -0
- package/dist/commands/team.js +1166 -0
- package/dist/one-shot.js +2 -0
- package/dist/projects/image-attachments.js +155 -0
- package/dist/projects/save-command.js +20 -0
- package/dist/register/atc-file.js +802 -0
- package/dist/register/baseline.js +106 -0
- package/dist/register/measure.js +380 -0
- package/dist/register/records.js +159 -0
- package/dist/register/store.js +227 -0
- package/dist/repl/post-turn-status.js +2 -1
- package/dist/repl.js +16 -2
- package/dist/rewind/candidates.js +14 -1
- package/dist/rewind/restore.js +53 -19
- package/dist/router/classifier.js +24 -4
- package/dist/session/recap.js +8 -5
- package/dist/session/resume.js +37 -17
- package/dist/session/store.js +5 -3
- package/dist/session/user-prompt.js +23 -0
- package/dist/skill-catalog.js +33 -0
- package/dist/skills/bundled-skills.js +1 -1
- package/dist/tools/filesystem/extract-document.js +6 -0
- package/dist/tools/filesystem/extract-xlsx.js +139 -0
- package/dist/tools/filesystem/file-write.js +20 -0
- package/dist/tools/filesystem/read-document.js +15 -6
- package/dist/tools/include-snapshot.js +24 -0
- package/dist/tools/sap-write.js +19 -9
- package/dist/tools/snapshot.js +27 -0
- package/dist/ui/clipboard-image.js +125 -0
- package/dist/ui/footer.js +11 -1
- package/dist/ui/text-input.js +30 -2
- package/package.json +7 -4
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The baseline: every custom object in scope, pulled from the SAP object
|
|
3
|
+
* directory (TADIR) by program code. It is the denominator of the whole
|
|
4
|
+
* Project Register, so it is never written by the model and never trusted
|
|
5
|
+
* without a control total.
|
|
6
|
+
*
|
|
7
|
+
* Rules, each learned on the live S4H pull of 2026-09-18 (spec §4.2):
|
|
8
|
+
* - One query per package, `DEVCLASS = '<pkg>'`. The ADT data-preview dialect
|
|
9
|
+
* rejects IN-lists and more than one LIKE, and a long IN-list was seen to
|
|
10
|
+
* return `[]` with NO error — an empty answer is not evidence of nothing.
|
|
11
|
+
* - Control totals first (`COUNT(*) … GROUP BY DEVCLASS`). Pulled rows must
|
|
12
|
+
* equal the control total for that package or the pull fails loudly.
|
|
13
|
+
* - Only code-bearing object types count. A plain Z-star pull is mostly gateway
|
|
14
|
+
* registrations (IWSG/IWOM) and other generated entries.
|
|
15
|
+
* - Local ($TMP) and generated (GENFLAG) objects are set aside and counted,
|
|
16
|
+
* not silently dropped.
|
|
17
|
+
*/
|
|
18
|
+
export const CODE_OBJECT_TYPES = [
|
|
19
|
+
'PROG', 'CLAS', 'INTF', 'FUGR', 'TABL', 'DTEL', 'DOMA', 'TTYP', 'VIEW', 'SHLP',
|
|
20
|
+
'DDLS', 'BDEF', 'SRVD', 'SRVB', 'DDLX', 'DCLS', 'ENHO', 'MSAG', 'TRAN', 'SFPF', 'SFPI', 'SSFO',
|
|
21
|
+
];
|
|
22
|
+
export const LOCAL_PACKAGE = '$TMP';
|
|
23
|
+
export class BaselineError extends Error {
|
|
24
|
+
}
|
|
25
|
+
/** Package names are interpolated into SQL, so they are checked, not escaped. */
|
|
26
|
+
const PACKAGE_RE = /^[A-Z0-9_/$]{1,30}$/;
|
|
27
|
+
const PREFIX_RE = /^[A-Z/][A-Z0-9_/]{0,29}$/;
|
|
28
|
+
const clean = (v) => (v ?? '').trim();
|
|
29
|
+
/**
|
|
30
|
+
* Customer packages with their object counts — the list the lead picks the
|
|
31
|
+
* scope from, and the control totals for the pull. `$TMP` is reported with the
|
|
32
|
+
* rest; whether it is in scope is the lead's call (default: no).
|
|
33
|
+
*/
|
|
34
|
+
export async function listCustomPackages(sql, prefixes = ['Z', 'Y']) {
|
|
35
|
+
const out = new Map();
|
|
36
|
+
for (const prefix of prefixes) {
|
|
37
|
+
if (!PREFIX_RE.test(prefix))
|
|
38
|
+
throw new BaselineError(`Not a valid name prefix: ${prefix}`);
|
|
39
|
+
const res = await sql(`SELECT DEVCLASS, COUNT(*) AS CNT FROM TADIR WHERE PGMID = 'R3TR' AND DELFLAG = ' ' AND DEVCLASS LIKE '${prefix}%' GROUP BY DEVCLASS`, 10000);
|
|
40
|
+
for (const row of res.rows)
|
|
41
|
+
out.set(clean(row.DEVCLASS), { package: clean(row.DEVCLASS), objects: Number(clean(row.CNT)) });
|
|
42
|
+
const local = await sql(`SELECT COUNT(*) AS CNT FROM TADIR WHERE PGMID = 'R3TR' AND DELFLAG = ' ' AND DEVCLASS = '${LOCAL_PACKAGE}' AND OBJ_NAME LIKE '${prefix}%'`, 10);
|
|
43
|
+
const n = Number(clean(local.rows[0]?.CNT) || 0);
|
|
44
|
+
if (n) {
|
|
45
|
+
const tmp = out.get(LOCAL_PACKAGE) ?? { package: LOCAL_PACKAGE, objects: 0, byPrefix: {} };
|
|
46
|
+
tmp.objects += n;
|
|
47
|
+
tmp.byPrefix[prefix] = n;
|
|
48
|
+
out.set(LOCAL_PACKAGE, tmp);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return [...out.values()].sort((a, b) => a.package.localeCompare(b.package));
|
|
52
|
+
}
|
|
53
|
+
export async function pullBaseline(sql, opts) {
|
|
54
|
+
const types = new Set((opts.objectTypes ?? CODE_OBJECT_TYPES).map((t) => t.toUpperCase()));
|
|
55
|
+
const objects = [];
|
|
56
|
+
const setAside = { generated: 0, otherTypes: 0 };
|
|
57
|
+
const perPackage = [];
|
|
58
|
+
const seen = new Set();
|
|
59
|
+
for (const { package: pkg, objects: expected, byPrefix } of opts.packages) {
|
|
60
|
+
if (!PACKAGE_RE.test(pkg))
|
|
61
|
+
throw new BaselineError(`Not a valid package name: ${pkg}`);
|
|
62
|
+
const select = `SELECT OBJECT, OBJ_NAME, DEVCLASS, AUTHOR, GENFLAG, CREATED_ON FROM TADIR WHERE PGMID = 'R3TR' AND DELFLAG = ' ' AND DEVCLASS = '${pkg}'`;
|
|
63
|
+
const res = { rows: [] };
|
|
64
|
+
if (byPrefix) {
|
|
65
|
+
for (const [prefix, n] of Object.entries(byPrefix)) {
|
|
66
|
+
if (!PREFIX_RE.test(prefix))
|
|
67
|
+
throw new BaselineError(`Not a valid name prefix: ${prefix}`);
|
|
68
|
+
res.rows.push(...(await sql(`${select} AND OBJ_NAME LIKE '${prefix}%'`, n + 50)).rows);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
res.rows.push(...(await sql(select, expected + 50)).rows);
|
|
73
|
+
}
|
|
74
|
+
if (res.rows.length !== expected) {
|
|
75
|
+
throw new BaselineError(`Package ${pkg}: the system counted ${expected} objects but the pull returned ${res.rows.length}. `
|
|
76
|
+
+ 'The baseline was NOT written. A wrong denominator would make every progress number wrong; run init again, '
|
|
77
|
+
+ 'and if this repeats, pull this package on its own.');
|
|
78
|
+
}
|
|
79
|
+
let kept = 0;
|
|
80
|
+
for (const row of res.rows) {
|
|
81
|
+
const type = clean(row.OBJECT).toUpperCase();
|
|
82
|
+
const name = clean(row.OBJ_NAME).toUpperCase();
|
|
83
|
+
if (!types.has(type)) {
|
|
84
|
+
setAside.otherTypes += 1;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (clean(row.GENFLAG) && !opts.includeGenerated) {
|
|
88
|
+
setAside.generated += 1;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const key = `${type}|${name}`;
|
|
92
|
+
if (seen.has(key))
|
|
93
|
+
continue;
|
|
94
|
+
seen.add(key);
|
|
95
|
+
objects.push({
|
|
96
|
+
type, name, package: clean(row.DEVCLASS),
|
|
97
|
+
...(clean(row.AUTHOR) ? { author: clean(row.AUTHOR) } : {}),
|
|
98
|
+
...(clean(row.CREATED_ON) ? { createdOn: clean(row.CREATED_ON) } : {}),
|
|
99
|
+
});
|
|
100
|
+
kept += 1;
|
|
101
|
+
}
|
|
102
|
+
perPackage.push({ package: pkg, pulled: res.rows.length, kept });
|
|
103
|
+
}
|
|
104
|
+
objects.sort((a, b) => a.package.localeCompare(b.package) || a.type.localeCompare(b.type) || a.name.localeCompare(b.name));
|
|
105
|
+
return { objects, setAside, perPackage };
|
|
106
|
+
}
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Measuring: an ATC run turned into a measurement record (step 2, "Proof").
|
|
3
|
+
*
|
|
4
|
+
* Four rules, each the difference between proof and a nice-looking number:
|
|
5
|
+
* · Rows come from the SCOPE — the baseline cut by a lane or by packages —
|
|
6
|
+
* never from the findings. An object the run covered and found nothing in
|
|
7
|
+
* gets `findings: []`: that row is what "clean" IS.
|
|
8
|
+
* · An object the run did NOT cover — its chunk was refused, its type has no
|
|
9
|
+
* ADT url, or the worklist never named it — gets NO row. It stays "not
|
|
10
|
+
* measured". A wrong "clean" is worse than a missing measurement.
|
|
11
|
+
* · Findings are never summed across objects. One finding in a shared include
|
|
12
|
+
* is reported under EVERY object that uses it (see `attributedBy:
|
|
13
|
+
* 'single-run'` in sap-client), so a call-level total would be a number
|
|
14
|
+
* nobody can defend. Per object, the counts are right.
|
|
15
|
+
* · Nothing measured → nothing written.
|
|
16
|
+
*
|
|
17
|
+
* Program code only. No model tool reaches this file, and the SAP call arrives
|
|
18
|
+
* as a capability so this layer never imports the SAP client.
|
|
19
|
+
*/
|
|
20
|
+
import { laneMatcher, normalizeKey, normalizeRuleId, objectKey, ruleAliasesFromAtc, ruleIdFromAtc, UNNAMED_FINDING, } from '@cspeach/register-core';
|
|
21
|
+
import { readBaseline, readProjectFile, RegisterError } from './store.js';
|
|
22
|
+
import { writeRecord } from './records.js';
|
|
23
|
+
/* ── Measuring ──────────────────────────────────────────────────────────── */
|
|
24
|
+
/**
|
|
25
|
+
* Whether an object the worklist never named may be written as clean.
|
|
26
|
+
*
|
|
27
|
+
* FALSE, AND IT STAYS FALSE. This is settled, not pending.
|
|
28
|
+
*
|
|
29
|
+
* The 2026-09-20 capture answered it (`sap-client/test/fixtures/README.md`,
|
|
30
|
+
* answer 1): SAP LISTS EVERY OBJECT A CHECK RUN COVERED. An object it found
|
|
31
|
+
* nothing in comes back listed, with an empty findings element — that is what
|
|
32
|
+
* clean looks like, and it is `evidence: 'listed'`. An object the worklist
|
|
33
|
+
* never names was not checked at all: the variant has no check for its type.
|
|
34
|
+
*
|
|
35
|
+
* So a silence is not a weak yes, it is a no. `absent` gets no row, is counted
|
|
36
|
+
* under `absent`, and stays "not measured". `measure()` still takes
|
|
37
|
+
* `absentMeansClean` because the FILE import path offers it — a person who
|
|
38
|
+
* exported only the findings can say their file's silence means clean, and the
|
|
39
|
+
* row they get says so.
|
|
40
|
+
*/
|
|
41
|
+
export const ABSENT_MEANS_CLEAN = false;
|
|
42
|
+
export function scopeLabel(scope) {
|
|
43
|
+
if (scope.kind === 'lane')
|
|
44
|
+
return `lane:${scope.id.trim()}`;
|
|
45
|
+
// Trimmed: the label is written into the record and two runs of the same
|
|
46
|
+
// packages, typed with and without a space, have to read as the same scope.
|
|
47
|
+
if (scope.kind === 'packages')
|
|
48
|
+
return `packages:${scope.patterns.map((p) => p.trim()).join(',')}`;
|
|
49
|
+
return 'all';
|
|
50
|
+
}
|
|
51
|
+
const toRegex = (pattern) => new RegExp(`^${pattern.trim().replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`, 'i');
|
|
52
|
+
export function scopeObjects(baseline, lanes, scope) {
|
|
53
|
+
if (scope.kind === 'all')
|
|
54
|
+
return baseline;
|
|
55
|
+
if (scope.kind === 'lane') {
|
|
56
|
+
const lane = lanes.find((l) => l.id.toLowerCase() === scope.id.toLowerCase());
|
|
57
|
+
if (!lane)
|
|
58
|
+
throw new RegisterError(`There is no lane "${scope.id}". Lanes: ${lanes.map((l) => l.id).join(', ') || 'none yet'}.`);
|
|
59
|
+
const match = laneMatcher([lane]);
|
|
60
|
+
return baseline.filter((o) => match(o).length > 0);
|
|
61
|
+
}
|
|
62
|
+
const patterns = scope.patterns.map(toRegex);
|
|
63
|
+
return baseline.filter((o) => patterns.some((re) => re.test(o.package)));
|
|
64
|
+
}
|
|
65
|
+
const NOT_RELEASED = /\b(?:not released|unreleased|non-released)\b/i;
|
|
66
|
+
// The two quote characters are written as \x27 and \x22 on purpose: the word
|
|
67
|
+
// tripwire (`team-words.test.ts`) reads this file as text and cannot tell a
|
|
68
|
+
// regex from a string, so a bare quote here would blind it for the rest of the
|
|
69
|
+
// file. Same pattern, no quote characters in the source.
|
|
70
|
+
const NAMED = /\b(?:table|view|class|interface|function module|function|object|api|type)\s+[\x27\x22]?([^\s\x27\x22,.;:()]+)/gi;
|
|
71
|
+
// Case-sensitive on purpose: SAP writes object names in capitals, prose is not.
|
|
72
|
+
// A leading `/NAMESPACE/` is part of the name (`/ACME/CL_TAX`).
|
|
73
|
+
const SAP_NAME = /^\/?[A-Z][A-Z0-9_]*(?:\/[A-Z0-9_]+)*$/;
|
|
74
|
+
/**
|
|
75
|
+
* Words the pattern above can capture that are ABAP grammar, not an object:
|
|
76
|
+
* `type REF TO`, `TYPE TABLE OF`, `API CDS view I_PRODUCT`. Taking one of these
|
|
77
|
+
* would put a source token in the record and name the rule after nothing.
|
|
78
|
+
*/
|
|
79
|
+
const NOT_A_NAME = new Set(['TABLE', 'VIEW', 'REF', 'CDS', 'TYPE', 'OF', 'TO', 'API', 'OBJECT', 'CLASS', 'INTERFACE', 'FUNCTION', 'MODULE', 'DATA', 'FIELD', 'STRUCTURE']);
|
|
80
|
+
function apiNameIn(text) {
|
|
81
|
+
for (const m of text.matchAll(NAMED)) {
|
|
82
|
+
const name = m[1];
|
|
83
|
+
if (name.length < 3 || name.length > 40)
|
|
84
|
+
continue;
|
|
85
|
+
if (NOT_A_NAME.has(name.replace(/^\//, '').toUpperCase()))
|
|
86
|
+
continue;
|
|
87
|
+
if (SAP_NAME.test(name))
|
|
88
|
+
return name;
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* D7: in a clean-core team project an unreleased-API finding is named by the
|
|
94
|
+
* API (`UNRELEASED_API:VBAK`), but ONLY when the finding's own text names it.
|
|
95
|
+
* No name → null → the ordinary ATC rule id stands, so a finding is never lost.
|
|
96
|
+
*
|
|
97
|
+
* The TITLE is read first: it is where SAP names the API, while the long text
|
|
98
|
+
* can quote source code and would otherwise hand a token like `TABLE` to the
|
|
99
|
+
* record. The wording is STILL ASSUMED: the 2026-09-20 capture ran DEFAULT and
|
|
100
|
+
* S4HANA_READINESS_2022 only, and neither produced an unreleased-API finding
|
|
101
|
+
* (README answer 9, still open). This function is the only place that wording
|
|
102
|
+
* is read, and it must be re-checked against a clean-core variant run.
|
|
103
|
+
*/
|
|
104
|
+
export function unreleasedApiRule(f) {
|
|
105
|
+
const title = f.messageTitle ?? '';
|
|
106
|
+
const body = f.messageText ?? '';
|
|
107
|
+
if (!NOT_RELEASED.test(`${title} ${body}`))
|
|
108
|
+
return null;
|
|
109
|
+
const name = apiNameIn(title) ?? apiNameIn(body);
|
|
110
|
+
return name ? `UNRELEASED_API:${name}` : null;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* SAP writes some check names as `ID / text` — `SLIN_DB / SELECT without ORDER
|
|
114
|
+
* BY`. A claim recorded by a developer may name either half, so both halves are
|
|
115
|
+
* kept as aliases: a fix claim and the finding it is about have to be able to
|
|
116
|
+
* meet, whichever half the person had in front of them.
|
|
117
|
+
*/
|
|
118
|
+
function halvesOf(src) {
|
|
119
|
+
const out = [];
|
|
120
|
+
for (const text of [src.checkId, src.messageId, src.category, src.messageTitle]) {
|
|
121
|
+
if (typeof text !== 'string' || !text.includes(' / '))
|
|
122
|
+
continue;
|
|
123
|
+
for (const half of text.split(' / ')) {
|
|
124
|
+
const id = normalizeRuleId(half);
|
|
125
|
+
if (id)
|
|
126
|
+
out.push(id);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Rows from a run's findings, one per object it covered.
|
|
133
|
+
*
|
|
134
|
+
* A ref may say HOW the run knew about the object. When it does, a row with no
|
|
135
|
+
* findings carries that word on: `listed` (the worklist named it and reported
|
|
136
|
+
* nothing) or `absent` (the row says clean only because somebody said silence
|
|
137
|
+
* means clean). A row WITH findings carries none — the findings are its
|
|
138
|
+
* evidence — and a ref that says nothing leaves the row exactly as it was, which
|
|
139
|
+
* is what the file importer relies on: it labels its own rows afterwards.
|
|
140
|
+
*
|
|
141
|
+
* Without this the day `ABSENT_MEANS_CLEAN` flips is the day the label is lost:
|
|
142
|
+
* `coverage.absent` goes to 0, an absent-clean row renders a plain "Clean", and
|
|
143
|
+
* nothing anywhere says that nothing actually looked at the object.
|
|
144
|
+
*/
|
|
145
|
+
export function findingsToRows(measured, findings, projectType) {
|
|
146
|
+
const byKey = new Map();
|
|
147
|
+
const evidenceOf = new Map();
|
|
148
|
+
for (const o of measured) {
|
|
149
|
+
const key = objectKey(o.type, o.name);
|
|
150
|
+
byKey.set(key, []);
|
|
151
|
+
// `listed` outranks `absent`: one sighting of the object beats a silence.
|
|
152
|
+
if (o.evidence && !(o.evidence === 'absent' && evidenceOf.get(key) === 'listed'))
|
|
153
|
+
evidenceOf.set(key, o.evidence);
|
|
154
|
+
}
|
|
155
|
+
for (const f of findings) {
|
|
156
|
+
// A finding whose key is not even text belongs to no object we asked for.
|
|
157
|
+
const list = typeof f?.key === 'string' ? byKey.get(normalizeKey(f.key)) : undefined;
|
|
158
|
+
if (!list)
|
|
159
|
+
continue; // a finding for an object that was not measured is never written
|
|
160
|
+
const atcRule = ruleIdFromAtc(f);
|
|
161
|
+
const api = projectType === 'clean-core' ? unreleasedApiRule(f) : null;
|
|
162
|
+
// A finding nobody can name still counts: dropping it would make the object look cleaner than it is.
|
|
163
|
+
const rule = api ?? (atcRule || UNNAMED_FINDING);
|
|
164
|
+
const aliases = [...new Set([...(api && atcRule ? [atcRule] : []), ...ruleAliasesFromAtc(f), ...halvesOf(f)])]
|
|
165
|
+
.filter((a) => a !== rule)
|
|
166
|
+
// F1: nothing BARE beside a qualified name. Half of `<check>:<message>` is
|
|
167
|
+
// a name other rules answer to — every message of one check carries that
|
|
168
|
+
// check's GUID, and more than one check uses a message id of `SELECT` —
|
|
169
|
+
// and a shared name is how two findings become one. `halvesOf` splits
|
|
170
|
+
// `ID / text` names, so it lands here too.
|
|
171
|
+
.filter((a) => !(rule.includes(':') && !a.includes(':')));
|
|
172
|
+
// The RULE was named above, from `f` alone. What is written as the message
|
|
173
|
+
// is a separate question, and `displayMessage` — when a caller built one —
|
|
174
|
+
// answers it without ever having been near the id.
|
|
175
|
+
const title = (f.displayMessage ?? f.messageTitle ?? '').trim();
|
|
176
|
+
list.push({
|
|
177
|
+
rule,
|
|
178
|
+
...(aliases.length ? { aliases } : {}),
|
|
179
|
+
...(f.priority ? { severity: String(f.priority) } : {}),
|
|
180
|
+
...(f.line ? { line: f.line } : {}),
|
|
181
|
+
// The title only. The long text can quote source, and the record promises: no source code.
|
|
182
|
+
...(title ? { message: title.slice(0, 200) } : {}),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
return [...byKey.entries()].map(([key, list]) => {
|
|
186
|
+
const evidence = list.length ? undefined : evidenceOf.get(key);
|
|
187
|
+
return { key, findings: list, ...(evidence ? { evidence } : {}) };
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
/** A chunk never attempted: the run stopped before it, or there was no budget left to split it. */
|
|
191
|
+
const NEVER_RAN = new Set(['not-run']);
|
|
192
|
+
/** The only words that mean the CALL ended early. Anything else is not a stop. */
|
|
193
|
+
const STOPPED_BY = new Set(['aborted', 'deadline', 'connection']);
|
|
194
|
+
/**
|
|
195
|
+
* As many absent keys as a caller could ever want to name. A whole estate can be
|
|
196
|
+
* absent — 20,000 keys the caller will print five of — so the list stops here
|
|
197
|
+
* and `absent` remains the count. Generous enough that the filtered subsets a
|
|
198
|
+
* caller really prints (objects with findings, objects with a claim) are whole
|
|
199
|
+
* on any real team project.
|
|
200
|
+
*/
|
|
201
|
+
const MAX_ABSENT_NAMES = 2000;
|
|
202
|
+
/**
|
|
203
|
+
* Anything that means "no row" beats anything that means "row", and the most
|
|
204
|
+
* specific reason wins among the ones that mean no row. So an object the answer
|
|
205
|
+
* calls measured AND failed is failed; one that is `not-run` and also in the
|
|
206
|
+
* failed list is still `not-run`, because that is the truer sentence to print.
|
|
207
|
+
*/
|
|
208
|
+
const BUCKETS = ['measured', 'absent', 'failed', 'notRun', 'unsupported'];
|
|
209
|
+
const ANSWER_UNREADABLE = 'The check run gave an answer CSPeach could not read. Nothing was written.';
|
|
210
|
+
/** A measurement needs a variant name; this one is a run nothing could be compared with. */
|
|
211
|
+
const NO_VARIANT = 'This check run was given no ATC check variant name, so nothing it measured could be compared. Nothing was written.';
|
|
212
|
+
/** The same sentence `cspeach team refresh` prints, so there is only one of it. */
|
|
213
|
+
export const variantMismatchMessage = (ours, asked) => `This team project measures with ${ours}. A run with ${asked} cannot be compared with it, so it was not started. `
|
|
214
|
+
+ 'To run it anyway — the record is kept, but not counted — add --force-variant';
|
|
215
|
+
export const noVariantYetMessage = (lead) => `This team project has no ATC check variant yet. The lead (${lead}) sets it once: cspeach team refresh --all --variant <NAME>`;
|
|
216
|
+
/**
|
|
217
|
+
* D6, the second lock. `refresh` settles the variant before it ever connects;
|
|
218
|
+
* this repeats the ruling for every other caller, because a record written with
|
|
219
|
+
* a blank variant is counted by the picture as though it were comparable.
|
|
220
|
+
*
|
|
221
|
+
* The team project file on DISK decides, not the copy in hand: the lead's first
|
|
222
|
+
* run writes the name there and measures in the same breath.
|
|
223
|
+
*/
|
|
224
|
+
function settleVariant(input) {
|
|
225
|
+
const asked = (input.variant ?? '').trim();
|
|
226
|
+
if (!asked)
|
|
227
|
+
throw new RegisterError(NO_VARIANT);
|
|
228
|
+
const onDisk = readProjectFile(input.joined.projectDir);
|
|
229
|
+
const ours = (onDisk.atcVariant ?? input.project.atcVariant ?? '').trim();
|
|
230
|
+
if (!ours) {
|
|
231
|
+
if (!input.forceVariant)
|
|
232
|
+
throw new RegisterError(noVariantYetMessage(onDisk.lead?.name ?? input.project.lead.name));
|
|
233
|
+
return asked;
|
|
234
|
+
}
|
|
235
|
+
// The team project's own name, in the capitals SAP writes check variants in.
|
|
236
|
+
// The file on disk can be hand-edited, and a lower-case name must not be what
|
|
237
|
+
// goes down the wire or into a record the picture compares with.
|
|
238
|
+
if (ours.toUpperCase() === asked.toUpperCase())
|
|
239
|
+
return ours.toUpperCase();
|
|
240
|
+
if (!input.forceVariant)
|
|
241
|
+
throw new RegisterError(variantMismatchMessage(ours.toUpperCase(), asked.toUpperCase()));
|
|
242
|
+
return asked;
|
|
243
|
+
}
|
|
244
|
+
const refKey = (o) => {
|
|
245
|
+
const r = o;
|
|
246
|
+
return r && typeof r.type === 'string' && typeof r.name === 'string' ? objectKey(r.type, r.name) : '';
|
|
247
|
+
};
|
|
248
|
+
const list = (v) => (Array.isArray(v) ? v : []);
|
|
249
|
+
/** An answer that is not an outcome is a refusal in words, never a stack trace. */
|
|
250
|
+
function readOutcome(answer) {
|
|
251
|
+
const o = answer;
|
|
252
|
+
if (!o || typeof o !== 'object'
|
|
253
|
+
|| !Array.isArray(o.measured) || !Array.isArray(o.failed) || !Array.isArray(o.unsupported)
|
|
254
|
+
|| !Array.isArray(o.findings) || !Array.isArray(o.chunks)) {
|
|
255
|
+
throw new RegisterError(ANSWER_UNREADABLE);
|
|
256
|
+
}
|
|
257
|
+
return o;
|
|
258
|
+
}
|
|
259
|
+
export async function measure(input) {
|
|
260
|
+
const label = scopeLabel(input.scope);
|
|
261
|
+
// One object, one row, however often the baseline names it: a case-twin would
|
|
262
|
+
// otherwise be asked for twice and counted twice.
|
|
263
|
+
const seen = new Set();
|
|
264
|
+
const inScope = scopeObjects(input.baseline, input.lanes, input.scope)
|
|
265
|
+
.filter((o) => { const k = objectKey(o.type, o.name); return seen.has(k) ? false : (seen.add(k), true); });
|
|
266
|
+
if (!inScope.length)
|
|
267
|
+
throw new RegisterError(`Nothing in this team project matches ${label}.`);
|
|
268
|
+
const absentMeansClean = input.absentMeansClean ?? ABSENT_MEANS_CLEAN;
|
|
269
|
+
const variant = settleVariant(input); // before SAP is touched
|
|
270
|
+
const outcome = readOutcome(await input.atc.runSet(inScope.map((o) => ({ type: o.type, name: o.name })), variant, {
|
|
271
|
+
...(input.chunkSize ? { chunkSize: input.chunkSize } : {}),
|
|
272
|
+
...(input.oneByOne === false ? { maxSingleRuns: 0 } : {}),
|
|
273
|
+
...(input.signal ? { signal: input.signal } : {}),
|
|
274
|
+
...(input.maxTotalMs ? { maxTotalMs: input.maxTotalMs } : {}),
|
|
275
|
+
...(input.onProgress ? { onProgress: input.onProgress } : {}),
|
|
276
|
+
}));
|
|
277
|
+
// Everything the answer says about an object is collected, and the object is
|
|
278
|
+
// then read the safe way round. Nothing here depends on the order it arrives in.
|
|
279
|
+
const claims = new Map();
|
|
280
|
+
for (const o of inScope)
|
|
281
|
+
claims.set(objectKey(o.type, o.name), new Set());
|
|
282
|
+
const claim = (key, b) => { claims.get(key)?.add(b); }; // an object nobody asked for is never counted
|
|
283
|
+
for (const m of outcome.measured)
|
|
284
|
+
claim(refKey(m), m?.evidence === 'listed' || absentMeansClean ? 'measured' : 'absent');
|
|
285
|
+
for (const u of outcome.unsupported)
|
|
286
|
+
claim(refKey(u), 'unsupported');
|
|
287
|
+
for (const c of outcome.chunks) {
|
|
288
|
+
if (c?.status !== 'refused' || c.superseded)
|
|
289
|
+
continue;
|
|
290
|
+
for (const o of list(c.asked))
|
|
291
|
+
claim(refKey(o), NEVER_RAN.has(c.reason ?? '') ? 'notRun' : 'failed');
|
|
292
|
+
}
|
|
293
|
+
for (const f of outcome.failed)
|
|
294
|
+
claim(refKey(f), 'failed');
|
|
295
|
+
// A row is a promise about an object of THIS team project, checked against the
|
|
296
|
+
// baseline on disk — the one `writeRecord` will check it against a moment later.
|
|
297
|
+
const known = new Set(readBaseline(input.joined.projectDir).map((o) => objectKey(o.type, o.name)));
|
|
298
|
+
const refusedRows = [];
|
|
299
|
+
for (const key of claims.keys()) {
|
|
300
|
+
if (known.has(key))
|
|
301
|
+
continue;
|
|
302
|
+
claim(key, 'failed');
|
|
303
|
+
refusedRows.push(key);
|
|
304
|
+
}
|
|
305
|
+
const bucketOf = (key) => {
|
|
306
|
+
const said = claims.get(key) ?? new Set();
|
|
307
|
+
for (let i = BUCKETS.length - 1; i >= 0; i -= 1)
|
|
308
|
+
if (said.has(BUCKETS[i]))
|
|
309
|
+
return BUCKETS[i];
|
|
310
|
+
return 'failed'; // asked for, and the answer said nothing about it
|
|
311
|
+
};
|
|
312
|
+
const buckets = new Map([...claims.keys()].map((k) => [k, bucketOf(k)]));
|
|
313
|
+
const tally = (b) => [...buckets.values()].filter((v) => v === b).length;
|
|
314
|
+
const coverageNow = () => ({
|
|
315
|
+
asked: inScope.length, measured: tally('measured'), absent: tally('absent'),
|
|
316
|
+
failed: tally('failed'), unsupported: tally('unsupported'), notRun: tally('notRun'),
|
|
317
|
+
});
|
|
318
|
+
// How the run knew about each object, the safe way round: one sighting beats
|
|
319
|
+
// a silence, whatever order the answer listed them in.
|
|
320
|
+
const evidenceOf = new Map();
|
|
321
|
+
for (const m of outcome.measured) {
|
|
322
|
+
const key = refKey(m);
|
|
323
|
+
if (!key)
|
|
324
|
+
continue;
|
|
325
|
+
const said = m?.evidence === 'listed' ? 'listed' : 'absent';
|
|
326
|
+
if (!(said === 'absent' && evidenceOf.get(key) === 'listed'))
|
|
327
|
+
evidenceOf.set(key, said);
|
|
328
|
+
}
|
|
329
|
+
// Row order follows the scope, so two runs of the same lane read the same way.
|
|
330
|
+
const rowRefs = inScope.filter((o) => buckets.get(objectKey(o.type, o.name)) === 'measured')
|
|
331
|
+
.map((o) => ({ type: o.type, name: o.name, ...(evidenceOf.get(objectKey(o.type, o.name)) ? { evidence: evidenceOf.get(objectKey(o.type, o.name)) } : {}) }));
|
|
332
|
+
const coverage = coverageNow();
|
|
333
|
+
const refusedChunks = outcome.chunks
|
|
334
|
+
.filter((c) => c?.status === 'refused' && !c.superseded)
|
|
335
|
+
.map((c) => ({ index: c.index, reason: c.reason ?? 'error', ...(c.detail ? { detail: c.detail } : {}), objects: list(c.asked).length }));
|
|
336
|
+
const keysInScope = inScope.map((o) => objectKey(o.type, o.name));
|
|
337
|
+
const unsupportedNames = keysInScope.filter((k) => buckets.get(k) === 'unsupported');
|
|
338
|
+
const absentNames = keysInScope.filter((k) => buckets.get(k) === 'absent').slice(0, MAX_ABSENT_NAMES);
|
|
339
|
+
// Only the three words this layer knows. Anything else is not a stop.
|
|
340
|
+
const stoppedBy = STOPPED_BY.has(outcome.stopped) ? outcome.stopped : undefined;
|
|
341
|
+
// A whole number of findings or nothing: a `'2'` or a −1 out of an answer
|
|
342
|
+
// CSPeach did not write must not become a sentence about the customer's ATC
|
|
343
|
+
// screen. F3.
|
|
344
|
+
const merged = outcome.duplicatesMerged;
|
|
345
|
+
const duplicates = typeof merged === 'number' && Number.isInteger(merged) && merged > 0 ? merged : 0;
|
|
346
|
+
const counts = {
|
|
347
|
+
...coverage, unsupportedNames, absentNames, scope: label, refusedChunks,
|
|
348
|
+
...(duplicates ? { duplicateFindingsMerged: duplicates } : {}),
|
|
349
|
+
...(stoppedBy ? { stoppedBy } : {}),
|
|
350
|
+
};
|
|
351
|
+
if (!rowRefs.length)
|
|
352
|
+
return { ...counts, file: null, withFindings: 0, refusedRows };
|
|
353
|
+
const rows = findingsToRows(rowRefs, outcome.findings, input.project.type);
|
|
354
|
+
const res = writeRecord(input.joined, 'measurement', rows, input.by, {
|
|
355
|
+
...(input.now ? { now: input.now } : {}), ...(input.dir ? { dir: input.dir } : {}),
|
|
356
|
+
...(input.fileName ? { fileName: input.fileName } : {}), ...(input.rand ? { rand: input.rand } : {}),
|
|
357
|
+
// M5: `coverage` is the run's own account, written in the one call that
|
|
358
|
+
// writes the record. A row `writeRecord` refuses — the baseline changed
|
|
359
|
+
// underneath the run — is only known once it returns, and rewriting the file
|
|
360
|
+
// to correct one count would mean two writes for a race. The RETURNED counts
|
|
361
|
+
// below are recomputed after the refusal and are the ones a caller prints.
|
|
362
|
+
extra: {
|
|
363
|
+
atcVariant: variant, scope: label, basis: 'system', coverage,
|
|
364
|
+
...(duplicates ? { duplicateFindingsMerged: duplicates } : {}),
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
// `measured` is a promise about the rows in that file. Everything named here
|
|
368
|
+
// was in the baseline a moment ago, so this only bites if the team project
|
|
369
|
+
// folder changed underneath the run — and then the counts say so.
|
|
370
|
+
for (const key of res.refused) {
|
|
371
|
+
const k = normalizeKey(key) || key;
|
|
372
|
+
claim(k, 'failed');
|
|
373
|
+
buckets.set(k, 'failed');
|
|
374
|
+
refusedRows.push(key);
|
|
375
|
+
}
|
|
376
|
+
return {
|
|
377
|
+
...counts, ...coverageNow(), file: res.file, refusedRows,
|
|
378
|
+
withFindings: rows.filter((r) => r.findings.length > 0).length,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writing this laptop's records (spec §4.4).
|
|
3
|
+
*
|
|
4
|
+
* A record is an export: fixed program code, fixed format, small readable JSON.
|
|
5
|
+
* The model has no tool that reaches this code or the outbox. Rows are checked
|
|
6
|
+
* against the baseline before anything is written — an object the project does
|
|
7
|
+
* not contain is refused and named, which is also the guard against a
|
|
8
|
+
* model-written detail file inventing objects.
|
|
9
|
+
*/
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { classificationFromCcaDetail, fromCcaDetailKey, normalizeKey, normalizeRuleId, objectKey, } from '@cspeach/register-core';
|
|
14
|
+
import { defaultRand, readBaseline } from './store.js';
|
|
15
|
+
const FIX_STATUSES = new Set(['fixed', 'skipped', 'failed', 'pending']);
|
|
16
|
+
export function writeRecord(joined, recordType, rows, by, opts = {}) {
|
|
17
|
+
const known = new Set(readBaseline(joined.projectDir).map((o) => objectKey(o.type, o.name)));
|
|
18
|
+
const kept = [];
|
|
19
|
+
const refused = [];
|
|
20
|
+
for (const row of rows) {
|
|
21
|
+
const key = normalizeKey(row.key);
|
|
22
|
+
if (known.has(key))
|
|
23
|
+
kept.push({ ...row, key });
|
|
24
|
+
else
|
|
25
|
+
refused.push(row.key);
|
|
26
|
+
}
|
|
27
|
+
if (!kept.length)
|
|
28
|
+
return { file: null, written: 0, refused };
|
|
29
|
+
const now = opts.now ?? new Date();
|
|
30
|
+
const recordId = (opts.rand ?? defaultRand)(6);
|
|
31
|
+
const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
|
|
32
|
+
const dir = opts.dir ?? path.join(joined.outboxDir, 'writers', joined.writerId, 'records');
|
|
33
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
34
|
+
// `basename`: a record is written into the folder it was written for, and a
|
|
35
|
+
// name carrying `..` can never walk out of it.
|
|
36
|
+
const file = path.join(dir, opts.fileName ? path.basename(opts.fileName) : `${stamp}-${recordType}-${recordId.slice(0, 4)}.json`);
|
|
37
|
+
const record = {
|
|
38
|
+
kind: 'record', formatVersion: 1, recordId, projectId: joined.projectId, recordType,
|
|
39
|
+
at: now.toISOString(), by: { name: by.name, writer: joined.writerId },
|
|
40
|
+
...(opts.source ? { source: opts.source } : {}),
|
|
41
|
+
...(opts.extra ?? {}),
|
|
42
|
+
rows: kept,
|
|
43
|
+
};
|
|
44
|
+
// Write whole, then rename: a sync tool must never pick up half a record.
|
|
45
|
+
fs.writeFileSync(`${file}.tmp`, `${JSON.stringify(record, null, 2)}\n`);
|
|
46
|
+
try {
|
|
47
|
+
fs.renameSync(`${file}.tmp`, file);
|
|
48
|
+
}
|
|
49
|
+
catch (e) {
|
|
50
|
+
// The rename can be refused — a folder standing where the file goes, a sync
|
|
51
|
+
// tool holding the name, a read-only share. The caller is told, and the
|
|
52
|
+
// half-written file goes: a stray `.json.tmp` in a folder the whole team
|
|
53
|
+
// syncs is a record nobody wrote, counted by the next reader as a file it
|
|
54
|
+
// could not read. Best effort, because the reason the rename failed may
|
|
55
|
+
// well be the reason this fails too — and the error the caller gets must be
|
|
56
|
+
// the real one.
|
|
57
|
+
try {
|
|
58
|
+
fs.unlinkSync(`${file}.tmp`);
|
|
59
|
+
}
|
|
60
|
+
catch { /* nothing more can be done about it here */ }
|
|
61
|
+
throw e;
|
|
62
|
+
}
|
|
63
|
+
return { file, written: kept.length, refused };
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Decisions from a saved CCA assessment. The full list lives in the detail
|
|
67
|
+
* file's `objects.json` (the envelope table stops at 50 rows), so that is read
|
|
68
|
+
* first; the envelope rows are the fallback. `UNKNOWN` is no decision.
|
|
69
|
+
*/
|
|
70
|
+
export function decisionRowsFromCca(content, projectRoot) {
|
|
71
|
+
const rows = new Map();
|
|
72
|
+
const detail = path.join(projectRoot, path.dirname(content.detailPath ?? ''), 'objects.json');
|
|
73
|
+
if (content.detailPath && fs.existsSync(detail)) {
|
|
74
|
+
try {
|
|
75
|
+
const objects = JSON.parse(fs.readFileSync(detail, 'utf8'));
|
|
76
|
+
for (const [detailKey, o] of Object.entries(objects ?? {})) {
|
|
77
|
+
const key = fromCcaDetailKey(detailKey);
|
|
78
|
+
const cls = classificationFromCcaDetail(o?.classification?.primary)
|
|
79
|
+
?? classificationFromCcaDetail(o?.technicalReadiness?.status);
|
|
80
|
+
if (!key || !cls)
|
|
81
|
+
continue;
|
|
82
|
+
const confidence = String(o?.classification?.confidence ?? '').toLowerCase();
|
|
83
|
+
rows.set(key, {
|
|
84
|
+
key, classification: cls,
|
|
85
|
+
...(['high', 'medium', 'low'].includes(confidence) ? { confidence: confidence } : {}),
|
|
86
|
+
...(typeof o?.classification?.evidence === 'string' ? { reason: o.classification.evidence.slice(0, 300) } : {}),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch { /* a detail file the model left unparseable: fall back to the envelope rows */ }
|
|
91
|
+
}
|
|
92
|
+
for (const c of content.classifications ?? []) {
|
|
93
|
+
const key = objectKey(c.objectType, c.objectName);
|
|
94
|
+
// A human-reviewed envelope row outranks the detail file's first pass.
|
|
95
|
+
rows.set(key, { key, classification: c.classification, confidence: c.confidence });
|
|
96
|
+
}
|
|
97
|
+
return [...rows.values()];
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Fix rows from a saved upgrade-progress. Its rows carry an object NAME but no
|
|
101
|
+
* type, so the name is resolved against the baseline; a name that matches two
|
|
102
|
+
* object types is refused rather than guessed.
|
|
103
|
+
*/
|
|
104
|
+
export function fixRowsFromUpgradeProgress(content, baseline) {
|
|
105
|
+
const byName = new Map();
|
|
106
|
+
for (const o of baseline) {
|
|
107
|
+
const name = o.name.toUpperCase();
|
|
108
|
+
byName.set(name, [...(byName.get(name) ?? []), objectKey(o.type, o.name)]);
|
|
109
|
+
}
|
|
110
|
+
const rows = [];
|
|
111
|
+
const ambiguous = [];
|
|
112
|
+
for (const f of content.fixes ?? []) {
|
|
113
|
+
const status = String(f.status).toLowerCase();
|
|
114
|
+
if (!FIX_STATUSES.has(status))
|
|
115
|
+
continue;
|
|
116
|
+
const keys = byName.get(String(f.objectName).trim().toUpperCase()) ?? [];
|
|
117
|
+
if (keys.length > 1) {
|
|
118
|
+
ambiguous.push(f.objectName);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
// The same function a measurement's rule goes through (register-core rules.ts),
|
|
122
|
+
// so a claim and the finding it is about can meet. A row with no rule names
|
|
123
|
+
// nothing a measurement could ever confirm, so it is not written.
|
|
124
|
+
const rule = normalizeRuleId(f.finding);
|
|
125
|
+
if (!rule)
|
|
126
|
+
continue;
|
|
127
|
+
rows.push({
|
|
128
|
+
// Unknown names keep a placeholder key so writeRecord refuses and names them.
|
|
129
|
+
key: keys[0] ?? `?|${f.objectName}`, rule, status, findingId: f.id,
|
|
130
|
+
...(content.transport ? { transport: content.transport } : {}),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return { rows, ambiguous };
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Called after every successful envelope save. Does nothing unless this laptop
|
|
137
|
+
* has joined a project; never throws (a record is a by-product, a save is not).
|
|
138
|
+
*/
|
|
139
|
+
export function recordFromEnvelope(env, savedPath, joined, by, projectRoot,
|
|
140
|
+
/** The clock and the ids. Tests only: real saves take the wall clock and random bytes. */
|
|
141
|
+
opts = {}) {
|
|
142
|
+
if (!joined)
|
|
143
|
+
return null;
|
|
144
|
+
const source = { file: path.basename(savedPath), sha256: '', skill: env.source?.skill };
|
|
145
|
+
try {
|
|
146
|
+
source.sha256 = createHash('sha256').update(fs.readFileSync(savedPath)).digest('hex');
|
|
147
|
+
}
|
|
148
|
+
catch { /* source checksum is a nicety */ }
|
|
149
|
+
if (env.artefactType === 'cca-assessment') {
|
|
150
|
+
const rows = decisionRowsFromCca(env.content, projectRoot);
|
|
151
|
+
return writeRecord(joined, 'decision', rows, by, { source, ...opts });
|
|
152
|
+
}
|
|
153
|
+
if (env.artefactType === 'upgrade-progress') {
|
|
154
|
+
const { rows, ambiguous } = fixRowsFromUpgradeProgress(env.content, readBaseline(joined.projectDir));
|
|
155
|
+
const res = writeRecord(joined, 'fix', rows, by, { source, ...opts });
|
|
156
|
+
return { ...res, refused: [...res.refused, ...ambiguous.map((n) => `${n} (name matches more than one object type)`)] };
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|