@cspeach/cli 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/dist/agent/loop.js +22 -9
  2. package/dist/approvals/op-labels.js +124 -0
  3. package/dist/approvals/render.js +42 -36
  4. package/dist/cli.js +15 -0
  5. package/dist/commands/compact.js +28 -2
  6. package/dist/commands/config-set.js +189 -0
  7. package/dist/commands/config-show.js +20 -0
  8. package/dist/commands/export-audit.js +43 -0
  9. package/dist/commands/help.js +5 -0
  10. package/dist/commands/plan-audit-evidence.js +266 -0
  11. package/dist/commands/plan-audit.js +692 -0
  12. package/dist/commands/plan-chain.js +671 -0
  13. package/dist/commands/plan-continue.js +179 -0
  14. package/dist/commands/plan-gate.js +154 -0
  15. package/dist/commands/plan-resume.js +588 -33
  16. package/dist/config/loader.js +128 -4
  17. package/dist/config/model-defaults.js +14 -0
  18. package/dist/cost/pricing.js +27 -1
  19. package/dist/doctor/checks/system-roles.js +41 -0
  20. package/dist/doctor/run.js +2 -0
  21. package/dist/models/resolve.js +61 -0
  22. package/dist/models/server-config.js +155 -0
  23. package/dist/one-shot.js +25 -3
  24. package/dist/projects/extract-cca.js +3 -1
  25. package/dist/projects/extract-modernize.js +3 -1
  26. package/dist/projects/extract-plan.js +60 -6
  27. package/dist/projects/extract-test-coverage.js +3 -1
  28. package/dist/projects/extract-upgrade.js +3 -1
  29. package/dist/projects/handover-md.js +195 -0
  30. package/dist/projects/index.js +1 -1
  31. package/dist/projects/plan-run.js +137 -13
  32. package/dist/projects/plan-schema.js +73 -0
  33. package/dist/projects/run-lease.js +157 -0
  34. package/dist/projects/save-command.js +26 -15
  35. package/dist/renderer/status-footer.js +22 -12
  36. package/dist/renderer/thinking-heartbeat.js +64 -8
  37. package/dist/renderer/todo-block.js +51 -0
  38. package/dist/renderer/tool-widget.js +37 -0
  39. package/dist/repl/bracketed-paste.js +28 -19
  40. package/dist/repl/builtin-commands.js +5 -0
  41. package/dist/repl/current-transport.js +10 -0
  42. package/dist/repl/history.js +86 -0
  43. package/dist/repl/ink-stdin-guard.js +64 -0
  44. package/dist/repl/mode-ceiling.js +16 -0
  45. package/dist/repl/mode-cycle.js +104 -0
  46. package/dist/repl/post-turn-status.js +24 -4
  47. package/dist/repl/slash-completer.js +5 -0
  48. package/dist/repl.js +954 -83
  49. package/dist/rewind/candidates.js +194 -0
  50. package/dist/rewind/cli.js +137 -0
  51. package/dist/rewind/format.js +27 -0
  52. package/dist/rewind/restore.js +245 -0
  53. package/dist/session/audit-export.js +459 -0
  54. package/dist/session/context-report.js +163 -0
  55. package/dist/session/recap.js +160 -0
  56. package/dist/skill-catalog.js +9 -3
  57. package/dist/skills/bundled-skills.js +71 -78
  58. package/dist/tools/approval.js +115 -7
  59. package/dist/tools/ask-question.js +304 -3
  60. package/dist/tools/extend-model/anchored-insert.js +604 -0
  61. package/dist/tools/extend-model/tool.js +162 -10
  62. package/dist/tools/fiori/fe-extend.js +76 -0
  63. package/dist/tools/fiori/fe-scaffold.js +29 -3
  64. package/dist/tools/fiori/floorplan-map.js +19 -0
  65. package/dist/tools/fiori/samples/data/index.json +13602 -0
  66. package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
  67. package/dist/tools/fiori/samples/loader.js +248 -0
  68. package/dist/tools/fiori/samples/search.js +63 -0
  69. package/dist/tools/fiori/samples/types.js +2 -0
  70. package/dist/tools/fiori/smoke/assertions.js +74 -0
  71. package/dist/tools/fiori/smoke/browser.js +52 -0
  72. package/dist/tools/fiori/smoke/driver.js +89 -0
  73. package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
  74. package/dist/tools/fiori/smoke/run-smoke.js +149 -0
  75. package/dist/tools/fiori/tools.js +328 -3
  76. package/dist/tools/local-build.js +11 -1
  77. package/dist/tools/sap-read.js +79 -11
  78. package/dist/tools/sap-write.js +24 -4
  79. package/dist/tools/snapshot.js +27 -1
  80. package/dist/tools/subagent/agent_run.js +27 -3
  81. package/dist/tools/todo.js +144 -0
  82. package/dist/ui/app.js +372 -19
  83. package/dist/ui/approval-modal.js +49 -16
  84. package/dist/ui/ask-question-emitter.js +14 -0
  85. package/dist/ui/context-grid.js +108 -0
  86. package/dist/ui/footer.js +109 -30
  87. package/dist/ui/header.js +7 -0
  88. package/dist/ui/line-resolution.js +18 -2
  89. package/dist/ui/rewind-emitter.js +10 -0
  90. package/dist/ui/rewind-panel.js +81 -0
  91. package/dist/ui/sap-state-store.js +1 -0
  92. package/dist/ui/status-line.js +43 -0
  93. package/dist/ui/text-input.js +72 -8
  94. package/dist/ui/todo-emitter.js +25 -0
  95. package/dist/ui/todo-panel.js +64 -0
  96. package/dist/ui/turn-status-emitter.js +50 -4
  97. package/dist/ui/turn-status.js +18 -3
  98. package/dist/ui/widgets/ask-form.js +242 -0
  99. package/dist/ui/widgets/ask-question-modal.js +17 -7
  100. package/package.json +4 -1
@@ -766,6 +766,610 @@ export function insertMdeActionButton(source, opts) {
766
766
  }
767
767
  return lines.join('\n');
768
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
+ }
769
1373
  /**
770
1374
  * Build a single BDEF clause line for a validation, determination, or action
771
1375
  * stub. Text only — no insertion. Fail-safe refusals: empty operations,