@cspeach/cli 1.1.23 → 1.1.24

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.
@@ -0,0 +1,459 @@
1
+ /**
2
+ * App-lane inputs — everything the resolver needs that comes from outside it:
3
+ *
4
+ * - system facts, probed (CVERS components) or declared (standalone profile);
5
+ * - spec text (md/txt/docx/pdf/xlsx) and the evidence-quote check;
6
+ * - the `[app-lane]` project pin in `.cspeach/conventions.toml`;
7
+ * - the `## App lanes` toml block in the RAW standards file;
8
+ * - the runtime-answer writer (the only writer of `[app-lane]`).
9
+ *
10
+ * Security: the spec path is contained (string + realpath), size-capped and
11
+ * denylisted; TOML is parsed, never evaluated, and only whitelisted values
12
+ * become a pin; errors never echo file contents; the runtime writer is atomic,
13
+ * touches only `[app-lane]`, and refuses symlinked targets.
14
+ */
15
+ import { promises as fs } from 'node:fs';
16
+ import path from 'node:path';
17
+ import { createHash, randomBytes } from 'node:crypto';
18
+ import { isDeepStrictEqual } from 'node:util';
19
+ import toml from '@iarna/toml';
20
+ import { resolveStandardsFile } from '../standards/standards-file.js';
21
+ import { extractDocument } from '../tools/filesystem/extract-document.js';
22
+ import { PathOutsideRootError } from '../tools/_filesystem-shared.js';
23
+ import { findNormalised } from './signals.js';
24
+ import { containSpecPath, SpecProtectedError } from './roots.js';
25
+ // ── facts ──────────────────────────────────────────────────────────────────
26
+ const UNKNOWN_FACTS = { source: 'unknown', tier: 'unknown', label: 'unknown' };
27
+ function rel(components, key) {
28
+ const v = components[key];
29
+ if (v === undefined || v === '')
30
+ return undefined;
31
+ const n = Number.parseInt(v, 10);
32
+ return Number.isFinite(n) ? n : undefined;
33
+ }
34
+ /** Tier from CVERS components (probed). The label is the system-info release. */
35
+ export function factsFromSystemInfo(info) {
36
+ if (!info)
37
+ return { ...UNKNOWN_FACTS };
38
+ const c = info.components ?? {};
39
+ const s4Raw = c['S4CORE'];
40
+ const appl = rel(c, 'SAP_APPL');
41
+ const basis = rel(c, 'SAP_BASIS');
42
+ let tier;
43
+ // S4CORE present means S/4, whatever the value; only a clean number ≥ 105 is 2020+.
44
+ if (typeof s4Raw === 'string' && s4Raw.trim() !== '') {
45
+ tier = /^\d+$/.test(s4Raw.trim()) && Number(s4Raw.trim()) >= 105 ? 's4-2020-plus' : 's4-before-2020';
46
+ }
47
+ else if (appl !== undefined)
48
+ tier = appl >= 617 ? 'ecc-ehp7-plus' : 'ecc-old';
49
+ else if (basis !== undefined)
50
+ tier = basis >= 740 ? 'netweaver-740-plus' : 'netweaver-old';
51
+ else
52
+ tier = 'unknown';
53
+ return { source: tier === 'unknown' ? 'unknown' : 'probed', tier, label: info.release };
54
+ }
55
+ /** Tier from the standalone profile (declared). Label `<platform> <release>`. */
56
+ export function factsFromStandalone(p) {
57
+ if (!p)
58
+ return { ...UNKNOWN_FACTS };
59
+ const release = p.release ?? '';
60
+ let tier;
61
+ switch (p.platform) {
62
+ case 'S/4HANA':
63
+ case 'S/4HANA Cloud Private': {
64
+ const m = /(?<!\d)((?:19|20)\d{2})(?!\d)/.exec(release);
65
+ tier = !m ? 'unknown' : Number(m[1]) >= 2020 ? 's4-2020-plus' : 's4-before-2020';
66
+ break;
67
+ }
68
+ case 'ECC':
69
+ tier = /EHP\s*[78]\b/i.test(release) ? 'ecc-ehp7-plus' : 'ecc-old';
70
+ break;
71
+ default:
72
+ // S/4HANA Cloud Public, BTP ABAP Environment: outside slice 1 — unverified.
73
+ tier = 'unknown';
74
+ }
75
+ const r = release.trim();
76
+ const label = r.toLowerCase().startsWith(p.platform.toLowerCase()) ? r : `${p.platform} ${r}`.trim();
77
+ return { source: 'declared', tier, label };
78
+ }
79
+ // ── quotes ─────────────────────────────────────────────────────────────────
80
+ /** Evidence-quote check. Delegates to the one canonical matcher. */
81
+ export function findQuote(text, quote) {
82
+ return findNormalised(text, quote);
83
+ }
84
+ // ── spec text ──────────────────────────────────────────────────────────────
85
+ const TEXT_MAX_BYTES = 2 * 1024 * 1024;
86
+ const DOC_MAX_BYTES = 50 * 1024 * 1024;
87
+ const DOC_MAX_PAGES = 150;
88
+ const DOC_EXTS = new Set(['.pdf', '.docx', '.xlsx']);
89
+ /**
90
+ * docx → turndown markdown: drop heading markers, drop unescaped `*` / `_`
91
+ * emphasis markers, and unescape turndown's backslash escapes (so
92
+ * `ZI\_SALES` becomes `ZI_SALES` again — literal underscores survive).
93
+ */
94
+ function stripDocxMarkdown(md) {
95
+ return md
96
+ .replace(/^[ \t]*#{1,6}[ \t]+/gm, '')
97
+ .replace(/\\([\\`*_{}[\]()#+\-.!>~|=])|[*_]/g, (_m, esc) => esc ?? '');
98
+ }
99
+ /** pdf: drop the `[page N]` marker lines so a quote can span a page break. */
100
+ function stripPdfMarkers(t) {
101
+ return t.replace(/^\[page \d+\]$/gm, '');
102
+ }
103
+ /** xlsx: drop the `## Sheet:` heading markers only — cell text is literal. */
104
+ function stripXlsxMarkers(t) {
105
+ return t.replace(/^#{1,6}[ \t]+/gm, '');
106
+ }
107
+ /**
108
+ * I2 (final review) — the extension-aware strip that turned the extracted
109
+ * document into the spec text. Every string matched against that text (each
110
+ * evidence quote, the `ui_only_service` value) goes through the SAME function,
111
+ * so a quote copied from the read_document output (`ZI\_SALES`, `**bold**`)
112
+ * matches like a quote from a plain-text spec. Identity for .md / .txt.
113
+ */
114
+ export function specNormaliser(ext) {
115
+ switch (ext.toLowerCase()) {
116
+ case '.docx': return stripDocxMarkdown;
117
+ case '.pdf': return stripPdfMarkers;
118
+ case '.xlsx': return stripXlsxMarkers;
119
+ default: return (s) => s;
120
+ }
121
+ }
122
+ /**
123
+ * Read the spec as plain text for the evidence-quote check. The path must
124
+ * stay inside `cwd` both as a string and after realpath (no symlink/junction
125
+ * escape). Errors name the path only — never the file contents.
126
+ */
127
+ export async function readSpecText(cwd, specPath) {
128
+ // I3 — contained to cwd OR the workspace folder, as a string AND after
129
+ // realpath (a cwd reached through a junction still works; a link inside a
130
+ // root cannot escape), and refused when any root would denylist it.
131
+ let abs;
132
+ let real;
133
+ try {
134
+ ({ abs, real } = await containSpecPath(cwd, specPath));
135
+ }
136
+ catch (e) {
137
+ if (e instanceof PathOutsideRootError)
138
+ throw e;
139
+ if (e instanceof SpecProtectedError)
140
+ throw new Error(e.message);
141
+ if (e.code === 'ENOENT')
142
+ throw new Error(`spec file not found: ${specPath}`);
143
+ throw new Error(`spec file cannot be read: ${specPath}`);
144
+ }
145
+ const stat = await fs.stat(real);
146
+ if (!stat.isFile())
147
+ throw new Error(`spec path is not a file: ${specPath}`);
148
+ const ext = path.extname(real).toLowerCase();
149
+ if (DOC_EXTS.has(ext)) {
150
+ if (stat.size > DOC_MAX_BYTES)
151
+ throw new Error(`spec file is larger than 50 MB: ${specPath}`);
152
+ const buf = await fs.readFile(real);
153
+ let extracted;
154
+ try {
155
+ extracted = (await extractDocument(buf, ext, { maxPages: DOC_MAX_PAGES })).text;
156
+ }
157
+ catch (e) {
158
+ // Extractor messages are about structure (page counts, format), not content.
159
+ const why = e instanceof Error ? e.message.split('\n')[0].slice(0, 200) : 'unreadable document';
160
+ throw new Error(`spec file could not be read as ${ext}: ${specPath} (${why})`);
161
+ }
162
+ const normalise = specNormaliser(ext);
163
+ return { abs, text: normalise(extracted), sha256: sha256Hex(buf), normalise };
164
+ }
165
+ if (stat.size > TEXT_MAX_BYTES)
166
+ throw new Error(`spec file is larger than 2 MB: ${specPath}`);
167
+ const bytes = await fs.readFile(real);
168
+ return { abs, text: bytes.toString('utf8'), sha256: sha256Hex(bytes), normalise: specNormaliser(ext) };
169
+ }
170
+ /** sha256 of the raw spec bytes (recorded in the lane decision). */
171
+ function sha256Hex(bytes) {
172
+ return createHash('sha256').update(bytes).digest('hex');
173
+ }
174
+ // ── [app-lane] parsing (shared by pin + standards) ─────────────────────────
175
+ const BACKENDS = ['rap', 'cap', 'none', 'abap-report', 'segw'];
176
+ const UIS = ['fiori-elements', 'freestyle', 'none'];
177
+ const RUNTIMES = ['btp', 'own-server', 'none'];
178
+ // A Map, not an object literal: keys come from the file (`constructor`, `__proto__`).
179
+ const ALLOWED = new Map([['backend', BACKENDS], ['ui', UIS], ['runtime', RUNTIMES]]);
180
+ const CONVENTIONS_REL = '.cspeach/conventions.toml';
181
+ const CONVENTIONS_MAX_BYTES = 1024 * 1024;
182
+ const HEADER_RE = /^\s*\[\s*(?:app-lane|"app-lane"|'app-lane')\s*\]\s*(?:#.*)?$/;
183
+ const ANY_HEADER_RE = /^\s*\[/;
184
+ /** 1-based line of `key =` inside the `[app-lane]` table, else the header line. */
185
+ function keyLine(lines, key) {
186
+ const start = lines.findIndex((l) => HEADER_RE.test(l));
187
+ if (start < 0)
188
+ return undefined;
189
+ const k = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // key may come from the file
190
+ const keyRe = new RegExp(`^\\s*(?:${k}|"${k}"|'${k}')\\s*=`);
191
+ for (let i = start + 1; i < lines.length && !ANY_HEADER_RE.test(lines[i]); i++) {
192
+ if (keyRe.test(lines[i]))
193
+ return i + 1;
194
+ }
195
+ return start + 1;
196
+ }
197
+ /**
198
+ * Parse TOML text (never evaluated) and validate its `[app-lane]` table.
199
+ * `lineOffset` maps block-relative lines to file lines (standards block).
200
+ */
201
+ function parseAppLane(text, lineOffset = 0) {
202
+ const at = (n) => (n === undefined ? '' : `line ${n + lineOffset}: `);
203
+ let doc;
204
+ try {
205
+ doc = toml.parse(text.replace(/^\uFEFF/, ''));
206
+ }
207
+ catch (e) {
208
+ const err = e;
209
+ // @iarna/toml appends " at row …" plus a source excerpt — keep only the reason.
210
+ const reason = String(err.message ?? 'parse error').split('\n')[0].split(' at row ')[0].slice(0, 160);
211
+ const line = typeof err.line === 'number' ? err.line + 1 : undefined;
212
+ return { error: `${at(line)}TOML parse error: ${reason}` };
213
+ }
214
+ if (!Object.prototype.hasOwnProperty.call(doc, 'app-lane'))
215
+ return {};
216
+ const lines = text.replace(/^\uFEFF/, '').split(/\r?\n/);
217
+ const table = doc['app-lane'];
218
+ if (table === null || typeof table !== 'object' || Array.isArray(table)) {
219
+ return { error: `${at(keyLine(lines, 'app-lane'))}app-lane must be a table` };
220
+ }
221
+ const pin = {};
222
+ for (const [key, value] of Object.entries(table)) {
223
+ const allowed = ALLOWED.get(key);
224
+ if (!allowed) {
225
+ return { error: `${at(keyLine(lines, key))}unknown key ${JSON.stringify(key.slice(0, 40))} (allowed: backend, ui, runtime)` };
226
+ }
227
+ if (typeof value !== 'string' || !allowed.includes(value)) {
228
+ return { error: `${at(keyLine(lines, key))}${key} must be one of ${allowed.join(', ')}` };
229
+ }
230
+ pin[key] = value;
231
+ }
232
+ return { pin: pin };
233
+ }
234
+ function toResult(o, file, source) {
235
+ if (o.error)
236
+ return { warning: `${file}: [app-lane] ignored — ${o.error}` };
237
+ if (!o.pin || Object.keys(o.pin).length === 0)
238
+ return {};
239
+ return { pin: { ...o.pin, source } };
240
+ }
241
+ /** The project pin: `[app-lane]` in `<cwd>/.cspeach/conventions.toml`. */
242
+ export async function readLanePin(cwd) {
243
+ const file = path.join(cwd, '.cspeach', 'conventions.toml');
244
+ let text;
245
+ try {
246
+ const st = await fs.stat(file);
247
+ if (st.size > CONVENTIONS_MAX_BYTES) {
248
+ return { warning: `${CONVENTIONS_REL}: [app-lane] ignored — file is larger than 1 MB` };
249
+ }
250
+ text = await fs.readFile(file, 'utf8');
251
+ }
252
+ catch (e) {
253
+ if (e.code === 'ENOENT')
254
+ return {};
255
+ return { warning: `${CONVENTIONS_REL}: [app-lane] ignored — file cannot be read` };
256
+ }
257
+ return toResult(parseAppLane(text), CONVENTIONS_REL, CONVENTIONS_REL);
258
+ }
259
+ /**
260
+ * The standards-file lane block: the first ```toml fence after a
261
+ * `## App lanes` heading in the RAW standards file (not the 8000-char render).
262
+ */
263
+ export async function readStandardsLane(cwd) {
264
+ const file = await resolveStandardsFile(cwd);
265
+ if (!file)
266
+ return {};
267
+ const lines = file.content.split(/\r?\n/);
268
+ const found = findLaneBlock(lines);
269
+ if (!found)
270
+ return {};
271
+ const { open, close } = found;
272
+ if (close < 0) {
273
+ return { warning: `${file.path}: [app-lane] ignored — line ${open + 1}: toml block is not closed` };
274
+ }
275
+ const block = lines.slice(open + 1, close).join('\n');
276
+ // resolveStandardsFile trims the content; recover leading lines so the
277
+ // reported line matches the file on disk.
278
+ const lead = await leadingLines(file.path);
279
+ return toResult(parseAppLane(block, open + 1 + lead), file.path, file.path);
280
+ }
281
+ const FENCE_OPEN_RE = /^[ \t]*(`{3,}|~{3,})[ \t]*([^`]*?)[ \t]*$/;
282
+ const LANE_HEADING_RE = /^ {0,3}##[ \t]+App lanes[ \t]*#*[ \t]*$/i;
283
+ /** ATX heading of level 1 or 2 — ends the `## App lanes` section. */
284
+ const SECTION_END_RE = /^ {0,3}#{1,2}(?:[ \t]|$)/;
285
+ /**
286
+ * Locate the first toml fence inside the `## App lanes` section. Headings
287
+ * and fences are only recognised OUTSIDE fenced code (```, ````, ~~~).
288
+ * Returns the opening/closing line indexes (close = -1 when unclosed), or
289
+ * null when there is no such section or block.
290
+ */
291
+ function findLaneBlock(lines) {
292
+ let fence = null;
293
+ let inSection = false;
294
+ for (let i = 0; i < lines.length; i++) {
295
+ const l = lines[i];
296
+ if (fence) {
297
+ const m = /^[ \t]*(`{3,}|~{3,})[ \t]*$/.exec(l);
298
+ if (m && m[1][0] === fence.ch && m[1].length >= fence.len)
299
+ fence = null;
300
+ continue;
301
+ }
302
+ const open = FENCE_OPEN_RE.exec(l);
303
+ if (open) {
304
+ const marker = open[1];
305
+ const info = (open[2] ?? '').split(/[ \t]/)[0].toLowerCase();
306
+ if (inSection && info === 'toml') {
307
+ for (let j = i + 1; j < lines.length; j++) {
308
+ const c = /^[ \t]*(`{3,}|~{3,})[ \t]*$/.exec(lines[j]);
309
+ if (c && c[1][0] === marker[0] && c[1].length >= marker.length)
310
+ return { open: i, close: j };
311
+ }
312
+ return { open: i, close: -1 };
313
+ }
314
+ fence = { ch: marker[0], len: marker.length };
315
+ continue;
316
+ }
317
+ if (!inSection) {
318
+ if (LANE_HEADING_RE.test(l))
319
+ inSection = true;
320
+ }
321
+ else if (SECTION_END_RE.test(l)) {
322
+ return null;
323
+ }
324
+ }
325
+ return null;
326
+ }
327
+ async function leadingLines(p) {
328
+ try {
329
+ const raw = await fs.readFile(p, 'utf8');
330
+ const lead = raw.slice(0, raw.length - raw.trimStart().length);
331
+ return (lead.match(/\n/g) ?? []).length;
332
+ }
333
+ catch {
334
+ return 0;
335
+ }
336
+ }
337
+ // ── runtime answer writer ──────────────────────────────────────────────────
338
+ async function isSymlink(p) {
339
+ try {
340
+ return (await fs.lstat(p)).isSymbolicLink();
341
+ }
342
+ catch (e) {
343
+ if (e.code === 'ENOENT')
344
+ return false;
345
+ throw e;
346
+ }
347
+ }
348
+ /**
349
+ * Record the developer's runtime answer in `[app-lane]` of
350
+ * `.cspeach/conventions.toml`. Only the `runtime` line of that table is
351
+ * touched (or the table is appended); every other byte stays as it was.
352
+ * Written atomically (temp file in the same folder, then rename).
353
+ */
354
+ export async function writeRuntimeAnswer(cwd, runtime) {
355
+ if (!RUNTIMES.includes(runtime)) {
356
+ throw new Error(`runtime must be one of ${RUNTIMES.join(', ')}`);
357
+ }
358
+ const dir = path.join(cwd, '.cspeach');
359
+ const file = path.join(dir, 'conventions.toml');
360
+ if (await isSymlink(dir))
361
+ throw new Error(`refusing to write: ${dir} is a symlink or junction`);
362
+ await fs.mkdir(dir, { recursive: true });
363
+ if (await isSymlink(file))
364
+ throw new Error(`refusing to write: ${CONVENTIONS_REL} is a symlink`);
365
+ // latin1 is a lossless byte↔char round-trip: every byte we do not edit is
366
+ // written back unchanged, even when the file is not valid UTF-8. The edit
367
+ // itself only matches and inserts ASCII.
368
+ let before = '';
369
+ try {
370
+ before = await fs.readFile(file, 'latin1');
371
+ }
372
+ catch (e) {
373
+ if (e.code !== 'ENOENT')
374
+ throw e;
375
+ }
376
+ const line = `runtime = "${runtime}"`;
377
+ const after = setRuntimeLine(before, line);
378
+ if (before.replace(/^\u00EF\u00BB\u00BF/, '').trim() !== '' && !parseBytes(before)) {
379
+ // Same reason + line the readLanePin warning shows; never file contents.
380
+ const why = parseAppLane(Buffer.from(before, 'latin1').toString('utf8')).error ?? 'TOML parse error';
381
+ throw new Error(`Could not save the runtime answer: ${CONVENTIONS_REL} has a TOML error — ${why}. ` +
382
+ `Fix that first, then add \`[app-lane] runtime = "${runtime}"\` by hand`);
383
+ }
384
+ if (!editIsSafe(before, after, runtime)) {
385
+ throw new Error(`Could not save the runtime answer to ${CONVENTIONS_REL} automatically — add \`[app-lane] runtime = "${runtime}"\` by hand`);
386
+ }
387
+ const tmp = path.join(dir, `.conventions.toml.${process.pid}.${randomBytes(6).toString('hex')}.tmp`);
388
+ try {
389
+ await fs.writeFile(tmp, after, { encoding: 'latin1', flag: 'wx' });
390
+ await fs.rename(tmp, file);
391
+ }
392
+ catch (e) {
393
+ await fs.rm(tmp, { force: true }).catch(() => undefined);
394
+ throw e;
395
+ }
396
+ }
397
+ /** Parse latin1-held bytes as UTF-8 TOML; null when it does not parse. */
398
+ function parseBytes(latin1Text) {
399
+ try {
400
+ const utf8 = Buffer.from(latin1Text, 'latin1').toString('utf8').replace(/^\uFEFF/, '');
401
+ return toml.parse(utf8);
402
+ }
403
+ catch {
404
+ return null;
405
+ }
406
+ }
407
+ /**
408
+ * The text edit is line-based, so prove it did what we meant: the result
409
+ * parses, `[app-lane]` is a valid table whose runtime is the answer, and —
410
+ * when the original parsed — every other top-level key is deep-equal.
411
+ */
412
+ function editIsSafe(before, after, runtime) {
413
+ const docAfter = parseBytes(after);
414
+ if (!docAfter)
415
+ return false;
416
+ const lane = docAfter['app-lane'];
417
+ if (lane === null || typeof lane !== 'object' || Array.isArray(lane))
418
+ return false;
419
+ if (lane.runtime !== runtime)
420
+ return false;
421
+ const check = parseAppLane(Buffer.from(after, 'latin1').toString('utf8'));
422
+ if (check.error || check.pin?.runtime !== runtime)
423
+ return false;
424
+ const docBefore = parseBytes(before);
425
+ if (docBefore) {
426
+ const keys = new Set([...Object.keys(docBefore), ...Object.keys(docAfter)]);
427
+ keys.delete('app-lane');
428
+ for (const k of keys) {
429
+ if (!isDeepStrictEqual(docBefore[k], docAfter[k]))
430
+ return false;
431
+ }
432
+ }
433
+ return true;
434
+ }
435
+ /** Pure text edit: replace/insert the runtime line inside `[app-lane]`, or append the table. */
436
+ function setRuntimeLine(text, line) {
437
+ // Split keeping each line's own terminator so untouched lines are byte-identical.
438
+ const parts = text.match(/[^\n]*\n|[^\n]+$/g) ?? [];
439
+ const body = (p) => p.replace(/\r?\n$/, '');
440
+ // Line 1 may carry a UTF-8 BOM, which the latin1 view shows as EF BB BF chars.
441
+ const start = parts.findIndex((p, i) => HEADER_RE.test(i === 0 ? body(p).replace(/^\u00EF\u00BB\u00BF/, '') : body(p)));
442
+ if (start < 0) {
443
+ return `${text}\n[app-lane]\n${line}\n`;
444
+ }
445
+ const runtimeRe = /^\s*(?:runtime|"runtime"|'runtime')\s*=/;
446
+ for (let i = start + 1; i < parts.length && !ANY_HEADER_RE.test(body(parts[i])); i++) {
447
+ if (runtimeRe.test(body(parts[i]))) {
448
+ const eol = parts[i].slice(body(parts[i]).length) || '\n';
449
+ parts[i] = line + eol;
450
+ return parts.join('');
451
+ }
452
+ }
453
+ const header = parts[start];
454
+ const headerEol = header.slice(body(header).length);
455
+ if (headerEol === '')
456
+ parts[start] = header + '\n';
457
+ parts.splice(start + 1, 0, `${line}${headerEol || '\n'}`);
458
+ return parts.join('');
459
+ }
@@ -0,0 +1,21 @@
1
+ // Leaf module: the lane decision file's path rule, shared by the store (write
2
+ // and read) and the plan schema (`from.lane.path`), so the two cannot drift.
3
+ // Imports nothing but node built-ins.
4
+ import { posix } from 'node:path';
5
+ export const LANE_DIR = '.cspeach/lane';
6
+ /** A file directly in `.cspeach/lane/`, named `<letter-or-digit>[A-Za-z0-9_-]{0,127}.json`. */
7
+ export const LANE_NAME_RE = /^\.cspeach\/lane\/([A-Za-z0-9][A-Za-z0-9_-]{0,127})\.json$/;
8
+ /**
9
+ * Canonical workspace-relative form: `./.cspeach/lane/x.json` and
10
+ * `.cspeach//lane/x.json` both become `.cspeach/lane/x.json`. Backslashes and
11
+ * NUL are left alone so the name rule refuses them.
12
+ */
13
+ export function canonicalLanePath(p) {
14
+ if (typeof p !== 'string' || p === '' || /[\\\u0000]/.test(p))
15
+ return p;
16
+ return posix.normalize(p);
17
+ }
18
+ /** True only for a canonical path the store would accept. */
19
+ export function isLanePath(p) {
20
+ return typeof p === 'string' && !/[\\\u0000]/.test(p) && !p.split('/').includes('..') && LANE_NAME_RE.test(p);
21
+ }
@@ -0,0 +1,109 @@
1
+ export const BUILT_LANES = ['rap', 'cap', 'none', 'abap-report'];
2
+ const CAP_KEYS = ['cap_external_users', 'cap_multi_system', 'cap_own_release_cycle', 'cap_ui_on_released_api'];
3
+ /** Reason lines the lane card matches exactly (exported for card.ts only). */
4
+ export const UNVERIFIED_REASON = 'System not checked: every built lane is shown unverified.';
5
+ export const READ_ONLY_ANALYTICAL_REASON = 'Read-only but analytical: it stays an app (ALP), not a report.';
6
+ export const DEFAULT_RAP_REASON = 'Default: on-stack RAP + Fiori Elements.';
7
+ const SEGW_NOT_YET = 'not available yet — ECC and S/4 before 2020 come next; /abap-segw works directly today';
8
+ export function allowedFor(tier) {
9
+ return tier === 's4-2020-plus' || tier === 'unknown' ? [...BUILT_LANES] : ['cap', 'none', 'abap-report'];
10
+ }
11
+ const strong = (s, k) => s[k].strength === 'strong';
12
+ const truthy = (s, k) => s[k].value !== false && s[k].value !== null && s[k].value !== '';
13
+ export function resolveLane(input) {
14
+ const { signals: s, facts } = input;
15
+ const allowed = allowedFor(facts.tier);
16
+ const crossedOut = [{ lane: 'segw', reason: SEGW_NOT_YET }, { lane: 'bopf-generated', reason: 'not available yet' }];
17
+ if (!allowed.includes('rap')) {
18
+ crossedOut.push({ lane: 'rap', reason: `needs S/4 2020: managed RAP + draft (this system: ${facts.label})` });
19
+ }
20
+ const crossOut = (lane, reason) => {
21
+ const hit = crossedOut.find((c) => c.lane === lane);
22
+ if (hit)
23
+ hit.reason = `${hit.reason}; ${reason}`;
24
+ else
25
+ crossedOut.push({ lane, reason: `${reason}, but it is not available on this system` });
26
+ };
27
+ const reasons = [];
28
+ reasons.push(facts.source === 'unknown'
29
+ ? UNVERIFIED_REASON
30
+ : `System: ${facts.label} (${facts.source === 'declared' ? 'declared, not probed' : 'probed'}).`);
31
+ let backend = null;
32
+ let decidedBy = 'rules';
33
+ let decidedByPath;
34
+ let fired = false;
35
+ for (const [who, pin] of [['pin', input.pin], ['standards', input.standards]]) {
36
+ if (backend || !pin?.backend)
37
+ continue;
38
+ if (allowed.includes(pin.backend)) {
39
+ backend = pin.backend;
40
+ decidedBy = who;
41
+ decidedByPath = pin.source;
42
+ fired = true;
43
+ reasons.push(`${who === 'pin' ? 'Project pin' : 'Standards file'} ${pin.source} sets the backend to ${pin.backend}.`);
44
+ }
45
+ else {
46
+ crossOut(pin.backend, `${who === 'pin' ? 'project pin' : 'standards file'} ${pin.source} asks for it`);
47
+ }
48
+ }
49
+ if (!backend && strong(s, 'ui_only_service') && allowed.includes('none')) {
50
+ backend = 'none';
51
+ fired = true;
52
+ reasons.push(`The spec asks only for a UI on a published service (${String(s.ui_only_service.value)}).`);
53
+ }
54
+ if (!backend && strong(s, 'report_only') && allowed.includes('abap-report')) {
55
+ if (strong(s, 'analytical') && allowed.includes('rap')) {
56
+ reasons.push(READ_ONLY_ANALYTICAL_REASON);
57
+ }
58
+ else {
59
+ backend = 'abap-report';
60
+ fired = true;
61
+ reasons.push('The spec asks for list output only, not an app.');
62
+ }
63
+ }
64
+ const strongCap = CAP_KEYS.filter((k) => strong(s, k));
65
+ if (!backend && strongCap.length > 0 && allowed.includes('cap')) {
66
+ backend = 'cap';
67
+ fired = true;
68
+ // M6 — a `"` inside a quote would close the quoted piece early: swap it for `'`.
69
+ reasons.push(`Side-by-side signal in the spec: ${strongCap.map((k) => `"${(s[k].evidence ?? k).replace(/"/g, "'")}"`).join('; ')}.`);
70
+ }
71
+ let noAppLane = false;
72
+ if (!backend) {
73
+ if (allowed.includes('rap')) {
74
+ backend = 'rap';
75
+ reasons.push(DEFAULT_RAP_REASON);
76
+ }
77
+ else {
78
+ noAppLane = true;
79
+ reasons.push(`No app lane is available on this system (${facts.label}).`);
80
+ }
81
+ }
82
+ const neutral = !fired && backend === 'rap';
83
+ const weakHints = neutral ? CAP_KEYS.filter((k) => truthy(s, k) && !strong(s, k)) : [];
84
+ // R2: a source's ui counts only when its backend was not crossed out, or it set only a ui.
85
+ const usable = (p) => (p && (!p.backend || allowed.includes(p.backend)) ? p : undefined);
86
+ const { ui, floorplan } = uiFor(backend, s, usable(input.pin)?.ui ?? usable(input.standards)?.ui);
87
+ const runtimeKnown = input.runtimeKnown || !!input.pin?.runtime || !!input.standards?.runtime;
88
+ return {
89
+ backend, ui, ...(floorplan ? { floorplan } : {}), decidedBy, ...(decidedByPath ? { decidedByPath } : {}),
90
+ neutral, noAppLane, needsRuntime: backend === 'cap' && !runtimeKnown,
91
+ allowed, crossedOut, reasons, weakHints, unverified: facts.tier === 'unknown', facts,
92
+ };
93
+ }
94
+ /**
95
+ * The UI rule (spec §5.2), run after the backend rule. Exported so the tool can
96
+ * re-run it after a developer switches the backend on the card.
97
+ */
98
+ export function uiFor(backend, signals, pinUi) {
99
+ if (backend === 'abap-report' || backend === null)
100
+ return { ui: 'none' };
101
+ const feFloorplan = strong(signals, 'analytical') ? 'alp' : 'listReport';
102
+ if (pinUi)
103
+ return pinUi === 'fiori-elements' ? { ui: pinUi, floorplan: feFloorplan } : { ui: pinUi };
104
+ if (strong(signals, 'analytical'))
105
+ return { ui: 'fiori-elements', floorplan: 'alp' };
106
+ if (strong(signals, 'custom_ui') || backend === 'none')
107
+ return { ui: 'freestyle' };
108
+ return { ui: 'fiori-elements', floorplan: 'listReport' };
109
+ }