@cspeach/cli 1.0.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.
- package/dist/agent/loop.js +22 -9
- package/dist/approvals/op-labels.js +124 -0
- package/dist/approvals/render.js +42 -36
- package/dist/cli.js +15 -0
- package/dist/commands/compact.js +28 -2
- package/dist/commands/config-set.js +189 -0
- package/dist/commands/config-show.js +20 -0
- package/dist/commands/export-audit.js +43 -0
- package/dist/commands/help.js +5 -0
- package/dist/commands/plan-audit-evidence.js +266 -0
- package/dist/commands/plan-audit.js +692 -0
- package/dist/commands/plan-chain.js +671 -0
- package/dist/commands/plan-continue.js +179 -0
- package/dist/commands/plan-gate.js +154 -0
- package/dist/commands/plan-resume.js +588 -33
- package/dist/config/loader.js +128 -4
- package/dist/config/model-defaults.js +14 -0
- package/dist/cost/pricing.js +27 -1
- package/dist/doctor/checks/system-roles.js +41 -0
- package/dist/doctor/run.js +2 -0
- package/dist/models/resolve.js +61 -0
- package/dist/models/server-config.js +155 -0
- package/dist/one-shot.js +25 -3
- package/dist/projects/extract-cca.js +3 -1
- package/dist/projects/extract-modernize.js +3 -1
- package/dist/projects/extract-plan.js +60 -6
- package/dist/projects/extract-test-coverage.js +3 -1
- package/dist/projects/extract-upgrade.js +3 -1
- package/dist/projects/handover-md.js +195 -0
- package/dist/projects/index.js +1 -1
- package/dist/projects/plan-run.js +137 -13
- package/dist/projects/plan-schema.js +73 -0
- package/dist/projects/run-lease.js +157 -0
- package/dist/projects/save-command.js +26 -15
- package/dist/renderer/status-footer.js +22 -12
- package/dist/renderer/thinking-heartbeat.js +64 -8
- package/dist/renderer/todo-block.js +51 -0
- package/dist/renderer/tool-widget.js +37 -0
- package/dist/repl/bracketed-paste.js +28 -19
- package/dist/repl/builtin-commands.js +5 -0
- package/dist/repl/current-transport.js +10 -0
- package/dist/repl/history.js +86 -0
- package/dist/repl/ink-stdin-guard.js +64 -0
- package/dist/repl/mode-ceiling.js +16 -0
- package/dist/repl/mode-cycle.js +104 -0
- package/dist/repl/post-turn-status.js +24 -4
- package/dist/repl/slash-completer.js +5 -0
- package/dist/repl.js +954 -83
- package/dist/rewind/candidates.js +194 -0
- package/dist/rewind/cli.js +137 -0
- package/dist/rewind/format.js +27 -0
- package/dist/rewind/restore.js +245 -0
- package/dist/session/audit-export.js +459 -0
- package/dist/session/context-report.js +163 -0
- package/dist/session/recap.js +160 -0
- package/dist/skill-catalog.js +9 -3
- package/dist/skills/bundled-skills.js +59 -66
- package/dist/tools/approval.js +115 -7
- package/dist/tools/ask-question.js +304 -3
- package/dist/tools/extend-model/anchored-insert.js +604 -0
- package/dist/tools/extend-model/tool.js +162 -10
- package/dist/tools/fiori/fe-extend.js +76 -0
- package/dist/tools/fiori/fe-scaffold.js +29 -3
- package/dist/tools/fiori/floorplan-map.js +19 -0
- package/dist/tools/fiori/samples/data/index.json +13602 -0
- package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
- package/dist/tools/fiori/samples/loader.js +248 -0
- package/dist/tools/fiori/samples/search.js +63 -0
- package/dist/tools/fiori/samples/types.js +2 -0
- package/dist/tools/fiori/smoke/assertions.js +74 -0
- package/dist/tools/fiori/smoke/browser.js +52 -0
- package/dist/tools/fiori/smoke/driver.js +89 -0
- package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
- package/dist/tools/fiori/smoke/run-smoke.js +149 -0
- package/dist/tools/fiori/tools.js +328 -3
- package/dist/tools/local-build.js +11 -1
- package/dist/tools/sap-read.js +79 -11
- package/dist/tools/sap-write.js +24 -4
- package/dist/tools/snapshot.js +27 -1
- package/dist/tools/subagent/agent_run.js +27 -3
- package/dist/tools/todo.js +144 -0
- package/dist/ui/app.js +372 -19
- package/dist/ui/approval-modal.js +49 -16
- package/dist/ui/ask-question-emitter.js +14 -0
- package/dist/ui/context-grid.js +108 -0
- package/dist/ui/footer.js +109 -30
- package/dist/ui/header.js +7 -0
- package/dist/ui/line-resolution.js +18 -2
- package/dist/ui/rewind-emitter.js +10 -0
- package/dist/ui/rewind-panel.js +81 -0
- package/dist/ui/sap-state-store.js +1 -0
- package/dist/ui/status-line.js +43 -0
- package/dist/ui/text-input.js +72 -8
- package/dist/ui/todo-emitter.js +25 -0
- package/dist/ui/todo-panel.js +64 -0
- package/dist/ui/turn-status-emitter.js +50 -4
- package/dist/ui/turn-status.js +18 -3
- package/dist/ui/widgets/ask-form.js +242 -0
- package/dist/ui/widgets/ask-question-modal.js +17 -7
- package/package.json +4 -1
|
@@ -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,
|