@cspeach/cli 0.9.0 → 1.0.0

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.
Files changed (131) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/intent-system-prompt.js +1 -1
  3. package/dist/agent/loop.js +209 -20
  4. package/dist/agent/providers/license-gate.js +44 -0
  5. package/dist/agent/skill-checkpoint.js +1 -1
  6. package/dist/agent/tool-dispatch.js +15 -0
  7. package/dist/approvals/canonical.js +91 -0
  8. package/dist/approvals/jwt.js +39 -2
  9. package/dist/auth/org-anthropic-key.js +25 -0
  10. package/dist/classifier/client.js +18 -3
  11. package/dist/commands/config-set.js +95 -0
  12. package/dist/commands/login.js +31 -14
  13. package/dist/commands/plan-model-tier.js +83 -0
  14. package/dist/commands/plan-resume.js +148 -21
  15. package/dist/config/loader.js +95 -1
  16. package/dist/doctor/checks/_http-probe.js +1 -0
  17. package/dist/doctor/checks/cert.js +14 -3
  18. package/dist/doctor/checks/sap.js +30 -8
  19. package/dist/doctor/checks/zcspeach.js +19 -4
  20. package/dist/one-shot.js +52 -4
  21. package/dist/projects/answer-blockers.js +137 -0
  22. package/dist/projects/extract-cca.js +108 -16
  23. package/dist/projects/extract-modernize.js +1 -1
  24. package/dist/projects/extract-plan.js +130 -37
  25. package/dist/projects/extract-spec-gap.js +34 -7
  26. package/dist/projects/extract-test-coverage.js +1 -1
  27. package/dist/projects/extract-upgrade.js +113 -22
  28. package/dist/projects/index.js +5 -2
  29. package/dist/projects/merge-cca.js +292 -0
  30. package/dist/projects/merge-upgrade.js +173 -0
  31. package/dist/projects/migration.js +103 -1
  32. package/dist/projects/output-paths.js +27 -0
  33. package/dist/projects/plan-run.js +159 -25
  34. package/dist/projects/plan-schema.js +63 -3
  35. package/dist/projects/promote-command.js +25 -2
  36. package/dist/projects/promote.js +128 -0
  37. package/dist/projects/save-command.js +247 -20
  38. package/dist/projects/status.js +3 -1
  39. package/dist/projects/validate.js +1 -1
  40. package/dist/projects/workspace.js +164 -20
  41. package/dist/renderer/notices.js +64 -0
  42. package/dist/renderer/progress-chatter.js +8 -0
  43. package/dist/renderer/tool-widget.js +18 -4
  44. package/dist/renderer/tty.js +43 -4
  45. package/dist/renderer/verify-chain.js +77 -0
  46. package/dist/repl/at-picker.js +60 -7
  47. package/dist/repl/builtin-commands.js +37 -0
  48. package/dist/repl/early-line-buffer.js +68 -0
  49. package/dist/repl/inquirer-guard.js +70 -5
  50. package/dist/repl/numbered-menu.js +131 -0
  51. package/dist/repl/post-turn-status.js +2 -2
  52. package/dist/repl/rule8-detector.js +17 -2
  53. package/dist/repl/safety-confirm.js +111 -2
  54. package/dist/repl/safety-mode-state.js +19 -3
  55. package/dist/repl/slash-picker.js +10 -15
  56. package/dist/repl.js +301 -35
  57. package/dist/router/classifier.js +150 -6
  58. package/dist/sap/capability-matrix.js +20 -0
  59. package/dist/sap/capability-matrix.json +11236 -0
  60. package/dist/sap/capability.js +146 -0
  61. package/dist/sap/connection-manager.js +19 -1
  62. package/dist/sap/onboarding.js +42 -4
  63. package/dist/session/pending.js +27 -0
  64. package/dist/skill-catalog.js +48 -43
  65. package/dist/skills/bundled-skills.js +279 -1
  66. package/dist/skills/promotion-dispatch.js +23 -0
  67. package/dist/tools/_command-shared.js +36 -12
  68. package/dist/tools/_filesystem-shared.js +139 -4
  69. package/dist/tools/_flag.js +25 -0
  70. package/dist/tools/approval.js +64 -21
  71. package/dist/tools/ask-question.js +96 -4
  72. package/dist/tools/capability/tool.js +74 -0
  73. package/dist/tools/dispatch-skill.js +22 -1
  74. package/dist/tools/extend-model/anchored-insert.js +810 -0
  75. package/dist/tools/extend-model/tool.js +188 -0
  76. package/dist/tools/filesystem/extract-document.js +57 -0
  77. package/dist/tools/filesystem/file-edit.js +12 -2
  78. package/dist/tools/filesystem/file-read.js +2 -2
  79. package/dist/tools/filesystem/file-write.js +11 -2
  80. package/dist/tools/filesystem/glob.js +11 -0
  81. package/dist/tools/filesystem/grep.js +10 -0
  82. package/dist/tools/filesystem/read-document.js +107 -0
  83. package/dist/tools/fiori/apply.js +50 -0
  84. package/dist/tools/fiori/bin.js +3 -0
  85. package/dist/tools/fiori/catalog/index.js +27 -0
  86. package/dist/tools/fiori/catalog/value-help.js +230 -0
  87. package/dist/tools/fiori/catalog/viz-chart.js +177 -0
  88. package/dist/tools/fiori/cli.js +71 -0
  89. package/dist/tools/fiori/deploy-config.js +73 -0
  90. package/dist/tools/fiori/fe-scaffold.js +45 -0
  91. package/dist/tools/fiori/i18n.js +39 -0
  92. package/dist/tools/fiori/manifest.js +70 -0
  93. package/dist/tools/fiori/render.js +77 -0
  94. package/dist/tools/fiori/scaffold.js +39 -0
  95. package/dist/tools/fiori/tools.js +356 -0
  96. package/dist/tools/fiori/types.js +1 -0
  97. package/dist/tools/local-build.js +76 -0
  98. package/dist/tools/local-files.js +31 -0
  99. package/dist/tools/project/_merge-shared.js +68 -0
  100. package/dist/tools/project/cca_merge.js +164 -0
  101. package/dist/tools/project/playbook_get.js +1 -1
  102. package/dist/tools/project/upgrade_merge_progress.js +206 -0
  103. package/dist/tools/sap-read.js +53 -9
  104. package/dist/tools/sap-write.js +530 -21
  105. package/dist/tools/shell/shell_exec.js +41 -6
  106. package/dist/tools/snapshot.js +37 -14
  107. package/dist/tools/subagent/background_run.js +17 -1
  108. package/dist/tools/transport-resolution.js +86 -0
  109. package/dist/tools/transport.js +224 -5
  110. package/dist/tools/write-mode.js +4 -0
  111. package/dist/ui/app.js +6 -2
  112. package/dist/ui/body.js +13 -0
  113. package/dist/ui/footer.js +20 -6
  114. package/dist/ui/line-resolution.js +17 -6
  115. package/dist/ui/session-timeline.js +1 -0
  116. package/dist/ui/text-input.js +150 -0
  117. package/dist/ui/widgets/ask-question-modal.js +4 -1
  118. package/package.json +19 -3
  119. package/bench/README.md +0 -78
  120. package/bench/prompts/abap-document-cds.md +0 -44
  121. package/bench/prompts/abap-explain-bdef-handler.md +0 -57
  122. package/bench/prompts/abap-test-method.md +0 -42
  123. package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
  124. package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
  125. package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
  126. package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
  127. package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
  128. package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
  129. package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
  130. package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
  131. package/bench/results/abap-test-method/claude-sonnet-4-6.md +0 -234
