@cspeach/cli 1.0.0 → 1.1.1
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 +22 -9
- package/dist/approvals/op-labels.js +124 -0
- package/dist/approvals/render.js +42 -36
- package/dist/cli.js +15 -0
- package/dist/commands/compact.js +28 -2
- package/dist/commands/config-set.js +189 -0
- package/dist/commands/config-show.js +20 -0
- package/dist/commands/export-audit.js +43 -0
- package/dist/commands/help.js +5 -0
- package/dist/commands/plan-audit-evidence.js +266 -0
- package/dist/commands/plan-audit.js +692 -0
- package/dist/commands/plan-chain.js +671 -0
- package/dist/commands/plan-continue.js +179 -0
- package/dist/commands/plan-gate.js +154 -0
- package/dist/commands/plan-resume.js +588 -33
- package/dist/config/loader.js +128 -4
- package/dist/config/model-defaults.js +14 -0
- package/dist/cost/pricing.js +27 -1
- package/dist/doctor/checks/system-roles.js +41 -0
- package/dist/doctor/run.js +2 -0
- package/dist/models/resolve.js +61 -0
- package/dist/models/server-config.js +155 -0
- package/dist/one-shot.js +25 -3
- package/dist/projects/extract-cca.js +3 -1
- package/dist/projects/extract-modernize.js +3 -1
- package/dist/projects/extract-plan.js +60 -6
- package/dist/projects/extract-test-coverage.js +3 -1
- package/dist/projects/extract-upgrade.js +3 -1
- package/dist/projects/handover-md.js +195 -0
- package/dist/projects/index.js +1 -1
- package/dist/projects/plan-run.js +137 -13
- package/dist/projects/plan-schema.js +73 -0
- package/dist/projects/run-lease.js +157 -0
- package/dist/projects/save-command.js +26 -15
- package/dist/renderer/status-footer.js +22 -12
- package/dist/renderer/thinking-heartbeat.js +64 -8
- package/dist/renderer/todo-block.js +51 -0
- package/dist/renderer/tool-widget.js +37 -0
- package/dist/repl/bracketed-paste.js +28 -19
- package/dist/repl/builtin-commands.js +5 -0
- package/dist/repl/current-transport.js +10 -0
- package/dist/repl/history.js +86 -0
- package/dist/repl/ink-stdin-guard.js +64 -0
- package/dist/repl/mode-ceiling.js +16 -0
- package/dist/repl/mode-cycle.js +104 -0
- package/dist/repl/post-turn-status.js +24 -4
- package/dist/repl/slash-completer.js +5 -0
- package/dist/repl.js +954 -83
- package/dist/rewind/candidates.js +194 -0
- package/dist/rewind/cli.js +137 -0
- package/dist/rewind/format.js +27 -0
- package/dist/rewind/restore.js +245 -0
- package/dist/session/audit-export.js +459 -0
- package/dist/session/context-report.js +163 -0
- package/dist/session/recap.js +160 -0
- package/dist/skill-catalog.js +9 -3
- package/dist/skills/bundled-skills.js +71 -78
- package/dist/tools/approval.js +115 -7
- package/dist/tools/ask-question.js +304 -3
- package/dist/tools/extend-model/anchored-insert.js +604 -0
- package/dist/tools/extend-model/tool.js +162 -10
- package/dist/tools/fiori/fe-extend.js +76 -0
- package/dist/tools/fiori/fe-scaffold.js +29 -3
- package/dist/tools/fiori/floorplan-map.js +19 -0
- package/dist/tools/fiori/samples/data/index.json +13602 -0
- package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
- package/dist/tools/fiori/samples/loader.js +248 -0
- package/dist/tools/fiori/samples/search.js +63 -0
- package/dist/tools/fiori/samples/types.js +2 -0
- package/dist/tools/fiori/smoke/assertions.js +74 -0
- package/dist/tools/fiori/smoke/browser.js +52 -0
- package/dist/tools/fiori/smoke/driver.js +89 -0
- package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
- package/dist/tools/fiori/smoke/run-smoke.js +149 -0
- package/dist/tools/fiori/tools.js +328 -3
- package/dist/tools/local-build.js +11 -1
- package/dist/tools/sap-read.js +79 -11
- package/dist/tools/sap-write.js +24 -4
- package/dist/tools/snapshot.js +27 -1
- package/dist/tools/subagent/agent_run.js +27 -3
- package/dist/tools/todo.js +144 -0
- package/dist/ui/app.js +372 -19
- package/dist/ui/approval-modal.js +49 -16
- package/dist/ui/ask-question-emitter.js +14 -0
- package/dist/ui/context-grid.js +108 -0
- package/dist/ui/footer.js +109 -30
- package/dist/ui/header.js +7 -0
- package/dist/ui/line-resolution.js +18 -2
- package/dist/ui/rewind-emitter.js +10 -0
- package/dist/ui/rewind-panel.js +81 -0
- package/dist/ui/sap-state-store.js +1 -0
- package/dist/ui/status-line.js +43 -0
- package/dist/ui/text-input.js +72 -8
- package/dist/ui/todo-emitter.js +25 -0
- package/dist/ui/todo-panel.js +64 -0
- package/dist/ui/turn-status-emitter.js +50 -4
- package/dist/ui/turn-status.js +18 -3
- package/dist/ui/widgets/ask-form.js +242 -0
- package/dist/ui/widgets/ask-question-modal.js +17 -7
- package/package.json +4 -1
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
*
|
|
38
38
|
* FLAG / LOCAL_BUILD
|
|
39
39
|
* ------------------
|
|
40
|
-
*
|
|
40
|
+
* Every fiori_* tool here is flagGated:true (category 'fiori') and listed in
|
|
41
41
|
* LOCAL_BUILD_TOOLS, so `cspeach config set local_build on` enables them
|
|
42
42
|
* alongside file_write/shell_exec — they are part of "build apps locally".
|
|
43
43
|
*
|
|
@@ -53,6 +53,21 @@ import { applyEntry } from './apply.js';
|
|
|
53
53
|
import { listCatalog } from './catalog/index.js';
|
|
54
54
|
import { writeDeployConfig } from './deploy-config.js';
|
|
55
55
|
import { scaffoldFioriElements } from './fe-scaffold.js';
|
|
56
|
+
import { feExtend, FE_EXTEND_OPS, NotV4FeAppError } from './fe-extend.js';
|
|
57
|
+
import { runSmoke } from './smoke/run-smoke.js';
|
|
58
|
+
import { deriveFreestyleSmokeSpec, FreestyleSmokeDerivationError } from './smoke/freestyle-spec.js';
|
|
59
|
+
import { loadConfig, resolveRenderSmoke } from '../../config/loader.js';
|
|
60
|
+
import { searchSamples } from './samples/search.js';
|
|
61
|
+
import { loadSampleSource, SampleFetchError, UnsafeSampleNameError } from './samples/loader.js';
|
|
62
|
+
// The grounding corpus ships as an imported JSON module + the bundled
|
|
63
|
+
// SAMPLE_SOURCES map (read inside loadSampleSource). NEVER readFileSync the
|
|
64
|
+
// corpus — the import is the only shipping-safe read (rule A-C1).
|
|
65
|
+
import sampleIndexJson from './samples/data/index.json' with { type: 'json' };
|
|
66
|
+
const SAMPLE_INDEX = sampleIndexJson;
|
|
67
|
+
// Short provenance line, always attached to a fiori_sample_get payload so the
|
|
68
|
+
// model can cite the source (licensing constraint A-M1). Derived from the
|
|
69
|
+
// corpus NOTICE: OpenUI5 sources, adapted, under Apache-2.0.
|
|
70
|
+
const SAMPLE_ATTRIBUTION = 'Adapted from OpenUI5 (github.com/SAP/openui5) sample sources, licensed under Apache-2.0.';
|
|
56
71
|
/** Resolve a user-supplied dir inside the project root, mapping the escape error. */
|
|
57
72
|
function safeDir(ctx, userPath) {
|
|
58
73
|
try {
|
|
@@ -213,6 +228,9 @@ export async function fioriScaffoldFeHandler(args, ctx) {
|
|
|
213
228
|
return { content: 'error: appId is required', is_error: true };
|
|
214
229
|
if (!args.mainEntity)
|
|
215
230
|
return { content: 'error: mainEntity is required', is_error: true };
|
|
231
|
+
if (args.localAnnotations && (!args.localAnnotations.technicalName || !args.localAnnotations.xml)) {
|
|
232
|
+
return { content: 'error: localAnnotations requires both technicalName and xml', is_error: true };
|
|
233
|
+
}
|
|
216
234
|
// metadata is OPTIONAL (the FE writer does not need it — spike §4); no $metadata fetch.
|
|
217
235
|
const resolved = safeDir(ctx, args.basePath);
|
|
218
236
|
if ('error' in resolved)
|
|
@@ -226,6 +244,7 @@ export async function fioriScaffoldFeHandler(args, ctx) {
|
|
|
226
244
|
service: { url: args.serviceUrl, path: args.servicePath, version: args.serviceVersion ?? '4.0', metadata: args.metadata, client: args.client },
|
|
227
245
|
mainEntity: args.mainEntity,
|
|
228
246
|
ui5Version: args.ui5Version,
|
|
247
|
+
localAnnotations: args.localAnnotations,
|
|
229
248
|
});
|
|
230
249
|
}
|
|
231
250
|
catch (err) {
|
|
@@ -239,6 +258,171 @@ export async function fioriScaffoldFeHandler(args, ctx) {
|
|
|
239
258
|
`Files:\n${files.map((f) => ` ${f}`).join('\n')}`,
|
|
240
259
|
};
|
|
241
260
|
}
|
|
261
|
+
// ───────────────────────────── fiori_fe_extend ────────────────────────────
|
|
262
|
+
export async function feExtendHandler(args, ctx) {
|
|
263
|
+
if (!args.basePath)
|
|
264
|
+
return { content: 'error: basePath is required', is_error: true };
|
|
265
|
+
if (!args.op)
|
|
266
|
+
return { content: 'error: op is required', is_error: true };
|
|
267
|
+
if (!FE_EXTEND_OPS.includes(args.op)) {
|
|
268
|
+
return { content: `error: unknown op "${args.op}" — expected one of ${FE_EXTEND_OPS.join(', ')}`, is_error: true };
|
|
269
|
+
}
|
|
270
|
+
const resolved = safeDir(ctx, args.basePath);
|
|
271
|
+
if ('error' in resolved)
|
|
272
|
+
return { content: `error: ${resolved.error}`, is_error: true };
|
|
273
|
+
const before = new Set(await listFilesUnder(ctx, resolved.abs));
|
|
274
|
+
try {
|
|
275
|
+
await feExtend({ basePath: resolved.abs, op: args.op, params: args.params ?? {} });
|
|
276
|
+
}
|
|
277
|
+
catch (err) {
|
|
278
|
+
// V4-only scope limit: fe-fpm-writer refuses non-V4-FE apps (no
|
|
279
|
+
// sap.fe.templates) — surfaced as a clean typed line, not the writer's
|
|
280
|
+
// internal wording.
|
|
281
|
+
if (err instanceof NotV4FeAppError)
|
|
282
|
+
return { content: `error: ${err.message}`, is_error: true };
|
|
283
|
+
return { content: `error: fe extend failed — ${err instanceof Error ? err.message : String(err)}`, is_error: true };
|
|
284
|
+
}
|
|
285
|
+
// Report only the newly-written files (the generator also touches manifest.json).
|
|
286
|
+
const after = await listFilesUnder(ctx, resolved.abs);
|
|
287
|
+
const created = after.filter((f) => !before.has(f));
|
|
288
|
+
const appDir = rel(ctx, resolved.abs);
|
|
289
|
+
return {
|
|
290
|
+
content: `Added FE extension "${args.op}" to ${appDir}/ (manifest.json updated).\n` +
|
|
291
|
+
(created.length
|
|
292
|
+
? `New files:\n${created.map((f) => ` ${f}`).join('\n')}`
|
|
293
|
+
: `No new files (manifest-only change).`),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* FREESTYLE MODE (appDir given): derive a SmokeSpec from the app's own webapp/
|
|
298
|
+
* tree via deriveFreestyleSmokeSpec, then let explicit spec args override the
|
|
299
|
+
* derived fields FIELD-BY-FIELD (explicit wins). `appUrl` always comes from the
|
|
300
|
+
* arg — derivation produces no URL. Returns the merged spec + derivation warnings,
|
|
301
|
+
* or a clean `error` string (bad appDir, or a typed derivation failure — named).
|
|
302
|
+
*
|
|
303
|
+
* Kept as a pure, exported helper so the derive+merge contract is unit-testable
|
|
304
|
+
* without launching a browser; the handler only orchestrates.
|
|
305
|
+
*/
|
|
306
|
+
export function resolveFreestyleSmokeSpec(args, ctx) {
|
|
307
|
+
const resolved = safeDir(ctx, args.appDir);
|
|
308
|
+
if ('error' in resolved)
|
|
309
|
+
return { error: resolved.error };
|
|
310
|
+
let derived;
|
|
311
|
+
try {
|
|
312
|
+
derived = deriveFreestyleSmokeSpec(resolved.abs);
|
|
313
|
+
}
|
|
314
|
+
catch (err) {
|
|
315
|
+
// Name the typed derivation failure (no stack trace) so the model sees WHY.
|
|
316
|
+
if (err instanceof FreestyleSmokeDerivationError) {
|
|
317
|
+
return { error: `${err.name}: ${err.message}` };
|
|
318
|
+
}
|
|
319
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
320
|
+
}
|
|
321
|
+
const { warnings } = derived;
|
|
322
|
+
// Field-by-field merge (explicit wins). `??` keeps an explicitly-provided empty
|
|
323
|
+
// array/false as an override, and omits fields that are neither explicit nor derived.
|
|
324
|
+
const controlKind = args.controlKind ?? derived.controlKind;
|
|
325
|
+
const expectedColumns = args.expectedColumns ?? derived.expectedColumns;
|
|
326
|
+
const exercises = args.exercises ?? derived.exercises;
|
|
327
|
+
const spec = {
|
|
328
|
+
appUrl: args.appUrl, // always the arg — derivation carries no real preview URL
|
|
329
|
+
...(controlKind !== undefined ? { controlKind } : {}),
|
|
330
|
+
...(expectedColumns !== undefined ? { expectedColumns } : {}),
|
|
331
|
+
...(args.allowEmptyRows !== undefined ? { allowEmptyRows: args.allowEmptyRows } : {}),
|
|
332
|
+
...(exercises !== undefined ? { exercises } : {}),
|
|
333
|
+
};
|
|
334
|
+
return { spec, warnings };
|
|
335
|
+
}
|
|
336
|
+
export async function fioriRenderSmokeHandler(args, ctx,
|
|
337
|
+
// Test seam: injected runSmoke deps (browser/openApp/renderSmokeEnabled). In
|
|
338
|
+
// production this is undefined and the real config-driven deps are used.
|
|
339
|
+
deps) {
|
|
340
|
+
if (!args.appUrl) {
|
|
341
|
+
return {
|
|
342
|
+
content: 'error: appUrl is required — the LOCAL AUTHENTICATED PREVIEW URL (the `npm run start` / ' +
|
|
343
|
+
'`fiori run` URL, e.g. http://localhost:8080/index.html), NOT the deployed BSP URL.',
|
|
344
|
+
is_error: true,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
// FE mode (no appDir): byte-identical to Track 2 — `args` is the spec, no warnings.
|
|
348
|
+
// Freestyle mode (appDir): derive + merge, and surface the derivation warnings.
|
|
349
|
+
let spec = args;
|
|
350
|
+
let warnings;
|
|
351
|
+
if (args.appDir) {
|
|
352
|
+
const derived = resolveFreestyleSmokeSpec(args, ctx);
|
|
353
|
+
if ('error' in derived) {
|
|
354
|
+
return {
|
|
355
|
+
content: `error: could not derive a freestyle smoke spec from appDir "${args.appDir}" — ${derived.error}`,
|
|
356
|
+
is_error: true,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
spec = derived.spec;
|
|
360
|
+
warnings = derived.warnings;
|
|
361
|
+
}
|
|
362
|
+
// The render_smoke config toggle (plain default true) gates whether a browser
|
|
363
|
+
// is actually launched; when off, runSmoke returns a skipped manual result.
|
|
364
|
+
const cfg = await loadConfig();
|
|
365
|
+
const runDeps = { renderSmokeEnabled: resolveRenderSmoke(cfg), ...deps };
|
|
366
|
+
const result = await runSmoke(spec, runDeps);
|
|
367
|
+
// A skip is NOT a tool error — it's a valid `verification:'manual'` outcome the
|
|
368
|
+
// caller must see and act on (bring the preview up, etc.). Return the full
|
|
369
|
+
// SmokeResult as JSON either way. In freestyle mode attach the derivation
|
|
370
|
+
// warnings (always present — even []) so the model sees skipped presses/routes.
|
|
371
|
+
const payload = warnings !== undefined ? { ...result, warnings } : result;
|
|
372
|
+
return { content: JSON.stringify(payload, null, 2) };
|
|
373
|
+
}
|
|
374
|
+
// ─────────────────────────────── fiori_sample_search ──────────────────────
|
|
375
|
+
export async function fioriSampleSearchHandler(args, _ctx) {
|
|
376
|
+
if (!args.text || !args.text.trim()) {
|
|
377
|
+
return { content: 'error: text is required — free-text keywords to match against the sample corpus', is_error: true };
|
|
378
|
+
}
|
|
379
|
+
// searchSamples is pure (Task 2): score the imported index, keep only real
|
|
380
|
+
// matches, ordered by descending score. Shape each hit as a compact,
|
|
381
|
+
// CatalogIndexItem-style summary + its score for the model to pick from.
|
|
382
|
+
// Clamp at the tool boundary: a model-supplied 0, negative, or fractional max
|
|
383
|
+
// would otherwise reach searchSamples' slice as-is (0 → no hits at all, a
|
|
384
|
+
// negative → silently drops from the tail). Undefined still means "default".
|
|
385
|
+
const max = args.max === undefined ? undefined : Math.max(1, Math.floor(args.max) || 1);
|
|
386
|
+
const hits = searchSamples(SAMPLE_INDEX, { text: args.text, control: args.control, max });
|
|
387
|
+
const summaries = hits.map(({ entry, score }) => ({
|
|
388
|
+
name: entry.name,
|
|
389
|
+
control: entry.control,
|
|
390
|
+
library: entry.library,
|
|
391
|
+
description: entry.description,
|
|
392
|
+
keywords: entry.keywords,
|
|
393
|
+
vendored: entry.sourceRef.vendored,
|
|
394
|
+
score,
|
|
395
|
+
}));
|
|
396
|
+
return { content: JSON.stringify(summaries, null, 2) };
|
|
397
|
+
}
|
|
398
|
+
// ─────────────────────────────── fiori_sample_get ─────────────────────────
|
|
399
|
+
export async function fioriSampleGetHandler(args, _ctx) {
|
|
400
|
+
if (!args.name || !args.name.trim()) {
|
|
401
|
+
return { content: 'error: name is required — the sample name from fiori_sample_search (e.g. "sap.m/Wizard")', is_error: true };
|
|
402
|
+
}
|
|
403
|
+
const entry = SAMPLE_INDEX.find((e) => e.name === args.name);
|
|
404
|
+
if (!entry) {
|
|
405
|
+
return {
|
|
406
|
+
content: `error: sample "${args.name}" is not found in the sample index — call fiori_sample_search first to get a valid name.`,
|
|
407
|
+
is_error: true,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
// loadSampleSource (Task 3): vendored → bundled SAMPLE_SOURCES; un-vendored →
|
|
411
|
+
// lazy fetch + cache. Its typed errors carry actionable, model-facing messages
|
|
412
|
+
// (fall back to recipes + sap-docs) — surface them verbatim, don't mask them.
|
|
413
|
+
try {
|
|
414
|
+
const loaded = await loadSampleSource(entry, {});
|
|
415
|
+
return {
|
|
416
|
+
content: JSON.stringify({ name: entry.name, files: loaded.files, license: 'Apache-2.0', attribution: SAMPLE_ATTRIBUTION }, null, 2),
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
catch (err) {
|
|
420
|
+
if (err instanceof SampleFetchError || err instanceof UnsafeSampleNameError) {
|
|
421
|
+
return { content: `error: ${err.message}`, is_error: true };
|
|
422
|
+
}
|
|
423
|
+
return { content: `error: could not load sample "${args.name}" — ${err instanceof Error ? err.message : String(err)}`, is_error: true };
|
|
424
|
+
}
|
|
425
|
+
}
|
|
242
426
|
// ─────────────────────────────── registration ─────────────────────────────
|
|
243
427
|
registerTool({
|
|
244
428
|
name: 'fiori_scaffold',
|
|
@@ -327,7 +511,7 @@ registerTool({
|
|
|
327
511
|
});
|
|
328
512
|
registerTool({
|
|
329
513
|
name: 'fiori_scaffold_fe',
|
|
330
|
-
description: 'Scaffold a trivial Fiori Elements app shell (List Report Object Page, Worklist, or
|
|
514
|
+
description: 'Scaffold a trivial Fiori Elements app shell (List Report Object Page, Worklist, Overview Page, or Analytical List Page) ' +
|
|
331
515
|
'against an ALREADY-PUBLISHED OData service. Driven entirely by the backend @UI annotations on the ' +
|
|
332
516
|
'CDS projection/DDLX — authors NO local annotation.xml (not an FE generator). metadata is OPTIONAL ' +
|
|
333
517
|
'(the running app reads it from the live service); pass it only if you already have it. basePath ' +
|
|
@@ -341,7 +525,7 @@ registerTool({
|
|
|
341
525
|
basePath: { type: 'string', description: 'Directory to scaffold into (relative to project root).' },
|
|
342
526
|
appId: { type: 'string', description: 'App namespace, e.g. "z.tcrs.courses".' },
|
|
343
527
|
appTitle: { type: 'string', description: 'Human-readable title. Defaults to appId.' },
|
|
344
|
-
template: { type: 'string', enum: ['lrop', 'worklist', 'ovp'], description: 'FE template. Default "lrop". All
|
|
528
|
+
template: { type: 'string', enum: ['lrop', 'worklist', 'ovp', 'alp'], description: 'FE template: lrop (List Report), worklist, ovp (Overview Page), or alp (Analytical List Page). Default "lrop". All four ship.' },
|
|
345
529
|
serviceUrl: { type: 'string', description: 'Backend host, e.g. "https://host:44300".' },
|
|
346
530
|
servicePath: { type: 'string', description: 'OData service path, e.g. "/sap/opu/odata4/sap/zc_x/srvd/sap/zc_x/0001/".' },
|
|
347
531
|
serviceVersion: { type: 'string', enum: ['2.0', '4.0'], description: 'OData version. Default "4.0".' },
|
|
@@ -349,8 +533,149 @@ registerTool({
|
|
|
349
533
|
client: { type: 'string', description: 'OPTIONAL SAP client (e.g. "100"), written to the manifest for the proxy layer.' },
|
|
350
534
|
mainEntity: { type: 'string', description: 'The entity set to bind, e.g. "Course".' },
|
|
351
535
|
ui5Version: { type: 'string', description: 'UI5 version. Default 1.120.0.' },
|
|
536
|
+
localAnnotations: {
|
|
537
|
+
type: 'object',
|
|
538
|
+
description: 'OPTIONAL local annotations.xml for a FOREIGN OData service you do NOT own (cannot add backend @UI). ' +
|
|
539
|
+
'Wires the given EDMX as a LOCAL ODataAnnotation dataSource (annotations/<technicalName>.xml) that ' +
|
|
540
|
+
'resolves at runtime — no catalog fetch. Omit for services whose backend already carries @UI annotations.',
|
|
541
|
+
properties: {
|
|
542
|
+
technicalName: { type: 'string', description: 'Local annotation name; also the dataSource key and file name (annotations/<technicalName>.xml).' },
|
|
543
|
+
xml: { type: 'string', description: 'The annotations EDMX (UI.LineItem/HeaderInfo/SelectionFields/Facets in EDMX form).' },
|
|
544
|
+
},
|
|
545
|
+
required: ['technicalName', 'xml'],
|
|
546
|
+
},
|
|
352
547
|
},
|
|
353
548
|
required: ['basePath', 'appId', 'serviceUrl', 'servicePath', 'mainEntity'],
|
|
354
549
|
},
|
|
355
550
|
handler: fioriScaffoldFeHandler,
|
|
356
551
|
});
|
|
552
|
+
registerTool({
|
|
553
|
+
name: 'fiori_fe_extend',
|
|
554
|
+
description: 'Add a Fiori Elements extension point to an EXISTING V4 FE app on disk via @sap-ux/fe-fpm-writer: ' +
|
|
555
|
+
'a custom-column (table column + fragment), custom-action (toolbar/table action), custom-section ' +
|
|
556
|
+
'(object-page section + fragment), or controller-extension (a .controller.js/.ts + manifest wiring). ' +
|
|
557
|
+
'V4 Fiori Elements ONLY (LROP/Worklist/ALP/FEOP) — OVP and all OData V2 apps are refused with a typed ' +
|
|
558
|
+
'error. params map 1:1 to fe-fpm-writer\'s CustomTableColumn / CustomAction / CustomSection / ' +
|
|
559
|
+
'ControllerExtension config and are passed verbatim. basePath is sandboxed to the project root.',
|
|
560
|
+
isMutating: true,
|
|
561
|
+
category: 'fiori',
|
|
562
|
+
flagGated: true,
|
|
563
|
+
input_schema: {
|
|
564
|
+
type: 'object',
|
|
565
|
+
properties: {
|
|
566
|
+
basePath: { type: 'string', description: 'App root of the existing V4 FE app (the folder containing webapp/manifest.json), relative to the project root.' },
|
|
567
|
+
op: {
|
|
568
|
+
type: 'string',
|
|
569
|
+
enum: [...FE_EXTEND_OPS],
|
|
570
|
+
description: 'Extension point to add: custom-column, custom-action, custom-section, or controller-extension.',
|
|
571
|
+
},
|
|
572
|
+
params: {
|
|
573
|
+
type: 'object',
|
|
574
|
+
description: 'Extension config, passed VERBATIM to the matching fe-fpm-writer generator. Required fields per op: ' +
|
|
575
|
+
'custom-column → { name, target (routing target, e.g. "<Entity>List"), targetEntity, position { placement: "After"|"Before"|"End", anchor? }, header }; ' +
|
|
576
|
+
'custom-action → { name, target { page (routing target), control ("@com.sap.vocabularies.UI.v1.LineItem" for a table action) }, settings { text } }; ' +
|
|
577
|
+
'custom-section → { name, target (object-page routing target, e.g. "<Entity>ObjectPage"), title }; ' +
|
|
578
|
+
'controller-extension → { name, extension ("ListReport"|"ObjectPage" or a page-target object) }. ' +
|
|
579
|
+
'eventHandler is optional on column/action/section.',
|
|
580
|
+
},
|
|
581
|
+
},
|
|
582
|
+
required: ['basePath', 'op', 'params'],
|
|
583
|
+
},
|
|
584
|
+
handler: feExtendHandler,
|
|
585
|
+
});
|
|
586
|
+
registerTool({
|
|
587
|
+
name: 'fiori_render_smoke',
|
|
588
|
+
description: 'Content-asserting render smoke (Track 2 D-4): launch a headless Edge/Chrome against the LOCAL ' +
|
|
589
|
+
'AUTHENTICATED PREVIEW (the `npm run start` URL — NEVER the deployed BSP URL) and assert the app ' +
|
|
590
|
+
'actually rendered — booted with no console errors, no failed OData ($metadata/$batch/entity) calls, ' +
|
|
591
|
+
'expected columns present, and (for a list) rows returned. TWO MODES: FIORI-ELEMENTS mode — pass the ' +
|
|
592
|
+
'spec fields (expectedColumns/controlKind/…) yourself. FREESTYLE mode — pass `appDir` (the scaffolded ' +
|
|
593
|
+
'app root) plus `appUrl` and the spec is DERIVED from the app\'s own webapp/ views + i18n + manifest ' +
|
|
594
|
+
'(controlKind, expected columns, press/route exercises); any spec field you ALSO pass overrides the ' +
|
|
595
|
+
'derived one. Derivation notes surface as a `warnings` array in the result. SKIPPABLE BUT NEVER ' +
|
|
596
|
+
'SILENTLY GREEN: when the render_smoke config is off, no browser is found, the preview is unreachable, ' +
|
|
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. Flag-gated (enabled ' +
|
|
599
|
+
'with local_build), read-only.',
|
|
600
|
+
isMutating: false,
|
|
601
|
+
category: 'fiori',
|
|
602
|
+
flagGated: true,
|
|
603
|
+
input_schema: {
|
|
604
|
+
type: 'object',
|
|
605
|
+
properties: {
|
|
606
|
+
appUrl: {
|
|
607
|
+
type: 'string',
|
|
608
|
+
description: 'The LOCAL AUTHENTICATED PREVIEW URL (the `npm run start` / `fiori run` URL that proxies to the ' +
|
|
609
|
+
'real backend with your auth), e.g. "http://localhost:8080/index.html". NOT the deployed BSP URL.',
|
|
610
|
+
},
|
|
611
|
+
appDir: {
|
|
612
|
+
type: 'string',
|
|
613
|
+
description: 'FREESTYLE mode only: the scaffolded app root (relative to the project root, sandboxed). When ' +
|
|
614
|
+
'given, the SmokeSpec is DERIVED from that app\'s webapp/ views + i18n + manifest, and any spec ' +
|
|
615
|
+
'field you also pass overrides the derived one. Omit for Fiori Elements mode (pass the spec fields directly).',
|
|
616
|
+
},
|
|
617
|
+
expectedColumns: {
|
|
618
|
+
type: 'array',
|
|
619
|
+
items: { type: 'string' },
|
|
620
|
+
description: 'Column headers that MUST be present (checked case-insensitively). Omit for non-list controls.',
|
|
621
|
+
},
|
|
622
|
+
controlKind: {
|
|
623
|
+
type: 'string',
|
|
624
|
+
enum: ['table', 'cards', 'chart', 'form', 'custom'],
|
|
625
|
+
description: 'The app\'s primary control. Only "table" gets a row-count assertion.',
|
|
626
|
+
},
|
|
627
|
+
allowEmptyRows: {
|
|
628
|
+
type: 'boolean',
|
|
629
|
+
description: 'When true, a table that renders 0 rows still passes (empty list acknowledged). Default false.',
|
|
630
|
+
},
|
|
631
|
+
exercises: {
|
|
632
|
+
type: 'array',
|
|
633
|
+
description: 'Post-boot interactions to exercise (press/route). Declared for Track 3; unused in Track 2.',
|
|
634
|
+
items: { type: 'object' },
|
|
635
|
+
},
|
|
636
|
+
},
|
|
637
|
+
required: ['appUrl'],
|
|
638
|
+
},
|
|
639
|
+
handler: fioriRenderSmokeHandler,
|
|
640
|
+
});
|
|
641
|
+
registerTool({
|
|
642
|
+
name: 'fiori_sample_search',
|
|
643
|
+
description: 'Search the vendored UI5 Demo Kit sample corpus (grounding for the freestyle composer): free-text ' +
|
|
644
|
+
'keywords + an optional exact control name return the best-matching samples as scored summaries ' +
|
|
645
|
+
'(name, control, library, description, keywords, vendored flag, score), highest score first. Use this ' +
|
|
646
|
+
'to FIND a real UI5 sample to ground a control against, then fiori_sample_get to read its source. ' +
|
|
647
|
+
'Read-only; flag-gated (enabled with local_build).',
|
|
648
|
+
isMutating: false,
|
|
649
|
+
category: 'fiori',
|
|
650
|
+
flagGated: true,
|
|
651
|
+
input_schema: {
|
|
652
|
+
type: 'object',
|
|
653
|
+
properties: {
|
|
654
|
+
text: { type: 'string', description: 'Free-text keywords, e.g. "wizard step" or "value help dialog".' },
|
|
655
|
+
control: { type: 'string', description: 'OPTIONAL exact UI5 control name (e.g. "Wizard") — an exact match dominates the ranking.' },
|
|
656
|
+
max: { type: 'number', description: 'Max results to return. Default 5.' },
|
|
657
|
+
},
|
|
658
|
+
required: ['text'],
|
|
659
|
+
},
|
|
660
|
+
handler: fioriSampleSearchHandler,
|
|
661
|
+
});
|
|
662
|
+
registerTool({
|
|
663
|
+
name: 'fiori_sample_get',
|
|
664
|
+
description: 'Fetch one UI5 sample\'s source files by name (the grounding payload the composer reads). Returns ' +
|
|
665
|
+
'{ files: [{ path, content }], license: "Apache-2.0", attribution }. Vendored samples are served from ' +
|
|
666
|
+
'the bundled corpus; un-vendored ones are lazily fetched and cached. The Apache-2.0 attribution line ' +
|
|
667
|
+
'is ALWAYS included. Call fiori_sample_search first to get a valid name. An unknown name, or a sample ' +
|
|
668
|
+
'that cannot be retrieved, returns a clean error steering you to the recipes catalog + sap-docs. ' +
|
|
669
|
+
'Read-only; flag-gated (enabled with local_build).',
|
|
670
|
+
isMutating: false,
|
|
671
|
+
category: 'fiori',
|
|
672
|
+
flagGated: true,
|
|
673
|
+
input_schema: {
|
|
674
|
+
type: 'object',
|
|
675
|
+
properties: {
|
|
676
|
+
name: { type: 'string', description: 'The sample name from fiori_sample_search, e.g. "sap.m/Wizard".' },
|
|
677
|
+
},
|
|
678
|
+
required: ['name'],
|
|
679
|
+
},
|
|
680
|
+
handler: fioriSampleGetHandler,
|
|
681
|
+
});
|
|
@@ -10,8 +10,13 @@
|
|
|
10
10
|
*
|
|
11
11
|
* filesystem: file_read, file_write, file_edit, glob, grep
|
|
12
12
|
* shell: shell_exec
|
|
13
|
-
* fiori: fiori_scaffold, fiori_apply, fiori_list, fiori_deploy_config
|
|
13
|
+
* fiori: fiori_scaffold, fiori_apply, fiori_list, fiori_deploy_config,
|
|
14
|
+
* fiori_scaffold_fe, fiori_fe_extend, fiori_render_smoke,
|
|
15
|
+
* fiori_sample_search, fiori_sample_get
|
|
14
16
|
* extend: extend_model_insert (anchored CDS/DDLX edit — abap-extend-model)
|
|
17
|
+
* subagent: agent_run (Task 7, 2026-07-03 — customers get subagent
|
|
18
|
+
* dispatch when local_build is on; plan phases force its
|
|
19
|
+
* read-only tool filter regardless of the model's args)
|
|
15
20
|
*
|
|
16
21
|
* This is what makes /abap-fiori-build runnable end to end — it writes local
|
|
17
22
|
* app files (FS tools), runs safelisted build CLIs (shell), AND scaffolds /
|
|
@@ -56,8 +61,13 @@ export const LOCAL_BUILD_TOOLS = [
|
|
|
56
61
|
'fiori_list',
|
|
57
62
|
'fiori_deploy_config',
|
|
58
63
|
'fiori_scaffold_fe',
|
|
64
|
+
'fiori_fe_extend',
|
|
65
|
+
'fiori_render_smoke',
|
|
66
|
+
'fiori_sample_search',
|
|
67
|
+
'fiori_sample_get',
|
|
59
68
|
'extend_model_insert',
|
|
60
69
|
'system_capability',
|
|
70
|
+
'agent_run',
|
|
61
71
|
];
|
|
62
72
|
/**
|
|
63
73
|
* Apply the persisted `local_build` toggle. When on, force-enables the FS +
|
package/dist/tools/sap-read.js
CHANGED
|
@@ -32,7 +32,9 @@ registerTool({
|
|
|
32
32
|
+ 'omitting it lets SAP choose (usually active). When the object has '
|
|
33
33
|
+ 'pending inactive changes and the caller did not pass `version`, '
|
|
34
34
|
+ 'the response is the active source AND a warning is emitted so the '
|
|
35
|
-
+ 'caller knows the inactive edits are not visible.'
|
|
35
|
+
+ 'caller knows the inactive edits are not visible. For a freshly '
|
|
36
|
+
+ 'created object with NO active version yet, the inactive (working) '
|
|
37
|
+
+ 'source is returned with version="inactive" and an honest note.',
|
|
36
38
|
isMutating: false,
|
|
37
39
|
input_schema: {
|
|
38
40
|
type: 'object',
|
|
@@ -68,15 +70,41 @@ registerTool({
|
|
|
68
70
|
const conflict = inactive.find((o) => o.name.toUpperCase() === upperName &&
|
|
69
71
|
(o.type === '' || o.type.toUpperCase().startsWith(upperType)));
|
|
70
72
|
if (conflict) {
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
73
|
+
// Fable review 2026-07-06 (clas-create-hygiene I1): a FRESH shell
|
|
74
|
+
// (created, never activated) ALSO appears in inactiveObjects(), but
|
|
75
|
+
// it has NO active version at all — sap-client's inactive-read
|
|
76
|
+
// fallback just served the INACTIVE (working) source. Claiming
|
|
77
|
+
// "this is the ACTIVE source" here would be false. Cross-check
|
|
78
|
+
// objectStructure's adtcore:version (honest for fresh shells now
|
|
79
|
+
// that objectStructure carries the same inactive fallback):
|
|
80
|
+
// 'inactive' ⇒ inactive-only shell.
|
|
81
|
+
let inactiveOnly = false;
|
|
82
|
+
try {
|
|
83
|
+
const struct = await ctx.adt.objectStructure(args.type, args.name);
|
|
84
|
+
const m = /\badtcore:version="([^"]*)"/.exec(struct.rawXml ?? '');
|
|
85
|
+
inactiveOnly = m?.[1] === 'inactive';
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// Cross-check failed — fall through to the conservative
|
|
89
|
+
// split-state warning (never claim inactive-only without proof).
|
|
90
|
+
}
|
|
91
|
+
if (inactiveOnly) {
|
|
92
|
+
resolvedVersion = 'inactive';
|
|
93
|
+
splitStateWarning =
|
|
94
|
+
`${args.type} ${args.name} has no active version yet — this is the `
|
|
95
|
+
+ `inactive (working) source. Activate it via sap_activate when ready.`;
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
// SAP defaults to the active version when version is unspecified,
|
|
99
|
+
// but the caller should know an inactive copy exists so they don't
|
|
100
|
+
// base dependent edits on stale committed source.
|
|
101
|
+
resolvedVersion = 'active';
|
|
102
|
+
splitStateWarning =
|
|
103
|
+
`${args.type} ${args.name} has a pending inactive version. `
|
|
104
|
+
+ `This response is the ACTIVE source; pending edits are NOT included. `
|
|
105
|
+
+ `Pass version="inactive" to read the in-flight version, or have the user `
|
|
106
|
+
+ `activate (or discard) the inactive version before basing dependent code on this read.`;
|
|
107
|
+
}
|
|
80
108
|
}
|
|
81
109
|
else {
|
|
82
110
|
resolvedVersion = 'active';
|
|
@@ -112,7 +140,22 @@ registerTool({
|
|
|
112
140
|
},
|
|
113
141
|
handler: async (args, ctx) => {
|
|
114
142
|
const result = await ctx.adt.objectStructure(args.type, args.name);
|
|
115
|
-
|
|
143
|
+
// Fable review 2026-07-05 (audit-reverify item 2) — surface the object's
|
|
144
|
+
// adtcore:version EARLY in the JSON. The sap-client ObjectStructure has
|
|
145
|
+
// no parsed version field; adtcore:version lives deep inside rawXml,
|
|
146
|
+
// beyond the audit evidence's 200-char result excerpt — so the audit
|
|
147
|
+
// contract's "sap_object_structure reporting an active version" proof
|
|
148
|
+
// path for verify-only re-runs was unreachable in production, and a
|
|
149
|
+
// re-run that skipped sap_inactive_objects would false-FAIL an active
|
|
150
|
+
// object. Parsing here (CLI-side, no sap-client bump) and placing
|
|
151
|
+
// `version` right after name/type puts it inside the excerpt window.
|
|
152
|
+
const versionMatch = /\badtcore:version="([^"]*)"/.exec(result.rawXml ?? '');
|
|
153
|
+
const version = versionMatch?.[1] || 'unknown';
|
|
154
|
+
// name/type/version lead the serialized JSON (rawXml stays last via the
|
|
155
|
+
// rest spread), so `version` sits inside the excerpt window while every
|
|
156
|
+
// ObjectStructure field is preserved.
|
|
157
|
+
const { name, type, ...rest } = result;
|
|
158
|
+
return { content: JSON.stringify({ name, type, version, ...rest }, null, 2) };
|
|
116
159
|
},
|
|
117
160
|
});
|
|
118
161
|
// -----------------------------------------------------------------------
|
|
@@ -475,6 +518,31 @@ registerTool({
|
|
|
475
518
|
handler: async (args, ctx) => {
|
|
476
519
|
// runUnitTest(type, name) — type first
|
|
477
520
|
const result = await ctx.adt.runUnitTest(args.type, args.name);
|
|
521
|
+
// B2 false-green guard (project_aunit_run_gap): the ADT AUnit-run endpoint
|
|
522
|
+
// returns HTTP 200 with an EMPTY runResult (total:0, no programs) even for a
|
|
523
|
+
// class that is active + syntax-clean with a valid FOR TESTING local class —
|
|
524
|
+
// a known environmental gap, exhaustively probed (sap-client buildUnitTestXml
|
|
525
|
+
// comment). The raw {total:0} shape reads to a caller like "0 tests, nothing
|
|
526
|
+
// failed → green", so an unguarded pass-through risks a false-green test gate.
|
|
527
|
+
// When we detect that empty signature, wrap the (still-carried) raw result in
|
|
528
|
+
// an explicit UNVERIFIED envelope that names Eclipse "Run As → ABAP Unit" as
|
|
529
|
+
// the authoritative check. A genuine run (total > 0) passes through unchanged.
|
|
530
|
+
const summary = result?.summary;
|
|
531
|
+
const programs = result?.programs;
|
|
532
|
+
const noTestsRun = summary?.total === 0 && (!Array.isArray(programs) || programs.length === 0);
|
|
533
|
+
if (noTestsRun) {
|
|
534
|
+
return {
|
|
535
|
+
content: JSON.stringify({
|
|
536
|
+
verification: 'UNVERIFIED',
|
|
537
|
+
warning: 'ABAP Unit run enumerated 0 tests (empty runResult). This does NOT mean the tests ' +
|
|
538
|
+
'passed — it means none executed. A FOR TESTING class that is active + syntax-clean ' +
|
|
539
|
+
'still returning 0 is a known ADT AUnit-run gap (project_aunit_run_gap): the ' +
|
|
540
|
+
'authoritative check is Eclipse "Run As → ABAP Unit" (Ctrl+Shift+F10). Do NOT treat ' +
|
|
541
|
+
'this as a green test gate.',
|
|
542
|
+
result,
|
|
543
|
+
}, null, 2),
|
|
544
|
+
};
|
|
545
|
+
}
|
|
478
546
|
return { content: JSON.stringify(result, null, 2) };
|
|
479
547
|
},
|
|
480
548
|
});
|
package/dist/tools/sap-write.js
CHANGED
|
@@ -48,15 +48,27 @@ function emitSnapshotNotice(ctx, snap, type, name) {
|
|
|
48
48
|
ctx.chunkEmitter?.emit('info', `snapshot ✓ ${type} ${name} ${snap.entry.timestamp}`);
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
|
+
/** Distinct LOUD note when the write proceeds against a fresh/never-activated
|
|
52
|
+
* shell that has no committed active version — nothing to snapshot (Rule 7 is
|
|
53
|
+
* satisfied because there is no prior source to protect). Kept separate from
|
|
54
|
+
* the 'new_object' (genuine 404) path so the transcript is honest about which
|
|
55
|
+
* case it was — this is the RAP behaviour-pool ZBP first-write. */
|
|
56
|
+
function emitNewShellNote(ctx, type, name) {
|
|
57
|
+
ctx.chunkEmitter?.emit('warn', `${type} ${name}: new/empty shell — no active version, no prior source to snapshot; proceeding with first write`);
|
|
58
|
+
}
|
|
51
59
|
/** Map the snapshot-gate outcome onto the chain line's honest states.
|
|
52
60
|
* `undefined` covers the best-effort gates whose failure was swallowed.
|
|
53
61
|
* 'object_not_found' is a genuine ADT 404 only; any other read failure maps
|
|
54
|
-
* to 'source_read_failed' (+ short detail) — never to "(new object)".
|
|
62
|
+
* to 'source_read_failed' (+ short detail) — never to "(new object)".
|
|
63
|
+
* 'new_shell' (exists but no active version — nothing to save) reuses the
|
|
64
|
+
* 'new_object' chain glyph; the honest which-case distinction is carried by
|
|
65
|
+
* the LOUD emitNewShellNote line, not the compact chain. */
|
|
55
66
|
function snapshotChainState(snap) {
|
|
56
67
|
if (snap?.taken)
|
|
57
68
|
return { snapshot: 'taken' };
|
|
58
|
-
if (snap?.reason === 'object_not_found')
|
|
69
|
+
if (snap?.reason === 'object_not_found' || snap?.reason === 'new_shell') {
|
|
59
70
|
return { snapshot: 'new_object' };
|
|
71
|
+
}
|
|
60
72
|
if (snap?.reason === 'source_read_failed') {
|
|
61
73
|
return { snapshot: 'source_read_failed', snapshotDetail: snap.detail };
|
|
62
74
|
}
|
|
@@ -128,11 +140,15 @@ registerTool({
|
|
|
128
140
|
error: ERR.SNAPSHOT_FAILED,
|
|
129
141
|
reason: 'source_read_failed',
|
|
130
142
|
detail: snapOutcome.detail,
|
|
131
|
-
recovery: 'Pre-write source read failed (non-404) — the object may exist
|
|
143
|
+
recovery: 'Pre-write source read failed (non-404) and no committed active version could be confirmed — the object may exist with source we could not read, so no snapshot was possible. If this is a live connection/auth problem, fix it and retry. If this is a freshly-created shell that got edit-locked by an earlier aborted write, recover it directly: unlock it (or activate the empty shell, then delete it) and re-run the create+write. Do not bypass Rule 7 by writing over source you never read.',
|
|
132
144
|
}),
|
|
133
145
|
is_error: true,
|
|
134
146
|
};
|
|
135
147
|
}
|
|
148
|
+
// Fresh/never-activated shell (no active version → nothing to snapshot):
|
|
149
|
+
// proceed like a new object, but say so LOUDLY and distinctly.
|
|
150
|
+
if (snapOutcome?.reason === 'new_shell')
|
|
151
|
+
emitNewShellNote(ctx, args.type, args.name);
|
|
136
152
|
emitSnapshotNotice(ctx, snapOutcome, args.type, args.name);
|
|
137
153
|
// ── Gate 3: Safety confirmation (Rule 7) ─────────────────────────────────
|
|
138
154
|
if (isSafetyConfirmEnabled()) {
|
|
@@ -285,11 +301,15 @@ registerTool({
|
|
|
285
301
|
error: ERR.SNAPSHOT_FAILED,
|
|
286
302
|
reason: 'source_read_failed',
|
|
287
303
|
detail: snapOutcome.detail,
|
|
288
|
-
recovery: 'Pre-write source read failed (non-404) — the class
|
|
304
|
+
recovery: 'Pre-write source read failed (non-404) and no committed active version could be confirmed — the class may exist with source we could not read, so no snapshot was possible. If this is a live connection/auth problem, fix it and retry. If this is a freshly-created class shell that got edit-locked by an earlier aborted write, recover it directly: unlock it (or activate the empty shell, then delete it) and re-run the create+write. Do not bypass Rule 7 by writing over source you never read.',
|
|
289
305
|
}),
|
|
290
306
|
is_error: true,
|
|
291
307
|
};
|
|
292
308
|
}
|
|
309
|
+
// Fresh/never-activated class shell (no active version → nothing to
|
|
310
|
+
// snapshot): proceed like a new object, but say so LOUDLY and distinctly.
|
|
311
|
+
if (snapOutcome?.reason === 'new_shell')
|
|
312
|
+
emitNewShellNote(ctx, 'CLAS', args.class_name);
|
|
293
313
|
emitSnapshotNotice(ctx, snapOutcome, 'CLAS', args.class_name);
|
|
294
314
|
// ── Gate 3: Diff preview & confirmation (Rule 7a) ────────────────────────
|
|
295
315
|
if (!ctx.previewHook) {
|
package/dist/tools/snapshot.js
CHANGED
|
@@ -66,7 +66,33 @@ export async function autoSnapshot(objectName, objectType, adt) {
|
|
|
66
66
|
catch (err) {
|
|
67
67
|
if (isNotFound(err))
|
|
68
68
|
return { taken: false, reason: 'object_not_found' }; // new object
|
|
69
|
-
|
|
69
|
+
// The DEFAULT read failed for a non-404 reason. Before treating this as a
|
|
70
|
+
// hard read failure (which blocks the write, Rule 7), distinguish an
|
|
71
|
+
// empty/never-activated SHELL from a real object whose source we couldn't
|
|
72
|
+
// read. A freshly-created behaviour-pool CLAS (ZBP … FOR BEHAVIOR OF …)
|
|
73
|
+
// EXISTS but has no committed source yet, so the default read returns HTTP
|
|
74
|
+
// 400 — not 404. Such a shell has NO committed ACTIVE version. Probe for one:
|
|
75
|
+
// - active read 404 → no active version ⇒ fresh shell, nothing to
|
|
76
|
+
// snapshot ⇒ proceed ('new_shell').
|
|
77
|
+
// - active read succeeds → there IS committed source to protect ⇒
|
|
78
|
+
// snapshot THAT active source and proceed.
|
|
79
|
+
// - active read fails otherwise → can't confirm emptiness ⇒ conservative
|
|
80
|
+
// block ('source_read_failed', unchanged).
|
|
81
|
+
let activeSrc;
|
|
82
|
+
try {
|
|
83
|
+
activeSrc = await adt.getSource(objectType, objectName, 'active');
|
|
84
|
+
}
|
|
85
|
+
catch (activeErr) {
|
|
86
|
+
if (isNotFound(activeErr))
|
|
87
|
+
return { taken: false, reason: 'new_shell' };
|
|
88
|
+
// Could not confirm the object is empty — keep the safety property: a
|
|
89
|
+
// real object with source we couldn't read must still block the write.
|
|
90
|
+
return { taken: false, reason: 'source_read_failed', detail: shortErrorMessage(err) };
|
|
91
|
+
}
|
|
92
|
+
// Committed active version exists — snapshot it. A snapshots.take failure
|
|
93
|
+
// still THROWS (Rule 7 hard-fail), same as the clean-read path below.
|
|
94
|
+
const activeEntry = await snapshots.take(objectType, objectName, activeSrc, 'before_write');
|
|
95
|
+
return { taken: true, entry: activeEntry };
|
|
70
96
|
}
|
|
71
97
|
const entry = await snapshots.take(objectType, objectName, src, 'before_write');
|
|
72
98
|
return { taken: true, entry };
|