@cspeach/cli 0.9.0 → 1.1.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 (195) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/intent-system-prompt.js +1 -1
  3. package/dist/agent/loop.js +228 -26
  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/approvals/op-labels.js +124 -0
  10. package/dist/approvals/render.js +42 -36
  11. package/dist/auth/org-anthropic-key.js +25 -0
  12. package/dist/classifier/client.js +18 -3
  13. package/dist/cli.js +15 -0
  14. package/dist/commands/compact.js +28 -2
  15. package/dist/commands/config-set.js +284 -0
  16. package/dist/commands/config-show.js +20 -0
  17. package/dist/commands/export-audit.js +43 -0
  18. package/dist/commands/help.js +5 -0
  19. package/dist/commands/login.js +31 -14
  20. package/dist/commands/plan-audit-evidence.js +266 -0
  21. package/dist/commands/plan-audit.js +692 -0
  22. package/dist/commands/plan-chain.js +671 -0
  23. package/dist/commands/plan-continue.js +179 -0
  24. package/dist/commands/plan-gate.js +154 -0
  25. package/dist/commands/plan-model-tier.js +83 -0
  26. package/dist/commands/plan-resume.js +728 -46
  27. package/dist/config/loader.js +223 -5
  28. package/dist/config/model-defaults.js +14 -0
  29. package/dist/cost/pricing.js +27 -1
  30. package/dist/doctor/checks/_http-probe.js +1 -0
  31. package/dist/doctor/checks/cert.js +14 -3
  32. package/dist/doctor/checks/sap.js +30 -8
  33. package/dist/doctor/checks/system-roles.js +41 -0
  34. package/dist/doctor/checks/zcspeach.js +19 -4
  35. package/dist/doctor/run.js +2 -0
  36. package/dist/models/resolve.js +61 -0
  37. package/dist/models/server-config.js +155 -0
  38. package/dist/one-shot.js +76 -6
  39. package/dist/projects/answer-blockers.js +137 -0
  40. package/dist/projects/extract-cca.js +111 -17
  41. package/dist/projects/extract-modernize.js +4 -2
  42. package/dist/projects/extract-plan.js +184 -37
  43. package/dist/projects/extract-spec-gap.js +34 -7
  44. package/dist/projects/extract-test-coverage.js +4 -2
  45. package/dist/projects/extract-upgrade.js +116 -23
  46. package/dist/projects/handover-md.js +195 -0
  47. package/dist/projects/index.js +5 -2
  48. package/dist/projects/merge-cca.js +292 -0
  49. package/dist/projects/merge-upgrade.js +173 -0
  50. package/dist/projects/migration.js +103 -1
  51. package/dist/projects/output-paths.js +27 -0
  52. package/dist/projects/plan-run.js +285 -27
  53. package/dist/projects/plan-schema.js +136 -3
  54. package/dist/projects/promote-command.js +25 -2
  55. package/dist/projects/promote.js +128 -0
  56. package/dist/projects/run-lease.js +157 -0
  57. package/dist/projects/save-command.js +259 -21
  58. package/dist/projects/status.js +3 -1
  59. package/dist/projects/validate.js +1 -1
  60. package/dist/projects/workspace.js +164 -20
  61. package/dist/renderer/notices.js +64 -0
  62. package/dist/renderer/progress-chatter.js +8 -0
  63. package/dist/renderer/status-footer.js +22 -12
  64. package/dist/renderer/thinking-heartbeat.js +64 -8
  65. package/dist/renderer/todo-block.js +51 -0
  66. package/dist/renderer/tool-widget.js +55 -4
  67. package/dist/renderer/tty.js +43 -4
  68. package/dist/renderer/verify-chain.js +77 -0
  69. package/dist/repl/at-picker.js +60 -7
  70. package/dist/repl/bracketed-paste.js +28 -19
  71. package/dist/repl/builtin-commands.js +42 -0
  72. package/dist/repl/current-transport.js +10 -0
  73. package/dist/repl/early-line-buffer.js +68 -0
  74. package/dist/repl/history.js +86 -0
  75. package/dist/repl/ink-stdin-guard.js +64 -0
  76. package/dist/repl/inquirer-guard.js +70 -5
  77. package/dist/repl/mode-ceiling.js +16 -0
  78. package/dist/repl/mode-cycle.js +104 -0
  79. package/dist/repl/numbered-menu.js +131 -0
  80. package/dist/repl/post-turn-status.js +26 -6
  81. package/dist/repl/rule8-detector.js +17 -2
  82. package/dist/repl/safety-confirm.js +111 -2
  83. package/dist/repl/safety-mode-state.js +19 -3
  84. package/dist/repl/slash-completer.js +5 -0
  85. package/dist/repl/slash-picker.js +10 -15
  86. package/dist/repl.js +1232 -95
  87. package/dist/rewind/candidates.js +194 -0
  88. package/dist/rewind/cli.js +137 -0
  89. package/dist/rewind/format.js +27 -0
  90. package/dist/rewind/restore.js +245 -0
  91. package/dist/router/classifier.js +150 -6
  92. package/dist/sap/capability-matrix.js +20 -0
  93. package/dist/sap/capability-matrix.json +11236 -0
  94. package/dist/sap/capability.js +146 -0
  95. package/dist/sap/connection-manager.js +19 -1
  96. package/dist/sap/onboarding.js +42 -4
  97. package/dist/session/audit-export.js +459 -0
  98. package/dist/session/context-report.js +163 -0
  99. package/dist/session/pending.js +27 -0
  100. package/dist/session/recap.js +160 -0
  101. package/dist/skill-catalog.js +51 -40
  102. package/dist/skills/bundled-skills.js +272 -1
  103. package/dist/skills/promotion-dispatch.js +23 -0
  104. package/dist/tools/_command-shared.js +36 -12
  105. package/dist/tools/_filesystem-shared.js +139 -4
  106. package/dist/tools/_flag.js +25 -0
  107. package/dist/tools/approval.js +177 -26
  108. package/dist/tools/ask-question.js +400 -7
  109. package/dist/tools/capability/tool.js +74 -0
  110. package/dist/tools/dispatch-skill.js +22 -1
  111. package/dist/tools/extend-model/anchored-insert.js +1414 -0
  112. package/dist/tools/extend-model/tool.js +340 -0
  113. package/dist/tools/filesystem/extract-document.js +57 -0
  114. package/dist/tools/filesystem/file-edit.js +12 -2
  115. package/dist/tools/filesystem/file-read.js +2 -2
  116. package/dist/tools/filesystem/file-write.js +11 -2
  117. package/dist/tools/filesystem/glob.js +11 -0
  118. package/dist/tools/filesystem/grep.js +10 -0
  119. package/dist/tools/filesystem/read-document.js +107 -0
  120. package/dist/tools/fiori/apply.js +50 -0
  121. package/dist/tools/fiori/bin.js +3 -0
  122. package/dist/tools/fiori/catalog/index.js +27 -0
  123. package/dist/tools/fiori/catalog/value-help.js +230 -0
  124. package/dist/tools/fiori/catalog/viz-chart.js +177 -0
  125. package/dist/tools/fiori/cli.js +71 -0
  126. package/dist/tools/fiori/deploy-config.js +73 -0
  127. package/dist/tools/fiori/fe-extend.js +76 -0
  128. package/dist/tools/fiori/fe-scaffold.js +71 -0
  129. package/dist/tools/fiori/floorplan-map.js +19 -0
  130. package/dist/tools/fiori/i18n.js +39 -0
  131. package/dist/tools/fiori/manifest.js +70 -0
  132. package/dist/tools/fiori/render.js +77 -0
  133. package/dist/tools/fiori/samples/data/index.json +13602 -0
  134. package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
  135. package/dist/tools/fiori/samples/loader.js +248 -0
  136. package/dist/tools/fiori/samples/search.js +63 -0
  137. package/dist/tools/fiori/samples/types.js +2 -0
  138. package/dist/tools/fiori/scaffold.js +39 -0
  139. package/dist/tools/fiori/smoke/assertions.js +74 -0
  140. package/dist/tools/fiori/smoke/browser.js +52 -0
  141. package/dist/tools/fiori/smoke/driver.js +89 -0
  142. package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
  143. package/dist/tools/fiori/smoke/run-smoke.js +149 -0
  144. package/dist/tools/fiori/tools.js +681 -0
  145. package/dist/tools/fiori/types.js +1 -0
  146. package/dist/tools/local-build.js +86 -0
  147. package/dist/tools/local-files.js +31 -0
  148. package/dist/tools/project/_merge-shared.js +68 -0
  149. package/dist/tools/project/cca_merge.js +164 -0
  150. package/dist/tools/project/playbook_get.js +1 -1
  151. package/dist/tools/project/upgrade_merge_progress.js +206 -0
  152. package/dist/tools/sap-read.js +132 -20
  153. package/dist/tools/sap-write.js +550 -21
  154. package/dist/tools/shell/shell_exec.js +41 -6
  155. package/dist/tools/snapshot.js +63 -14
  156. package/dist/tools/subagent/agent_run.js +27 -3
  157. package/dist/tools/subagent/background_run.js +17 -1
  158. package/dist/tools/todo.js +144 -0
  159. package/dist/tools/transport-resolution.js +86 -0
  160. package/dist/tools/transport.js +224 -5
  161. package/dist/tools/write-mode.js +4 -0
  162. package/dist/ui/app.js +378 -21
  163. package/dist/ui/approval-modal.js +49 -16
  164. package/dist/ui/ask-question-emitter.js +14 -0
  165. package/dist/ui/body.js +13 -0
  166. package/dist/ui/context-grid.js +108 -0
  167. package/dist/ui/footer.js +120 -27
  168. package/dist/ui/header.js +7 -0
  169. package/dist/ui/line-resolution.js +35 -8
  170. package/dist/ui/rewind-emitter.js +10 -0
  171. package/dist/ui/rewind-panel.js +81 -0
  172. package/dist/ui/sap-state-store.js +1 -0
  173. package/dist/ui/session-timeline.js +1 -0
  174. package/dist/ui/status-line.js +43 -0
  175. package/dist/ui/text-input.js +214 -0
  176. package/dist/ui/todo-emitter.js +25 -0
  177. package/dist/ui/todo-panel.js +64 -0
  178. package/dist/ui/turn-status-emitter.js +50 -4
  179. package/dist/ui/turn-status.js +18 -3
  180. package/dist/ui/widgets/ask-form.js +242 -0
  181. package/dist/ui/widgets/ask-question-modal.js +21 -8
  182. package/package.json +22 -3
  183. package/bench/README.md +0 -78
  184. package/bench/prompts/abap-document-cds.md +0 -44
  185. package/bench/prompts/abap-explain-bdef-handler.md +0 -57
  186. package/bench/prompts/abap-test-method.md +0 -42
  187. package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
  188. package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
  189. package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
  190. package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
  191. package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
  192. package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
  193. package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
  194. package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
  195. package/bench/results/abap-test-method/claude-sonnet-4-6.md +0 -234