@@ -0,0 +1,810 @@
1
+ /**
2
+ * Anchored insertion for ABAP CDS / DDLX source (B1 of the revision-aware
3
+ * design). We deliberately do NOT parse the DSL into a model and re-serialize
4
+ * — a lossless CDS/BDEF round-trip is a weeks-long sub-project and a half-built
5
+ * serializer corrupts more than it protects. Instead we locate a stable
6
+ * textual anchor and insert the new element there, leaving every other byte
7
+ * untouched. The corruption surface is the inserted span only; the real safety
8
+ * net is the skill's snapshot → preview-diff → syntax-check → activate-verify →
9
+ * rollback chain around this pure function.
10
+ *
11
+ * See docs/superpowers/specs/2026-06-24-revision-aware-cspeach-design.md §5.2.
12
+ */
13
+ /**
14
+ * Base indentation of the element whose final line is `lastIdx`: the
15
+ * shallowest indentation among the contiguous run of non-empty lines ending
16
+ * at `lastIdx`, scanning upward and stopping at the first blank line (the
17
+ * element separator) or the first line that leaves the view/annotate body
18
+ * (a zero-indent line such as the opening `{`).
19
+ *
20
+ * Annotation lines and the element's first line sit at this base indent;
21
+ * continuation lines of a multi-line expression are deeper, so the minimum
22
+ * yields the element's own indentation rather than a deep continuation line.
23
+ * Returns '' if no indented line is found (caller may apply a default).
24
+ */
25
+ function baseIndentOfLastElement(lines, lastIdx) {
26
+ let minIndent = '';
27
+ let minLen = Infinity;
28
+ for (let i = lastIdx; i >= 0; i--) {
29
+ const trimmed = lines[i].trim();
30
+ if (trimmed === '')
31
+ break; // element separator
32
+ const indent = (lines[i].match(/^\s*/) ?? [''])[0];
33
+ if (indent.length === 0)
34
+ break; // left the body (e.g. the opening brace)
35
+ if (indent.length < minLen) {
36
+ minLen = indent.length;
37
+ minIndent = indent;
38
+ }
39
+ }
40
+ return minIndent;
41
+ }
42
+ /**
43
+ * Insert a new field into a CDS `define view [entity] … { … }` element list.
44
+ *
45
+ * The new field is appended as the last element before the body's closing
46
+ * brace, matching the indentation of the existing last element. CDS element
47
+ * lists separate elements with commas and the last element carries no trailing
48
+ * comma, so the previously-last element gains a comma.
49
+ *
50
+ * @param source full CDS view source
51
+ * @param fieldDef the element to add, e.g. `Priority` or `priority as Priority`
52
+ * (no leading indentation, no trailing comma — both are applied)
53
+ * @returns the modified source
54
+ */
55
+ export function insertCdsField(source, fieldDef) {
56
+ const lines = source.split('\n');
57
+ // Anchor: the view body's closing brace — the last line that is just '}'.
58
+ let braceIdx = -1;
59
+ for (let i = lines.length - 1; i >= 0; i--) {
60
+ if (lines[i].trim() === '}') {
61
+ braceIdx = i;
62
+ break;
63
+ }
64
+ }
65
+ if (braceIdx === -1) {
66
+ throw new Error('insertCdsField: no closing brace found for the view body');
67
+ }
68
+ // The last existing element: the last non-empty line before the brace.
69
+ let lastIdx = -1;
70
+ for (let i = braceIdx - 1; i >= 0; i--) {
71
+ if (lines[i].trim() !== '') {
72
+ lastIdx = i;
73
+ break;
74
+ }
75
+ }
76
+ if (lastIdx === -1) {
77
+ throw new Error('insertCdsField: no element found in the view body');
78
+ }
79
+ // Reuse the last element's *base* indentation for the new one. The last
80
+ // element may span multiple lines (e.g. a multi-line CASE/CAST expression)
81
+ // whose final line is indented far deeper than the element's own start.
82
+ // Reusing that final line's indent would mis-indent the new element, so
83
+ // derive the base indent from the shallowest line of the element's block.
84
+ const indent = baseIndentOfLastElement(lines, lastIdx);
85
+ // Ensure the prior last element ends with a comma.
86
+ if (!lines[lastIdx].trimEnd().endsWith(',')) {
87
+ lines[lastIdx] = `${lines[lastIdx].trimEnd()},`;
88
+ }
89
+ // Insert the new field immediately before the closing brace.
90
+ lines.splice(braceIdx, 0, `${indent}${fieldDef}`);
91
+ return lines.join('\n');
92
+ }
93
+ /**
94
+ * Insert an `@UI.lineItem`-annotated field into a DDLX metadata extension
95
+ * (`annotate view … with { … }`) so the field appears as a column in a Fiori
96
+ * Elements List Report. The annotated field block is appended before the
97
+ * body's closing brace, matching the indentation of the existing entries.
98
+ *
99
+ * DDLX entries are `;`-terminated independently, so — unlike a CDS element
100
+ * list — no prior entry needs editing.
101
+ *
102
+ * @param source full DDLX metadata-extension source
103
+ * @param field the field name to expose as a column, e.g. `Priority`
104
+ * @param position the `@UI.lineItem` position (column order)
105
+ * @returns the modified source
106
+ */
107
+ export function insertDdlxLineItem(source, field, position) {
108
+ const lines = source.split('\n');
109
+ // Anchor: the body's closing brace — the last line that is just '}'.
110
+ let braceIdx = -1;
111
+ for (let i = lines.length - 1; i >= 0; i--) {
112
+ if (lines[i].trim() === '}') {
113
+ braceIdx = i;
114
+ break;
115
+ }
116
+ }
117
+ if (braceIdx === -1) {
118
+ throw new Error('insertDdlxLineItem: no closing brace found for the annotate body');
119
+ }
120
+ // Reuse the base indentation of the last entry (default 2 spaces). Using
121
+ // the element's shallowest line keeps the new entry aligned even when the
122
+ // last entry spans multiple lines.
123
+ let lastIdx = -1;
124
+ for (let i = braceIdx - 1; i >= 0; i--) {
125
+ const trimmed = lines[i].trim();
126
+ if (trimmed !== '' && trimmed !== '{') {
127
+ lastIdx = i;
128
+ break;
129
+ }
130
+ }
131
+ const indent = lastIdx === -1 ? ' ' : baseIndentOfLastElement(lines, lastIdx) || ' ';
132
+ // Insert the annotation line followed by the field line.
133
+ lines.splice(braceIdx, 0, `${indent}@UI.lineItem: [{ position: ${position} }]`, `${indent}${field};`);
134
+ return lines.join('\n');
135
+ }
136
+ /**
137
+ * Promote an element that is ALREADY exposed in a CDS `define view [entity]`
138
+ * projection to a Fiori Elements list column, by inserting an
139
+ * `@UI.lineItem` annotation directly above the element's declaration line.
140
+ *
141
+ * Unlike {@link insertCdsField} (which APPENDS a new element to the body) this
142
+ * function annotates a field that is already in the element list — closing the
143
+ * gap where the model only knew how to add fields, not surface existing ones.
144
+ *
145
+ * The anchor is the element's own declaration line, located by matching the
146
+ * declared element name (the alias after `as`, or the bare/`key`-prefixed
147
+ * name). The new annotation is inserted immediately above that line at the
148
+ * same indentation, so it joins any existing contiguous annotation block.
149
+ *
150
+ * Fail-safe by design:
151
+ * - Refuses (throws) when the field cannot be uniquely identified (not found,
152
+ * or ambiguous across multiple declarations).
153
+ * - Refuses multi-line / computed elements (e.g. a `case … end as Name`): the
154
+ * declaration line is a continuation of a larger expression, and inserting
155
+ * above it would split that expression. Such elements must be promoted
156
+ * manually or via /abap-refactor.
157
+ * - Idempotent: refuses when the field already carries an `@UI.lineItem` in the
158
+ * contiguous annotation block directly above it, so re-running is a no-op
159
+ * error rather than a duplicate column.
160
+ *
161
+ * @param source full CDS view source
162
+ * @param field the EXISTING exposed element name to promote, e.g. `CreatedBy`
163
+ * @param position the `@UI.lineItem` position (column order)
164
+ * @returns the modified source
165
+ */
166
+ export function promoteCdsFieldToLineItem(source, field, position) {
167
+ const lines = source.split('\n');
168
+ // The element name a line declares, or null. Strip trailing whitespace, then
169
+ // a single optional trailing comma, then trailing whitespace again:
170
+ // `expr as Name` -> Name (aliased element)
171
+ // `key Name` / `Name` (bare) -> Name
172
+ // anything else (continuation, brace, annotation, expression) -> null
173
+ const declaredName = (line) => {
174
+ const s = line.replace(/\s+$/, '').replace(/,$/, '').replace(/\s+$/, '');
175
+ const aliased = s.match(/\bas\s+(\w+)$/);
176
+ if (aliased)
177
+ return aliased[1];
178
+ const bare = s.trim().match(/^(?:key\s+)?(\w+)$/);
179
+ if (bare)
180
+ return bare[1];
181
+ return null;
182
+ };
183
+ // 1. Locate the unique element declaring `field`.
184
+ const defIdxs = [];
185
+ for (let i = 0; i < lines.length; i++) {
186
+ if (declaredName(lines[i]) === field)
187
+ defIdxs.push(i);
188
+ }
189
+ if (defIdxs.length === 0) {
190
+ throw new Error(`promoteCdsFieldToLineItem: field '${field}' not found in the view element list`);
191
+ }
192
+ if (defIdxs.length > 1) {
193
+ throw new Error(`promoteCdsFieldToLineItem: field '${field}' is ambiguous (${defIdxs.length} matches)`);
194
+ }
195
+ const defIdx = defIdxs[0];
196
+ // 2. Refuse if defIdx is a continuation line of a multi-line element —
197
+ // inserting above it would split the expression. A "fresh start" (safe)
198
+ // is only when the line above is blank, the opening brace, an annotation,
199
+ // or the previous element's terminator (ends with a comma).
200
+ const prev = defIdx > 0 ? lines[defIdx - 1].trim() : '';
201
+ const isFreshStart = prev === '' || prev === '{' || prev.startsWith('@') || prev.endsWith(',');
202
+ if (!isFreshStart) {
203
+ throw new Error(`promoteCdsFieldToLineItem: '${field}' resolves to a multi-line/computed element; promote it manually or via /abap-refactor`);
204
+ }
205
+ // 3. Idempotency: refuse if `field` already has an @UI.lineItem in the
206
+ // contiguous single-line annotation block directly above defIdx.
207
+ for (let i = defIdx - 1; i >= 0 && lines[i].trim().startsWith('@'); i--) {
208
+ if (/@UI\.lineItem\b/.test(lines[i])) {
209
+ throw new Error(`promoteCdsFieldToLineItem: '${field}' is already exposed as a column (@UI.lineItem present)`);
210
+ }
211
+ }
212
+ // 4. Insert the annotation immediately above defIdx, matching defIdx's indent.
213
+ const indent = (lines[defIdx].match(/^\s*/) ?? [''])[0];
214
+ lines.splice(defIdx, 0, `${indent}@UI.lineItem: [{ position: ${position} }]`);
215
+ return lines.join('\n');
216
+ }
217
+ /**
218
+ * Add a NON-KEY field to a CDS `define table NAME { … }` body (B2a — the first
219
+ * leg of the table→CDS→BDEF single-field chain).
220
+ *
221
+ * Like {@link insertDdlxLineItem}, table fields are `;`-terminated independently
222
+ * — there is no comma-separated element list — so NO prior line is edited; only
223
+ * the new line is inserted. The new field is appended as the last entry before
224
+ * the body's closing brace, at the indentation of the existing last entry.
225
+ *
226
+ * No DSL parser is used: the anchor is the textual closing brace (the last line
227
+ * whose trim() === '}'), exactly as the CDS/DDLX helpers do.
228
+ *
229
+ * Fail-safe by design:
230
+ * - Refuses (throws) a `key …` clause: adding/removing a key is a destructive
231
+ * DDIC table-conversion, never a safe append. Only non-key fields at the end
232
+ * are supported.
233
+ * - Throws when no closing brace can be found for the table body.
234
+ *
235
+ * @param source full CDS `define table` source
236
+ * @param fieldClause the field definition WITHOUT a trailing `;`, e.g.
237
+ * `reference : zde_reference` or `reference : abap.char(16)`.
238
+ * Any trailing `;`/whitespace is stripped and exactly one
239
+ * `;` is re-applied.
240
+ * @returns the modified source
241
+ */
242
+ export function addTableField(source, fieldClause) {
243
+ // Fail-safe: never touch keys.
244
+ if (/^\s*key\s/i.test(fieldClause)) {
245
+ throw new Error('addTableField: refusing to add a key field (key changes are destructive); only non-key fields at the end are supported');
246
+ }
247
+ // Normalize: strip trailing whitespace, then any trailing ';', then
248
+ // whitespace again — the function re-adds exactly one ';'.
249
+ const normalizedClause = fieldClause.replace(/\s+$/, '').replace(/;$/, '').replace(/\s+$/, '');
250
+ const lines = source.split('\n');
251
+ // Anchor: the body's closing brace — the last line that is just '}'.
252
+ let braceIdx = -1;
253
+ for (let i = lines.length - 1; i >= 0; i--) {
254
+ if (lines[i].trim() === '}') {
255
+ braceIdx = i;
256
+ break;
257
+ }
258
+ }
259
+ if (braceIdx === -1) {
260
+ throw new Error('addTableField: no closing brace found for the table body');
261
+ }
262
+ // Reuse the base indent of the last entry (default 2 spaces).
263
+ let lastIdx = -1;
264
+ for (let i = braceIdx - 1; i >= 0; i--) {
265
+ const trimmed = lines[i].trim();
266
+ if (trimmed !== '' && trimmed !== '{') {
267
+ lastIdx = i;
268
+ break;
269
+ }
270
+ }
271
+ const indent = lastIdx === -1 ? ' ' : baseIndentOfLastElement(lines, lastIdx) || ' ';
272
+ // Insert the new field immediately before the closing brace.
273
+ lines.splice(braceIdx, 0, `${indent}${normalizedClause};`);
274
+ return lines.join('\n');
275
+ }
276
+ /**
277
+ * Resolve the behavior entity body span (bodyOpen..bodyClose) for the chosen
278
+ * behavior, and REPORT the implementation keyword and resolved alias. Shared by
279
+ * insertBdefField, insertBdefClause, and resolveBehaviorAlias. Fail-safe:
280
+ * refuses ambiguity and a missing body.
281
+ *
282
+ * IMPORTANT (extensibility seam, spec §9): this resolver does NOT refuse
283
+ * unmanaged — it returns `implKeyword` and the CALLERS enforce the managed-only
284
+ * policy. A future unmanaged/saver capability flips that caller policy without
285
+ * touching this shared helper. (Extracted from insertBdefField — existing
286
+ * insertBdefField tests guard the refactor.)
287
+ */
288
+ function resolveBehaviorEntityBody(lines, alias) {
289
+ const behaviors = [];
290
+ for (let i = 0; i < lines.length; i++) {
291
+ const m = lines[i].match(/\bdefine\s+behavior\s+for\s+\S+\s+alias\s+(\w+)/i);
292
+ if (m)
293
+ behaviors.push({ lineIdx: i, alias: m[1] });
294
+ }
295
+ if (behaviors.length === 0) {
296
+ throw new Error('no "define behavior … alias …" found in the source');
297
+ }
298
+ let chosen;
299
+ if (alias) {
300
+ const wanted = alias.toLowerCase();
301
+ const match = behaviors.find((b) => b.alias.toLowerCase() === wanted);
302
+ if (!match)
303
+ throw new Error(`alias '${alias}' not found among the defined behaviors`);
304
+ chosen = match;
305
+ }
306
+ else if (behaviors.length === 1) {
307
+ chosen = behaviors[0];
308
+ }
309
+ else {
310
+ throw new Error('multiple behaviors; specify alias');
311
+ }
312
+ let implKeyword = null;
313
+ for (let i = chosen.lineIdx; i >= 0; i--) {
314
+ const m = lines[i].match(/\b(managed|unmanaged)\s+implementation\s+in\s+class\b/i);
315
+ if (m) {
316
+ implKeyword = m[1].toLowerCase();
317
+ break;
318
+ }
319
+ }
320
+ let bodyOpen = -1;
321
+ for (let i = chosen.lineIdx; i < lines.length; i++) {
322
+ if (lines[i].includes('{')) {
323
+ bodyOpen = i;
324
+ break;
325
+ }
326
+ }
327
+ if (bodyOpen === -1)
328
+ throw new Error(`no body '{' found for alias ${chosen.alias}`);
329
+ const bodyClose = matchingClose(lines, bodyOpen);
330
+ if (bodyClose === -1)
331
+ throw new Error(`no matching '}' found for alias ${chosen.alias}`);
332
+ return { bodyOpen, bodyClose, implKeyword, alias: chosen.alias };
333
+ }
334
+ /**
335
+ * Resolve the effective BDEF entity alias for a given source string: auto-picks
336
+ * the single behavior when no alias is given, inheriting the same ambiguity /
337
+ * not-found / no-behavior errors from resolveBehaviorEntityBody. Intended as the
338
+ * thin alias-resolution entry point for handleBdefStub (I-A).
339
+ */
340
+ export function resolveBehaviorAlias(source, alias) {
341
+ return resolveBehaviorEntityBody(source.split('\n'), alias).alias;
342
+ }
343
+ /**
344
+ * Add a `field ( … )` declaration or a mapping line to a MANAGED RAP behavior
345
+ * definition (B2a — the final leg of the table→CDS→BDEF chain).
346
+ *
347
+ * No DSL parser is used. The behavior entity body and the mapping block are
348
+ * located by their textual braces using simple `{`/`}` counting (BDEF rarely
349
+ * carries braces inside strings, so raw counting is sufficient and far safer
350
+ * than a half-built parser). Every other byte is left untouched.
351
+ *
352
+ * Fail-safe by design:
353
+ * - Refuses (throws) an UNMANAGED implementation — only managed BDEF is
354
+ * supported (the chain auto-persists; unmanaged needs hand-written savers).
355
+ * - When several behaviors exist and no `alias` is given, throws rather than
356
+ * guessing which entity to extend; throws too if a requested alias is absent.
357
+ * - target 'mapping' with no mapping block throws rather than silently adding
358
+ * nothing.
359
+ *
360
+ * @param source full managed BDEF source
361
+ * @param opts.alias the entity alias to extend (`alias <ALIAS>`); optional only
362
+ * when exactly one behavior is defined
363
+ * @param opts.target 'field' → add a `field ( … ) Name;` to the entity body;
364
+ * 'mapping' → add `Name = column;` to the entity's mapping block
365
+ * @param opts.clause the declaration WITHOUT a trailing `;`. For 'field', the
366
+ * full `field ( readonly ) Reference`; for 'mapping', the
367
+ * assignment `Reference = reference`. Exactly one `;` is
368
+ * re-applied.
369
+ * @returns the modified source
370
+ */
371
+ export function insertBdefField(source, opts) {
372
+ const lines = source.split('\n');
373
+ // Normalize the clause: strip trailing whitespace + one trailing ';'.
374
+ const clause = opts.clause.replace(/\s+$/, '').replace(/;$/, '').replace(/\s+$/, '');
375
+ // 1–3. Resolve behavior entity body via shared helper; enforce managed-only
376
+ // at this call site (caller policy per spec §9 extensibility seam).
377
+ const { bodyOpen, bodyClose, implKeyword } = resolveBehaviorEntityBody(lines, opts.alias);
378
+ if (implKeyword === 'unmanaged') {
379
+ throw new Error('insertBdefField: unmanaged BDEF not supported (managed only)');
380
+ }
381
+ if (opts.target === 'field') {
382
+ // 4. Insert after the LAST `field (` line inside the body; else just
383
+ // before bodyClose at the body indent.
384
+ let lastFieldIdx = -1;
385
+ for (let i = bodyOpen + 1; i < bodyClose; i++) {
386
+ if (/^\s*field\s*\(/.test(lines[i]))
387
+ lastFieldIdx = i;
388
+ }
389
+ if (lastFieldIdx !== -1) {
390
+ const indent = (lines[lastFieldIdx].match(/^\s*/) ?? [''])[0];
391
+ lines.splice(lastFieldIdx + 1, 0, `${indent}${clause};`);
392
+ }
393
+ else {
394
+ const indent = bodyIndent(lines, bodyOpen, bodyClose);
395
+ lines.splice(bodyClose, 0, `${indent}${clause};`);
396
+ }
397
+ return lines.join('\n');
398
+ }
399
+ // 5. target 'mapping': find a `mapping for …` line inside the body, then its
400
+ // block braces, and insert before the mapping block's close.
401
+ let mappingLineIdx = -1;
402
+ for (let i = bodyOpen + 1; i < bodyClose; i++) {
403
+ if (/^\s*mapping\s+for\b/.test(lines[i])) {
404
+ mappingLineIdx = i;
405
+ break;
406
+ }
407
+ }
408
+ if (mappingLineIdx === -1) {
409
+ throw new Error(`insertBdefField: no mapping block found for alias ${opts.alias ?? '(single)'}`);
410
+ }
411
+ let mapOpen = -1;
412
+ for (let i = mappingLineIdx; i < bodyClose; i++) {
413
+ if (lines[i].includes('{')) {
414
+ mapOpen = i;
415
+ break;
416
+ }
417
+ }
418
+ if (mapOpen === -1) {
419
+ throw new Error(`insertBdefField: no mapping block found for alias ${opts.alias ?? '(single)'}`);
420
+ }
421
+ const mapClose = matchingClose(lines, mapOpen);
422
+ if (mapClose === -1) {
423
+ throw new Error(`insertBdefField: no matching '}' for the mapping block of alias ${opts.alias ?? '(single)'}`);
424
+ }
425
+ // Indent from an existing mapping entry; default to the mapping line's
426
+ // indent + 2 spaces.
427
+ let mapEntryIndent = null;
428
+ for (let i = mapOpen + 1; i < mapClose; i++) {
429
+ if (lines[i].trim() !== '') {
430
+ mapEntryIndent = (lines[i].match(/^\s*/) ?? [''])[0];
431
+ break;
432
+ }
433
+ }
434
+ if (mapEntryIndent === null) {
435
+ const mapLineIndent = (lines[mappingLineIdx].match(/^\s*/) ?? [''])[0];
436
+ mapEntryIndent = `${mapLineIndent} `;
437
+ }
438
+ lines.splice(mapClose, 0, `${mapEntryIndent}${clause};`);
439
+ return lines.join('\n');
440
+ }
441
+ /**
442
+ * Insert a complete BDEF clause line (a validation/determination/action built
443
+ * by buildClauseLine) into the chosen MANAGED entity body, immediately before
444
+ * the body's closing brace, at the body indentation. Managed-only policy is
445
+ * enforced HERE (the caller), not in the shared resolver (spec §9). See §4.1.
446
+ */
447
+ export function insertBdefClause(source, opts) {
448
+ const lines = source.split('\n');
449
+ const { bodyOpen, bodyClose, implKeyword } = resolveBehaviorEntityBody(lines, opts.alias);
450
+ if (implKeyword === 'unmanaged') {
451
+ throw new Error('insertBdefClause: unmanaged BDEF not supported (managed only)');
452
+ }
453
+ const indent = bodyIndent(lines, bodyOpen, bodyClose);
454
+ lines.splice(bodyClose, 0, `${indent}${opts.clauseLine}`);
455
+ return lines.join('\n');
456
+ }
457
+ /**
458
+ * Index of the line carrying the '}' that matches the FIRST '{' on line
459
+ * `openIdx`, counting '{'/'}' across all lines (raw count — BDEF rarely has
460
+ * braces inside string literals). Returns -1 if unbalanced.
461
+ */
462
+ function matchingClose(lines, openIdx) {
463
+ let depth = 0;
464
+ let seenOpen = false;
465
+ for (let i = openIdx; i < lines.length; i++) {
466
+ for (const ch of lines[i]) {
467
+ if (ch === '{') {
468
+ depth++;
469
+ seenOpen = true;
470
+ }
471
+ else if (ch === '}') {
472
+ depth--;
473
+ if (seenOpen && depth === 0)
474
+ return i;
475
+ }
476
+ }
477
+ }
478
+ return -1;
479
+ }
480
+ /**
481
+ * Indentation to use for a new line inside an entity body that has no `field (`
482
+ * anchor: derived from the first non-empty body line, else the body-open
483
+ * line's indent + 2 spaces.
484
+ */
485
+ function bodyIndent(lines, bodyOpen, bodyClose) {
486
+ for (let i = bodyOpen + 1; i < bodyClose; i++) {
487
+ if (lines[i].trim() !== '')
488
+ return (lines[i].match(/^\s*/) ?? [''])[0];
489
+ }
490
+ const openIndent = (lines[bodyOpen].match(/^\s*/) ?? [''])[0];
491
+ return `${openIndent} `;
492
+ }
493
+ function stubImpl(name, extraComment) {
494
+ const body = extraComment
495
+ ? ` " TODO: implement business logic — use /abap-eml\n " ${extraComment}`
496
+ : ' " TODO: implement business logic — use /abap-eml';
497
+ return `METHOD ${name}.\n${body}\nENDMETHOD.`;
498
+ }
499
+ /**
500
+ * Build the CCIMP handler method (declaration + empty implementation) for a
501
+ * stub. Signatures verified against ABAP keyword docs ABAPHANDLER_METH_MODIFY
502
+ * (action: REQUEST is optional; the input parameter is delivered via
503
+ * keys-%param). `<Entity>` is the BDEF alias. See spec §4.1.
504
+ */
505
+ export function buildHandlerMethod(kind, opts) {
506
+ const e = opts.entity;
507
+ if (kind === 'validation') {
508
+ return {
509
+ decl: `METHODS ${opts.name} FOR VALIDATE ON SAVE IMPORTING keys FOR ${e}~${opts.name}.`,
510
+ impl: stubImpl(opts.name),
511
+ };
512
+ }
513
+ if (kind === 'determination') {
514
+ const on = opts.trigger === 'save' ? 'SAVE' : 'MODIFY';
515
+ return {
516
+ decl: `METHODS ${opts.name} FOR DETERMINE ON ${on} IMPORTING keys FOR ${e}~${opts.name}.`,
517
+ impl: stubImpl(opts.name),
518
+ };
519
+ }
520
+ // action
521
+ const resultClause = opts.result ? ' RESULT result' : '';
522
+ const method = {
523
+ decl: `METHODS ${opts.name} FOR MODIFY IMPORTING keys FOR ACTION ${e}~${opts.name}${resultClause}.`,
524
+ impl: stubImpl(opts.name, opts.parameter ? 'input parameter is in keys-%param' : undefined),
525
+ };
526
+ if (opts.features) {
527
+ method.featuresDecl = `METHODS get_instance_features FOR INSTANCE FEATURES IMPORTING keys REQUEST requested_features FOR ${e} RESULT result.`;
528
+ method.featuresImpl = `METHOD get_instance_features.\n " TODO: populate result with the %action-${opts.name} enabled-state — use /abap-eml\nENDMETHOD.`;
529
+ }
530
+ return method;
531
+ }
532
+ /** True if the line is a full-line ABAP comment (`*` in col 1) — skipped when scanning. */
533
+ function isCommentLine(line) {
534
+ return /^\s*\*/.test(line) || line.trimStart().startsWith('"');
535
+ }
536
+ /**
537
+ * Locate a named local class's DEFINITION and IMPLEMENTATION spans by ABAP
538
+ * keyword matching (NOT brace counting — ABAP classes have no braces, so the
539
+ * B2a `matchingClose` primitive does not apply). Case-insensitive; skips
540
+ * full-line comments; tolerant of multiple classes in one CCIMP include.
541
+ * Returns null if either block is missing. See spec §4.1 (review C4).
542
+ */
543
+ export function classBlockSpan(lines, className) {
544
+ const name = className.toLowerCase();
545
+ const defRe = new RegExp(`^\\s*class\\s+${name}\\s+definition\\b`, 'i');
546
+ const implRe = new RegExp(`^\\s*class\\s+${name}\\s+implementation\\b`, 'i');
547
+ const endRe = /^\s*endclass\s*\./i;
548
+ const findBlock = (headerRe) => {
549
+ for (let i = 0; i < lines.length; i++) {
550
+ if (isCommentLine(lines[i]))
551
+ continue;
552
+ if (headerRe.test(lines[i])) {
553
+ for (let j = i + 1; j < lines.length; j++) {
554
+ if (isCommentLine(lines[j]))
555
+ continue;
556
+ if (endRe.test(lines[j]))
557
+ return { start: i, end: j };
558
+ }
559
+ return null; // header without ENDCLASS
560
+ }
561
+ }
562
+ return null;
563
+ };
564
+ const def = findBlock(defRe);
565
+ const impl = findBlock(implRe);
566
+ if (!def || !impl)
567
+ return null;
568
+ return { defStart: def.start, defEnd: def.end, implStart: impl.start, implEnd: impl.end };
569
+ }
570
+ /** The method name a `METHODS <name> …` declaration line declares (lowercased), or null. */
571
+ function declaredMethodName(declLine) {
572
+ const m = declLine.match(/^\s*methods\s+(\w+)/i);
573
+ return m ? m[1].toLowerCase() : null;
574
+ }
575
+ /** Apply a base indent to every non-empty line of a multi-line block. */
576
+ function indentBlock(block, indent) {
577
+ return block
578
+ .split('\n')
579
+ .map((l) => (l === '' ? '' : `${indent}${l}`))
580
+ .join('\n');
581
+ }
582
+ /**
583
+ * Insert a handler method (decl into PRIVATE/PROTECTED SECTION, impl before
584
+ * the IMPLEMENTATION ENDCLASS) into an EXISTING lhc_<entity> class, scoped via
585
+ * classBlockSpan. Idempotent (refuses a duplicate method name). For a
586
+ * feature-controlled action, also inserts the get_instance_features pair, but
587
+ * only if that method is not already present. See spec §4.1 / §4.4.
588
+ */
589
+ export function insertHandlerMethod(ccimp, opts) {
590
+ const className = `lhc_${opts.entity.toLowerCase()}`;
591
+ const lines = ccimp.split('\n');
592
+ const span = classBlockSpan(lines, className);
593
+ if (!span)
594
+ throw new Error(`insertHandlerMethod: no ${className} class found — create a skeleton instead`);
595
+ const { method } = opts;
596
+ // Existing method names within the definition span (idempotency).
597
+ const existingNames = new Set();
598
+ for (let i = span.defStart; i <= span.defEnd; i++) {
599
+ const n = declaredMethodName(lines[i]);
600
+ if (n)
601
+ existingNames.add(n);
602
+ }
603
+ const newName = declaredMethodName(method.decl);
604
+ if (newName && existingNames.has(newName)) {
605
+ throw new Error(`insertHandlerMethod: method '${newName}' already exists in ${className}`);
606
+ }
607
+ // Locate the visibility section header inside the definition span.
608
+ let sectionIdx = -1;
609
+ for (let i = span.defStart + 1; i < span.defEnd; i++) {
610
+ if (/^\s*(private|protected)\s+section\s*\./i.test(lines[i])) {
611
+ sectionIdx = i;
612
+ break;
613
+ }
614
+ }
615
+ if (sectionIdx === -1) {
616
+ throw new Error(`insertHandlerMethod: no PRIVATE/PROTECTED SECTION found in ${className}`);
617
+ }
618
+ // Indents: decl one level under the section header; impl one level under the class.
619
+ const sectionIndent = (lines[sectionIdx].match(/^\s*/) ?? [''])[0];
620
+ const declIndent = `${sectionIndent} `;
621
+ const implIndent = ((lines[span.implStart].match(/^\s*/) ?? [''])[0]) + ' ';
622
+ const wantFeatures = !!(method.featuresDecl && method.featuresImpl && !existingNames.has('get_instance_features'));
623
+ // Insert IMPL side first (higher line indices) so DEFINITION inserts don't shift implEnd.
624
+ const implInserts = [];
625
+ implInserts.push(indentBlock(method.impl, implIndent));
626
+ if (wantFeatures)
627
+ implInserts.push(indentBlock(method.featuresImpl, implIndent));
628
+ lines.splice(span.implEnd, 0, ...implInserts);
629
+ // Insert DEFINITION side (decl right after the section header).
630
+ const declInserts = [`${declIndent}${method.decl}`];
631
+ if (wantFeatures)
632
+ declInserts.push(`${declIndent}${method.featuresDecl}`);
633
+ lines.splice(sectionIdx + 1, 0, ...declInserts);
634
+ return lines.join('\n');
635
+ }
636
+ /**
637
+ * Create a new lhc_<entity> behavior handler class (DEFINITION + IMPLEMENTATION)
638
+ * appended to the CCIMP source, when none exists yet (a plain CRUD managed stack
639
+ * has no handler class until the first augmentation). Refuses if the class is
640
+ * already present. See spec §4.1 / §4.4 (review C3).
641
+ */
642
+ export function createHandlerClassSkeleton(ccimp, opts) {
643
+ const className = `lhc_${opts.entity.toLowerCase()}`;
644
+ if (classBlockSpan(ccimp.split('\n'), className)) {
645
+ throw new Error(`createHandlerClassSkeleton: ${className} already exists — use insertHandlerMethod`);
646
+ }
647
+ const { method } = opts;
648
+ const defLines = [
649
+ `CLASS ${className} DEFINITION INHERITING FROM cl_abap_behavior_handler.`,
650
+ ' PRIVATE SECTION.',
651
+ ` ${method.decl}`,
652
+ ];
653
+ if (method.featuresDecl)
654
+ defLines.push(` ${method.featuresDecl}`);
655
+ defLines.push('ENDCLASS.');
656
+ const implLines = [`CLASS ${className} IMPLEMENTATION.`, indentBlock(method.impl, ' ')];
657
+ if (method.featuresImpl)
658
+ implLines.push(indentBlock(method.featuresImpl, ' '));
659
+ implLines.push('ENDCLASS.');
660
+ const skeleton = [...defLines, '', ...implLines].join('\n');
661
+ const trimmed = ccimp.replace(/\s+$/, '');
662
+ return trimmed === '' ? skeleton : `${trimmed}\n\n${skeleton}`;
663
+ }
664
+ /**
665
+ * Insert a `use <clause>;` line (e.g. `use action Approve;`) into a PROJECTION
666
+ * behavior definition's entity body, before its closing brace. Projection BDEFs
667
+ * carry a top-level `projection;`; this refuses anything else (a base/managed
668
+ * BDEF uses `field`/`validation`/… not `use`). Idempotent: refuses if the same
669
+ * `use <clause>;` is already present. See spec §4.4.
670
+ */
671
+ export function insertProjectionUse(source, opts) {
672
+ if (!/^\s*projection\s*;/m.test(source)) {
673
+ throw new Error('insertProjectionUse: not a projection BDEF (no top-level `projection;`)');
674
+ }
675
+ const lines = source.split('\n');
676
+ const { bodyOpen, bodyClose } = resolveBehaviorEntityBody(lines, opts.alias);
677
+ const useLine = `use ${opts.useClause.replace(/^use\s+/i, '').replace(/;\s*$/, '')}`;
678
+ // Idempotency by NAME, not exact line (review I3): a grouped or
679
+ // feature-qualified `use ( … ) action Approve;` must still count as present.
680
+ // Match the trailing identifier of the clause (e.g. 'Approve' in 'action Approve').
681
+ const nameMatch = opts.useClause.match(/(\w+)\s*;?\s*$/);
682
+ const name = nameMatch ? nameMatch[1] : null;
683
+ if (name) {
684
+ // Case-SENSITIVE name match (SC-6): RAP action names are PascalCase (e.g. 'Delete'),
685
+ // while CRUD keywords are lowercase ('delete'). A case-insensitive flag would
686
+ // false-positive `use action Delete;` against an existing `use delete;`.
687
+ const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
688
+ const dupRe = new RegExp(`\\buse\\b.*\\b${escapedName}\\b`);
689
+ for (let i = bodyOpen + 1; i < bodyClose; i++) {
690
+ if (dupRe.test(lines[i])) {
691
+ throw new Error(`insertProjectionUse: a 'use … ${name}' is already present in the projection body`);
692
+ }
693
+ }
694
+ }
695
+ const indent = bodyIndent(lines, bodyOpen, bodyClose);
696
+ lines.splice(bodyClose, 0, `${indent}${useLine};`);
697
+ return lines.join('\n');
698
+ }
699
+ /**
700
+ * Insert an FE action-button annotation above an existing field in a DDLX
701
+ * metadata extension (or projection @UI). Form (doc-verified vs the SAP Fiori
702
+ * feature showcase): `@UI.lineItem: [{ type: #FOR_ACTION, dataAction: '<a>',
703
+ * label: '<l>', position: <p> }]` on a property — the button renders in the
704
+ * table toolbar. Idempotent: refuses if a lineItem with the same dataAction is
705
+ * already present. See spec §4.4.
706
+ */
707
+ export function insertMdeActionButton(source, opts) {
708
+ const label = opts.label ?? opts.action;
709
+ const position = typeof opts.position === 'number' ? opts.position : 10;
710
+ // Idempotency: refuse a second FOR_ACTION lineItem for the same action.
711
+ // Escape the action name (defensive — ABAP names are \w+, but never build a
712
+ // RegExp from an unescaped input).
713
+ const esc = opts.action.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
714
+ if (new RegExp(`dataAction:\\s*'${esc}'`).test(source)) {
715
+ throw new Error(`insertMdeActionButton: an action button for '${opts.action}' already exists`);
716
+ }
717
+ const lines = source.split('\n');
718
+ // Anchor: the field's own declaration line (`<field>;` or `<field>`), matched
719
+ // like promoteCdsFieldToLineItem's bare-name rule.
720
+ let defIdx = -1;
721
+ for (let i = 0; i < lines.length; i++) {
722
+ const t = lines[i].trim().replace(/;$/, '').trim();
723
+ if (t === opts.anchorField) {
724
+ defIdx = i;
725
+ break;
726
+ }
727
+ }
728
+ if (defIdx === -1) {
729
+ throw new Error(`insertMdeActionButton: anchor field '${opts.anchorField}' not found in the metadata extension`);
730
+ }
731
+ // Walk up past lines that start with '@' to find the top of the annotation block.
732
+ let blockStart = defIdx;
733
+ while (blockStart > 0 && lines[blockStart - 1].trim().startsWith('@'))
734
+ blockStart--;
735
+ const indent = (lines[defIdx].match(/^\s*/) ?? [''])[0];
736
+ // If the annotation block already contains a @UI.lineItem, MERGE the FOR_ACTION
737
+ // entry as the first element of that existing array (one @UI.lineItem per element).
738
+ // Otherwise, insert a standalone @UI.lineItem line above the block.
739
+ let existingLineItemIdx = -1;
740
+ for (let i = blockStart; i < defIdx; i++) {
741
+ if (/@UI\.lineItem\b/.test(lines[i])) {
742
+ existingLineItemIdx = i;
743
+ break;
744
+ }
745
+ }
746
+ if (existingLineItemIdx !== -1) {
747
+ // Find the first '[' on or after the @UI.lineItem line and insert the FOR_ACTION
748
+ // object immediately after it.
749
+ const lineItemLine = lines[existingLineItemIdx];
750
+ const bracketPos = lineItemLine.indexOf('[');
751
+ if (bracketPos === -1) {
752
+ // Malformed — fall through to standalone insert (defensive)
753
+ const ann = `${indent}@UI.lineItem: [{ type: #FOR_ACTION, dataAction: '${opts.action}', label: '${label}', position: ${position} }]`;
754
+ lines.splice(blockStart, 0, ann);
755
+ }
756
+ else {
757
+ const forActionEntry = `{ type: #FOR_ACTION, dataAction: '${opts.action}', label: '${label}', position: ${position} }, `;
758
+ lines[existingLineItemIdx] =
759
+ lineItemLine.slice(0, bracketPos + 1) + forActionEntry + lineItemLine.slice(bracketPos + 1);
760
+ }
761
+ }
762
+ else {
763
+ // Standalone: insert a new @UI.lineItem line above the annotation block.
764
+ const ann = `${indent}@UI.lineItem: [{ type: #FOR_ACTION, dataAction: '${opts.action}', label: '${label}', position: ${position} }]`;
765
+ lines.splice(blockStart, 0, ann);
766
+ }
767
+ return lines.join('\n');
768
+ }
769
+ /**
770
+ * Build a single BDEF clause line for a validation, determination, or action
771
+ * stub. Text only — no insertion. Fail-safe refusals: empty operations,
772
+ * static `$self` result (a static action has no instance), static + instance
773
+ * features. See spec §4.1.
774
+ */
775
+ export function buildClauseLine(kind, opts) {
776
+ if (kind === 'validation') {
777
+ const ops = (opts.ops ?? 'create; update;').trim();
778
+ if (ops === '')
779
+ throw new Error('buildClauseLine: empty operations set for validation');
780
+ return `validation ${opts.name} on save { ${ops} }`;
781
+ }
782
+ if (kind === 'determination') {
783
+ const trigger = opts.trigger ?? 'modify';
784
+ const ops = (opts.ops ?? 'create;').trim();
785
+ if (ops === '')
786
+ throw new Error('buildClauseLine: empty operations set for determination');
787
+ return `determination ${opts.name} on ${trigger} { ${ops} }`;
788
+ }
789
+ // action
790
+ if (opts.static && opts.features) {
791
+ throw new Error('buildClauseLine: a static action cannot have features: instance control (no instance exists)');
792
+ }
793
+ const parts = [];
794
+ if (opts.static)
795
+ parts.push('static');
796
+ parts.push('action');
797
+ if (opts.features)
798
+ parts.push('( features : instance )');
799
+ parts.push(opts.name);
800
+ if (opts.parameter)
801
+ parts.push(`parameter ${opts.parameter}`);
802
+ if (opts.result) {
803
+ const resultType = opts.resultType ?? '$self';
804
+ if (opts.static && resultType === '$self') {
805
+ throw new Error('buildClauseLine: a static action cannot return $self (no instance); give a typed resultType');
806
+ }
807
+ parts.push(`result ${opts.resultCard ?? '[1]'} ${resultType}`);
808
+ }
809
+ return `${parts.join(' ')};`;
810
+ }