@cspeach/cli 1.1.1 → 1.1.3
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/agent/loop.js +33 -4
- package/dist/agent/session-context.js +24 -0
- package/dist/agent/tool-dispatch.js +17 -1
- package/dist/approvals/jwt.js +12 -1
- package/dist/cli-args.js +24 -0
- package/dist/cli.js +33 -4
- package/dist/commands/config-set.js +139 -4
- package/dist/config/loader.js +34 -0
- package/dist/cost/pricing.js +4 -0
- package/dist/cost/session-cost.js +28 -0
- package/dist/doctor/checks/_standalone-skip.js +37 -0
- package/dist/doctor/checks/cert.js +5 -0
- package/dist/doctor/checks/forge-rules.js +6 -0
- package/dist/doctor/checks/sap.js +5 -0
- package/dist/doctor/checks/standards.js +27 -0
- package/dist/doctor/checks/system-roles.js +5 -0
- package/dist/doctor/checks/zcspeach.js +5 -0
- package/dist/doctor/run.js +2 -0
- package/dist/one-shot.js +36 -11
- package/dist/renderer/abap-inline.js +2 -1
- package/dist/renderer/syntax.js +7 -1
- package/dist/repl/current-transport.js +23 -1
- package/dist/repl/post-turn-status.js +9 -6
- package/dist/repl.js +40 -7
- package/dist/sap/offline-adt-client.js +58 -0
- package/dist/sap/standalone-onboarding.js +118 -0
- package/dist/sap/standalone-profile.js +237 -0
- package/dist/skills/bundled-skills.js +41 -41
- package/dist/standards/standards-file.js +107 -0
- package/dist/standards/standards-init.js +194 -0
- package/dist/tools/capability/tool.js +2 -0
- package/dist/tools/fiori/fe-scaffold.js +25 -0
- package/dist/tools/fiori/smoke/assertions.js +8 -2
- package/dist/tools/fiori/smoke/driver.js +6 -0
- package/dist/tools/fiori/smoke/run-smoke.js +69 -2
- package/dist/tools/fiori/tools.js +4 -2
- package/dist/tools/index.js +25 -0
- package/dist/tools/sap-read.js +18 -0
- package/dist/tools/sap-write.js +32 -0
- package/dist/tools/snapshot.js +3 -0
- package/dist/tools/todo.js +11 -3
- package/dist/tools/transport.js +4 -0
- package/dist/ui/app.js +1 -1
- package/dist/ui/sap-state-store.js +1 -1
- package/dist/ui/status-line.js +2 -2
- package/dist/ui/todo-panel.js +27 -6
- package/fiori/zlas-inspection-lr/package.json +26 -0
- package/fiori/zlas-inspection-lr/ui5-local.yaml +45 -0
- package/fiori/zlas-inspection-lr/ui5.yaml +41 -0
- package/fiori/zlas-inspection-lr/webapp/Component.js +12 -0
- package/fiori/zlas-inspection-lr/webapp/i18n/i18n.properties +9 -0
- package/fiori/zlas-inspection-lr/webapp/index.html +35 -0
- package/fiori/zlas-inspection-lr/webapp/manifest.json +145 -0
- package/package.json +109 -109
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Customer development standards — the team's own ABAP conventions, injected
|
|
3
|
+
* into every turn's `<session_context>` so skills follow the customer's rules
|
|
4
|
+
* instead of CSPeach's defaults.
|
|
5
|
+
*
|
|
6
|
+
* Entirely opt-in: when no standards file exists this module renders NOTHING,
|
|
7
|
+
* so an existing user's prompts are byte-identical to before the feature.
|
|
8
|
+
*
|
|
9
|
+
* Lookup order (first hit wins):
|
|
10
|
+
* 1. `<cwd>/CSPEACH-STANDARDS.md` — checked in next to the code
|
|
11
|
+
* 2. `<cwd>/.cspeach/standards.md` — project-local, gitignorable
|
|
12
|
+
* 3. `~/.cspeach/standards.md` — per-developer default
|
|
13
|
+
*
|
|
14
|
+
* Applies in BOTH standalone and connected mode. The system prompt is assembled
|
|
15
|
+
* server-side by the proxy, so the override rule travels inside the element
|
|
16
|
+
* rather than in a CLI-side preamble constant.
|
|
17
|
+
*/
|
|
18
|
+
import { promises as fs } from 'node:fs';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { cspeachRoot } from '../config/paths.js';
|
|
22
|
+
/** Hard cap on how much of the document travels in each turn's context. */
|
|
23
|
+
export const STANDARDS_MAX_CHARS = 8000;
|
|
24
|
+
/** Standard file name a team checks in beside their code. */
|
|
25
|
+
export const STANDARDS_REPO_FILENAME = 'CSPEACH-STANDARDS.md';
|
|
26
|
+
/**
|
|
27
|
+
* First line inside `<development_standards>`. CSPeach's own conventions ship
|
|
28
|
+
* in the server-side system prompt; this tells the model which wins.
|
|
29
|
+
*/
|
|
30
|
+
export const STANDARDS_OVERRIDE_RULE = "Rule: these customer standards override CSPeach's default conventions. If a default conflicts, follow the customer standard and say so once.";
|
|
31
|
+
/** Per-process cache, keyed by cwd. Mirrors the project-context cache. */
|
|
32
|
+
const cache = new Map();
|
|
33
|
+
/** Test hook — drop the per-process cache. */
|
|
34
|
+
export function resetStandardsCache() {
|
|
35
|
+
cache.clear();
|
|
36
|
+
}
|
|
37
|
+
/** The candidate paths, in precedence order, for a given cwd. */
|
|
38
|
+
export function standardsCandidates(cwd) {
|
|
39
|
+
return [
|
|
40
|
+
path.join(cwd, STANDARDS_REPO_FILENAME),
|
|
41
|
+
path.join(cwd, '.cspeach', 'standards.md'),
|
|
42
|
+
path.join(cspeachRoot(), 'standards.md'),
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Find the active standards file for `cwd`, or null when the user has none.
|
|
47
|
+
* Best-effort: an unreadable candidate is skipped, never thrown.
|
|
48
|
+
*/
|
|
49
|
+
export async function resolveStandardsFile(cwd) {
|
|
50
|
+
if (cache.has(cwd))
|
|
51
|
+
return cache.get(cwd);
|
|
52
|
+
let found = null;
|
|
53
|
+
for (const candidate of standardsCandidates(cwd)) {
|
|
54
|
+
try {
|
|
55
|
+
const raw = await fs.readFile(candidate, 'utf8');
|
|
56
|
+
const content = raw.trim();
|
|
57
|
+
// A present-but-empty file means "nothing to say" — keep looking, and if
|
|
58
|
+
// nothing else turns up render no element at all.
|
|
59
|
+
if (content.length === 0)
|
|
60
|
+
continue;
|
|
61
|
+
found = { path: candidate, content };
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// ENOENT / EACCES / EISDIR — try the next candidate.
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
cache.set(cwd, found);
|
|
69
|
+
return found;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Display form of a standards path: relative when it sits under cwd, `~`-
|
|
73
|
+
* prefixed when it sits under the home directory, absolute otherwise.
|
|
74
|
+
*/
|
|
75
|
+
export function displayStandardsPath(filePath, cwd) {
|
|
76
|
+
const rel = path.relative(cwd, filePath);
|
|
77
|
+
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel))
|
|
78
|
+
return rel;
|
|
79
|
+
const home = os.homedir();
|
|
80
|
+
const relHome = path.relative(home, filePath);
|
|
81
|
+
if (relHome && !relHome.startsWith('..') && !path.isAbsolute(relHome)) {
|
|
82
|
+
return '~' + path.sep + relHome;
|
|
83
|
+
}
|
|
84
|
+
return filePath;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Render the `<development_standards>` element for `<session_context>`, or ''
|
|
88
|
+
* when the user has no standards file (the no-regression path).
|
|
89
|
+
*/
|
|
90
|
+
export async function renderStandardsBlock(cwd) {
|
|
91
|
+
const file = await resolveStandardsFile(cwd);
|
|
92
|
+
if (!file)
|
|
93
|
+
return '';
|
|
94
|
+
let body = file.content;
|
|
95
|
+
if (body.length > STANDARDS_MAX_CHARS) {
|
|
96
|
+
body = body.slice(0, STANDARDS_MAX_CHARS) + `\n[truncated — full document at ${file.path}]`;
|
|
97
|
+
}
|
|
98
|
+
return [
|
|
99
|
+
`<development_standards source="${escapeAttr(displayStandardsPath(file.path, cwd))}">`,
|
|
100
|
+
STANDARDS_OVERRIDE_RULE,
|
|
101
|
+
body,
|
|
102
|
+
'</development_standards>',
|
|
103
|
+
].join('\n');
|
|
104
|
+
}
|
|
105
|
+
function escapeAttr(s) {
|
|
106
|
+
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
107
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cspeach standards init` — capture the team's ABAP development standards
|
|
3
|
+
* once, as a Markdown file the session context then carries into every turn
|
|
4
|
+
* (see standards-file.ts).
|
|
5
|
+
*
|
|
6
|
+
* Three routes:
|
|
7
|
+
* a) import — point at an existing .md/.txt and copy it in with a header
|
|
8
|
+
* b) answer — a short interview, written as one `## ` section per answer
|
|
9
|
+
* c) defaults— write nothing; CSPeach's built-in conventions apply
|
|
10
|
+
*
|
|
11
|
+
* Also runs as the LAST question of the standalone profile wizard.
|
|
12
|
+
*
|
|
13
|
+
* Prompts are injected so the flow is unit-testable without a TTY; the default
|
|
14
|
+
* implementation uses the same `@inquirer/prompts` primitives as the SAP wizard.
|
|
15
|
+
*/
|
|
16
|
+
import chalk from 'chalk';
|
|
17
|
+
import { promises as fs } from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { cspeachRoot } from '../config/paths.js';
|
|
20
|
+
import { STANDARDS_REPO_FILENAME, displayStandardsPath, resetStandardsCache, } from './standards-file.js';
|
|
21
|
+
/**
|
|
22
|
+
* The interview. Every question is OPTIONAL — an empty answer means the team
|
|
23
|
+
* has no rule there, and no section is written for it.
|
|
24
|
+
*/
|
|
25
|
+
export const STANDARDS_INTERVIEW_QUESTIONS = [
|
|
26
|
+
{
|
|
27
|
+
key: 'namespace',
|
|
28
|
+
heading: 'Namespace / prefix',
|
|
29
|
+
message: 'Namespace or prefix for custom objects (Enter to skip):',
|
|
30
|
+
default: 'Z',
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
key: 'packages',
|
|
34
|
+
heading: 'Packages for new objects',
|
|
35
|
+
message: 'Package(s) new objects belong in, e.g. ZFI_CORE (Enter to skip):',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
key: 'naming',
|
|
39
|
+
heading: 'Naming style',
|
|
40
|
+
message: 'Naming style for classes / methods / variables — e.g. "zcl_<area>_<noun>", Hungarian lv_/ls_/lt_ (Enter to skip):',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
key: 'forbidden',
|
|
44
|
+
heading: 'Forbidden or discouraged statements',
|
|
45
|
+
message: 'Forbidden or discouraged statements, e.g. "no SELECT *", "no CALL TRANSACTION" (Enter to skip):',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
key: 'atc_variant',
|
|
49
|
+
heading: 'ATC check variant',
|
|
50
|
+
message: 'ATC check variant name (Enter to skip):',
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
key: 'clean_core',
|
|
54
|
+
heading: 'Clean core',
|
|
55
|
+
message: 'Clean-core requirement:',
|
|
56
|
+
choices: ['ABAP Cloud only', 'released APIs preferred', 'classic allowed', '(skip)'],
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
key: 'transport_process',
|
|
60
|
+
heading: 'Transport / change process',
|
|
61
|
+
message: 'Transport / change process, e.g. "ChaRM, one TR per ticket" (Enter to skip):',
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
key: 'header_template',
|
|
65
|
+
heading: 'Code header template',
|
|
66
|
+
message: 'Required code header / comment template (Enter to skip):',
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
key: 'unit_tests',
|
|
70
|
+
heading: 'Unit tests',
|
|
71
|
+
message: 'Unit-test expectation:',
|
|
72
|
+
choices: ['required for all classes', 'business logic only', 'none', '(skip)'],
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
key: 'other',
|
|
76
|
+
heading: 'Anything else',
|
|
77
|
+
message: 'Anything else CSPeach should follow (Enter to skip):',
|
|
78
|
+
},
|
|
79
|
+
];
|
|
80
|
+
/** Header written at the top of an IMPORTED standards document. */
|
|
81
|
+
export function importedStandardsHeader(sourcePath, when = new Date()) {
|
|
82
|
+
const date = when.toISOString().slice(0, 10);
|
|
83
|
+
return `# Development standards (imported from ${sourcePath} on ${date})`;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Render the interview answers as Markdown — one `## ` section per ANSWERED
|
|
87
|
+
* item, in question order. Returns '' when nothing was answered, so the caller
|
|
88
|
+
* can skip writing a file that would say nothing.
|
|
89
|
+
*/
|
|
90
|
+
export function renderStandardsMarkdown(answers) {
|
|
91
|
+
const sections = [];
|
|
92
|
+
for (const q of STANDARDS_INTERVIEW_QUESTIONS) {
|
|
93
|
+
const raw = answers[q.key];
|
|
94
|
+
const value = typeof raw === 'string' ? raw.trim() : '';
|
|
95
|
+
if (value.length === 0 || value === '(skip)')
|
|
96
|
+
continue;
|
|
97
|
+
sections.push(`## ${q.heading}\n\n${value}`);
|
|
98
|
+
}
|
|
99
|
+
if (sections.length === 0)
|
|
100
|
+
return '';
|
|
101
|
+
return `# Development standards\n\n${sections.join('\n\n')}\n`;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Write the standards document. Prefers `<cwd>/CSPEACH-STANDARDS.md` so the
|
|
105
|
+
* team can check it in; falls back to `~/.cspeach/standards.md` when cwd is not
|
|
106
|
+
* writable (read-only checkout, a directory the user doesn't own).
|
|
107
|
+
* Returns the path actually written.
|
|
108
|
+
*/
|
|
109
|
+
export async function writeStandardsFile(cwd, content) {
|
|
110
|
+
const repoPath = path.join(cwd, STANDARDS_REPO_FILENAME);
|
|
111
|
+
try {
|
|
112
|
+
await fs.writeFile(repoPath, content, 'utf8');
|
|
113
|
+
resetStandardsCache();
|
|
114
|
+
return repoPath;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
const homePath = path.join(cspeachRoot(), 'standards.md');
|
|
118
|
+
await fs.mkdir(path.dirname(homePath), { recursive: true });
|
|
119
|
+
await fs.writeFile(homePath, content, 'utf8');
|
|
120
|
+
resetStandardsCache();
|
|
121
|
+
return homePath;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
async function defaultPrompts() {
|
|
125
|
+
const { input, select } = await import('@inquirer/prompts');
|
|
126
|
+
return {
|
|
127
|
+
select: (opts) => select({ message: opts.message, choices: opts.choices, default: opts.default }),
|
|
128
|
+
input: (opts) => input({ message: opts.message, default: opts.default }),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Ask whether the team has development standards, and capture them.
|
|
133
|
+
* Never throws on a bad answer — a missing import path degrades to 'defaults'
|
|
134
|
+
* with a printed hint, because this runs inside startup wizards.
|
|
135
|
+
*/
|
|
136
|
+
export async function runStandardsInit(cwd, prompts) {
|
|
137
|
+
const p = prompts ?? (await defaultPrompts());
|
|
138
|
+
const route = await p.select({
|
|
139
|
+
key: 'route',
|
|
140
|
+
message: 'Does your team have ABAP development standards?',
|
|
141
|
+
choices: [
|
|
142
|
+
{ value: 'import', name: 'Point me to the document' },
|
|
143
|
+
{ value: 'interview', name: 'Answer a few questions' },
|
|
144
|
+
{ value: 'defaults', name: 'Use CSPeach defaults for now' },
|
|
145
|
+
],
|
|
146
|
+
default: 'defaults',
|
|
147
|
+
});
|
|
148
|
+
if (route === 'import') {
|
|
149
|
+
const src = (await p.input({
|
|
150
|
+
key: 'source',
|
|
151
|
+
message: 'Path to the standards document (.md or .txt):',
|
|
152
|
+
})).trim();
|
|
153
|
+
try {
|
|
154
|
+
const body = await fs.readFile(src, 'utf8');
|
|
155
|
+
const content = `${importedStandardsHeader(src)}\n\n${body.trim()}\n`;
|
|
156
|
+
const written = await writeStandardsFile(cwd, content);
|
|
157
|
+
console.log(chalk.green(`✓ Standards imported to ${displayStandardsPath(written, cwd)}`));
|
|
158
|
+
return { action: 'import', path: written };
|
|
159
|
+
}
|
|
160
|
+
catch (e) {
|
|
161
|
+
console.log(chalk.yellow(` Could not read "${src}" (${e.message}). Keeping CSPeach defaults.`));
|
|
162
|
+
printHowToAddLater(cwd);
|
|
163
|
+
return { action: 'defaults' };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (route === 'interview') {
|
|
167
|
+
const answers = {};
|
|
168
|
+
for (const q of STANDARDS_INTERVIEW_QUESTIONS) {
|
|
169
|
+
const answer = q.choices
|
|
170
|
+
? await p.select({
|
|
171
|
+
key: q.key,
|
|
172
|
+
message: q.message,
|
|
173
|
+
choices: q.choices.map((c) => ({ value: c, name: c })),
|
|
174
|
+
default: '(skip)',
|
|
175
|
+
})
|
|
176
|
+
: await p.input({ key: q.key, message: q.message, default: q.default });
|
|
177
|
+
answers[q.key] = answer;
|
|
178
|
+
}
|
|
179
|
+
const md = renderStandardsMarkdown(answers);
|
|
180
|
+
if (md.length === 0) {
|
|
181
|
+
console.log(chalk.dim(' Nothing answered — keeping CSPeach defaults.'));
|
|
182
|
+
printHowToAddLater(cwd);
|
|
183
|
+
return { action: 'defaults' };
|
|
184
|
+
}
|
|
185
|
+
const written = await writeStandardsFile(cwd, md);
|
|
186
|
+
console.log(chalk.green(`✓ Standards written to ${displayStandardsPath(written, cwd)}`));
|
|
187
|
+
return { action: 'interview', path: written };
|
|
188
|
+
}
|
|
189
|
+
printHowToAddLater(cwd);
|
|
190
|
+
return { action: 'defaults' };
|
|
191
|
+
}
|
|
192
|
+
function printHowToAddLater(cwd) {
|
|
193
|
+
console.log(chalk.dim(` Add standards later: drop a ${STANDARDS_REPO_FILENAME} in ${cwd}, or run: cspeach standards init`));
|
|
194
|
+
}
|
|
@@ -57,6 +57,8 @@ registerTool({
|
|
|
57
57
|
+ 'the feature consulted, the matrix column used, the reason, and (only for with-fallback) a concrete '
|
|
58
58
|
+ 'fallback instruction. Read-only — never writes to SAP.',
|
|
59
59
|
isMutating: false,
|
|
60
|
+
// Probes the CONNECTED release via getSapSystemInfo(ctx.adt, …).
|
|
61
|
+
requiresSap: true,
|
|
60
62
|
category: 'sap',
|
|
61
63
|
flagGated: true,
|
|
62
64
|
input_schema: {
|
|
@@ -62,6 +62,31 @@ export async function scaffoldFioriElements(opts) {
|
|
|
62
62
|
ui5: { minUI5Version: ui5Version, version: ui5Version },
|
|
63
63
|
};
|
|
64
64
|
await generate(opts.basePath, config, fs);
|
|
65
|
+
if (!opts.localAnnotations) {
|
|
66
|
+
// @sap-ux writers force-inject a 'localAnnotationsName: annotation' dataSource for
|
|
67
|
+
// EDMX services even when none was requested, but only write the XML file when
|
|
68
|
+
// metadata is passed — leaving a manifest reference that 404s at boot (F-L2-7).
|
|
69
|
+
const manifestPath = join(opts.basePath, 'webapp', 'manifest.json');
|
|
70
|
+
const manifest = fs.readJSON(manifestPath);
|
|
71
|
+
const ds = manifest?.['sap.app']?.dataSources ?? {};
|
|
72
|
+
const removed = Object.keys(ds).filter((k) => ds[k]?.type === 'ODataAnnotation');
|
|
73
|
+
if (removed.length > 0) {
|
|
74
|
+
for (const k of removed)
|
|
75
|
+
delete ds[k];
|
|
76
|
+
for (const d of Object.values(ds)) {
|
|
77
|
+
if (Array.isArray(d?.settings?.annotations)) {
|
|
78
|
+
d.settings.annotations = d.settings.annotations.filter((name) => !removed.includes(name));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
fs.writeJSON(manifestPath, manifest);
|
|
82
|
+
// also drop the orphan empty EDMX if the writer emitted one (metadata path)
|
|
83
|
+
for (const k of removed) {
|
|
84
|
+
const orphan = join(opts.basePath, 'webapp', 'annotations', `${k}.xml`);
|
|
85
|
+
if (fs.exists(orphan))
|
|
86
|
+
fs.delete(orphan);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
65
90
|
// Overwrite the writer's empty EDMX skeleton with the caller's real annotations
|
|
66
91
|
// EDMX, in the mem-fs editor, BEFORE commit so the flushed file is the caller's.
|
|
67
92
|
if (opts.localAnnotations) {
|
|
@@ -3,15 +3,21 @@
|
|
|
3
3
|
// under the gateway/odata4 path. An incidental non-OData 404 (favicon, a
|
|
4
4
|
// analytics beacon) must NOT false-fail an otherwise healthy render.
|
|
5
5
|
const ODATA_URL = /\$metadata|\$batch|\/sap\/opu\/odata|\/odata4?\//i;
|
|
6
|
+
// Unbuilt dev-serve artifacts: `fiori run` 404s Component-preload.js and
|
|
7
|
+
// resources.json on every app that hasn't been through `ui5 build`.
|
|
8
|
+
// Dev-mode noise, not app defects (F-L2-12) — must not fail boot.
|
|
9
|
+
const UNBUILT_DEV_404 = /(Component-preload\.js|resources\.json)/i;
|
|
10
|
+
const LOAD_FAILURE = /(404|failed to load)/i;
|
|
6
11
|
/** Boot check: the app loaded without throwing anything to the console. */
|
|
7
12
|
export function checkBoot(consoleErrors) {
|
|
8
|
-
const
|
|
13
|
+
const real = consoleErrors.filter((e) => !(UNBUILT_DEV_404.test(e) && LOAD_FAILURE.test(e)));
|
|
14
|
+
const ok = real.length === 0;
|
|
9
15
|
return {
|
|
10
16
|
name: 'boot',
|
|
11
17
|
ok,
|
|
12
18
|
detail: ok
|
|
13
19
|
? 'app booted with no console errors'
|
|
14
|
-
: `${
|
|
20
|
+
: `${real.length} console error(s) during boot: ${real.slice(0, 3).join(' | ')}`,
|
|
15
21
|
};
|
|
16
22
|
}
|
|
17
23
|
/** OData check: no 4xx/5xx on any $metadata / $batch / entity-set request. */
|
|
@@ -74,6 +74,12 @@ export async function openApp(executablePath, url, timeoutMs) {
|
|
|
74
74
|
}, name);
|
|
75
75
|
await page.waitForNetworkIdle({ idleTime: 200, timeout: timeoutMs }).catch(() => { });
|
|
76
76
|
},
|
|
77
|
+
// F-L2-10A: evidence capture. The caller owns the artifact dir (it is
|
|
78
|
+
// created before this is called) and owns failure handling — a rejection
|
|
79
|
+
// here is caught there and the entry omitted, never failing the smoke.
|
|
80
|
+
screenshot: async (target) => {
|
|
81
|
+
await page.screenshot({ path: target });
|
|
82
|
+
},
|
|
77
83
|
close: async () => {
|
|
78
84
|
await browser.close();
|
|
79
85
|
},
|
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
//
|
|
19
19
|
// The browser + driver are injectable (RunSmokeDeps) so the unit suite drives
|
|
20
20
|
// every path with fakes and NEVER launches a real browser.
|
|
21
|
+
import { mkdirSync } from 'node:fs';
|
|
22
|
+
import * as os from 'node:os';
|
|
23
|
+
import * as path from 'node:path';
|
|
21
24
|
import { resolveBrowserExecutable } from './browser.js';
|
|
22
25
|
import { openApp as realOpenApp } from './driver.js';
|
|
23
26
|
import { checkBoot, checkOData, checkColumns, checkRows, checkExercise, } from './assertions.js';
|
|
@@ -44,6 +47,30 @@ export const SMOKE_SELECTORS = {
|
|
|
44
47
|
// us to an auth endpoint instead of serving the app.
|
|
45
48
|
const LOGON_URL = /sap-system-login|\/saml2\/|\/logon|\/login/i;
|
|
46
49
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
50
|
+
/**
|
|
51
|
+
* A filesystem-safe app name for the artifact dir, derived from the preview URL:
|
|
52
|
+
* the last real path segment (`…/mycourses/index.html` → `mycourses`), falling
|
|
53
|
+
* back to host+port (`http://localhost:8080/index.html` → `localhost-8080`).
|
|
54
|
+
*/
|
|
55
|
+
function appNameFromUrl(appUrl) {
|
|
56
|
+
let raw;
|
|
57
|
+
try {
|
|
58
|
+
const u = new URL(appUrl);
|
|
59
|
+
const segs = u.pathname.split('/').filter((s) => s.length > 0 && !s.includes('.'));
|
|
60
|
+
raw = segs.length > 0 ? segs[segs.length - 1] : u.host;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
raw = appUrl;
|
|
64
|
+
}
|
|
65
|
+
const slug = raw.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
66
|
+
return slug.length > 0 ? slug : 'app';
|
|
67
|
+
}
|
|
68
|
+
/** Local-time `yyyyMMdd-HHmmss` stamp — one dir per smoke run, sorts naturally. */
|
|
69
|
+
function runStamp(now = new Date()) {
|
|
70
|
+
const p2 = (n) => String(n).padStart(2, '0');
|
|
71
|
+
return (`${now.getFullYear()}${p2(now.getMonth() + 1)}${p2(now.getDate())}` +
|
|
72
|
+
`-${p2(now.getHours())}${p2(now.getMinutes())}${p2(now.getSeconds())}`);
|
|
73
|
+
}
|
|
47
74
|
/** A not-run result: manual verification, a single explanatory skipped check. */
|
|
48
75
|
function skipped(detail) {
|
|
49
76
|
return { verification: 'manual', passed: false, checks: [{ name: 'skipped', ok: false, detail }] };
|
|
@@ -111,6 +138,31 @@ export async function runSmoke(spec, deps) {
|
|
|
111
138
|
catch {
|
|
112
139
|
return skipped(`preview server not reachable at ${spec.appUrl}; run npm run start first`);
|
|
113
140
|
}
|
|
141
|
+
// F-L2-10A evidence capture. The dir is created LAZILY on the first successful
|
|
142
|
+
// capture, so a skip path (or a driver with no screenshot capability) writes
|
|
143
|
+
// nothing at all. Every capture is try/catch-wrapped: a screenshot failure
|
|
144
|
+
// omits the entry and NEVER fails the smoke.
|
|
145
|
+
const screenshots = {};
|
|
146
|
+
const canShoot = typeof page.screenshot === 'function';
|
|
147
|
+
let shotDir;
|
|
148
|
+
const capture = async (key, file) => {
|
|
149
|
+
if (!page.screenshot)
|
|
150
|
+
return;
|
|
151
|
+
try {
|
|
152
|
+
shotDir ??=
|
|
153
|
+
deps.artifactDir ??
|
|
154
|
+
path.join(os.homedir(), '.cspeach', 'smoke', `${appNameFromUrl(spec.appUrl)}-${runStamp()}`);
|
|
155
|
+
mkdirSync(shotDir, { recursive: true });
|
|
156
|
+
const target = path.join(shotDir, file);
|
|
157
|
+
await page.screenshot(target);
|
|
158
|
+
screenshots[key] = target;
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
// Evidence is best-effort — a capture problem must never fail the smoke.
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
// Hoisted so the finally block can decide whether a failure shot is owed.
|
|
165
|
+
const checks = [];
|
|
114
166
|
try {
|
|
115
167
|
// Skip path 4 — SAP logon page (deployed BSP / unauthenticated preview).
|
|
116
168
|
// Detect via a logon-redirect URL OR the presence of a logon-form field, and
|
|
@@ -120,9 +172,10 @@ export async function runSmoke(spec, deps) {
|
|
|
120
172
|
if (LOGON_URL.test(url) || logonHits.length > 0) {
|
|
121
173
|
return skipped(`preview served a SAP logon page (not authenticated) at ${url}; run npm run start first`);
|
|
122
174
|
}
|
|
175
|
+
// The app is open past the logon check — this is the boot evidence shot.
|
|
176
|
+
await capture('boot', 'boot.png');
|
|
123
177
|
// Real render — assert content. boot + odata always; columns only when the
|
|
124
178
|
// spec asked for them; rows only for a rows-bearing (table) control.
|
|
125
|
-
const checks = [];
|
|
126
179
|
checks.push(checkBoot(page.consoleErrors()));
|
|
127
180
|
checks.push(checkOData(page.failedRequests()));
|
|
128
181
|
if (spec.expectedColumns && spec.expectedColumns.length > 0) {
|
|
@@ -141,9 +194,23 @@ export async function runSmoke(spec, deps) {
|
|
|
141
194
|
const results = await runExercises(page, spec.exercises);
|
|
142
195
|
checks.push(checkExercise(results));
|
|
143
196
|
}
|
|
144
|
-
|
|
197
|
+
// Post-exercise evidence: the app's final state after every declared
|
|
198
|
+
// interaction ran (and simply the settled app when the spec declares none).
|
|
199
|
+
await capture('postExercise', 'post-exercise.png');
|
|
200
|
+
// `screenshots` is the SAME object the finally block may still add the
|
|
201
|
+
// failure entry to, so a failing run carries it even though the value is
|
|
202
|
+
// built here. It rides along only when the driver can shoot at all.
|
|
203
|
+
return {
|
|
204
|
+
verification: 'smoke',
|
|
205
|
+
passed: checks.every((c) => c.ok),
|
|
206
|
+
checks,
|
|
207
|
+
...(canShoot ? { screenshots } : {}),
|
|
208
|
+
};
|
|
145
209
|
}
|
|
146
210
|
finally {
|
|
211
|
+
// A failing check earns a failure shot — captured BEFORE the page is closed.
|
|
212
|
+
if (checks.some((c) => !c.ok))
|
|
213
|
+
await capture('failure', 'failure.png');
|
|
147
214
|
await page.close();
|
|
148
215
|
}
|
|
149
216
|
}
|
|
@@ -595,8 +595,10 @@ registerTool({
|
|
|
595
595
|
'derived one. Derivation notes surface as a `warnings` array in the result. SKIPPABLE BUT NEVER ' +
|
|
596
596
|
'SILENTLY GREEN: when the render_smoke config is off, no browser is found, the preview is unreachable, ' +
|
|
597
597
|
'or the page is a SAP logon form, it returns verification:"manual" + passed:false with a "skipped" ' +
|
|
598
|
-
'check explaining why — it can never report a passing smoke without a real render.
|
|
599
|
-
'
|
|
598
|
+
'check explaining why — it can never report a passing smoke without a real render. On a real render it ' +
|
|
599
|
+
'also saves boot / post-exercise / failure screenshots under ~/.cspeach/smoke/ and returns their absolute ' +
|
|
600
|
+
'paths in a `screenshots` object (best-effort evidence — a capture failure never fails the smoke). ' +
|
|
601
|
+
'Flag-gated (enabled with local_build), read-only.',
|
|
600
602
|
isMutating: false,
|
|
601
603
|
category: 'fiori',
|
|
602
604
|
flagGated: true,
|
package/dist/tools/index.js
CHANGED
|
@@ -20,6 +20,31 @@ export function listTools() {
|
|
|
20
20
|
// always returned, preserving today's listTools() semantics exactly.
|
|
21
21
|
return Array.from(registry.values()).filter((t) => !t.flagGated || isToolFlagOn(t.name));
|
|
22
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* Reserved alias for standalone mode — CSPeach running with no SAP system.
|
|
25
|
+
* Set at the single ctx-construction site in repl.tsx / one-shot.ts alongside
|
|
26
|
+
* the offline AdtClient stand-in (sap/offline-adt-client.ts).
|
|
27
|
+
*/
|
|
28
|
+
export const STANDALONE_ALIAS = 'none';
|
|
29
|
+
/** True when this context has no SAP system behind it. */
|
|
30
|
+
export function isStandalone(ctx) {
|
|
31
|
+
return ctx.sapAlias === STANDALONE_ALIAS;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Narrow a tool list to what the given context can actually run: in standalone
|
|
35
|
+
* mode every `requiresSap` tool is hidden from the LLM, because calling one
|
|
36
|
+
* could only ever fail.
|
|
37
|
+
*
|
|
38
|
+
* Deliberately NOT folded into `listTools()` — other callers (the local_build
|
|
39
|
+
* startup hook, tool-count diagnostics, the doctor) must keep seeing the whole
|
|
40
|
+
* registry. When not standalone this returns the SAME array reference it was
|
|
41
|
+
* given, so the connected path does no work and stays byte-identical.
|
|
42
|
+
*/
|
|
43
|
+
export function toolsForContext(tools, ctx) {
|
|
44
|
+
if (!isStandalone(ctx))
|
|
45
|
+
return tools;
|
|
46
|
+
return tools.filter((t) => !t.requiresSap);
|
|
47
|
+
}
|
|
23
48
|
export function getToolsByCategory(category) {
|
|
24
49
|
return listTools().filter((t) => t.category === category);
|
|
25
50
|
}
|
package/dist/tools/sap-read.js
CHANGED
|
@@ -36,6 +36,7 @@ registerTool({
|
|
|
36
36
|
+ 'created object with NO active version yet, the inactive (working) '
|
|
37
37
|
+ 'source is returned with version="inactive" and an honest note.',
|
|
38
38
|
isMutating: false,
|
|
39
|
+
requiresSap: true,
|
|
39
40
|
input_schema: {
|
|
40
41
|
type: 'object',
|
|
41
42
|
properties: {
|
|
@@ -130,6 +131,7 @@ registerTool({
|
|
|
130
131
|
name: 'sap_object_structure',
|
|
131
132
|
description: 'Retrieve metadata and structural information for an ABAP repository object (package, description, lock state, etc.).',
|
|
132
133
|
isMutating: false,
|
|
134
|
+
requiresSap: true,
|
|
133
135
|
input_schema: {
|
|
134
136
|
type: 'object',
|
|
135
137
|
properties: {
|
|
@@ -165,6 +167,7 @@ registerTool({
|
|
|
165
167
|
name: 'sap_search_object',
|
|
166
168
|
description: 'Search the SAP repository by object name pattern. Supports wildcard * (e.g. ZCL_ORDER*).',
|
|
167
169
|
isMutating: false,
|
|
170
|
+
requiresSap: true,
|
|
168
171
|
input_schema: {
|
|
169
172
|
type: 'object',
|
|
170
173
|
properties: {
|
|
@@ -192,6 +195,7 @@ registerTool({
|
|
|
192
195
|
name: 'sap_change_log',
|
|
193
196
|
description: 'Retrieve the version history (change log) of an ABAP object — date, author, description per revision.',
|
|
194
197
|
isMutating: false,
|
|
198
|
+
requiresSap: true,
|
|
195
199
|
input_schema: {
|
|
196
200
|
type: 'object',
|
|
197
201
|
properties: {
|
|
@@ -214,6 +218,7 @@ registerTool({
|
|
|
214
218
|
+ 'Dialect limits (violations return an opaque "SAP HTTP 400: sqlQuery"): no IN-lists and at most one LIKE per WHERE clause '
|
|
215
219
|
+ '(multi-OR LIKE chains are rejected) — use a single LIKE or split into separate queries.',
|
|
216
220
|
isMutating: false,
|
|
221
|
+
requiresSap: true,
|
|
217
222
|
input_schema: {
|
|
218
223
|
type: 'object',
|
|
219
224
|
properties: {
|
|
@@ -237,6 +242,7 @@ registerTool({
|
|
|
237
242
|
+ 'result includes a `coverage` line stating exactly which window was fetched — trust it '
|
|
238
243
|
+ 'over assumptions. With dumpId returns the full diagnostic detail.',
|
|
239
244
|
isMutating: false,
|
|
245
|
+
requiresSap: true,
|
|
240
246
|
input_schema: {
|
|
241
247
|
type: 'object',
|
|
242
248
|
properties: {
|
|
@@ -293,6 +299,7 @@ registerTool({
|
|
|
293
299
|
name: 'sap_atc_run',
|
|
294
300
|
description: 'Run ABAP Test Cockpit (ATC) checks on a specific object and return all findings.',
|
|
295
301
|
isMutating: false,
|
|
302
|
+
requiresSap: true,
|
|
296
303
|
input_schema: {
|
|
297
304
|
type: 'object',
|
|
298
305
|
properties: {
|
|
@@ -318,6 +325,7 @@ registerTool({
|
|
|
318
325
|
name: 'sap_usage_references',
|
|
319
326
|
description: 'Find all places where a given ABAP object is referenced (where-used list).',
|
|
320
327
|
isMutating: false,
|
|
328
|
+
requiresSap: true,
|
|
321
329
|
input_schema: {
|
|
322
330
|
type: 'object',
|
|
323
331
|
properties: {
|
|
@@ -339,6 +347,7 @@ registerTool({
|
|
|
339
347
|
name: 'sap_api_state',
|
|
340
348
|
description: 'Check the ABAP Cloud / clean-core release state of SAP objects. Pass comma-separated TYPE:NAME pairs, e.g. "CLAS:CL_GUI_ALV_GRID,FUNC:BAPI_SALESORDER_GETLIST".',
|
|
341
349
|
isMutating: false,
|
|
350
|
+
requiresSap: true,
|
|
342
351
|
input_schema: {
|
|
343
352
|
type: 'object',
|
|
344
353
|
properties: {
|
|
@@ -362,6 +371,7 @@ registerTool({
|
|
|
362
371
|
name: 'sap_abap_docu',
|
|
363
372
|
description: 'Retrieve official ABAP language documentation from the SAP system for a keyword, class, or function module.',
|
|
364
373
|
isMutating: false,
|
|
374
|
+
requiresSap: true,
|
|
365
375
|
input_schema: {
|
|
366
376
|
type: 'object',
|
|
367
377
|
properties: {
|
|
@@ -384,6 +394,7 @@ registerTool({
|
|
|
384
394
|
name: 'sap_class_includes',
|
|
385
395
|
description: 'List the includes of an ABAP class, or read one specific include source (testclasses, locals_def, locals_imp, macros).',
|
|
386
396
|
isMutating: false,
|
|
397
|
+
requiresSap: true,
|
|
387
398
|
input_schema: {
|
|
388
399
|
type: 'object',
|
|
389
400
|
properties: {
|
|
@@ -415,6 +426,7 @@ registerTool({
|
|
|
415
426
|
name: 'sap_inactive_objects',
|
|
416
427
|
description: 'List all ABAP objects that are currently inactive (not yet activated) in the system.',
|
|
417
428
|
isMutating: false,
|
|
429
|
+
requiresSap: true,
|
|
418
430
|
input_schema: {
|
|
419
431
|
type: 'object',
|
|
420
432
|
properties: {},
|
|
@@ -432,6 +444,7 @@ registerTool({
|
|
|
432
444
|
name: 'sap_syntax_check',
|
|
433
445
|
description: 'Run a syntax check on an ABAP object and return any errors or warnings without activating it.',
|
|
434
446
|
isMutating: false,
|
|
447
|
+
requiresSap: true,
|
|
435
448
|
input_schema: {
|
|
436
449
|
type: 'object',
|
|
437
450
|
properties: {
|
|
@@ -453,6 +466,7 @@ registerTool({
|
|
|
453
466
|
name: 'sap_diff',
|
|
454
467
|
description: 'Compute a unified diff for an ABAP object between two optional source strings; if omitted, diffs the current version against the previous from the change log.',
|
|
455
468
|
isMutating: false,
|
|
469
|
+
requiresSap: true,
|
|
456
470
|
input_schema: {
|
|
457
471
|
type: 'object',
|
|
458
472
|
properties: {
|
|
@@ -482,6 +496,7 @@ registerTool({
|
|
|
482
496
|
name: 'sap_preview_change',
|
|
483
497
|
description: 'Preview a proposed source change for an ABAP object as a unified diff without writing anything to the system.',
|
|
484
498
|
isMutating: false,
|
|
499
|
+
requiresSap: true,
|
|
485
500
|
input_schema: {
|
|
486
501
|
type: 'object',
|
|
487
502
|
properties: {
|
|
@@ -507,6 +522,7 @@ registerTool({
|
|
|
507
522
|
name: 'sap_run_unit_test',
|
|
508
523
|
description: 'Execute ABAP Unit tests for an object and return a structured pass/fail report with method-level results.',
|
|
509
524
|
isMutating: false,
|
|
525
|
+
requiresSap: true,
|
|
510
526
|
input_schema: {
|
|
511
527
|
type: 'object',
|
|
512
528
|
properties: {
|
|
@@ -553,6 +569,7 @@ registerTool({
|
|
|
553
569
|
name: 'sap_odata_test',
|
|
554
570
|
description: 'Test an OData service endpoint (V2 or V4). Fetches $metadata or reads an entity set and returns the real HTTP status, the request URL/Accept actually used, and the response body. Works on draft-enabled OData V4 RAP services: V4 is auto-detected from /odata4/ paths, $metadata gets an XML Accept, and data reads use a JSON Accept without the V2-only $format option. System query options ($metadata, $top, $filter, $select, $expand, $count) are preserved literally.',
|
|
555
571
|
isMutating: false,
|
|
572
|
+
requiresSap: true,
|
|
556
573
|
input_schema: {
|
|
557
574
|
type: 'object',
|
|
558
575
|
properties: {
|
|
@@ -593,6 +610,7 @@ registerTool({
|
|
|
593
610
|
name: 'sap_badi_list',
|
|
594
611
|
description: 'Search for BAdI definitions and enhancement spots by name pattern (supports * wildcard), with optional enhancement-spot drill-down.',
|
|
595
612
|
isMutating: false,
|
|
613
|
+
requiresSap: true,
|
|
596
614
|
input_schema: {
|
|
597
615
|
type: 'object',
|
|
598
616
|
properties: {
|