@cspeach/cli 1.1.22 → 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.
- package/dist/agent/loop.js +124 -0
- package/dist/agent/tool-dispatch.js +4 -2
- package/dist/classifier/client.js +27 -2
- package/dist/cli.js +13 -0
- package/dist/commands/plan-audit-evidence.js +61 -4
- package/dist/commands/plan-audit.js +13 -1
- package/dist/commands/plan-continue.js +11 -1
- package/dist/commands/plan-resume.js +38 -1
- package/dist/commands/sap-link.js +52 -0
- package/dist/lane/app-chain.js +297 -0
- package/dist/lane/app-flow.js +55 -0
- package/dist/lane/card.js +148 -0
- package/dist/lane/chain-token.js +59 -0
- package/dist/lane/inputs.js +459 -0
- package/dist/lane/paths.js +21 -0
- package/dist/lane/resolve.js +109 -0
- package/dist/lane/roots.js +113 -0
- package/dist/lane/signals.js +80 -0
- package/dist/lane/store.js +232 -0
- package/dist/lane/types.js +1 -0
- package/dist/lib/shutdown-hooks.js +87 -0
- package/dist/one-shot.js +5 -0
- package/dist/projects/extract-plan.js +46 -6
- package/dist/projects/plan-lane.js +214 -0
- package/dist/projects/plan-run.js +5 -1
- package/dist/projects/plan-schema.js +12 -1
- package/dist/projects/promote-command.js +8 -2
- package/dist/projects/save-command.js +71 -25
- package/dist/projects/workspace.js +10 -0
- package/dist/renderer/tool-labels.js +6 -1
- package/dist/repl/credential-handover-gate.js +12 -6
- package/dist/repl/resume-standalone.js +3 -0
- package/dist/repl.js +27 -2
- package/dist/sap/connection-manager.js +30 -24
- package/dist/sap-link/policy.js +185 -0
- package/dist/sap-link/project.js +163 -0
- package/dist/sap-link/registry.js +259 -0
- package/dist/sap-link/server.js +351 -0
- package/dist/sap-link/upstream.js +367 -0
- package/dist/sap-link/write-log.js +60 -0
- package/dist/skill-catalog.js +14 -6
- package/dist/skills/bundled-skills.js +61 -54
- package/dist/tools/cap/_fetch-errors.js +20 -0
- package/dist/tools/cap/link-smoke.js +703 -0
- package/dist/tools/cap/metadata-import.js +325 -0
- package/dist/tools/cap/sap-link-tool.js +133 -0
- package/dist/tools/cap/service-catalog.js +248 -0
- package/dist/tools/fiori/preview/registry.js +30 -24
- package/dist/tools/lane/app-lane-resolve.js +329 -0
- package/dist/tools/local-build.js +15 -0
- package/dist/tools/subagent/_background-shared.js +59 -37
- package/dist/tools/subagent/background_run.js +9 -5
- package/dist/tools/tool-loading.js +2 -0
- package/package.json +121 -121
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// App lane routing slice 1, Task 7 — the harness-owned /abap-app chain.
|
|
2
|
+
//
|
|
3
|
+
// stage 1 /abap-app @<spec> → runs as abap-spec-gap
|
|
4
|
+
// stage 2 /abap-app --from @<specgap> → lane card (app_lane_resolve, Task 5)
|
|
5
|
+
// stage 3 /abap-plan --from @<specgap> → plan with the lane block (Task 6)
|
|
6
|
+
// start Yes → /abap-plan --resume @<saved plan> --guarded
|
|
7
|
+
//
|
|
8
|
+
// Every command queued here is built by CLI code from a path the CLI itself
|
|
9
|
+
// produced (the saved spec-gap / plan envelope). Model text never reaches a
|
|
10
|
+
// queued command. Headless (M2, what is true): no card is ever shown and no
|
|
11
|
+
// build starts — the start command is printed, never queued. The lane tool
|
|
12
|
+
// (app_lane_resolve) still queues the plan step (/abap-plan --from …) when
|
|
13
|
+
// the rules decide the lane; after a headless spec-gap save the CLI says to
|
|
14
|
+
// run /abap-app in an interactive session (no flow survives the process).
|
|
15
|
+
import { promises as fs, statSync } from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { readProjectFile } from '../projects/status.js';
|
|
18
|
+
import { parsePlanContent } from '../projects/plan-schema.js';
|
|
19
|
+
import { buildResumeCommand } from '../commands/plan-resume.js';
|
|
20
|
+
import { canonicalLanePath } from './paths.js';
|
|
21
|
+
import { cardLine, interactiveRunLine, shownToken } from './card.js';
|
|
22
|
+
import { clearAppFlow, getAppFlow, setSpecGap } from './app-flow.js';
|
|
23
|
+
import { chainFromToken } from './chain-token.js';
|
|
24
|
+
import { containSpecPath, specDisplayPath, SpecProtectedError } from './roots.js';
|
|
25
|
+
import { resolveAtTokenAsync } from '../projects/workspace.js';
|
|
26
|
+
import { parseFromFlag } from '../skills/promotion-dispatch.js';
|
|
27
|
+
export const STEP_1 = 'Step 1 of 3: finding the gaps in the spec.';
|
|
28
|
+
export const STEP_2 = 'Step 2 of 3: choosing the build lane.';
|
|
29
|
+
export const STEP_3 = 'Step 3 of 3: planning the build.';
|
|
30
|
+
export const START_QUESTION = 'Start building now? It runs every phase and stops only for your answers, SAP approvals or a failed check.';
|
|
31
|
+
/** Trailing characters a sentence puts after a token: `see @spec.md.` / `(@spec.md)`. */
|
|
32
|
+
const TRAILING_PUNCT = /[.,;:!?)\]}>]+$/;
|
|
33
|
+
/**
|
|
34
|
+
* The first `@<token>` of a /abap-app message, as typed. `@"a b.docx"` and
|
|
35
|
+
* `@'a b.docx'` keep their inner spaces. Undefined when there is none.
|
|
36
|
+
*/
|
|
37
|
+
export function extractSpecToken(message) {
|
|
38
|
+
const m = /(?:^|\s)@(?:"([^"\r\n]+)"|'([^'\r\n]+)'|(\S+))/.exec(message);
|
|
39
|
+
if (!m)
|
|
40
|
+
return undefined;
|
|
41
|
+
return m[1] ?? m[2] ?? m[3];
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Clean a spec token: no leading `@`, no surrounding quotes, no trailing
|
|
45
|
+
* sentence punctuation (a file name never ends in `.`, `,` or `)`).
|
|
46
|
+
*/
|
|
47
|
+
export function cleanSpecToken(raw) {
|
|
48
|
+
let t = raw.trim();
|
|
49
|
+
// Punctuation can sit outside the quotes (`@"a b.md".`), so strip, unquote, strip.
|
|
50
|
+
for (let i = 0; i < 2; i++) {
|
|
51
|
+
t = t.replace(/^@/, '').replace(TRAILING_PUNCT, '');
|
|
52
|
+
const q = /^(["'])([\s\S]*)\1$/.exec(t);
|
|
53
|
+
if (q)
|
|
54
|
+
t = q[2];
|
|
55
|
+
t = t.trim();
|
|
56
|
+
}
|
|
57
|
+
return t;
|
|
58
|
+
}
|
|
59
|
+
const NOT_STARTED = 'The app flow did not start.';
|
|
60
|
+
/**
|
|
61
|
+
* Resolve the /abap-app spec token to a stable path (forward slashes):
|
|
62
|
+
* cwd-relative inside cwd, absolute for a spec in the workspace folder outside
|
|
63
|
+
* cwd. The token is looked up with resolveAtTokenAsync — the same lookup the
|
|
64
|
+
* @-attachment expansion uses — so the pinned spec and the analysed spec are
|
|
65
|
+
* the same file. Refused, with one plain line, when the message names no
|
|
66
|
+
* file, the name is ambiguous, the file leaves cwd and the workspace folder
|
|
67
|
+
* (also through a link), sits in a protected place, or does not exist. The
|
|
68
|
+
* stored value is what app_lane_resolve's `sameFile` compares, and that check
|
|
69
|
+
* (like readSpecText) uses the same containment (lane/roots.ts).
|
|
70
|
+
*/
|
|
71
|
+
export async function resolveAppSpec(cwd, message) {
|
|
72
|
+
const raw = extractSpecToken(message);
|
|
73
|
+
const tok = raw === undefined ? '' : cleanSpecToken(raw);
|
|
74
|
+
// M3 — the plain example, then the usual 'did not start' sentence.
|
|
75
|
+
if (tok === '')
|
|
76
|
+
return { ok: false, line: `Name the spec file with @, for example: /abap-app @specs/my-app.docx\n${NOT_STARTED}` };
|
|
77
|
+
const shown = cardLine(tok);
|
|
78
|
+
const outside = { ok: false, line: `The spec file must be inside this folder or the workspace folder: ${shown}. ${NOT_STARTED}` };
|
|
79
|
+
const missing = { ok: false, line: `Spec file not found: ${shown}. ${NOT_STARTED}` };
|
|
80
|
+
if (tok.includes('\0'))
|
|
81
|
+
return outside;
|
|
82
|
+
let found;
|
|
83
|
+
try {
|
|
84
|
+
const r = await resolveAtTokenAsync(tok, cwd);
|
|
85
|
+
if (r.kind === 'notFound')
|
|
86
|
+
return missing;
|
|
87
|
+
if (r.kind === 'ambiguous') {
|
|
88
|
+
return { ok: false, line: `More than one file matches ${shown}. Use the full name. ${NOT_STARTED}` };
|
|
89
|
+
}
|
|
90
|
+
found = r.path;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return missing;
|
|
94
|
+
}
|
|
95
|
+
// I3 — inside cwd OR the workspace folder, string + realpath contained,
|
|
96
|
+
// denylist kept (the same rule app_lane_resolve applies to the spec).
|
|
97
|
+
let abs;
|
|
98
|
+
let real;
|
|
99
|
+
try {
|
|
100
|
+
({ abs, real } = await containSpecPath(cwd, path.resolve(cwd, found)));
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
if (e instanceof SpecProtectedError) {
|
|
104
|
+
return { ok: false, line: `The spec file is in a protected folder and cannot be read: ${shown}. ${NOT_STARTED}` };
|
|
105
|
+
}
|
|
106
|
+
return e?.code === 'ENOENT' ? missing : outside;
|
|
107
|
+
}
|
|
108
|
+
let isFile = false;
|
|
109
|
+
try {
|
|
110
|
+
isFile = statSync(real).isFile();
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
isFile = false;
|
|
114
|
+
}
|
|
115
|
+
if (!isFile)
|
|
116
|
+
return missing;
|
|
117
|
+
return { ok: true, rel: specDisplayPath(cwd, abs) };
|
|
118
|
+
}
|
|
119
|
+
/** The skill that runs each chain step. */
|
|
120
|
+
const STEP_SKILL = { 'spec-gap': 'abap-spec-gap', lane: 'abap-app', plan: 'abap-plan' };
|
|
121
|
+
const norm = (s) => s.trim().replace(/\s+/g, ' ');
|
|
122
|
+
/** Shown once when a turn ends a flow that already has a spec-gap file or a lane (interactive only). */
|
|
123
|
+
export const FLOW_ENDED_LINE = 'The /abap-app run ended here; this plan will not use the build lane. Run /abap-app again to use it.';
|
|
124
|
+
/** Real path, case-folded on Windows. Undefined when the file cannot be resolved. */
|
|
125
|
+
async function realKey(p) {
|
|
126
|
+
try {
|
|
127
|
+
const r = path.resolve(await fs.realpath(p));
|
|
128
|
+
return process.platform === 'win32' ? r.toLowerCase() : r;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/** True when both paths name the same existing real file (realpath compare, never strings). */
|
|
135
|
+
export async function sameRealFile(a, b) {
|
|
136
|
+
const [x, y] = await Promise.all([realKey(a), realKey(b)]);
|
|
137
|
+
return x !== undefined && x === y;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* I4(b) — true when a typed `--from @X` resolves (same lookup as the promote
|
|
141
|
+
* flow) to the flow's own spec-gap file. Paths are compared after realpath,
|
|
142
|
+
* never as strings.
|
|
143
|
+
*/
|
|
144
|
+
async function fromNamesFlowGap(cwd, userMessage, specGapPath) {
|
|
145
|
+
if (!specGapPath)
|
|
146
|
+
return false;
|
|
147
|
+
const { fromPath } = parseFromFlag(userMessage);
|
|
148
|
+
if (!fromPath)
|
|
149
|
+
return false;
|
|
150
|
+
let found;
|
|
151
|
+
try {
|
|
152
|
+
const r = await resolveAtTokenAsync(fromPath, cwd);
|
|
153
|
+
if (r.kind !== 'path')
|
|
154
|
+
return false;
|
|
155
|
+
found = path.resolve(cwd, r.path);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
const [a, b] = await Promise.all([realKey(found), realKey(specGapPath)]);
|
|
161
|
+
return a !== undefined && a === b;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* R11 — runs at the top of every top-level turn except the stage-1 turn (which
|
|
165
|
+
* restarts the flow). The flow survives only a turn that is part of its
|
|
166
|
+
* current step:
|
|
167
|
+
* - the exact command the CLI queued (or printed) for this step; or
|
|
168
|
+
* - I4(b): the step's own `--from` command typed by hand, when its `@X`
|
|
169
|
+
* resolves (realpath) to the flow's spec-gap file —
|
|
170
|
+
* `/abap-app --from @X` at the lane step, `/abap-plan --from @X` at the
|
|
171
|
+
* plan step; or
|
|
172
|
+
* - a plain reply (not a slash command) while the session is already in the
|
|
173
|
+
* step's skill and the reply is routed to it.
|
|
174
|
+
* Any other turn — another slash command, a reply routed elsewhere — ends it.
|
|
175
|
+
*/
|
|
176
|
+
export async function gateAppFlowForTurn(session, t) {
|
|
177
|
+
const flow = getAppFlow(session);
|
|
178
|
+
if (!flow)
|
|
179
|
+
return { ended: false, hadState: false };
|
|
180
|
+
const stepSkill = STEP_SKILL[flow.expect];
|
|
181
|
+
if (t.explicitSlash) {
|
|
182
|
+
if (flow.queued !== undefined && norm(`/${t.skill} ${t.userMessage}`) === norm(flow.queued)) {
|
|
183
|
+
return { ended: false, hadState: false };
|
|
184
|
+
}
|
|
185
|
+
if ((flow.expect === 'lane' || flow.expect === 'plan') && t.skill === stepSkill
|
|
186
|
+
&& await fromNamesFlowGap(t.cwd, t.userMessage, flow.specGapPath)) {
|
|
187
|
+
return { ended: false, hadState: false };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
else if (t.skill === stepSkill && t.previousSkill === stepSkill) {
|
|
191
|
+
return { ended: false, hadState: false };
|
|
192
|
+
}
|
|
193
|
+
clearAppFlow(session);
|
|
194
|
+
return { ended: true, hadState: flow.specGapPath !== undefined || flow.lane !== undefined };
|
|
195
|
+
}
|
|
196
|
+
/** The plan start command, built from the exact saved path (the offerNextPhaseAutoRun rule). */
|
|
197
|
+
export function startCommand(savedPlanPath) {
|
|
198
|
+
if (!/^[A-Za-z0-9._-]+$/.test(path.basename(savedPlanPath)))
|
|
199
|
+
return undefined;
|
|
200
|
+
return `${buildResumeCommand(savedPlanPath)} --guarded`;
|
|
201
|
+
}
|
|
202
|
+
/** The saved plan's `from.lane`, or undefined (not a plan, unreadable, no lane). */
|
|
203
|
+
export function savedPlanLane(savedPath) {
|
|
204
|
+
try {
|
|
205
|
+
const env = readProjectFile(savedPath);
|
|
206
|
+
if (env.artefactType !== 'plan')
|
|
207
|
+
return undefined;
|
|
208
|
+
const pc = parsePlanContent(env.content);
|
|
209
|
+
return pc.ok ? pc.content.from.lane : undefined;
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* After a spec-gap save inside an app flow that is at its spec-gap step:
|
|
217
|
+
* record the saved path and queue stage 2. Runs once per flow (R11): a later
|
|
218
|
+
* spec-gap save, or no app flow, does nothing (no line, no queue).
|
|
219
|
+
*/
|
|
220
|
+
export async function afterSpecGapSave(h, savedPath) {
|
|
221
|
+
const flow = getAppFlow(h.session);
|
|
222
|
+
if (!flow || flow.expect !== 'spec-gap' || flow.specGapPath !== undefined)
|
|
223
|
+
return;
|
|
224
|
+
const t = await chainFromToken(h.cwd, savedPath);
|
|
225
|
+
if (h.headless) {
|
|
226
|
+
// I4(c) — no flow survives a headless / one-shot run: point at the
|
|
227
|
+
// interactive chain instead of a command that would run without its flow.
|
|
228
|
+
if (t)
|
|
229
|
+
setSpecGap(h.session, savedPath, `/abap-app --from ${t.token}`);
|
|
230
|
+
else
|
|
231
|
+
clearAppFlow(h.session);
|
|
232
|
+
h.emit(interactiveRunLine(flow.specToken));
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (!t) {
|
|
236
|
+
// I4(b)/(c) — the flow stays at the lane step with nothing queued: the
|
|
237
|
+
// user's own `/abap-app --from @<file>` keeps it (the gate compares real paths).
|
|
238
|
+
setSpecGap(h.session, savedPath);
|
|
239
|
+
h.emit(`The saved spec-gap file cannot be queued as a command. To go on, run: /abap-app --from ${shownToken(path.basename(savedPath), 'spec-gap file')}`);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const cmd = `/abap-app --from ${t.token}`;
|
|
243
|
+
setSpecGap(h.session, savedPath, cmd);
|
|
244
|
+
if (!h.queueNext) {
|
|
245
|
+
h.emit(`Next: ${cmd}`);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
h.queueNext(cmd);
|
|
249
|
+
h.emit(STEP_2);
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* After a plan save: when the saved plan carries `from.lane`, offer to start
|
|
253
|
+
* the guarded run. Inside an active app flow the flow's lane must be the
|
|
254
|
+
* plan's lane. A plan without `from.lane` → nothing.
|
|
255
|
+
*/
|
|
256
|
+
export async function afterPlanSave(h, savedPath) {
|
|
257
|
+
const lane = savedPlanLane(savedPath);
|
|
258
|
+
if (!lane)
|
|
259
|
+
return;
|
|
260
|
+
const flow = getAppFlow(h.session);
|
|
261
|
+
if (flow && (flow.expect !== 'plan' || !flow.lane
|
|
262
|
+
|| canonicalLanePath(flow.lane.path) !== canonicalLanePath(lane.path) || flow.lane.sha256 !== lane.sha256)) {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const end = () => { if (flow)
|
|
266
|
+
clearAppFlow(h.session); };
|
|
267
|
+
const cmd = startCommand(savedPath);
|
|
268
|
+
if (!cmd) {
|
|
269
|
+
h.emit('The plan file name cannot be used in a command. Run /abap-plan --resume @<plan file> --guarded yourself.');
|
|
270
|
+
end();
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (h.headless || !h.queueNext) {
|
|
274
|
+
h.emit(`Start building with: ${cmd}`);
|
|
275
|
+
end();
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const confirm = h.confirm ?? (async (req) => {
|
|
279
|
+
const { askConfirm } = await import('../ui/confirm-request.js');
|
|
280
|
+
return askConfirm(req);
|
|
281
|
+
});
|
|
282
|
+
const res = await confirm({
|
|
283
|
+
id: 'app-start-build',
|
|
284
|
+
tag: 'QUESTION',
|
|
285
|
+
title: START_QUESTION,
|
|
286
|
+
choices: [{ value: 'yes', label: 'Yes' }, { value: 'no', label: 'No' }],
|
|
287
|
+
initial: 0,
|
|
288
|
+
aliases: { y: 'yes', n: 'no' },
|
|
289
|
+
});
|
|
290
|
+
if (res.value === 'yes' && !res.cancelled) {
|
|
291
|
+
h.queueNext(cmd);
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
h.emit(`Start it later with: ${cmd}`);
|
|
295
|
+
}
|
|
296
|
+
end();
|
|
297
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const flows = new WeakMap();
|
|
2
|
+
/** Start (or restart) the flow for this session. A restart drops earlier state. */
|
|
3
|
+
export function startAppFlow(session, specToken) {
|
|
4
|
+
flows.set(session, { specToken, expect: 'spec-gap' });
|
|
5
|
+
}
|
|
6
|
+
/** A frozen copy: callers change state only through the setters below. */
|
|
7
|
+
export function getAppFlow(session) {
|
|
8
|
+
const f = flows.get(session);
|
|
9
|
+
if (!f)
|
|
10
|
+
return undefined;
|
|
11
|
+
const copy = { specToken: f.specToken, expect: f.expect };
|
|
12
|
+
if (f.specGapPath !== undefined)
|
|
13
|
+
copy.specGapPath = f.specGapPath;
|
|
14
|
+
if (f.lane)
|
|
15
|
+
copy.lane = Object.freeze({ ...f.lane });
|
|
16
|
+
if (f.queued !== undefined)
|
|
17
|
+
copy.queued = f.queued;
|
|
18
|
+
if (f.gapConfirmed !== undefined)
|
|
19
|
+
copy.gapConfirmed = f.gapConfirmed;
|
|
20
|
+
return Object.freeze(copy);
|
|
21
|
+
}
|
|
22
|
+
/** The spec-gap is saved: the lane step is next. No-op when no flow was started. */
|
|
23
|
+
export function setSpecGap(session, path, queued) {
|
|
24
|
+
const f = flows.get(session);
|
|
25
|
+
if (!f)
|
|
26
|
+
return;
|
|
27
|
+
f.specGapPath = path;
|
|
28
|
+
f.expect = 'lane';
|
|
29
|
+
if (queued !== undefined)
|
|
30
|
+
f.queued = queued;
|
|
31
|
+
else
|
|
32
|
+
delete f.queued;
|
|
33
|
+
}
|
|
34
|
+
/** The lane is decided: the plan step is next. No-op when no flow was started. */
|
|
35
|
+
export function setLane(session, path, sha256, queued) {
|
|
36
|
+
const f = flows.get(session);
|
|
37
|
+
if (!f)
|
|
38
|
+
return;
|
|
39
|
+
f.lane = { path, sha256 };
|
|
40
|
+
f.expect = 'plan';
|
|
41
|
+
if (queued !== undefined)
|
|
42
|
+
f.queued = queued;
|
|
43
|
+
else
|
|
44
|
+
delete f.queued;
|
|
45
|
+
}
|
|
46
|
+
/** M4 — record that the open-items question was confirmed for this spec-gap file. */
|
|
47
|
+
export function setGapConfirmed(session, path) {
|
|
48
|
+
const f = flows.get(session);
|
|
49
|
+
if (!f)
|
|
50
|
+
return;
|
|
51
|
+
f.gapConfirmed = path;
|
|
52
|
+
}
|
|
53
|
+
export function clearAppFlow(session) {
|
|
54
|
+
flows.delete(session);
|
|
55
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { DEFAULT_RAP_REASON, READ_ONLY_ANALYTICAL_REASON, UNVERIFIED_REASON } from './resolve.js';
|
|
2
|
+
import { SIGNAL_KEYS } from './signals.js';
|
|
3
|
+
const MAX_LINE = 300;
|
|
4
|
+
/**
|
|
5
|
+
* Control characters (C0, DEL, C1) and bidi / invisible format characters
|
|
6
|
+
* (soft hyphen, CGJ, ALM, Mongolian vowel separator, zero-width, LRM/RLM, line/para
|
|
7
|
+
* separators, embeddings/overrides, isolates, BOM, interlinear annotation, tag characters).
|
|
8
|
+
*/
|
|
9
|
+
// eslint-disable-next-line no-control-regex
|
|
10
|
+
const UNSAFE = /[\u{0000}-\u{001F}\u{007F}-\u{009F}\u{00AD}\u{034F}\u{061C}\u{180E}\u{200B}-\u{200F}\u{2028}-\u{202E}\u{2060}-\u{2069}\u{FEFF}\u{FFF9}-\u{FFFB}\u{E0000}-\u{E007F}]/gu;
|
|
11
|
+
/** Strip unsafe characters and cap the line at 300 characters (code points). */
|
|
12
|
+
export function cardLine(text) {
|
|
13
|
+
const clean = text.replace(UNSAFE, '');
|
|
14
|
+
const chars = Array.from(clean);
|
|
15
|
+
return chars.length <= MAX_LINE ? clean : `${chars.slice(0, MAX_LINE - 1).join('')}\u2026`;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* `@<path>` for a plain line the user reads (never a queued command): quoted
|
|
19
|
+
* when it has whitespace, `<placeholder>` when it cannot be shown as a token.
|
|
20
|
+
*/
|
|
21
|
+
export function shownToken(p, placeholder) {
|
|
22
|
+
const t = p === undefined ? '' : cardLine(p).trim();
|
|
23
|
+
if (t === '' || t.includes('"'))
|
|
24
|
+
return `@<${placeholder}>`;
|
|
25
|
+
return /\s/.test(t) ? `@"${t}"` : `@${t}`;
|
|
26
|
+
}
|
|
27
|
+
/** I4(c) — headless / one-shot: no app flow survives the process, so the chain must be run interactively. */
|
|
28
|
+
export function interactiveRunLine(specToken) {
|
|
29
|
+
return `Run /abap-app ${shownToken(specToken, 'spec file')} in an interactive cspeach session to build with the lane card.`;
|
|
30
|
+
}
|
|
31
|
+
const BACKEND_LABEL = {
|
|
32
|
+
rap: 'on-stack RAP',
|
|
33
|
+
cap: 'CAP (side-by-side)',
|
|
34
|
+
none: 'UI only',
|
|
35
|
+
'abap-report': 'classic report',
|
|
36
|
+
segw: 'SEGW',
|
|
37
|
+
'bopf-generated': 'BOPF-generated',
|
|
38
|
+
};
|
|
39
|
+
const UI_LABEL = {
|
|
40
|
+
'fiori-elements': 'Fiori Elements',
|
|
41
|
+
freestyle: 'freestyle UI5',
|
|
42
|
+
none: '',
|
|
43
|
+
};
|
|
44
|
+
const HINT_WORDS = {
|
|
45
|
+
cap_external_users: 'users outside SAP',
|
|
46
|
+
cap_multi_system: 'data from several systems',
|
|
47
|
+
cap_own_release_cycle: 'its own release cycle',
|
|
48
|
+
cap_ui_on_released_api: 'mostly UI and logic on a released SAP API',
|
|
49
|
+
};
|
|
50
|
+
const hintWords = (k) => HINT_WORDS[k] ?? k.replace(/_/g, ' ');
|
|
51
|
+
const SIGNAL_WORDS = {
|
|
52
|
+
...HINT_WORDS,
|
|
53
|
+
app_requested: 'an app is asked for',
|
|
54
|
+
ui_only_service: 'a UI on a published service',
|
|
55
|
+
report_only: 'list output only',
|
|
56
|
+
analytical: 'charts or KPIs',
|
|
57
|
+
transactional: 'create, change or delete',
|
|
58
|
+
custom_ui: 'a custom UI',
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* One card line per claimed signal (truthy value) with no evidence quote, or
|
|
62
|
+
* whose quote was not found word for word in the spec: it was downgraded to
|
|
63
|
+
* weak (spec §8, Review Focus 1). Pure; lines are sanitised and capped.
|
|
64
|
+
*/
|
|
65
|
+
export function quoteNotFoundFacts(signals) {
|
|
66
|
+
const out = [];
|
|
67
|
+
for (const k of SIGNAL_KEYS) {
|
|
68
|
+
const s = signals[k];
|
|
69
|
+
const claimed = typeof s.value === 'string' ? s.value.trim() !== '' : s.value === true;
|
|
70
|
+
if (!claimed || s.quoteFound)
|
|
71
|
+
continue;
|
|
72
|
+
const why = (s.evidence ?? '').trim() === '' ? 'no quote given' : 'quote not found';
|
|
73
|
+
out.push(cardLine(`Weak signal, ${why}: ${SIGNAL_WORDS[k] ?? k}`));
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
const NEUTRAL_DEFAULT = 'No strong signal. Default is on-stack RAP + Fiori Elements.';
|
|
78
|
+
const NEUTRAL_ANALYTICAL = 'No strong side-by-side signal. Recommended: on-stack RAP with an analytical app (ALP).';
|
|
79
|
+
function laneName(backend, ui) {
|
|
80
|
+
const u = UI_LABEL[ui];
|
|
81
|
+
return u ? `${BACKEND_LABEL[backend]} + ${u}` : BACKEND_LABEL[backend];
|
|
82
|
+
}
|
|
83
|
+
function decidedByLine(d) {
|
|
84
|
+
const path = d.decidedByPath ?? 'unknown file';
|
|
85
|
+
if (d.decidedBy === 'pin')
|
|
86
|
+
return `Decided by: project pin (${path})`;
|
|
87
|
+
if (d.decidedBy === 'standards')
|
|
88
|
+
return `Decided by: standards file (${path})`;
|
|
89
|
+
if (d.decidedBy === 'developer')
|
|
90
|
+
return 'Decided by: the developer';
|
|
91
|
+
return 'Decided by: the spec and the rules';
|
|
92
|
+
}
|
|
93
|
+
/** The lane card. Pure: builds the request from the decision, never mutates it. */
|
|
94
|
+
export function laneCardRequest(decision) {
|
|
95
|
+
// Card-only de-duplication: the neutral line restates the default, the
|
|
96
|
+
// "System not checked." line restates the unverified reason. The decision is untouched.
|
|
97
|
+
const reasons = decision.reasons.filter((r) => !(decision.neutral && r === DEFAULT_RAP_REASON) && !(decision.unverified && r === UNVERIFIED_REASON));
|
|
98
|
+
const facts = [...reasons, decidedByLine(decision)];
|
|
99
|
+
if (decision.neutral) {
|
|
100
|
+
const analytical = decision.reasons.includes(READ_ONLY_ANALYTICAL_REASON);
|
|
101
|
+
facts.push(analytical ? NEUTRAL_ANALYTICAL : NEUTRAL_DEFAULT);
|
|
102
|
+
if (decision.weakHints.length > 0) {
|
|
103
|
+
facts.push(`Weak hints toward CAP: ${decision.weakHints.map(hintWords).join(', ')}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
for (const c of decision.crossedOut)
|
|
107
|
+
facts.push(`✗ ${BACKEND_LABEL[c.lane] ?? c.lane}: ${c.reason}`);
|
|
108
|
+
if (decision.unverified)
|
|
109
|
+
facts.push('System not checked.');
|
|
110
|
+
const choices = [];
|
|
111
|
+
const allowed = [...new Set(decision.allowed)];
|
|
112
|
+
const recommended = !decision.noAppLane && decision.backend && allowed.includes(decision.backend) ? decision.backend : null;
|
|
113
|
+
if (recommended) {
|
|
114
|
+
choices.push({ value: 'confirm', label: `Use ${laneName(recommended, decision.ui)}` });
|
|
115
|
+
}
|
|
116
|
+
for (const lane of allowed) {
|
|
117
|
+
if (lane === recommended)
|
|
118
|
+
continue;
|
|
119
|
+
choices.push({ value: `switch:${lane}`, label: `Switch to ${BACKEND_LABEL[lane]}` });
|
|
120
|
+
}
|
|
121
|
+
choices.push({ value: 'cancel', label: 'Cancel' });
|
|
122
|
+
const title = recommended
|
|
123
|
+
? `Build lane: ${laneName(recommended, decision.ui)}`
|
|
124
|
+
: 'No app lane is available on this system';
|
|
125
|
+
return {
|
|
126
|
+
id: 'app-lane',
|
|
127
|
+
tag: 'QUESTION',
|
|
128
|
+
title: cardLine(title),
|
|
129
|
+
facts: facts.map(cardLine),
|
|
130
|
+
choices: choices.map((c) => ({ ...c, label: cardLine(c.label) })),
|
|
131
|
+
initial: 0,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/** The runtime card for a CAP lane whose runtime is not known yet. Pure. */
|
|
135
|
+
export function runtimeCardRequest() {
|
|
136
|
+
return {
|
|
137
|
+
id: 'app-lane-runtime',
|
|
138
|
+
tag: 'QUESTION',
|
|
139
|
+
title: 'Where will this CAP app run?',
|
|
140
|
+
choices: [
|
|
141
|
+
{ value: 'btp', label: 'SAP BTP' },
|
|
142
|
+
{ value: 'own-server', label: 'Our own server' },
|
|
143
|
+
{ value: 'none', label: "Only on the developer's laptop for now" },
|
|
144
|
+
],
|
|
145
|
+
initial: 0,
|
|
146
|
+
footnote: 'Saved to .cspeach/conventions.toml',
|
|
147
|
+
};
|
|
148
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Task 7 review round 1, R10 — the ONE place that turns the session's saved
|
|
2
|
+
// spec-gap path into the `@<path>` token of a queued `--from` command. Used by
|
|
3
|
+
// afterSpecGapSave (`/abap-app --from …`) and app_lane_resolve
|
|
4
|
+
// (`/abap-plan --from …`), so the two can never disagree about the save root.
|
|
5
|
+
//
|
|
6
|
+
// Input is CLI state only (the app flow's specGapPath, from the save hook),
|
|
7
|
+
// never a tool argument. Refused (undefined) unless:
|
|
8
|
+
// - the name is a saved spec-gap envelope (projectFilename shape, with the
|
|
9
|
+
// optional `-N` collision suffix saveProject adds);
|
|
10
|
+
// - the path has no `"` and no control character (it rides in a command);
|
|
11
|
+
// - the real file exists inside realpath(cwd) or realpath(workspace folder),
|
|
12
|
+
// so a link out of the save root is refused.
|
|
13
|
+
// Round trip: parseFromFlag reads `@"…"` / `@\S+`; resolveAtTokenAsync takes a
|
|
14
|
+
// path with a separator or an absolute path verbatim; coerceDocFromFlagToAttachment
|
|
15
|
+
// leaves `.cspeach.json` alone.
|
|
16
|
+
import { promises as fs } from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { assertRealPathContained } from '../tools/_filesystem-shared.js';
|
|
19
|
+
import { workspaceFolderFor } from './roots.js';
|
|
20
|
+
/** `<slug>-spec-gap-<4 hex>-v<N>[-<n>].cspeach.json` — titleSlug is `[a-z0-9-]`, possibly empty. */
|
|
21
|
+
export const SPEC_GAP_NAME_RE = /^[a-z0-9-]*-spec-gap-[0-9a-f]{4}-v\d+(?:-\d+)?\.cspeach\.json$/;
|
|
22
|
+
// eslint-disable-next-line no-control-regex
|
|
23
|
+
const UNSAFE = /["\u0000-\u001f\u007f-\u009f]/;
|
|
24
|
+
/** A segment after whitespace (or at the start) that begins with `--`. */
|
|
25
|
+
const FLAG_LIKE = /(?:^|\s)--/;
|
|
26
|
+
async function containedIn(abs, root) {
|
|
27
|
+
if (!root)
|
|
28
|
+
return false;
|
|
29
|
+
try {
|
|
30
|
+
const realRoot = await fs.realpath(root);
|
|
31
|
+
await assertRealPathContained(abs, realRoot);
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export async function chainFromToken(cwd, savedPath) {
|
|
39
|
+
if (typeof savedPath !== 'string' || savedPath.trim() === '' || UNSAFE.test(savedPath))
|
|
40
|
+
return undefined;
|
|
41
|
+
if (!SPEC_GAP_NAME_RE.test(path.basename(savedPath)))
|
|
42
|
+
return undefined;
|
|
43
|
+
const root = path.resolve(cwd);
|
|
44
|
+
const abs = path.resolve(root, savedPath);
|
|
45
|
+
if (UNSAFE.test(abs))
|
|
46
|
+
return undefined;
|
|
47
|
+
const ws = await workspaceFolderFor(cwd).catch(() => undefined);
|
|
48
|
+
if (!(await containedIn(abs, root)) && !(await containedIn(abs, ws)))
|
|
49
|
+
return undefined;
|
|
50
|
+
const rel = path.relative(root, abs);
|
|
51
|
+
let p = rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel) ? rel.split(path.sep).join('/') : abs;
|
|
52
|
+
if (!path.isAbsolute(p) && !p.includes('/'))
|
|
53
|
+
p = `./${p}`;
|
|
54
|
+
// Review round 2 — a whitespace-separated piece that looks like a flag
|
|
55
|
+
// (`w --resume --guarded`) would be read as one by quote-unaware parsers.
|
|
56
|
+
if (FLAG_LIKE.test(p) || FLAG_LIKE.test(abs))
|
|
57
|
+
return undefined;
|
|
58
|
+
return { token: /\s/.test(p) ? `@"${p}"` : `@${p}`, path: p };
|
|
59
|
+
}
|