@cspeach/cli 1.1.24 → 1.1.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/projects/detail-path.js +117 -0
- package/dist/projects/merge-cca.js +19 -0
- package/dist/projects/merge-upgrade.js +19 -1
- package/dist/projects/promote.js +7 -1
- package/dist/register/records.js +7 -2
- package/dist/skills/bundled-skills.js +1 -1
- package/dist/tools/_filesystem-shared.js +363 -33
- package/dist/tools/cap/metadata-import.js +10 -4
- package/dist/tools/filesystem/file-write.js +12 -39
- package/dist/tools/filesystem/glob.js +25 -2
- package/dist/tools/filesystem/grep.js +25 -2
- package/dist/tools/fiori/apply.js +30 -21
- package/dist/tools/fiori/commit-gate.js +58 -0
- package/dist/tools/fiori/fe-extend.js +6 -1
- package/dist/tools/fiori/fe-scaffold.js +6 -1
- package/dist/tools/fiori/path-params.js +78 -0
- package/dist/tools/fiori/scaffold.js +2 -1
- package/dist/tools/fiori/tools.js +53 -14
- package/dist/tools/project/_merge-shared.js +36 -1
- package/dist/tools/project/cca_merge.js +4 -0
- package/dist/tools/project/upgrade_merge_progress.js +21 -10
- package/package.json +1 -1
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// cspeach-cli/src/projects/detail-path.ts
|
|
2
|
+
/**
|
|
3
|
+
* SEC (review round 2): `content.detailPath` inside a saved envelope is DATA
|
|
4
|
+
* the model can write — never a trusted path. Every reader that turns it into
|
|
5
|
+
* a file access (upgrade merge baseline hash, the register's CCA objects.json,
|
|
6
|
+
* promote's file_read hint) goes through here:
|
|
7
|
+
*
|
|
8
|
+
* - safeDetailRel: the string must be a plain project-relative path under a
|
|
9
|
+
* skill working folder — `.cspeach/<domain>/…` (or the legacy
|
|
10
|
+
* `.abapforge/<domain>/…`). No absolute paths, drive letters, `..`, `:`
|
|
11
|
+
* streams, control characters, trailing dots/spaces or device names.
|
|
12
|
+
* - resolveDetailFile(Sync): the REAL file must sit in that working folder
|
|
13
|
+
* under the project root (or the workspace folder) and not be a protected
|
|
14
|
+
* path. Returns null for refused AND missing alike, so callers can give ONE
|
|
15
|
+
* message and never act as an existence oracle.
|
|
16
|
+
*/
|
|
17
|
+
import * as path from 'node:path';
|
|
18
|
+
import { realpathSync, statSync } from 'node:fs';
|
|
19
|
+
import { CSPEACH_WORK_SUBDIRS, isDenylistedPath, isWindowsReservedName } from '../tools/_filesystem-shared.js';
|
|
20
|
+
import { workspaceFolderFor } from '../lane/roots.js';
|
|
21
|
+
/** Working-folder roots a detail path may start with (current, legacy). */
|
|
22
|
+
const DETAIL_ROOTS = ['.cspeach', '.abapforge'];
|
|
23
|
+
/**
|
|
24
|
+
* The normalised '/'-separated project-relative detail path, or null when the
|
|
25
|
+
* value is not a safe path into `<root>/<domain>/` (any domain when omitted).
|
|
26
|
+
*/
|
|
27
|
+
export function safeDetailRel(detailPath, domain) {
|
|
28
|
+
if (typeof detailPath !== 'string' || detailPath.length === 0 || detailPath.length > 512)
|
|
29
|
+
return null;
|
|
30
|
+
if (/[\u0000-\u001f\u007f]/.test(detailPath))
|
|
31
|
+
return null;
|
|
32
|
+
if (path.posix.isAbsolute(detailPath) || path.win32.isAbsolute(detailPath) || /^[A-Za-z]:/.test(detailPath))
|
|
33
|
+
return null;
|
|
34
|
+
const segs = detailPath.replace(/\\/g, '/').split('/');
|
|
35
|
+
while (segs[0] === '.')
|
|
36
|
+
segs.shift();
|
|
37
|
+
if (segs.length < 3)
|
|
38
|
+
return null;
|
|
39
|
+
for (const seg of segs) {
|
|
40
|
+
if (seg === '' || seg === '.' || seg === '..')
|
|
41
|
+
return null;
|
|
42
|
+
if (seg.includes(':') || /[. ]$/.test(seg) || /[<>"|?*]/.test(seg))
|
|
43
|
+
return null;
|
|
44
|
+
if (isWindowsReservedName(seg))
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
const rootSeg = segs[0].toLowerCase();
|
|
48
|
+
const domainSeg = segs[1].toLowerCase();
|
|
49
|
+
if (!DETAIL_ROOTS.includes(rootSeg))
|
|
50
|
+
return null;
|
|
51
|
+
if (domain ? domainSeg !== domain : !CSPEACH_WORK_SUBDIRS.includes(domainSeg))
|
|
52
|
+
return null;
|
|
53
|
+
return segs.join('/');
|
|
54
|
+
}
|
|
55
|
+
function realOrNull(p) {
|
|
56
|
+
try {
|
|
57
|
+
return realpathSync.native(p);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The real path of the first candidate that exists, is a regular file, stays
|
|
65
|
+
* inside `<base>/<first two segments of rel>/` after links are followed, and
|
|
66
|
+
* is not denylisted relative to `cwd`. null otherwise.
|
|
67
|
+
*/
|
|
68
|
+
function firstContained(cands, cwd) {
|
|
69
|
+
for (const { base, rel, keep } of cands) {
|
|
70
|
+
const realBase = realOrNull(path.resolve(base));
|
|
71
|
+
const real = realOrNull(path.join(path.resolve(base), ...rel.split('/')));
|
|
72
|
+
if (!realBase || !real)
|
|
73
|
+
continue;
|
|
74
|
+
const segs = rel.split('/');
|
|
75
|
+
const folder = path.join(realBase, ...segs.slice(0, keep));
|
|
76
|
+
const inner = path.relative(folder, real);
|
|
77
|
+
if (inner === '' || inner.startsWith('..') || path.isAbsolute(inner))
|
|
78
|
+
continue;
|
|
79
|
+
if (isDenylistedPath(real, cwd).blocked)
|
|
80
|
+
continue;
|
|
81
|
+
try {
|
|
82
|
+
if (!statSync(real).isFile())
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
return real;
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
/** Sync resolution under the project root only (callers without a workspace). */
|
|
93
|
+
export function resolveDetailFileSync(detailPath, domain, projectRoot) {
|
|
94
|
+
const rel = safeDetailRel(detailPath, domain);
|
|
95
|
+
if (!rel)
|
|
96
|
+
return null;
|
|
97
|
+
return firstContained([{ base: projectRoot, rel, keep: 2 }], projectRoot);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Resolution under the project root (cwd) and the resolved workspace folder
|
|
101
|
+
* (which may sit elsewhere: a git-root `.cspeach/`, a configured folder). For
|
|
102
|
+
* the workspace, a `.cspeach/<domain>/x` path maps to `<workspace>/<domain>/x`.
|
|
103
|
+
*/
|
|
104
|
+
export async function resolveDetailFile(detailPath, domain, cwd) {
|
|
105
|
+
const rel = safeDetailRel(detailPath, domain);
|
|
106
|
+
if (!rel)
|
|
107
|
+
return null;
|
|
108
|
+
const cands = [{ base: cwd, rel, keep: 2 }];
|
|
109
|
+
const segs = rel.split('/');
|
|
110
|
+
if (segs[0].toLowerCase() === '.cspeach') {
|
|
111
|
+
try {
|
|
112
|
+
cands.push({ base: await workspaceFolderFor(cwd), rel: segs.slice(1).join('/'), keep: 1 });
|
|
113
|
+
}
|
|
114
|
+
catch { /* no workspace — project root only */ }
|
|
115
|
+
}
|
|
116
|
+
return firstContained(cands, cwd);
|
|
117
|
+
}
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
* file must be consulted for full coverage
|
|
28
28
|
*/
|
|
29
29
|
import { OUTPUT_ROOT } from './output-paths.js';
|
|
30
|
+
import { isWindowsReservedName } from '../tools/_filesystem-shared.js';
|
|
30
31
|
/** Higher = more restrictive. Conflict resolution picks the max. */
|
|
31
32
|
const RESTRICTIVENESS = {
|
|
32
33
|
retire: 3,
|
|
@@ -94,6 +95,19 @@ export function scopePatternsIntersect(a, b) {
|
|
|
94
95
|
function todayUtc() {
|
|
95
96
|
return new Date().toISOString().slice(0, 10);
|
|
96
97
|
}
|
|
98
|
+
/** A folder/file-name slug: `^[A-Za-z0-9._-]+$`, no `..`, not `.`, no device name. */
|
|
99
|
+
export function assertMergeSlug(label, value) {
|
|
100
|
+
const ok = typeof value === 'string'
|
|
101
|
+
&& value.length > 0 && value.length <= 100
|
|
102
|
+
&& /^[A-Za-z0-9._-]+$/.test(value)
|
|
103
|
+
&& !value.includes('..') && value !== '.'
|
|
104
|
+
&& !/[. ]$/.test(value)
|
|
105
|
+
&& !isWindowsReservedName(value);
|
|
106
|
+
if (!ok) {
|
|
107
|
+
throw new Error(`${label} ${JSON.stringify(value)} cannot name a folder — use letters, digits, ".", "_" and "-" only `
|
|
108
|
+
+ '(no "..", no device names such as CON or NUL).');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
97
111
|
export function mergeCcaAssessments(inputs, opts = {}) {
|
|
98
112
|
if (inputs.length < 2) {
|
|
99
113
|
throw new Error(`cca merge requires at least 2 inputs (got ${inputs.length}) — a single assessment needs no merge`);
|
|
@@ -214,6 +228,11 @@ export function mergeCcaAssessments(inputs, opts = {}) {
|
|
|
214
228
|
// overlap is already warned about above).
|
|
215
229
|
const uncategorized = inputs.reduce((sum, inp) => sum + (inp.content.summary.uncategorized || 0), 0);
|
|
216
230
|
const date = opts.mergedDate ?? todayUtc();
|
|
231
|
+
// SEC (review round 2): projectId comes from INSIDE an envelope (model-
|
|
232
|
+
// writable) and becomes a folder name under .cspeach/cca/projects/. Only a
|
|
233
|
+
// plain slug may do that — never `../`, a drive, a stream or a device name.
|
|
234
|
+
assertMergeSlug('projectId', first.content.projectId);
|
|
235
|
+
assertMergeSlug('merged date', date);
|
|
217
236
|
const mergedProjectId = `${first.content.projectId}_merged_${date}`;
|
|
218
237
|
const scopes = [];
|
|
219
238
|
for (const inp of inputs) {
|
|
@@ -17,6 +17,22 @@
|
|
|
17
17
|
* - counts RECOMPUTED from merged rows — never trusted from inputs
|
|
18
18
|
*/
|
|
19
19
|
import { OUTPUT_ROOT } from './output-paths.js';
|
|
20
|
+
import { isWindowsReservedName } from '../tools/_filesystem-shared.js';
|
|
21
|
+
/**
|
|
22
|
+
* SEC (review round 2): the merged detail FILE NAME is built from the last
|
|
23
|
+
* segment of the baseline path, which comes from inside an envelope (model-
|
|
24
|
+
* writable). On Windows `base.json:ads` would write an NTFS stream and `CON`
|
|
25
|
+
* opens the console. Keep only `[A-Za-z0-9._-]`, drop a stream suffix, never
|
|
26
|
+
* `..` or a device name; fall back to `baseline`.
|
|
27
|
+
*/
|
|
28
|
+
export function safeStem(baselinePath) {
|
|
29
|
+
const last = (typeof baselinePath === 'string' ? baselinePath : '').split(/[\\/]/).pop() ?? '';
|
|
30
|
+
let stem = last.split(':')[0].replace(/\.json$/i, '');
|
|
31
|
+
stem = stem.replace(/[^A-Za-z0-9._-]/g, '_').replace(/\.{2,}/g, '_').replace(/^[._-]+|[._ -]+$/g, '');
|
|
32
|
+
if (!stem || stem.length > 100 || isWindowsReservedName(stem))
|
|
33
|
+
return 'baseline';
|
|
34
|
+
return stem;
|
|
35
|
+
}
|
|
20
36
|
/** Higher = wins the reconciliation. */
|
|
21
37
|
const STATUS_PRECEDENCE = {
|
|
22
38
|
failed: 3,
|
|
@@ -124,7 +140,9 @@ export function mergeUpgradeProgress(inputs, baseline) {
|
|
|
124
140
|
}
|
|
125
141
|
const baselinePath = baseline.path ?? inputs[0].content.baselineRef.path;
|
|
126
142
|
const date = baseline.mergedDate ?? todayUtc();
|
|
127
|
-
const stem = (baselinePath
|
|
143
|
+
const stem = safeStem(baselinePath);
|
|
144
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date))
|
|
145
|
+
throw new Error(`merged date ${JSON.stringify(date)} must be YYYY-MM-DD`);
|
|
128
146
|
const merged = {
|
|
129
147
|
detailPath: `${OUTPUT_ROOT}/upgrades/${stem}_progress_merged_${date}.json`,
|
|
130
148
|
baselineRef: { path: baselinePath, sha256: baseline.sha256 },
|
package/dist/projects/promote.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { canonicalSha256 } from './canonicalize.js';
|
|
2
|
+
import { safeDetailRel } from './detail-path.js';
|
|
2
3
|
function itemTextFor(env, itemId) {
|
|
3
4
|
if (env.artefactType === 'spec-gap') {
|
|
4
5
|
for (const s of env.content.sections) {
|
|
@@ -105,7 +106,12 @@ export function buildReviewDecisionsBlock(source) {
|
|
|
105
106
|
* makes readable — so we hand the skill that exact path to `file_read` directly.
|
|
106
107
|
*/
|
|
107
108
|
export function buildDetailPathHint(source) {
|
|
108
|
-
|
|
109
|
+
// SEC (review round 2): detailPath is envelope DATA (model-writable). Only a
|
|
110
|
+
// plain project-relative path into a skill working folder
|
|
111
|
+
// (.cspeach/<cca|upgrades|modernize|tests>/…) is handed to the skill as a
|
|
112
|
+
// file_read target — never an absolute path, `..`, a stream, a protected
|
|
113
|
+
// file, or text carrying a newline into the prompt.
|
|
114
|
+
const detailPath = safeDetailRel(source.content?.detailPath);
|
|
109
115
|
if (!detailPath)
|
|
110
116
|
return null;
|
|
111
117
|
return [
|
package/dist/register/records.js
CHANGED
|
@@ -12,6 +12,7 @@ import fs from 'node:fs';
|
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
import { classificationFromCcaDetail, fromCcaDetailKey, normalizeKey, normalizeRuleId, objectKey, } from '@cspeach/register-core';
|
|
14
14
|
import { defaultRand, readBaseline } from './store.js';
|
|
15
|
+
import { safeDetailRel, resolveDetailFileSync } from '../projects/detail-path.js';
|
|
15
16
|
const FIX_STATUSES = new Set(['fixed', 'skipped', 'failed', 'pending']);
|
|
16
17
|
export function writeRecord(joined, recordType, rows, by, opts = {}) {
|
|
17
18
|
const known = new Set(readBaseline(joined.projectDir).map((o) => objectKey(o.type, o.name)));
|
|
@@ -69,8 +70,12 @@ export function writeRecord(joined, recordType, rows, by, opts = {}) {
|
|
|
69
70
|
*/
|
|
70
71
|
export function decisionRowsFromCca(content, projectRoot) {
|
|
71
72
|
const rows = new Map();
|
|
72
|
-
|
|
73
|
-
|
|
73
|
+
// SEC (review round 2): detailPath is envelope DATA. objects.json is read only
|
|
74
|
+
// from beside a safe `.cspeach/cca/…` detail path, as a real file inside the
|
|
75
|
+
// project's cca working folder — never from an arbitrary location.
|
|
76
|
+
const rel = safeDetailRel(content.detailPath, 'cca');
|
|
77
|
+
const detail = rel ? resolveDetailFileSync(`${path.posix.dirname(rel)}/objects.json`, 'cca', projectRoot) : null;
|
|
78
|
+
if (detail) {
|
|
74
79
|
try {
|
|
75
80
|
const objects = JSON.parse(fs.readFileSync(detail, 'utf8'));
|
|
76
81
|
for (const [detailKey, o] of Object.entries(objects ?? {})) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// GENERATED FILE — bundled skills for local mode.
|
|
2
2
|
// Source: https://manifest.cspeach.dev/v1.json
|
|
3
|
-
// Generated: 2026-10-
|
|
3
|
+
// Generated: 2026-10-02T14:32:26.255Z
|
|
4
4
|
// Manifest signature verified at build time.
|
|
5
5
|
export const BUNDLED_SKILLS = {
|
|
6
6
|
"abap-app": {
|