@@ -0,0 +1,1414 @@
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
+ * Insert a view-level `@UI.headerInfo: { … }` block into a DDLX metadata
771
+ * extension, ABOVE the `annotate view … with` line — header annotations sit on
772
+ * the view, not on a field. This drives the Fiori Elements Object Page header
773
+ * (object type name + title/description).
774
+ *
775
+ * Pure string splice — no CDS parser. The anchor is the textual `annotate view`
776
+ * line; the block is inserted at that line's indentation immediately above it.
777
+ *
778
+ * Fail-safe by design:
779
+ * - Idempotent: refuses (throws /already/) when a `@UI.headerInfo` already
780
+ * exists anywhere in the source — a metadata extension has exactly one.
781
+ * - Throws, naming the anchor, when no `annotate view` line is found.
782
+ *
783
+ * @param source full DDLX metadata-extension source
784
+ * @param opts.typeName the singular object type name (e.g. 'Maintenance Request')
785
+ * @param opts.typeNamePlural the plural object type name
786
+ * @param opts.titleField the field whose value renders as the header title
787
+ * @param opts.descriptionField optional field for the header description line
788
+ * @returns the modified source
789
+ */
790
+ export function insertDdlxHeaderInfo(source, opts) {
791
+ // Idempotency: a metadata extension carries exactly one headerInfo block.
792
+ if (/@UI\.headerInfo\b/.test(source)) {
793
+ throw new Error('insertDdlxHeaderInfo: a @UI.headerInfo block already exists (idempotent refusal)');
794
+ }
795
+ const lines = source.split('\n');
796
+ // Anchor: the `annotate view … with` line — header annotations sit above it.
797
+ let annotateIdx = -1;
798
+ for (let i = 0; i < lines.length; i++) {
799
+ if (/\bannotate\s+view\b/.test(lines[i])) {
800
+ annotateIdx = i;
801
+ break;
802
+ }
803
+ }
804
+ if (annotateIdx === -1) {
805
+ throw new Error('insertDdlxHeaderInfo: no "annotate view" line found (header annotations sit on the view)');
806
+ }
807
+ const indent = (lines[annotateIdx].match(/^\s*/) ?? [''])[0];
808
+ const block = [
809
+ `${indent}@UI.headerInfo: {`,
810
+ `${indent} typeName: '${opts.typeName}',`,
811
+ `${indent} typeNamePlural: '${opts.typeNamePlural}',`,
812
+ ];
813
+ if (opts.descriptionField) {
814
+ block.push(`${indent} title: { type: #STANDARD, value: '${opts.titleField}' },`);
815
+ block.push(`${indent} description: { type: #STANDARD, value: '${opts.descriptionField}' }`);
816
+ }
817
+ else {
818
+ block.push(`${indent} title: { type: #STANDARD, value: '${opts.titleField}' }`);
819
+ }
820
+ block.push(`${indent}}`);
821
+ lines.splice(annotateIdx, 0, ...block);
822
+ return lines.join('\n');
823
+ }
824
+ /**
825
+ * Add a `@UI.selectionField: [{ position: N }]` annotation to a DDLX metadata
826
+ * extension so the field appears as a filter-bar entry in a Fiori Elements List
827
+ * Report. Dual behaviour, mirroring the lineItem kinds:
828
+ * - In-place (like {@link insertMdeActionButton}'s anchor / the
829
+ * {@link promoteCdsFieldToLineItem} promotion): when the field is already
830
+ * exposed in the annotate body, insert the annotation directly above the
831
+ * field's declaration line at the same indentation.
832
+ * - Append (like {@link insertDdlxLineItem}): when the field is not yet present,
833
+ * append `@UI.selectionField …` + `<field>;` before the body's closing brace.
834
+ *
835
+ * Pure string splice — no CDS parser. Throws, naming the closing-brace anchor,
836
+ * when the field is absent AND no closing brace can be found.
837
+ *
838
+ * @param source full DDLX metadata-extension source
839
+ * @param opts.field the field name to expose as a filter
840
+ * @param opts.position the `@UI.selectionField` position (filter order)
841
+ * @returns the modified source
842
+ */
843
+ export function insertDdlxSelectionField(source, opts) {
844
+ const lines = source.split('\n');
845
+ // In-place: locate the field's own declaration line (`<field>;` or `<field>`).
846
+ let defIdx = -1;
847
+ for (let i = 0; i < lines.length; i++) {
848
+ const t = lines[i].trim().replace(/;$/, '').trim();
849
+ if (t === opts.field) {
850
+ defIdx = i;
851
+ break;
852
+ }
853
+ }
854
+ if (defIdx !== -1) {
855
+ const indent = (lines[defIdx].match(/^\s*/) ?? [''])[0];
856
+ lines.splice(defIdx, 0, `${indent}@UI.selectionField: [{ position: ${opts.position} }]`);
857
+ return lines.join('\n');
858
+ }
859
+ // Append: before the body's closing brace (mirror insertDdlxLineItem),
860
+ // routed through the shared appendAnchor resolver.
861
+ const { braceIdx, indent } = appendAnchor(lines, 'insertDdlxSelectionField');
862
+ lines.splice(braceIdx, 0, `${indent}@UI.selectionField: [{ position: ${opts.position} }]`, `${indent}${opts.field};`);
863
+ return lines.join('\n');
864
+ }
865
+ /**
866
+ * Add a `@UI.identification` annotation to a DDLX metadata extension. Two modes:
867
+ * - Plain (field given, no forAction): `@UI.identification: [{ position: N }]`
868
+ * inserted directly above the field's declaration line (Object Page field
869
+ * section) — same in-place anchor as {@link insertDdlxSelectionField}.
870
+ * - FOR_ACTION button (forAction set): `@UI.identification: [{ type: #FOR_ACTION,
871
+ * dataAction: '<a>', label: '<l>', position: N }]` — the Object-Page action
872
+ * button (lifts the out-of-scope note in SKILL.md). Anchored above `field`
873
+ * when given, else appended before the body's closing brace.
874
+ *
875
+ * Idempotency (mirrors the lineItem FOR_ACTION refuse logic): refuses (throws
876
+ * /duplicate/) when an existing `@UI.identification` already carries the same
877
+ * `dataAction`. Throws, naming the closing-brace anchor, when appending with no
878
+ * closing brace, or (plain mode) when the field cannot be located.
879
+ *
880
+ * @param source full DDLX metadata-extension source
881
+ * @param opts.field the field to annotate (required for plain mode; optional
882
+ * anchor for a FOR_ACTION button)
883
+ * @param opts.position the `@UI.identification` position
884
+ * @param opts.forAction FOR_ACTION button descriptor { dataAction, label }
885
+ * @returns the modified source
886
+ */
887
+ export function insertDdlxIdentification(source, opts) {
888
+ const lines = source.split('\n');
889
+ if (opts.forAction) {
890
+ const { dataAction, label } = opts.forAction;
891
+ // A #FOR_ACTION @UI.identification is ELEMENT-ATTACHED: a target field is
892
+ // REQUIRED. A lone annotation before the closing brace is invalid DDLX and
893
+ // fails activation — so, exactly like insertMdeActionButton's mandatory
894
+ // anchor field, the button piggybacks on an existing element line.
895
+ if (!opts.field) {
896
+ throw new Error('insertDdlxIdentification: forAction requires a target field to attach the button to');
897
+ }
898
+ // Idempotency: refuse a duplicate dataAction within ANY existing
899
+ // @UI.identification array — whole-source, bracket-balanced, so a duplicate
900
+ // inside a multi-line hand-authored array is caught, not just same-line.
901
+ if (identificationHasDataAction(source, dataAction)) {
902
+ throw new Error(`insertDdlxIdentification: an identification button for dataAction '${dataAction}' already exists (duplicate)`);
903
+ }
904
+ const defIdx = findFieldLine(lines, opts.field);
905
+ if (defIdx === -1) {
906
+ throw new Error(`insertDdlxIdentification: anchor field '${opts.field}' not found in the metadata extension`);
907
+ }
908
+ const indent = (lines[defIdx].match(/^\s*/) ?? [''])[0];
909
+ lines.splice(defIdx, 0, `${indent}@UI.identification: [{ type: #FOR_ACTION, dataAction: '${dataAction}', label: '${label}', position: ${opts.position} }]`);
910
+ return lines.join('\n');
911
+ }
912
+ // Plain identification is also element-attached — a field is required.
913
+ if (!opts.field) {
914
+ throw new Error('insertDdlxIdentification: field is required for a plain @UI.identification');
915
+ }
916
+ const defIdx = findFieldLine(lines, opts.field);
917
+ if (defIdx === -1) {
918
+ throw new Error(`insertDdlxIdentification: field '${opts.field}' not found in the metadata extension`);
919
+ }
920
+ const indent = (lines[defIdx].match(/^\s*/) ?? [''])[0];
921
+ lines.splice(defIdx, 0, `${indent}@UI.identification: [{ position: ${opts.position} }]`);
922
+ return lines.join('\n');
923
+ }
924
+ /**
925
+ * True if any `@UI.identification: [ … ]` array in the source already carries
926
+ * the given `dataAction`. Scans the whole source and balances the array's
927
+ * brackets, so a duplicate inside a MULTI-LINE hand-authored identification
928
+ * array is caught — not just a same-line occurrence.
929
+ */
930
+ function identificationHasDataAction(source, dataAction) {
931
+ const esc = dataAction.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
932
+ const dupRe = new RegExp(`dataAction:\\s*'${esc}'`);
933
+ const marker = '@UI.identification';
934
+ let idx = source.indexOf(marker);
935
+ while (idx !== -1) {
936
+ const bracket = source.indexOf('[', idx);
937
+ if (bracket === -1)
938
+ break;
939
+ // Balance '['/']' from the array's opening bracket to find its end.
940
+ let depth = 0;
941
+ let end = source.length - 1;
942
+ for (let i = bracket; i < source.length; i++) {
943
+ const ch = source[i];
944
+ if (ch === '[')
945
+ depth++;
946
+ else if (ch === ']') {
947
+ depth--;
948
+ if (depth === 0) {
949
+ end = i;
950
+ break;
951
+ }
952
+ }
953
+ }
954
+ if (dupRe.test(source.slice(bracket, end + 1)))
955
+ return true;
956
+ idx = source.indexOf(marker, end + 1);
957
+ }
958
+ return false;
959
+ }
960
+ /** Index of a field's own declaration line (`<field>;` or `<field>`), or -1. */
961
+ function findFieldLine(lines, field) {
962
+ for (let i = 0; i < lines.length; i++) {
963
+ const t = lines[i].trim().replace(/;$/, '').trim();
964
+ if (t === field)
965
+ return i;
966
+ }
967
+ return -1;
968
+ }
969
+ /**
970
+ * Resolve the "append before the closing brace" anchor for a DDLX annotate
971
+ * body: the last line that is just `}`, plus the indentation of the last entry
972
+ * (default 2 spaces). Throws, naming the anchor, when no closing brace exists.
973
+ */
974
+ function appendAnchor(lines, who) {
975
+ let braceIdx = -1;
976
+ for (let i = lines.length - 1; i >= 0; i--) {
977
+ if (lines[i].trim() === '}') {
978
+ braceIdx = i;
979
+ break;
980
+ }
981
+ }
982
+ if (braceIdx === -1) {
983
+ throw new Error(`${who}: no closing brace found for the annotate body`);
984
+ }
985
+ let lastIdx = -1;
986
+ for (let i = braceIdx - 1; i >= 0; i--) {
987
+ const trimmed = lines[i].trim();
988
+ if (trimmed !== '' && trimmed !== '{') {
989
+ lastIdx = i;
990
+ break;
991
+ }
992
+ }
993
+ const indent = lastIdx === -1 ? ' ' : baseIndentOfLastElement(lines, lastIdx) || ' ';
994
+ return { braceIdx, indent };
995
+ }
996
+ /**
997
+ * True if any array of the given view-level annotation (`marker`, e.g.
998
+ * `@UI.facet` / `@UI.chart`) satisfies `predicate` on its bracket-balanced
999
+ * array text. Scans the whole source and balances the array's `[`/`]`, so a
1000
+ * match inside a MULTI-LINE array is caught, not just a same-line one. Mirrors
1001
+ * the scanning shape of {@link identificationHasDataAction} but generalised
1002
+ * over the marker and the per-array test.
1003
+ */
1004
+ function annotationArrayMatches(source, marker, predicate) {
1005
+ let idx = source.indexOf(marker);
1006
+ while (idx !== -1) {
1007
+ const bracket = source.indexOf('[', idx);
1008
+ if (bracket === -1)
1009
+ break;
1010
+ let depth = 0;
1011
+ let end = source.length - 1;
1012
+ for (let i = bracket; i < source.length; i++) {
1013
+ const ch = source[i];
1014
+ if (ch === '[')
1015
+ depth++;
1016
+ else if (ch === ']') {
1017
+ depth--;
1018
+ if (depth === 0) {
1019
+ end = i;
1020
+ break;
1021
+ }
1022
+ }
1023
+ }
1024
+ if (predicate(source.slice(bracket, end + 1)))
1025
+ return true;
1026
+ idx = source.indexOf(marker, end + 1);
1027
+ }
1028
+ return false;
1029
+ }
1030
+ /** Index of the `annotate view … with` line, or -1. */
1031
+ function findAnnotateViewLine(lines) {
1032
+ for (let i = 0; i < lines.length; i++) {
1033
+ if (/\bannotate\s+view\b/.test(lines[i]))
1034
+ return i;
1035
+ }
1036
+ return -1;
1037
+ }
1038
+ /**
1039
+ * Add a `@UI.fieldGroup: [{ qualifier: '<q>', position: N }]` annotation to a
1040
+ * DDLX metadata extension for a single field — the same dual behaviour as
1041
+ * {@link insertDdlxSelectionField}: in-place above the field's declaration when
1042
+ * it is already exposed, else append the field + annotation before the body's
1043
+ * closing brace. Shared by {@link insertDdlxFacet}.
1044
+ */
1045
+ function insertDdlxFieldGroup(source, qualifier, field, position) {
1046
+ const lines = source.split('\n');
1047
+ const ann = `@UI.fieldGroup: [{ qualifier: '${qualifier}', position: ${position} }]`;
1048
+ const defIdx = findFieldLine(lines, field);
1049
+ if (defIdx !== -1) {
1050
+ const indent = (lines[defIdx].match(/^\s*/) ?? [''])[0];
1051
+ lines.splice(defIdx, 0, `${indent}${ann}`);
1052
+ return lines.join('\n');
1053
+ }
1054
+ const { braceIdx, indent } = appendAnchor(lines, 'insertDdlxFacet');
1055
+ lines.splice(braceIdx, 0, `${indent}${ann}`, `${indent}${field};`);
1056
+ return lines.join('\n');
1057
+ }
1058
+ /**
1059
+ * Add a Fiori Elements collection facet to a DDLX metadata extension, in two
1060
+ * coordinated parts:
1061
+ * 1. ONE view-level `@UI.facet` entry (a `#FIELDGROUP_REFERENCE`) above the
1062
+ * `annotate view` line. If a `@UI.facet: [ … ]` array already exists, the new
1063
+ * entry is MERGED into it (one array of facets); otherwise a fresh
1064
+ * `@UI.facet: [ … ]` line is inserted.
1065
+ * 2. A per-field `@UI.fieldGroup: [{ qualifier: '<fieldGroupQualifier>',
1066
+ * position: N }]` on EACH named field — in-place above the field when it is
1067
+ * already exposed, else appended (same dual behaviour as
1068
+ * {@link insertDdlxSelectionField}). The facet's `targetQualifier` binds to
1069
+ * the field group, so the Object Page renders the grouped fields under the
1070
+ * facet's label.
1071
+ *
1072
+ * Pure string splice — no CDS parser. Fail-safe by design:
1073
+ * - Idempotent as a unit on `facetId`: refuses (throws /already/) when a facet
1074
+ * with the same id is already present anywhere in the source, BEFORE any
1075
+ * field is touched — so a refused call is a true no-op.
1076
+ * - Throws, naming the anchor, when a fresh facet must be inserted but no
1077
+ * `annotate view` line exists.
1078
+ */
1079
+ export function insertDdlxFacet(source, opts) {
1080
+ // Idempotency (unit): refuse a duplicate facetId across any existing @UI.facet
1081
+ // array before touching a single field.
1082
+ const escId = opts.facetId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1083
+ const idRe = new RegExp(`id:\\s*'${escId}'`);
1084
+ if (annotationArrayMatches(source, '@UI.facet', (txt) => idRe.test(txt))) {
1085
+ throw new Error(`insertDdlxFacet: a facet with id '${opts.facetId}' already exists (idempotent refusal)`);
1086
+ }
1087
+ const entry = `{ id: '${opts.facetId}', purpose: #STANDARD, type: #FIELDGROUP_REFERENCE, `
1088
+ + `label: '${opts.label}', targetQualifier: '${opts.fieldGroupQualifier}', position: ${opts.position} }`;
1089
+ // 1. View-level facet: merge into an existing @UI.facet array, else insert
1090
+ // a fresh line above `annotate view`.
1091
+ let lines = source.split('\n');
1092
+ let facetIdx = -1;
1093
+ for (let i = 0; i < lines.length; i++) {
1094
+ if (/@UI\.facet\b/.test(lines[i])) {
1095
+ facetIdx = i;
1096
+ break;
1097
+ }
1098
+ }
1099
+ let merged = false;
1100
+ if (facetIdx !== -1) {
1101
+ const bracketPos = lines[facetIdx].indexOf('[');
1102
+ if (bracketPos !== -1) {
1103
+ lines[facetIdx] =
1104
+ lines[facetIdx].slice(0, bracketPos + 1) + entry + ', ' + lines[facetIdx].slice(bracketPos + 1);
1105
+ merged = true;
1106
+ }
1107
+ // Malformed (no '[' on the @UI.facet line) → fall through to a fresh insert.
1108
+ }
1109
+ if (!merged) {
1110
+ const annotateIdx = findAnnotateViewLine(lines);
1111
+ if (annotateIdx === -1) {
1112
+ throw new Error('insertDdlxFacet: no "annotate view" line found (facet annotations sit on the view)');
1113
+ }
1114
+ const indent = (lines[annotateIdx].match(/^\s*/) ?? [''])[0];
1115
+ lines.splice(annotateIdx, 0, `${indent}@UI.facet: [${entry}]`);
1116
+ }
1117
+ // 2. Per-field field groups (dual: in-place or append), re-splicing per field.
1118
+ let out = lines.join('\n');
1119
+ for (const f of opts.fields) {
1120
+ out = insertDdlxFieldGroup(out, opts.fieldGroupQualifier, f.field, f.position);
1121
+ }
1122
+ return out;
1123
+ }
1124
+ /**
1125
+ * Insert a `@UI.dataPoint: { title: '<title>' }` annotation above an EXISTING
1126
+ * exposed field in a DDLX metadata extension (append `, criticality:
1127
+ * '<criticalityField>'` when a criticality field is given). The data point is
1128
+ * element-attached — the target field is required and must already be exposed;
1129
+ * this transform does NOT create the criticality calculated field (that pairing
1130
+ * is the skill's job via a prior `cds-field` insert).
1131
+ *
1132
+ * Pure string splice — no CDS parser. Throws, naming the field, when the target
1133
+ * field is absent from the metadata extension.
1134
+ *
1135
+ * @param source full DDLX metadata-extension source
1136
+ * @param opts.field the field to annotate (required, must be present)
1137
+ * @param opts.title the data point title text
1138
+ * @param opts.criticalityField optional field name driving the criticality colour
1139
+ * @returns the modified source
1140
+ */
1141
+ export function insertDdlxDataPoint(source, opts) {
1142
+ const lines = source.split('\n');
1143
+ const defIdx = findFieldLine(lines, opts.field);
1144
+ if (defIdx === -1) {
1145
+ throw new Error(`insertDdlxDataPoint: field '${opts.field}' not found in the metadata extension`);
1146
+ }
1147
+ const crit = opts.criticalityField ? `, criticality: '${opts.criticalityField}'` : '';
1148
+ const indent = (lines[defIdx].match(/^\s*/) ?? [''])[0];
1149
+ lines.splice(defIdx, 0, `${indent}@UI.dataPoint: { title: '${opts.title}'${crit} }`);
1150
+ return lines.join('\n');
1151
+ }
1152
+ const CHART_TYPES = ['COLUMN', 'BAR', 'LINE', 'DONUT'];
1153
+ /**
1154
+ * Insert a view-level `@UI.chart` AND its paired `@UI.presentationVariant`
1155
+ * (`visualizations: [{ type: #AS_CHART … }]`) above the `annotate view` line of
1156
+ * a DDLX metadata extension. ALP/OVP bind a chart through the presentation
1157
+ * variant, never the raw chart — so BOTH are always emitted together, as a unit.
1158
+ *
1159
+ * Pure string splice — no CDS parser. Fail-safe by design:
1160
+ * - `chartType` must be one of COLUMN|BAR|LINE|DONUT and maps to the CDS enum
1161
+ * literal `#<TYPE>`; anything else throws.
1162
+ * - Idempotent as a unit on the qualifier: a named chart refuses (throws
1163
+ * /already/) when a `@UI.chart` with the same qualifier is present; an
1164
+ * anonymous chart (no qualifier) refuses when any anonymous `@UI.chart`
1165
+ * already exists.
1166
+ * - Throws, naming the anchor, when no `annotate view` line exists.
1167
+ *
1168
+ * @param source full DDLX metadata-extension source
1169
+ * @param opts.qualifier optional chart/presentation-variant qualifier (binds the pair)
1170
+ * @param opts.chartType COLUMN|BAR|LINE|DONUT → `#COLUMN`/`#BAR`/`#LINE`/`#DONUT`
1171
+ * @param opts.dimensions dimension field names
1172
+ * @param opts.measures measure field names
1173
+ * @returns the modified source
1174
+ */
1175
+ export function insertDdlxChart(source, opts) {
1176
+ if (!CHART_TYPES.includes(opts.chartType)) {
1177
+ throw new Error(`insertDdlxChart: unknown chartType '${opts.chartType}' — expected COLUMN|BAR|LINE|DONUT`);
1178
+ }
1179
+ // Idempotency (unit): keyed on qualifier. A named chart matches on its
1180
+ // qualifier; an anonymous chart matches any existing @UI.chart without one.
1181
+ if (opts.qualifier) {
1182
+ const escQ = opts.qualifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1183
+ const qRe = new RegExp(`qualifier:\\s*'${escQ}'`);
1184
+ if (annotationArrayMatches(source, '@UI.chart', (txt) => qRe.test(txt))) {
1185
+ throw new Error(`insertDdlxChart: a chart with qualifier '${opts.qualifier}' already exists (idempotent refusal)`);
1186
+ }
1187
+ }
1188
+ else if (annotationArrayMatches(source, '@UI.chart', (txt) => !/qualifier:/.test(txt))) {
1189
+ throw new Error('insertDdlxChart: an anonymous @UI.chart already exists (idempotent refusal)');
1190
+ }
1191
+ const lines = source.split('\n');
1192
+ const annotateIdx = findAnnotateViewLine(lines);
1193
+ if (annotateIdx === -1) {
1194
+ throw new Error('insertDdlxChart: no "annotate view" line found (chart annotations sit on the view)');
1195
+ }
1196
+ const indent = (lines[annotateIdx].match(/^\s*/) ?? [''])[0];
1197
+ const dims = opts.dimensions.map((d) => `'${d}'`).join(', ');
1198
+ const meas = opts.measures.map((m) => `'${m}'`).join(', ');
1199
+ const chartQ = opts.qualifier ? `qualifier: '${opts.qualifier}', ` : '';
1200
+ const chartLine = `${indent}@UI.chart: [{ ${chartQ}chartType: #${opts.chartType}, `
1201
+ + `dimensions: [${dims}], measures: [${meas}] }]`;
1202
+ const pvQ = opts.qualifier ? `, qualifier: '${opts.qualifier}'` : '';
1203
+ const pvLine = `${indent}@UI.presentationVariant: [{ visualizations: [{ type: #AS_CHART${pvQ} }] }]`;
1204
+ lines.splice(annotateIdx, 0, chartLine, pvLine);
1205
+ return lines.join('\n');
1206
+ }
1207
+ /**
1208
+ * Insert a `@Consumption.valueHelpDefinition` annotation ABOVE an EXISTING
1209
+ * exposed element in a CDS view (DDLS) body — the same anchor rules as
1210
+ * {@link promoteCdsFieldToLineItem}. This targets the VIEW source (a field
1211
+ * annotation in a `define view`/DDLS body), NOT a metadata extension: a value
1212
+ * help binding is a data-model annotation, so it lives on the view element.
1213
+ *
1214
+ * The anchor is the element's own declaration line, located by the declared
1215
+ * element name (the alias after `as`, or the bare/`key`-prefixed name). The
1216
+ * annotation is inserted immediately above that line at the same indentation,
1217
+ * so it joins any existing contiguous annotation block.
1218
+ *
1219
+ * Fail-safe by design (mirrors promoteCdsFieldToLineItem):
1220
+ * - Refuses (throws) when the field cannot be uniquely identified (not found,
1221
+ * or ambiguous across multiple declarations).
1222
+ * - Refuses a multi-line / computed element (e.g. `case … end as Name`) whose
1223
+ * declaration line continues a larger expression — inserting above it would
1224
+ * split the expression.
1225
+ * - Idempotent: refuses when the field already carries a
1226
+ * `@Consumption.valueHelpDefinition` in the contiguous annotation block
1227
+ * directly above it.
1228
+ *
1229
+ * @param source full CDS view source
1230
+ * @param opts.field the EXISTING exposed element name to bind, e.g. `CustomerID`
1231
+ * @param opts.entity the value help provider CDS entity, e.g. `ZI_Customer`
1232
+ * @param opts.element the provider element to bind to, e.g. `CustomerID`
1233
+ * @param opts.additionalBinding optional extra binding { localElement, element }
1234
+ * @returns the modified source
1235
+ */
1236
+ export function insertCdsValueHelp(source, opts) {
1237
+ const lines = source.split('\n');
1238
+ // The element name a line declares, or null (same rule as promoteCdsFieldToLineItem).
1239
+ const declaredName = (line) => {
1240
+ const s = line.replace(/\s+$/, '').replace(/,$/, '').replace(/\s+$/, '');
1241
+ const aliased = s.match(/\bas\s+(\w+)$/);
1242
+ if (aliased)
1243
+ return aliased[1];
1244
+ const bare = s.trim().match(/^(?:key\s+)?(\w+)$/);
1245
+ if (bare)
1246
+ return bare[1];
1247
+ return null;
1248
+ };
1249
+ // 1. Locate the unique element declaring `field`.
1250
+ const defIdxs = [];
1251
+ for (let i = 0; i < lines.length; i++) {
1252
+ if (declaredName(lines[i]) === opts.field)
1253
+ defIdxs.push(i);
1254
+ }
1255
+ if (defIdxs.length === 0) {
1256
+ throw new Error(`insertCdsValueHelp: field '${opts.field}' not found in the view element list`);
1257
+ }
1258
+ if (defIdxs.length > 1) {
1259
+ throw new Error(`insertCdsValueHelp: field '${opts.field}' is ambiguous (${defIdxs.length} matches)`);
1260
+ }
1261
+ const defIdx = defIdxs[0];
1262
+ // 2. Refuse a continuation line of a multi-line element (would split the expression).
1263
+ const prev = defIdx > 0 ? lines[defIdx - 1].trim() : '';
1264
+ const isFreshStart = prev === '' || prev === '{' || prev.startsWith('@') || prev.endsWith(',');
1265
+ if (!isFreshStart) {
1266
+ throw new Error(`insertCdsValueHelp: '${opts.field}' resolves to a multi-line/computed element; bind it manually or via /abap-refactor`);
1267
+ }
1268
+ // 3. Idempotency: refuse if `field` already has a valueHelpDefinition in the
1269
+ // contiguous single-line annotation block directly above defIdx.
1270
+ for (let i = defIdx - 1; i >= 0 && lines[i].trim().startsWith('@'); i--) {
1271
+ if (/@Consumption\.valueHelpDefinition\b/.test(lines[i])) {
1272
+ throw new Error(`insertCdsValueHelp: '${opts.field}' already has a @Consumption.valueHelpDefinition`);
1273
+ }
1274
+ }
1275
+ // 4. Build the annotation and insert it above defIdx at defIdx's indent.
1276
+ const binding = opts.additionalBinding
1277
+ ? `, additionalBinding: [{ localElement: '${opts.additionalBinding.localElement}', element: '${opts.additionalBinding.element}' }]`
1278
+ : '';
1279
+ const annotation = `@Consumption.valueHelpDefinition: [{ entity: { name: '${opts.entity}', element: '${opts.element}' }${binding} }]`;
1280
+ const indent = (lines[defIdx].match(/^\s*/) ?? [''])[0];
1281
+ lines.splice(defIdx, 0, `${indent}${annotation}`);
1282
+ return lines.join('\n');
1283
+ }
1284
+ /**
1285
+ * Insert a BDEF `side effects { … }` block into a MANAGED behavior entity body,
1286
+ * immediately before the body's closing brace, at the body indentation — the
1287
+ * SAME managed-BDEF `matchingClose` anchor machinery {@link insertBdefClause}
1288
+ * and {@link insertBdefField} use. Managed-only policy is enforced HERE (the
1289
+ * caller), not in the shared resolver (spec §9).
1290
+ *
1291
+ * NOTE: this is the RAP BDEF `side effects` clause (valid on ABAP Platform
1292
+ * 2022+/S4H 2023), NOT a CDS `@Sideeffects` annotation (which is invalid on
1293
+ * modern RAP). Emits:
1294
+ * side effects
1295
+ * {
1296
+ * field <sourceFields ', '> affects field <targetFields ', '>;
1297
+ * }
1298
+ *
1299
+ * Fail-safe by design:
1300
+ * - Refuses (throws) an UNMANAGED implementation — mirrors bdef-field/bdef-stub.
1301
+ * - When several behaviors exist and no alias is given, throws (via the shared
1302
+ * resolver) rather than guessing; throws too if a requested alias is absent.
1303
+ * - Refuses empty sourceFields or targetFields (an empty clause is invalid).
1304
+ *
1305
+ * @param source full managed BDEF source
1306
+ * @param opts.entityAlias the entity alias to extend; optional only when exactly
1307
+ * one behavior is defined
1308
+ * @param opts.sourceFields the trigger fields (`field <…>`) — non-empty
1309
+ * @param opts.targetFields the affected fields (`affects field <…>`) — non-empty
1310
+ * @returns the modified source
1311
+ */
1312
+ export function insertBdefSideEffects(source, opts) {
1313
+ if (!Array.isArray(opts.sourceFields) || opts.sourceFields.length === 0) {
1314
+ throw new Error('insertBdefSideEffects: at least one source field is required');
1315
+ }
1316
+ if (!Array.isArray(opts.targetFields) || opts.targetFields.length === 0) {
1317
+ throw new Error('insertBdefSideEffects: at least one target field is required');
1318
+ }
1319
+ const lines = source.split('\n');
1320
+ const { bodyOpen, bodyClose, implKeyword } = resolveBehaviorEntityBody(lines, opts.entityAlias);
1321
+ if (implKeyword === 'unmanaged') {
1322
+ throw new Error('insertBdefSideEffects: unmanaged BDEF not supported (managed only)');
1323
+ }
1324
+ const clause = `field ${opts.sourceFields.join(', ')} affects field ${opts.targetFields.join(', ')};`;
1325
+ // RAP permits exactly ONE `side effects { … }` block per entity. If the entity
1326
+ // already has one inside its body, MERGE the new clause into it (never emit a
1327
+ // second block — two blocks fail activation). Locate a `side effects` line
1328
+ // within the resolved body, then its block braces via matchingClose.
1329
+ let seLineIdx = -1;
1330
+ for (let i = bodyOpen + 1; i < bodyClose; i++) {
1331
+ if (/^\s*side\s+effects\b/.test(lines[i])) {
1332
+ seLineIdx = i;
1333
+ break;
1334
+ }
1335
+ }
1336
+ if (seLineIdx !== -1) {
1337
+ let seOpen = -1;
1338
+ for (let i = seLineIdx; i < bodyClose; i++) {
1339
+ if (lines[i].includes('{')) {
1340
+ seOpen = i;
1341
+ break;
1342
+ }
1343
+ }
1344
+ const seClose = seOpen === -1 ? -1 : matchingClose(lines, seOpen);
1345
+ if (seOpen === -1 || seClose === -1) {
1346
+ throw new Error('insertBdefSideEffects: found a "side effects" clause with no matching block braces');
1347
+ }
1348
+ // Idempotency: refuse an identical `field … affects field …;` already declared.
1349
+ for (let i = seOpen + 1; i < seClose; i++) {
1350
+ if (lines[i].trim() === clause) {
1351
+ throw new Error(`insertBdefSideEffects: side effect '${clause.replace(/;$/, '')}' already declared`);
1352
+ }
1353
+ }
1354
+ // Inner indent from an existing block entry; else block-open indent + 2 spaces.
1355
+ let entryIndent = null;
1356
+ for (let i = seOpen + 1; i < seClose; i++) {
1357
+ if (lines[i].trim() !== '') {
1358
+ entryIndent = (lines[i].match(/^\s*/) ?? [''])[0];
1359
+ break;
1360
+ }
1361
+ }
1362
+ if (entryIndent === null) {
1363
+ entryIndent = ((lines[seOpen].match(/^\s*/) ?? [''])[0]) + ' ';
1364
+ }
1365
+ lines.splice(seClose, 0, `${entryIndent}${clause}`);
1366
+ return lines.join('\n');
1367
+ }
1368
+ // No existing block: emit the full `side effects { … }` block at the body close.
1369
+ const indent = bodyIndent(lines, bodyOpen, bodyClose);
1370
+ lines.splice(bodyClose, 0, `${indent}side effects`, `${indent}{`, `${indent} ${clause}`, `${indent}}`);
1371
+ return lines.join('\n');
1372
+ }
1373
+ /**
1374
+ * Build a single BDEF clause line for a validation, determination, or action
1375
+ * stub. Text only — no insertion. Fail-safe refusals: empty operations,
1376
+ * static `$self` result (a static action has no instance), static + instance
1377
+ * features. See spec §4.1.
1378
+ */
1379
+ export function buildClauseLine(kind, opts) {
1380
+ if (kind === 'validation') {
1381
+ const ops = (opts.ops ?? 'create; update;').trim();
1382
+ if (ops === '')
1383
+ throw new Error('buildClauseLine: empty operations set for validation');
1384
+ return `validation ${opts.name} on save { ${ops} }`;
1385
+ }
1386
+ if (kind === 'determination') {
1387
+ const trigger = opts.trigger ?? 'modify';
1388
+ const ops = (opts.ops ?? 'create;').trim();
1389
+ if (ops === '')
1390
+ throw new Error('buildClauseLine: empty operations set for determination');
1391
+ return `determination ${opts.name} on ${trigger} { ${ops} }`;
1392
+ }
1393
+ // action
1394
+ if (opts.static && opts.features) {
1395
+ throw new Error('buildClauseLine: a static action cannot have features: instance control (no instance exists)');
1396
+ }
1397
+ const parts = [];
1398
+ if (opts.static)
1399
+ parts.push('static');
1400
+ parts.push('action');
1401
+ if (opts.features)
1402
+ parts.push('( features : instance )');
1403
+ parts.push(opts.name);
1404
+ if (opts.parameter)
1405
+ parts.push(`parameter ${opts.parameter}`);
1406
+ if (opts.result) {
1407
+ const resultType = opts.resultType ?? '$self';
1408
+ if (opts.static && resultType === '$self') {
1409
+ throw new Error('buildClauseLine: a static action cannot return $self (no instance); give a typed resultType');
1410
+ }
1411
+ parts.push(`result ${opts.resultCard ?? '[1]'} ${resultType}`);
1412
+ }
1413
+ return `${parts.join(' ')};`;
1414
+ }