@cspeach/cli 1.1.34 → 1.1.36
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/.gitattributes +1 -0
- package/dist/agent/loop.js +127 -31
- package/dist/agent/parallel-write-guard.js +3 -1
- package/dist/commands/plan-audit-evidence.js +6 -1
- package/dist/commands/plan-gate.js +10 -1
- package/dist/commands/plan-resume.js +9 -0
- package/dist/lib/graceful-exit.js +17 -0
- package/dist/one-shot.js +3 -0
- package/dist/projects/answer-blockers.js +25 -12
- package/dist/projects/extract-plan.js +6 -0
- package/dist/projects/plan-objects.js +1 -1
- package/dist/projects/plan-skeleton.js +93 -0
- package/dist/projects/save-command.js +17 -0
- package/dist/rap/finish-checks.js +389 -0
- package/dist/rap/generator-names.js +57 -0
- package/dist/rap/skeleton-card.js +31 -0
- package/dist/rap/skeleton-rules.js +38 -0
- package/dist/rap/skeleton-session.js +21 -0
- package/dist/rap/skeleton-store.js +40 -0
- package/dist/renderer/change-card.js +28 -4
- package/dist/renderer/notices.js +27 -13
- package/dist/renderer/progress-chatter.js +3 -1
- package/dist/renderer/tool-labels.js +12 -3
- package/dist/renderer/verify-chain.js +41 -18
- package/dist/renderer/wrap-rows.js +87 -0
- package/dist/repl/rule8-detector.js +3 -2
- package/dist/repl.js +11 -2
- package/dist/sap/connection-manager.js +15 -0
- package/dist/sap/generator-fact.js +21 -0
- package/dist/sap/session-end.js +66 -0
- package/dist/session/audit-export.js +19 -1
- package/dist/tools/ask-question.js +1 -1
- package/dist/tools/rap/finish-check.js +118 -0
- package/dist/tools/rap/generate-objects.js +317 -0
- package/dist/tools/rap/skeleton-resolve.js +283 -0
- package/dist/tools/sap-read.js +22 -22
- package/dist/tools/sap-write.js +249 -18
- package/dist/tools/snapshot.js +26 -0
- package/dist/tools/tool-loading.js +7 -1
- package/dist/tools/write-mode.js +2 -0
- package/dist/ui/app.js +19 -9
- package/dist/ui/approval-modal.js +37 -5
- package/dist/ui/live-area.js +14 -1
- package/dist/ui/widgets/ask-card.js +183 -107
- package/dist/ui/widgets/coaching-picker.js +22 -1
- package/dist/ui/widgets/confirm-card.js +102 -76
- package/package.json +2 -2
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
// cspeach-cli/src/rap/finish-checks.ts
|
|
2
|
+
//
|
|
3
|
+
// RAP generator plan, Task 10 — Finish items F1–F5 (spec 2026-10-14 §6),
|
|
4
|
+
// checked mechanically on the app's sources so the model cannot claim them
|
|
5
|
+
// done. Pure: the tool `rap_finish_check` reads the live sources and calls
|
|
6
|
+
// checkFinish. SAP's real generated skeleton is the failing fixture.
|
|
7
|
+
//
|
|
8
|
+
// Problem texts are short, plain sentences a developer understands; the only
|
|
9
|
+
// ids in them are object and field names.
|
|
10
|
+
//
|
|
11
|
+
// Fix round 1: comments (`//`, `--`, `/* */`) are stripped from the BDEF, CDS,
|
|
12
|
+
// DDLX and table sources before any rule reads them, so commented-out code never
|
|
13
|
+
// passes a check. A view field the parser cannot read is a problem, never a
|
|
14
|
+
// silent skip.
|
|
15
|
+
export const FINISH_TITLES = {
|
|
16
|
+
F1: 'Authorization',
|
|
17
|
+
F2: 'Access control',
|
|
18
|
+
F3: 'Admin fields',
|
|
19
|
+
F4: 'Labels',
|
|
20
|
+
F5: 'Coded fields',
|
|
21
|
+
};
|
|
22
|
+
const ADMIN_FIELDS = ['CreatedBy', 'CreatedAt', 'LastChangedBy', 'LastChangedAt', 'LocalLastChangedAt', 'LocalLastChangedBy'];
|
|
23
|
+
const item = (id, problems) => ({ id, ok: problems.length === 0, problems });
|
|
24
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
25
|
+
const eqi = (a, b) => a.toLowerCase() === b.toLowerCase();
|
|
26
|
+
const unreadable = (line, object) => `Could not read the field on line ${line} of ${object}; check it by hand`;
|
|
27
|
+
/**
|
|
28
|
+
* CDS / BDEF source without comments: `// …` and `-- …` to end of line, and
|
|
29
|
+
* `/* … *\/` blocks. Quoted literals ('…') are kept as they are. Line breaks
|
|
30
|
+
* inside a block comment are kept, so line numbers do not move.
|
|
31
|
+
*/
|
|
32
|
+
export function stripCdsComments(src) {
|
|
33
|
+
let out = '';
|
|
34
|
+
let i = 0;
|
|
35
|
+
const n = src.length;
|
|
36
|
+
while (i < n) {
|
|
37
|
+
const c = src[i];
|
|
38
|
+
const d = src[i + 1];
|
|
39
|
+
if (c === "'") {
|
|
40
|
+
const j = src.indexOf("'", i + 1);
|
|
41
|
+
const e = j < 0 ? n : j + 1;
|
|
42
|
+
out += src.slice(i, e);
|
|
43
|
+
i = e;
|
|
44
|
+
}
|
|
45
|
+
else if ((c === '/' && d === '/') || (c === '-' && d === '-')) {
|
|
46
|
+
while (i < n && src[i] !== '\n' && src[i] !== '\r')
|
|
47
|
+
i++;
|
|
48
|
+
}
|
|
49
|
+
else if (c === '/' && d === '*') {
|
|
50
|
+
const j = src.indexOf('*/', i + 2);
|
|
51
|
+
const e = j < 0 ? n : j + 2;
|
|
52
|
+
out += src.slice(i, e).replace(/[^\r\n]/g, '');
|
|
53
|
+
i = e;
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
out += c;
|
|
57
|
+
i++;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
/** Annotation lines directly above line index `i` of `lines`, joined. */
|
|
63
|
+
function annotationsAt(lines, i) {
|
|
64
|
+
const ann = [];
|
|
65
|
+
let depth = 0;
|
|
66
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
67
|
+
const t = lines[j].trim();
|
|
68
|
+
depth += (t.match(/[\]}]/g)?.length ?? 0) - (t.match(/[[{]/g)?.length ?? 0);
|
|
69
|
+
if (t === '' && depth <= 0)
|
|
70
|
+
break;
|
|
71
|
+
if (depth <= 0 && !t.startsWith('@') && !/^[\]}]/.test(t))
|
|
72
|
+
break;
|
|
73
|
+
ann.unshift(t);
|
|
74
|
+
}
|
|
75
|
+
return ann.join('\n');
|
|
76
|
+
}
|
|
77
|
+
/** Annotation lines that sit directly above `element` (DDLS or DDLX), joined. Comments are ignored. */
|
|
78
|
+
export function annotationsFor(source, element) {
|
|
79
|
+
const lines = stripCdsComments(source).split(/\r?\n/);
|
|
80
|
+
const re = new RegExp(`^\\s*(key\\s+)?([a-z0-9_.]+\\s+as\\s+)?${escapeRe(element)}\\s*[,;]?\\s*$`, 'i');
|
|
81
|
+
const i = lines.findIndex((l) => re.test(l));
|
|
82
|
+
return i < 0 ? '' : annotationsAt(lines, i);
|
|
83
|
+
}
|
|
84
|
+
/** Splits an annotation block into single annotations (each starts with '@'). */
|
|
85
|
+
function splitAnnotations(block) {
|
|
86
|
+
const out = [];
|
|
87
|
+
for (const line of block.split('\n')) {
|
|
88
|
+
if (line.startsWith('@') || out.length === 0)
|
|
89
|
+
out.push(line);
|
|
90
|
+
else
|
|
91
|
+
out[out.length - 1] += `\n${line}`;
|
|
92
|
+
}
|
|
93
|
+
return out.filter((a) => a.trim() !== '');
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Field labels in an annotation block. Left out: @UI.facet (a section title)
|
|
97
|
+
* and lineItem/identification entries of `type: #FOR_ACTION` (an action button).
|
|
98
|
+
*/
|
|
99
|
+
function fieldLabels(block) {
|
|
100
|
+
const labels = [];
|
|
101
|
+
for (const a of splitAnnotations(block)) {
|
|
102
|
+
if (/^@UI\.facet\b/i.test(a))
|
|
103
|
+
continue;
|
|
104
|
+
const fieldOnly = a.replace(/\{[^{}]*#FOR_ACTION\b[^{}]*\}/gi, '');
|
|
105
|
+
for (const m of fieldOnly.matchAll(/\blabel\s*:\s*'([^']*)'/gi))
|
|
106
|
+
labels.push(m[1]);
|
|
107
|
+
}
|
|
108
|
+
return labels;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Fields of a view entity body. Handles `key X`, `X`, `x as X`, `path.x as X`,
|
|
112
|
+
* `virtual X : …`, and expressions ending in `as X` (`cast( … ) as X`, a
|
|
113
|
+
* `case … end as X` over several lines). Association exposures (`_Assoc`,
|
|
114
|
+
* `_Assoc : redirected to …`) are skipped. Anything else, and two fields on
|
|
115
|
+
* one line, is reported in `unreadable`; it is never skipped silently.
|
|
116
|
+
*/
|
|
117
|
+
export function ddlsElements(source) {
|
|
118
|
+
const text = stripCdsComments(source);
|
|
119
|
+
const lines = text.split(/\r?\n/);
|
|
120
|
+
const def = text.search(/\bdefine\b/i);
|
|
121
|
+
const open = text.indexOf('{', def < 0 ? 0 : def);
|
|
122
|
+
const close = text.lastIndexOf('}');
|
|
123
|
+
if (open < 0 || close <= open)
|
|
124
|
+
return { elements: [], unreadable: [] };
|
|
125
|
+
const lineOf = (pos) => text.slice(0, pos).match(/\n/g)?.length ?? 0;
|
|
126
|
+
const colOf = (pos) => pos - (text.lastIndexOf('\n', pos - 1) + 1);
|
|
127
|
+
const openLine = lineOf(open);
|
|
128
|
+
const closeLine = lineOf(close);
|
|
129
|
+
const body = [];
|
|
130
|
+
for (let k = openLine; k <= closeLine; k++) {
|
|
131
|
+
const l = lines[k];
|
|
132
|
+
const from = k === openLine ? colOf(open) + 1 : 0;
|
|
133
|
+
const to = k === closeLine ? colOf(close) : l.length;
|
|
134
|
+
body.push(l.slice(from, to));
|
|
135
|
+
}
|
|
136
|
+
// Blank the annotations (they may span lines and contain commas).
|
|
137
|
+
let depth = 0;
|
|
138
|
+
for (let k = 0; k < body.length; k++) {
|
|
139
|
+
const t = body[k].trim();
|
|
140
|
+
if (depth > 0 || t.startsWith('@')) {
|
|
141
|
+
const bare = t.replace(/'[^']*'/g, '');
|
|
142
|
+
depth = Math.max(0, depth + (bare.match(/[[{(]/g)?.length ?? 0) - (bare.match(/[\]})]/g)?.length ?? 0));
|
|
143
|
+
body[k] = '';
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const chunks = [];
|
|
147
|
+
let cur = '';
|
|
148
|
+
let first = -1;
|
|
149
|
+
let last = -1;
|
|
150
|
+
let nest = 0;
|
|
151
|
+
let inQuote = false;
|
|
152
|
+
const flush = () => {
|
|
153
|
+
if (cur.trim() !== '')
|
|
154
|
+
chunks.push({ text: cur, first, last });
|
|
155
|
+
cur = '';
|
|
156
|
+
first = -1;
|
|
157
|
+
last = -1;
|
|
158
|
+
};
|
|
159
|
+
for (let k = 0; k < body.length; k++) {
|
|
160
|
+
for (const ch of `${body[k]}\n`) {
|
|
161
|
+
if (!/\s/.test(ch)) {
|
|
162
|
+
if (first < 0)
|
|
163
|
+
first = k;
|
|
164
|
+
last = k;
|
|
165
|
+
}
|
|
166
|
+
if (inQuote) {
|
|
167
|
+
if (ch === "'")
|
|
168
|
+
inQuote = false;
|
|
169
|
+
cur += ch;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (ch === "'")
|
|
173
|
+
inQuote = true;
|
|
174
|
+
else if ('([{'.includes(ch))
|
|
175
|
+
nest++;
|
|
176
|
+
else if (')]}'.includes(ch))
|
|
177
|
+
nest--;
|
|
178
|
+
else if (ch === ',' && nest === 0) {
|
|
179
|
+
flush();
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
cur += ch;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
flush();
|
|
186
|
+
const usage = new Map();
|
|
187
|
+
for (const c of chunks)
|
|
188
|
+
for (let k = c.first; k <= c.last; k++)
|
|
189
|
+
usage.set(k, (usage.get(k) ?? 0) + 1);
|
|
190
|
+
const bad = new Set();
|
|
191
|
+
const elements = [];
|
|
192
|
+
for (const c of chunks) {
|
|
193
|
+
const line = openLine + c.first;
|
|
194
|
+
const shared = [...Array(c.last - c.first + 1).keys()].map((d) => c.first + d).find((k) => (usage.get(k) ?? 0) > 1);
|
|
195
|
+
if (shared !== undefined) {
|
|
196
|
+
bad.add(openLine + shared + 1);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
const s = c.text.replace(/\s+/g, ' ').trim();
|
|
200
|
+
if (/^_\w+(\s*:.*)?$/.test(s))
|
|
201
|
+
continue;
|
|
202
|
+
const virt = /^(?:key\s+)?virtual\s+([A-Za-z_]\w*)\s*:/i.exec(s);
|
|
203
|
+
if (virt) {
|
|
204
|
+
elements.push({ name: virt[1], line });
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const m = /^(?:key\s+)?(?:(.+?)\s+as\s+)?([A-Za-z_]\w*)$/i.exec(s);
|
|
208
|
+
if (!m) {
|
|
209
|
+
bad.add(line + 1);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
elements.push(m[1] && /^[\w.]+$/.test(m[1]) ? { name: m[2], source: m[1], line } : { name: m[2], line });
|
|
213
|
+
}
|
|
214
|
+
return { elements, unreadable: [...bad].sort((x, y) => x - y) };
|
|
215
|
+
}
|
|
216
|
+
/** ABAP source without comment lines (`*` in column 1) and end-of-line `"` comments. */
|
|
217
|
+
function stripAbapComments(src) {
|
|
218
|
+
return src.split(/\r?\n/).filter((l) => !l.startsWith('*')).map((l) => l.replace(/".*$/, '')).join('\n');
|
|
219
|
+
}
|
|
220
|
+
/** F1 — instance authorization with a real AUTHORITY-CHECK. Expects comment-free BDEF. */
|
|
221
|
+
function checkAuth(src, opts) {
|
|
222
|
+
const problems = [];
|
|
223
|
+
if (!/authorization\s+master\s*\(\s*instance\s*\)/i.test(src.rootBdef)) {
|
|
224
|
+
problems.push(/authorization\s+master\s*\(\s*global\s*\)/i.test(src.rootBdef)
|
|
225
|
+
? 'Authorization is global; it must be instance'
|
|
226
|
+
: `${opts.root} has no instance authorization; add authorization master ( instance )`);
|
|
227
|
+
}
|
|
228
|
+
const code = stripAbapComments(src.ccimp);
|
|
229
|
+
const start = /\bMETHOD\s+get_instance_authorizations\s*\./i.exec(code);
|
|
230
|
+
if (!start) {
|
|
231
|
+
problems.push('get_instance_authorizations is not implemented');
|
|
232
|
+
return item('F1', problems);
|
|
233
|
+
}
|
|
234
|
+
const rest = code.slice(start.index + start[0].length);
|
|
235
|
+
const end = /\bENDMETHOD\b/i.exec(rest);
|
|
236
|
+
const body = end ? rest.slice(0, end.index) : rest;
|
|
237
|
+
if (!/\bAUTHORITY-CHECK\b/i.test(body)) {
|
|
238
|
+
problems.push('get_instance_authorizations has no AUTHORITY-CHECK');
|
|
239
|
+
}
|
|
240
|
+
else if (opts.authObject && !new RegExp(`\\bOBJECT\\s+'${escapeRe(opts.authObject)}'`, 'i').test(body)) {
|
|
241
|
+
problems.push(`get_instance_authorizations does not check ${opts.authObject}`);
|
|
242
|
+
}
|
|
243
|
+
return item('F1', problems);
|
|
244
|
+
}
|
|
245
|
+
/** F2 — an access control for the root view and for the projection. */
|
|
246
|
+
function checkDcl(src, opts) {
|
|
247
|
+
const have = new Set(src.dclsNames.map((n) => n.toUpperCase()));
|
|
248
|
+
const problems = [opts.root, opts.projection]
|
|
249
|
+
.filter((n) => !have.has(n.toUpperCase()))
|
|
250
|
+
.map((n) => `No access control for ${n}`);
|
|
251
|
+
return item('F2', problems);
|
|
252
|
+
}
|
|
253
|
+
const ABP_ADMIN_TYPE = /^abp_(?:creation_(?:user|tstmpl)|lastchange_(?:user|tstmpl)|locinst_\w+)$/i;
|
|
254
|
+
/** Columns of a `define table` source typed with an abp_* admin type (lower-case names). */
|
|
255
|
+
function abpAdminColumns(table) {
|
|
256
|
+
if (!table)
|
|
257
|
+
return [];
|
|
258
|
+
const cols = [];
|
|
259
|
+
for (const m of table.matchAll(/^\s*(?:key\s+)?([A-Za-z_]\w*)\s*:\s*([A-Za-z_]\w*)/gim)) {
|
|
260
|
+
if (ABP_ADMIN_TYPE.test(m[2]))
|
|
261
|
+
cols.push(m[1].toLowerCase());
|
|
262
|
+
}
|
|
263
|
+
return cols;
|
|
264
|
+
}
|
|
265
|
+
/** F3 — admin fields read-only; a UUID key uses managed numbering. Expects comment-free sources. */
|
|
266
|
+
function checkAdminReadOnly(src, opts) {
|
|
267
|
+
const root = ddlsElements(src.rootDdls);
|
|
268
|
+
const problems = root.unreadable.map((l) => unreadable(l, opts.root));
|
|
269
|
+
const readonly = new Set();
|
|
270
|
+
for (const m of src.rootBdef.matchAll(/field\s*\(\s*readonly\s*\)\s*([^;]+);/gi)) {
|
|
271
|
+
for (const f of m[1].split(','))
|
|
272
|
+
readonly.add(f.trim().toLowerCase());
|
|
273
|
+
}
|
|
274
|
+
const names = new Set(root.elements.map((e) => e.name.toLowerCase()));
|
|
275
|
+
// Final review M7 — with the table source, the admin fields are also the
|
|
276
|
+
// columns typed abp_* (creation/lastchange user + timestamp, locinst_*),
|
|
277
|
+
// under whatever alias the root view gives them. The fixed names stay the fallback.
|
|
278
|
+
const admin = ADMIN_FIELDS.filter((f) => names.has(f.toLowerCase()));
|
|
279
|
+
for (const col of abpAdminColumns(src.table)) {
|
|
280
|
+
const el = root.elements.find((e) => e.source !== undefined && e.source.split('.').pop().toLowerCase() === col);
|
|
281
|
+
if (el && !admin.some((f) => f.toLowerCase() === el.name.toLowerCase()))
|
|
282
|
+
admin.push(el.name);
|
|
283
|
+
}
|
|
284
|
+
for (const f of admin) {
|
|
285
|
+
if (!readonly.has(f.toLowerCase()))
|
|
286
|
+
problems.push(`${f} can be edited; make it read-only`);
|
|
287
|
+
}
|
|
288
|
+
const uuidKey = src.table ? /\bkey\s+(\w+)\s*:\s*sysuuid_x16\b/i.exec(src.table) : null;
|
|
289
|
+
if (uuidKey && !/numbering\s*:\s*managed/i.test(src.rootBdef)) {
|
|
290
|
+
problems.push(`The key ${uuidKey[1]} is a UUID; add numbering : managed`);
|
|
291
|
+
}
|
|
292
|
+
return item('F3', problems);
|
|
293
|
+
}
|
|
294
|
+
/** F4 — every visible field has a real label; the app title is a business name. Expects comment-free sources. */
|
|
295
|
+
function checkLabels(src, opts) {
|
|
296
|
+
if (src.ddlx === undefined)
|
|
297
|
+
return item('F4', [`No metadata extension for ${opts.projection}`]);
|
|
298
|
+
const ddlx = src.ddlx;
|
|
299
|
+
const proj = ddlsElements(src.projectionDdls);
|
|
300
|
+
const root = ddlsElements(src.rootDdls).elements;
|
|
301
|
+
const projLines = src.projectionDdls.split(/\r?\n/);
|
|
302
|
+
const rootLines = src.rootDdls.split(/\r?\n/);
|
|
303
|
+
const problems = proj.unreadable.map((l) => unreadable(l, opts.projection));
|
|
304
|
+
let emptyAttributed = 0;
|
|
305
|
+
for (const el of proj.elements) {
|
|
306
|
+
const xBlock = annotationsFor(ddlx, el.name);
|
|
307
|
+
const pBlock = annotationsAt(projLines, el.line);
|
|
308
|
+
const rootEl = root.find((r) => eqi(r.name, el.source ?? el.name));
|
|
309
|
+
const rBlock = rootEl ? annotationsAt(rootLines, rootEl.line) : '';
|
|
310
|
+
const xLabels = fieldLabels(xBlock);
|
|
311
|
+
const empties = xLabels.filter((l) => l.trim() === '').length;
|
|
312
|
+
emptyAttributed += empties;
|
|
313
|
+
if (empties > 0)
|
|
314
|
+
problems.push(`${el.name} has an empty label`);
|
|
315
|
+
if (xLabels.some((l) => l.length > 8 && !/\s/.test(l) && eqi(l, el.name))) {
|
|
316
|
+
problems.push(`${el.name} label is just the field name`);
|
|
317
|
+
}
|
|
318
|
+
const hidden = /@UI\.hidden\s*:\s*true\b/i.test(`${xBlock}\n${pBlock}`);
|
|
319
|
+
const endUser = (b) => [...b.matchAll(/@EndUserText\.label\s*:\s*'([^']*)'/gi)].map((m) => m[1]);
|
|
320
|
+
const all = [...xLabels, ...endUser(pBlock), ...endUser(rBlock)];
|
|
321
|
+
if (!hidden && empties === 0 && !all.some((l) => l.trim() !== ''))
|
|
322
|
+
problems.push(`${el.name} has no label`);
|
|
323
|
+
}
|
|
324
|
+
const emptyTotal = [...ddlx.matchAll(/\blabel\s*:\s*''/gi)].length;
|
|
325
|
+
if (emptyTotal > emptyAttributed)
|
|
326
|
+
problems.push('A section of the metadata extension has an empty label');
|
|
327
|
+
const header = `${ddlx}\n${src.projectionDdls}`;
|
|
328
|
+
const typeName = /\btypeName\s*:\s*'([^']*)'/i.exec(header)?.[1]?.trim();
|
|
329
|
+
const plural = /\btypeNamePlural\s*:\s*'([^']*)'/i.exec(header)?.[1]?.trim();
|
|
330
|
+
if (!typeName)
|
|
331
|
+
problems.push('The app has no title (headerInfo typeName)');
|
|
332
|
+
else if (eqi(typeName, opts.entity))
|
|
333
|
+
problems.push(`The app title is ${typeName}; use the business name`);
|
|
334
|
+
if (!plural)
|
|
335
|
+
problems.push('The app has no plural title (headerInfo typeNamePlural)');
|
|
336
|
+
else if (eqi(plural, opts.entity) || eqi(plural, `${opts.entity}s`)) {
|
|
337
|
+
problems.push(`The plural app title is ${plural}; use the business name`);
|
|
338
|
+
}
|
|
339
|
+
return item('F4', problems);
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* F5 — coded fields show a value help and their text, not the raw code. The
|
|
343
|
+
* text element must be a field of the projection (a path starting with `_` is
|
|
344
|
+
* accepted), or the field uses @ObjectModel.text.association. Expects comment-free sources.
|
|
345
|
+
*/
|
|
346
|
+
function checkCodedFields(src, opts) {
|
|
347
|
+
const problems = [];
|
|
348
|
+
const elements = ddlsElements(src.projectionDdls).elements;
|
|
349
|
+
const projLines = src.projectionDdls.split(/\r?\n/);
|
|
350
|
+
for (const f of opts.codedFields ?? []) {
|
|
351
|
+
const el = elements.find((e) => eqi(e.name, f));
|
|
352
|
+
if (!el) {
|
|
353
|
+
problems.push(`${f} is not in ${opts.projection}`);
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
const ann = `${annotationsAt(projLines, el.line)}\n${src.ddlx ? annotationsFor(src.ddlx, el.name) : ''}`;
|
|
357
|
+
if (!/@Consumption\.valueHelpDefinition\b/i.test(ann))
|
|
358
|
+
problems.push(`${f} has no value help`);
|
|
359
|
+
if (!/@ObjectModel\.text\.association\b/i.test(ann)) {
|
|
360
|
+
const te = /@ObjectModel\.text\.element\s*:\s*\[\s*'([^']+)'/i.exec(ann)?.[1]?.trim();
|
|
361
|
+
if (!te)
|
|
362
|
+
problems.push(`${f} has no text`);
|
|
363
|
+
else if (!te.startsWith('_') && !elements.some((e) => eqi(e.name, te))) {
|
|
364
|
+
problems.push(`${f} shows text ${te}, which is not in ${opts.projection}`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if (!/#TEXT_ONLY\b/i.test(ann))
|
|
368
|
+
problems.push(`${f} does not show its text (#TEXT_ONLY)`);
|
|
369
|
+
}
|
|
370
|
+
return item('F5', problems);
|
|
371
|
+
}
|
|
372
|
+
/** F1–F5, always five items, in order. Comments are stripped first. */
|
|
373
|
+
export function checkFinish(src, opts) {
|
|
374
|
+
const clean = {
|
|
375
|
+
...src,
|
|
376
|
+
rootDdls: stripCdsComments(src.rootDdls),
|
|
377
|
+
projectionDdls: stripCdsComments(src.projectionDdls),
|
|
378
|
+
rootBdef: stripCdsComments(src.rootBdef),
|
|
379
|
+
...(src.ddlx !== undefined ? { ddlx: stripCdsComments(src.ddlx) } : {}),
|
|
380
|
+
...(src.table !== undefined ? { table: stripCdsComments(src.table) } : {}),
|
|
381
|
+
};
|
|
382
|
+
return [
|
|
383
|
+
checkAuth(clean, opts),
|
|
384
|
+
checkDcl(clean, opts),
|
|
385
|
+
checkAdminReadOnly(clean, opts),
|
|
386
|
+
checkLabels(clean, opts),
|
|
387
|
+
checkCodedFields(clean, opts),
|
|
388
|
+
];
|
|
389
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const camel = (base) => base.toLowerCase().split('_').filter(Boolean)
|
|
2
|
+
.map((p) => p[0].toUpperCase() + p.slice(1)).join('');
|
|
3
|
+
/**
|
|
4
|
+
* The names both skeleton paths build. `suffix` (final review I3, spec row 7):
|
|
5
|
+
* a digit appended to the base when a name is taken — every derived name
|
|
6
|
+
* carries it, and the cuts that keep the limits are made BEFORE it, so the
|
|
7
|
+
* digit is never cut off. The table name itself never changes.
|
|
8
|
+
*/
|
|
9
|
+
export function proposeNames(table, suffix = '') {
|
|
10
|
+
const t = table.trim().toUpperCase();
|
|
11
|
+
const plain = t.replace(/^[ZY]/, '').replace(/^_/, '');
|
|
12
|
+
const base = `${plain}${suffix}`;
|
|
13
|
+
// Draft table ≤16 chars; strip ALL trailing underscores so it never ends in '__D'.
|
|
14
|
+
const draft = `${t.slice(0, 14 - suffix.length).replace(/_+$/, '')}${suffix}_D`;
|
|
15
|
+
// Service names ≤26 (SRVB limit): shorten the service base only (spec §3: "the plan proposes shorter ones").
|
|
16
|
+
const svcBase = `ZUI_${base}_O4`.length <= 26 ? base : `${plain.slice(0, 19 - suffix.length).replace(/_+$/, '')}${suffix}`;
|
|
17
|
+
const svc = `ZUI_${svcBase}_O4`;
|
|
18
|
+
return {
|
|
19
|
+
table: t, base, entity: camel(base),
|
|
20
|
+
root: `ZR_${base}`, projection: `ZC_${base}`, behaviorPool: `ZBP_${base}`,
|
|
21
|
+
draftTable: draft, serviceDefinition: svc, serviceBinding: svc,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function namesFitLimits(n) {
|
|
25
|
+
return n.draftTable.length <= 16 && n.serviceBinding.length <= 26
|
|
26
|
+
&& [n.root, n.projection, n.behaviorPool, n.serviceDefinition].every((x) => x.length <= 30);
|
|
27
|
+
}
|
|
28
|
+
/** The 9 objects SAP's uiservice generator creates, in SAP's preview order. */
|
|
29
|
+
export function generatorPlannedObjects(n, pkg) {
|
|
30
|
+
const p = pkg.toUpperCase();
|
|
31
|
+
return [
|
|
32
|
+
{ type: 'DDLS', name: n.root, package: p },
|
|
33
|
+
{ type: 'BDEF', name: n.root, package: p },
|
|
34
|
+
{ type: 'CLAS', name: n.behaviorPool, package: p },
|
|
35
|
+
{ type: 'TABL', name: n.draftTable, package: p },
|
|
36
|
+
{ type: 'DDLS', name: n.projection, package: p },
|
|
37
|
+
{ type: 'BDEF', name: n.projection, package: p },
|
|
38
|
+
{ type: 'DDLX', name: n.projection, package: p },
|
|
39
|
+
{ type: 'SRVD', name: n.serviceDefinition, package: p },
|
|
40
|
+
{ type: 'SRVB', name: n.serviceBinding, package: p },
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
export function buildUiserviceContent(n, pkg, description) {
|
|
44
|
+
return {
|
|
45
|
+
metadata: { package: pkg.toUpperCase(), masterLanguage: 'EN' },
|
|
46
|
+
general: { referenceObjectName: n.table, description: description.slice(0, 60) },
|
|
47
|
+
businessObject: {
|
|
48
|
+
dataModelEntity: { cdsName: n.root, entityName: n.entity },
|
|
49
|
+
behavior: { implementationType: 'Managed', implementationClass: n.behaviorPool, draftTable: n.draftTable },
|
|
50
|
+
},
|
|
51
|
+
serviceProjection: { name: n.projection },
|
|
52
|
+
businessService: {
|
|
53
|
+
serviceDefinition: { name: n.serviceDefinition },
|
|
54
|
+
serviceBinding: { name: n.serviceBinding, bindingType: 'OData V4 - UI' },
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { cardLine } from '../lane/card.js';
|
|
2
|
+
import { OBJECT_TYPE_LABELS } from '../projects/plan-objects.js';
|
|
3
|
+
export const SKELETON_WHO = {
|
|
4
|
+
'sap-generator': "SAP's generator",
|
|
5
|
+
cspeach: 'CSPeach writer',
|
|
6
|
+
};
|
|
7
|
+
export function skeletonCardRequest(d, blockedReason, n, objects) {
|
|
8
|
+
const by = d.by ?? 'cspeach';
|
|
9
|
+
const facts = [
|
|
10
|
+
`Table: ${n.table}`,
|
|
11
|
+
`Why: ${d.reason}`,
|
|
12
|
+
...(by === 'sap-generator'
|
|
13
|
+
? objects.map((o) => `${o.name} · ${OBJECT_TYPE_LABELS[o.type] ?? o.type}`)
|
|
14
|
+
: [`App shape: ${n.root}, ${n.projection}, ${n.serviceBinding}`]),
|
|
15
|
+
'Either way, CSPeach then adds security, labels, value helps, logic and tests, and checks them.',
|
|
16
|
+
];
|
|
17
|
+
const choices = [{ value: 'confirm', label: `Build the skeleton with the ${SKELETON_WHO[by]}` }];
|
|
18
|
+
if (by === 'sap-generator')
|
|
19
|
+
choices.push({ value: 'switch:cspeach', label: 'Use the CSPeach writer instead' });
|
|
20
|
+
else if (!blockedReason)
|
|
21
|
+
choices.push({ value: 'switch:sap-generator', label: "Use SAP's generator instead" });
|
|
22
|
+
choices.push({ value: 'cancel', label: 'Cancel' });
|
|
23
|
+
return {
|
|
24
|
+
id: 'rap-skeleton',
|
|
25
|
+
tag: 'QUESTION',
|
|
26
|
+
title: 'How should the RAP skeleton be built?',
|
|
27
|
+
facts: facts.map(cardLine),
|
|
28
|
+
choices: choices.map((c) => ({ ...c, label: cardLine(c.label) })),
|
|
29
|
+
initial: 0,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const RAP_TIERS = new Set(['s4-2020-plus', 'unknown']);
|
|
2
|
+
export function generatorBlockers(req, f) {
|
|
3
|
+
// Row 3 variant (final review I1): no SAP at all — nothing to check, the CSPeach writer.
|
|
4
|
+
if (f.connected === false)
|
|
5
|
+
return { row: 3, reason: 'Not connected to SAP' };
|
|
6
|
+
if (f.generator === 'no')
|
|
7
|
+
return { row: 3, reason: 'This system has no RAP generator' };
|
|
8
|
+
if (f.generator === 'unknown')
|
|
9
|
+
return { row: 3, reason: "Couldn't confirm a RAP generator on this system" };
|
|
10
|
+
if (req.entities !== 1 || req.implementation !== 'managed' || !req.draft || req.binding !== 'odata-v4-ui') {
|
|
11
|
+
return { row: 4, reason: "SAP's generator supports only one table, managed, with draft, V4 UI" };
|
|
12
|
+
}
|
|
13
|
+
// Row 4 variant (final review M2): the generator writes to a transport; a $ package has none.
|
|
14
|
+
if (f.localPackage)
|
|
15
|
+
return { row: 4, reason: "SAP's generator needs a transport, and a local package has none" };
|
|
16
|
+
if (f.tableExists && f.tableHasTimestamps === false)
|
|
17
|
+
return { row: 5, reason: "This table lacks the fields SAP's generator needs" };
|
|
18
|
+
if (f.tableExists && f.uiserviceOffered === false)
|
|
19
|
+
return { row: 6, reason: "SAP's generator isn't offered for this table" };
|
|
20
|
+
if (!f.namesFit)
|
|
21
|
+
return { row: 7, reason: "These names are too long for SAP's generator" };
|
|
22
|
+
if (f.tableExists && f.validation && !f.validation.ok) {
|
|
23
|
+
return { row: 7, reason: `SAP's generator check failed: ${f.validation.text || 'no reason given'}` };
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
export function decideSkeleton(req, f, override) {
|
|
28
|
+
if (!RAP_TIERS.has(f.tier))
|
|
29
|
+
return { by: null, row: 2, reason: 'RAP needs S/4HANA 2020 or later', overridden: false };
|
|
30
|
+
const blocked = generatorBlockers(req, f);
|
|
31
|
+
if (override === 'cspeach')
|
|
32
|
+
return { by: 'cspeach', row: 1, reason: 'Chosen by you', overridden: true };
|
|
33
|
+
if (override === 'sap-generator' && !blocked)
|
|
34
|
+
return { by: 'sap-generator', row: 1, reason: 'Chosen by you', overridden: true };
|
|
35
|
+
if (blocked)
|
|
36
|
+
return { by: 'cspeach', row: blocked.row, reason: blocked.reason, overridden: false };
|
|
37
|
+
return { by: 'sap-generator', row: 8, reason: "Fits SAP's generator: built in seconds, then finished by CSPeach", overridden: false };
|
|
38
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// cspeach-cli/src/rap/skeleton-session.ts
|
|
2
|
+
//
|
|
3
|
+
// RAP generator plan, Task 8 fix round 1 — the session's latest skeleton
|
|
4
|
+
// decision. rap_skeleton_resolve records the {path, sha256} it just wrote; the
|
|
5
|
+
// plan save then accepts only that decision, so a plan cannot cite an older
|
|
6
|
+
// sha-valid file (e.g. one from before an owner override). Keyed on the session
|
|
7
|
+
// object in a WeakMap, like the app flow (lane/app-flow.ts): two sessions never
|
|
8
|
+
// see each other's decision and nothing outlives its session.
|
|
9
|
+
const latest = new WeakMap();
|
|
10
|
+
/** Record the decision rap_skeleton_resolve just wrote (replaces any earlier one). */
|
|
11
|
+
export function setLatestSkeletonDecision(session, ref) {
|
|
12
|
+
latest.set(session, { path: ref.path, sha256: ref.sha256 });
|
|
13
|
+
}
|
|
14
|
+
/** A frozen copy of the session's latest decision, or undefined when none was made. */
|
|
15
|
+
export function getLatestSkeletonDecision(session) {
|
|
16
|
+
const r = latest.get(session);
|
|
17
|
+
return r ? Object.freeze({ ...r }) : undefined;
|
|
18
|
+
}
|
|
19
|
+
export function clearLatestSkeletonDecision(session) {
|
|
20
|
+
latest.delete(session);
|
|
21
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// cspeach-cli/src/rap/skeleton-store.ts
|
|
2
|
+
//
|
|
3
|
+
// RAP generator plan, Task 6 — the skeleton decision file, written only by
|
|
4
|
+
// rap_skeleton_resolve, read by the plan-save check and sap_generate_objects.
|
|
5
|
+
// sha-pinned like the lane decision (lane/store.ts): a hand edit is refused.
|
|
6
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
7
|
+
import { promises as fs } from 'node:fs';
|
|
8
|
+
import * as path from 'node:path';
|
|
9
|
+
const DIR = '.cspeach/lane';
|
|
10
|
+
const sha = (s) => createHash('sha256').update(s).digest('hex');
|
|
11
|
+
export async function writeSkeletonDecision(laneRoot, rec) {
|
|
12
|
+
const full = { kind: 'rap-skeleton', version: 1, ...rec, createdAt: new Date().toISOString() };
|
|
13
|
+
const body = `${JSON.stringify(full, null, 2)}\n`;
|
|
14
|
+
const day = full.createdAt.slice(0, 10).replace(/-/g, '');
|
|
15
|
+
const rel = `${DIR}/skeleton-${day}-${randomBytes(4).toString('hex')}.json`;
|
|
16
|
+
await fs.mkdir(path.join(laneRoot, DIR), { recursive: true });
|
|
17
|
+
await fs.writeFile(path.join(laneRoot, rel), body, { encoding: 'utf8', flag: 'wx' });
|
|
18
|
+
return { path: rel, sha256: sha(body) };
|
|
19
|
+
}
|
|
20
|
+
export async function readSkeletonDecision(laneRoot, relPath, sha256) {
|
|
21
|
+
const norm = String(relPath ?? '').replace(/\\/g, '/');
|
|
22
|
+
if (!/^\.cspeach\/lane\/skeleton-\d{8}-[0-9a-f]{8}\.json$/.test(norm)) {
|
|
23
|
+
return { ok: false, message: 'The skeleton decision path is not a CSPeach decision file. Run /abap-plan again.' };
|
|
24
|
+
}
|
|
25
|
+
let body;
|
|
26
|
+
try {
|
|
27
|
+
body = await fs.readFile(path.join(laneRoot, norm), 'utf8');
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return { ok: false, message: 'The skeleton decision file is missing. Run /abap-plan again.' };
|
|
31
|
+
}
|
|
32
|
+
if (sha(body) !== sha256) {
|
|
33
|
+
return { ok: false, message: 'The skeleton decision file changed after it was saved. Run /abap-plan again.' };
|
|
34
|
+
}
|
|
35
|
+
const rec = JSON.parse(body);
|
|
36
|
+
if (rec.kind !== 'rap-skeleton' || rec.version !== 1) {
|
|
37
|
+
return { ok: false, message: 'The skeleton decision file is not readable by this CSPeach version. Run /abap-plan again.' };
|
|
38
|
+
}
|
|
39
|
+
return { ok: true, rec };
|
|
40
|
+
}
|
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
import { diffLines } from 'diff';
|
|
7
7
|
import { paint } from './theme.js';
|
|
8
8
|
import { getBoxChars } from '../ui/ascii-chars.js';
|
|
9
|
+
import { ERROR_DETAIL_ROWS, SNAPSHOT_DETAIL_ROWS } from './verify-chain.js';
|
|
9
10
|
import { ANSI_RE } from './tool-labels.js';
|
|
11
|
+
import { wrapRows } from './wrap-rows.js';
|
|
10
12
|
export const MIN_CARD_WIDTH = 40;
|
|
11
13
|
const DIFF_LINES = 6;
|
|
12
|
-
const DETAIL_MAX = 60;
|
|
13
14
|
/** Myers diff is O(N·D) and synchronous: past this budget we show no diff
|
|
14
15
|
* at all (object + footer only), never a partial one. */
|
|
15
16
|
const DIFF_TIMEOUT_MS = 200;
|
|
@@ -33,7 +34,7 @@ function footerParts(input) {
|
|
|
33
34
|
else if (input.snapshot === 'new_object')
|
|
34
35
|
parts.push(paint('dim', '– snapshot (new object)'));
|
|
35
36
|
else if (input.snapshot === 'source_read_failed')
|
|
36
|
-
parts.push(paint('dim',
|
|
37
|
+
parts.push(paint('dim', '– snapshot (no source read)'));
|
|
37
38
|
else
|
|
38
39
|
parts.push(paint('dim', '– snapshot (skipped)'));
|
|
39
40
|
parts.push(input.written ? `${paint('ok', '✓')} written` : `${paint('bad', '✗')} written`);
|
|
@@ -54,15 +55,35 @@ function footerParts(input) {
|
|
|
54
55
|
else if (a.state === 'unverified')
|
|
55
56
|
parts.push(paint('warn', '· activation unverified'));
|
|
56
57
|
else
|
|
57
|
-
parts.push(`${paint('bad', '✗')} activation
|
|
58
|
+
parts.push(`${paint('bad', '✗')} activation`);
|
|
58
59
|
}
|
|
59
60
|
else {
|
|
60
61
|
// Activation is blocked — say so; a pending step would lie.
|
|
61
|
-
parts.push(`${paint('bad', '✗')} syntax
|
|
62
|
+
parts.push(`${paint('bad', '✗')} syntax`);
|
|
62
63
|
parts.push(paint('dim', '· activation blocked'));
|
|
63
64
|
}
|
|
64
65
|
return parts;
|
|
65
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* Fix batch 2026-10-12 (Task 2): the reasons behind a failed step — the
|
|
69
|
+
* snapshot read failure, the first syntax error, SAP's activation refusal —
|
|
70
|
+
* are what the user needs, so they are rows of their own (wrapped at word
|
|
71
|
+
* breaks to `cells`, up to the verify-chain caps), never cut on the footer.
|
|
72
|
+
*/
|
|
73
|
+
function detailLines(input, cells) {
|
|
74
|
+
const out = [];
|
|
75
|
+
const wrap = (text, rows) => wrapRows(flat(clean(text)), Math.max(1, cells), rows);
|
|
76
|
+
if (input.snapshot === 'source_read_failed' && input.snapshotDetail) {
|
|
77
|
+
out.push(...wrap(`No source read: ${input.snapshotDetail}`, SNAPSHOT_DETAIL_ROWS).map((r) => paint('dim', r)));
|
|
78
|
+
}
|
|
79
|
+
if (input.syntax !== 'not_checked' && !input.syntax.ok) {
|
|
80
|
+
out.push(...wrap(input.syntax.errors[0] ?? 'syntax errors', ERROR_DETAIL_ROWS).map((r) => paint('bad', r)));
|
|
81
|
+
}
|
|
82
|
+
else if (input.syntax !== 'not_checked' && input.activation?.state === 'failed' && input.activation.detail) {
|
|
83
|
+
out.push(...wrap(input.activation.detail, ERROR_DETAIL_ROWS).map((r) => paint('bad', r)));
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
66
87
|
/** Changed lines for the card. Exported for tests only. */
|
|
67
88
|
export function diffBody(card, differ = defaultDiffer) {
|
|
68
89
|
if (card.oldSource === undefined || card.newSource === undefined)
|
|
@@ -99,6 +120,7 @@ export function buildChangeCard(input, width, box = getBoxChars()) {
|
|
|
99
120
|
// cut to the width so nothing wraps (colour dropped only on a cut part).
|
|
100
121
|
for (const p of parts)
|
|
101
122
|
out.push(visibleLen(p) > width ? cut(p.replace(ANSI, ''), width) : p);
|
|
123
|
+
out.push(...detailLines(input, width));
|
|
102
124
|
if (card.transport)
|
|
103
125
|
out.push(paint('dim', cut(`· ${clean(card.transport)}`, width)));
|
|
104
126
|
return out.join('\n');
|
|
@@ -117,6 +139,8 @@ export function buildChangeCard(input, width, box = getBoxChars()) {
|
|
|
117
139
|
lines.push(row(paint(l.startsWith('+') ? 'ok' : 'bad', cut(l, inner))));
|
|
118
140
|
if (body.hidden > 0)
|
|
119
141
|
lines.push(row(paint('dim', ` … +${body.hidden} lines`)));
|
|
142
|
+
for (const d of detailLines(input, inner))
|
|
143
|
+
lines.push(row(d));
|
|
120
144
|
lines.push(...footerLines(parts, chain, transportText, width, b, row));
|
|
121
145
|
return lines.join('\n');
|
|
122
146
|
}
|